PageSourceSearch

https://tinycrafts.ai/markv/

html tinycrafts.ai collected 2026-09-24 14:29:55 UTC 1,059,371 bytes, 31,041 lines download raw bytes

1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="utf-8" />
5    <meta name="viewport" content="width=device-width,initial-scale=1" />
6    <meta name="referrer" content="no-referrer" />
7    <meta
8      http-equiv="Content-Security-Policy"
9      content="default-src 'self'; script-src 'self' https://gc.zgo.at https://cdnjs.cloudflare.com https://cdn.jsdelivr.net 'sha256-gMK4pykGz/gVoh9PKGKrH+Bj1eMsDBrwjVwuvOt+Zys=' 'sha256-p4uUv+kujfJG7TrNl2/wjrWrDiuIENDML8qDEeuxfuM=' 'sha256-DNx9rrL+BfMish9h8ETnC3xGfwx5iYIvQaL/HhqNpcQ=' 'sha256-1wO3OWK0/Ob77IkLXZkvXhxesyzKilfI/c3j/Ru/tCY=' 'sha256-2NI1bfKYlapXvdvjl9Y+SCpn8kLnFRDCBmBjS5ZhbMk=' 'sha256-KtgzXSeWJyWuGF0lImPlTc48a51d2W4ThOb4qFgwZxI=' 'sha256-vEbSE+62dSRCWcJvhgYD//x/1wbTAhQuFUscaMfLr6s=' 'sha256-lHZB5jp1zqqQIt+dZXxT1xCAHbJhNEGNx36zowvQyWU='; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; img-src 'self' blob: data: https://markv.goatcounter.com https://github.com https://raw.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://avatars.githubusercontent.com; media-src 'self' blob: data: https://github.com https://raw.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://avatars.githubusercontent.com; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://markv.goatcounter.com https://api.groq.com http://127.0.0.1:* https://127.0.0.1:* http://localhost:* https://localhost:*; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; manifest-src 'self'"
10    />
11    <meta
12      name="theme-color"
13      content="#fafaf9"
14      media="(prefers-color-scheme: light)"
15    />
16    <meta
17      name="theme-color"
18      content="#161616"
19      media="(prefers-color-scheme: dark)"
20    />
21    <title>Markdown Viewer</title>
22    <link rel="manifest" href="./manifest.webmanifest" />
23    <link
24      rel="icon"
25      type="image/png"
26      sizes="192x192"
27      href="./assets/icons/icon-192.png"
28    />
29    <link
30      rel="icon"
31      type="image/png"
32      sizes="512x512"
33      href="./assets/icons/icon-512.png"
34    />
35    <link rel="apple-touch-icon" href="./assets/icons/icon-192.png" />
36    <link rel="stylesheet" href="assets/command-palette.css?v=2" />
37
38    
38<script src="assets/analytics.js"></script>
38
39    
39<script src="assets/library-core.js?v=16"></script>
39
40    
40<script src="assets/visual-blocks.js?v=16"></script>
40
41    
41<script src="assets/shortcut-registry.js?v=2"></script>
41
42    
42<script src="assets/editor-markdown-actions.js?v=2"></script>
42
43    
43<script src="assets/command-palette-core.js?v=2"></script>
43
44    
44<script src="assets/command-palette-host.js?v=4"></script>
44
45    
45<script src="assets/command-palette.js?v=5"></script>
45
46    
46<script src="assets/reader-palettes.js?v=4"></script>
46
47    
47<script src="assets/export-core.js?v=2"></script>
47
48    
48<script src="assets/export-host.js?v=4"></script>
48
49    
49<script src="assets/csv-sheet.js?v=1"></script>
49
50    
50<script src="assets/pwa.js"></script>
50
51    
51<script>
52      (function () {
53        "use strict";
54
55        const READING_KEY = "md-viewer-reading";
56        const defaults = {
57          fontSize: 19,
58          lineHeight: 1.72,
59          measure: 680,
60        };
61
62        function clamp(value, min, max) {
63          return Math.min(max, Math.max(min, value));
64        }
65
66        function roundToStep(value, step, min) {
67          const offset = Math.round((value - min) / step) * step + min;
68          return Number(offset.toFixed(2));
69        }
70
71        try {
72          const raw = window.localStorage.getItem(READING_KEY);
73          if (!raw) return;
74          const parsed = JSON.parse(raw);
75          if (!parsed || typeof parsed !== "object") return;
76
77          const fontSize = roundToStep(
78            clamp(Number(parsed.fontSize) || defaults.fontSize, 16, 22),
79            0.5,
80            16,
81          );
82          const lineHeight = roundToStep(
83            clamp(Number(parsed.lineHeight) || defaults.lineHeight, 1.45, 1.9),
84            0.02,
85            1.45,
86          );
87          const measure = roundToStep(
88            clamp(Number(parsed.measure) || defaults.measure, 560, 820),
89            20,
90            560,
91          );
92
93          const root = document.documentElement;
94          root.style.setProperty("--reading-font-size", fontSize + "px");
95          root.style.setProperty("--reading-line-height", String(lineHeight));
96          root.style.setProperty("--reading-measure", measure + "px");
97        } catch (_error) {}
98      })();
99    </script>
99
100
101    <style>
102      /* ============================================================
103       Markdown Viewer — editorial, readability-first.
104       Body text: local serif stack
105       UI chrome:  local sans stack
106       Code:       system monospace
107       Palette:    warm cream (light) / soft charcoal (dark)
108       ============================================================ */
109
110      :root,
111      :root[data-theme="light"] {
112        color-scheme: light;
113
114        /* Still Water — neutral mist, rice-paper calm. */
115        --bg-canvas: #fafaf9;
116        --bg-subtle: #f4f3f1;
117        --bg-muted: #ecebe9;
118        --bg-raised: #ffffff;
119
120        /* Soft near-black — warm enough to feel human, neutral enough for calm. */
121        --fg-default: #222221;
122        --fg-muted: #71706e;
123        --fg-subtle: #a3a09c;
124
125        /* Borders dissolve into the background. */
126        --border-soft: rgba(0, 0, 0, 0.04);
127        --border-default: rgba(0, 0, 0, 0.07);
128        --border-strong: rgba(0, 0, 0, 0.11);
129
130        /* Sage-eucalyptus accent — quieter than saturated teal. */
131        --accent-fg: #5f8f80;
132        --accent-hover: #4d7d6e;
133
134        --code-bg: rgba(0, 0, 0, 0.025);
135        --code-inline-bg: rgba(0, 0, 0, 0.035);
136        --code-block-bg: #f5f4f2;
137
138        --blockquote-border: #c7c4be;
139        --blockquote-bg: rgba(0, 0, 0, 0.018);
140
141        --selection: rgba(95, 143, 128, 0.14);
142        --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.025);
143        --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.04);
144        --alert-note: #4f7fa8;
145        --alert-tip: #4f8c74;
146        --alert-important: #6f73c4;
147        --alert-warning: #b6801f;
148        --alert-caution: #b35d5d;
149        --ont-intro: rgba(100, 150, 210, 0.55);
150        --ont-definition: rgba(100, 150, 210, 0.55);
151        --ont-explanation: rgba(170, 135, 55, 0.55);
152        --ont-instruction: rgba(130, 170, 110, 0.55);
153        --ont-argument: rgba(175, 100, 100, 0.5);
154        --ont-narrative: rgba(150, 120, 180, 0.5);
155        --ont-example: rgba(100, 160, 160, 0.5);
156        --ont-unknown: rgba(138, 133, 120, 0.45);
157        --ont-term-underline: rgba(214, 176, 89, 0.5);
158
159        /* ── Mindmap tokens ────────────────────────────────────── */
160        --mm-canvas-bg: var(--bg-canvas);
161        --mm-node-bg: var(--bg-raised);
162        --mm-node-border: var(--border-strong);
163        --mm-node-border-hover: var(--accent-fg);
164        --mm-node-shadow:
165          0 2px 14px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.04);
166        --mm-node-shadow-hover:
167          0 6px 28px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.06);
168        --mm-node-filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.05))
169          drop-shadow(0 1px 3px rgba(0, 0, 0, 0.04));
170        --mm-node-filter-hover: drop-shadow(0 6px 28px rgba(0, 0, 0, 0.08))
171          drop-shadow(0 2px 6px rgba(0, 0, 0, 0.06));
172        --mm-node-bg-selected: color-mix(
173          in srgb,
174          var(--accent-fg) 6%,
175          transparent
176        );
177        --mm-edge-stroke: var(--fg-muted);
178        --mm-edge-opacity-primary: 0.32;
179        --mm-edge-opacity-secondary: 0.22;
180        --mm-edge-opacity-tertiary: 0.14;
181        --mm-label-serif: var(--font-serif);
182        --mm-badge-sans: var(--font-sans);
183        --mm-root-label-size: calc(var(--reading-font-size, 19px) * 1.18);
184        --mm-primary-label-size: calc(var(--reading-font-size, 19px) * 0.84);
185        --mm-secondary-label-size: calc(var(--reading-font-size, 19px) * 0.74);
186        --mm-badge-size: 10px;
187        --mm-ring-1-radius: 320px;
188        --mm-ring-2-radius: 620px;
189        --mm-ring-3-radius: 920px;
190        --mm-node-padding-x: 20px;
191        --mm-node-padding-y: 14px;
192        --mm-node-gap-min: 28px;
193
194        /* ── Motion system ────────────────────────────────────────
195           Calm & bliss curve — gentle ease-out, never bouncy.
196           Durations scale with affordance: micro → hover/focus,
197           small → toggles/toasts, panel → drawers/dialogs,
198           page → route-level transitions. */
199        --motion-micro: 140ms;
200        --motion-small: 220ms;
201        --motion-panel: 380ms;
202        --motion-page: 520ms;
203        --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
204        --ease-in: cubic-bezier(0.4, 0, 1, 1);
205        --ease-calm: cubic-bezier(0.25, 0.46, 0.45, 0.94);
206      }
207
208      /* Reduced-motion: honor user preference globally. Kills the
209         translate/scale, leaves opacity in place so state changes
210         still read. Any component-level animation below should
211         likewise respect this. */
212      @media (prefers-reduced-motion: reduce) {
213        *,
214        *::before,
215        *::after {
216          animation-duration: 0.01ms !important;
217          animation-iteration-count: 1 !important;
218          transition-duration: 0.01ms !important;
219          scroll-behavior: auto !important;
220        }
221      }
222
223      :root[data-theme="dark"] {
224        color-scheme: dark;
225
226        /* Evening Calm — neutral charcoal, no warm/cool bias. */
227        --bg-canvas: #161616;
228        --bg-subtle: #1d1d1d;
229        --bg-muted: #262626;
230        --bg-raised: #1f1f1f;
231
232        --fg-default: #e4e3e0;
233        --fg-muted: #9a9895;
234        --fg-subtle: #636261;
235
236        --border-soft: rgba(255, 255, 255, 0.05);
237        --border-default: rgba(255, 255, 255, 0.08);
238        --border-strong: rgba(255, 255, 255, 0.13);
239
240        --accent-fg: #88bead;
241        --accent-hover: #a0cfbf;
242
243        --code-bg: rgba(255, 255, 255, 0.04);
244        --code-inline-bg: rgba(255, 255, 255, 0.06);
245        --code-block-bg: #131313;
246
247        --blockquote-border: #5a5850;
248        --blockquote-bg: rgba(255, 255, 255, 0.02);
249
250        --selection: rgba(136, 190, 173, 0.18);
251        --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
252        --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.35);
253        --alert-note: #83acd6;
254        --alert-tip: #83c1a7;
255        --alert-important: #9da6ef;
256        --alert-warning: #d6af62;
257        --alert-caution: #d98d8d;
258        --ont-intro: rgba(120, 170, 230, 0.45);
259        --ont-definition: rgba(120, 170, 230, 0.45);
260        --ont-explanation: rgba(190, 155, 75, 0.45);
261        --ont-instruction: rgba(150, 190, 130, 0.45);
262        --ont-argument: rgba(195, 120, 120, 0.4);
263        --ont-narrative: rgba(170, 140, 200, 0.4);
264        --ont-example: rgba(120, 180, 180, 0.4);
265        --ont-unknown: rgba(110, 104, 92, 0.4);
266        --ont-term-underline: rgba(214, 176, 89, 0.45);
267
268        /* ── Mindmap dark overrides ─────────────────────────────── */
269        --mm-node-shadow:
270          0 2px 18px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25);
271        --mm-node-shadow-hover:
272          0 6px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3);
273        --mm-node-filter: drop-shadow(0 2px 18px rgba(0, 0, 0, 0.35))
274          drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25));
275        --mm-node-filter-hover: drop-shadow(0 6px 32px rgba(0, 0, 0, 0.45))
276          drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
277        --mm-edge-opacity-primary: 0.38;
278        --mm-edge-opacity-secondary: 0.26;
279        --mm-edge-opacity-tertiary: 0.16;
280        --mm-node-bg-selected: rgba(120, 170, 230, 0.08);
281      }
282
283      /* Font stacks */
284      :root {
285        --font-serif:
286          "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua",
287          Georgia, serif;
288        --font-sans:
289          ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
290          "Helvetica Neue", Arial, sans-serif;
291        --font-mono:
292          ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas,
293          "Liberation Mono", monospace;
294        --reading-font-size: 19px;
295        --reading-line-height: 1.72;
296        --reading-measure: 680px;
297      }
298
299      * {
300        box-sizing: border-box;
301      }
302
303      .visually-hidden {
304        position: absolute;
305        width: 1px;
306        height: 1px;
307        padding: 0;
308        margin: -1px;
309        overflow: hidden;
310        clip: rect(0, 0, 0, 0);
311        white-space: nowrap;
312        border: 0;
313      }
314
315      html,
316      body {
317        margin: 0;
318        height: 100%;
319        overflow-x: clip;
320        background: var(--bg-canvas);
321        color: var(--fg-default);
322        font-family: var(--font-sans);
323        font-size: 16px;
324        line-height: 1.5;
325        -webkit-font-smoothing: antialiased;
326        -moz-osx-font-smoothing: grayscale;
327        text-rendering: optimizeLegibility;
328      }
329
330      ::selection {
331        background: var(--selection);
332      }
333
334      body.dragging::before {
335        content: "";
336        position: fixed;
337        inset: 16px;
338        border: 1.5px dashed var(--accent-fg);
339        border-radius: 16px;
340        pointer-events: none;
341        z-index: 100;
342        background: color-mix(in srgb, var(--accent-fg) 4%, transparent);
343        animation: drag-pulse 1s ease-in-out infinite alternate;
344      }
345      @keyframes drag-pulse {
346        from {
347          opacity: 0.6;
348        }
349        to {
350          opacity: 1;
351        }
352      }
353
354      html.empty-preview-state,
355      body.empty-preview-state {
356        overflow: hidden;
357      }
358
359      .app {
360        min-height: 100%;
361        min-height: 100dvh;
362        display: flex;
363        flex-direction: column;
364      }
365
366      /* ---------- Toolbar ---------- */
367      .toolbar {
368        position: sticky;
369        top: 0;
370        z-index: 10;
371        display: flex;
372        align-items: center;
373        gap: 6px;
374        padding: 10px 20px;
375        background: color-mix(in srgb, var(--bg-canvas) 88%, transparent);
376        -webkit-backdrop-filter: saturate(1.2) blur(16px);
377        backdrop-filter: saturate(1.2) blur(16px);
378        border-bottom: 1px solid var(--border-soft);
379      }
380      body:is(.preview-mode, .edit-mode-active, .mindmap-open) .toolbar,
381      .preview-pane,
382      .editor-pane,
383      .mindmap-viewer,
384      .mermaid-viewer {
385        --bg-canvas: var(--reader-bg-canvas, #fafaf9);
386        --bg-subtle: var(--reader-bg-subtle, #f4f3f1);
387        --bg-muted: var(--reader-bg-subtle, #ecebe9);
388        --bg-raised: var(--reader-bg-raised, #ffffff);
389        --fg-default: var(--reader-fg-default, #222221);
390        --fg-heading: var(--reader-fg-heading, #222221);
391        --fg-muted: var(--reader-fg-muted, #71706e);
392        --fg-subtle: var(--reader-fg-subtle, #a3a09c);
393        --border-soft: var(--reader-border-soft, rgba(0, 0, 0, 0.04));
394        --border-default: var(--reader-border-default, rgba(0, 0, 0, 0.07));
395        --border-strong: var(--reader-border-strong, rgba(0, 0, 0, 0.11));
396        --accent-fg: var(--reader-accent, #5f8f80);
397        --accent-hover: var(--reader-accent-hover, #4d7d6e);
398        --selection: var(--reader-selection, rgba(95, 143, 128, 0.14));
399        --code-block-bg: var(--reader-code-bg, #f5f4f2);
400        --code-inline-bg: var(--reader-code-inline-bg, rgba(0, 0, 0, 0.035));
401        --blockquote-bg: var(--reader-blockquote-bg, rgba(0, 0, 0, 0.018));
402        --blockquote-border: var(--reader-blockquote-border, #c7c4be);
403        --shadow-sm: var(--reader-shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.025));
404        --alert-note: var(--reader-alert-note, #4f7fa8);
405        --alert-tip: var(--reader-alert-tip, #4f8c74);
406        --alert-important: var(--reader-alert-important, #6f73c4);
407        --alert-warning: var(--reader-alert-warning, #b6801f);
408        --alert-caution: var(--reader-alert-caution, #b35d5d);
409        --mm-canvas-bg: var(--reader-bg-canvas, var(--bg-canvas));
410        --mm-node-bg: var(--reader-bg-raised, var(--bg-raised));
411        --mm-node-bg-selected: color-mix(
412          in srgb,
413          var(--reader-accent, var(--accent-fg)) 8%,
414          transparent
415        );
416        --mm-node-border: var(--reader-border-strong, var(--border-strong));
417        --mm-node-border-hover: var(--reader-accent, var(--accent-fg));
418        --mm-edge-stroke: var(--reader-fg-muted, var(--fg-muted));
419      }
420
421      body:is(.preview-mode, .edit-mode-active, .mindmap-open) .toolbar {
422        background: color-mix(
423          in srgb,
424          var(--reader-bg-canvas) 90%,
425          transparent
426        );
427        border-bottom-color: var(--reader-border-soft);
428        color: var(--reader-fg-default);
429        transition:
430          background-color var(--motion-small) var(--ease-calm),
431          border-color var(--motion-small) var(--ease-calm),
432          color var(--motion-small) var(--ease-calm);
433      }
434      body:is(.preview-mode, .edit-mode-active, .mindmap-open),
435      body:is(.preview-mode, .edit-mode-active, .mindmap-open) .app,
436      body:is(.preview-mode, .edit-mode-active, .mindmap-open) .main {
437        background: var(--reader-bg-canvas, var(--bg-canvas));
438        color: var(--reader-fg-default, var(--fg-default));
439      }
440
441      body.preview-mode #saveBtn {
442        display: none !important;
443      }
444
445      .brand {
446        display: flex;
447        align-items: center;
448        gap: 6px;
449        font-family: var(--font-serif);
450        font-weight: 600;
451        font-size: 14px;
452        font-style: normal;
453        letter-spacing: -0.01em;
454        color: var(--fg-muted);
455        margin-right: 4px;
456      }
457
458      .brand-mark {
459        display: inline-flex;
460        align-items: center;
461        justify-content: center;
462        flex-shrink: 0;
463        width: 18px;
464        height: 18px;
465        color: var(--fg-default);
466      }
467
468      .brand-mark svg {
469        width: 100%;
470        height: 100%;
471        display: block;
472      }
473
474      .brand-name {
475        font-family: var(--font-serif);
476        font-weight: 700;
477        font-size: 0.85rem;
478        letter-spacing: -0.045em;
479        line-height: 1;
480        text-transform: lowercase;
481        max-width: 360px;
482        white-space: nowrap;
483        overflow: hidden;
484        text-overflow: ellipsis;
485        color: var(--fg-default);
486      }
487      .brand-name.is-file {
488        font-weight: 500;
489        letter-spacing: -0.005em;
490        text-transform: none;
491      }
492      .brand-name.is-untitled {
493        font-weight: 500;
494        letter-spacing: -0.005em;
495        text-transform: none;
496        color: var(--fg-subtle);
497      }
498      .brand-dirty {
499        display: inline-block;
500        width: 6px;
501        height: 6px;
502        margin-left: 6px;
503        border-radius: 50%;
504        background: color-mix(in srgb, var(--alert-warning) 80%, transparent);
505        flex-shrink: 0;
506      }
507      .brand-dirty[hidden] {
508        display: none;
509      }
510      @media (max-width: 640px) {
511        .brand-name {
512          max-width: 180px;
513        }
514      }
515
516      /* Command palette shortcut hint — Option C "purist" trigger.
517         Inherits sizing/padding/border from .btn so it visually peers with
518         the surrounding chrome buttons (Settings, etc.). This class only
519         layers on the "kbd hint" character: mono font, slight tracking,
520         muted color, lower opacity at rest. Hidden on mobile (no shortcut).
521         The real #commandPaletteBtn is hidden in the desktop .toolbar but
522         remains live for the mobile overflow sheet (toolbar-mobile.js
523         teleports it via data-mv-overflow-section). */
524      .palette-shortcut-hint {
525        font-family: var(
526          --font-mono,
527          ui-monospace,
528          "SF Mono",
529          Menlo,
530          Consolas,
531          monospace
532        );
533        font-size: 12px;
534        font-weight: 500;
535        letter-spacing: 0.02em;
536        color: var(--fg-subtle);
537        opacity: 0.8;
538        transition:
539          opacity 140ms ease,
540          color 140ms ease;
541      }
542      .palette-shortcut-hint:hover,
543      .palette-shortcut-hint:focus-visible {
544        opacity: 1;
545        color: var(--fg-default);
546      }
547      body.command-palette-open .palette-shortcut-hint {
548        visibility: hidden;
549      }
550      @media (max-width: 640px) {
551        .palette-shortcut-hint {
552          display: none;
553        }
554      }
555      .toolbar #commandPaletteBtn {
556        display: none;
557      }
558
559      .toolbar .spacer {
560        flex: 1;
561        min-width: 0;
562      }
563
564      /* ── Toolbar groups & dividers ──────────────────────────────
565         Cluster related controls (Gestalt proximity) so the eye can
566         parse the bar as 5–6 meaningful regions instead of a flat
567         list of 11+ buttons. Dividers give the rhythm. */
568      .tb-group {
569        display: inline-flex;
570        align-items: center;
571        gap: 4px;
572        flex-shrink: 0;
573      }
574
575      .tb-divider {
576        width: 1px;
577        height: 18px;
578        background: var(--border-default);
579        margin: 0 8px;
580        flex: 0 0 auto;
581        border-radius: 1px;
582      }
583
584      /* ── Segmented view-mode pill (Edit · Mindmap) ──────────────
585         Two adjacent toggles joined into one control. Active segment
586         reflects body.edit-mode and body.mindmap-open (synced via
587         a small MutationObserver near the end of this file). */
588      .tb-segment {
589        display: inline-flex;
590        align-items: center;
591        gap: 2px;
592        padding: 3px;
593        background: var(--bg-subtle);
594        border: 1px solid var(--border-soft);
595        border-radius: 10px;
596        flex-shrink: 0;
597      }
598      .tb-segment .tb-seg-btn {
599        display: inline-flex;
600        align-items: center;
601        gap: 6px;
602        height: 26px;
603        padding: 0 11px;
604        font-family: var(--font-sans);
605        font-size: 12.5px;
606        font-weight: 500;
607        color: var(--fg-muted);
608        background: transparent;
609        border: 1px solid transparent;
610        border-radius: 7px;
611        cursor: pointer;
612        white-space: nowrap;
613        transition:
614          color 140ms ease,
615          background 140ms ease,
616          box-shadow 140ms ease;
617      }
618      .tb-segment .tb-seg-btn:hover:not(:disabled):not([aria-pressed="true"]) {
619        color: var(--fg-default);
620      }
621      .tb-segment .tb-seg-btn[aria-pressed="true"] {
622        color: var(--fg-default);
623        background: var(--bg-raised);
624        box-shadow:
625          0 1px 2px rgba(0, 0, 0, 0.04),
626          0 0 0 1px var(--border-soft);
627      }
628      .tb-segment .tb-seg-btn:focus-visible {
629        outline: none;
630        box-shadow: 0 0 0 3px var(--selection);
631      }
632      .tb-segment .tb-seg-btn:disabled {
633        opacity: 0.4;
634        cursor: not-allowed;
635      }
636      /* Preserve the "unsaved changes" dot on the Edit segment. */
637      .tb-segment .mode-toggle-btn.has-unsaved::after {
638        content: "";
639        width: 6px;
640        height: 6px;
641        border-radius: 999px;
642        background: color-mix(in srgb, var(--accent-fg) 74%, white 26%);
643        box-shadow: 0 0 0 3px
644          color-mix(in srgb, var(--accent-fg) 12%, transparent);
645        flex: 0 0 auto;
646      }
647
648      /* ── Legacy theme icons (sun + moon) ───────────────────────
649         Hidden by default; the state-neutral contrast icon replaces
650         them. Left in the DOM so reverting is a one-line change —
651         just remove this rule (or delete the wrapper span in the
652         themeToggleBtn markup). The JS applyTheme() still targets
653         #themeIconSun / #themeIconMoon, so toggling works instantly
654         on re-enable. */
655      .theme-icons-legacy {
656        display: none;
657      }
658
659      /* View Transition theme swap — when startViewTransition is
660         supported, the new theme unfolds from the theme button as
661         a radial clip. `--vt-origin-x/y` are set on :root at click
662         time (screen-space pixels). Uses a large radius so the
663         reveal always completes across even ultra-wide monitors.
664         Firefox and older Safari fall back to the default crossfade
665         via the API, no change needed there. */
666      ::view-transition-old(root),
667      ::view-transition-new(root) {
668        animation: none;
669        mix-blend-mode: normal;
670      }
671      ::view-transition-old(root) {
672        z-index: 1;
673      }
674      ::view-transition-new(root) {
675        z-index: 2;
676        animation: vt-reveal 520ms cubic-bezier(0.22, 1, 0.36, 1);
677      }
678      @keyframes vt-reveal {
679        from {
680          clip-path: circle(
681            0 at var(--vt-origin-x, 50%) var(--vt-origin-y, 50%)
682          );
683        }
684        to {
685          clip-path: circle(
686            var(--vt-end-radius, 150%) at var(--vt-origin-x, 50%)
687              var(--vt-origin-y, 50%)
688          );
689        }
690      }
691
692      /* ── Reading progress gutter ─────────────────────────────
693         A 2px vertical thread pinned to the left viewport edge that
694         fills from top as the reader scrolls. Ambient presence, no
695         labels, no numbers — just a quiet "how far in" signal.
696         GPU-only: animates scaleY on an inner fill, transform-origin
697         top. Hidden when the user is at the very top (nothing to
698         show yet) and in edit mode (chrome distraction). */
699      #readingProgressGutter {
700        position: fixed;
701        left: 0;
702        top: 0;
703        bottom: 0;
704        width: 2px;
705        pointer-events: none;
706        z-index: 5;
707        opacity: 0;
708        transition: opacity var(--motion-small) var(--ease-calm);
709      }
710      #readingProgressGutter.is-active {
711        opacity: 1;
712      }
713      #readingProgressGutter::before {
714        content: "";
715        position: absolute;
716        inset: 0;
717        background: var(--border-soft);
718      }
719      #readingProgressGutter .fill {
720        position: absolute;
721        inset: 0;
722        background: color-mix(in srgb, var(--accent-fg) 55%, transparent);
723        transform-origin: top;
724        transform: scaleY(var(--progress, 0));
725        will-change: transform;
726      }
727      body.edit-mode-active #readingProgressGutter,
728      body.mindmap-open #readingProgressGutter,
729      body.library-open #readingProgressGutter {
730        opacity: 0 !important;
731      }
732
733      /* Mirror the contrast icon horizontally in dark mode. In light
734         theme the filled half sits on the right; in dark theme it
735         flips to the left, giving a clear visual cue that the theme
736         state actually changed even though the icon itself is
737         state-neutral. `transform-origin: center` keeps the flip
738         optically centered inside the button. */
739      #themeIconContrast {
740        transition: transform 180ms ease;
741      }
742      :root[data-theme="dark"] #themeIconContrast {
743        transform: scaleX(-1);
744      }
745
746      /* ── Destructive affordance: Clear ──────────────────────────
747         Kept in the toolbar (no feature removal) but muted by default
748         and tinted red on hover so the caution reads without shouting. */
749      #clearBtn {
750        color: var(--fg-subtle);
751      }
752      #clearBtn:hover:not(:disabled) {
753        color: #b94f4f;
754        background: color-mix(in srgb, #b94f4f 8%, transparent);
755        border-color: color-mix(in srgb, #b94f4f 22%, var(--border-default));
756      }
757
758      /* Responsive: collapse dividers on narrow screens; the
759         .toolbar already sets flex-wrap at ≤768px. */
760      @media (max-width: 768px) {
761        .tb-divider {
762          display: none;
763        }
764        .tb-group {
765          gap: 3px;
766        }
767        .tb-segment .tb-seg-btn {
768          padding: 0 9px;
769        }
770      }
771
772      /* ── Mobile overflow ("More") system ──────────────────────────
773         At ≤640px the toolbar shrinks to a single primary row and a
774         "⋯ More" button. Secondary controls are teleported (by JS,
775         see assets/toolbar-mobile.js) into the bottom sheet that
776         opens from this button. CSS below handles the default-hidden
777         state for the More button + sheet on desktop, the sheet's
778         visual presentation, and the ≤640px overrides. */
779
780      /* Mobile-only "More" trigger. Higher specificity than `.btn`
781         so the default-hidden state isn't overridden by the base
782         button rule (which is defined later in this stylesheet). */
783      .toolbar .mv-more-btn {
784        display: none;
785        flex-shrink: 0;
786      }
787
788      .mv-more-backdrop {
789        position: fixed;
790        inset: 0;
791        z-index: 9990;
792        background: color-mix(in srgb, #000 38%, transparent);
793        -webkit-backdrop-filter: blur(2px);
794        backdrop-filter: blur(2px);
795        opacity: 0;
796        pointer-events: none;
797        transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
798      }
799      .mv-more-backdrop.is-open {
800        opacity: 1;
801        pointer-events: auto;
802      }
803      .mv-more-backdrop[hidden] {
804        display: none !important;
805      }
806
807      .mv-more-sheet {
808        position: fixed;
809        left: 0;
810        right: 0;
811        bottom: 0;
812        z-index: 9995;
813        max-height: 86vh;
814        overflow-y: auto;
815        -webkit-overflow-scrolling: touch;
816        background: var(--bg-canvas);
817        border-top: 1px solid var(--border-default);
818        border-top-left-radius: 18px;
819        border-top-right-radius: 18px;
820        box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.18);
821        transform: translateY(100%);
822        transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
823        padding: 4px 16px calc(env(safe-area-inset-bottom, 0px) + 20px);
824      }
825      .mv-more-sheet.is-open {
826        transform: translateY(0);
827      }
828      .mv-more-sheet[hidden] {
829        display: none !important;
830      }
831      @media (prefers-reduced-motion: reduce) {
832        .mv-more-backdrop,
833        .mv-more-sheet {
834          transition: none;
835        }
836      }
837
838      /* Drag-handle hint: subtle pill at top of sheet */
839      .mv-more-sheet::before {
840        content: "";
841        display: block;
842        width: 38px;
843        height: 4px;
844        margin: 8px auto 4px;
845        border-radius: 999px;
846        background: var(--border-default);
847        opacity: 0.7;
848      }
849
850      .mv-more-sheet__header {
851        position: sticky;
852        top: 0;
853        z-index: 1;
854        display: flex;
855        align-items: center;
856        justify-content: space-between;
857        padding: 8px 2px 12px;
858        background: var(--bg-canvas);
859        border-bottom: 1px solid var(--border-soft);
860      }
861      .mv-more-sheet__title {
862        margin: 0;
863        font-family: var(--font-serif);
864        font-weight: 600;
865        font-size: 1.05rem;
866        letter-spacing: -0.01em;
867        color: var(--fg-default);
868      }
869      .mv-more-sheet__close {
870        flex-shrink: 0;
871      }
872
873      .mv-more-sheet__body {
874        display: flex;
875        flex-direction: column;
876        gap: 18px;
877        padding: 16px 0 8px;
878      }
879      .mv-more-section {
880        display: flex;
881        flex-direction: column;
882        gap: 10px;
883      }
884      .mv-more-section__label {
885        font-family: var(--font-sans);
886        font-size: 11px;
887        font-weight: 600;
888        letter-spacing: 0.08em;
889        text-transform: uppercase;
890        color: var(--fg-subtle);
891        padding: 0 2px;
892      }
893      .mv-more-section__row {
894        display: flex;
895        flex-wrap: wrap;
896        align-items: center;
897        gap: 8px;
898      }
899      /* Inside the sheet, hoist tb-group children directly into the
900         row so we get one consistent flex-wrap layout regardless of
901         which groups happen to teleport in. */
902      .mv-more-sheet .tb-group {
903        display: contents;
904      }
905      .mv-more-sheet .tb-divider {
906        display: none;
907      }
908      /* Touch-target floor inside the sheet (WCAG 2.5.5). */
909      .mv-more-sheet .btn,
910      .mv-more-sheet .tb-segment .tb-seg-btn,
911      .mv-more-sheet .auto-refresh {
912        min-height: 44px;
913        min-width: 44px;
914      }
915
916      /* ── ≤640px: collapse toolbar to primary row + More ─────────
917         FOUC prevention: hide secondary items via attribute selector
918         even before toolbar-mobile.js runs. JS then physically moves
919         them into #mvMoreSheetBody (single source of truth — same
920         buttons, same IDs, same event handlers). */
921      @media (max-width: 640px) {
922        .toolbar {
923          flex-wrap: nowrap;
924          padding: 8px 12px;
925          gap: 4px;
926        }
927        .toolbar .status,
928        .toolbar #wordCount {
929          display: none;
930        }
931        .toolbar .tb-divider {
932          display: none;
933        }
934        /* Whole groups marked for overflow: hidden inside toolbar. */
935        .toolbar [data-mv-overflow-section] {
936          display: none;
937        }
938        /* Individual secondary buttons inside mixed groups. */
939        .toolbar #saveBtn,
940        .toolbar .ont-settings {
941          display: none;
942        }
943        /* Primary controls keep WCAG 2.5.5 tap floor + room to breathe. */
944        .toolbar .btn {
945          min-height: 44px;
946        }
947        .toolbar .mv-more-btn {
948          display: inline-flex;
949          margin-left: auto;
950        }
951        /* While sheet is open, freeze background scroll. */
952        body.mv-more-open {
953          overflow: hidden;
954        }
955      }
956
957      .btn {
958        display: inline-flex;
959        align-items: center;
960        gap: 6px;
961        height: 32px;
962        padding: 0 11px;
963        font-family: var(--font-sans);
964        font-size: 12.5px;
965        font-weight: 500;
966        color: var(--fg-muted);
967        background: transparent;
968        border: 1px solid var(--border-default);
969        border-radius: 8px;
970        cursor: pointer;
971        white-space: nowrap;
972        position: relative;
973        /* Settle physics — hover lifts 0.5px with a soft shadow bloom;
974           press sinks. Transitions driven off the motion system so
975           the whole UI shares one timing vocabulary. */
976        transform: translateY(0);
977        box-shadow: 0 0 0 rgba(0, 0, 0, 0);
978        transition:
979          color var(--motion-micro) var(--ease-calm),
980          background var(--motion-micro) var(--ease-calm),
981          border-color var(--motion-micro) var(--ease-calm),
982          transform var(--motion-micro) var(--ease-out),
983          box-shadow var(--motion-micro) var(--ease-out);
984      }
985      .btn-prefix {
986        display: inline-flex;
987        align-items: center;
988        justify-content: center;
989        width: 15px;
990        height: 15px;
991        margin-top: -1px;
992        font-family: var(--font-sans);
993        font-size: 17px;
994        font-weight: 400;
995        line-height: 1;
996        color: color-mix(in srgb, var(--fg-muted) 85%, transparent);
997      }
998      .btn:hover .btn-prefix,
999      .btn:focus-visible .btn-prefix {
1000        color: var(--fg-default);
1001      }
1002      /* Pressed-state underline: a calm alternative to a full
1003         segmented-pill slider. When a toggle button (Edit, Mindmap,
1004         Reading, Library, etc.) enters aria-pressed=true, a thin
1005         accent underline scales in from center. Reads as "active"
1006         without shouting, and the scaleX transition gives a gentle
1007         slide feeling rather than a hard on/off. */
1008      .btn::after {
1009        content: "";
1010        position: absolute;
1011        left: 20%;
1012        right: 20%;
1013        bottom: 3px;
1014        height: 1.5px;
1015        border-radius: 2px;
1016        background: var(--accent-fg);
1017        transform: scaleX(0);
1018        transform-origin: center;
1019        opacity: 0;
1020        pointer-events: none;
1021        transition:
1022          transform var(--motion-small) var(--ease-out),
1023          opacity var(--motion-small) var(--ease-out);
1024      }
1025      .btn[aria-pressed="true"]:not(:disabled)::after {
1026        transform: scaleX(1);
1027        opacity: 0.7;
1028      }
1029      .btn:hover:not(:disabled) {
1030        color: var(--fg-default);
1031        background: var(--bg-subtle);
1032        border-color: var(--border-strong);
1033        transform: translateY(-0.5px);
1034        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
1035      }
1036      .btn:active:not(:disabled) {
1037        background: var(--bg-muted);
1038        transform: translateY(0.5px);
1039        box-shadow: 0 0 0 rgba(0, 0, 0, 0);
1040        transition-duration: 80ms;
1041      }
1042      /* Toggle-state buttons (e.g. Edit, Mindmap): visually "pressed"
1043         when aria-pressed="true". Driven by assets/toolbar-sync.js
1044         from body classes. Discrete-button alternative to a segmented
1045         pill — consistent with the rest of the File / Refresh groups. */
1046      .btn[aria-pressed="true"]:not(:disabled) {
1047        background: var(--bg-subtle);
1048        border-color: var(--border-strong);
1049        color: var(--fg-default);
1050      }
1051      .btn[aria-pressed="true"]:not(:disabled):hover {
1052        background: var(--bg-muted);
1053      }
1054      .btn:disabled {
1055        opacity: 0.35;
1056        cursor: not-allowed;
1057      }
1058      #shareBtn {
1059        min-width: 96px;
1060        justify-content: center;
1061      }
1062      /* ─── Export: split button + compact menu ─────────────────
1063         Main label runs the last-used format directly. Caret opens
1064         the small format picker. Default format is remembered in
1065         localStorage via assets/export-host.js. */
1066      .export-settings {
1067        position: relative;
1068        display: inline-flex;
1069      }
1070      .export-split {
1071        position: relative;
1072        display: inline-flex;
1073        align-items: stretch;
1074        border: 1px solid var(--border-default);
1075        border-radius: 6px;
1076        color: var(--fg-muted);
1077        background: transparent;
1078        transform: translateY(0);
1079        box-shadow: 0 0 0 rgba(0, 0, 0, 0);
1080        transition:
1081          color var(--motion-micro) var(--ease-calm),
1082          border-color 120ms ease,
1083          background-color 120ms ease,
1084          transform var(--motion-micro) var(--ease-out),
1085          box-shadow var(--motion-micro) var(--ease-out);
1086      }
1087      .export-split:hover:not(.is-disabled):not([aria-disabled="true"]) {
1088        color: var(--fg-default);
1089        background: var(--bg-subtle);
1090        border-color: var(--border-strong);
1091        transform: translateY(-0.5px);
1092        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
1093      }
1094      .export-split.is-disabled,
1095      .export-split[aria-disabled="true"] {
1096        opacity: 0.55;
1097        pointer-events: none;
1098      }
1099      .export-split__main {
1100        display: inline-flex;
1101        align-items: center;
1102        gap: 6px;
1103        padding: 5px 11px;
1104        background: transparent;
1105        border: 0;
1106        border-radius: 6px 0 0 6px;
1107        color: inherit;
1108        font: inherit;
1109        font-size: 12.5px;
1110        font-weight: 500;
1111        line-height: 1.4;
1112        cursor: pointer;
1113        white-space: nowrap;
1114        transition: background-color 120ms ease;
1115      }
1116      .export-split__main:hover:not(:disabled) {
1117        background: transparent;
1118      }
1119      .export-split__main:disabled {
1120        cursor: not-allowed;
1121      }
1122      .export-split__sep {
1123        width: 1px;
1124        background: var(--border-soft);
1125        align-self: stretch;
1126      }
1127      .export-split__caret {
1128        display: inline-flex;
1129        align-items: center;
1130        justify-content: center;
1131        padding: 5px 7px;
1132        background: transparent;
1133        border: 0;
1134        border-radius: 0 6px 6px 0;
1135        color: inherit;
1136        cursor: pointer;
1137        transition:
1138          background-color 120ms ease,
1139          color 120ms ease;
1140      }
1141      .export-split__caret:hover:not(:disabled) {
1142        background: transparent;
1143        color: inherit;
1144      }
1145      .export-split__caret:disabled {
1146        cursor: not-allowed;
1147      }
1148      .export-split__main .icon {
1149        width: 14px;
1150        height: 14px;
1151        flex-shrink: 0;
1152        color: currentColor;
1153        transition: color 120ms ease;
1154      }
1155      .export-split__main:hover:not(:disabled) .icon {
1156        color: currentColor;
1157      }
1158      .export-split__caret svg {
1159        width: 14px;
1160        height: 14px;
1161        flex-shrink: 0;
1162      }
1163
1164      /* Compact format menu anchored to the split-button container */
1165      .export-menu {
1166        position: absolute;
1167        top: calc(100% + 6px);
1168        right: 0;
1169        z-index: 90;
1170        min-width: 200px;
1171        padding: 4px;
1172        border: 1px solid var(--border-default);
1173        border-radius: 8px;
1174        background: var(--bg-raised);
1175        color: var(--fg-default);
1176        box-shadow:
1177          0 1px 2px rgba(0, 0, 0, 0.04),
1178          0 6px 18px rgba(0, 0, 0, 0.08),
1179          0 24px 64px rgba(0, 0, 0, 0.1);
1180      }
1181      .export-menu[hidden] {
1182        display: none;
1183      }
1184      .export-menu__head {
1185        padding: 6px 10px 4px;
1186        font-size: 9.5px;
1187        font-weight: 700;
1188        text-transform: uppercase;
1189        letter-spacing: 0.14em;
1190        color: var(--fg-subtle);
1191      }
1192      .export-option {
1193        display: flex;
1194        align-items: center;
1195        gap: 10px;
1196        width: 100%;
1197        padding: 7px 10px;
1198        border: 0;
1199        border-radius: 5px;
1200        background: transparent;
1201        color: var(--fg-default);
1202        font: inherit;
1203        font-size: 13.5px;
1204        font-weight: 500;
1205        text-align: left;
1206        cursor: pointer;
1207        transition: background-color 100ms ease;
1208      }
1209      .export-option:hover:not(:disabled),
1210      .export-option:focus-visible {
1211        background: var(--bg-subtle);
1212        outline: none;
1213      }
1214      .export-option:disabled {
1215        opacity: 0.45;
1216        cursor: not-allowed;
1217      }
1218      .export-option__label {
1219        flex: 1;
1220      }
1221      .export-option__ext {
1222        font-family: var(--font-mono, ui-monospace, monospace);
1223        font-size: 10.5px;
1224        font-weight: 500;
1225        letter-spacing: 0.02em;
1226        color: var(--fg-subtle);
1227      }
1228      .export-option.is-active .export-option__ext {
1229        color: var(--accent-fg);
1230      }
1231      .export-option.is-active::after {
1232        content: "✓";
1233        font-size: 11px;
1234        font-weight: 700;
1235        color: var(--accent-fg);
1236      }
1237      /* Legacy classes kept for stray references — hidden when unused */
1238      .export-option__title {
1239        font-weight: 600;
1240      }
1241      .export-option__meta {
1242        font-size: 12px;
1243        color: var(--fg-muted);
1244        display: none;
1245      }
1246      .btn.is-busy {
1247        color: var(--fg-default);
1248        background: var(--bg-subtle);
1249        border-color: var(--border-strong);
1250      }
1251      .btn.is-busy::before {
1252        content: "";
1253        width: 11px;
1254        height: 11px;
1255        border-radius: 999px;
1256        border: 1.5px solid currentColor;
1257        border-right-color: transparent;
1258        opacity: 0.68;
1259        animation: btn-spin 720ms linear infinite;
1260      }
1261      .btn:focus-visible,
1262      .chat-fab:focus-visible,
1263      .scroll-top-btn:focus-visible,
1264      .chat-header-btn:focus-visible,
1265      .chat-send-btn:focus-visible,
1266      .ctx-menu-item:focus-visible,
1267      .ont-close:focus-visible,
1268      .ont-section-row:focus-visible,
1269      .selection-action:focus-visible,
1270      .highlight-remove-chip:focus-visible,
1271      .highlight-undo-btn:focus-visible {
1272        outline: none;
1273        box-shadow: 0 0 0 3px var(--selection);
1274      }
1275
1276      /* Unsaved-state cue. Moved to ::before so the ::after pseudo
1277         is free for the pressed-state underline (shared across all
1278         .btn). The dot breathes gently — 4s cycle, opacity+scale —
1279         so the "you have unsaved changes" signal is present without
1280         being anxious. */
1281      .mode-toggle-btn.has-unsaved::before {
1282        content: "";
1283        width: 6px;
1284        height: 6px;
1285        border-radius: 999px;
1286        background: color-mix(in srgb, var(--accent-fg) 74%, white 26%);
1287        box-shadow: 0 0 0 3px
1288          color-mix(in srgb, var(--accent-fg) 12%, transparent);
1289        flex: 0 0 auto;
1290        order: 99; /* place it after the label, like the old ::after */
1291        margin-left: 2px;
1292        animation: unsaved-breathe 4s var(--ease-calm) infinite;
1293      }
1294      @keyframes unsaved-breathe {
1295        0%,
1296        100% {
1297          opacity: 0.7;
1298          transform: scale(1);
1299        }
1300        50% {
1301          opacity: 1;
1302          transform: scale(1.15);
1303        }
1304      }
1305
1306      /* Just-saved morph: when the unsaved state resolves (JS adds
1307         .just-saved for 1.4s), the dot becomes a checkmark glyph.
1308         Same footprint, no layout shift — just a calm confirmation. */
1309      .mode-toggle-btn.just-saved::before {
1310        content: "";
1311        width: 10px;
1312        height: 10px;
1313        border-radius: 0;
1314        background: none;
1315        box-shadow: none;
1316        flex: 0 0 auto;
1317        order: 99;
1318        margin-left: 4px;
1319        animation: none;
1320        /* SVG checkmark as a background mask so it picks up accent color */
1321        -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M13.5 4.5 L6 12 L2.5 8.5' fill='none' stroke='%23000' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'/></svg>")
1322          center / contain no-repeat;
1323        mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M13.5 4.5 L6 12 L2.5 8.5' fill='none' stroke='%23000' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'/></svg>")
1324          center / contain no-repeat;
1325        background: var(--accent-fg);
1326        opacity: 0;
1327        transform: scale(0.6);
1328        animation: just-saved-pop var(--motion-panel) var(--ease-out) forwards;
1329      }
1330      @keyframes just-saved-pop {
1331        0% {
1332          opacity: 0;
1333          transform: scale(0.6);
1334        }
1335        55% {
1336          opacity: 1;
1337          transform: scale(1.08);
1338        }
1339        100% {
1340          opacity: 1;
1341          transform: scale(1);
1342        }
1343      }
1344
1345      .btn-icon {
1346        width: 32px;
1347        padding: 0;
1348        justify-content: center;
1349      }
1350
1351      .btn-primary {
1352        background: var(--fg-default);
1353        color: var(--bg-canvas);
1354        border-color: transparent;
1355      }
1356      .btn-primary:hover:not(:disabled) {
1357        background: var(--fg-muted);
1358        color: var(--bg-canvas);
1359        border-color: transparent;
1360      }
1361
1362      /* ── PWA update note ───────────────────────────────────────
1363         Quiet, temporary, and editorial: the update should feel like
1364         a slip tucked onto the desk, not part of the toolbar chrome. */
1365      .pwa-update-toast {
1366        position: fixed;
1367        top: calc(var(--toolbar-h, 72px) + 18px);
1368        right: 20px;
1369        z-index: 30;
1370        width: min(360px, calc(100vw - 32px));
1371        padding: 14px 14px 13px;
1372        display: grid;
1373        grid-template-columns: 1fr auto;
1374        gap: 12px 16px;
1375        border-radius: 16px;
1376        border: 1px solid var(--border-default);
1377        background: linear-gradient(
1378          180deg,
1379          color-mix(in srgb, var(--bg-raised) 94%, transparent),
1380          color-mix(in srgb, var(--bg-subtle) 90%, transparent)
1381        );
1382        box-shadow:
1383          0 18px 42px rgba(0, 0, 0, 0.12),
1384          0 1px 0 rgba(255, 255, 255, 0.08) inset;
1385        -webkit-backdrop-filter: saturate(1.1) blur(16px);
1386        backdrop-filter: saturate(1.1) blur(16px);
1387        opacity: 0;
1388        transform: translateY(-8px) translateX(10px) scale(0.985);
1389        pointer-events: none;
1390        transition:
1391          opacity var(--motion-small) var(--ease-out),
1392          transform var(--motion-small) var(--ease-out);
1393      }
1394      .pwa-update-toast::before {
1395        content: "";
1396        position: absolute;
1397        left: 14px;
1398        top: 12px;
1399        bottom: 12px;
1400        width: 1px;
1401        border-radius: 999px;
1402        background: linear-gradient(
1403          180deg,
1404          color-mix(in srgb, var(--accent-fg) 78%, white 22%),
1405          color-mix(in srgb, var(--accent-fg) 18%, transparent)
1406        );
1407        opacity: 0.9;
1408      }
1409      .pwa-update-toast.is-visible {
1410        opacity: 1;
1411        transform: translateY(0) translateX(0) scale(1);
1412        pointer-events: auto;
1413      }
1414      .pwa-update-toast__body {
1415        position: relative;
1416        padding-left: 10px;
1417        min-width: 0;
1418      }
1419      .pwa-update-toast__eyebrow {
1420        margin: 0 0 4px;
1421        color: var(--fg-muted);
1422        font-family: var(--font-serif);
1423        font-size: 11px;
1424        font-weight: 600;
1425        letter-spacing: 0.08em;
1426        text-transform: uppercase;
1427      }
1428      .pwa-update-toast__title {
1429        margin: 0;
1430        color: var(--fg-default);
1431        font-family: var(--font-serif);
1432        font-size: 15px;
1433        font-weight: 600;
1434        letter-spacing: -0.02em;
1435        line-height: 1.22;
1436      }
1437      .pwa-update-toast__copy {
1438        margin: 5px 0 0;
1439        color: var(--fg-muted);
1440        font-size: 12.5px;
1441        line-height: 1.45;
1442      }
1443      .pwa-update-toast__actions {
1444        display: inline-flex;
1445        align-items: flex-start;
1446        gap: 8px;
1447        align-self: center;
1448      }
1449      .pwa-update-toast__actions .btn {
1450        height: 30px;
1451        padding: 0 10px;
1452        font-size: 12px;
1453      }
1454      .pwa-update-toast__dismiss {
1455        min-width: 34px;
1456        padding: 0;
1457        justify-content: center;
1458      }
1459      .pwa-update-toast__dismiss svg {
1460        width: 12px;
1461        height: 12px;
1462      }
1463      .app-toast {
1464        z-index: 35;
1465      }
1466      .app-toast[data-kind="error"]::before {
1467        background: linear-gradient(
1468          180deg,
1469          color-mix(in srgb, var(--alert-caution) 86%, white 14%),
1470          color-mix(in srgb, var(--alert-caution) 22%, transparent)
1471        );
1472      }
1473      .app-toast[data-kind="error"] .pwa-update-toast__eyebrow {
1474        color: var(--alert-caution);
1475      }
1476      @media (max-width: 768px) {
1477        .pwa-update-toast {
1478          top: auto;
1479          right: 16px;
1480          left: 16px;
1481          bottom: 16px;
1482          width: auto;
1483          grid-template-columns: 1fr;
1484          gap: 12px;
1485          transform: translateY(10px) scale(0.985);
1486        }
1487        .pwa-update-toast__actions {
1488          justify-content: flex-end;
1489        }
1490      }
1491
1492      .auto-refresh {
1493        display: inline-flex;
1494        align-items: center;
1495        gap: 7px;
1496        height: 32px;
1497        padding: 0 11px;
1498        font-family: var(--font-sans);
1499        font-size: 12px;
1500        color: var(--fg-subtle);
1501        background: transparent;
1502        border: 1px solid var(--border-default);
1503        border-radius: 8px;
1504        cursor: pointer;
1505        user-select: none;
1506        white-space: nowrap;
1507        transition:
1508          color 150ms ease,
1509          border-color 150ms ease;
1510      }
1511      .auto-refresh:has(input:disabled) {
1512        opacity: 0.4;
1513        cursor: not-allowed;
1514      }
1515      .auto-refresh input {
1516        accent-color: var(--accent-fg);
1517        margin: 0;
1518      }
1519
1520      .reading-settings {
1521        position: relative;
1522      }
1523      #readingToggleBtn[aria-expanded="true"] {
1524        background: var(--bg-subtle);
1525        border-color: var(--border-strong);
1526      }
1527
1528      .reading-panel {
1529        position: absolute;
1530        top: calc(100% + 6px);
1531        right: 0;
1532        z-index: 50;
1533        width: min(360px, calc(100vw - 32px));
1534        max-height: none;
1535        padding: 18px;
1536        overflow: visible;
1537        background: var(--bg-raised);
1538        border: 1px solid var(--border-soft);
1539        border-radius: 16px;
1540        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06);
1541        animation: panel-enter 200ms ease-out;
1542      }
1543      @media (max-height: 760px) {
1544        .reading-panel {
1545          max-height: calc(100dvh - var(--toolbar-h, 72px) - 24px);
1546          overflow-y: auto;
1547          overscroll-behavior: contain;
1548        }
1549      }
1550      :root[data-theme="dark"] .reading-panel {
1551        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
1552      }
1553      @keyframes panel-enter {
1554        from {
1555          opacity: 0;
1556          transform: translateY(-4px);
1557        }
1558        to {
1559          opacity: 1;
1560          transform: translateY(0);
1561        }
1562      }
1563      .reading-panel-header {
1564        display: flex;
1565        align-items: flex-start;
1566        justify-content: space-between;
1567        gap: 12px;
1568        margin-bottom: 16px;
1569      }
1570      .reading-panel-title {
1571        margin: 0;
1572        font-family: var(--font-serif);
1573        font-size: 1.05rem;
1574        font-weight: 700;
1575        letter-spacing: -0.02em;
1576        line-height: 1.15;
1577      }
1578      .reading-panel-help {
1579        margin: 4px 0 0;
1580        max-width: 22ch;
1581        font-size: 12px;
1582        line-height: 1.45;
1583        color: var(--fg-muted);
1584      }
1585      .reading-preset-custom {
1586        align-self: center;
1587        font-size: 12px;
1588        color: var(--fg-subtle);
1589        white-space: nowrap;
1590      }
1591      .reading-panel-section + .reading-panel-section {
1592        margin-top: 16px;
1593      }
1594      .reading-section-label {
1595        display: block;
1596        margin-bottom: 8px;
1597        font-size: 12px;
1598        font-weight: 600;
1599        letter-spacing: 0.01em;
1600        color: var(--fg-muted);
1601        text-transform: uppercase;
1602      }
1603      .reading-preset-group {
1604        display: grid;
1605        grid-template-columns: repeat(3, minmax(0, 1fr));
1606        gap: 6px;
1607      }
1608      .reading-preset {
1609        justify-content: center;
1610        padding: 0 10px;
1611      }
1612      .reading-preset.is-active {
1613        background: var(--fg-default);
1614        color: var(--bg-canvas);
1615        border-color: transparent;
1616      }
1617      .reading-preset.is-active:hover:not(:disabled) {
1618        background: var(--fg-muted);
1619        color: var(--bg-canvas);
1620        border-color: transparent;
1621      }
1622      .reading-palette-grid {
1623        display: grid;
1624        grid-template-columns: repeat(3, minmax(0, 1fr));
1625        gap: 8px;
1626      }
1627      .reading-palette {
1628        --swatch-paper: #fafaf9;
1629        --swatch-ink: #222221;
1630        --swatch-accent: #5f8f80;
1631        display: grid;
1632        align-content: space-between;
1633        min-width: 0;
1634        min-height: 62px;
1635        padding: 8px;
1636        color: var(--fg-default);
1637        background: var(--bg-raised);
1638        border: 1px solid var(--border-default);
1639        border-radius: 8px;
1640        cursor: pointer;
1641        text-align: left;
1642        transition:
1643          border-color var(--motion-micro) var(--ease-calm),
1644          background-color var(--motion-micro) var(--ease-calm),
1645          box-shadow var(--motion-micro) var(--ease-calm);
1646      }
1647      .reading-palette:hover {
1648        border-color: var(--border-strong);
1649        background: var(--bg-subtle);
1650      }
1651      .reading-palette:focus-visible {
1652        outline: 2px solid var(--accent-fg);
1653        outline-offset: 2px;
1654      }
1655      .reading-palette[aria-checked="true"] {
1656        border-color: var(--fg-default);
1657        box-shadow: 0 0 0 2px var(--selection);
1658      }
1659      .reading-palette__swatch {
1660        position: relative;
1661        display: block;
1662        height: 22px;
1663        margin-bottom: 7px;
1664        overflow: hidden;
1665        background: var(--swatch-paper);
1666        border: 1px solid var(--border-soft);
1667        border-radius: 6px;
1668      }
1669      .reading-palette__swatch::before,
1670      .reading-palette__swatch::after {
1671        content: "";
1672        position: absolute;
1673        inset-block: 0;
1674      }
1675      .reading-palette__swatch::before {
1676        left: 0;
1677        width: 38%;
1678        background: var(--swatch-ink);
1679      }
1680      .reading-palette__swatch::after {
1681        right: 0;
1682        width: 22%;
1683        background: var(--swatch-accent);
1684      }
1685      .reading-palette__label {
1686        display: block;
1687        min-width: 0;
1688        overflow: hidden;
1689        font-family: var(--font-sans);
1690        font-size: 11.5px;
1691        font-weight: 650;
1692        line-height: 1.15;
1693        text-overflow: ellipsis;
1694        white-space: nowrap;
1695      }
1696      .reading-palette[data-reader-palette="ivory"] {
1697        --swatch-paper: #f3ebdd;
1698        --swatch-ink: #1f1a13;
1699        --swatch-accent: #416f62;
1700      }
1701      .reading-palette[data-reader-palette="manilla"] {
1702        --swatch-paper: #ebdbbc;
1703        --swatch-ink: #221a14;
1704        --swatch-accent: #7b3e2d;
1705      }
1706      .reading-palette[data-reader-palette="cloud"] {
1707        --swatch-paper: #bfbfba;
1708        --swatch-ink: #191919;
1709        --swatch-accent: #666663;
1710      }
1711      .reading-palette[data-reader-palette="slate"] {
1712        --swatch-paper: #191919;
1713        --swatch-ink: #f2f0ea;
1714        --swatch-accent: #61aaf2;
1715      }
1716      .reading-palette[data-reader-palette="garden"] {
1717        --swatch-paper: #e8f3de;
1718        --swatch-ink: #001002;
1719        --swatch-accent: #9a344a;
1720      }
1721      @media (max-width: 380px) {
1722        .reading-palette-grid {
1723          grid-template-columns: repeat(2, minmax(0, 1fr));
1724        }
1725      }
1726      .reading-control {
1727        display: grid;
1728        gap: 8px;
1729      }
1730      .reading-control + .reading-control {
1731        margin-top: 12px;
1732      }
1733      .reading-control-head {
1734        display: flex;
1735        align-items: baseline;
1736        justify-content: space-between;
1737        gap: 12px;
1738      }
1739      .reading-control-head label {
1740        font-size: 13px;
1741        font-weight: 600;
1742        color: var(--fg-default);
1743      }
1744      .reading-control-value {
1745        font-size: 12px;
1746        color: var(--fg-muted);
1747        font-variant-numeric: tabular-nums;
1748      }
1749      .reading-range {
1750        --range-progress: 50%;
1751        width: 100%;
1752        height: 4px;
1753        margin: 0;
1754        border: 0;
1755        border-radius: 999px;
1756        outline: none;
1757        cursor: pointer;
1758        appearance: none;
1759        -webkit-appearance: none;
1760        background: linear-gradient(
1761          to right,
1762          var(--accent-fg) 0,
1763          var(--accent-fg) var(--range-progress),
1764          var(--bg-muted) var(--range-progress),
1765          var(--bg-muted) 100%
1766        );
1767      }
1768      .reading-range::-webkit-slider-runnable-track {
1769        height: 4px;
1770        border-radius: 999px;
1771        background: transparent;
1772      }
1773      .reading-range::-webkit-slider-thumb {
1774        margin-top: -6px;
1775        width: 16px;
1776        height: 16px;
1777        border: 1px solid var(--border-strong);
1778        border-radius: 50%;
1779        background: var(--bg-raised);
1780        box-shadow: var(--shadow-md);
1781        appearance: none;
1782        -webkit-appearance: none;
1783      }
1784      .reading-range::-moz-range-track {
1785        height: 4px;
1786        border: 0;
1787        border-radius: 999px;
1788        background: var(--bg-muted);
1789      }
1790      .reading-range::-moz-range-progress {
1791        height: 4px;
1792        border-radius: 999px;
1793        background: var(--accent-fg);
1794      }
1795      .reading-range::-moz-range-thumb {
1796        width: 16px;
1797        height: 16px;
1798        border: 1px solid var(--border-strong);
1799        border-radius: 50%;
1800        background: var(--bg-raised);
1801        box-shadow: var(--shadow-md);
1802      }
1803      .reading-range:focus-visible {
1804        outline: none;
1805      }
1806      .reading-range:focus-visible::-webkit-slider-thumb {
1807        box-shadow:
1808          0 0 0 3px var(--selection),
1809          var(--shadow-md);
1810      }
1811      .reading-range:focus-visible::-moz-range-thumb {
1812        box-shadow:
1813          0 0 0 3px var(--selection),
1814          var(--shadow-md);
1815      }
1816      .reading-actions {
1817        display: flex;
1818        justify-content: flex-end;
1819        gap: 8px;
1820        margin-top: 18px;
1821        padding-top: 14px;
1822        border-top: 1px solid var(--border-soft);
1823      }
1824
1825      .status {
1826        font-family: var(--font-sans);
1827        font-size: 12px;
1828        color: var(--fg-subtle);
1829        max-width: 260px;
1830        overflow: hidden;
1831        text-overflow: ellipsis;
1832        white-space: nowrap;
1833        font-feature-settings: "tnum";
1834      }
1835      .word-count {
1836        font-family: var(--font-sans);
1837        font-size: 12px;
1838        color: var(--fg-subtle);
1839        white-space: nowrap;
1840        font-feature-settings: "tnum";
1841        opacity: 0.7;
1842      }
1843      .word-count::before {
1844        content: "·";
1845        margin: 0 6px;
1846        opacity: 0.5;
1847      }
1848      .status[data-kind="error"] {
1849        color: #b05050;
1850        max-width: 420px;
1851        overflow: visible;
1852        white-space: normal;
1853      }
1854      .status[data-kind="progress"] {
1855        color: var(--fg-muted);
1856        max-width: 420px;
1857        overflow: visible;
1858        white-space: normal;
1859      }
1860
1861      @keyframes btn-spin {
1862        to {
1863          transform: rotate(360deg);
1864        }
1865      }
1866      @media (prefers-reduced-motion: reduce) {
1867        .btn.is-busy::before {
1868          animation: none;
1869        }
1870      }
1871
1872      /* ---------- Empty state ---------- */
1873      .empty-state {
1874        display: flex;
1875        flex-direction: column;
1876        align-items: center;
1877        justify-content: center;
1878        min-height: calc(100vh - 200px);
1879        padding: 48px 24px;
1880        text-align: center;
1881        animation: empty-breathe 600ms ease-out both;
1882      }
1883      @keyframes empty-breathe {
1884        from {
1885          opacity: 0;
1886          transform: scale(0.98);
1887        }
1888        to {
1889          opacity: 1;
1890          transform: scale(1);
1891        }
1892      }
1893      .empty-state-mark {
1894        width: 48px;
1895        height: 48px;
1896        margin-bottom: 28px;
1897        color: var(--border-strong);
1898        opacity: 0.6;
1899      }
1900      .empty-state-mark svg {
1901        width: 100%;
1902        height: 100%;
1903      }
1904      .empty-state-title {
1905        font-family: var(--font-serif);
1906        font-size: 1.35rem;
1907        font-weight: 600;
1908        letter-spacing: -0.02em;
1909        color: var(--fg-muted);
1910        margin: 0 0 10px;
1911      }
1912      .empty-state-hint {
1913        font-family: var(--font-sans);
1914        font-size: 0.85rem;
1915        color: var(--fg-subtle);
1916        line-height: 1.6;
1917        max-width: 28ch;
1918      }
1919      .empty-state-keys {
1920        display: flex;
1921        gap: 20px;
1922        margin-top: 32px;
1923        font-family: var(--font-sans);
1924        font-size: 0.78rem;
1925        color: var(--fg-subtle);
1926      }
1927      .empty-state-keys span {
1928        display: flex;
1929        align-items: center;
1930        gap: 6px;
1931      }
1932      .empty-state-keys kbd {
1933        display: inline-block;
1934        padding: 2px 7px;
1935        font-family: var(--font-mono);
1936        font-size: 0.82em;
1937        color: var(--fg-muted);
1938        background: var(--bg-subtle);
1939        border: 1px solid var(--border-default);
1940        border-bottom-width: 2px;
1941        border-radius: 4px;
1942        line-height: 1.4;
1943      }
1944
1945      /* ---------- Main content area ---------- */
1946      .main {
1947        flex: 1;
1948        min-height: 0;
1949        display: flex;
1950        flex-direction: column;
1951        position: relative;
1952      }
1953
1954      .editor-pane {
1955        display: none;
1956      }
1957      .preview-pane {
1958        display: block;
1959        flex: 1 0 auto;
1960        min-height: calc(100dvh - var(--toolbar-h, 0px));
1961        background: var(--bg-canvas);
1962        color: var(--fg-default);
1963        transition:
1964          background-color var(--motion-small) var(--ease-calm),
1965          color var(--motion-small) var(--ease-calm);
1966      }
1967      .preview-pane ::selection {
1968        background: var(--selection);
1969      }
1970      .preview-pane .markdown-body,
1971      .preview-pane .reader-rail,
1972      .preview-pane .markdown-body pre,
1973      .preview-pane .markdown-body blockquote,
1974      .preview-pane .reader-connections__panel,
1975      .preview-pane .reader-connections__graph {
1976        transition:
1977          background-color var(--motion-small) var(--ease-calm),
1978          border-color var(--motion-small) var(--ease-calm),
1979          color var(--motion-small) var(--ease-calm);
1980      }
1981      .main.edit-mode .editor-pane {
1982        display: block;
1983      }
1984      .main.edit-mode .preview-pane {
1985        display: none;
1986      }
1987
1988      .editor-pane {
1989        flex: 1;
1990        min-height: calc(100vh - 63px);
1991        background: var(--bg-canvas);
1992        color: var(--fg-default);
1993        transition:
1994          background-color var(--motion-small) var(--ease-calm),
1995          color var(--motion-small) var(--ease-calm);
1996      }
1997
1998      #editor {
1999        display: block;
2000        width: 100%;
2001        min-height: calc(100vh - 63px);
2002        padding: 48px max(24px, calc((100% - 820px) / 2)) 120px;
2003        border: 0;
2004        resize: none;
2005        background: transparent;
2006        color: var(--fg-default);
2007        outline: none;
2008        tab-size: 2;
2009        font-family: var(--font-mono);
2010        font-size: 14px;
2011        line-height: 1.65;
2012      }
2013      #editor::placeholder {
2014        color: var(--fg-subtle);
2015        font-style: italic;
2016      }
2017      #editor:focus-visible {
2018        outline: 2px solid var(--accent-fg);
2019        outline-offset: -2px;
2020      }
2021
2022      /* ── Floating editor toolbar — Medium-style, selection-driven ───── */
2023      .editor-toolbar {
2024        position: absolute;
2025        top: 0;
2026        left: 0;
2027        z-index: 1000;
2028        display: flex;
2029        align-items: center;
2030        height: 44px;
2031        background: #1a1a1a;
2032        color: #fff;
2033        border-radius: 6px;
2034        box-shadow:
2035          0 6px 20px rgba(0, 0, 0, 0.28),
2036          0 2px 4px rgba(0, 0, 0, 0.18);
2037        opacity: 0;
2038        transform: translateY(4px);
2039        transition:
2040          opacity 160ms ease,
2041          transform 160ms ease;
2042        pointer-events: none;
2043        user-select: none;
2044        -webkit-user-select: none;
2045        white-space: nowrap;
2046        max-width: calc(100vw - 16px);
2047      }
2048      .editor-toolbar.visible {
2049        opacity: 1;
2050        transform: translateY(0);
2051        pointer-events: auto;
2052      }
2053      .editor-toolbar .et-buttons {
2054        display: flex;
2055        align-items: center;
2056        height: 100%;
2057        overflow-x: auto;
2058        overflow-y: hidden;
2059        scrollbar-width: none;
2060      }
2061      .editor-toolbar .et-buttons::-webkit-scrollbar {
2062        display: none;
2063      }
2064      .editor-toolbar.et-link-mode .et-buttons {
2065        display: none;
2066      }
2067      .editor-toolbar .et-link {
2068        display: none;
2069        align-items: center;
2070        height: 100%;
2071        padding: 0 6px 0 12px;
2072        gap: 6px;
2073      }
2074      .editor-toolbar.et-link-mode .et-link {
2075        display: flex;
2076      }
2077      .editor-toolbar button {
2078        all: unset;
2079        box-sizing: border-box;
2080        display: inline-flex;
2081        align-items: center;
2082        justify-content: center;
2083        height: 44px;
2084        min-width: 38px;
2085        padding: 0 9px;
2086        color: #fff;
2087        cursor: pointer;
2088        flex: 0 0 auto;
2089        transition: color 140ms ease;
2090      }
2091      .editor-toolbar button:hover,
2092      .editor-toolbar button:focus-visible {
2093        color: #6fcf8c;
2094      }
2095      .editor-toolbar button:focus-visible {
2096        outline: 2px solid #6fcf8c;
2097        outline-offset: -4px;
2098        border-radius: 4px;
2099      }
2100      .editor-toolbar .et-divider {
2101        width: 1px;
2102        height: 24px;
2103        background: #3a3a3a;
2104        margin: 0 2px;
2105      }
2106      .editor-toolbar .et-glyph {
2107        font-family: Georgia, "Source Serif 4", "Times New Roman", serif;
2108        font-feature-settings: "kern" 1;
2109        line-height: 1;
2110      }
2111      .editor-toolbar .et-glyph-b {
2112        font-weight: 700;
2113        font-size: 19px;
2114      }
2115      .editor-toolbar .et-glyph-i {
2116        font-style: italic;
2117        font-weight: 500;
2118        font-size: 20px;
2119      }
2120      .editor-toolbar .et-glyph-t1 {
2121        font-weight: 700;
2122        font-size: 22px;
2123      }
2124      .editor-toolbar .et-glyph-t2 {
2125        font-weight: 700;
2126        font-size: 15px;
2127      }
2128      .editor-toolbar .et-glyph-q {
2129        font-weight: 700;
2130        font-size: 26px;
2131        transform: translateY(4px);
2132        display: inline-block;
2133      }
2134      .editor-toolbar .et-glyph-code {
2135        font-family: var(--font-mono);
2136        font-size: 18px;
2137        font-weight: 700;
2138      }
2139      .editor-toolbar .et-link-input {
2140        all: unset;
2141        box-sizing: border-box;
2142        height: 32px;
2143        min-width: 260px;
2144        padding: 0 10px;
2145        color: #fff;
2146        font-family: var(
2147          --font-mono,
2148          ui-monospace,
2149          SFMono-Regular,
2150          Menlo,
2151          monospace
2152        );
2153        font-size: 13px;
2154        background: transparent;
2155      }
2156      .editor-toolbar .et-link-input::placeholder {
2157        color: #888;
2158      }
2159      .editor-toolbar .et-link button {
2160        min-width: 32px;
2161        height: 32px;
2162        font-size: 22px;
2163        padding: 0 8px;
2164      }
2165      .editor-toolbar .et-arrow {
2166        position: absolute;
2167        bottom: -6px;
2168        left: 50%;
2169        width: 0;
2170        height: 0;
2171        margin-left: -6px;
2172        border-left: 6px solid transparent;
2173        border-right: 6px solid transparent;
2174        border-top: 6px solid #1a1a1a;
2175        pointer-events: none;
2176      }
2177      .editor-toolbar.et-arrow-up .et-arrow {
2178        top: -6px;
2179        bottom: auto;
2180        border-top: 0;
2181        border-bottom: 6px solid #1a1a1a;
2182      }
2183      @media (prefers-reduced-motion: reduce) {
2184        .editor-toolbar {
2185          transition: opacity 0s;
2186          transform: none;
2187        }
2188      }
2189
2190      .visual-block-caret-mirror {
2191        position: absolute;
2192        visibility: hidden;
2193        top: 0;
2194        left: -99999px;
2195        white-space: pre-wrap;
2196        overflow-wrap: break-word;
2197        word-wrap: break-word;
2198        pointer-events: none;
2199      }
2200
2201      .visual-block-menu {
2202        position: fixed;
2203        z-index: 1001;
2204        box-sizing: border-box;
2205        width: min(390px, calc(100vw - 20px));
2206        max-height: min(440px, calc(100vh - 32px));
2207        overflow-y: auto;
2208        overscroll-behavior: contain;
2209        padding: 14px 12px 12px;
2210        border: 1px solid var(--border-soft);
2211        border-radius: 14px;
2212        background: color-mix(in srgb, var(--bg-raised) 98%, var(--bg-subtle));
2213        color: var(--fg-default);
2214        box-shadow:
2215          0 24px 70px rgba(0, 0, 0, 0.11),
2216          0 3px 16px rgba(0, 0, 0, 0.05);
2217        font-family: var(--font-sans);
2218        opacity: 0;
2219        transform: translateY(3px) scale(0.99);
2220        transform-origin: top left;
2221        transition:
2222          opacity var(--motion-micro) var(--ease-out),
2223          transform var(--motion-micro) var(--ease-out);
2224      }
2225
2226      :root[data-theme="dark"] .visual-block-menu {
2227        background: color-mix(in srgb, var(--bg-raised) 96%, var(--bg-subtle));
2228        border-color: var(--border-default);
2229        box-shadow:
2230          0 26px 80px rgba(0, 0, 0, 0.52),
2231          0 3px 16px rgba(0, 0, 0, 0.28);
2232      }
2233
2234      .visual-block-menu[hidden] {
2235        display: none;
2236      }
2237
2238      .visual-block-menu.is-open {
2239        opacity: 1;
2240        transform: translateY(0) scale(1);
2241      }
2242
2243      .visual-block-menu__section {
2244        padding: 6px 8px 5px;
2245        color: var(--fg-subtle);
2246        font-size: 16px;
2247        font-weight: 500;
2248        line-height: 1.2;
2249      }
2250
2251      .visual-block-menu__section:not(:first-child) {
2252        margin-top: 7px;
2253        padding-top: 9px;
2254      }
2255
2256      .visual-block-menu__item {
2257        display: grid;
2258        grid-template-columns: 28px minmax(0, 1fr);
2259        align-items: center;
2260        min-height: 38px;
2261        gap: 10px;
2262        padding: 3px 8px;
2263        border-radius: 7px;
2264        border: 1px solid transparent;
2265        cursor: default;
2266        user-select: none;
2267        -webkit-user-select: none;
2268        transition:
2269          background-color var(--motion-micro) var(--ease-out),
2270          border-color var(--motion-micro) var(--ease-out),
2271          transform var(--motion-micro) var(--ease-out);
2272      }
2273
2274      .visual-block-menu__item[aria-selected="true"] {
2275        border-color: transparent;
2276        background: color-mix(in srgb, var(--fg-default) 7%, transparent);
2277      }
2278
2279      .visual-block-menu__item--disclosure .visual-block-menu__title {
2280        color: color-mix(in srgb, var(--fg-default) 88%, var(--fg-muted));
2281        font-weight: 500;
2282      }
2283
2284      .visual-block-menu__item--disclosure .visual-block-menu__icon {
2285        color: color-mix(in srgb, var(--fg-default) 64%, var(--fg-muted));
2286      }
2287
2288      .visual-block-menu__item:active {
2289        transform: translateY(1px);
2290      }
2291
2292      .visual-block-menu__icon {
2293        display: inline-flex;
2294        align-items: center;
2295        justify-content: center;
2296        width: 24px;
2297        height: 24px;
2298        color: color-mix(in srgb, var(--fg-muted) 82%, var(--fg-subtle));
2299      }
2300
2301      .visual-block-menu__icon svg {
2302        width: 20px;
2303        height: 20px;
2304        fill: none;
2305        stroke: currentColor;
2306        stroke-width: 1.7;
2307        stroke-linecap: round;
2308        stroke-linejoin: round;
2309        overflow: visible;
2310        transform: scale(1.08);
2311        transform-origin: center;
2312      }
2313
2314      .visual-block-menu__title {
2315        color: var(--fg-default);
2316        overflow: hidden;
2317        font-size: 17px;
2318        font-weight: 430;
2319        line-height: 1.2;
2320        text-overflow: ellipsis;
2321        white-space: nowrap;
2322      }
2323
2324      .visual-block-menu__description {
2325        display: none;
2326      }
2327
2328      @supports selector(:has(*)) {
2329        .visual-block-menu__item:has(.visual-block-menu__description):not(
2330            :has(.visual-block-menu__icon)
2331          ) {
2332          grid-template-columns: minmax(0, 1fr);
2333        }
2334      }
2335
2336      .visual-block-menu__empty {
2337        padding: 14px 8px 8px;
2338        color: var(--fg-muted);
2339        font-family: var(--font-sans);
2340        font-size: 16px;
2341        line-height: 1.35;
2342      }
2343
2344      @media (max-width: 640px) {
2345        .visual-block-menu {
2346          width: min(340px, calc(100vw - 20px));
2347          max-height: min(380px, calc(100vh - 28px));
2348          padding: 12px 10px 10px;
2349          border-radius: 12px;
2350        }
2351
2352        .visual-block-menu__section {
2353          font-size: 15px;
2354        }
2355
2356        .visual-block-menu__item {
2357          grid-template-columns: 26px minmax(0, 1fr);
2358          min-height: 36px;
2359          gap: 9px;
2360          padding-inline: 6px;
2361        }
2362
2363        .visual-block-menu__icon {
2364          width: 22px;
2365          height: 22px;
2366        }
2367
2368        .visual-block-menu__icon svg {
2369          width: 19px;
2370          height: 19px;
2371        }
2372
2373        .visual-block-menu__title {
2374          font-size: 16px;
2375        }
2376      }
2377
2378      @media (max-height: 760px) {
2379        .visual-block-menu {
2380          max-height: min(400px, calc(100vh - 28px));
2381          padding: 12px 10px 10px;
2382          border-radius: 12px;
2383        }
2384
2385        .visual-block-menu__section {
2386          padding: 5px 7px 4px;
2387          font-size: 15px;
2388        }
2389
2390        .visual-block-menu__section:not(:first-child) {
2391          margin-top: 5px;
2392          padding-top: 7px;
2393        }
2394
2395        .visual-block-menu__item {
2396          min-height: 34px;
2397          padding-block: 2px;
2398        }
2399
2400        .visual-block-menu__title {
2401          font-size: 16px;
2402        }
2403      }
2404
2405      @media (prefers-reduced-motion: reduce) {
2406        .visual-block-menu,
2407        .visual-block-menu__item {
2408          transition: opacity 0s;
2409          transform: none;
2410        }
2411      }
2412
2413      .preview-pane {
2414        padding: 72px max(24px, calc((100% - 740px) / 2)) 128px;
2415      }
2416      .preview-pane.has-reader-rail {
2417        display: grid;
2418        grid-template-columns: minmax(0, var(--reading-measure)) minmax(
2419            220px,
2420            264px
2421          );
2422        justify-content: center;
2423        align-items: start;
2424        column-gap: clamp(32px, 5vw, 76px);
2425        padding-inline: 32px;
2426      }
2427      .preview-pane.has-reader-rail > * {
2428        min-width: 0;
2429      }
2430      .preview-pane.has-reader-rail .markdown-body {
2431        width: 100%;
2432        max-width: min(var(--reading-measure), 100%);
2433        margin: 0;
2434      }
2435      .preview-pane.is-empty-state {
2436        display: flex;
2437        align-items: center;
2438        justify-content: center;
2439        padding: 0;
2440        overflow: auto;
2441      }
2442      .preview-pane.is-empty-state .empty-state {
2443        min-height: 0;
2444        width: min(100%, 680px);
2445      }
2446      .preview-pane:focus-visible {
2447        outline: 2px solid var(--accent-fg);
2448        outline-offset: -2px;
2449      }
2450
2451      .reader-rail {
2452        --reader-rail-top: calc(var(--toolbar-h, 52px) + 42px);
2453        position: sticky;
2454        top: var(--reader-rail-top);
2455        display: flex;
2456        flex-direction: column;
2457        width: 100%;
2458        max-width: 248px;
2459        max-height: calc(100dvh - var(--reader-rail-top) - 28px);
2460        min-height: 0;
2461        padding-bottom: 28px;
2462        overflow: hidden;
2463        box-sizing: border-box;
2464        font-family: var(--font-sans);
2465        color: var(--fg-default);
2466      }
2467      .reader-rail.has-reader-connections {
2468        max-height: calc(100dvh - var(--reader-rail-top) - 28px);
2469      }
2470      .reader-rail.has-reader-connections > .reader-rail__section {
2471        min-height: 0;
2472      }
2473      .reader-outline-section,
2474      .reader-rail.has-reader-connections > .reader-outline-section {
2475        display: flex;
2476        flex-direction: column;
2477        flex: 1 1 auto;
2478        min-height: 0;
2479      }
2480      .reader-rail.has-reader-connections > .reader-connections {
2481        flex-shrink: 0;
2482      }
2483      .reader-rail[hidden] {
2484        display: none;
2485      }
2486      .reader-rail__section + .reader-rail__section {
2487        margin-top: 22px;
2488        padding-top: 20px;
2489        border-top: 1px solid var(--border-soft);
2490      }
2491      .reader-rail__header,
2492      .reader-connections__header {
2493        display: flex;
2494        align-items: center;
2495        justify-content: space-between;
2496        gap: 12px;
2497        margin-bottom: 8px;
2498      }
2499      .reader-rail__title,
2500      .reader-connections__title {
2501        margin: 0;
2502        color: var(--fg-muted);
2503        font-family: var(--font-sans);
2504        font-size: 10.5px;
2505        font-weight: 650;
2506        letter-spacing: 0.13em;
2507        line-height: 1.2;
2508        text-transform: uppercase;
2509      }
2510      .reader-outline-toggle {
2511        display: none;
2512      }
2513      .reader-outline-section {
2514        min-height: 0;
2515      }
2516      .reader-outline {
2517        display: grid;
2518        gap: 0;
2519        margin: 0;
2520        max-height: min(64dvh, calc(100dvh - var(--reader-rail-top) - 74px));
2521        min-height: 0;
2522        padding: 0 4px 0 0;
2523        list-style: none;
2524        overflow-x: hidden;
2525        overflow-y: auto;
2526        overscroll-behavior: contain;
2527        scrollbar-gutter: stable;
2528        scrollbar-width: thin;
2529        scrollbar-color: transparent transparent;
2530      }
2531      .reader-rail.has-reader-connections .reader-outline {
2532        max-height: min(42dvh, 360px);
2533      }
2534      .reader-outline__item {
2535        min-width: 0;
2536      }
2537      .reader-outline::-webkit-scrollbar {
2538        width: 5px;
2539      }
2540      .reader-outline::-webkit-scrollbar-track {
2541        background: transparent;
2542      }
2543      .reader-outline::-webkit-scrollbar-thumb {
2544        border-radius: 999px;
2545        background: transparent;
2546      }
2547      .reader-outline:focus-within,
2548      .reader-outline-section:focus-within .reader-outline {
2549        scrollbar-color: color-mix(in srgb, var(--fg-subtle) 34%, transparent)
2550          transparent;
2551      }
2552      .reader-outline:focus-within::-webkit-scrollbar-thumb,
2553      .reader-outline-section:focus-within
2554        .reader-outline::-webkit-scrollbar-thumb {
2555        background: color-mix(in srgb, var(--fg-subtle) 34%, transparent);
2556      }
2557      @media (hover: hover) and (pointer: fine) {
2558        .reader-outline:hover,
2559        .reader-outline-section:hover .reader-outline {
2560          scrollbar-color: color-mix(in srgb, var(--fg-subtle) 34%, transparent)
2561            transparent;
2562        }
2563        .reader-outline:hover::-webkit-scrollbar-thumb,
2564        .reader-outline-section:hover .reader-outline::-webkit-scrollbar-thumb {
2565          background: color-mix(in srgb, var(--fg-subtle) 34%, transparent);
2566        }
2567      }
2568      .reader-outline__link {
2569        display: block;
2570        box-sizing: border-box;
2571        max-width: 100%;
2572        padding: 4.5px 0 4.5px 10px;
2573        border-left: 2px solid transparent;
2574        color: var(--fg-muted);
2575        font-size: 12.5px;
2576        line-height: 1.25;
2577        text-decoration: none;
2578        transition:
2579          border-color var(--motion-micro) var(--ease-calm),
2580          color var(--motion-micro) var(--ease-calm);
2581      }
2582      .reader-outline__text {
2583        display: -webkit-box;
2584        overflow: hidden;
2585        overflow-wrap: anywhere;
2586        -webkit-box-orient: vertical;
2587        -webkit-line-clamp: 2;
2588      }
2589      .reader-outline__link[data-level="3"] {
2590        padding-left: 18px;
2591        color: var(--fg-subtle);
2592        font-size: 12px;
2593      }
2594      .reader-outline__link[data-level="4"] {
2595        padding-left: 26px;
2596        color: var(--fg-subtle);
2597        font-size: 11.5px;
2598      }
2599      .reader-outline__link:hover,
2600      .reader-outline__link:focus-visible,
2601      .reader-outline__link[aria-current="true"] {
2602        border-left-color: var(--accent-fg);
2603        color: var(--fg-default);
2604      }
2605      .reader-outline__link:focus-visible {
2606        outline: 2px solid var(--accent-fg);
2607        outline-offset: 3px;
2608      }
2609      .reader-connections__panel {
2610        border: 1px solid var(--border-soft);
2611        border-radius: 8px;
2612        background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
2613        box-shadow: var(--shadow-sm);
2614        overflow: hidden;
2615      }
2616      :root[data-theme="dark"] .reader-connections__panel {
2617        background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
2618      }
2619      .reader-connections__graph {
2620        display: block;
2621        width: 100%;
2622        aspect-ratio: 1.35;
2623        touch-action: manipulation;
2624        background: var(--bg-subtle);
2625      }
2626      .reader-connections__edge {
2627        stroke: color-mix(in srgb, var(--fg-subtle) 42%, transparent);
2628        stroke-width: 1;
2629      }
2630      .reader-connections__edge[data-direction="inbound"] {
2631        stroke-dasharray: 3 4;
2632      }
2633      .reader-connections__point {
2634        outline: none;
2635        transform-box: fill-box;
2636        transform-origin: center;
2637        transition:
2638          opacity var(--motion-micro) var(--ease-calm),
2639          transform var(--motion-small) var(--ease-calm);
2640        will-change: transform;
2641      }
2642      .reader-connections__point[data-openable="true"] {
2643        cursor: pointer;
2644      }
2645      .reader-connections__node {
2646        fill: color-mix(in srgb, var(--fg-muted) 46%, var(--bg-raised));
2647        stroke: var(--bg-raised);
2648        stroke-width: 2;
2649        transition:
2650          stroke-width var(--motion-micro) var(--ease-calm),
2651          opacity var(--motion-micro) var(--ease-calm);
2652      }
2653      .reader-connections__node[data-direction="outbound"] {
2654        fill: color-mix(in srgb, var(--accent-fg) 68%, #6b8fb9);
2655      }
2656      .reader-connections__node[data-direction="inbound"] {
2657        fill: color-mix(in srgb, var(--fg-muted) 62%, #8fb38e);
2658      }
2659      .reader-connections__node[data-direction="bidirectional"] {
2660        fill: color-mix(in srgb, var(--accent-fg) 52%, #caa35c);
2661      }
2662      .reader-connections__node[data-related="true"] {
2663        r: 5.5px;
2664      }
2665      .reader-connections__point[data-openable="true"]:hover
2666        .reader-connections__node,
2667      .reader-connections__point[data-openable="true"]:focus-visible
2668        .reader-connections__node {
2669        stroke-width: 3;
2670      }
2671      .reader-connections__point:focus-visible .reader-connections__node {
2672        stroke: var(--accent-fg);
2673      }
2674      .reader-connections__root {
2675        fill: var(--accent-fg);
2676        stroke: var(--bg-raised);
2677        stroke-width: 2.5;
2678      }
2679      .reader-connections__root-label {
2680        fill: var(--fg-muted);
2681        font-family: var(--font-sans);
2682        font-size: 9.5px;
2683        font-weight: 540;
2684        letter-spacing: 0.005em;
2685        paint-order: stroke;
2686        stroke: var(--bg-subtle);
2687        stroke-linejoin: round;
2688        stroke-width: 5px;
2689        transition: opacity var(--motion-micro) var(--ease-calm);
2690      }
2691      .reader-connections__meta {
2692        display: grid;
2693        grid-template-columns: repeat(3, minmax(0, 1fr));
2694        border-top: 1px solid var(--border-soft);
2695      }
2696      .reader-connections__stat {
2697        display: grid;
2698        gap: 2px;
2699        padding: 8px 7px;
2700        text-align: center;
2701      }
2702      .reader-connections__stat + .reader-connections__stat {
2703        border-left: 1px solid var(--border-soft);
2704      }
2705      .reader-connections__stat strong {
2706        color: var(--fg-default);
2707        font-family: var(--font-sans);
2708        font-size: 12px;
2709        font-weight: 600;
2710        line-height: 1;
2711        font-variant-numeric: tabular-nums;
2712        transition: color var(--motion-micro) var(--ease-calm);
2713      }
2714      .reader-connections__stat span {
2715        color: var(--fg-subtle);
2716        font-size: 10px;
2717        line-height: 1.2;
2718      }
2719      .reader-connections__stat[data-empty="true"] {
2720        opacity: 0.45;
2721      }
2722      .reader-connections__stat[data-empty="true"] strong {
2723        color: var(--fg-subtle);
2724        font-weight: 500;
2725      }
2726      .reader-connections__list {
2727        display: grid;
2728        gap: 0;
2729        margin-top: 9px;
2730      }
2731      .reader-connection {
2732        display: grid;
2733        grid-template-columns: 7px minmax(0, 1fr);
2734        gap: 8px;
2735        width: 100%;
2736        padding: 6px 0;
2737        border: 0;
2738        border-top: 1px solid var(--border-soft);
2739        border-radius: 0;
2740        color: inherit;
2741        background: transparent;
2742        text-align: left;
2743      }
2744      button.reader-connection {
2745        cursor: pointer;
2746      }
2747      button.reader-connection:hover .reader-connection__title,
2748      button.reader-connection:focus-visible .reader-connection__title {
2749        color: var(--accent-fg);
2750      }
2751      button.reader-connection:focus-visible {
2752        outline: 2px solid var(--accent-fg);
2753        outline-offset: 3px;
2754      }
2755      .reader-connection__dot {
2756        width: 6px;
2757        height: 6px;
2758        margin-top: 5px;
2759        border-radius: 50%;
2760        background: color-mix(in srgb, var(--fg-muted) 52%, var(--bg-raised));
2761      }
2762      .reader-connection__dot[data-direction="outbound"] {
2763        background: color-mix(in srgb, var(--accent-fg) 68%, #6b8fb9);
2764      }
2765      .reader-connection__dot[data-direction="inbound"] {
2766        background: color-mix(in srgb, var(--fg-muted) 62%, #8fb38e);
2767      }
2768      .reader-connection__dot[data-direction="bidirectional"] {
2769        background: color-mix(in srgb, var(--accent-fg) 52%, #caa35c);
2770      }
2771      .reader-connection__title {
2772        display: block;
2773        overflow: hidden;
2774        color: var(--fg-default);
2775        font-size: 12.5px;
2776        font-weight: 540;
2777        line-height: 1.28;
2778        text-overflow: ellipsis;
2779        white-space: nowrap;
2780        transition: color var(--motion-micro) var(--ease-calm);
2781      }
2782      .reader-connection__detail {
2783        display: block;
2784        margin-top: 1px;
2785        overflow: hidden;
2786        color: var(--fg-subtle);
2787        font-size: 11px;
2788        line-height: 1.25;
2789        text-overflow: ellipsis;
2790        white-space: nowrap;
2791      }
2792      .reader-connections__more {
2793        margin: 7px 0 0;
2794        color: var(--fg-subtle);
2795        font-size: 11px;
2796        line-height: 1.4;
2797      }
2798
2799      @media (max-width: 900px) {
2800        .preview-pane.has-reader-rail {
2801          display: block;
2802          padding: 72px max(24px, calc((100% - 740px) / 2)) 128px;
2803        }
2804        .preview-pane.has-reader-rail .markdown-body {
2805          margin: 0 auto;
2806        }
2807        .reader-rail {
2808          position: static;
2809          max-width: min(var(--reading-measure), 100%);
2810          max-height: none;
2811          margin: 48px auto 0;
2812          padding-bottom: 0;
2813          overflow: visible;
2814        }
2815        .reader-rail.has-reader-connections {
2816          max-height: none;
2817          padding-bottom: 0;
2818        }
2819        .reader-outline,
2820        .reader-rail.has-reader-connections .reader-outline {
2821          max-height: none;
2822          padding-right: 0;
2823          overflow: visible;
2824          scrollbar-gutter: auto;
2825        }
2826      }
2827
2828      @media (min-width: 721px) and (max-width: 900px) {
2829        .reader-rail.has-reader-connections {
2830          display: grid;
2831          grid-template-columns: minmax(0, 1fr) minmax(240px, 0.82fr);
2832          align-items: start;
2833          column-gap: clamp(24px, 5vw, 40px);
2834          padding-top: 26px;
2835          border-top: 1px solid var(--border-soft);
2836        }
2837        .reader-rail.has-reader-connections
2838          .reader-rail__section
2839          + .reader-rail__section {
2840          margin-top: 0;
2841          padding-top: 0;
2842          border-top: 0;
2843        }
2844      }
2845
2846      @media (max-width: 720px) {
2847        .preview-pane.has-reader-rail {
2848          padding: 40px 22px 96px;
2849        }
2850        .reader-rail {
2851          margin-top: 38px;
2852        }
2853        .reader-rail__section + .reader-rail__section {
2854          margin-top: 18px;
2855          padding-top: 18px;
2856        }
2857        .reader-outline-section .reader-rail__header {
2858          display: none;
2859        }
2860        .reader-outline-toggle {
2861          display: flex;
2862          align-items: center;
2863          justify-content: space-between;
2864          gap: 14px;
2865          width: 100%;
2866          min-height: 42px;
2867          padding: 0;
2868          border: 0;
2869          border-radius: 0;
2870          color: var(--fg-default);
2871          background: transparent;
2872          font: inherit;
2873          text-align: left;
2874          cursor: pointer;
2875        }
2876        .reader-outline-toggle:focus-visible {
2877          outline: 2px solid var(--accent-fg);
2878          outline-offset: 4px;
2879        }
2880        .reader-outline-toggle__kicker {
2881          display: block;
2882          color: var(--fg-muted);
2883          font-size: 10.5px;
2884          font-weight: 650;
2885          letter-spacing: 0.13em;
2886          line-height: 1.2;
2887          text-transform: uppercase;
2888        }
2889        .reader-outline-toggle__summary {
2890          display: block;
2891          margin-top: 4px;
2892          color: var(--fg-subtle);
2893          font-size: 12px;
2894          line-height: 1.2;
2895        }
2896        .reader-outline-toggle__chevron {
2897          width: 8px;
2898          height: 8px;
2899          margin-right: 2px;
2900          border-right: 1.5px solid currentColor;
2901          border-bottom: 1.5px solid currentColor;
2902          color: var(--fg-muted);
2903          transform: rotate(45deg);
2904          transition: transform var(--motion-small) var(--ease-calm);
2905        }
2906        .reader-outline-section.is-open .reader-outline-toggle__chevron {
2907          transform: rotate(225deg);
2908        }
2909        .reader-outline-section .reader-outline {
2910          display: none;
2911          margin-top: 10px;
2912          padding-top: 10px;
2913          border-top: 1px solid var(--border-soft);
2914        }
2915        .reader-rail.has-reader-connections
2916          .reader-outline-section
2917          .reader-outline {
2918          max-height: min(46dvh, 360px);
2919          overflow-y: auto;
2920          scrollbar-gutter: stable;
2921        }
2922        .reader-outline-section.is-open .reader-outline {
2923          display: grid;
2924        }
2925        .reader-outline__link {
2926          padding-block: 6px;
2927          font-size: 13px;
2928        }
2929        .reader-outline__link[data-level="3"] {
2930          font-size: 12.5px;
2931        }
2932        .reader-outline__link[data-level="4"] {
2933          font-size: 12px;
2934        }
2935        .reader-connections__graph {
2936          height: clamp(150px, 42vw, 210px);
2937          aspect-ratio: auto;
2938        }
2939        .reader-connections__panel {
2940          box-shadow: none;
2941        }
2942        .reader-connections__meta {
2943          border-top: 1px solid var(--border-soft);
2944        }
2945      }
2946
2947      /* ============================================================
2948       Markdown body — editorial serif typography.
2949       The whole point of this app.
2950       ============================================================ */
2951      .markdown-body {
2952        max-width: min(var(--reading-measure), 100%);
2953        margin: 0 auto;
2954        font-family: var(--font-serif);
2955        font-size: var(--reading-font-size);
2956        /* Optical size axis — Source Serif 4 has variable optical sizing,
2957         this tells it to render as a reading-weight design. */
2958        font-optical-sizing: auto;
2959        font-variation-settings: "opsz" 20;
2960        line-height: var(--reading-line-height);
2961        color: var(--fg-default);
2962        word-wrap: break-word;
2963        /* Better text shaping for long reads */
2964        font-feature-settings: "kern", "liga", "calt", "onum";
2965        hanging-punctuation: first last;
2966      }
2967
2968      .markdown-body > *:first-child {
2969        margin-top: 0 !important;
2970      }
2971      .markdown-body > *:last-child {
2972        margin-bottom: 0 !important;
2973      }
2974
2975      .markdown-body p {
2976        margin: 0 0 1.15em;
2977      }
2978
2979      /* Drop-cap-adjacent first-paragraph lift, just a touch */
2980      .markdown-body > p:first-of-type {
2981        font-size: 1.05em;
2982        color: var(--fg-default);
2983      }
2984
2985      /* Headings — Source Serif with tighter leading, negative tracking */
2986      .markdown-body h1,
2987      .markdown-body h2,
2988      .markdown-body h3,
2989      .markdown-body h4,
2990      .markdown-body h5,
2991      .markdown-body h6 {
2992        font-family: var(--font-serif);
2993        font-weight: 600;
2994        line-height: 1.2;
2995        letter-spacing: -0.018em;
2996        color: var(--fg-heading, var(--fg-default));
2997        margin: 1.5em 0 0.45em;
2998        font-variation-settings: "opsz" 36;
2999      }
3000
3001      /* ── Heading anchor links ────────────────────────────────
3002         A subtle `#` link appears to the left of each heading on
3003         hover, enabling deep-linking without visual noise. Uses
3004         absolute positioning so it never shifts the heading text.
3005         Anchor is a real <a> (wired up in JS) so it works with
3006         keyboard, middle-click, and copy-link-address. */
3007      .preview-pane .markdown-body :is(h1, h2, h3, h4, h5, h6) {
3008        position: relative;
3009      }
3010      .heading-anchor {
3011        position: absolute;
3012        left: -1.15em;
3013        top: 0;
3014        display: inline-flex;
3015        align-items: baseline;
3016        color: var(--fg-subtle);
3017        font-family: var(--font-sans);
3018        font-weight: 400;
3019        font-size: 0.7em;
3020        text-decoration: none;
3021        opacity: 0;
3022        transform: translateX(4px);
3023        transition:
3024          opacity var(--motion-small) var(--ease-calm),
3025          transform var(--motion-small) var(--ease-out),
3026          color var(--motion-micro) var(--ease-calm);
3027        line-height: inherit;
3028        padding: 0 0.25em;
3029        border-radius: 4px;
3030      }
3031      .preview-pane
3032        .markdown-body
3033        :is(h1, h2, h3, h4, h5, h6):hover
3034        .heading-anchor,
3035      .heading-anchor:focus-visible {
3036        opacity: 0.7;
3037        transform: translateX(0);
3038      }
3039      .heading-anchor:hover {
3040        opacity: 1 !important;
3041        color: var(--accent-fg);
3042      }
3043      .heading-anchor:focus-visible {
3044        outline: 2px solid var(--accent-fg);
3045        outline-offset: 2px;
3046      }
3047      /* Small viewports: park the anchor inline at the end of the
3048         heading rather than floating in a missing left margin. */
3049      @media (max-width: 900px) {
3050        .heading-anchor {
3051          position: static;
3052          display: inline-block;
3053          margin-left: 0.35em;
3054          transform: none;
3055        }
3056      }
3057
3058      /* ── Code block chrome (language label + copy) ───────────
3059         A floating pill in the top-right of each <pre> that shows
3060         the language and a Copy button. Both hidden by default,
3061         fade/slide in on hover or keyboard focus within the block.
3062         Positioned so it never overlaps the first line of code. */
3063      .preview-pane .markdown-body pre {
3064        position: relative;
3065      }
3066      .code-chrome {
3067        position: absolute;
3068        top: 8px;
3069        right: 8px;
3070        display: flex;
3071        align-items: center;
3072        gap: 4px;
3073        opacity: 0;
3074        transform: translateX(6px);
3075        pointer-events: none;
3076        transition:
3077          opacity var(--motion-small) var(--ease-calm),
3078          transform var(--motion-small) var(--ease-out);
3079      }
3080      .preview-pane .markdown-body pre:hover .code-chrome,
3081      .preview-pane .markdown-body pre:focus-within .code-chrome {
3082        opacity: 1;
3083        transform: translateX(0);
3084        pointer-events: auto;
3085      }
3086      .code-lang-badge {
3087        display: inline-flex;
3088        align-items: center;
3089        height: 22px;
3090        padding: 0 8px;
3091        font-family: var(--font-sans);
3092        font-size: 10.5px;
3093        font-weight: 500;
3094        color: var(--fg-muted);
3095        background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
3096        border: 1px solid var(--border-soft);
3097        border-radius: 6px;
3098        text-transform: lowercase;
3099        letter-spacing: 0.03em;
3100        backdrop-filter: blur(4px);
3101        -webkit-backdrop-filter: blur(4px);
3102      }
3103      .code-copy-btn {
3104        display: inline-flex;
3105        align-items: center;
3106        justify-content: center;
3107        width: 26px;
3108        height: 22px;
3109        padding: 0;
3110        color: var(--fg-muted);
3111        background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
3112        border: 1px solid var(--border-soft);
3113        border-radius: 6px;
3114        cursor: pointer;
3115        transition:
3116          color var(--motion-micro) var(--ease-calm),
3117          background var(--motion-micro) var(--ease-calm);
3118        backdrop-filter: blur(4px);
3119        -webkit-backdrop-filter: blur(4px);
3120      }
3121      .code-copy-btn:hover {
3122        color: var(--fg-default);
3123        background: color-mix(in srgb, var(--bg-raised) 100%, transparent);
3124      }
3125      .code-copy-btn:focus-visible {
3126        outline: 2px solid var(--accent-fg);
3127        outline-offset: 1px;
3128      }
3129      .code-copy-btn svg {
3130        width: 12px;
3131        height: 12px;
3132      }
3133      .code-copy-btn.is-copied {
3134        color: var(--accent-fg);
3135      }
3136
3137      /* ── Reading breadcrumb ──────────────────────────────────
3138         Quiet "where am I" signal. Updates as the user scrolls
3139         through sections. Sits directly under the toolbar, fades
3140         in only once the user has scrolled past the first heading.
3141         Hidden in edit/library/mindmap modes (chrome noise). */
3142      #readingBreadcrumb {
3143        position: fixed;
3144        top: calc(var(--toolbar-h, 52px) + 4px);
3145        left: 50%;
3146        transform: translateX(-50%) translateY(-4px);
3147        z-index: 4;
3148        display: inline-flex;
3149        align-items: center;
3150        gap: 6px;
3151        max-width: min(720px, calc(100vw - 48px));
3152        padding: 6px 12px;
3153        font-family: var(--font-sans);
3154        font-size: 11.5px;
3155        color: var(--fg-muted);
3156        background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
3157        border: 1px solid var(--border-soft);
3158        border-radius: 999px;
3159        box-shadow: var(--shadow-sm);
3160        backdrop-filter: blur(10px);
3161        -webkit-backdrop-filter: blur(10px);
3162        opacity: 0;
3163        pointer-events: none;
3164        transition:
3165          opacity var(--motion-small) var(--ease-calm),
3166          transform var(--motion-small) var(--ease-out);
3167        overflow: hidden;
3168        text-overflow: ellipsis;
3169        white-space: nowrap;
3170      }
3171      #readingBreadcrumb.is-visible {
3172        opacity: 1;
3173        transform: translateX(-50%) translateY(0);
3174      }
3175      #readingBreadcrumb .sep {
3176        color: var(--fg-subtle);
3177        margin: 0 2px;
3178      }
3179      #readingBreadcrumb .crumb {
3180        color: var(--fg-muted);
3181      }
3182      #readingBreadcrumb .crumb:last-child {
3183        color: var(--fg-default);
3184      }
3185      body.edit-mode-active #readingBreadcrumb,
3186      body.mindmap-open #readingBreadcrumb,
3187      body.library-open #readingBreadcrumb {
3188        opacity: 0 !important;
3189      }
3190
3191      /* ── Link preview card ───────────────────────────────────
3192         Appears on long hover (~500ms) of an <a> inside the doc.
3193         For internal anchors, surfaces the target heading and
3194         first paragraph. For external, shows the domain + path.
3195         Positioned near the hovered link; JS handles placement
3196         to keep it in-viewport. */
3197      .link-preview {
3198        position: fixed;
3199        z-index: 50;
3200        max-width: 340px;
3201        padding: 12px 14px;
3202        font-family: var(--font-sans);
3203        font-size: 12.5px;
3204        line-height: 1.45;
3205        color: var(--fg-default);
3206        background: var(--bg-raised);
3207        border: 1px solid var(--border-soft);
3208        border-radius: 10px;
3209        box-shadow: var(--shadow-md);
3210        opacity: 0;
3211        transform: translateY(4px) scale(0.98);
3212        pointer-events: none;
3213        transition:
3214          opacity var(--motion-small) var(--ease-calm),
3215          transform var(--motion-small) var(--ease-out);
3216      }
3217      .link-preview.is-visible {
3218        opacity: 1;
3219        transform: translateY(0) scale(1);
3220      }
3221      .link-preview .lp-title {
3222        font-weight: 600;
3223        color: var(--fg-default);
3224        margin-bottom: 4px;
3225        overflow: hidden;
3226        text-overflow: ellipsis;
3227        white-space: nowrap;
3228      }
3229      .link-preview .lp-excerpt {
3230        color: var(--fg-muted);
3231        font-size: 11.5px;
3232        display: -webkit-box;
3233        -webkit-line-clamp: 3;
3234        -webkit-box-orient: vertical;
3235        overflow: hidden;
3236      }
3237      .link-preview .lp-url {
3238        margin-top: 6px;
3239        font-family: var(--font-mono, ui-monospace, monospace);
3240        font-size: 10.5px;
3241        color: var(--fg-subtle);
3242        overflow: hidden;
3243        text-overflow: ellipsis;
3244        white-space: nowrap;
3245      }
3246      .link-preview .lp-kind {
3247        display: inline-block;
3248        font-size: 9.5px;
3249        text-transform: uppercase;
3250        letter-spacing: 0.08em;
3251        color: var(--fg-subtle);
3252        margin-bottom: 4px;
3253      }
3254
3255      /* Scroll-reveal for headings — only applies when JS wires up
3256         the IntersectionObserver (body class `js-scroll-reveal` is
3257         set from script). If JS fails for any reason, content stays
3258         visible by default. Gate on `.preview-pane` so the editor
3259         view and mindmap aren't affected.  */
3260      html.js-scroll-reveal
3261        .preview-pane
3262        .markdown-body
3263        :is(h1, h2, h3, h4):not([data-revealed]) {
3264        opacity: 0;
3265        transform: translateY(8px);
3266      }
3267      html.js-scroll-reveal .preview-pane .markdown-body :is(h1, h2, h3, h4) {
3268        transition:
3269          opacity var(--motion-page) var(--ease-out),
3270          transform var(--motion-page) var(--ease-out);
3271      }
3272
3273      .markdown-body h1 {
3274        font-size: 2.4em;
3275        font-weight: 700;
3276        letter-spacing: -0.025em;
3277        margin-top: 0;
3278        margin-bottom: 0.35em;
3279      }
3280      .markdown-body h2 {
3281        font-size: 1.7em;
3282        margin-top: 1.7em;
3283        padding-bottom: 0.3em;
3284        border-bottom: 1px solid var(--border-soft);
3285      }
3286      .markdown-body h3 {
3287        font-size: 1.32em;
3288        margin-top: 1.4em;
3289      }
3290      .markdown-body h4 {
3291        font-size: 1.12em;
3292      }
3293      .markdown-body h5 {
3294        font-size: 1em;
3295        text-transform: uppercase;
3296        letter-spacing: 0.04em;
3297        color: var(--fg-muted);
3298      }
3299      .markdown-body h6 {
3300        font-size: 0.9em;
3301        color: var(--fg-muted);
3302      }
3303
3304      .markdown-body h1 + p,
3305      .markdown-body h2 + p,
3306      .markdown-body h3 + p {
3307        margin-top: 0;
3308      }
3309
3310      /* Links — subtle underline offset, ink color */
3311      .markdown-body a {
3312        color: var(--reader-link, var(--accent-fg));
3313        text-decoration: underline;
3314        text-decoration-thickness: 1px;
3315        text-decoration-color: color-mix(
3316          in srgb,
3317          currentColor 30%,
3318          transparent
3319        );
3320        text-underline-offset: 3px;
3321        transition:
3322          text-decoration-color 180ms ease,
3323          color 180ms ease;
3324      }
3325      .markdown-body a:hover {
3326        color: var(--accent-hover);
3327        text-decoration-color: currentColor;
3328      }
3329
3330      .markdown-body strong {
3331        font-weight: 700;
3332        color: var(--fg-default);
3333      }
3334
3335      .markdown-body em {
3336        font-style: italic;
3337      }
3338
3339      .markdown-body hr {
3340        height: 1px;
3341        margin: 2em auto;
3342        width: 64px;
3343        background: var(--border-default);
3344        border: 0;
3345      }
3346
3347      .markdown-body ul,
3348      .markdown-body ol {
3349        margin: 0 0 1.15em;
3350        padding-left: 1.6em;
3351      }
3352      .markdown-body ul ul,
3353      .markdown-body ul ol,
3354      .markdown-body ol ul,
3355      .markdown-body ol ol {
3356        margin: 0.3em 0;
3357      }
3358
3359      .markdown-body li {
3360        margin: 0.3em 0;
3361      }
3362      .markdown-body li::marker {
3363        color: var(--fg-subtle);
3364      }
3365      .markdown-body li > p {
3366        margin: 0.5em 0;
3367      }
3368
3369      /* Blockquote — warm, editorial pull-quote feel */
3370      .markdown-body blockquote {
3371        margin: 1.5em 0;
3372        padding: 0.4em 1.2em;
3373        color: var(--fg-muted);
3374        font-style: italic;
3375        border-left: 3px solid var(--blockquote-border);
3376        background: var(--blockquote-bg);
3377        border-radius: 0 4px 4px 0;
3378      }
3379      .markdown-body blockquote > :first-child {
3380        margin-top: 0;
3381      }
3382      .markdown-body blockquote > :last-child {
3383        margin-bottom: 0;
3384      }
3385      .markdown-body blockquote p {
3386        font-size: 0.98em;
3387      }
3388
3389      .markdown-body .markdown-alert,
3390      .chat-msg.markdown-rendered .markdown-alert {
3391        --alert-accent: var(--alert-note);
3392        margin: 1.5em 0;
3393        padding: 0.95em 1.1em 1em;
3394        color: var(--fg-default);
3395        background: color-mix(
3396          in srgb,
3397          var(--alert-accent) 7%,
3398          var(--bg-raised)
3399        );
3400        border: 1px solid
3401          color-mix(in srgb, var(--alert-accent) 18%, var(--border-soft));
3402        border-left: 4px solid
3403          color-mix(in srgb, var(--alert-accent) 62%, transparent);
3404        border-radius: 10px;
3405        box-shadow: var(--shadow-sm);
3406      }
3407      .markdown-body .markdown-alert[data-alert-kind="note"],
3408      .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="note"] {
3409        --alert-accent: var(--alert-note);
3410      }
3411      .markdown-body .markdown-alert[data-alert-kind="tip"],
3412      .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="tip"] {
3413        --alert-accent: var(--alert-tip);
3414      }
3415      .markdown-body .markdown-alert[data-alert-kind="important"],
3416      .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="important"] {
3417        --alert-accent: var(--alert-important);
3418      }
3419      .markdown-body .markdown-alert[data-alert-kind="warning"],
3420      .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="warning"] {
3421        --alert-accent: var(--alert-warning);
3422      }
3423      .markdown-body .markdown-alert[data-alert-kind="caution"],
3424      .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="caution"] {
3425        --alert-accent: var(--alert-caution);
3426      }
3427      .markdown-body .markdown-alert-title,
3428      .chat-msg.markdown-rendered .markdown-alert-title {
3429        display: flex;
3430        align-items: center;
3431        gap: 0.55rem;
3432        margin: 0 0 0.55em;
3433        font-family: var(--font-sans);
3434        font-size: 0.9rem;
3435        font-weight: 700;
3436        line-height: 1.25;
3437        color: color-mix(in srgb, var(--alert-accent) 74%, var(--fg-default));
3438      }
3439      .markdown-body .markdown-alert-title svg,
3440      .chat-msg.markdown-rendered .markdown-alert-title svg {
3441        width: 1rem;
3442        height: 1rem;
3443        flex-shrink: 0;
3444      }
3445      .markdown-body .markdown-alert-content,
3446      .chat-msg.markdown-rendered .markdown-alert-content {
3447        color: var(--fg-default);
3448      }
3449      .markdown-body .markdown-alert-content > :first-child,
3450      .chat-msg.markdown-rendered .markdown-alert-content > :first-child {
3451        margin-top: 0;
3452      }
3453      .markdown-body .markdown-alert-content > :last-child,
3454      .chat-msg.markdown-rendered .markdown-alert-content > :last-child {
3455        margin-bottom: 0;
3456      }
3457      /* Inline code — slight tint so it sits in the prose without shouting */
3458      .markdown-body code,
3459      .markdown-body tt {
3460        padding: 0.15em 0.4em;
3461        margin: 0;
3462        font-family: var(--font-mono);
3463        font-size: 0.85em;
3464        background: var(--code-inline-bg);
3465        border-radius: 4px;
3466        white-space: break-spaces;
3467        color: var(--reader-code-fg, var(--fg-default));
3468      }
3469
3470      /* Code blocks — standalone cards */
3471      .markdown-body pre {
3472        margin: 1.5em 0;
3473        padding: 18px 22px;
3474        overflow: auto;
3475        font-family: var(--font-mono);
3476        font-size: 13.5px;
3477        line-height: 1.6;
3478        color: var(--reader-code-fg, var(--fg-default));
3479        background: var(--code-block-bg);
3480        border: 1px solid var(--border-soft);
3481        border-radius: 8px;
3482      }
3483      .markdown-body pre code,
3484      .markdown-body pre tt {
3485        display: inline;
3486        padding: 0;
3487        margin: 0;
3488        background: transparent;
3489        border: 0;
3490        font-size: inherit;
3491        white-space: pre;
3492        color: inherit;
3493      }
3494      .markdown-body .mermaid-block {
3495        position: relative;
3496        margin: 1.5em 0;
3497        padding: 18px 22px;
3498        overflow: auto;
3499        color: var(--reader-code-fg, var(--fg-default));
3500        background: var(--code-block-bg);
3501        border: 1px solid var(--border-soft);
3502        border-radius: 8px;
3503      }
3504      .markdown-body .mermaid-block:not(.is-loading):not(.is-error) {
3505        cursor: zoom-in;
3506      }
3507      .markdown-body .mermaid-block.is-loading {
3508        display: flex;
3509        align-items: center;
3510        justify-content: center;
3511        min-height: 160px;
3512        color: var(--fg-muted);
3513        font-family: var(--font-sans);
3514        font-size: 0.95rem;
3515      }
3516      .markdown-body .mermaid-block svg {
3517        display: block;
3518        height: auto;
3519        max-width: 100%;
3520        margin: 0 auto;
3521      }
3522      .markdown-body .mermaid-inline-actions {
3523        position: sticky;
3524        top: 10px;
3525        z-index: 1;
3526        display: flex;
3527        justify-content: flex-end;
3528        margin: 0 0 10px;
3529        pointer-events: none;
3530      }
3531      .markdown-body .mermaid-inline-action {
3532        display: inline-flex;
3533        align-items: center;
3534        gap: 6px;
3535        padding: 7px 10px;
3536        border: 1px solid var(--border-default);
3537        border-radius: 999px;
3538        background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
3539        color: var(--fg-default);
3540        box-shadow: var(--shadow-sm);
3541        opacity: 0;
3542        pointer-events: auto;
3543        transform: translateY(-2px);
3544        transition:
3545          opacity 140ms ease,
3546          transform 140ms ease,
3547          border-color 140ms ease,
3548          background 140ms ease;
3549      }
3550      .markdown-body .mermaid-block:hover .mermaid-inline-action,
3551      .markdown-body .mermaid-block:focus-within .mermaid-inline-action,
3552      .markdown-body .mermaid-inline-action:focus-visible {
3553        opacity: 1;
3554        transform: translateY(0);
3555      }
3556      .markdown-body .mermaid-inline-action:hover {
3557        border-color: var(--border-strong);
3558        background: var(--bg-raised);
3559      }
3560      .markdown-body .mermaid-inline-action svg {
3561        width: 13px;
3562        height: 13px;
3563        margin: 0;
3564        flex: none;
3565      }
3566      .markdown-body .mermaid-block.is-error {
3567        padding: 0;
3568        overflow: hidden;
3569      }
3570      .markdown-body .mermaid-error {
3571        padding: 12px 16px 0;
3572        font-family: var(--font-sans);
3573        font-size: 0.9rem;
3574        color: var(--fg-muted);
3575      }
3576      .markdown-body .mermaid-error-detail {
3577        margin: 12px 0 0;
3578        padding: 12px 16px;
3579        font-size: 12.5px;
3580        line-height: 1.5;
3581      }
3582      .markdown-body .mermaid-block.is-error pre {
3583        margin: 12px 0 0;
3584        border: 0;
3585        border-top: 1px solid var(--border-soft);
3586        border-radius: 0;
3587      }
3588      /* ── KaTeX math ────────────────────────────────────────────── */
3589      .markdown-body .katex-display-block {
3590        margin: 1.2em 0;
3591        padding: 14px 18px;
3592        overflow-x: auto;
3593        text-align: center;
3594        background: var(--code-block-bg);
3595        border: 1px solid var(--border-soft);
3596        border-radius: 8px;
3597      }
3598      .markdown-body .katex-inline {
3599        padding: 0 2px;
3600      }
3601      /* ── Footnotes ────────────────────────────────────────────── */
3602      .markdown-body .footnote-ref a {
3603        color: var(--accent-fg);
3604        text-decoration: none;
3605        font-weight: 600;
3606        font-size: 0.78em;
3607        vertical-align: super;
3608        line-height: 0;
3609        padding: 0 2px;
3610      }
3611      .markdown-body .footnote-ref a:hover {
3612        text-decoration: underline;
3613      }
3614      .markdown-body .footnotes {
3615        margin-top: 2em;
3616        padding-top: 1em;
3617        font-size: 0.9rem;
3618        color: var(--fg-muted);
3619      }
3620      .markdown-body .footnotes ol {
3621        padding-left: 1.5em;
3622      }
3623      .markdown-body .footnotes li {
3624        margin-bottom: 0.5em;
3625      }
3626      .markdown-body .footnotes li:target {
3627        background: color-mix(in srgb, var(--accent-fg) 8%, transparent);
3628        border-radius: 4px;
3629        padding: 2px 6px;
3630        margin-left: -6px;
3631      }
3632      .markdown-body .footnote-backref {
3633        color: var(--accent-fg);
3634        text-decoration: none;
3635        margin-left: 4px;
3636        font-size: 0.85em;
3637      }
3638      .markdown-body .footnote-backref:hover {
3639        text-decoration: underline;
3640      }
3641      /* Override hljs background to match our code block style */
3642      .markdown-body pre code.hljs {
3643        color: var(--reader-code-fg, var(--fg-default));
3644        background: transparent;
3645        padding: 0;
3646      }
3647      .preview-pane .markdown-body :is(.hljs-comment, .hljs-quote) {
3648        color: var(--reader-syntax-comment, var(--fg-muted));
3649        font-style: italic;
3650      }
3651      .preview-pane
3652        .markdown-body
3653        :is(.hljs-keyword, .hljs-selector-tag, .hljs-subst, .hljs-doctag) {
3654        color: var(
3655          --reader-syntax-keyword,
3656          var(--reader-code-fg, var(--fg-default))
3657        );
3658      }
3659      .preview-pane
3660        .markdown-body
3661        :is(
3662          .hljs-string,
3663          .hljs-regexp,
3664          .hljs-symbol,
3665          .hljs-bullet,
3666          .hljs-addition
3667        ) {
3668        color: var(
3669          --reader-syntax-string,
3670          var(--reader-code-fg, var(--fg-default))
3671        );
3672      }
3673      .preview-pane
3674        .markdown-body
3675        :is(
3676          .hljs-number,
3677          .hljs-literal,
3678          .hljs-selector-id,
3679          .hljs-selector-class
3680        ) {
3681        color: var(
3682          --reader-syntax-number,
3683          var(--reader-code-fg, var(--fg-default))
3684        );
3685      }
3686      .preview-pane
3687        .markdown-body
3688        :is(
3689          .hljs-title,
3690          .hljs-title.class_,
3691          .hljs-title.function_,
3692          .hljs-section
3693        ) {
3694        color: var(
3695          --reader-syntax-title,
3696          var(--reader-code-fg, var(--fg-default))
3697        );
3698      }
3699      .preview-pane
3700        .markdown-body
3701        :is(
3702          .hljs-attr,
3703          .hljs-attribute,
3704          .hljs-variable,
3705          .hljs-template-variable,
3706          .hljs-property,
3707          .hljs-type,
3708          .hljs-built_in,
3709          .hljs-name,
3710          .hljs-tag,
3711          .hljs-meta
3712        ) {
3713        color: var(
3714          --reader-syntax-meta,
3715          var(--reader-code-fg, var(--fg-default))
3716        );
3717      }
3718      .preview-pane .markdown-body :is(.hljs-emphasis) {
3719        font-style: italic;
3720      }
3721      .preview-pane .markdown-body :is(.hljs-strong) {
3722        font-weight: 700;
3723      }
3724      /* ── Mermaid Viewer: overlay & shell ────────────────────────── */
3725      .mermaid-viewer {
3726        position: fixed;
3727        inset: 0;
3728        z-index: 70;
3729        display: flex;
3730        align-items: center;
3731        justify-content: center;
3732        padding: 0;
3733        background: rgba(0, 0, 0, 0.35);
3734        -webkit-backdrop-filter: blur(24px) saturate(1.05);
3735        backdrop-filter: blur(24px) saturate(1.05);
3736        animation: mv-overlay-in 220ms ease-out;
3737      }
3738      @keyframes mv-overlay-in {
3739        from {
3740          opacity: 0;
3741        }
3742        to {
3743          opacity: 1;
3744        }
3745      }
3746      @keyframes mv-shell-in {
3747        from {
3748          opacity: 0;
3749          transform: scale(0.985) translateY(4px);
3750        }
3751        to {
3752          opacity: 1;
3753          transform: scale(1) translateY(0);
3754        }
3755      }
3756      .mermaid-viewer[hidden] {
3757        display: none;
3758      }
3759      .mermaid-viewer-shell {
3760        display: flex;
3761        flex-direction: column;
3762        width: 100vw;
3763        height: 100vh;
3764        background: var(--bg-canvas);
3765        overflow: hidden;
3766        animation: mv-shell-in 220ms ease-out;
3767      }
3768
3769      /* ── Header: compact floating toolbar ──────────────────────── */
3770      .mermaid-viewer-header {
3771        display: flex;
3772        align-items: center;
3773        justify-content: space-between;
3774        gap: 12px;
3775        padding: 8px 16px;
3776        border-bottom: 1px solid var(--border-soft);
3777        background: var(--bg-raised);
3778      }
3779      .mermaid-viewer-title-wrap {
3780        display: flex;
3781        align-items: center;
3782        gap: 10px;
3783        min-width: 0;
3784      }
3785      .mermaid-viewer-title {
3786        margin: 0;
3787        font-family: var(--font-sans);
3788        font-size: 0.82rem;
3789        font-weight: 600;
3790        letter-spacing: 0.01em;
3791        white-space: nowrap;
3792      }
3793      .mermaid-viewer-help {
3794        display: none;
3795      }
3796
3797      /* ── Toolbar: icon-only pill buttons ───────────────────────── */
3798      .mermaid-viewer-actions {
3799        display: flex;
3800        align-items: center;
3801        gap: 2px;
3802        flex-shrink: 0;
3803      }
3804      .mermaid-viewer-sep {
3805        width: 1px;
3806        height: 18px;
3807        margin: 0 6px;
3808        background: var(--border-soft);
3809        flex-shrink: 0;
3810      }
3811      .mermaid-viewer-btn {
3812        display: inline-flex;
3813        align-items: center;
3814        justify-content: center;
3815        width: 32px;
3816        height: 32px;
3817        padding: 0;
3818        border: none;
3819        border-radius: 8px;
3820        background: transparent;
3821        color: var(--fg-muted);
3822        font: inherit;
3823        font-size: 0.78rem;
3824        cursor: pointer;
3825        transition:
3826          background 120ms ease,
3827          color 120ms ease;
3828        position: relative;
3829      }
3830      .mermaid-viewer-btn:hover {
3831        background: var(--bg-subtle);
3832        color: var(--fg-default);
3833      }
3834      .mermaid-viewer-btn:active {
3835        background: var(--border-soft);
3836      }
3837      .mermaid-viewer-btn:focus-visible {
3838        outline: 2px solid var(--accent-fg);
3839        outline-offset: 1px;
3840      }
3841      .mermaid-viewer-btn svg {
3842        width: 16px;
3843        height: 16px;
3844        flex: none;
3845        stroke-width: 1.75;
3846      }
3847      .mermaid-viewer-btn .mv-btn-label {
3848        position: absolute;
3849        width: 1px;
3850        height: 1px;
3851        overflow: hidden;
3852        clip: rect(0, 0, 0, 0);
3853        white-space: nowrap;
3854      }
3855      .mermaid-viewer-btn--text {
3856        width: auto;
3857        padding: 0 8px;
3858        font-weight: 500;
3859        font-size: 0.75rem;
3860        letter-spacing: 0.01em;
3861      }
3862      .mermaid-viewer-btn--close {
3863        width: 32px;
3864        height: 32px;
3865        border-radius: 8px;
3866      }
3867      .mermaid-viewer-btn--close:hover {
3868        background: rgba(180, 80, 80, 0.08);
3869        color: #b05050;
3870      }
3871      .mermaid-viewer-zoom-label {
3872        min-width: 40px;
3873        text-align: center;
3874        font-size: 0.73rem;
3875        font-weight: 600;
3876        font-variant-numeric: tabular-nums;
3877        color: var(--fg-muted);
3878        letter-spacing: 0.02em;
3879        user-select: none;
3880      }
3881
3882      /* ── Stage: dot-grid canvas ────────────────────────────────── */
3883      .mermaid-viewer-stage {
3884        position: relative;
3885        flex: 1;
3886        overflow: auto;
3887        padding: 32px;
3888        background-color: var(--bg-canvas);
3889        background-image: radial-gradient(
3890          circle,
3891          color-mix(in srgb, var(--fg-muted) 12%, transparent) 1p
3891x,
3892          transparent 1px
3893        );
3894        background-size: 20px 20px;
3895        cursor: grab;
3896        user-select: none;
3897        touch-action: none;
3898      }
3899      .mermaid-viewer-stage.is-dragging {
3900        cursor: grabbing;
3901      }
3902      .mermaid-viewer-content {
3903        display: flex;
3904        align-items: flex-start;
3905        justify-content: center;
3906        width: max-content;
3907        min-width: 100%;
3908        min-height: 100%;
3909        padding: 24px;
3910      }
3911      .mermaid-viewer-zoomable {
3912        transform-origin: top left;
3913        will-change: transform;
3914      }
3915      .mermaid-viewer-zoomable svg {
3916        display: block;
3917        max-width: none;
3918        height: auto;
3919        background: var(--bg-raised);
3920        border: 1px solid var(--border-soft);
3921        border-radius: 12px;
3922        box-shadow:
3923          0 1px 3px rgba(0, 0, 0, 0.06),
3924          0 8px 24px rgba(0, 0, 0, 0.08);
3925      }
3926
3927      /* ── Footer: minimal status bar ────────────────────────────── */
3928      .mermaid-viewer-footer {
3929        display: flex;
3930        align-items: center;
3931        justify-content: space-between;
3932        gap: 12px;
3933        padding: 6px 16px;
3934        border-top: 1px solid var(--border-soft);
3935        color: var(--fg-muted);
3936        font-size: 0.72rem;
3937        letter-spacing: 0.01em;
3938        background: var(--bg-raised);
3939      }
3940      .mermaid-viewer-footer kbd {
3941        display: inline-block;
3942        padding: 1px 5px;
3943        font-family: var(--font-mono);
3944        font-size: 0.82em;
3945        color: var(--fg-default);
3946        background: var(--bg-canvas);
3947        border: 1px solid var(--border-default);
3948        border-bottom-width: 2px;
3949        border-radius: 3px;
3950        line-height: 1.4;
3951      }
3952
3953      /* ── Responsive ────────────────────────────────────────────── */
3954      @media (max-width: 900px) {
3955        .mermaid-viewer-header {
3956          padding: 6px 10px;
3957        }
3958        .mermaid-viewer-footer {
3959          padding: 5px 10px;
3960        }
3961        .mermaid-viewer-stage {
3962          padding: 16px;
3963        }
3964        .mermaid-viewer-btn {
3965          width: 30px;
3966          height: 30px;
3967        }
3968        .mermaid-viewer-sep {
3969          margin: 0 4px;
3970        }
3971      }
3972      @media (max-width: 520px) {
3973        .mermaid-viewer-title {
3974          font-size: 0.76rem;
3975        }
3976        .mermaid-viewer-footer span:first-child {
3977          display: none;
3978        }
3979      }
3980
3981      /* ============================================================
3982         Mindmap Viewer
3983         ============================================================ */
3984
3985      .mindmap-viewer {
3986        position: fixed;
3987        inset: 0;
3988        z-index: 1200;
3989        display: flex;
3990        align-items: stretch;
3991        justify-content: stretch;
3992        background: color-mix(in srgb, var(--bg-canvas) 94%, transparent);
3993        backdrop-filter: blur(10px);
3994        -webkit-backdrop-filter: blur(10px);
3995        opacity: 0;
3996        pointer-events: none;
3997        transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
3998      }
3999
4000      .mindmap-viewer[aria-hidden="false"] {
4001        opacity: 1;
4002        pointer-events: auto;
4003      }
4004
4005      .mindmap-viewer-shell {
4006        position: relative;
4007        flex: 1;
4008        display: flex;
4009        flex-direction: column;
4010      }
4011
4012      .mindmap-viewer-header {
4013        display: flex;
4014        align-items: flex-start;
4015        justify-content: space-between;
4016        padding: 28px 40px 18px;
4017        gap: 32px;
4018      }
4019
4020      .mindmap-viewer-title-wrap {
4021        display: flex;
4022        flex-direction: column;
4023        gap: 4px;
4024        min-width: 0;
4025      }
4026
4027      .mindmap-viewer-title {
4028        margin: 0;
4029        font-family: var(--font-serif);
4030        font-size: 22px;
4031        font-weight: 500;
4032        letter-spacing: -0.01em;
4033        color: var(--fg-default);
4034      }
4035
4036      .mindmap-viewer-help {
4037        margin: 0;
4038        font-family: var(--font-sans);
4039        font-size: 12px;
4040        color: var(--fg-muted);
4041        letter-spacing: 0.01em;
4042      }
4043
4044      .mindmap-viewer-actions {
4045        display: flex;
4046        align-items: center;
4047        gap: 8px;
4048      }
4049
4050      .mindmap-viewer-btn {
4051        display: inline-flex;
4052        align-items: center;
4053        gap: 6px;
4054        height: 32px;
4055        padding: 0 12px;
4056        font: 500 12px/1 var(--font-sans);
4057        color: var(--fg-muted);
4058        background: transparent;
4059        border: 1px solid var(--border-default);
4060        border-radius: 8px;
4061        cursor: pointer;
4062        transition:
4063          color 150ms,
4064          border-color 150ms,
4065          background 150ms;
4066      }
4067
4068      .mindmap-viewer-btn:hover {
4069        color: var(--fg-default);
4070        border-color: var(--border-strong);
4071        background: var(--bg-subtle);
4072      }
4073
4074      .mindmap-viewer-btn svg {
4075        width: 14px;
4076        height: 14px;
4077      }
4078
4079      .mindmap-viewer-zoom-label {
4080        min-width: 44px;
4081        text-align: center;
4082        font: 500 11px/1 var(--font-sans);
4083        color: var(--fg-muted);
4084        font-variant-numeric: tabular-nums;
4085      }
4086
4087      .mindmap-viewer-stage {
4088        position: relative;
4089        flex: 1;
4090        overflow: hidden;
4091        background: var(--mm-canvas-bg);
4092        cursor: grab;
4093      }
4094
4095      .mindmap-viewer-stage.is-panning {
4096        cursor: grabbing;
4097      }
4098
4099      .mindmap-svg {
4100        width: 100%;
4101        height: 100%;
4102        display: block;
4103        user-select: none;
4104        -webkit-user-select: none;
4105      }
4106
4107      .mindmap-svg-root {
4108        transform-origin: 0 0;
4109        transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
4110      }
4111
4112      .mindmap-svg-root.is-interactive {
4113        transition: none;
4114      }
4115
4116      /* ── Nodes ──────────────────────────────────────────────── */
4117
4118      .mm-node {
4119        cursor: default;
4120        transition: transform 200ms cubic-bezier(0.34, 1.3, 0.64, 1);
4121        transform-box: fill-box;
4122        transform-origin: center;
4123      }
4124
4125      /* Mindmap fan-out on open — nodes emerge with a staggered
4126         fade+defocus. Chose opacity+blur over scale to avoid
4127         fighting the SVG positioning transforms. Stagger by
4128         data-depth so the root node lands first, rings follow.
4129         Respects prefers-reduced-motion via the global reset. */
4130      body.mindmap-open .mm-node {
4131        animation: mindmap-fan-out var(--motion-page) var(--ease-out) both;
4132      }
4133      body.mindmap-open .mm-node[data-depth="0"] {
4134        animation-delay: 0ms;
4135      }
4136      body.mindmap-open .mm-node[data-depth="1"] {
4137        animation-delay: 90ms;
4138      }
4139      body.mindmap-open .mm-node[data-depth="2"] {
4140        animation-delay: 180ms;
4141      }
4142      body.mindmap-open .mm-node[data-depth="3"] {
4143        animation-delay: 260ms;
4144      }
4145      body.mindmap-open .mm-node[data-depth="4"] {
4146        animation-delay: 320ms;
4147      }
4148
4149      @keyframes mindmap-fan-out {
4150        from {
4151          opacity: 0;
4152          filter: blur(3px);
4153        }
4154        to {
4155          opacity: 1;
4156          filter: blur(0);
4157        }
4158      }
4159
4160      /* Mindmap edges follow slightly behind the nodes so the
4161         structure assembles gracefully rather than flashing in. */
4162      body.mindmap-open .mindmap-edge,
4163      body.mindmap-open path.mm-edge {
4164        animation: mindmap-edge-enter var(--motion-page) var(--ease-out) 160ms
4165          both;
4166      }
4167      @keyframes mindmap-edge-enter {
4168        from {
4169          opacity: 0;
4170        }
4171        to {
4172          opacity: var(--mm-edge-opacity-primary, 0.4);
4173        }
4174      }
4175
4176      .mm-node.is-interactive {
4177        cursor: pointer;
4178      }
4179
4180      .mm-node-card {
4181        fill: var(--mm-node-bg);
4182        stroke: var(--mm-node-border);
4183        stroke-width: 1;
4184        filter: var(--mm-node-filter);
4185        transition:
4186          stroke 180ms,
4187          filter 220ms;
4188      }
4189
4190      .mm-node.is-interactive:hover .mm-node-card {
4191        stroke: var(--mm-node-border-hover);
4192        filter: var(--mm-node-filter-hover);
4193      }
4194
4195      .mm-node.is-selected .mm-node-card {
4196        fill: var(--mm-node-bg-selected);
4197        stroke: var(--mm-node-border-hover);
4198      }
4199
4200      .mm-node.is-focused .mm-node-card {
4201        fill: var(--mm-node-bg-selected);
4202        stroke: var(--accent-fg);
4203        stroke-width: 1.5;
4204        filter: var(--mm-node-filter-hover);
4205      }
4206
4207      .mm-node.is-related .mm-node-card {
4208        stroke: color-mix(in srgb, var(--accent-fg) 48%, var(--mm-node-border));
4209      }
4210
4211      .mm-node.is-muted .mm-node-card,
4212      .mm-node.is-muted .mm-node-typebar,
4213      .mm-node.is-muted .mm-node-label,
4214      .mm-node.is-muted .mm-node-badge {
4215        opacity: 0.24;
4216      }
4217
4218      .mm-node.is-interactive:focus-visible .mm-node-card {
4219        stroke: var(--accent-fg);
4220        stroke-width: 2;
4221        outline: none;
4222      }
4223
4224      .mm-node-typebar {
4225        /* color set inline per node type via CSS var */
4226      }
4227
4228      .mm-node-label {
4229        font-family: var(--mm-label-serif);
4230        fill: var(--fg-default);
4231        dominant-baseline: middle;
4232        pointer-events: none;
4233      }
4234
4235      .mm-node-badge {
4236        font-family: var(--mm-badge-sans);
4237        font-size: var(--mm-badge-size);
4238        font-weight: 500;
4239        letter-spacing: 0.12em;
4240        text-transform: uppercase;
4241        fill: var(--fg-muted);
4242        dominant-baseline: middle;
4243        pointer-events: none;
4244      }
4245
4246      .mm-node[data-depth="0"] .mm-node-label {
4247        font-size: var(--mm-root-label-size);
4248        font-weight: 500;
4249        letter-spacing: 0.08em;
4250        text-transform: uppercase;
4251      }
4252
4253      .mm-node[data-depth="1"] .mm-node-label {
4254        font-size: var(--mm-primary-label-size);
4255        font-weight: 500;
4256      }
4257
4258      .mm-node[data-depth="2"] .mm-node-label,
4259      .mm-node[data-depth="3"] .mm-node-label {
4260        font-size: var(--mm-secondary-label-size);
4261        font-weight: 400;
4262      }
4263
4264      /* ── Edges ──────────────────────────────────────────────── */
4265
4266      .mm-edge {
4267        fill: none;
4268        stroke: var(--mm-edge-stroke);
4269        stroke-linecap: round;
4270        stroke-linejoin: round;
4271        pointer-events: none;
4272      }
4273
4274      .mm-edge[data-depth="1"] {
4275        stroke-width: 2;
4276        stroke-opacity: var(--mm-edge-opacity-primary);
4277      }
4278
4279      .mm-edge[data-depth="2"] {
4280        stroke-width: 1.5;
4281        stroke-opacity: var(--mm-edge-opacity-secondary);
4282      }
4283
4284      .mm-edge[data-depth="3"] {
4285        stroke-width: 1;
4286        stroke-opacity: var(--mm-edge-opacity-tertiary);
4287      }
4288
4289      .mm-edge.is-active {
4290        stroke: var(--accent-fg);
4291        stroke-opacity: 0.7;
4292        stroke-width: 2.25;
4293      }
4294
4295      .mm-edge.is-muted {
4296        stroke-opacity: 0.06;
4297      }
4298
4299      /* ── Tooltip card (hover detail) ────────────────────────── */
4300
4301      .mm-tooltip {
4302        position: absolute;
4303        max-width: 280px;
4304        padding: 14px 18px;
4305        background: var(--bg-raised);
4306        border: 1px solid var(--border-default);
4307        border-radius: 10px;
4308        box-shadow: var(--shadow-md);
4309        pointer-events: none;
4310        opacity: 0;
4311        transform: translateY(4px);
4312        transition:
4313          opacity 140ms,
4314          transform 140ms;
4315        z-index: 5;
4316      }
4317
4318      .mm-tooltip.is-visible {
4319        opacity: 1;
4320        transform: translateY(0);
4321      }
4322
4323      .mm-tooltip-type {
4324        display: block;
4325        font: 500 10px/1 var(--font-sans);
4326        letter-spacing: 0.12em;
4327        text-transform: uppercase;
4328        color: var(--fg-muted);
4329        margin-bottom: 6px;
4330      }
4331
4332      .mm-tooltip-label {
4333        display: block;
4334        font: 500 15px/1.3 var(--font-serif);
4335        color: var(--fg-default);
4336        margin-bottom: 6px;
4337      }
4338
4339      .mm-tooltip-summary {
4340        display: block;
4341        font: 400 13px/1.55 var(--font-serif);
4342        color: var(--fg-muted);
4343      }
4344
4345      /* ── Search and focus inspector ─────────────────────────── */
4346
4347      .mindmap-search {
4348        position: absolute;
4349        top: 18px;
4350        left: 24px;
4351        z-index: 6;
4352        width: min(320px, calc(100% - 48px));
4353        font-family: var(--font-sans);
4354      }
4355
4356      .mindmap-search-input {
4357        width: 100%;
4358        height: 36px;
4359        padding: 0 12px 0 34px;
4360        color: var(--fg-default);
4361        background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
4362        border: 1px solid var(--border-default);
4363        border-radius: 8px;
4364        box-shadow: var(--shadow-sm);
4365        outline: none;
4366        font: 500 12px/1 var(--font-sans);
4367      }
4368
4369      .mindmap-search-input::placeholder {
4370        color: var(--fg-subtle);
4371      }
4372
4373      .mindmap-search-input:focus {
4374        border-color: var(--accent-fg);
4375        box-shadow: 0 0 0 3px
4376          color-mix(in srgb, var(--accent-fg) 16%, transparent);
4377      }
4378
4379      .mindmap-search-icon {
4380        position: absolute;
4381        left: 12px;
4382        top: 11px;
4383        width: 14px;
4384        height: 14px;
4385        color: var(--fg-muted);
4386        pointer-events: none;
4387      }
4388
4389      .mindmap-search-results {
4390        position: absolute;
4391        left: 0;
4392        right: 0;
4393        top: calc(100% + 6px);
4394        max-height: min(320px, calc(100vh - 260px));
4395        overflow-y: auto;
4396        padding: 6px;
4397        background: var(--bg-raised);
4398        border: 1px solid var(--border-default);
4399        border-radius: 8px;
4400        box-shadow: var(--shadow-md);
4401      }
4402
4403      .mindmap-search-results.is-hidden {
4404        display: none;
4405      }
4406
4407      .mindmap-search-result {
4408        display: grid;
4409        grid-template-columns: 6px 1fr;
4410        gap: 10px;
4411        width: 100%;
4412        padding: 9px 10px;
4413        color: var(--fg-default);
4414        background: transparent;
4415        border: 0;
4416        border-radius: 6px;
4417        text-align: left;
4418        cursor: pointer;
4419      }
4420
4421      .mindmap-search-result:hover,
4422      .mindmap-search-result:focus-visible {
4423        background: var(--bg-subtle);
4424        outline: none;
4425      }
4426
4427      .mindmap-search-result-dot {
4428        width: 6px;
4429        height: 6px;
4430        margin-top: 6px;
4431        border-radius: 50%;
4432      }
4433
4434      .mindmap-search-result-label {
4435        display: block;
4436        font: 500 13px/1.25 var(--font-serif);
4437      }
4438
4439      .mindmap-search-result-type {
4440        display: block;
4441        margin-top: 3px;
4442        color: var(--fg-muted);
4443        font: 600 9px/1 var(--font-sans);
4444        letter-spacing: 0.12em;
4445        text-transform: uppercase;
4446      }
4447
4448      .mindmap-inspector {
4449        position: absolute;
4450        top: 18px;
4451        right: 24px;
4452        z-index: 6;
4453        width: min(340px, calc(100% - 48px));
4454        max-height: calc(100% - 36px);
4455        overflow: auto;
4456        padding: 18px;
4457        color: var(--fg-default);
4458        background: color-mix(in srgb, var(--bg-raised) 94%, transparent);
4459        border: 1px solid var(--border-default);
4460        border-radius: 8px;
4461        box-shadow: var(--shadow-md);
4462        font-family: var(--font-sans);
4463        opacity: 0;
4464        pointer-events: none;
4465        transform: translateY(6px);
4466        transition:
4467          opacity 160ms,
4468          transform 160ms;
4469      }
4470
4471      .mindmap-inspector.is-visible {
4472        opacity: 1;
4473        pointer-events: auto;
4474        transform: translateY(0);
4475      }
4476
4477      .mindmap-inspector-header {
4478        display: flex;
4479        align-items: flex-start;
4480        justify-content: space-between;
4481        gap: 12px;
4482      }
4483
4484      .mindmap-inspector-type {
4485        display: flex;
4486        align-items: center;
4487        gap: 7px;
4488        color: var(--fg-muted);
4489        font: 600 10px/1 var(--font-sans);
4490        letter-spacing: 0.12em;
4491        text-transform: uppercase;
4492      }
4493
4494      .mindmap-inspector-dot {
4495        width: 7px;
4496        height: 7px;
4497        border-radius: 50%;
4498      }
4499
4500      .mindmap-inspector-title {
4501        margin: 10px 0 0;
4502        color: var(--fg-default);
4503        font: 500 19px/1.25 var(--font-serif);
4504      }
4505
4506      .mindmap-inspector-close {
4507        display: inline-flex;
4508        align-items: center;
4509        justify-content: center;
4510        width: 28px;
4511        height: 28px;
4512        color: var(--fg-muted);
4513        background: transparent;
4514        border: 1px solid var(--border-default);
4515        border-radius: 8px;
4516        cursor: pointer;
4517      }
4518
4519      .mindmap-inspector-close:hover,
4520      .mindmap-inspector-close:focus-visible {
4521        color: var(--fg-default);
4522        background: var(--bg-subtle);
4523        outline: none;
4524      }
4525
4526      .mindmap-inspector-summary {
4527        margin: 12px 0 0;
4528        color: var(--fg-muted);
4529        font: 400 13px/1.55 var(--font-serif);
4530      }
4531
4532      .mindmap-inspector-actions {
4533        display: flex;
4534        gap: 8px;
4535        margin-top: 16px;
4536      }
4537
4538      .mindmap-inspector-action {
4539        display: inline-flex;
4540        align-items: center;
4541        justify-content: center;
4542        gap: 6px;
4543        height: 34px;
4544        padding: 0 12px;
4545        color: var(--bg-canvas);
4546        background: var(--fg-default);
4547        border: 0;
4548        border-radius: 8px;
4549        font: 600 12px/1 var(--font-sans);
4550        cursor: pointer;
4551      }
4552
4553      .mindmap-inspector-action:hover,
4554      .mindmap-inspector-action:focus-visible {
4555        background: var(--accent-fg);
4556        outline: none;
4557      }
4558
4559      .mindmap-inspector-section {
4560        margin-top: 18px;
4561        padding-top: 14px;
4562        border-top: 1px solid var(--border-soft);
4563      }
4564
4565      .mindmap-inspector-section-title {
4566        margin: 0 0 8px;
4567        color: var(--fg-muted);
4568        font: 600 10px/1 var(--font-sans);
4569        letter-spacing: 0.12em;
4570        text-transform: uppercase;
4571      }
4572
4573      .mindmap-connection-list {
4574        display: grid;
4575        gap: 6px;
4576      }
4577
4578      .mindmap-connection {
4579        display: grid;
4580        grid-template-columns: 6px 1fr;
4581        gap: 10px;
4582        width: 100%;
4583        padding: 8px;
4584        color: var(--fg-default);
4585        background: transparent;
4586        border: 0;
4587        border-radius: 6px;
4588        text-align: left;
4589        cursor: pointer;
4590      }
4591
4592      .mindmap-connection:hover,
4593      .mindmap-connection:focus-visible {
4594        background: var(--bg-subtle);
4595        outline: none;
4596      }
4597
4598      .mindmap-connection-dot {
4599        width: 6px;
4600        height: 6px;
4601        margin-top: 6px;
4602        border-radius: 50%;
4603      }
4604
4605      .mindmap-connection-label {
4606        display: block;
4607        overflow: hidden;
4608        text-overflow: ellipsis;
4609        white-space: nowrap;
4610        font: 500 13px/1.25 var(--font-serif);
4611      }
4612
4613      .mindmap-connection-meta {
4614        display: block;
4615        margin-top: 3px;
4616        color: var(--fg-muted);
4617        font: 600 9px/1 var(--font-sans);
4618        letter-spacing: 0.1em;
4619        text-transform: uppercase;
4620      }
4621
4622      @media (max-width: 860px) {
4623        .mindmap-search {
4624          top: 12px;
4625          left: 12px;
4626          width: min(300px, calc(100% - 24px));
4627        }
4628
4629        .mindmap-inspector {
4630          top: auto;
4631          right: 12px;
4632          bottom: 12px;
4633          left: 12px;
4634          width: auto;
4635          max-height: 42%;
4636        }
4637      }
4638
4639      /* ── Entry motion ───────────────────────────────────────── */
4640
4641      .mindmap-viewer[aria-hidden="false"] .mm-node {
4642        animation: mm-node-in 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
4643        animation-delay: calc(var(--mm-stagger, 0) * 60ms);
4644      }
4645
4646      .mindmap-viewer[aria-hidden="false"] .mm-edge {
4647        animation: mm-edge-in 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
4648        animation-delay: calc(var(--mm-stagger, 0) * 60ms + 120ms);
4649      }
4650
4651      @keyframes mm-node-in {
4652        from {
4653          opacity: 0;
4654          transform: translateY(8px);
4655        }
4656        to {
4657          opacity: 1;
4658          transform: translateY(0);
4659        }
4660      }
4661
4662      @keyframes mm-edge-in {
4663        from {
4664          opacity: 0;
4665        }
4666        to {
4667          opacity: 1;
4668        }
4669      }
4670
4671      @media (prefers-reduced-motion: reduce) {
4672        .mindmap-viewer[aria-hidden="false"] .mm-node,
4673        .mindmap-viewer[aria-hidden="false"] .mm-edge {
4674          animation: none;
4675        }
4676      }
4677
4678      /* ── Loading / empty states ─────────────────────────────── */
4679
4680      .mm-state-wrap {
4681        position: absolute;
4682        inset: 0;
4683        display: flex;
4684        align-items: center;
4685        justify-content: center;
4686        flex-direction: column;
4687        gap: 16px;
4688        color: var(--fg-muted);
4689        font-family: var(--font-sans);
4690      }
4691
4692      .mm-state-wrap.is-hidden {
4693        display: none;
4694      }
4695
4696      .mm-spinner {
4697        width: 20px;
4698        height: 20px;
4699        border: 2px solid var(--border-default);
4700        border-top-color: var(--accent-fg);
4701        border-radius: 50%;
4702        animation: mm-spin 900ms linear infinite;
4703      }
4704
4705      @keyframes mm-spin {
4706        to {
4707          transform: rotate(360deg);
4708        }
4709      }
4710
4711      .mm-state-label {
4712        font-size: 13px;
4713        letter-spacing: 0.02em;
4714      }
4715
4716      .mm-state-retry {
4717        display: inline-flex;
4718        align-items: center;
4719        gap: 6px;
4720        height: 32px;
4721        padding: 0 14px;
4722        font: 500 12px/1 var(--font-sans);
4723        color: var(--accent-fg);
4724        background: transparent;
4725        border: 1px solid var(--border-default);
4726        border-radius: 8px;
4727        cursor: pointer;
4728        transition:
4729          color 150ms,
4730          border-color 150ms,
4731          background 150ms;
4732      }
4733
4734      .mm-state-retry:hover {
4735        background: var(--bg-subtle);
4736        border-color: var(--accent-fg);
4737      }
4738
4739      /* ── Legend (semantic type key) ─────────────────────────── */
4740
4741      .mindmap-legend {
4742        display: flex;
4743        flex-wrap: wrap;
4744        gap: 10px 18px;
4745        padding: 0 40px 16px;
4746        border-bottom: 1px solid var(--border-soft);
4747      }
4748
4749      .mindmap-legend-chip {
4750        display: inline-flex;
4751        align-items: center;
4752        gap: 6px;
4753        font: 500 10px/1 var(--font-sans);
4754        letter-spacing: 0.12em;
4755        text-transform: uppercase;
4756        color: var(--fg-muted);
4757      }
4758
4759      .mindmap-legend-dot {
4760        width: 6px;
4761        height: 6px;
4762        border-radius: 50%;
4763        display: inline-block;
4764      }
4765
4766      @media (max-width: 720px) {
4767        .mindmap-legend {
4768          padding: 0 20px 12px;
4769          gap: 8px 14px;
4770        }
4771      }
4772
4773      @media (max-width: 720px) {
4774        .mindmap-viewer-header {
4775          padding: 20px 20px 14px;
4776          flex-direction: column;
4777          align-items: stretch;
4778          gap: 14px;
4779        }
4780        .mindmap-viewer-actions {
4781          flex-wrap: wrap;
4782        }
4783        .mindmap-viewer-btn span {
4784          display: none;
4785        }
4786      }
4787
4788      /* ── Screen-reader-only concept list ────────────────────── */
4789
4790      .mindmap-sr-only {
4791        position: absolute;
4792        width: 1px;
4793        height: 1px;
4794        padding: 0;
4795        margin: -1px;
4796        overflow: hidden;
4797        clip: rect(0, 0, 0, 0);
4798        white-space: nowrap;
4799        border: 0;
4800      }
4801
4802      .mm-jump-flash {
4803        animation: mm-flash 1800ms ease-out;
4804      }
4805
4806      @keyframes mm-flash {
4807        0% {
4808          background-color: color-mix(
4809            in srgb,
4810            var(--accent-fg) 22%,
4811            transparent
4812          );
4813        }
4814        40% {
4815          background-color: color-mix(
4816            in srgb,
4817            var(--accent-fg) 14%,
4818            transparent
4819          );
4820        }
4821        100% {
4822          background-color: transparent;
4823        }
4824      }
4825
4826      @media (prefers-reduced-motion: reduce) {
4827        .mm-jump-flash {
4828          animation: none;
4829        }
4830      }
4831
4832      .markdown-body .raw-html {
4833        border-style: dashed;
4834        opacity: 0.85;
4835      }
4836
4837      /* Tables */
4838      .markdown-body table {
4839        margin: 1.5em 0;
4840        display: block;
4841        width: max-content;
4842        max-width: 100%;
4843        overflow: auto;
4844        border-spacing: 0;
4845        border-collapse: collapse;
4846        font-size: 0.92em;
4847        font-family: var(--font-sans);
4848      }
4849      .markdown-body table th,
4850      .markdown-body table td {
4851        padding: 10px 16px;
4852        border-bottom: 1px solid var(--border-soft);
4853        text-align: left;
4854      }
4855      .markdown-body table th {
4856        font-weight: 600;
4857        color: var(--fg-muted);
4858        text-transform: uppercase;
4859        letter-spacing: 0.04em;
4860        font-size: 0.82em;
4861        border-bottom: 1px solid var(--border-strong);
4862      }
4863      .markdown-body table tr:last-child td {
4864        border-bottom: 0;
4865      }
4866
4867      .markdown-body .csv-sheet-wrap {
4868        margin: 1.5em 0;
4869        max-width: 100%;
4870        overflow: auto;
4871        border: 1px solid
4872          var(--reader-border-default, var(--border-default));
4873        border-radius: 8px;
4874        background: var(--reader-bg-surface, var(--bg-raised));
4875      }
4876
4877      .markdown-body .csv-sheet {
4878        display: table;
4879        width: max-content;
4880        min-width: 100%;
4881        max-width: none;
4882        margin: 0;
4883        overflow: visible;
4884        border-collapse: separate;
4885        border-spacing: 0;
4886        font-family: var(--font-sans);
4887        font-size: 0.88em;
4888      }
4889
4890      .markdown-body .csv-sheet th,
4891      .markdown-body .csv-sheet td {
4892        min-width: 9ch;
4893        max-width: 28ch;
4894        padding: 8px 12px;
4895        border-right: 1px solid
4896          var(--reader-border-soft, var(--border-soft));
4897        border-bottom: 1px solid
4898          var(--reader-border-soft, var(--border-soft));
4899        color: var(--reader-fg-default, var(--fg-default));
4900        text-align: left;
4901        text-transform: none;
4902        letter-spacing: 0;
4903        vertical-align: top;
4904        white-space: normal;
4905        overflow-wrap: anywhere;
4906      }
4907
4908      .markdown-body .csv-sheet thead th {
4909        position: sticky;
4910        top: 0;
4911        z-index: 1;
4912        border-bottom-color: var(
4913          --reader-border-default,
4914          var(--border-default)
4915        );
4916        background: var(--reader-bg-subtle, var(--bg-subtle));
4917        color: var(--reader-fg-default, var(--fg-default));
4918        font-size: 0.82em;
4919        font-weight: 600;
4920      }
4921
4922      .markdown-body .csv-sheet tbody th {
4923        width: 1%;
4924        min-width: 4ch;
4925        max-width: 6ch;
4926        background: var(--reader-bg-subtle, var(--bg-subtle));
4927        color: var(--reader-fg-muted, var(--fg-muted));
4928        font-size: 0.82em;
4929        font-weight: 500;
4930      }
4931
4932      .markdown-body .csv-sheet tr:last-child th,
4933      .markdown-body .csv-sheet tr:last-child td {
4934        border-bottom: 0;
4935      }
4936
4937      .markdown-body .csv-sheet th:last-child,
4938      .markdown-body .csv-sheet td:last-child {
4939        border-right: 0;
4940      }
4941
4942      .markdown-body .csv-sheet td:empty::after {
4943        content: "\00a0";
4944      }
4945
4946      .markdown-body .csv-sheet-note {
4947        margin: -0.9em 0 1.5em;
4948        color: var(--reader-fg-muted, var(--fg-muted));
4949        font-family: var(--font-sans);
4950        font-size: 0.86em;
4951      }
4952
4953      /* Media */
4954      .markdown-body img,
4955      .markdown-body video {
4956        display: block;
4957        max-width: 100%;
4958        margin: 1.5em auto;
4959        border-radius: 8px;
4960        box-shadow: var(--shadow-md);
4961      }
4962      .markdown-body video {
4963        width: min(100%, 960px);
4964        background: #000;
4965      }
4966      .markdown-body .blocked-resource {
4967        display: block;
4968        margin: 1.4em 0;
4969        padding: 12px 14px;
4970        font-family: var(--font-sans);
4971        font-size: 0.9rem;
4972        color: var(--fg-muted);
4973        background: var(--bg-subtle);
4974        border: 1px solid var(--border-soft);
4975        border-radius: 8px;
4976      }
4977
4978      /* Task lists */
4979      .markdown-body ul > li.task-list-item {
4980        list-style: none;
4981      }
4982      .markdown-body .task-list-item-checkbox {
4983        display: inline-flex;
4984        align-items: center;
4985        justify-content: center;
4986        width: 0.98em;
4987        height: 0.98em;
4988        margin: 0.08em 0.62em 0 0;
4989        vertical-align: text-top;
4990        border: 1px solid var(--border-strong);
4991        border-radius: 0.24em;
4992        background: var(--bg-raised);
4993        color: transparent;
4994        box-shadow: var(--shadow-sm);
4995      }
4996      .markdown-body .task-list-item-checkbox.is-checked {
4997        border-color: var(--accent-fg);
4998        background: color-mix(in srgb, var(--accent-fg) 12%, var(--bg-raised));
4999        color: var(--accent-fg);
5000      }
5001      .markdown-body .task-list-item-checkbox svg {
5002        width: 0.72em;
5003        height: 0.72em;
5004        display: block;
5005      }
5006
5007      /* kbd */
5008      .markdown-body kbd {
5009        display: inline-block;
5010        padding: 2px 6px;
5011        font-family: var(--font-mono);
5012        font-size: 0.78em;
5013        color: var(--fg-default);
5014        background: var(--bg-raised);
5015        border: 1px solid var(--border-default);
5016        border-bottom-width: 2px;
5017        border-radius: 4px;
5018      }
5019
5020      /* Details/summary */
5021      .markdown-body details {
5022        margin: 1.2em 0;
5023        padding: 12px 16px;
5024        background: var(--bg-subtle);
5025        border: 1px solid var(--border-soft);
5026        border-radius: 8px;
5027      }
5028      .markdown-body details summary {
5029        cursor: pointer;
5030        font-weight: 600;
5031        color: var(--fg-default);
5032      }
5033      .markdown-body details[open] summary {
5034        margin-bottom: 8px;
5035      }
5036
5037      /* ---------- Responsive ----------
5038         Progressive degradation strategy:
5039         • ≤1024px: hide verbose .status text (live-region still announces
5040           state to AT). Prevents the 769–950px "squeeze zone" where
5041           .status collapsed to 0 width, creating visual dead space.
5042         • ≤768px: wrap toolbar, enlarge tap targets to WCAG 2.5.5 AA
5043           (44×44px) since this is the coarse-pointer territory.
5044      */
5045      @media (max-width: 1024px) {
5046        .toolbar {
5047          flex-wrap: wrap;
5048        }
5049        .toolbar .status {
5050          display: none;
5051        }
5052      }
5053
5054      @media (max-width: 768px) {
5055        .toolbar {
5056          padding: 8px 14px;
5057          gap: 6px;
5058          flex-wrap: wrap;
5059        }
5060        /* WCAG 2.5.5 AA — minimum 44×44px tap target on touch-sized
5061           viewports. Also covers Apple HIG (44pt) and Material (48dp). */
5062        .toolbar .btn,
5063        .toolbar .tb-segment .tb-seg-btn {
5064          min-height: 44px;
5065          min-width: 44px;
5066        }
5067        .toolbar .tb-segment {
5068          padding: 4px;
5069        }
5070        .tb-divider {
5071          display: none;
5072        }
5073        .brand {
5074          margin-right: 0;
5075        }
5076        .brand-name:not(.is-file):not(.is-untitled) {
5077          display: none;
5078        }
5079        .toolbar .status {
5080          display: block;
5081          order: 11;
5082          flex-basis: 100%;
5083          max-width: none;
5084          overflow: visible;
5085          white-space: normal;
5086        }
5087        .reading-panel {
5088          width: min(320px, calc(100vw - 32px));
5089        }
5090        .preview-pane {
5091          padding: 40px 22px 96px;
5092        }
5093        #editor {
5094          padding: 32px 20px 96px;
5095        }
5096        .markdown-body h1 {
5097          font-size: 2em;
5098        }
5099        .markdown-body h2 {
5100          font-size: 1.5em;
5101        }
5102      }
5103
5104      @media (max-width: 820px) {
5105        .reading-settings .reading-panel,
5106        .ont-settings .ont-panel {
5107          position: fixed;
5108          top: 12px;
5109          right: 12px;
5110          bottom: auto;
5111          left: 12px;
5112          margin: 0 auto;
5113          width: min(420px, calc(100vw - 24px));
5114          max-width: calc(100vw - 24px);
5115          height: fit-content;
5116          max-height: min(720px, calc(100dvh - 24px));
5117          z-index: 9500;
5118          animation: panel-center-pop 220ms cubic-bezier(0.22, 1, 0.36, 1);
5119          animation-fill-mode: both;
5120        }
5121        .reading-settings .reading-panel {
5122          overflow-y: auto;
5123          overscroll-behavior: contain;
5124        }
5125        .ont-settings .ont-panel {
5126          overflow-y: auto;
5127          overscroll-behavior: contain;
5128        }
5129        @media (max-height: 640px) {
5130          .reading-settings .reading-panel {
5131            overflow-y: auto;
5132            overscroll-behavior: contain;
5133          }
5134        }
5135        @keyframes panel-center-pop {
5136          from {
5137            opacity: 0;
5138            transform: translateY(-8px);
5139          }
5140          to {
5141            opacity: 1;
5142            transform: translateY(0);
5143          }
5144        }
5145        .ont-settings .ont-header {
5146          position: sticky;
5147          top: 0;
5148          background: var(--bg-raised);
5149          border-bottom: 1px solid var(--border-soft);
5150          z-index: 3;
5151        }
5152        .reading-settings .reading-panel-header {
5153          position: sticky;
5154          top: -18px;
5155          margin: -18px -18px 16px;
5156          padding: 18px 18px 12px;
5157          background: var(--bg-raised);
5158          border-bottom: 1px solid var(--border-soft);
5159          z-index: 3;
5160        }
5161        .reading-width-control {
5162          display: none;
5163        }
5164        .reading-actions {
5165          flex-wrap: wrap;
5166        }
5167        .reading-actions .btn {
5168          flex: 1 1 140px;
5169          justify-content: center;
5170        }
5171      }
5172
5173      /* ---------- Ontology Insights Panel ---------- */
5174      .ont-settings {
5175        position: relative;
5176      }
5177      #ontologyToggleBtn[aria-expanded="true"] {
5178        background: var(--bg-subtle);
5179        border-color: var(--border-strong);
5180      }
5181      .ont-panel {
5182        position: absolute;
5183        top: calc(100% + 6px);
5184        right: 0;
5185        z-index: 900;
5186        width: min(320px, calc(100vw - 32px));
5187        max-height: min(480px, calc(100vh - 80px));
5188        overflow-y: auto;
5189        background: var(--bg-raised);
5190        border: 1px solid var(--border-soft);
5191        border-radius: 14px;
5192        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06);
5193        font-family: var(--font-sans);
5194        font-size: 12px;
5195        color: var(--fg-default);
5196        animation: panel-enter 200ms ease-out;
5197      }
5198      :root[data-theme="dark"] .ont-panel {
5199        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
5200      }
5201      .ont-panel.hidden {
5202        display: none;
5203      }
5204      .ont-header {
5205        display: flex;
5206        align-items: center;
5207        justify-content: space-between;
5208        padding: 14px 16px 12px;
5209        border-bottom: 1px solid var(--border-soft);
5210        position: sticky;
5211        top: 0;
5212        background: var(--bg-raised);
5213        border-radius: 14px 14px 0 0;
5214        z-index: 1;
5215      }
5216      .ont-title {
5217        font-weight: 700;
5218        font-size: 12px;
5219      }
5220      .ont-summary {
5221        font-size: 10px;
5222        color: var(--fg-muted);
5223      }
5224      .ont-close {
5225        width: 22px;
5226        height: 22px;
5227        display: flex;
5228        align-items: center;
5229        justify-content: center;
5230        border-radius: 5px;
5231        border: 0;
5232        background: transparent;
5233        color: var(--fg-muted);
5234        cursor: pointer;
5235        font-size: 14px;
5236        line-height: 1;
5237      }
5238      .ont-close:hover {
5239        background: var(--bg-subtle);
5240        color: var(--fg-default);
5241      }
5242      .ont-section-label {
5243        font-size: 10px;
5244        font-weight: 700;
5245        text-transform: uppercase;
5246        letter-spacing: 0.8px;
5247        color: var(--fg-muted);
5248        padding: 10px 14px 6px;
5249      }
5250      .ont-section-row {
5251        display: flex;
5252        align-items: center;
5253        gap: 8px;
5254        padding: 6px 14px;
5255        border: 0;
5256        background: transparent;
5257        width: 100%;
5258        text-align: left;
5259        cursor: pointer;
5260        font-family: var(--font-sans);
5261        font-size: 12px;
5262        color: var(--fg-default);
5263        border-radius: 0;
5264      }
5265      .ont-section-row:hover {
5266        background: var(--bg-subtle);
5267      }
5268      .ont-section-row:focus-visible {
5269        outline: none;
5270        box-shadow: inset 0 0 0 2px var(--selection);
5271      }
5272      .ont-section-dot {
5273        width: 7px;
5274        height: 7px;
5275        border-radius: 50%;
5276        flex-shrink: 0;
5277      }
5278      .ont-section-name {
5279        flex: 1;
5280        font-weight: 500;
5281        white-space: nowrap;
5282        overflow: hidden;
5283        text-overflow: ellipsis;
5284      }
5285      .ont-section-badge {
5286        font-size: 9px;
5287        color: var(--fg-muted);
5288        padding: 1px 6px;
5289        border-radius: 8px;
5290        flex-shrink: 0;
5291      }
5292      .ont-section-badge.is-loading {
5293        width: 36px;
5294        height: 14px;
5295        border-radius: 7px;
5296        background: linear-gradient(
5297          90deg,
5298          var(--bg-subtle) 25%,
5299          var(--bg-muted) 50%,
5300          var(--bg-subtle) 75%
5301        );
5302        background-size: 200% 100%;
5303        animation: ont-shimmer 1.5s ease infinite;
5304      }
5305      @keyframes ont-shimmer {
5306        0% {
5307          background-position: 200% 0;
5308        }
5309        100% {
5310          background-position: -200% 0;
5311        }
5312      }
5313      .ont-divider {
5314        border-top: 1px solid var(--border-soft);
5315        margin: 0 14px;
5316      }
5317      .ont-concepts {
5318        display: flex;
5319        flex-wrap: wrap;
5320        gap: 5px;
5321        padding: 6px 14px 14px;
5322      }
5323      .ont-concept-pill {
5324        display: inline-flex;
5325        align-items: center;
5326        gap: 4px;
5327        background: rgba(214, 176, 89, 0.12);
5328        border: 1px solid rgba(214, 176, 89, 0.18);
5329        padding: 3px 9px;
5330        border-radius: 12px;
5331        font-size: 11px;
5332        color: var(--fg-muted);
5333        cursor: pointer;
5334        font-family: var(--font-sans);
5335        font-weight: 400;
5336      }
5337      .ont-concept-pill:hover {
5338        background: rgba(214, 176, 89, 0.22);
5339      }
5340      .ont-concept-pill:focus-visible {
5341        outline: none;
5342        box-shadow: 0 0 0 2px var(--selection);
5343      }
5344      .ont-concept-count {
5345        font-size: 9px;
5346        color: var(--fg-subtle);
5347      }
5348      .ont-shape-bar {
5349        display: flex;
5350        height: 6px;
5351        border-radius: 3px;
5352        overflow: hidden;
5353        gap: 1px;
5354        margin: 0 14px;
5355      }
5356      .ont-shape-segment {
5357        transition: flex 200ms ease;
5358      }
5359      .ont-shape-labels {
5360        display: flex;
5361        justify-content: space-between;
5362        padding: 4px 14px 12px;
5363        font-size: 9px;
5364        color: var(--fg-subtle);
5365      }
5366      .ont-shape-section {
5367        padding: 8px 14px 4px;
5368        border-top: 1px solid var(--border-soft);
5369      }
5370      .ont-stale-link {
5371        font-size: 10px;
5372        color: var(--accent-fg);
5373        cursor: pointer;
5374        background: none;
5375        border: none;
5376        padding: 0;
5377        font-family: var(--font-sans);
5378        text-decoration: underline;
5379        text-decoration-color: color-mix(
5380          in srgb,
5381          var(--accent-fg) 30%,
5382          transparent
5383        );
5384      }
5385      .ont-stale-link:hover {
5386        text-decoration-color: currentColor;
5387      }
5388      .ont-empty,
5389      .ont-fallback {
5390        padding: 20px 14px;
5391        text-align: center;
5392        color: var(--fg-muted);
5393        font-size: 12px;
5394        line-height: 1.5;
5395      }
5396      .ont-preview-dot {
5397        display: none;
5398        width: 6px;
5399        height: 6px;
5400        border-radius: 50%;
5401        margin-right: 6px;
5402        flex-shrink: 0;
5403        vertical-align: middle;
5404      }
5405      .ontology-active .ont-preview-dot {
5406        display: inline-block;
5407      }
5408      .ont-term {
5409        border-bottom: 1.5px dotted transparent;
5410      }
5411      .ontology-active .ont-term {
5412        border-bottom-color: var(--ont-term-underline);
5413      }
5414      @keyframes ont-flash {
5415        0% {
5416          background: rgba(214, 176, 89, 0.35);
5417        }
5418        100% {
5419          background: transparent;
5420        }
5421      }
5422      .ont-term-flash {
5423        animation: ont-flash 0.8s ease-out forwards;
5424        border-radius: 2px;
5425      }
5426
5427      .export-print-root {
5428        display: none;
5429      }
5430
5431      @page {
5432        margin: 16mm 18mm;
5433      }
5434
5435      @media print {
5436        body.export-pdf-active {
5437          background: var(--reader-bg-canvas) !important;
5438        }
5439        body.export-pdf-active > *:not(#markvExportPrintRoot) {
5440          display: none !important;
5441        }
5442        body.export-pdf-active #markvExportPrintRoot {
5443          display: block !important;
5444        }
5445        .export-print-root {
5446          min-height: 100vh;
5447          background: var(--reader-bg-canvas);
5448          color: var(--reader-fg-default);
5449        }
5450        .export-print-document.markdown-body {
5451          max-width: none;
5452          margin: 0;
5453          padding: 0;
5454          color: var(--reader-fg-default);
5455          background: transparent;
5456          orphans: 3;
5457          widows: 3;
5458        }
5459        .export-print-document.markdown-body h1,
5460        .export-print-document.markdown-body h2,
5461        .export-print-document.markdown-body h3,
5462        .export-print-document.markdown-body h4,
5463        .export-print-document.markdown-body h5,
5464        .export-print-document.markdown-body h6 {
5465          break-after: avoid;
5466          page-break-after: avoid;
5467          color: var(--reader-fg-heading);
5468        }
5469        .export-print-document.markdown-body pre,
5470        .export-print-document.markdown-body table,
5471        .export-print-document.markdown-body blockquote,
5472        .export-print-document.markdown-body img,
5473        .export-print-document.markdown-body figure,
5474        .export-print-document.markdown-body .mermaid-block,
5475        .export-print-document.markdown-body .markdown-alert,
5476        .export-print-document.markdown-body .katex-display {
5477          break-inside: avoid;
5478          page-break-inside: avoid;
5479        }
5480        .export-print-document.markdown-body img,
5481        .export-print-document.markdown-body svg {
5482          max-width: 100%;
5483          height: auto;
5484        }
5485        .export-print-document.markdown-body pre,
5486        .export-print-document.markdown-body .mermaid-block {
5487          background: var(--reader-code-bg) !important;
5488          border-color: var(--reader-border-default) !important;
5489        }
5490        .export-print-document.markdown-body code {
5491          color: var(--reader-code-fg);
5492        }
5493        .export-pri
5493nt-document.markdown-body pre code.hljs {
5494          color: var(--reader-code-fg) !important;
5495          background: transparent !important;
5496        }
5497        .export-print-document.markdown-body .csv-sheet-wrap {
5498          max-width: 100%;
5499          overflow: visible;
5500          border-color: var(--reader-border-default) !important;
5501          box-shadow: none;
5502          break-inside: auto;
5503          page-break-inside: auto;
5504        }
5505        .export-print-document.markdown-body .csv-sheet {
5506          width: 100%;
5507          min-width: 0;
5508          table-layout: fixed;
5509          font-size: 0.72em;
5510          break-inside: auto;
5511          page-break-inside: auto;
5512        }
5513        .export-print-document.markdown-body .csv-sheet thead th {
5514          position: static;
5515          background: var(--reader-bg-subtle) !important;
5516        }
5517        .export-print-document.markdown-body .csv-sheet th,
5518        .export-print-document.markdown-body .csv-sheet td {
5519          max-width: none;
5520          padding: 6px 8px;
5521          border-color: var(--reader-border-soft) !important;
5522          white-space: normal;
5523          overflow-wrap: anywhere;
5524        }
5525        .export-print-document.markdown-body .csv-sheet tr {
5526          break-inside: avoid;
5527          page-break-inside: avoid;
5528        }
5529        .export-print-document.markdown-body :is(.hljs-comment, .hljs-quote) {
5530          color: var(--reader-syntax-comment) !important;
5531          font-style: italic;
5532        }
5533        .export-print-document.markdown-body
5534          :is(.hljs-keyword, .hljs-selector-tag, .hljs-subst, .hljs-doctag) {
5535          color: var(--reader-syntax-keyword) !important;
5536        }
5537        .export-print-document.markdown-body
5538          :is(
5539            .hljs-string,
5540            .hljs-regexp,
5541            .hljs-symbol,
5542            .hljs-bullet,
5543            .hljs-addition
5544          ) {
5545          color: var(--reader-syntax-string) !important;
5546        }
5547        .export-print-document.markdown-body
5548          :is(
5549            .hljs-number,
5550            .hljs-literal,
5551            .hljs-selector-id,
5552            .hljs-selector-class
5553          ) {
5554          color: var(--reader-syntax-number) !important;
5555        }
5556        .export-print-document.markdown-body
5557          :is(
5558            .hljs-title,
5559            .hljs-title.class_,
5560            .hljs-title.function_,
5561            .hljs-section
5562          ) {
5563          color: var(--reader-syntax-title) !important;
5564        }
5565        .export-print-document.markdown-body
5566          :is(
5567            .hljs-attr,
5568            .hljs-attribute,
5569            .hljs-variable,
5570            .hljs-template-variable,
5571            .hljs-property,
5572            .hljs-type,
5573            .hljs-built_in,
5574            .hljs-name,
5575            .hljs-tag,
5576            .hljs-meta
5577          ) {
5578          color: var(--reader-syntax-meta) !important;
5579        }
5580        .export-print-document.markdown-body .doc-highlight {
5581          background: transparent !important;
5582          box-shadow: inset 0 -1px 0 currentColor !important;
5583        }
5584        .export-timeout-fallback,
5585        .export-image-fallback {
5586          padding: 12px;
5587          border: 1px solid var(--reader-border-default);
5588          border-radius: 8px;
5589          background: var(--reader-bg-subtle);
5590          color: var(--reader-fg-default);
5591        }
5592      }
5593
5594      /* Reduce motion */
5595      @media (prefers-reduced-motion: reduce) {
5596        * {
5597          transition-duration: 0.01ms !important;
5598          animation-duration: 0.01ms !important;
5599        }
5600        .chat-msg.assistant .typing-dots::after {
5601          animation: none;
5602          content: "...";
5603        }
5604        .empty-state,
5605        .reading-panel,
5606        .ont-panel {
5607          animation: none;
5608        }
5609      }
5610
5611      /* ============================================================
5612       Chat Bubble — floating Q&A panel (Ollama / Groq RAG)
5613       ============================================================ */
5614      .chat-fab {
5615        position: fixed;
5616        bottom: 24px;
5617        right: 24px;
5618        z-index: 1000;
5619        width: 46px;
5620        height: 46px;
5621        border-radius: 50%;
5622        border: none;
5623        background: var(--fg-default);
5624        color: var(--bg-canvas);
5625        cursor: pointer;
5626        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
5627        display: flex;
5628        align-items: center;
5629        justify-content: center;
5630        transition:
5631          transform 200ms ease,
5632          box-shadow 200ms ease,
5633          opacity 200ms ease;
5634        opacity: 0.85;
5635      }
5636      .chat-fab:hover {
5637        transform: scale(1.06);
5638        box-shadow: 0 6px 28px rgba(0, 0, 0, 0.14);
5639        opacity: 1;
5640      }
5641      .chat-fab.hidden {
5642        display: none;
5643      }
5644      .scroll-top-btn {
5645        position: fixed;
5646        right: max(24px, env(safe-area-inset-right));
5647        bottom: max(86px, calc(env(safe-area-inset-bottom) + 86px));
5648        z-index: 900;
5649        width: 38px;
5650        height: 38px;
5651        border: 1px solid var(--border-default);
5652        border-radius: 999px;
5653        background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
5654        color: var(--fg-muted);
5655        cursor: pointer;
5656        display: inline-flex;
5657        align-items: center;
5658        justify-content: center;
5659        box-shadow: 0 18px 34px -28px rgba(0, 0, 0, 0.45);
5660        backdrop-filter: blur(10px);
5661        -webkit-backdrop-filter: blur(10px);
5662        opacity: 0;
5663        pointer-events: none;
5664        transform: translateY(8px);
5665        transition:
5666          opacity 180ms ease,
5667          transform 180ms ease,
5668          color 180ms ease,
5669          border-color 180ms ease,
5670          background-color 180ms ease;
5671      }
5672      .scroll-top-btn.is-visible {
5673        opacity: 0.82;
5674        pointer-events: auto;
5675        transform: translateY(0);
5676      }
5677      .scroll-top-btn:hover,
5678      .scroll-top-btn:focus-visible {
5679        background: var(--bg-subtle);
5680        border-color: var(--border-strong);
5681        color: var(--fg-default);
5682        opacity: 1;
5683      }
5684      .scroll-top-btn.is-visible:active {
5685        transform: translateY(1px);
5686      }
5687      .scroll-top-btn svg {
5688        width: 16px;
5689        height: 16px;
5690      }
5691      body.library-open .scroll-top-btn,
5692      body.mindmap-open .scroll-top-btn,
5693      body.mv-more-open .scroll-top-btn {
5694        opacity: 0;
5695        pointer-events: none;
5696        transform: translateY(8px);
5697      }
5698      body.chat-open .scroll-top-btn {
5699        right: calc(360px + max(24px, env(safe-area-inset-right)));
5700      }
5701      @media (max-width: 640px) {
5702        .scroll-top-btn {
5703          right: 50%;
5704          bottom: max(16px, env(safe-area-inset-bottom));
5705          transform: translate(50%, 8px);
5706        }
5707        .scroll-top-btn.is-visible {
5708          transform: translate(50%, 0);
5709        }
5710        .scroll-top-btn.is-visible:active {
5711          transform: translate(50%, 1px);
5712        }
5713        body.library-open .scroll-top-btn,
5714        body.mindmap-open .scroll-top-btn,
5715        body.mv-more-open .scroll-top-btn {
5716          transform: translate(50%, 8px);
5717        }
5718      }
5719      @media (max-width: 600px) {
5720        body.chat-open .scroll-top-btn {
5721          opacity: 0;
5722          pointer-events: none;
5723          transform: translate(50%, 8px);
5724        }
5725      }
5726
5727      .chat-panel {
5728        position: fixed;
5729        top: 0;
5730        right: 0;
5731        bottom: 0;
5732        z-index: 200;
5733        width: 360px;
5734        max-width: 90vw;
5735        height: 100vh;
5736        background: var(--bg-raised);
5737        border-left: 1px solid var(--border-soft);
5738        border-radius: 0;
5739        box-shadow: -4px 0 30px rgba(0, 0, 0, 0.05);
5740        display: flex;
5741        flex-direction: column;
5742        overflow: hidden;
5743        transform: translateX(0);
5744        transition:
5745          transform 350ms cubic-bezier(0.22, 1, 0.36, 1),
5746          visibility 350ms;
5747        visibility: visible;
5748      }
5749      /* Keep panel in render tree so transitions work */
5750      #chatPanel[hidden] {
5751        display: flex !important;
5752      }
5753      .chat-panel.hidden {
5754        transform: translateX(100%);
5755        visibility: hidden;
5756        pointer-events: none;
5757      }
5758      /* Push main content + toolbar when sidebar is open */
5759      #main,
5760      .toolbar {
5761        transition:
5762          margin-right 300ms cubic-bezier(0.4, 0, 0.2, 1),
5763          margin-left 300ms cubic-bezier(0.4, 0, 0.2, 1);
5764      }
5765      body.chat-open #main,
5766      body.chat-open .toolbar {
5767        margin-right: 360px;
5768      }
5769      body.library-open #main,
5770      body.library-open .toolbar {
5771        margin-left: 320px;
5772      }
5773
5774      /* Library drawer stagger — when `body.library-open` is set,
5775         direct children of #libraryBody fade/slide in with a 40ms
5776         cascade. Capped at item 12 so very long libraries don't
5777         crawl. Reduced-motion users get the instant show via the
5778         global reset. */
5779      body.library-open #libraryBody > * {
5780        animation: library-item-enter var(--motion-panel) var(--ease-out) both;
5781      }
5782      body.library-open #libraryBody > *:nth-child(1) {
5783        animation-delay: 0ms;
5784      }
5785      body.library-open #libraryBody > *:nth-child(2) {
5786        animation-delay: 40ms;
5787      }
5788      body.library-open #libraryBody > *:nth-child(3) {
5789        animation-delay: 80ms;
5790      }
5791      body.library-open #libraryBody > *:nth-child(4) {
5792        animation-delay: 120ms;
5793      }
5794      body.library-open #libraryBody > *:nth-child(5) {
5795        animation-delay: 160ms;
5796      }
5797      body.library-open #libraryBody > *:nth-child(6) {
5798        animation-delay: 200ms;
5799      }
5800      body.library-open #libraryBody > *:nth-child(7) {
5801        animation-delay: 240ms;
5802      }
5803      body.library-open #libraryBody > *:nth-child(8) {
5804        animation-delay: 280ms;
5805      }
5806      body.library-open #libraryBody > *:nth-child(9) {
5807        animation-delay: 320ms;
5808      }
5809      body.library-open #libraryBody > *:nth-child(10) {
5810        animation-delay: 360ms;
5811      }
5812      body.library-open #libraryBody > *:nth-child(11) {
5813        animation-delay: 400ms;
5814      }
5815      body.library-open #libraryBody > *:nth-child(12) {
5816        animation-delay: 440ms;
5817      }
5818      body.library-open #libraryBody > *:nth-child(n + 13) {
5819        animation-delay: 480ms;
5820      }
5821
5822      @keyframes library-item-enter {
5823        from {
5824          opacity: 0;
5825          transform: translateX(-6px);
5826        }
5827        to {
5828          opacity: 1;
5829          transform: translateX(0);
5830        }
5831      }
5832      body.mindmap-open {
5833        overflow: hidden;
5834      }
5835      @media (max-width: 600px) {
5836        body.chat-open #main,
5837        body.chat-open .toolbar,
5838        body.library-open #main,
5839        body.library-open .toolbar {
5840          margin-right: 0;
5841          margin-left: 0;
5842        }
5843        .chat-panel {
5844          width: 100vw;
5845          max-width: 100vw;
5846        }
5847      }
5848
5849      .chat-header {
5850        display: flex;
5851        align-items: center;
5852        gap: 8px;
5853        padding: 12px 14px;
5854        border-bottom: 1px solid var(--border-soft);
5855        font-family: var(--font-sans);
5856        font-size: 13px;
5857        font-weight: 600;
5858        color: var(--fg-default);
5859        flex-shrink: 0;
5860      }
5861      .chat-header-title {
5862        flex: 1;
5863      }
5864      .chat-header-btn {
5865        background: none;
5866        border: none;
5867        color: var(--fg-muted);
5868        cursor: pointer;
5869        padding: 4px;
5870        border-radius: 6px;
5871        display: flex;
5872        align-items: center;
5873        justify-content: center;
5874      }
5875      .chat-header-btn:hover {
5876        color: var(--fg-default);
5877        background: var(--bg-subtle);
5878      }
5879
5880      .chat-messages {
5881        flex: 1;
5882        overflow-y: auto;
5883        padding: 12px 14px;
5884        display: flex;
5885        flex-direction: column;
5886        gap: 10px;
5887        font-family: var(--font-sans);
5888        font-size: 13px;
5889        line-height: 1.55;
5890        color: var(--fg-default);
5891      }
5892      .chat-msg {
5893        padding: 8px 12px;
5894        border-radius: 10px;
5895        max-width: 88%;
5896        word-wrap: break-word;
5897        white-space: pre-wrap;
5898      }
5899      .chat-msg.user {
5900        align-self: flex-end;
5901        background: var(--fg-default);
5902        color: var(--bg-canvas);
5903      }
5904      .chat-msg.assistant {
5905        align-self: flex-start;
5906        background: var(--bg-subtle);
5907        color: var(--fg-default);
5908      }
5909      .chat-msg.system {
5910        align-self: center;
5911        font-size: 11.5px;
5912        color: var(--fg-subtle);
5913        background: none;
5914        padding: 4px 0;
5915        text-align: center;
5916      }
5917      .chat-msg.markdown-rendered {
5918        white-space: normal;
5919        line-height: 1.6;
5920      }
5921      .chat-msg.markdown-rendered > *:first-child {
5922        margin-top: 0;
5923      }
5924      .chat-msg.markdown-rendered > *:last-child {
5925        margin-bottom: 0;
5926      }
5927      .chat-msg.markdown-rendered p {
5928        margin: 0 0 0.8em;
5929      }
5930      .chat-msg.markdown-rendered h1,
5931      .chat-msg.markdown-rendered h2,
5932      .chat-msg.markdown-rendered h3,
5933      .chat-msg.markdown-rendered h4,
5934      .chat-msg.markdown-rendered h5,
5935      .chat-msg.markdown-rendered h6 {
5936        margin: 1.1em 0 0.45em;
5937        line-height: 1.25;
5938        font-family: var(--font-sans);
5939        font-weight: 600;
5940        letter-spacing: -0.015em;
5941      }
5942      .chat-msg.markdown-rendered h1 {
5943        font-size: 1.28em;
5944      }
5945      .chat-msg.markdown-rendered h2 {
5946        font-size: 1.16em;
5947      }
5948      .chat-msg.markdown-rendered h3,
5949      .chat-msg.markdown-rendered h4,
5950      .chat-msg.markdown-rendered h5,
5951      .chat-msg.markdown-rendered h6 {
5952        font-size: 1.02em;
5953      }
5954      .chat-msg.markdown-rendered ul,
5955      .chat-msg.markdown-rendered ol {
5956        margin: 0 0 0.8em;
5957        padding-left: 1.4em;
5958      }
5959      .chat-msg.markdown-rendered li + li {
5960        margin-top: 0.28em;
5961      }
5962      .chat-msg.markdown-rendered li > p {
5963        margin: 0.35em 0;
5964      }
5965      .chat-msg.markdown-rendered a {
5966        color: var(--accent-fg);
5967        text-decoration: underline;
5968        text-decoration-thickness: 1px;
5969        text-decoration-color: color-mix(
5970          in srgb,
5971          var(--accent-fg) 28%,
5972          transparent
5973        );
5974        text-underline-offset: 2px;
5975      }
5976      .chat-msg.markdown-rendered a:hover {
5977        color: var(--accent-hover);
5978        text-decoration-color: currentColor;
5979      }
5980      .chat-msg.markdown-rendered blockquote {
5981        margin: 0 0 0.9em;
5982        padding: 0.1em 0 0.1em 0.9em;
5983        color: var(--fg-muted);
5984        border-left: 3px solid var(--blockquote-border);
5985      }
5986      .chat-msg.markdown-rendered .markdown-alert {
5987        margin: 0 0 0.9em;
5988        padding: 0.8em 0.9em 0.85em;
5989      }
5990      .chat-msg.markdown-rendered .markdown-alert-title {
5991        font-size: 0.85rem;
5992      }
5993      .chat-msg.markdown-rendered hr {
5994        border: 0;
5995        border-top: 1px solid var(--border-soft);
5996        margin: 1em 0;
5997      }
5998      .chat-msg.markdown-rendered code,
5999      .chat-msg.markdown-rendered tt {
6000        padding: 0.14em 0.38em;
6001        font-family: var(--font-mono);
6002        font-size: 0.9em;
6003        color: var(--fg-default);
6004        background: var(--code-inline-bg);
6005        border-radius: 4px;
6006        white-space: break-spaces;
6007      }
6008      .chat-msg.markdown-rendered pre {
6009        margin: 0 0 0.9em;
6010        padding: 12px 14px;
6011        overflow-x: auto;
6012        background: var(--code-block-bg);
6013        border: 1px solid var(--border-soft);
6014        border-radius: 8px;
6015      }
6016      .chat-msg.markdown-rendered pre code,
6017      .chat-msg.markdown-rendered pre tt {
6018        display: inline;
6019        padding: 0;
6020        background: transparent;
6021        border: 0;
6022        border-radius: 0;
6023        white-space: pre;
6024      }
6025      .chat-msg.markdown-rendered .raw-html {
6026        border-style: dashed;
6027      }
6028      .chat-msg.markdown-rendered table {
6029        display: block;
6030        width: max-content;
6031        max-width: 100%;
6032        overflow-x: auto;
6033        margin: 0 0 0.9em;
6034        border-spacing: 0;
6035        border-collapse: collapse;
6036        font-size: 0.94em;
6037      }
6038      .chat-msg.markdown-rendered th,
6039      .chat-msg.markdown-rendered td {
6040        padding: 8px 10px;
6041        text-align: left;
6042        border-bottom: 1px solid var(--border-soft);
6043      }
6044      .chat-msg.markdown-rendered th {
6045        color: var(--fg-muted);
6046        fo
6046nt-weight: 600;
6047      }
6048      .chat-msg.markdown-rendered img {
6049        display: block;
6050        max-width: 100%;
6051        margin: 0 0 0.9em;
6052        border-radius: 8px;
6053      }
6054      .chat-msg.markdown-rendered .blocked-resource {
6055        display: block;
6056        margin: 0 0 0.9em;
6057        padding: 10px 12px;
6058        color: var(--fg-muted);
6059        background: var(--bg-muted);
6060        border: 1px solid var(--border-soft);
6061        border-radius: 8px;
6062      }
6063      .chat-msg.assistant .typing-dots::after {
6064        content: "";
6065        animation: typingDots 1.2s steps(4, end) infinite;
6066      }
6067      @keyframes typingDots {
6068        0% {
6069          content: "";
6070        }
6071        25% {
6072          content: ".";
6073        }
6074        50% {
6075          content: "..";
6076        }
6077        75% {
6078          content: "...";
6079        }
6080      }
6081
6082      .chat-input-area {
6083        padding: 12px 14px 14px;
6084        border-top: 1px solid var(--border-soft);
6085        flex-shrink: 0;
6086      }
6087      /* Single card wrapping textarea + action row */
6088      .chat-input-card {
6089        display: flex;
6090        flex-direction: column;
6091        border: 1px solid var(--border-default);
6092        border-radius: 14px;
6093        background: var(--bg-canvas);
6094        transition:
6095          border-color 200ms ease,
6096          box-shadow 200ms ease;
6097      }
6098      .chat-input-card:focus-within {
6099        border-color: var(--accent-fg);
6100        box-shadow: 0 0 0 3px var(--selection);
6101      }
6102      .chat-input {
6103        flex: 1;
6104        border: none;
6105        border-radius: 14px 14px 0 0;
6106        padding: 12px 14px 6px;
6107        font-family: var(--font-sans);
6108        font-size: 13.5px;
6109        color: var(--fg-default);
6110        background: transparent;
6111        outline: none;
6112        resize: none;
6113        min-height: 64px;
6114        max-height: 140px;
6115        line-height: 1.55;
6116      }
6117      .chat-input::placeholder {
6118        color: var(--fg-subtle);
6119      }
6120      /* Bottom row: hint left, send button right */
6121      .chat-input-actions {
6122        display: flex;
6123        align-items: center;
6124        justify-content: space-between;
6125        padding: 6px 10px 8px;
6126      }
6127      .chat-input-hint {
6128        font-family: var(--font-sans);
6129        font-size: 11px;
6130        color: var(--fg-subtle);
6131        user-select: none;
6132      }
6133      .chat-send-btn {
6134        width: 32px;
6135        height: 32px;
6136        border-radius: 50%;
6137        border: none;
6138        background: var(--fg-default);
6139        color: var(--bg-canvas);
6140        cursor: pointer;
6141        display: flex;
6142        align-items: center;
6143        justify-content: center;
6144        flex-shrink: 0;
6145        transition:
6146          opacity 120ms ease,
6147          transform 120ms ease;
6148      }
6149      .chat-send-btn:not(:disabled):hover {
6150        transform: scale(1.08);
6151      }
6152      .chat-send-btn:disabled {
6153        opacity: 0.3;
6154        cursor: not-allowed;
6155      }
6156
6157      /* ---------- AI Settings ---------- */
6158      .chat-settings {
6159        padding: 12px 14px;
6160        border-top: 1px solid var(--border-soft);
6161        font-family: var(--font-sans);
6162        font-size: 12px;
6163        color: var(--fg-muted);
6164        display: flex;
6165        flex-direction: column;
6166        gap: 8px;
6167        flex-shrink: 0;
6168      }
6169      .chat-settings.hidden {
6170        display: none;
6171      }
6172      .chat-settings label {
6173        display: flex;
6174        flex-direction: column;
6175        gap: 3px;
6176      }
6177      .chat-settings label span {
6178        font-weight: 600;
6179        font-size: 11px;
6180        text-transform: uppercase;
6181        letter-spacing: 0.04em;
6182        color: var(--fg-subtle);
6183      }
6184      .chat-settings input,
6185      .chat-settings select {
6186        border: 1px solid var(--border-default);
6187        border-radius: 6px;
6188        padding: 5px 8px;
6189        font-size: 12px;
6190        color: var(--fg-default);
6191        background: var(--bg-canvas);
6192        font-family: var(--font-mono);
6193      }
6194      .chat-settings input:focus,
6195      .chat-settings select:focus {
6196        outline: none;
6197        border-color: var(--accent-fg);
6198      }
6199      .chat-settings-note {
6200        margin: 0;
6201        color: var(--fg-subtle);
6202        font-size: 11px;
6203        line-height: 1.45;
6204      }
6205
6206      /* The old inline chat settings panel is now permanently hidden.
6207         Its inputs remain in the DOM as the source of truth for AI config —
6208         the new global settings drawer mirrors values into them. */
6209      #chatSettings {
6210        display: none !important;
6211      }
6212
6213      /* ---------- Files Drawer ---------- */
6214      .lib-overlay {
6215        position: fixed;
6216        inset: 0;
6217        background: rgba(10, 14, 20, 0);
6218        backdrop-filter: blur(0px);
6219        -webkit-backdrop-filter: blur(0px);
6220        z-index: 9996;
6221        pointer-events: none;
6222        transition:
6223          background-color 220ms ease,
6224          backdrop-filter 220ms ease;
6225      }
6226      .lib-overlay.is-open {
6227        background: rgba(10, 14, 20, 0.16);
6228        backdrop-filter: blur(3px);
6229        -webkit-backdrop-filter: blur(3px);
6230        pointer-events: auto;
6231      }
6232      :root[data-theme="dark"] .lib-overlay.is-open {
6233        background: rgba(0, 0, 0, 0.38);
6234      }
6235
6236      .lib-drawer {
6237        position: fixed;
6238        top: 0;
6239        left: 0;
6240        height: 100vh;
6241        width: min(320px, 100vw);
6242        background: var(--bg-canvas);
6243        border-right: 1px solid var(--border-soft);
6244        box-shadow: none;
6245        z-index: 9997;
6246        display: flex;
6247        flex-direction: column;
6248        color: var(--fg-default);
6249        transform: translateX(-100%);
6250        transition: transform 260ms cubic-bezier(0.2, 0.7, 0.2, 1);
6251      }
6252      .lib-drawer.is-open {
6253        box-shadow: 24px 0 48px -32px rgba(0, 0, 0, 0.18);
6254        transform: translateX(0);
6255      }
6256      .lib-header {
6257        display: flex;
6258        align-items: center;
6259        gap: 10px;
6260        padding: 20px 18px 4px;
6261        flex-shrink: 0;
6262      }
6263      .lib-header-copy {
6264        flex: 1;
6265        min-width: 0;
6266      }
6267      .lib-header-title {
6268        margin: 0;
6269        font-family: var(--font-serif);
6270        font-size: 1.05rem;
6271        font-weight: 600;
6272        letter-spacing: -0.02em;
6273        color: var(--fg-default);
6274      }
6275      .lib-header-help {
6276        display: none;
6277      }
6278      .lib-close {
6279        background: transparent;
6280        border: 1px solid transparent;
6281        border-radius: 8px;
6282        width: 28px;
6283        height: 28px;
6284        display: inline-flex;
6285        align-items: center;
6286        justify-content: center;
6287        color: var(--fg-subtle);
6288        cursor: pointer;
6289        transition:
6290          background-color 140ms ease,
6291          color 140ms ease;
6292      }
6293      .lib-close:hover {
6294        background: var(--bg-subtle);
6295        color: var(--fg-default);
6296      }
6297      .lib-actions {
6298        display: flex;
6299        align-items: center;
6300        flex-wrap: wrap;
6301        gap: 10px;
6302        padding: 10px 18px 10px;
6303        flex-shrink: 0;
6304      }
6305      .lib-file-action {
6306        display: inline-flex;
6307        align-items: center;
6308        gap: 8px;
6309        padding: 7px 12px 7px 10px;
6310        background: transparent;
6311        border: 1px dashed var(--border-default);
6312        border-radius: 10px;
6313        color: var(--fg-muted);
6314        font-family: var(--font-sans);
6315        font-size: 12.5px;
6316        font-weight: 500;
6317        cursor: pointer;
6318        transition:
6319          color 140ms ease,
6320          border-color 140ms ease,
6321          background-color 140ms ease;
6322      }
6323      .lib-file-action:hover:not(:disabled) {
6324        color: var(--fg-default);
6325        border-color: var(--border-strong);
6326        background: var(--bg-subtle);
6327      }
6328      .lib-file-action:focus-visible {
6329        outline: none;
6330        box-shadow: 0 0 0 3px var(--selection);
6331      }
6332      .lib-file-action:disabled {
6333        opacity: 0.5;
6334        cursor: not-allowed;
6335      }
6336      .lib-file-action-icon {
6337        display: inline-flex;
6338        color: var(--fg-subtle);
6339        transform: translateY(-0.5px);
6340      }
6341      .lib-file-action-icon svg {
6342        width: 14px;
6343        height: 14px;
6344        flex-shrink: 0;
6345      }
6346      .lib-toggle-all {
6347        appearance: none;
6348        display: inline-flex;
6349        align-items: center;
6350        gap: 5px;
6351        padding: 0;
6352        margin-left: 2px;
6353        border: 0;
6354        background: transparent;
6355        color: var(--fg-subtle);
6356        font: inherit;
6357        font-size: 11.5px;
6358        line-height: 1;
6359        cursor: pointer;
6360        transition: color 140ms ease;
6361      }
6362      .lib-toggle-all[hidden] {
6363        display: none;
6364      }
6365      .lib-toggle-all:hover,
6366      .lib-toggle-all:focus-visible {
6367        color: var(--fg-default);
6368      }
6369      .lib-toggle-all svg {
6370        width: 9px;
6371        height: 9px;
6372        flex-shrink: 0;
6373        opacity: 0.75;
6374        transition: opacity 140ms ease;
6375      }
6376      .lib-toggle-all:hover svg,
6377      .lib-toggle-all:focus-visible svg {
6378        opacity: 1;
6379      }
6380      .lib-supported-note {
6381        margin: 2px 18px 10px;
6382        padding: 9px 11px;
6383        font-size: 11.5px;
6384        line-height: 1.55;
6385        color: var(--fg-subtle);
6386        background: var(--bg-subtle);
6387        border-radius: 9px;
6388      }
6389      .lib-body {
6390        flex: 1;
6391        min-height: 0;
6392        overflow: auto;
6393        padding: 6px 8px 24px;
6394      }
6395      .lib-empty {
6396        padding: 32px 18px 24px;
6397        text-align: center;
6398        color: var(--fg-subtle);
6399      }
6400      .lib-empty-title {
6401        margin: 0 0 6px;
6402        font-family: var(--font-serif);
6403        font-size: 14px;
6404        font-weight: 500;
6405        letter-spacing: -0.01em;
6406        color: var(--fg-muted);
6407      }
6408      .lib-empty-copy {
6409        margin: 0;
6410        font-size: 12px;
6411        line-height: 1.6;
6412        color: var(--fg-subtle);
6413      }
6414      .lib-drafts {
6415        margin: 0;
6416        padding: 8px 0 12px;
6417        border-bottom: 1px solid var(--border-soft);
6418      }
6419      .lib-drafts + .lib-folder {
6420        margin-top: 8px;
6421      }
6422      .lib-section-label {
6423        display: block;
6424        padding: 0 12px 6px;
6425        font-family: var(--font-sans);
6426        font-size: 10.5px;
6427        font-weight: 700;
6428        letter-spacing: 0.12em;
6429        text-transform: uppercase;
6430        color: var(--fg-subtle);
6431      }
6432      .lib-draft-node {
6433        display: flex;
6434        align-items: center;
6435        gap: 4px;
6436        min-width: 0;
6437      }
6438      .lib-draft-row {
6439        position: relative;
6440        flex: 1;
6441        min-width: 0;
6442        display: flex;
6443        align-items: center;
6444        gap: 7px;
6445        padding: 6px 10px;
6446        border: 0;
6447        background: transparent;
6448        border-radius: 7px;
6449        color: var(--fg-muted);
6450        font: inherit;
6451        font-size: 12.5px;
6452        text-align: left;
6453        cursor: pointer;
6454        transition:
6455          background-color 140ms ease,
6456          color 140ms ease;
6457      }
6458      .lib-draft-row:hover {
6459        background: var(--bg-subtle);
6460        color: var(--fg-default);
6461      }
6462      .lib-draft-row.is-active {
6463        color: var(--fg-default);
6464        font-weight: 500;
6465      }
6466      .lib-draft-row.is-active::before {
6467        content: "";
6468        position: absolute;
6469        left: 3px;
6470        top: 6px;
6471        bottom: 6px;
6472        width: 2px;
6473        border-radius: 1px;
6474        background: color-mix(in srgb, var(--accent-fg) 55%, transparent);
6475      }
6476      .lib-draft-dot {
6477        width: 14px;
6478        height: 14px;
6479        display: inline-flex;
6480        align-items: center;
6481        justify-content: center;
6482        color: var(--fg-subtle);
6483        flex-shrink: 0;
6484      }
6485      .lib-draft-dot::before {
6486        content: "";
6487        width: 7px;
6488        height: 9px;
6489        border: 1.3px solid currentColor;
6490        border-radius: 2px;
6491        opacity: 0.52;
6492      }
6493      .lib-draft-row[data-dirty="true"] .lib-draft-dot::before {
6494        width: 6px;
6495        height: 6px;
6496        border: 0;
6497        border-radius: 999px;
6498        background: color-mix(in srgb, var(--accent-fg) 62%, transparent);
6499        opacity: 0.95;
6500      }
6501      .lib-draft-label {
6502        min-width: 0;
6503        overflow: hidden;
6504        text-overflow: ellipsis;
6505        white-space: nowrap;
6506      }
6507      .lib-draft-discard {
6508        appearance: none;
6509        width: 24px;
6510        height: 24px;
6511        border: 0;
6512        border-radius: 6px;
6513        background: transparent;
6514        color: var(--fg-subtle);
6515        cursor: pointer;
6516        display: inline-flex;
6517        align-items: center;
6518        justify-content: center;
6519        opacity: 0;
6520        flex-shrink: 0;
6521        transition:
6522          opacity 140ms ease,
6523          color 140ms ease,
6524          background-color 140ms ease;
6525      }
6526      .lib-draft-node:hover .lib-draft-discard,
6527      .lib-draft-node:focus-within .lib-draft-discard {
6528        opacity: 1;
6529      }
6530      .lib-draft-discard:hover {
6531        color: color-mix(in srgb, var(--alert-warning) 72%, var(--fg-default));
6532        background: var(--bg-subtle);
6533      }
6534      .lib-draft-discard svg {
6535        width: 13px;
6536        height: 13px;
6537      }
6538      @media (hover: none) {
6539        .lib-draft-discard {
6540          opacity: 0.55;
6541        }
6542      }
6543      .lib-folder {
6544        margin: 0;
6545        padding: 10px 0 4px;
6546        position: relative;
6547        transition: opacity 140ms ease;
6548      }
6549      .lib-folder + .lib-folder {
6550        margin-top: 8px;
6551        padding-top: 14px;
6552        border-top: 1px solid var(--border-soft);
6553      }
6554      .lib-folder[data-dragging="true"] {
6555        opacity: 0.35;
6556      }
6557      .lib-folder[data-drop="before"]::before,
6558      .lib-folder[data-drop="after"]::after {
6559        content: "";
6560        position: absolute;
6561        left: 8px;
6562        right: 8px;
6563        height: 2px;
6564        background: var(--accent-fg);
6565        border-radius: 2px;
6566        pointer-events: none;
6567      }
6568      .lib-folder[data-drop="before"]::before {
6569        top: -1px;
6570      }
6571      .lib-folder[data-drop="after"]::after {
6572        bottom: -1px;
6573      }
6574      .lib-folder-head {
6575        padding: 4px 10px;
6576        cursor: pointer;
6577        border-radius: 8px;
6578        transition: background-color 140ms ease;
6579      }
6580      .lib-folder-head:hover {
6581        background: var(--bg-subtle);
6582      }
6583      .lib-folder-top {
6584        display: flex;
6585        align-items: flex-start;
6586        gap: 8px;
6587      }
6588      .lib-folder-toggle {
6589        appearance: none;
6590        border: 0;
6591        background: transparent;
6592        padding: 4px;
6593        margin: -2px 0 -2px -4px;
6594        color: var(--fg-subtle);
6595        cursor: pointer;
6596        border-radius: 6px;
6597        display: inline-flex;
6598        align-items: center;
6599        justify-content: center;
6600        flex-shrink: 0;
6601        transition:
6602          color 140ms ease,
6603          background-color 140ms ease;
6604      }
6605      .lib-folder-toggle:hover,
6606      .lib-folder-toggle:focus-visible {
6607        color: var(--fg-default);
6608        background: var(--bg-subtle);
6609      }
6610      .lib-folder-toggle svg {
6611        width: 10px;
6612        height: 10px;
6613        transition: transform 160ms ease;
6614      }
6615      .lib-folder-toggle[aria-expanded="true"] svg {
6616        transform: rotate(90deg);
6617      }
6618      .lib-folder-meta {
6619        flex: 1;
6620        min-width: 0;
6621        display: flex;
6622        align-items: baseline;
6623        flex-wrap: nowrap;
6624        column-gap: 8px;
6625        row-gap: 2px;
6626      }
6627      .lib-folder-name {
6628        margin: 0;
6629        font-family: var(--font-serif);
6630        font-size: 13.5px;
6631        font-weight: 500;
6632        line-height: 1.3;
6633        letter-spacing: -0.01em;
6634        color: var(--fg-default);
6635        min-width: 0;
6636        overflow: hidden;
6637        text-overflow: ellipsis;
6638        white-space: nowrap;
6639      }
6640      .lib-folder-sub {
6641        display: none;
6642      }
6643      .lib-folder-status {
6644        margin: 0;
6645        padding: 0;
6646        background: transparent;
6647        font-size: 11px;
6648        color: var(--fg-subtle);
6649        display: inline;
6650      }
6651      .lib-folder-status[data-status="ready"] {
6652        display: none;
6653      }
6654      .lib-folder-status[data-status="needs-permission"] {
6655        color: color-mix(in srgb, var(--alert-warning) 70%, var(--fg-default));
6656      }
6657      .lib-folder-status[data-status="loading"]::before {
6658        content: "";
6659        display: inline-block;
6660        width: 6px;
6661        height: 6px;
6662        border-radius: 50%;
6663        background: currentColor;
6664        margin-right: 6px;
6665        vertical-align: baseline;
6666        opacity: 0.5;
6667        animation: lib-pulse 900ms ease-in-out infinite alternate;
6668      }
6669      @keyframes lib-pulse {
6670        from {
6671          opacity: 0.3;
6672        }
6673        to {
6674          opacity: 0.85;
6675        }
6676      }
6677      .lib-folder-actions {
6678        display: inline-flex;
6679        flex-wrap: wrap;
6680        gap: 10px;
6681        margin: 0 0 0 auto;
6682        flex-shrink: 0;
6683        opacity: 0;
6684        transition: opacity 140ms ease;
6685      }
6686      .lib-folder:hover .lib-folder-actions,
6687      .lib-folder:focus-within .lib-folder-actions {
6688        opacity: 1;
6689      }
6690      @media (hover: none) {
6691        .lib-folder-actions {
6692          opacity: 0.55;
6693        }
6694      }
6695      .lib-action-btn {
6696        appearance: none;
6697        border: 0;
6698        background: transparent;
6699        color: var(--fg-subtle);
6700        border-radius: 0;
6701        padding: 0;
6702        font: inherit;
6703        font-size: 11.5px;
6704        cursor: pointer;
6705        transition: color 140ms ease;
6706      }
6707      .lib-action-btn:hover {
6708        background: transparent;
6709        color: var(--fg-default);
6710        border-color: transparent;
6711      }
6712      .lib-action-btn[data-action="remove-folder"]:hover {
6713        color: color-mix(in srgb, var(--alert-warning) 72%, var(--fg-default));
6714      }
6715      .lib-tree {
6716        padding: 1px 0 4px;
6717      }
6718      .lib-tree-node {
6719        display: block;
6720      }
6721      .lib-tree-row {
6722        position: relative;
6723        width: 100%;
6724        display: flex;
6725        align-items: center;
6726        gap: 6px;
6727        padding: 5px 10px;
6728        border: 0;
6729        background: transparent;
6730        border-radius: 7px;
6731        color: var(--fg-muted);
6732        font: inherit;
6733        font-size: 12.5px;
6734        text-align: left;
6735        cursor: pointer;
6736        transition:
6737          background-color 140ms ease,
6738          color 140ms ease;
6739      }
6740      .lib-tree-row:hover {
6741        background: var(--bg-subtle);
6742        color: var(--fg-default);
6743      }
6744      .lib-tree-row.is-active {
6745        background: transparent;
6746        color: var(--fg-default);
6747        font-weight: 500;
6748      }
6749      .lib-tree-row.is-active::before {
6750        content: "";
6751        position: absolute;
6752        left: 3px;
6753        top: 6px;
6754        bottom: 6px;
6755        width: 2px;
6756        border-radius: 1px;
6757        background: color-mix(in srgb, var(--accent-fg) 55%, transparent);
6758      }
6759      .lib-tree-row:focus-visible,
6760      .lib-draft-row:focus-visible,
6761      .lib-draft-discard:focus-visible,
6762      .lib-close:focus-visible,
6763      .lib-toggle-all:focus-visible,
6764      .lib-folder-toggle:focus-visible,
6765      .lib-action-btn:focus-visible {
6766        outline: none;
6767        box-shadow: 0 0 0 3px var(--selection);
6768      }
6769      .lib-tree-row[data-kind="directory"] {
6770        color: var(--fg-muted);
6771        font-weight: 500;
6772      }
6773      .lib-tree-caret {
6774        width: 14px;
6775        height: 14px;
6776        display: inline-flex;
6777        align-items: center;
6778        justify-content: center;
6779        font-size: 9px;
6780        color: var(--fg-subtle);
6781        flex-shrink: 0;
6782        transition:
6783          transform 140ms ease,
6784          opacity 140ms ease;
6785        opacity: 0.7;
6786      }
6787      .lib-tree-row[data-kind="file"] .lib-tree-caret {
6788        opacity: 0.18;
6789      }
6790      .lib-tree-row[data-kind="directory"] .lib-tree-caret {
6791        font-size: 0;
6792        line-height: 0;
6793        background-color: currentColor;
6794        color: var(--fg-subtle);
6795        -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='white' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M1.5 5V11.25A1 1 0 0 0 2.5 12.25H11.5A1 1 0 0 0 12.5 11.25V5.5A1 1 0 0 0 11.5 4.5H7L5.5 3H2.5A1 1 0 0 0 1.5 4Z'/></svg>")
6796          center / 13px 13px no-repeat;
6797        mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='white' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M1.5 5V11.25A1 1 0 0 0 2.5 12.25H11.5A1 1 0 0 0 12.5 11.25V5.5A1 1 0 0 0 11.5 4.5H7L5.5 3H2.5A1 1 0 0 0 1.5 4Z'/></svg>")
6798          center / 13px 13px no-repeat;
6799        opacity: 0.58;
6800      }
6801      .lib-tree-row[data-kind="directory"][aria-expanded="true"]
6802        .lib-tree-caret {
6803        transform: none;
6804        opacity: 0.82;
6805      }
6806      .lib-tree-row[data-kind="directory"]:hover .lib-tree-caret {
6807        opacity: 0.88;
6808      }
6809      .lib-tree-row[aria-expanded="true"] .lib-tree-caret {
6810        transform: rotate(90deg);
6811      }
6812      .lib-tree-children {
6813        margin-left: 0;
6814        padding-left: 0;
6815        border-left: 0;
6816      }
6817      .lib-tree-label {
6818        min-width: 0;
6819        flex: 1;
6820        overflow: hidden;
6821        text-overflow: ellipsis;
6822        white-space: nowrap;
6823      }
6824      .lib-tree-empty {
6825        padding: 3px 12px 0 42px;
6826        font-size: 11.5px;
6827        font-style: italic;
6828        color: var(--fg-subtle);
6829      }
6830
6831      /* ---------- Global Settings Drawer ---------- */
6832      .gs-overlay {
6833        position: fixed;
6834        inset: 0;
6835        background: rgba(10, 14, 20, 0);
6836        backdrop-filter: blur(0px);
6837        -webkit-backdrop-filter: blur(0px);
6838        z-index: 9998;
6839        pointer-events: none;
6840        transition:
6841          background-color 220ms ease,
6842          backdrop-filter 220ms ease;
6843      }
6844      .gs-overlay.is-open {
6845        background: rgba(10, 14, 20, 0.32);
6846        backdrop-filter: blur(3px);
6847        -webkit-backdrop-filter: blur(3px);
6848        pointer-events: auto;
6849      }
6850      :root[data-theme="dark"] .gs-overlay.is-open {
6851        background: rgba(0, 0, 0, 0.5);
6852      }
6853
6854      .gs-drawer {
6855        position: fixed;
6856        top: 0;
6857        right: 0;
6858        height: 100vh;
6859        width: min(440px, 100vw);
6860        background: var(--bg-canvas);
6861        border-left: 1px solid var(--border-soft);
6862        box-shadow: none;
6863        z-index: 9999;
6864        display: flex;
6865        flex-direction: column;
6866        font-family: var(--font-sans);
6867        color: var(--fg-default);
6868        transform: translateX(100%);
6869        transition: transform 260ms cubic-bezier(0.2, 0.7, 0.2, 1);
6870      }
6871      .gs-drawer.is-open {
6872        box-shadow: -24px 0 48px -24px rgba(0, 0, 0, 0.24);
6873        transform: translateX(0);
6874      }
6875      @media (prefers-reduced-motion: reduce) {
6876        .lib-drawer,
6877        .lib-overlay,
6878        .gs-drawer,
6879        .gs-overlay {
6880          transition: none;
6881        }
6882      }
6883
6884      .gs-header {
6885        display: flex;
6886        align-items: center;
6887        gap: 12px;
6888        padding: 18px 20px 16px;
6889        border-bottom: 1px solid var(--border-soft);
6890        flex-shrink: 0;
6891      }
6892      .gs-header-title {
6893        font-size: 15px;
6894        font-weight: 600;
6895        letter-spacing: -0.01em;
6896        color: var(--fg-default);
6897        margin: 0;
6898        flex: 1;
6899      }
6900      .gs-close {
6901        background: transparent;
6902        border: 1px solid transparent;
6903        border-radius: 6px;
6904        width: 28px;
6905        height: 28px;
6906        display: inline-flex;
6907        align-items: center;
6908        justify-content: center;
6909        cursor: pointer;
6910        color: var(--fg-muted);
6911        transition:
6912          background-color 140ms ease,
6913          color 140ms ease,
6914          border-color 140ms ease;
6915      }
6916      .gs-close:hover {
6917        background: var(--border-soft);
6918        color: var(--fg-default);
6919      }
6920      .gs-close:focus-visible {
6921        outline: none;
6922        border-color: var(--accent-fg);
6923      }
6924
6925      .gs-body {
6926        flex: 1;
6927        overflow-y: auto;
6928        padding: 20px;
6929        display: flex;
6930        flex-direction: column;
6931        gap: 18px;
6932      }
6933
6934      .gs-section-label {
6935        font-size: 10.5p
6935x;
6936        font-weight: 600;
6937        text-transform: uppercase;
6938        letter-spacing: 0.08em;
6939        color: var(--fg-subtle);
6940        margin: 0 0 10px;
6941      }
6942
6943      /* Status chip */
6944      .gs-status {
6945        display: inline-flex;
6946        align-items: center;
6947        gap: 8px;
6948        padding: 8px 12px;
6949        border-radius: 999px;
6950        background: var(--border-soft);
6951        font-size: 12px;
6952        font-weight: 500;
6953        color: var(--fg-muted);
6954        align-self: flex-start;
6955        transition:
6956          background-color 160ms ease,
6957          color 160ms ease;
6958      }
6959      .gs-status-dot {
6960        width: 7px;
6961        height: 7px;
6962        border-radius: 50%;
6963        background: var(--fg-subtle);
6964        box-shadow: 0 0 0 0 rgba(86, 171, 100, 0);
6965        transition:
6966          background-color 160ms ease,
6967          box-shadow 160ms ease;
6968      }
6969      .gs-status.is-ready {
6970        background: rgba(86, 171, 100, 0.14);
6971        color: rgb(46, 120, 60);
6972      }
6973      .gs-status.is-ready .gs-status-dot {
6974        background: rgb(86, 171, 100);
6975        box-shadow: 0 0 0 3px rgba(86, 171, 100, 0.18);
6976      }
6977      :root[data-theme="dark"] .gs-status.is-ready {
6978        color: rgb(140, 210, 150);
6979      }
6980      .gs-status-text-mono {
6981        font-family: var(--font-mono);
6982        font-size: 11px;
6983        color: inherit;
6984        opacity: 0.85;
6985      }
6986
6987      /* Segmented control */
6988      .gs-segmented {
6989        display: grid;
6990        grid-template-columns: 1fr 1fr;
6991        gap: 4px;
6992        padding: 4px;
6993        background: var(--border-soft);
6994        border-radius: 10px;
6995        position: relative;
6996      }
6997      .gs-seg-btn {
6998        appearance: none;
6999        background: transparent;
7000        border: 1px solid transparent;
7001        border-radius: 6px;
7002        padding: 6px 12px;
7003        font: inherit;
7004        font-size: 12.5px;
7005        font-weight: 500;
7006        color: var(--fg-muted);
7007        cursor: pointer;
7008        display: inline-flex;
7009        align-items: center;
7010        justify-content: center;
7011        gap: 7px;
7012        transition:
7013          background-color 180ms ease,
7014          color 180ms ease,
7015          box-shadow 180ms ease;
7016      }
7017      .gs-seg-btn:hover {
7018        color: var(--fg-default);
7019      }
7020      .gs-seg-btn[aria-pressed="true"] {
7021        background: var(--bg-canvas);
7022        color: var(--fg-default);
7023        box-shadow:
7024          0 1px 2px rgba(0, 0, 0, 0.06),
7025          0 1px 4px rgba(0, 0, 0, 0.04);
7026      }
7027      :root[data-theme="dark"] .gs-seg-btn[aria-pressed="true"] {
7028        background: rgba(255, 255, 255, 0.06);
7029        box-shadow:
7030          0 1px 2px rgba(0, 0, 0, 0.4),
7031          inset 0 0 0 1px rgba(255, 255, 255, 0.04);
7032      }
7033      .gs-seg-btn:focus-visible {
7034        outline: none;
7035        border-color: var(--accent-fg);
7036      }
7037      .gs-seg-icon {
7038        width: 14px;
7039        height: 14px;
7040        flex-shrink: 0;
7041      }
7042
7043      /* Provider config cards */
7044      .gs-card {
7045        border: 1px solid var(--border-soft);
7046        border-radius: 12px;
7047        padding: 16px;
7048        background: var(--bg-canvas);
7049        display: flex;
7050        flex-direction: column;
7051        gap: 14px;
7052        opacity: 0;
7053        transform: translateY(4px);
7054        animation: gs-card-in 260ms ease forwards;
7055      }
7056      @keyframes gs-card-in {
7057        to {
7058          opacity: 1;
7059          transform: translateY(0);
7060        }
7061      }
7062      .gs-card[hidden] {
7063        display: none;
7064      }
7065
7066      .gs-field {
7067        display: flex;
7068        flex-direction: column;
7069        gap: 6px;
7070      }
7071      .gs-field-label {
7072        font-size: 11px;
7073        font-weight: 600;
7074        text-transform: uppercase;
7075        letter-spacing: 0.06em;
7076        color: var(--fg-subtle);
7077      }
7078      .gs-input-wrap {
7079        position: relative;
7080        display: flex;
7081        align-items: center;
7082      }
7083      .gs-input {
7084        flex: 1;
7085        border: 1px solid var(--border-default);
7086        border-radius: 8px;
7087        padding: 10px 12px;
7088        font-size: 13px;
7089        color: var(--fg-default);
7090        background: var(--bg-canvas);
7091        font-family: var(--font-mono);
7092        transition:
7093          border-color 140ms ease,
7094          box-shadow 140ms ease;
7095      }
7096      .gs-input:focus {
7097        outline: none;
7098        border-color: var(--accent-fg);
7099        box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.16);
7100      }
7101      .gs-input.has-reveal {
7102        padding-right: 40px;
7103      }
7104      .gs-reveal-btn {
7105        position: absolute;
7106        right: 6px;
7107        top: 50%;
7108        transform: translateY(-50%);
7109        background: transparent;
7110        border: 1px solid transparent;
7111        border-radius: 6px;
7112        width: 28px;
7113        height: 28px;
7114        display: inline-flex;
7115        align-items: center;
7116        justify-content: center;
7117        cursor: pointer;
7118        color: var(--fg-subtle);
7119        transition:
7120          color 140ms ease,
7121          background-color 140ms ease;
7122      }
7123      .gs-reveal-btn:hover {
7124        color: var(--fg-default);
7125        background: var(--border-soft);
7126      }
7127      .gs-reveal-btn:focus-visible {
7128        outline: none;
7129        border-color: var(--accent-fg);
7130      }
7131
7132      .gs-field-hint {
7133        font-size: 11px;
7134        color: var(--fg-subtle);
7135        line-height: 1.45;
7136        margin: 0;
7137      }
7138      .gs-field-hint a {
7139        color: var(--accent-fg);
7140        text-decoration: none;
7141      }
7142      .gs-field-hint a:hover {
7143        text-decoration: underline;
7144      }
7145
7146      .gs-actions-row {
7147        display: flex;
7148        gap: 8px;
7149        align-items: center;
7150        flex-wrap: wrap;
7151      }
7152      .gs-test-btn {
7153        appearance: none;
7154        background: var(--bg-canvas);
7155        border: 1px solid var(--border-default);
7156        border-radius: 8px;
7157        padding: 8px 14px;
7158        font: inherit;
7159        font-size: 12px;
7160        font-weight: 500;
7161        color: var(--fg-default);
7162        cursor: pointer;
7163        display: inline-flex;
7164        align-items: center;
7165        gap: 6px;
7166        transition:
7167          border-color 140ms ease,
7168          background-color 140ms ease,
7169          color 140ms ease;
7170      }
7171      .gs-test-btn:hover:not(:disabled) {
7172        border-color: var(--accent-fg);
7173        color: var(--accent-fg);
7174      }
7175      .gs-test-btn:disabled {
7176        opacity: 0.55;
7177        cursor: not-allowed;
7178      }
7179      .gs-test-result {
7180        font-size: 11.5px;
7181        color: var(--fg-muted);
7182        display: inline-flex;
7183        align-items: center;
7184        gap: 5px;
7185      }
7186      .gs-test-result.is-ok {
7187        color: rgb(46, 120, 60);
7188      }
7189      .gs-test-result.is-err {
7190        color: rgb(190, 70, 70);
7191      }
7192      :root[data-theme="dark"] .gs-test-result.is-ok {
7193        color: rgb(140, 210, 150);
7194      }
7195      :root[data-theme="dark"] .gs-test-result.is-err {
7196        color: rgb(240, 140, 140);
7197      }
7198
7199      /* Card header: context subtitle + activation affordance */
7200      .gs-card-head {
7201        display: flex;
7202        align-items: center;
7203        justify-content: space-between;
7204        gap: 12px;
7205        margin: -2px 0 2px;
7206      }
7207      .gs-card-sub {
7208        flex: 1 1 auto;
7209        min-width: 0;
7210        font-size: 11.5px;
7211        color: var(--fg-subtle);
7212        margin: 0;
7213        line-height: 1.4;
7214      }
7215      .gs-in-use-badge,
7216      .gs-activate-btn {
7217        flex-shrink: 0;
7218      }
7219      .gs-activate-btn {
7220        appearance: none;
7221        background: transparent;
7222        border: 1px solid var(--border-default);
7223        border-radius: 999px;
7224        padding: 6px 12px;
7225        font: inherit;
7226        font-size: 11.5px;
7227        font-weight: 500;
7228        color: var(--fg-muted);
7229        cursor: pointer;
7230        transition:
7231          border-color 160ms ease,
7232          color 160ms ease,
7233          background-color 160ms ease,
7234          opacity 160ms ease;
7235      }
7236      .gs-activate-btn:hover:not(:disabled) {
7237        border-color: var(--accent-fg);
7238        color: var(--accent-fg);
7239      }
7240      .gs-activate-btn:disabled {
7241        opacity: 0.5;
7242        cursor: not-allowed;
7243        border-color: var(--border-soft);
7244        color: var(--fg-muted);
7245      }
7246      .gs-activate-btn:focus-visible {
7247        outline: none;
7248        border-color: var(--accent-fg);
7249        box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.16);
7250      }
7251      .gs-in-use-badge {
7252        display: inline-flex;
7253        align-items: center;
7254        gap: 6px;
7255        padding: 6px 12px;
7256        border-radius: 999px;
7257        background: rgba(86, 171, 100, 0.14);
7258        color: rgb(46, 120, 60);
7259        font-size: 11.5px;
7260        font-weight: 500;
7261        letter-spacing: 0.01em;
7262      }
7263      .gs-in-use-badge::before {
7264        content: "";
7265        width: 6px;
7266        height: 6px;
7267        border-radius: 50%;
7268        background: rgb(86, 171, 100);
7269        box-shadow: 0 0 0 3px rgba(86, 171, 100, 0.18);
7270      }
7271      :root[data-theme="dark"] .gs-in-use-badge {
7272        color: rgb(140, 210, 150);
7273      }
7274      [hidden].gs-in-use-badge,
7275      [hidden].gs-activate-btn {
7276        display: none;
7277      }
7278
7279      .gs-foot {
7280        padding: 14px 20px;
7281        border-top: 1px solid var(--border-soft);
7282        font-size: 11px;
7283        color: var(--fg-subtle);
7284        line-height: 1.5;
7285        flex-shrink: 0;
7286      }
7287
7288      .doc-highlight {
7289        color: inherit;
7290        cursor: pointer;
7291        background: var(--reader-highlight-bg, rgba(180, 200, 140, 0.22));
7292        box-shadow: var(
7293          --reader-highlight-shadow,
7294          inset 0 -1px 0 rgba(140, 170, 100, 0.22)
7295        );
7296        border-radius: 0.22em;
7297        box-decoration-break: clone;
7298        -webkit-box-decoration-break: clone;
7299        transition:
7300          background-color 180ms ease,
7301          box-shadow 180ms ease;
7302      }
7303      :root[data-theme="dark"][data-reader-palette="original"] .doc-highlight {
7304        background: rgba(180, 200, 140, 0.16);
7305        box-shadow: inset 0 -1px 0 rgba(180, 200, 140, 0.16);
7306      }
7307      .doc-highlight:hover,
7308      .doc-highlight.is-active {
7309        background: var(--reader-highlight-hover-bg, rgba(180, 200, 140, 0.36));
7310        box-shadow: var(
7311          --reader-highlight-hover-shadow,
7312          inset 0 -1px 0 rgba(140, 170, 100, 0.32)
7313        );
7314      }
7315      :root[data-theme="dark"][data-reader-palette="original"]
7316        .doc-highlight:hover,
7317      :root[data-theme="dark"][data-reader-palette="original"]
7318        .doc-highlight.is-active {
7319        background: rgba(180, 200, 140, 0.28);
7320        box-shadow: inset 0 -1px 0 rgba(180, 200, 140, 0.24);
7321      }
7322
7323      .selection-bubble,
7324      .ctx-menu,
7325      .highlight-remove-chip,
7326      .highlight-undo {
7327        position: fixed;
7328        z-index: 2000;
7329        background: var(--bg-raised);
7330        border: 1px solid var(--border-soft);
7331        border-radius: 12px;
7332        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
7333      }
7334      :root[data-theme="dark"] .selection-bubble,
7335      :root[data-theme="dark"] .ctx-menu,
7336      :root[data-theme="dark"] .highlight-remove-chip,
7337      :root[data-theme="dark"] .highlight-undo {
7338        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
7339      }
7340      .selection-bubble.hidden,
7341      .ctx-menu.hidden,
7342      .highlight-remove-chip.hidden,
7343      .highlight-undo.hidden {
7344        display: none;
7345      }
7346
7347      .selection-bubble {
7348        display: flex;
7349        align-items: center;
7350        gap: 6px;
7351        padding: 6px;
7352        transform: translateY(6px);
7353        opacity: 0;
7354        transition:
7355          opacity 120ms ease,
7356          transform 120ms ease;
7357      }
7358      .selection-bubble.is-visible {
7359        transform: translateY(0);
7360        opacity: 1;
7361      }
7362      .selection-action,
7363      .highlight-remove-chip,
7364      .ctx-menu-item,
7365      .highlight-undo-btn {
7366        touch-action: manipulation;
7367      }
7368      .selection-action {
7369        display: inline-flex;
7370        align-items: center;
7371        gap: 6px;
7372        min-height: 32px;
7373        padding: 0 10px;
7374        border: 1px solid transparent;
7375        border-radius: 8px;
7376        background: transparent;
7377        color: var(--fg-default);
7378        font-family: var(--font-sans);
7379        font-size: 12px;
7380        font-weight: 600;
7381        cursor: pointer;
7382        white-space: nowrap;
7383        transition:
7384          background-color 140ms ease,
7385          border-color 140ms ease,
7386          color 140ms ease,
7387          transform 120ms ease;
7388      }
7389      .selection-action:hover {
7390        background: var(--bg-subtle);
7391      }
7392      .selection-action:active {
7393        transform: scale(0.98);
7394      }
7395      .selection-action svg {
7396        flex-shrink: 0;
7397        color: var(--fg-muted);
7398      }
7399      .selection-action-primary {
7400        background: color-mix(in srgb, var(--accent-fg) 9%, transparent);
7401        border-color: color-mix(in srgb, var(--accent-fg) 24%, transparent);
7402        color: var(--accent-hover);
7403      }
7404      .selection-action-primary:hover {
7405        background: color-mix(in srgb, var(--accent-fg) 14%, transparent);
7406        border-color: color-mix(in srgb, var(--accent-fg) 34%, transparent);
7407        color: var(--accent-hover);
7408      }
7409      .selection-action-primary svg {
7410        color: currentColor;
7411      }
7412
7413      .highlight-remove-chip {
7414        display: inline-flex;
7415        align-items: center;
7416        gap: 6px;
7417        min-height: 32px;
7418        padding: 0 11px;
7419        color: var(--fg-default);
7420        font-family: var(--font-sans);
7421        font-size: 12px;
7422        font-weight: 600;
7423        cursor: pointer;
7424      }
7425      .highlight-remove-chip:hover {
7426        background: var(--bg-subtle);
7427      }
7428
7429      .highlight-undo {
7430        z-index: 2100;
7431        right: 20px;
7432        bottom: 20px;
7433        display: flex;
7434        align-items: center;
7435        gap: 12px;
7436        max-width: min(360px, calc(100vw - 32px));
7437        padding: 10px 12px;
7438      }
7439      .highlight-undo-message {
7440        font-family: var(--font-sans);
7441        font-size: 12px;
7442        line-height: 1.45;
7443        color: var(--fg-default);
7444      }
7445      .highlight-undo-btn {
7446        display: inline-flex;
7447        align-items: center;
7448        justify-content: center;
7449        min-height: 30px;
7450        padding: 0 10px;
7451        border: 0;
7452        border-radius: 7px;
7453        background: var(--fg-default);
7454        color: var(--bg-canvas);
7455        font-family: var(--font-sans);
7456        font-size: 12px;
7457        font-weight: 600;
7458        cursor: pointer;
7459      }
7460
7461      /* ---------- Custom Context Menu ---------- */
7462      .ctx-menu {
7463        padding: 4px;
7464        min-width: 180px;
7465        font-family: var(--font-sans);
7466        font-size: 13px;
7467      }
7468      .ctx-menu-item {
7469        display: flex;
7470        align-items: center;
7471        gap: 8px;
7472        padding: 7px 10px;
7473        border-radius: 6px;
7474        cursor: pointer;
7475        color: var(--fg-default);
7476        border: none;
7477        background: none;
7478        width: 100%;
7479        text-align: left;
7480        font-size: 13px;
7481        font-family: var(--font-sans);
7482      }
7483      .ctx-menu-item:hover {
7484        background: var(--bg-subtle);
7485      }
7486      .ctx-menu-item svg {
7487        flex-shrink: 0;
7488        color: var(--fg-muted);
7489      }
7490
7491      @media (pointer: coarse) {
7492        .selection-bubble {
7493          display: none !important;
7494        }
7495        .highlight-remove-chip {
7496          min-height: 44px;
7497          padding: 0 16px;
7498          font-size: 14px;
7499          border-radius: 12px;
7500        }
7501      }
7502
7503      @media (prefers-reduced-motion: reduce) {
7504        .selection-bubble,
7505        .doc-highlight {
7506          transition: none;
7507        }
7508        .ont-section-badge.is-loading {
7509          animation: none;
7510        }
7511      }
7512
7513      @media print {
7514        .selection-bubble,
7515        .ctx-menu,
7516        .highlight-remove-chip,
7517        .highlight-undo,
7518        .chat-fab,
7519        .scroll-top-btn,
7520        .chat-panel,
7521        .ont-panel {
7522          display: none !important;
7523        }
7524        .doc-highlight {
7525          background: transparent !important;
7526          box-shadow: inset 0 -1px 0 currentColor !important;
7527        }
7528      }
7529    </style>
7530  </head>
7531  <body>
7532    <div class="app">
7533      <header class="toolbar" role="toolbar" aria-label="Viewer controls">
7534        <div
7535          class="brand"
7536          title="Markdown Viewer"
7537          aria-label="MarkV — Markdown Viewer"
7538        >
7539          <span class="brand-mark" aria-hidden="true">
7540            <svg
7541              xmlns="http://www.w3.org/2000/svg"
7542              viewBox="0 0 100 100"
7543              fill="currentColor"
7544              aria-hidden="true"
7545              focusable="false"
7546            >
7547              <!-- Four quadrants: straight inner edges to the cross gap, concave outer arcs through each corner -->
7548              <path d="M46 0L46 46L0 46Q6 6 46 0Z" />
7549              <path d="M54 0L54 46L100 46Q94 6 54 0Z" />
7550              <path d="M46 100L46 54L0 54Q6 94 46 100Z" />
7551              <path d="M54 100L54 54L100 54Q94 94 54 100Z" />
7552            </svg>
7553          </span>
7554          <span class="brand-name" id="brandName">MarkV</span>
7555          <span
7556            id="brandDirty"
7557            class="brand-dirty"
7558            aria-hidden="true"
7559            hidden
7560          ></span>
7561        </div>
7562
7563        <div id="status" class="status">Ready</div>
7564        <span id="wordCount" class="word-count" hidden></span>
7565        <div
7566          id="liveRegion"
7567          class="visually-hidden"
7568          aria-live="polite"
7569          aria-atomic="true"
7570        ></div>
7571        <div class="spacer"></div>
7572
7573        <!-- ── Cluster: File — New · Files · Save ───────────────
7574             Save auto-hides in preview mode via existing CSS
7575             `body.preview-mode #saveBtn { display: none !important }`. -->
7576        <div class="tb-group" role="group" aria-label="File">
7577          <button
7578            id="newDocumentBtn"
7579            type="button"
7580            class="btn btn--new"
7581            title="New entry"
7582          >
7583            <span class="btn-prefix" aria-hidden="true">+</span>
7584            New
7585          </button>
7586
7587          <button
7588            id="libraryToggleBtn"
7589            type="button"
7590            class="btn"
7591            title="Open files"
7592            aria-label="Open files"
7593            aria-haspopup="dialog"
7594            aria-controls="libraryDrawer"
7595            aria-expanded="false"
7596          >
7597            <svg
7598              width="15"
7599              height="15"
7600              viewBox="0 0 24 24"
7601              fill="none"
7602              stroke="currentColor"
7603              stroke-width="1.75"
7604              stroke-linecap="round"
7605              stroke-linejoin="round"
7606              aria-hidden="true"
7607            >
7608              <path
7609                d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"
7610              />
7611            </svg>
7612            Files
7613          </button>
7614
7615          <button
7616            id="commandPaletteBtn"
7617            type="button"
7618            class="btn"
7619            data-shortcut-id="global.search"
7620            data-title-base="Search files and commands"
7621            title="Search files and commands"
7622            aria-label="Search files and commands"
7623            aria-haspopup="dialog"
7624            aria-controls="commandPaletteDialog"
7625            aria-expanded="false"
7626            data-mv-overflow-section="File"
7627          >
7628            Search
7629          </button>
7630
7631          <button
7632            id="saveBtn"
7633            type="button"
7634            class="btn"
7635            data-shortcut-id="global.save"
7636            data-title-base="Save current document"
7637            title="Save current document"
7638            disabled
7639            hidden
7640            data-mv-overflow-section="File"
7641          >
7642            Save
7643          </button>
7644        </div>
7645
7646        <span class="tb-divider" aria-hidden="true"></span>
7647
7648        <!-- ── Cluster: Edit & insights — Mode · Document insights ──
7649             Edit-mode toggle paired with the document insights popover.
7650             Both pertain to "working with the current document." -->
7651        <div class="tb-group" role="group" aria-label="Edit and insights">
7652          <button
7653            id="modeToggleBtn"
7654            type="button"
7655            class="btn mode-toggle-btn"
7656            data-shortcut-id="global.mode"
7657            data-title-base="Toggle edit mode"
7658            title="Toggle edit mode"
7659            aria-pressed="false"
7660          >
7661            <svg
7662              id="modeIconEdit"
7663              width="14"
7664              height="14"
7665              viewBox="0 0 16 16"
7666              fill="currentColor"
7667              aria-hidden="true"
7668            >
7669              <path
7670                d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"
7671              />
7672            </svg>
7673            <svg
7674              id="modeIconPreview"
7675              width="14"
7676              height="14"
7677              viewBox="0 0 16 16"
7678              fill="currentColor"
7679              aria-hidden="true"
7680              style="display: none"
7681            >
7682              <path
7683                d="M8 2C4.5 2 1.73 4.11.07 7.18a1.5 1.5 0 0 0 0 1.64C1.73 11.89 4.5 14 8 14s6.27-2.11 7.93-5.18a1.5 1.5 0 0 0 0-1.64C14.27 4.11 11.5 2 8 2Zm0 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"
7684              />
7685            </svg>
7686            <span id="modeToggleLabel">Edit</span>
7687          </button>
7688
7689          <div class="ont-settings" data-mv-overflow-section="View">
7690            <button
7691              id="ontologyToggleBtn"
7692              type="button"
7693              class="btn btn-icon"
7694              title="Document insights"
7695              aria-label="Document insights"
7696              aria-expanded="false"
7697              aria-controls="ontologyPanel"
7698            >
7699              <svg
7700                width="14"
7701                height="14"
7702                viewBox="0 0 16 16"
7703                fill="currentColor"
7704                aria-hidden="true"
7705              >
7706                <path
7707                  d="M2.5 4a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0ZM5 2.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm6.5 2a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm2-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM5 7a3 3 0 0 0-3 3v1.75a.75.75 0 0 0 1.5 0V10A1.5 1.5 0 0 1 5 8.5h1a.75.75 0 0 0 0-1.5H5Zm8 3a2 2 0 0 0-2-2h-.5a.75.75 0 0 0 0 1.5h.5a.5.5 0 0 1 .5.5v1.75a.75.75 0 0 0 1.5 0V10Z"
7708                />
7709              </svg>
7710            </button>
7711            <div
7712              id="ontologyPanel"
7713              class="ont-panel hidden"
7714              role="region"
7715              aria-label="Document insights"
7716              aria-hidden="true"
7717              hidden
7718            >
7719              <div class="ont-header">
7720                <span class="ont-title">Document Insights</span>
7721                <div style="display: flex; align-items: center; gap: 8px">
7722                  <span id="ontSummary" class="ont-summary"></span>
7723                  <button
7724                    id="ontCloseBtn"
7725                    type="button"
7726                    class="ont-close"
7727                    aria-label="Close insights"
7728                  >
7729                    &times;
7730                  </button>
7731                </div>
7732              </div>
7733              <div id="ontBody">
7734                <div class="ont-empty">No content to analyze.</div>
7735              </div>
7736            </div>
7737          </div>
7738        </div>
7739
7740        <span class="tb-divider" aria-hidden="true"></span>
7741
7742        <!-- ── Cluster: Refresh — Reload · Auto-refresh ────────── -->
7743        <div
7744          class="tb-group"
7745          role="group"
7746          aria-label="Refresh"
7747          data-mv-overflow-section="Live"
7748        >
7749          <button
7750            id="reloadBtn"
7751            type="button"
7752            class="btn btn-icon"
7753            title="Reload current file"
7754            disabled
7755            aria-label="Reload"
7756          >
7757            <svg
7758              width="14"
7759              height="14"
7760              viewBox="0 0 16 16"
7761              fill="currentColor"
7762              aria-hidden="true"
7763            >
7764              <path
7765                d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .655-.834ZM8 1.5a6.5 6.5 0 0 0-4.95 2.277L1.67 2.397A.25.25 0 0 0 1.244 2.574v3.646a.25.25 0 0 0 .25.25h3.646a.25.25 0 0 0 .177-.427L3.942 4.84A5.5 5.5 0 0 1 13.5 8.16a.75.75 0 0 0 1.49-.16A7.003 7.003 0 0 0 8 1.5Z"
7766              />
7767            </svg>
7768          </button>
7769
7770          <label
7771            class="auto-refresh"
7772            title="Auto refresh works for opened file handles"
7773          >
7774            <input id="autoRefreshToggle" type="checkbox" disabled />
7775            <span>Auto</span>
7776          </label>
7777        </div>
7778
7779        <span class="tb-divider" aria-hidden="true"></span>
7780
7781        <!-- ── Cluster: Display preferences — Reading · Theme ──── -->
7782        <div
7783          class="tb-group"
7784          role="group"
7785          aria-label="Display preferences"
7786          data-mv-overflow-section="View"
7787        >
7788          <div class="reading-settings">
7789            <button
7790              id="readingToggleBtn"
7791              type="button"
7792              class="btn"
7793              title="Adjust reading settings"
7794              aria-haspopup="dialog"
7795              aria-controls="readingPanel"
7796              aria-expanded="false"
7797            >
7798              <svg
7799                width="14"
7800                height="14"
7801                viewBox="0 0 16 16"
7802                fill="currentColor"
7803                aria-hidden="true"
7804              >
7805                <path
7806                  d="M2.5 3a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2.5 3Zm2 5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 4.5 8Zm-2 5a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2.5 13Z"
7807                />
7808              </svg>
7809              Reading
7810            </button>
7811
7812            <div
7813              id="readingPanel"
7814              class="reading-panel"
7815              role="dialog"
7816              aria-modal="false"
7817              aria-labelledby="readingPanelTitle"
7818              aria-describedby="readingPanelHelp"
7819              aria-hidden="true"
7820              hidden
7821            >
7822              <div class="reading-panel-header">
7823                <div>
7824                  <h2 id="readingPanelTitle" class="reading-panel-title">
7825                    Reading
7826                  </h2>
7827                  <p id="readingPanelHelp" class="reading-panel-help">
7828                    Tune reading comfort.
7829                  </p>
7830                </div>
7831                <span
7832                  id="readingPresetCustom"
7833                  class="reading-preset-custom"
7834                  hidden
7835                  >Custom</span
7836                >
7837              </div>
7838
7839              <section class="reading-panel-section">
7840                <span class="reading-section-label">Preset</span>
7841                <div
7842                  id="readingPresetGroup"
7843                  class="reading-preset-group"
7844                  role="radiogroup"
7845                  aria-label="Reading preset"
7846                >
7847                  <button
7848                    type="button"
7849                    class="btn reading-preset"
7850                    data-preset="compact"
7851                    role="radio"
7852                    aria-checked="false"
7853                    tabindex="-1"
7854                  >
7855                    Compact
7856                  </button>
7857                  <button
7858                    type="button"
7859                    class="btn reading-preset"
7860                    data-preset="balanced"
7861                    role="radio"
7862                    aria-checked="true"
7863                    tabindex="0"
7864                  >
7865                    Balanced
7866                  </button>
7867                  <button
7868                    type="button"
7869                    class="btn reading-preset"
7870                    data-preset="spacious"
7871                    role="radio"
7872                    aria-checked="false"
7873                    tabindex="-1"
7874                  >
7875                    Spacious
7876                  </button>
7877                </div>
7878              </section>
7879
7880              <section class="reading-panel-section">
7881                <span id="readingPaletteLabel" class="reading-section-label"
7882                  >Palette</span
7883                >
7884                <div
7885                  id="readingPaletteGroup"
7886                  class="reading-palette-grid"
7887                  role="radiogroup"
7888                  aria-labelledby="readingPaletteLabel"
7889                >
7890                  <button
7891                    type="button"
7892                    class="reading-palette"
7893                    data-reader-palette="original"
7894                    role="radio"
7895                    aria-checked="true"
7896                    tabindex="0"
7897                  >
7898                    <span
7899                      class="reading-palette__swatch"
7900                      aria-hidden="true"
7901                    ></span>
7902                    <span class="reading-palette__label">Original</span>
7903                  </button>
7904                  <button
7905                    type="button"
7906                    class="reading-palette"
7907                    data-reader-palette="ivory"
7908                    role="radio"
7909                    aria-checked="false"
7910                    tabindex="-1"
7911                  >
7912                    <span
7913                      class="reading-palette__swatch"
7914                      aria-hidden="true"
7915                    ></span>
7916                    <span class="reading-palette__label">Ivory</span>
7917                  </button>
7918                  <button
7919                    type="button"
7920                    class="reading-palette"
7921                    data-reader-palette="manilla"
7922                    role="radio"
7923                    aria-checked="false"
7924                    tabindex="-1"
7925                  >
7926                    <span
7927                      class="reading-palette__swatch"
7928                      aria-hidden="true"
7929                    ></span>
7930                    <span class="reading-palette__label">Manilla</span>
7931                  </button>
7932                  <button
7933                    type="button"
7934                    class="reading-palette"
7935                    data-reader-palette="cloud"
7936                    role="radio"
7937                    aria-checked="false"
7938                    tabindex="-1"
7939                  >
7940                    <span
7941                      class="reading-palette__swatch"
7942                      aria-hidden="true"
7943                    ></span>
7944                    <span class="reading-palette__label">Cloud</span>
7945                  </button>
7946                  <button
7947                    type="button"
7948                    class="reading-palette"
7949                    data-reader-palette="slate"
7950                    role="radio"
7951                    aria-checked="false"
7952                    tabindex="-1"
7953                  >
7954                    <span
7955                      class="reading-palette__swatch"
7956                      aria-hidden="true"
7957                    ></span>
7958                    <span class="reading-palette__label">Slate</span>
7959                  </button>
7960                  <button
7961                    type="button"
7962                    class="reading-palette"
7963                    data-reader-palette="garden"
7964                    role="radio"
7965                    aria-checked="false"
7966                    tabindex="-1"
7967                  >
7968                    <span
7969                      class="reading-palette__swatch"
7970                      aria-hidden="true"
7971                    ></span>
7972                    <span class="reading-palette__label">Garden</span>
7973                  </button>
7974                </div>
7975              </section>
7976
7977              <section class="reading-panel-section">
7978                <div class="reading-control">
7979                  <div class="reading-control-head">
7980                    <label for="readingFontSize">Text Size</label>
7981                    <output
7982                      id="readingFontSizeValue"
7983                      class="reading-control-value"
7984                      for="readingFontSize"
7985                    ></output>
7986                  </div>
7987                  <input
7988                    id="readingFontSize"
7989                    class="reading-range"
7990                    type="range"
7991                    min="16"
7992                    max="22"
7993                    step="0.5"
7994                    value="19"
7995                  />
7996                </div>
7997
7998                <div class="reading-control">
7999                  <div class="reading-control-head">
8000                    <label for="readingLineHeight">Line Spacing</label>
8001                    <output
8002                      id="readingLineHeightValue"
8003                      class="reading-control-value"
8004                      for="readingLineHeight"
8005                    ></output>
8006                  </div>
8007                  <input
8008                    id="readingLineHeight"
8009                    class="reading-range"
8010                    type="range"
8011                    min="1.45"
8012                    max="1.9"
8013                    step="0.02"
8014                    value="1.72"
8015                  />
8016                </div>
8017
8018                <div class="reading-control reading-width-control">
8019                  <div class="reading-control-head">
8020                    <label for="readingMeasure">Reading Width</label>
8021                    <output
8022                      id="readingMeasureValue"
8023                      class="reading-control-value"
8024                      for="readingMeasure"
8025                    ></output>
8026                  </div>
8027                  <input
8028                    id="readingMeasure"
8029                    class="reading-range"
8030                    type="range"
8031                    min="560"
8032                    max="820"
8033                    step="20"
8034                    value="680"
8035                  />
8036                </div>
8037              </section>
8038
8039              <div class="reading-actions">
8040                <button
8041                  id="readingResetBtn"
8042                  type="button"
8043                  class="btn"
8044                  title="Reset to the Balanced preset"
8045                >
8046                  Reset to Balanced
8047                </button>
8048                <button
8049                  id="readingCloseBtn"
8050                  type="button"
8051                  class="btn"
8052                  title="Close reading settings"
8053                >
8054                  Close
8055                </button>
8056              </div>
8057            </div>
8058          </div>
8059
8060          <button
8061            id="themeToggleBtn"
8062            type="button"
8063            class="btn btn-icon"
8064            title="Toggle theme"
8065            aria-label="Toggle theme"
8066          >
8067            <!-- Active: state-neutral "contrast" icon. Reads as
8068               "theme toggle" regardless of current mode, following the
8069               iOS/macOS/Material `contrast` convention.
8070               Two-tone design: the filled half uses `--fg-default`
8071               (strong), the stroke uses `--fg-muted` (soft). Both
8072               tokens invert with theme, so light-on-dark becomes
8073               dark-on-light automatically — "vice versa" swap. */ -->
8074            <svg
8075              id="themeIconContrast"
8076              width="15"
8077              height="15"
8078              viewBox="0 0 24 24"
8079              aria-hidden="true"
8080            >
8081              <circle
8082                cx="12"
8083                cy="12"
8084                r="9"
8085                fill="none"
8086                stroke="var(--fg-muted)"
8087                stroke-width="1.75"
8088              />
8089              <path d="M12 3 A9 9 0 0 1 12 21 Z" fill="var(--fg-default)" />
8090            </svg>
8091            <!-- Legacy sun/moon icons preserved for easy revert.
8092               Remove this wrapper (or delete the `.theme-icons-legacy`
8093               CSS rule) to restore the state-aware icon swap. The JS
8094               in applyTheme() continues to toggle their inline display
8095               so swapping back is truly one-line. -->
8096            <span class="theme-icons-legacy" aria-hidden="true">
8097              <svg
8098                id="themeIconSun"
8099                width="14"
8100                height="14"
8101                viewBox="0 0 16 16"
8102                fill="currentColor"
8103                aria-hidden="true"
8104              >
8105                <path
8106                  d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0Zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm8-5a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm10.657-5.657a.75.75 0 0 1 0 1.061l-1.06 1.06a.75.75 0 1 1-1.061-1.06l1.06-1.06a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0Zm9.193 2.121a.75.75 0 0 1-1.06 0l-1.061-1.06a.75.75 0 0 1 1.06-1.061l1.061 1.06a.75.75 0 0 1 0 1.061ZM4.464 4.465a.75.75 0 0 1-1.06 0L2.343 3.404a.75.75 0 0 1 1.06-1.06l1.061 1.06a.75.75 0 0 1 0 1.06Z"
8107                />
8108              </svg>
8109              <svg
8110                id="themeIconMoon"
8111                width="14"
8112                height="14"
8113                viewBox="0 0 16 16"
8114                fill="currentColor"
8115                aria-hidden="true"
8116                style="display: none"
8117              >
8118                <path
8119                  d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"
8120                />
8121              </svg>
8122            </span>
8123          </button>
8124        </div>
8125
8126        <span class="tb-divider" aria-hidden="true"></span>
8127
8128        <!-- ── Cluster: Mindmap, share & settings — Concept map · Snapshot link · Preferences ── -->
8129        <div
8130          class="tb-group"
8131          role="group"
8132          aria-label="Mindmap, share, and settings"
8133          data-mv-overflow-section="Explore"
8134        >
8135          <button
8136            id="mindmapBtn"
8137            type="button"
8138            class="btn"
8139            title="Open concept mindmap"
8140            aria-label="Open concept mindmap"
8141            aria-pressed="false"
8142          >
8143            <svg
8144              width="14"
8145              height="14"
8146              viewBox="0 0 24 24"
8147              fill="none"
8148              stroke="currentColor"
8149              stroke-width="1.75"
8150              stroke-linecap="round"
8151              stroke-linejoin="round"
8152              aria-hidden="true"
8153            >
8154              <circle cx="12" cy="12" r="2.2"></circle>
8155              <circle cx="4" cy="5" r="1.8"></circle>
8156              <circle cx="20" cy="5" r="1.8"></circle>
8157              <circle cx="4" cy="19" r="1.8"></circle>
8158              <circle cx="20" cy="19" r="1.8"></circle>
8159              <path d="M12 12 L5.5 6"></path>
8160              <path d="M12 12 L18.5 6"></path>
8161              <path d="M12 12 L5.5 18"></path>
8162              <path d="M12 12 L18.5 18"></path>
8163            </svg>
8164            <span>Mindmap</span>
8165          </button>
8166
8167          <button
8168            id="shareBtn"
8169            type="button"
8170            class="btn"
8171            title="Share a snapshot link for the current markdown"
8172            disabled
8173          >
8174            <svg
8175              width="14"
8176              height="14"
8177              viewBox="0 0 24 24"
8178              fill="none"
8179              stroke="currentColor"
8180              stroke-width="1.75"
8181              stroke-linecap="round"
8182              stroke-linejoin="round"
8183              aria-hidden="true"
8184            >
8185              <circle cx="18" cy="5" r="2.2"></circle>
8186              <circle cx="6" cy="12" r="2.2"></circle>
8187              <circle cx="18" cy="19" r="2.2"></circle>
8188              <path d="M7.9 10.95 15.95 6.05"></path>
8189              <path d="M7.9 13.05 15.95 17.95"></path>
8190            </svg>
8191            <span id="shareBtnLabel">Share</span>
8192          </button>
8193
8194          <button
8195            id="paletteShortcutHint"
8196            type="button"
8197            class="btn palette-shortcut-hint"
8198            data-shortcut-id="global.search"
8199            aria-label="Open command palette"
8200          >
8201            ⌘K
8202          </button>
8203
8204          <div class="export-split" data-mv-overflow-section="File">
8205            <button
8206              id="exportBtn"
8207              type="button"
8208              class="export-split__main"
8209              title="Export current document"
8210              aria-label="Export current document"
8211              disabled
8212            >
8213              <svg
8214                class="icon"
8215                viewBox="0 0 24 24"
8216                fill="none"
8217                stroke="currentColor"
8218                stroke-width="1.75"
8219                stroke-linecap="round"
8220                stroke-linejoin="round"
8221                aria-hidden="true"
8222              >
8223                <path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
8224              </svg>
8225              <span id="exportLabel">Export PDF</span>
8226            </button>
8227            <span class="export-split__sep" aria-hidden="true"></span>
8228            <button
8229              id="exportCaretBtn"
8230              type="button"
8231              class="export-split__caret"
8232              aria-label="Choose export format"
8233              aria-haspopup="dialog"
8234              aria-controls="exportMenu"
8235              aria-expanded="false"
8236              disabled
8237            >
8238              <svg
8239                viewBox="0 0 24 24"
8240                fill="none"
8241                stroke="currentColor"
8242                stroke-width="2.4"
8243                stroke-linecap="round"
8244                stroke-linejoin="round"
8245                aria-hidden="true"
8246              >
8247                <polyline points="6 9 12 15 18 9" />
8248              </svg>
8249            </button>
8250            <div
8251              id="exportMenu"
8252              class="export-menu"
8253              role="dialog"
8254              aria-label="Choose export format"
8255              hidden
8256            >
8257              <div class="export-menu__head">Format</div>
8258              <button id="exportPdfBtn" type="button" class="export-option">
8259                <span class="export-option__label">PDF</span>
8260                <span class="export-option__ext">.pdf</span>
8261              </button>
8262              <button
8263                id="exportMarkdownBtn"
8264                type="button"
8265                class="export-option"
8266              >
8267                <span class="export-option__label">Markdown</span>
8268                <span class="export-option__ext">.md</span>
8269              </button>
8270            </div>
8271          </div>
8272
8273          <button
8274            id="globalSettingsBtn"
8275            type="button"
8276            class="btn btn-icon"
8277            title="Settings"
8278            aria-label="Open settings"
8279            aria-haspopup="dialog"
8280            aria-controls="globalSettingsDrawer"
8281            aria-expanded="false"
8282          >
8283            <svg
8284              width="14"
8285              height="14"
8286              viewBox="0 0 24 24"
8287              fill="none"
8288              stroke="currentColor"
8289              stroke-width="2"
8290              stroke-linecap="round"
8291              stroke-linejoin="round"
8292              aria-hidden="true"
8293            >
8294              <circle cx="12" cy="12" r="3" />
8295              <path
8296                d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
8297              />
8298            </svg>
8299          </button>
8300        </div>
8301
8302        <span class="tb-divider" aria-hidden="true"></span>
8303
8304        <!-- ── Cluster: Document actions — Clear (destructive) ─── -->
8305        <div
8306          class="tb-group"
8307          role="group"
8308          aria-label="Document actions"
8309          data-mv-overflow-section="Manage"
8310        >
8311          <button
8312            id="clearBtn"
8313            type="button"
8314            class="btn"
8315            title="Clear all content"
8316          >
8317            Clear
8318          </button>
8319        </div>
8320
8321        <!-- ── Mobile-only "More" overflow trigger ─────────────────────
8322             Hidden ≥641px. At ≤640px, JS teleports the secondary tb-group
8323             nodes into #mvMoreSheetBody and toggles this button's
8324             aria-expanded. See assets/toolbar-mobile.js. -->
8325        <button
8326          id="mvMoreBtn"
8327          type="button"
8328          class="btn btn-icon mv-more-btn"
8329          aria-haspopup="dialog"
8330          aria-controls="mvMoreSheet"
8331          aria-expanded="false"
8332          aria-label="More toolbar actions"
8333          title="More"
8334        >
8335          <svg
8336            width="16"
8337            height="16"
8338            viewBox="0 0 24 24"
8339            fill="currentColor"
8340            aria-hidden="true"
8341            focusable="false"
8342          >
8343            <circle cx="5" cy="12" r="1.8" />
8344            <circle cx="12" cy="12" r="1.8" />
8345            <circle cx="19" cy="12" r="1.8" />
8346          </svg>
8347        </button>
8348
8349        <input
8350          id="fileInput"
8351          type="file"
8352          accept=".md,.markdown,.mdown,.mkdn,.txt,text/markdown,text/plain"
8353          hidden
8354        />
8355        <input
8356          id="libraryFolderInput"
8357          type="file"
8358          webkitdirectory
8359          multiple
8360          hidden
8361        />
8362      </header>
8363
8364      <!-- ── Mobile More overflow sheet ───────────────────────────────
8365           Toolbar groups live inside .toolbar on desktop. On ≤640px,
8366           toolbar-mobile.js moves the secondary .tb-group elements into
8367           #mvMoreSheetBody so they remain a single source of truth
8368           (existing event handlers and IDs unchanged). -->
8369      <div
8370        id="mvMoreBackdrop"
8371        class="mv-more-backdrop"
8372        hidden
8373        aria-hidden="true"
8374      ></div>
8375      <aside
8376        id="mvMoreSheet"
8377        class="mv-more-sheet"
8378        role="dialog"
8379        aria-modal="true"
8380        aria-labelledby="mvMoreSheetTitle"
8381        aria-hidden="true"
8382        hidden
8383      >
8384        <header class="mv-more-sheet__header">
8385          <h2 id="mvMoreSheetTitle" class="mv-more-sheet__title">More</h2>
8386          <button
8387            id="mvMoreCloseBtn"
8388            type="button"
8389            class="btn btn-icon mv-more-sheet__close"
8390            aria-label="Close more menu"
8391            title="Close"
8392          >
8393            <svg
8394              width="16"
8395              height="16"
8396              viewBox="0 0 24 24"
8397              fill="none"
8398              stroke="currentColor"
8399              stroke-width="2"
8400              stroke-linecap="round"
8401              stroke-linejoin="round"
8402              aria-hidden="true"
8403              focusable="false"
8404            >
8405              <path d="M6 6l12 12M18 6l-12 12" />
8406            </svg>
8407          </button>
8408        </header>
8409        <div
8410          id="mvMoreSheetBody"
8411          class="mv-more-sheet__body"
8412          role="group"
8413          aria-label="More toolbar actions"
8414        ></div>
8415      </aside>
8416
8417      <div
8418        id="pwaUpdateToast"
8419        class="pwa-update-toast"
8420        role="status"
8421        aria-live="polite"
8422        aria-atomic="true"
8423        hidden
8424      >
8425        <div class="pwa-update-toast__body">
8426          <p class="pwa-update-toast__eyebrow">MarkV</p>
8427          <p id="pwaUpdateToastTitle" class="pwa-update-toast__title">
8428            A newer build is ready.
8429          </p>
8430          <p class="pwa-update-toast__copy">
8431            Refresh when you're ready to pick up the latest shell.
8432          </p>
8433        </div>
8434        <div class="pwa-update-toast__actions">
8435          <button
8436            id="pwaUpdateToastAction"
8437            type="button"
8438            class="btn btn-primary"
8439            title="Refresh MarkV to apply the latest update"
8440          >
8441            Refresh
8442          </button>
8443          <button
8444            id="pwaUpdateToastDismiss"
8445            type="button"
8446            class="btn pwa-update-toast__dismiss"
8447            title="Dismiss update notice"
8448            aria-label="Dismiss update notice"
8449          >
8450            <svg
8451              viewBox="0 0 16 16"
8452              fill="none"
8453              stroke="currentColor"
8454              stroke-width="1.8"
8455              stroke-linecap="round"
8456              aria-hidden="true"
8457            >
8458              <path d="M3.5 3.5 12.5 12.5"></path>
8459              <path d="M12.5 3.5 3.5 12.5"></path>
8460            </svg>
8461          </button>
8462        </div>
8463      </div>
8464
8465      <div
8466        id="appToast"
8467        class="pwa-update-toast app-toast"
8468        role="status"
8469        aria-live="polite"
8470        aria-atomic="true"
8471        aria-hidden="true"
8472        hidden
8473      >
8474        <div class="pwa-update-toast__body">
8475          <p id="appToastEyebrow" class="pwa-update-toast__eyebrow">MarkV</p>
8476          <p id="appToastTitle" class="pwa-update-toast__title"></p>
8477          <p id="appToastMessage" class="pwa-update-toast__copy"></p>
8478        </div>
8479        <div class="pwa-update-toast__actions">
8480          <button
8481            id="appToastDismiss"
8482            type="button"
8483            class="btn pwa-update-toast__dismiss"
8484            title="Dismiss message"
8485            aria-label="Dismiss message"
8486          >
8487            <svg
8488              viewBox="0 0 16 16"
8489              fill="none"
8490              stroke="currentColor"
8491              stroke-width="1.8"
8492              stroke-linecap="round"
8493              aria-hidden="true"
8494            >
8495              <path d="M3.5 3.5 12.5 12.5"></path>
8496              <path d="M12.5 3.5 3.5 12.5"></path>
8497            </svg>
8498          </button>
8499        </div>
8500      </div>
8501
8502      <main class="main" id="main">
8503        <section class="editor-pane" aria-labelledby="editorLabel">
8504          <label id="editorLabel" class="visually-hidden" for="editor"
8505            >Markdown editor</label
8506          >
8507          <textarea
8508            id="editor"
8509            aria-labelledby="editorLabel"
8510            spellcheck="false"
8511            placeholder="# Start typing markdown...
8512
8513Drop a .md file anywhere on the page, or open one from Files."
8514          ></textarea>
8515        </section>
8516
8517        <section
8518          class="preview-pane"
8519          aria-label="Rendered markdown preview"
8520          tabindex="0"
8521        >
8522          <article id="preview" class="markdown-body"></article>
8523          <aside
8524            id="readerConnections"
8525            class="reader-rail"
8526            aria-label="Document navigation and local connections"
8527            hidden
8528          ></aside>
8529        </section>
8530      </main>
8531
8532      <!-- Floating editor formatting toolbar (edit mode, selection-driven) -->
8533      <div
8534        id="editorToolbar"
8535        class="editor-toolbar"
8536        role="toolbar"
8537        aria-label="Text formatting"
8538        hidden
8539      >
8540        <div class="et-buttons">
8541          <button
8542            type="button"
8543            data-et-action="bold"
8544            data-shortcut-id="editor.bold"
8545            data-title-base="Bold"
8546            title="Bold"
8547            aria-label="Bold"
8548          >
8549            <span class="et-glyph et-glyph-b">B</span>
8550          </button>
8551          <button
8552            type="button"
8553            data-et-action="italic"
8554            data-shortcut-id="editor.italic"
8555            data-title-base="Italic"
8556            title="Italic"
8557            aria-label="Italic"
8558          >
8559            <span class="et-glyph et-glyph-i">i</span>
8560          </button>
8561          <button
8562            type="button"
8563            data-et-action="inline-code"
8564            title="Inline code"
8565            aria-label="Inline code"
8566          >
8567            <span class="et-glyph et-glyph-code">`</span>
8568          </button>
8569          <button
8570            type="button"
8571            data-et-action="link"
8572            data-shortcut-id="editor.link"
8573            data-title-base="Link"
8574            title="Link"
8575            aria-label="Link"
8576          >
8577            <svg
8578              width="16"
8579              height="16"
8580              viewBox="0 0 16 16"
8581              fill="none"
8582              stroke="currentColor"
8583              stroke-width="1.6"
8584              stroke-linecap="round"
8585              stroke-linejoin="round"
8586              aria-hidden="true"
8587            >
8588              <path
8589                d="M6.5 9.5a2.8 2.8 0 0 0 4 0l2.4-2.4a2.8 2.8 0 0 0-4-4L7.7 4.3"
8590              />
8591              <path
8592                d="M9.5 6.5a2.8 2.8 0 0 0-4 0L3.1 8.9a2.8 2.8 0 0 0 4 4l1.2-1.2"
8593              />
8594            </svg>
8595          </button>
8596          <span class="et-divider" aria-hidden="true"></span>
8597          <button
8598            type="button"
8599            data-et-action="h1"
8600            title="Heading 1"
8601            aria-label="Heading 1"
8602          >
8603            <span class="et-glyph et-glyph-t1">T</span>
8604          </button>
8605          <button
8606            type="button"
8607            data-et-action="h2"
8608            title="Heading 2"
8609            aria-label="Heading 2"
8610          >
8611            <span class="et-glyph et-glyph-t2">T</span>
8612          </button>
8613          <button
8614            type="button"
8615            data-et-action="quote"
8616            title="Quote"
8617            aria-label="Quote"
8618          >
8619            <span class="et-glyph et-glyph-q">&ldquo;</span>
8620          </button>
8621          <button
8622            type="button"
8623            data-et-action="code-block"
8624            title="Code block"
8625            aria-label="Code block"
8626          >
8627            <svg
8628              width="17"
8629              height="17"
8630              viewBox="0 0 16 16"
8631              fill="none"
8632              stroke="currentColor"
8633              stroke-width="1.45"
8634              stroke-linecap="round"
8635              stroke-linejoin="round"
8636              aria-hidden="true"
8637            >
8638              <path d="M5.8 4.2 2.2 8l3.6 3.8" />
8639              <path d="m10.2 4.2 3.6 3.8-3.6 3.8" />
8640              <path d="m8.8 3.3-1.6 9.4" />
8641            </svg>
8642          </button>
8643          <button
8644            type="button"
8645            data-et-action="bullet-list"
8646            data-shortcut-id="editor.bullet-list"
8647            data-title-base="Bullet list"
8648            title="Bullet list"
8649            aria-label="Bullet list"
8650          >
8651            <svg
8652              width="17"
8653              height="17"
8654              viewBox="0 0 16 16"
8655              fill="none"
8656              stroke="currentColor"
8657              stroke-width="1.45"
8658              stroke-linecap="round"
8659              stroke-linejoin="round"
8660              aria-hidden="true"
8661            >
8662              <path d="M6.2 4h7" />
8663              <path d="M6.2 8h7" />
8664              <path d="M6.2 12h7" />
8665              <path d="M2.8 4h.1" />
8666              <path d="M2.8 8h.1" />
8667              <path d="M2.8 12h.1" />
8668            </svg>
8669          </button>
8670          <button
8671            type="button"
8672            data-et-action="numbered-list"
8673            data-shortcut-id="editor.numbered-list"
8674            data-title-base="Numbered list"
8675            title="Numbered list"
8676            aria-label="Numbered list"
8677          >
8678            <svg
8679              width="17"
8680              height="17"
8681              viewBox="0 0 16 16"
8682              fill="none"
8683              stroke="currentColor"
8684              stroke-width="1.45"
8685              stroke-linecap="round"
8686              stroke-linejoin="round"
8687              aria-hidden="true"
8688            >
8689              <path d="M6.2 4h7" />
8690              <path d="M6.2 8h7" />
8691              <path d="M6.2 12h7" />
8692              <path d="M2.5 3.2h.8v2.2" />
8693              <path
8694                d="M2.4 8.7c.9-.6 1.3-.9 1.3-1.3 0-.3-.3-.5-.6-.5-.3 0-.5.1-.7.3"
8695              />
8696              <path
8697                d="M2.4 11.1c.2-.2.4-.3.7-.3.4 0 .7.2.7.5 0 .4-.3.6-.8.6h-.2"
8698              />
8699            </svg>
8700          </button>
8701          <button
8702            type="button"
8703            data-et-action="image"
8704            title="Image markdown"
8705            aria-label="Image markdown"
8706          >
8707            <svg
8708              width="17"
8709              height="17"
8710              viewBox="0 0 16 16"
8711              fill="none"
8712              stroke="currentColor"
8713              stroke-width="1.45"
8714              stroke-linecap="round"
8715              stroke-linejoin="round"
8716              aria-hidden="true"
8717            >
8718              <rect x="2.5" y="3" width="11" height="10" rx="1.5" />
8719              <path d="m3.5 11 3-3 2 2 1.5-1.5 2.5 2.5" />
8720              <path d="M10.8 5.5h.1" />
8721            </svg>
8722          </button>
8723        </div>
8724        <div class="et-link" role="group" aria-label="Insert link">
8725          <input
8726            type="url"
8727            class="et-link-input"
8728            placeholder="Paste or type a link…"
8729            aria-label="Link URL"
8730            spellcheck="false"
8731            autocomplete="off"
8732          />
8733          <button
8734            type="button"
8735            data-et-action="link-cancel"
8736            title="Cancel"
8737            aria-label="Cancel"
8738          >
8739            &times;
8740          </button>
8741        </div>
8742        <span class="et-arrow" aria-hidden="true"></span>
8743      </div>
8744
8745      <div
8746        id="mermaidViewer"
8747        class="mermaid-viewer"
8748        role="dialog"
8749        aria-modal="true"
8750        aria-labelledby="mermaidViewerTitle"
8751        aria-describedby="mermaidViewerHelp"
8752        aria-hidden="true"
8753        hidden
8754      >
8755        <div class="mermaid-viewer-shell">
8756          <div class="mermaid-viewer-header">
8757            <div class="mermaid-viewer-title-wrap">
8758              <h2 id="mermaidViewerTitle" class="mermaid-viewer-title">
8759                Diagram Viewer
8760              </h2>
8761              <p id="mermaidViewerHelp" class="mermaid-viewer-help">
8762                Zoom, drag to pan, or download the rendered SVG.
8763              </p>
8764            </div>
8765            <div
8766              class="mermaid-viewer-actions"
8767              aria-label="Diagram viewer actions"
8768            >
8769              <!-- Zoom group -->
8770              <button
8771                id="mermaidViewerZoomOutBtn"
8772                type="button"
8773                class="mermaid-viewer-btn"
8774                title="Zoom out (−)"
8775              >
8776                <svg
8777                  viewBox="0 0 24 24"
8778                  fill="none"
8779                  stroke="currentColor"
8780                  stroke-width="1.75"
8781                  stroke-linecap="round"
8782                  stroke-linejoin="round"
8783                  aria-hidden="true"
8784                >
8785                  <circle cx="11" cy="11" r="7"></circle>
8786                  <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
8787                  <line x1="8" y1="11" x2="14" y2="11"></line>
8788                </svg>
8789                <span class="mv-btn-label">Zoom out</span>
8790              </button>
8791              <span
8792                id="mermaidViewerZoomLabel"
8793                class="mermaid-viewer-zoom-label"
8794                >100%</span
8795              >
8796              <button
8797                id="mermaidViewerZoomInBtn"
8798                type="button"
8799                class="mermaid-viewer-btn"
8800                title="Zoom in (+)"
8801              >
8802                <svg
8803                  viewBox="0 0 24 24"
8804                  fill="none"
8805                  stroke="currentColor"
8806                  stroke-width="1.75"
8807                  stroke-linecap="round"
8808                  stroke-linejoin="round"
8809                  aria-hidden="true"
8810                >
8811                  <circle cx="11" cy="11" r="7"></circle>
8812                  <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
8813                  <line x1="11" y1="8" x2="11" y2="14"></line>
8814                  <line x1="8" y1="11" x2="14" y2="11"></line>
8815                </svg>
8816                <span class="mv-btn-label">Zoom in</span>
8817              </button>
8818              <span class="mermaid-viewer-sep" aria-hidden="true"></span>
8819              <!-- View group -->
8820              <button
8821                id="mermaidViewerFitBtn"
8822                type="button"
8823                class="mermaid-viewer-btn"
8824                title="Fit to view (F)"
8825              >
8826                <svg
8827                  viewBox="0 0 24 24"
8828                  fill="none"
8829                  stroke="currentColor"
8830                  stroke-width="1.75"
8831                  stroke-linecap="round"
8832                  stroke-linejoin="round"
8833                  aria-hidden="true"
8834                >
8835                  <path d="M15 3h6v6"></path>
8836                  <path d="M9 21H3v-6"></path>
8837                  <path d="M21 3l-7 7"></path>
8838                  <path d="M3 21l7-7"></path>
8839                </svg>
8840                <span class="mv-btn-label">Fit to view</span>
8841              </button>
8842              <button
8843                id="mermaidViewerResetBtn"
8844                type="button"
8845                class="mermaid-viewer-btn mermaid-viewer-btn--text"
8846                title="Actual size (0)"
8847              >
8848                1:1
8849              </button>
8850              <span class="mermaid-viewer-sep" aria-hidden="true"></span>
8851              <!-- Download -->
8852              <button
8853                id="mermaidViewerDownloadBtn"
8854                type="button"
8855                class="mermaid-viewer-btn"
8856                title="Download SVG"
8857              >
8858                <svg
8859                  viewBox="0 0 24 24"
8860                  fill="none"
8861                  stroke="currentColor"
8862                  stroke-width="1.75"
8863                  stroke-linecap="round"
8864                  stroke-linejoin="round"
8865                  aria-hidden="true"
8866                >
8867                  <path d="M12 3v12"></path>
8868                  <path d="M8 11l4 4 4-4"></path>
8869                  <path d="M20 21H4"></path>
8870                </svg>
8871                <span class="mv-btn-label">Download SVG</span>
8872              </button>
8873              <span class="mermaid-viewer-sep" aria-hidden="true"></span>
8874              <!-- Close -->
8875              <button
8876                id="mermaidViewerCloseBtn"
8877                type="button"
8878                class="mermaid-viewer-btn mermaid-viewer-btn--close"
8879                title="Close (Esc)"
8880              >
8881                <svg
8882                  viewBox="0 0 24 24"
8883                  fill="none"
8884                  stroke="currentColor"
8885                  stroke-width="1.75"
8886                  stroke-linecap="round"
8887                  stroke-linejoin="round"
8888                  aria-hidden="true"
8889                >
8890                  <line x1="18" y1="6" x2="6" y2="18"></line>
8891                  <line x1="6" y1="6" x2="18" y2="18"></line>
8892                </svg>
8893                <span class="mv-btn-label">Close</span>
8894              </button>
8895            </div>
8896          </div>
8897          <div
8898            id="mermaidViewerStage"
8899            class="mermaid-viewer-stage"
8900            tabindex="0"
8901            aria-label="Diagram canvas"
8902          >
8903            <div class="mermaid-viewer-content">
8904              <div
8905                id="mermaidViewerZoomable"
8906                class="mermaid-viewer-zoomable"
8907              ></div>
8908            </div>
8909          </div>
8910          <div class="mermaid-viewer-footer">
8911            <span
8912              ><kbd>+</kbd> <kbd>−</kbd> zoom &nbsp; <kbd>0</kbd> reset &nbsp;
8913              <kbd>F</kbd> fit &nbsp; <kbd>Esc</kbd> close</span
8914            >
8915            <span id="mermaidViewerMeta">No diagram selected.</span>
8916          </div>
8917        </div>
8918      </div>
8919
8920      <div
8921        id="mindmapViewer"
8922        class="mindmap-viewer"
8923        role="dialog"
8924        aria-modal="true"
8925        aria-labelledby="mindmapViewerTitle"
8926        aria-describedby="mindmapViewerHelp"
8927        aria-hidden="true"
8928        hidden
8929      >
8930        <div class="mindmap-viewer-shell">
8931          <div class="mindmap-viewer-header">
8932            <div class="mindmap-viewer-title-wrap">
8933              <h2 id="mindmapViewerTitle" class="mindmap-viewer-title">
8934                Concept Mindmap
8935              </h2>
8936              <p id="mindmapViewerHelp" class="mindmap-viewer-help">
8937                Click a concept to inspect its neighborhood. Drag to pan, scroll
8938                to zoom.
8939              </p>
8940            </div>
8941            <div
8942              class="mindmap-viewer-actions"
8943              aria-label="Mindmap viewer actions"
8944            >
8945              <button
8946                id="mindmapZoomOutBtn"
8947                type="button"
8948                class="mindmap-viewer-btn"
8949                title="Zoom out"
8950              >
8951                <svg
8952                  viewBox="0 0 24 24"
8953                  fill="none"
8954                  stroke="currentColor"
8955                  stroke-width="1.75"
8956                  stroke-linecap="round"
8957                  stroke-linejoin="round"
8958                  aria-hidden="true"
8959                >
8960                  <circle cx="11" cy="11" r="7"></circle>
8961                  <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
8962                  <line x1="8" y1="11" x2="14" y2="11"></line>
8963                </svg>
8964                <span>Out</span>
8965              </button>
8966              <span id="mindmapZoomLabel" class="mindmap-viewer-zoom-label"
8967                >100%</span
8968              >
8969              <button
8970                id="mindmapZoomInBtn"
8971                type="button"
8972                class="mindmap-viewer-btn"
8973                title="Zoom in"
8974              >
8975                <svg
8976                  viewBox="0 0 24 24"
8977                  fill="none"
8978                  stroke="currentColor"
8979                  stroke-width="1.75"
8980                  stroke-linecap="round"
8981                  stroke-linejoin="round"
8982                  aria-hidden="true"
8983                >
8984                  <circle cx="11" cy="11" r="7"></circle>
8985                  <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
8986                  <line x1="11" y1="8" x2="11" y2="14"></line>
8987                  <line x1="8" y1="11" x2="14" y2="11"></line>
8988                </svg>
8989                <span>In</span>
8990              </button>
8991              <button
8992                id="mindmapFitBtn"
8993                type="button"
8994                class="mindmap-viewer-btn"
8995                title="Fit to view"
8996              >
8997                <svg
8998                  viewBox="0 0 24 24"
8999                  fill="none"
9000                  stroke="currentColor"
9001                  stroke-width="1.75"
9002                  stroke-linecap="round"
9003                  stroke-linejoin="round"
9004                  aria-hidden="true"
9005                >
9006                  <path d="M15 3h6v6"></path>
9007                  <path d="M9 21H3v-6"></path>
9008                  <path d="M21 3l-7 7"></path>
9009                  <path d="M3 21l7-7"></path>
9010                </svg>
9011                <span>Fit</span>
9012              </button>
9013              <button
9014                id="mindmapRegenerateBtn"
9015                type="button"
9016                class="mindmap-viewer-btn"
9017                title="Regenerate from current document"
9018              >
9019                <svg
9020                  viewBox="0 0 24 24"
9021                  fill="none"
9022                  stroke="currentColor"
9023                  stroke-width="1.75"
9024                  stroke-linecap="round"
9025                  stroke-linejoin="round"
9026                  aria-hidden="true"
9027                >
9028                  <path d="M21 12a9 9 0 1 1-3-6.7"></path>
9029                  <polyline points="21 4 21 10 15 10"></polyline>
9030                </svg>
9031                <span>Regenerate</span>
9032              </button>
9033              <button
9034                id="mindmapCloseBtn"
9035                type="button"
9036                class="mindmap-viewer-btn"
9037                title="Close (Esc)"
9038                aria-label="Close mindmap viewer"
9039              >
9040                <svg
9041                  viewBox="0 0 24 24"
9042                  fill="none"
9043                  stroke="currentColor"
9044                  stroke-width="1.75"
9045                  stroke-linecap="round"
9046                  stroke-linejoin="round"
9047                  aria-hidden="true"
9048                >
9049                  <line x1="18" y1="6" x2="6" y2="18"></line>
9050                  <line x1="6" y1="6" x2="18" y2="18"></line>
9051                </svg>
9052              </button>
9053            </div>
9054          </div>
9055
9056          <div
9057            id="mindmapLegend"
9058            class="mindmap-legend"
9059            aria-label="Concept type legend"
9060          ></div>
9061
9062          <div id="mindmapStage" class="mindmap-viewer-stage">
9063            <div id="mindmapSearch" class="mindmap-search" role="search">
9064              <svg
9065                class="mindmap-search-icon"
9066                viewBox="0 0 24 24"
9067                fill="none"
9068                stroke="currentColor"
9069                stroke-width="1.75"
9070                stroke-linecap="round"
9071                stroke-linejoin="round"
9072                aria-hidden="true"
9073              >
9074                <circle cx="11" cy="11" r="7"></circle>
9075                <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
9076              </svg>
9077              <input
9078                id="mindmapSearchInput"
9079                class="mindmap-search-input"
9080                type="search"
9081                autocomplete="off"
9082                spellcheck="false"
9083                placeholder="Find concept"
9084                aria-label="Find concept"
9085                aria-controls="mindmapSearchResults"
9086              />
9087              <div
9088                id="mindmapSearchResults"
9089                class="mindmap-search-results is-hidden"
9090                role="listbox"
9091                aria-label="Matching concepts"
9092              ></div>
9093            </div>
9094
9095            <svg
9096              id="mindmapSvg"
9097              class="mindmap-svg"
9098              xmlns="http://www.w3.org/2000/svg"
9099              role="img"
9100              aria-labelledby="mindmapViewerTitle"
9101              aria-describedby="mindmapSrList"
9102            >
9103              <g id="mindmapSvgRoot" class="mindmap-svg-root">
9104                <g id="mindmapEdges"></g>
9105                <g id="mindmapNodes"></g>
9106              </g>
9107            </svg>
9108
9109            <div
9110              id="mindmapSrList"
9111              class="mindmap-sr-only"
9112              aria-live="polite"
9113            ></div>
9114
9115            <div id="mindmapTooltip" class="mm-tooltip" role="tooltip">
9116              <span class="mm-tooltip-type"></span>
9117              <span class="mm-tooltip-label"></span>
9118              <span class="mm-tooltip-summary"></span>
9119            </div>
9120
9121            <aside
9122              id="mindmapInspector"
9123              class="mindmap-inspector"
9124              aria-live="polite"
9125              aria-hidden="true"
9126            ></aside>
9127
9128            <div id="mindmapLoading" class="mm-state-wrap is-hidden">
9129              <div class="mm-spinner" aria-hidden="true"></div>
9130              <p class="mm-state-label">Extracting concepts…</p>
9131            </div>
9132
9133            <div id="mindmapError" class="mm-state-wrap is-hidden">
9134              <p class="mm-state-label" id="mindmapErrorMessage">
9135                Something went wrong.
9136              </p>
9137              <button id="mindmapRetryBtn" type="button" class="mm-state-retry">
9138                Try again
9139              </button>
9140            </div>
9141          </div>
9142        </div>
9143      </div>
9144
9145      <div
9146        id="selectionBubble"
9147        class="selection-bubble hidden"
9148        role="toolbar"
9149        aria-label="Selection actions"
9150        aria-hidden="true"
9151        hidden
9152      >
9153        <button
9154          id="selectionCopyBtn"
9155          type="button"
9156          class="selection-action"
9157          title="Copy selection"
9158        >
9159          <svg
9160            width="13"
9161            height="13"
9162            viewBox="0 0 24 24"
9163            fill="none"
9164            stroke="currentColor"
9165            stroke-width="2"
9166            stroke-linecap="round"
9167            stroke-linejoin="round"
9168            aria-hidden="true"
9169          >
9170            <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
9171            <path
9172              d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
9173            />
9174          </svg>
9175          Copy
9176        </button>
9177        <button
9178          id="selectionHighlightBtn"
9179          type="button"
9180          class="selection-action selection-action-primary"
9181          title="Highlight selection"
9182        >
9183          <svg
9184            width="13"
9185            height="13"
9186            viewBox="0 0 24 24"
9187            fill="none"
9188            stroke="currentColor"
9189            stroke-width="2"
9190            stroke-linecap="round"
9191            stroke-linejoin="round"
9192            aria-hidden="true"
9193          >
9194            <path d="m9 11 6-6 4 4-6 6" />
9195            <path d="M12.5 7.5 16.5 11.5" />
9196            <path d="M4 20h6" />
9197          </svg>
9198          Highlight
9199        </button>
9200        <button
9201          id="selectionAskBtn"
9202          type="button"
9203          class="selection-action"
9204          title="Ask about selection"
9205        >
9206          <svg
9207            width="13"
9208            height="13"
9209            viewBox="0 0 24 24"
9210            fill="none"
9211            stroke="currentColor"
9212            stroke-width="2"
9213            stroke-linecap="round"
9214            stroke-linejoin="round"
9215            aria-hidden="true"
9216          >
9217            <circle cx="12" cy="12" r="10" />
9218            <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
9219            <line x1="12" y1="17" x2="12.01" y2="17" />
9220          </svg>
9221          Ask
9222        </button>
9223        <button
9224          id="selectionSummarizeBtn"
9225          type="button"
9226          class="selection-action"
9227          title="Summarize selection"
9228        >
9229          <svg
9230            width="13"
9231            height="13"
9232            viewBox="0 0 24 24"
9233            fill="none"
9234            stroke="currentColor"
9235            stroke-width="2"
9236            stroke-linecap="round"
9237            stroke-linejoin="round"
9238            aria-hidden="true"
9239          >
9240            <line x1="21" y1="10" x2="3" y2="10" />
9241            <line x1="21" y1="6" x2="3" y2="6" />
9242            <line x1="21" y1="14" x2="3" y2="14" />
9243            <line x1="21" y1="18" x2="3" y2="18" />
9244          </svg>
9245          Summarize
9246        </button>
9247      </div>
9248
9249      <button
9250        id="highlightRemoveChip"
9251        type="button"
9252        class="highlight-remove-chip hidden"
9253        title="Remove highlight"
9254        aria-label="Remove highlight"
9255        aria-hidden="true"
9256        hidden
9257      >
9258        <svg
9259          width="13"
9260          height="13"
9261          viewBox="0 0 24 24"
9262          fill="none"
9263          stroke="currentColor"
9264          stroke-width="2"
9265          stroke-linecap="round"
9266          stroke-linejoin="round"
9267          aria-hidden="true"
9268        >
9269          <line x1="18" y1="6" x2="6" y2="18" />
9270          <line x1="6" y1="6" x2="18" y2="18" />
9271        </svg>
9272        Remove
9273      </button>
9274
9275      <div
9276        id="highlightUndoToast"
9277        class="highlight-undo hidden"
9278        role="status"
9279        aria-live="polite"
9280        aria-atomic="true"
9281        aria-hidden="true"
9282        hidden
9283      >
9284        <span id="highlightUndoMessage" class="highlight-undo-message"
9285          >Highlight removed.</span
9286        >
9287        <button id="highlightUndoBtn" type="button" class="highlight-undo-btn">
9288          Undo
9289        </button>
9290      </div>
9291
9292      <div
9293        id="ctxMenu"
9294        class="ctx-menu hidden"
9295        role="menu"
9296        aria-label="Selection actions"
9297        aria-hidden="true"
9298        hidden
9299      >
9300        <button
9301          id="ctxHighlight"
9302          class="ctx-menu-item"
9303          type="button"
9304          role="menuitem"
9305        >
9306          <svg
9307            width="14"
9308            height="14"
9309            viewBox="0 0 24 24"
9310            fill="none"
9311            stroke="currentColor"
9312            stroke-width="2"
9313            stroke-linecap="round"
9314            stroke-linejoin="round"
9315            aria-hidden="true"
9316          >
9317            <path d="m9 11 6-6 4 4-6 6" />
9318            <path d="M12.5 7.5 16.5 11.5" />
9319            <path d="M4 20h6" />
9320          </svg>
9321          Highlight selection
9322        </button>
9323        <button id="ctxAsk" class="ctx-menu-item" type="button" role="menuitem">
9324          <svg
9325            width="14"
9326            height="14"
9327            viewBox="0 0 24 24"
9328            fill="none"
9329            stroke="currentColor"
9330            stroke-width="2"
9331            stroke-linecap="round"
9332            stroke-linejoin="round"
9333            aria-hidden="true"
9334          >
9335            <circle cx="12" cy="12" r="10" />
9336            <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
9337            <line x1="12" y1="17" x2="12.01" y2="17" />
9338          </svg>
9339          Ask about selection
9340        </button>
9341        <button
9342          id="ctxSummarize"
9343          class="ctx-menu-item"
9344          type="button"
9345          role="menuitem"
9346        >
9347          <svg
9348            width="14"
9349            height="14"
9350            viewBox="0 0 24 24"
9351            fill="none"
9352            stroke="currentColor"
9353            stroke-width="2"
9354            stroke-linecap="round"
9355            stroke-linejoin="round"
9356            aria-hidden="true"
9357          >
9358            <line x1="21" y1="10" x2="3" y2="10" />
9359            <line x1="21" y1="6" x2="3" y2="6" />
9360            <line x1="21" y1="14" x2="3" y2="14" />
9361            <line x1="21" y1="18" x2="3" y2="18" />
9362          </svg>
9363          Summarize selection
9364        </button>
9365      </div>
9366    </div>
9367
9368    
9368<script>
9369      /**
9370       * marked - a markdown parser
9371       * Copyright (c) 2011-2022, Christopher Jeffrey. (MIT Licensed)
9372       * https://github.com/markedjs/marked
9373       */
9374
9375      /**
9376       * DO NOT EDIT THIS FILE
9377       * The code in this file is generated from files in ./src/
9378       */
9379
9380      (function (global, factory) {
9381        typeof exports === "object" && typeof module !== "undefined"
9382          ? factory(exports)
9383          : typeof define === "function" && define.amd
9384            ? define(["exports"], factory)
9385            : ((global =
9386                typeof globalThis !== "undefined"
9387                  ? globalThis
9388                  : global || self),
9389              factory((global.marked = {})));
9390      })(this, function (exports) {
9391        "use strict";
9392
9393        function _defineProperties(target, props) {
9394          for (var i = 0; i < props.length; i++) {
9395            var descriptor = props[i];
9396            descriptor.enumerable = descriptor.enumerable || false;
9397            descriptor.configurable = true;
9398            if ("value" in descriptor) descriptor.writable = true;
9399            Object.defineProperty(target, descriptor.key, descriptor);
9400          }
9401        }
9402
9403        function _createClass(Constructor, protoProps, staticProps) {
9404          if (protoProps) _defineProperties(Constructor.prototype, protoProps);
9405          if (staticProps) _defineProperties(Constructor, staticProps);
9406          Object.defineProperty(Constructor, "prototype", {
9407            writable: false,
9408          });
9409          return Constructor;
9410        }
9411
9412        function _unsupportedIterableToArray(o, minLen) {
9413          if (!o) return;
9414          if (typeof o === "string") return _arrayLikeToArray(o, minLen);
9415          var n = Object.prototype.toString.call(o).slice(8, -1);
9416          if (n === "Object" && o.constructor) n = o.constructor.name;
9417          if (n === "Map" || n === "Set") return Array.from(o);
9418          if (
9419            n === "Arguments" ||
9420            /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)
9421          )
9422            return _arrayLikeToArray(o, minLen);
9423        }
9424
9425        function _arrayLikeToArray(arr, len) {
9426          if (len == null || len > arr.length) len = arr.length;
9427
9428          for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];
9429
9430          return arr2;
9431        }
9432
9433        function _createForOfIteratorHelperLoose(o, allowArrayLike) {
9434          var it =
9435            (typeof Symbol !== "undefined" && o[Symbol.iterator]) ||
9436            o["@@iterator"];
9437          if (it) return (it = it.call(o)).next.bind(it);
9438
9439          if (
9440            Array.isArray(o) ||
9441            (it = _unsupportedIterableToArray(o)) ||
9442            (allowArrayLike && o && typeof o.length === "number")
9443          ) {
9444            if (it) o = it;
9445            var i = 0;
9446            return function () {
9447              if (i >= o.length)
9448                return {
9449                  done: true,
9450                };
9451              return {
9452                done: false,
9453                value: o[i++],
9454              };
9455            };
9456          }
9457
9458          throw new TypeError(
9459            "Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.",
9460          );
9461        }
9462
9463        function getDefaults() {
9464          return {
9465            baseUrl: null,
9466            breaks: false,
9467            extensions: null,
9468            gfm: true,
9469            headerIds: true,
9470            headerPrefix: "",
9471            highlight: null,
9472            langPrefix: "language-",
9473            mangle: true,
9474            pedantic: false,
9475            renderer: null,
9476            sanitize: false,
9477            sanitizer: null,
9478            silent: false,
9479            smartLists: false,
9480            smartypants: false,
9481            tokenizer: null,
9482            walkTokens: null,
9483            xhtml: false,
9484          };
9485        }
9486        exports.defaults = getDefaults();
9487        function changeDefaults(newDefaults) {
9488          exports.defaults = newDefaults;
9489        }
9490
9491        /**
9492         * Helpers
9493         */
9494        var escapeTest = /[&<>"']/;
9495        var escapeReplace = /[&<>"']/g;
9496        var escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/;
9497        var escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g;
9498        var escapeReplacements = {
9499          "&": "&amp;",
9500          "<": "&lt;",
9501          ">": "&gt;",
9502          '"': "&quot;",
9503          "'": "&#39;",
9504        };
9505
9506        var getEscapeReplacement = function getEscapeReplacement(ch) {
9507          return escapeReplacements[ch];
9508        };
9509
9510        function escape(html, encode) {
9511          if (encode) {
9512            if (escapeTest.test(html)) {
9513              return html.replace(escapeReplace, getEscapeReplacement);
9514            }
9515          } else {
9516            if (escapeTestNoEncode.test(html)) {
9517              return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
9518            }
9519          }
9520
9521          return html;
9522        }
9523        var unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;
9524        /**
9525         * @param {string} html
9526         */
9527
9528        function unescape(html) {
9529          // explicitly match decimal, hex, and named HTML entities
9530          return html.replace(unescapeTest, function (_, n) {
9531            n = n.toLowerCase();
9532            if (n === "colon") return ":";
9533
9534            if (n.charAt(0) === "#") {
9535              return n.charAt(1) === "x"
9536                ? String.fromCharCode(parseInt(n.substring(2), 16))
9537                : String.fromCharCode(+n.substring(1));
9538            }
9539
9540            return "";
9541          });
9542        }
9543        var caret = /(^|[^\[])\^/g;
9544        /**
9545         * @param {string | RegExp} regex
9546         * @param {string} opt
9547         */
9548
9549        function edit(regex, opt) {
9550          regex = typeof regex === "string" ? regex : regex.source;
9551          opt = opt || "";
9552          var obj = {
9553            replace: function replace(name, val) {
9554              val = val.source || val;
9555              val = val.replace(caret, "$1");
9556              regex = regex.replace(name, val);
9557              return obj;
9558            },
9559            getRegex: function getRegex() {
9560              return new RegExp(regex, opt);
9561            },
9562          };
9563          return obj;
9564        }
9565        var nonWordAndColonTest = /[^\w:]/g;
9566        var originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;
9567        /**
9568         * @param {boolean} sanitize
9569         * @param {string} base
9570         * @param {string} href
9571         */
9572
9573        function cleanUrl(sanitize, base, href) {
9574          if (sanitize) {
9575            var prot;
9576
9577            try {
9578              prot = decodeURIComponent(unescape(href))
9579                .replace(nonWordAndColonTest, "")
9580                .toLowerCase();
9581            } catch (e) {
9582              return null;
9583            }
9584
9585            if (
9586              prot.indexOf("javascript:") === 0 ||
9587              prot.indexOf("vbscript:") === 0 ||
9588              prot.indexOf("data:") === 0
9589            ) {
9590              return null;
9591            }
9592          }
9593
9594          if (base && !originIndependentUrl.test(href)) {
9595            href = resolveUrl(base, href);
9596          }
9597
9598          try {
9599            href = encodeURI(href).replace(/%25/g, "%");
9600          } catch (e) {
9601            return null;
9602          }
9603
9604          return href;
9605        }
9606        var baseUrls = {};
9607        var justDomain = /^[^:]+:\/*[^/]*$/;
9608        var protocol = /^([^:]+:)[\s\S]*$/;
9609        var domain = /^([^:]+:\/*[^/]*)[\s\S]*$/;
9610        /**
9611         * @param {string} base
9612         * @param {string} href
9613         */
9614
9615        function resolveUrl(base, href) {
9616          if (!baseUrls[" " + base]) {
9617            // we can ignore everything in base after the last slash of its path component,
9618            // but we might need to add _that_
9619            // https://tools.ietf.org/html/rfc3986#section-3
9620            if (justDomain.test(base)) {
9621              baseUrls[" " + base] = base + "/";
9622            } else {
9623              baseUrls[" " + base] = rtrim(base, "/", true);
9624            }
9625          }
9626
9627          base = baseUrls[" " + base];
9628          var relativeBase = base.indexOf(":") === -1;
9629
9630          if (href.substring(0, 2) === "//") {
9631            if (relativeBase) {
9632              return href;
9633            }
9634
9635            return base.replace(protocol, "$1") + href;
9636          } else if (href.charAt(0) === "/") {
9637            if (relativeBase) {
9638              return href;
9639            }
9640
9641            return base.replace(domain, "$1") + href;
9642          } else {
9643            return base + href;
9644          }
9645        }
9646        var noopTest = {
9647          exec: function noopTest() {},
9648        };
9649        function merge(obj) {
9650          var i = 1,
9651            target,
9652            key;
9653
9654          for (; i < arguments.length; i++) {
9655            target = arguments[i];
9656
9657            for (key in target) {
9658              if (Object.prototype.hasOwnProperty.call(target, key)) {
9659                obj[key] = target[key];
9660              }
9661            }
9662          }
9663
9664          return obj;
9665        }
9666        function splitCells(tableRow, count) {
9667          // ensure that every cell-delimiting pipe has a space
9668          // before it to distinguish it from an escaped pipe
9669          var row = tableRow.replace(/\|/g, function (match, offset, str) {
9670              var escaped = false,
9671                curr = offset;
9672
9673              while (--curr >= 0 && str[curr] === "\\") {
9674                escaped = !escaped;
9675              }
9676
9677              if (escaped) {
9678                // odd number of slashes means | is escaped
9679                // so we leave it alone
9680                return "|";
9681              } else {
9682                // add space before unescaped |
9683                return " |";
9684              }
9685            }),
9686            cells = row.split(/ \|/);
9687          var i = 0; // First/last cell in a row cannot be empty if it has no leading/trailing pipe
9688
9689          if (!cells[0].trim()) {
9690            cells.shift();
9691          }
9692
9693          if (cells.length > 0 && !cells[cells.length - 1].trim()) {
9694            cells.pop();
9695          }
9696
9697          if (cells.length > count) {
9698            cells.splice(count);
9699          } else {
9700            while (cells.length < count) {
9701              cells.push("");
9702            }
9703          }
9704
9705          for (; i < cells.length; i++) {
9706            // leading or trailing whitespace is ignored per the gfm spec
9707            cells[i] = cells[i].trim().replace(/\\\|/g, "|");
9708          }
9709
9710          return cells;
9711        }
9712        /**
9713         * Remove trailing 'c's. Equivalent to str.replace(/c*$/, '').
9714         * /c*$/ is vulnerable to REDOS.
9715         *
9716         * @param {string} str
9717         * @param {string} c
9718         * @param {boolean} invert Remove suffix of non-c chars instead. Default falsey.
9719         */
9720
9721        function rtrim(str, c, invert) {
9722          var l = str.length;
9723
9724          if (l === 0) {
9725            return "";
9726          } // Length of suffix matching the invert condition.
9727
9728          var suffLen = 0; // Step left until we fail to match the invert condition.
9729
9730          while (suffLen < l) {
9731            var currChar = str.charAt(l - suffLen - 1);
9732
9733            if (currChar === c && !invert) {
9734              suffLen++;
9735            } else if (currChar !== c && invert) {
9736              suffLen++;
9737            } else {
9738              break;
9739            }
9740          }
9741
9742          return str.slice(0, l - suffLen);
9743        }
9744        function findClosingBracket(str, b) {
9745          if (str.indexOf(b[1]) === -1) {
9746            return -1;
9747          }
9748
9749          var l = str.length;
9750          var level = 0,
9751            i = 0;
9752
9753          for (; i < l; i++) {
9754            if (str[i] === "\\") {
9755              i++;
9756            } else if (str[i] === b[0]) {
9757              level++;
9758            } else if (str[i] === b[1]) {
9759              level--;
9760
9761              if (level < 0) {
9762                return i;
9763              }
9764            }
9765          }
9766
9767          return -1;
9768        }
9769        function checkSanitizeDeprecation(opt) {
9770          if (opt && opt.sanitize && !opt.silent) {
9771            console.warn(
9772              "marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options",
9773            );
9774          }
9775        } // copied from https://stackoverflow.com/a/5450113/806777
9776
9777        /**
9778         * @param {string} pattern
9779         * @param {number} count
9780         */
9781
9782        function repeatString(pattern, count) {
9783          if (count < 1) {
9784            return "";
9785          }
9786
9787          var result = "";
9788
9789          while (count > 1) {
9790            if (count & 1) {
9791              result += pattern;
9792            }
9793
9794            count >>= 1;
9795            pattern += pattern;
9796          }
9797
9798          return result + pattern;
9799        }
9800
9801        function outputLink(cap, link, raw, lexer) {
9802          var href = link.href;
9803          var title = link.title ? escape(link.title) : null;
9804          var text = cap[1].replace(/\\([\[\]])/g, "$1");
9805
9806          if (cap[0].charAt(0) !== "!") {
9807            lexer.state.inLink = true;
9808            var token = {
9809              type: "link",
9810              raw: raw,
9811              href: href,
9812              title: title,
9813              text: text,
9814              tokens: lexer.inlineTokens(text, []),
9815            };
9816            lexer.state.inLink = false;
9817            return token;
9818          }
9819
9820          return {
9821            type: "image",
9822            raw: raw,
9823            href: href,
9824            title: title,
9825            text: escape(text),
9826          };
9827        }
9828
9829        function indentCodeCompensation(raw, text) {
9830          var matchIndentToCode = raw.match(/^(\s+)(?:```)/);
9831
9832          if (matchIndentToCode === null) {
9833            return text;
9834          }
9835
9836          var indentToCode = matchIndentToCode[1];
9837          return text
9838            .split("\n")
9839            .map(function (node) {
9840              var matchIndentInNode = node.match(/^\s+/);
9841
9842              if (matchIndentInNode === null) {
9843                return node;
9844              }
9845
9846              var indentInNode = matchIndentInNode[0];
9847
9848              if (indentInNode.length >= indentToCode.length) {
9849                return node.slice(indentToCode.length);
9850              }
9851
9852              return node;
9853            })
9854            .join("\n");
9855        }
9856        /**
9857         * Tokenizer
9858         */
9859
9860        var Tokenizer = /*#__PURE__*/ (function () {
9861          function Tokenizer(options) {
9862            this.options = options || exports.defaults;
9863          }
9864
9865          var _proto = Tokenizer.prototype;
9866
9867          _proto.space = function space(src) {
9868            var cap = this.rules.block.newline.exec(src);
9869
9870            if (cap && cap[0].length > 0) {
9871              return {
9872                type: "space",
9873                raw: cap[0],
9874              };
9875            }
9876          };
9877
9878          _proto.code = function code(src) {
9879            var cap = this.rules.block.code.exec(src);
9880
9881            if (cap) {
9882              var text = cap[0].replace(/^ {1,4}/gm, "");
9883              return {
9884                type: "code",
9885                raw: cap[0],
9886                codeBlockStyle: "indented",
9887                text: !this.options.pedantic ? rtrim(text, "\n") : text,
9888              };
9889            }
9890          };
9891
9892          _proto.fences = function fences(src) {
9893            var cap = this.rules.block.fences.exec(src);
9894
9895            if (cap) {
9896              var raw = cap[0];
9897              var text = indentCodeCompensation(raw, cap[3] || "");
9898              return {
9899                type: "code",
9900                raw: raw,
9901                lang: cap[2] ? cap[2].trim() : cap[2],
9902                text: text,
9903              };
9904            }
9905          };
9906
9907          _proto.heading = function heading(src) {
9908            var cap = this.rules.block.heading.exec(src);
9909
9910            if (cap) {
9911              var text = cap[2].trim(); // remove trailing #s
9912
9913              if (/#$/.test(text)) {
9914                var trimmed = rtrim(text, "#");
9915
9916                if (this.options.pedantic) {
9917                  text = trimmed.trim();
9918                } else if (!trimmed || / $/.test(trimmed)) {
9919                  // CommonMark requires space before trailing #s
9920                  text = trimmed.trim();
9921                }
9922              }
9923
9924              var token = {
9925                type: "heading",
9926                raw: cap[0],
9927                depth: cap[1].length,
9928                text: text,
9929                tokens: [],
9930              };
9931              this.lexer.inline(token.text, token.tokens);
9932              return token;
9933            }
9934          };
9935
9936          _proto.hr = function hr(src) {
9937            var cap = this.rules.block.hr.exec(src);
9938
9939            if (cap) {
9940              return {
9941                type: "hr",
9942                raw: cap[0],
9943              };
9944            }
9945          };
9946
9947          _proto.blockquote = function blockquote(src) {
9948            var cap = this.rules.block.blockquote.exec(src);
9949
9950            if (cap) {
9951              var text = cap[0].replace(/^ *>[ \t]?/gm, "");
9952              return {
9953                type: "blockquote",
9954                raw: cap[0],
9955                tokens: this.lexer.blockTokens(text, []),
9956                text: text,
9957              };
9958            }
9959          };
9960
9961          _proto.list = function list(src) {
9962            var cap = this.rules.block.list.exec(src);
9963
9964            if (cap) {
9965              var raw,
9966                istask,
9967                ischecked,
9968                indent,
9969                i,
9970                blankLine,
9971                endsWithBlankLine,
9972                line,
9973                nextLine,
9974                rawLine,
9975                itemContents,
9976                endEarly;
9977              var bull = cap[1].trim();
9978              var isordered = bull.length > 1;
9979              var list = {
9980                type: "list",
9981                raw: "",
9982                ordered: isordered,
9983                start: isordered ? +bull.slice(0, -1) : "",
9984                loose: false,
9985                items: [],
9986              };
9987              bull = isordered ? "\\d{1,9}\\" + bull.slice(-1) : "\\" + bull;
9988
9989              if (this.options.pedantic) {
9990                bull = isordered ? bull : "[*+-]";
9991              } // Get next list item
9992
9993              var itemRegex = new RegExp(
9994                "^( {0,3}" + bull + ")((?:[\t ][^\\n]*)?(?:\\n|$))",
9995              ); // Check if current bullet point can start a new List Item
9996
9997              while (src) {
9998                endEarly = false;
9999
10000                if (!(cap = itemRegex.exec(src))) {
10001                  break;
10002                }
10003
10004                if (this.rules.block.hr.test(src)) {
10005                  // End list if bullet was actually HR (possibly move into itemRegex?)
10006                  break;
10007                }
10008
10009                raw = cap[0];
10010                src = src.substring(raw.length);
10011                line = cap[2].split("\n", 1)[0];
10012                nextLine = src.split("\n", 1)[0];
10013
10014                if (this.options.pedantic) {
10015                  indent = 2;
10016                  itemContents = line.trimLeft();
10017                } else {
10018                  indent = cap[2].search(/[^ ]/); // Find first non-space char
10019
10020                  indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent
10021
10022                  itemContents = line.slice(indent);
10023                  indent += cap[1].length;
10024                }
10025
10026                blankLine = false;
10027
10028                if (!line && /^ *$/.test(nextLine)) {
10029                  // Items begin with at most one blank line
10030                  raw += nextLine + "\n";
10031                  src = src.substring(nextLine.length + 1);
10032                  endEarly = true;
10033                }
10034
10035                if (!endEarly) {
10036                  var nextBulletRegex = new RegExp(
10037                    "^ {0," +
10038                      Math.min(3, indent - 1) +
10039                      "}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))",
10040                  );
10041                  var hrRegex = new RegExp(
10042                    "^ {0," +
10043                      Math.min(3, indent - 1) +
10044                      "}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)",
10045                  );
10046                  var fencesBeginRegex = new RegExp(
10047                    "^ {0," + Math.min(3, indent - 1) + "}(?:```|~~~)",
10048                  );
10049                  var headingBeginRegex = new RegExp(
10050                    "^ {0," + Math.min(3, indent - 1) + "}#",
10051                  ); // Check if following lines should be included in List Item
10052
10053                  while (src) {
10054                    rawLine = src.split("\n", 1)[0];
10055                    line = rawLine; // Re-align to follow commonmark nesting rules
10056
10057                    if (this.options.pedantic) {
10058                      line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, "  ");
10059                    } // End list item if found code fences
10060
10061                    if (fencesBeginRegex.test(line)) {
10062                      break;
10063                    } // End list item if found start of new heading
10064
10065                    if (headingBeginRegex.test(line)) {
10066                      break;
10067                    } // End list item if found start of new bullet
10068
10069                    if (nextBulletRegex.test(line)) {
10070                      break;
10071                    } // Horizontal rule found
10072
10073                    if (hrRegex.test(src)) {
10074                      break;
10075                    }
10076
10077                    if (line.search(/[^ ]/) >= indent || !line.trim()) {
10078                      // Dedent if possible
10079                      itemContents += "\n" + line.slice(indent);
10080                    } else if (!blankLine) {
10081                      // Until blank line, item doesn't need indentation
10082                      itemContents += "\n" + line;
10083                    } else {
10084                      // Otherwise, improper indentation ends this item
10085                      break;
10086                    }
10087
10088                    if (!blankLine && !line.trim()) {
10089                      // Check if current line is blank
10090                      blankLine = true;
10091                    }
10092
10093                    raw += rawLine + "\n";
10094                    src = src.substring(rawLine.length + 1);
10095                  }
10096                }
10097
10098                if (!list.loose) {
10099                  // If the previous item ended with a blank line, the list is loose
10100                  if (endsWithBlankLine) {
10101                    list.loose = true;
10102                  } else if (/\n *\n *$/.test(raw)) {
10103                    endsWithBlankLine = true;
10104                  }
10105                } // Check for task list items
10106
10107                if (this.options.gfm) {
10108                  istask = /^\[[ xX]\] /.exec(itemContents);
10109
10110                  if (istask) {
10111                    ischecked = istask[0] !== "[ ] ";
10112                    itemContents = itemContents.replace(/^\[[ xX]\] +/, "");
10113                  }
10114                }
10115
10116                list.items.push({
10117                  type: "list_item",
10118                  raw: raw,
10119                  task: !!istask,
10120                  checked: ischecked,
10121                  loose: false,
10122                  text: itemContents,
10123                });
10124                list.raw += raw;
10125              } // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to 
10125simplify/speed up endsWithBlankLine logic
10126
10127              list.items[list.items.length - 1].raw = raw.trimRight();
10128              list.items[list.items.length - 1].text = itemContents.trimRight();
10129              list.raw = list.raw.trimRight();
10130              var l = list.items.length; // Item child tokens handled here at end because we needed to have the final item to trim it first
10131
10132              for (i = 0; i < l; i++) {
10133                this.lexer.state.top = false;
10134                list.items[i].tokens = this.lexer.blockTokens(
10135                  list.items[i].text,
10136                  [],
10137                );
10138                var spacers = list.items[i].tokens.filter(function (t) {
10139                  return t.type === "space";
10140                });
10141                var hasMultipleLineBreaks = spacers.every(function (t) {
10142                  var chars = t.raw.split("");
10143                  var lineBreaks = 0;
10144
10145                  for (
10146                    var _iterator = _createForOfIteratorHelperLoose(chars),
10147                      _step;
10148                    !(_step = _iterator()).done;
10149                  ) {
10150                    var _char = _step.value;
10151
10152                    if (_char === "\n") {
10153                      lineBreaks += 1;
10154                    }
10155
10156                    if (lineBreaks > 1) {
10157                      return true;
10158                    }
10159                  }
10160
10161                  return false;
10162                });
10163
10164                if (!list.loose && spacers.length && hasMultipleLineBreaks) {
10165                  // Having a single line break doesn't mean a list is loose. A single line break is terminating the last list item
10166                  list.loose = true;
10167                  list.items[i].loose = true;
10168                }
10169              }
10170
10171              return list;
10172            }
10173          };
10174
10175          _proto.html = function html(src) {
10176            var cap = this.rules.block.html.exec(src);
10177
10178            if (cap) {
10179              var token = {
10180                type: "html",
10181                raw: cap[0],
10182                pre:
10183                  !this.options.sanitizer &&
10184                  (cap[1] === "pre" ||
10185                    cap[1] === "script" ||
10186                    cap[1] === "style"),
10187                text: cap[0],
10188              };
10189
10190              if (this.options.sanitize) {
10191                token.type = "paragraph";
10192                token.text = this.options.sanitizer
10193                  ? this.options.sanitizer(cap[0])
10194                  : escape(cap[0]);
10195                token.tokens = [];
10196                this.lexer.inline(token.text, token.tokens);
10197              }
10198
10199              return token;
10200            }
10201          };
10202
10203          _proto.def = function def(src) {
10204            var cap = this.rules.block.def.exec(src);
10205
10206            if (cap) {
10207              if (cap[3]) cap[3] = cap[3].substring(1, cap[3].length - 1);
10208              var tag = cap[1].toLowerCase().replace(/\s+/g, " ");
10209              return {
10210                type: "def",
10211                tag: tag,
10212                raw: cap[0],
10213                href: cap[2],
10214                title: cap[3],
10215              };
10216            }
10217          };
10218
10219          _proto.table = function table(src) {
10220            var cap = this.rules.block.table.exec(src);
10221
10222            if (cap) {
10223              var item = {
10224                type: "table",
10225                header: splitCells(cap[1]).map(function (c) {
10226                  return {
10227                    text: c,
10228                  };
10229                }),
10230                align: cap[2].replace(/^ *|\| *$/g, "").split(/ *\| */),
10231                rows:
10232                  cap[3] && cap[3].trim()
10233                    ? cap[3].replace(/\n[ \t]*$/, "").split("\n")
10234                    : [],
10235              };
10236
10237              if (item.header.length === item.align.length) {
10238                item.raw = cap[0];
10239                var l = item.align.length;
10240                var i, j, k, row;
10241
10242                for (i = 0; i < l; i++) {
10243                  if (/^ *-+: *$/.test(item.align[i])) {
10244                    item.align[i] = "right";
10245                  } else if (/^ *:-+: *$/.test(item.align[i])) {
10246                    item.align[i] = "center";
10247                  } else if (/^ *:-+ *$/.test(item.align[i])) {
10248                    item.align[i] = "left";
10249                  } else {
10250                    item.align[i] = null;
10251                  }
10252                }
10253
10254                l = item.rows.length;
10255
10256                for (i = 0; i < l; i++) {
10257                  item.rows[i] = splitCells(
10258                    item.rows[i],
10259                    item.header.length,
10260                  ).map(function (c) {
10261                    return {
10262                      text: c,
10263                    };
10264                  });
10265                } // parse child tokens inside headers and cells
10266                // header child tokens
10267
10268                l = item.header.length;
10269
10270                for (j = 0; j < l; j++) {
10271                  item.header[j].tokens = [];
10272                  this.lexer.inline(item.header[j].text, item.header[j].tokens);
10273                } // cell child tokens
10274
10275                l = item.rows.length;
10276
10277                for (j = 0; j < l; j++) {
10278                  row = item.rows[j];
10279
10280                  for (k = 0; k < row.length; k++) {
10281                    row[k].tokens = [];
10282                    this.lexer.inline(row[k].text, row[k].tokens);
10283                  }
10284                }
10285
10286                return item;
10287              }
10288            }
10289          };
10290
10291          _proto.lheading = function lheading(src) {
10292            var cap = this.rules.block.lheading.exec(src);
10293
10294            if (cap) {
10295              var token = {
10296                type: "heading",
10297                raw: cap[0],
10298                depth: cap[2].charAt(0) === "=" ? 1 : 2,
10299                text: cap[1],
10300                tokens: [],
10301              };
10302              this.lexer.inline(token.text, token.tokens);
10303              return token;
10304            }
10305          };
10306
10307          _proto.paragraph = function paragraph(src) {
10308            var cap = this.rules.block.paragraph.exec(src);
10309
10310            if (cap) {
10311              var token = {
10312                type: "paragraph",
10313                raw: cap[0],
10314                text:
10315                  cap[1].charAt(cap[1].length - 1) === "\n"
10316                    ? cap[1].slice(0, -1)
10317                    : cap[1],
10318                tokens: [],
10319              };
10320              this.lexer.inline(token.text, token.tokens);
10321              return token;
10322            }
10323          };
10324
10325          _proto.text = function text(src) {
10326            var cap = this.rules.block.text.exec(src);
10327
10328            if (cap) {
10329              var token = {
10330                type: "text",
10331                raw: cap[0],
10332                text: cap[0],
10333                tokens: [],
10334              };
10335              this.lexer.inline(token.text, token.tokens);
10336              return token;
10337            }
10338          };
10339
10340          _proto.escape = function escape$1(src) {
10341            var cap = this.rules.inline.escape.exec(src);
10342
10343            if (cap) {
10344              return {
10345                type: "escape",
10346                raw: cap[0],
10347                text: escape(cap[1]),
10348              };
10349            }
10350          };
10351
10352          _proto.tag = function tag(src) {
10353            var cap = this.rules.inline.tag.exec(src);
10354
10355            if (cap) {
10356              if (!this.lexer.state.inLink && /^<a /i.test(cap[0])) {
10357                this.lexer.state.inLink = true;
10358              } else if (this.lexer.state.inLink && /^<\/a>/i.test(cap[0])) {
10359                this.lexer.state.inLink = false;
10360              }
10361
10362              if (
10363                !this.lexer.state.inRawBlock &&
10364                /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])
10365              ) {
10366                this.lexer.state.inRawBlock = true;
10367              } else if (
10368                this.lexer.state.inRawBlock &&
10369                /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])
10370              ) {
10371                this.lexer.state.inRawBlock = false;
10372              }
10373
10374              return {
10375                type: this.options.sanitize ? "text" : "html",
10376                raw: cap[0],
10377                inLink: this.lexer.state.inLink,
10378                inRawBlock: this.lexer.state.inRawBlock,
10379                text: this.options.sanitize
10380                  ? this.options.sanitizer
10381                    ? this.options.sanitizer(cap[0])
10382                    : escape(cap[0])
10383                  : cap[0],
10384              };
10385            }
10386          };
10387
10388          _proto.link = function link(src) {
10389            var cap = this.rules.inline.link.exec(src);
10390
10391            if (cap) {
10392              var trimmedUrl = cap[2].trim();
10393
10394              if (!this.options.pedantic && /^</.test(trimmedUrl)) {
10395                // commonmark requires matching angle brackets
10396                if (!/>$/.test(trimmedUrl)) {
10397                  return;
10398                } // ending angle bracket cannot be escaped
10399
10400                var rtrimSlash = rtrim(trimmedUrl.slice(0, -1), "\\");
10401
10402                if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) {
10403                  return;
10404                }
10405              } else {
10406                // find closing parenthesis
10407                var lastParenIndex = findClosingBracket(cap[2], "()");
10408
10409                if (lastParenIndex > -1) {
10410                  var start = cap[0].indexOf("!") === 0 ? 5 : 4;
10411                  var linkLen = start + cap[1].length + lastParenIndex;
10412                  cap[2] = cap[2].substring(0, lastParenIndex);
10413                  cap[0] = cap[0].substring(0, linkLen).trim();
10414                  cap[3] = "";
10415                }
10416              }
10417
10418              var href = cap[2];
10419              var title = "";
10420
10421              if (this.options.pedantic) {
10422                // split pedantic href and title
10423                var link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href);
10424
10425                if (link) {
10426                  href = link[1];
10427                  title = link[3];
10428                }
10429              } else {
10430                title = cap[3] ? cap[3].slice(1, -1) : "";
10431              }
10432
10433              href = href.trim();
10434
10435              if (/^</.test(href)) {
10436                if (this.options.pedantic && !/>$/.test(trimmedUrl)) {
10437                  // pedantic allows starting angle bracket without ending angle bracket
10438                  href = href.slice(1);
10439                } else {
10440                  href = href.slice(1, -1);
10441                }
10442              }
10443
10444              return outputLink(
10445                cap,
10446                {
10447                  href: href
10448                    ? href.replace(this.rules.inline._escapes, "$1")
10449                    : href,
10450                  title: title
10451                    ? title.replace(this.rules.inline._escapes, "$1")
10452                    : title,
10453                },
10454                cap[0],
10455                this.lexer,
10456              );
10457            }
10458          };
10459
10460          _proto.reflink = function reflink(src, links) {
10461            var cap;
10462
10463            if (
10464              (cap = this.rules.inline.reflink.exec(src)) ||
10465              (cap = this.rules.inline.nolink.exec(src))
10466            ) {
10467              var link = (cap[2] || cap[1]).replace(/\s+/g, " ");
10468              link = links[link.toLowerCase()];
10469
10470              if (!link || !link.href) {
10471                var text = cap[0].charAt(0);
10472                return {
10473                  type: "text",
10474                  raw: text,
10475                  text: text,
10476                };
10477              }
10478
10479              return outputLink(cap, link, cap[0], this.lexer);
10480            }
10481          };
10482
10483          _proto.emStrong = function emStrong(src, maskedSrc, prevChar) {
10484            if (prevChar === void 0) {
10485              prevChar = "";
10486            }
10487
10488            var match = this.rules.inline.emStrong.lDelim.exec(src);
10489            if (!match) return; // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well
10490
10491            if (
10492              match[3] &&
10493              prevChar.match(
10494                
vendor: 9,025 bytes, line 10494
10494/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/
10494,
10495              )
10496            )
10497              return;
10498            var nextChar = match[1] || match[2] || "";
10499
10500            if (
10501              !nextChar ||
10502              (nextChar &&
10503                (prevChar === "" ||
10504                  this.rules.inline.punctuation.exec(prevChar)))
10505            ) {
10506              var lLength = match[0].length - 1;
10507              var rDelim,
10508                rLength,
10509                delimTotal = lLength,
10510                midDelimTotal = 0;
10511              var endReg =
10512                match[0][0] === "*"
10513                  ? this.rules.inline.emStrong.rDelimAst
10514                  : this.rules.inline.emStrong.rDelimUnd;
10515              endReg.lastIndex = 0; // Clip maskedSrc to same section of string as src (move to lexer?)
10516
10517              maskedSrc = maskedSrc.slice(-1 * src.length + lLength);
10518
10519              while ((match = endReg.exec(maskedSrc)) != null) {
10520                rDelim =
10521                  match[1] ||
10522                  match[2] ||
10523                  match[3] ||
10524                  match[4] ||
10525                  match[5] ||
10526                  match[6];
10527                if (!rDelim) continue; // skip single * in __abc*abc__
10528
10529                rLength = rDelim.length;
10530
10531                if (match[3] || match[4]) {
10532                  // found another Left Delim
10533                  delimTotal += rLength;
10534                  continue;
10535                } else if (match[5] || match[6]) {
10536                  // either Left or Right Delim
10537                  if (lLength % 3 && !((lLength + rLength) % 3)) {
10538                    midDelimTotal += rLength;
10539                    continue; // CommonMark Emphasis Rules 9-10
10540                  }
10541                }
10542
10543                delimTotal -= rLength;
10544                if (delimTotal > 0) continue; // Haven't found enough closing delimiters
10545                // Remove extra characters. *a*** -> *a*
10546
10547                rLength = Math.min(
10548                  rLength,
10549                  rLength + delimTotal + midDelimTotal,
10550                ); // Create `em` if smallest delimiter has odd char count. *a***
10551
10552                if (Math.min(lLength, rLength) % 2) {
10553                  var _text = src.slice(1, lLength + match.index + rLength);
10554
10555                  return {
10556                    type: "em",
10557                    raw: src.slice(0, lLength + match.index + rLength + 1),
10558                    text: _text,
10559                    tokens: this.lexer.inlineTokens(_text, []),
10560                  };
10561                } // Create 'strong' if smallest delimiter has even char count. **a***
10562
10563                var text = src.slice(2, lLength + match.index + rLength - 1);
10564                return {
10565                  type: "strong",
10566                  raw: src.slice(0, lLength + match.index + rLength + 1),
10567                  text: text,
10568                  tokens: this.lexer.inlineTokens(text, []),
10569                };
10570              }
10571            }
10572          };
10573
10574          _proto.codespan = function codespan(src) {
10575            var cap = this.rules.inline.code.exec(src);
10576
10577            if (cap) {
10578              var text = cap[2].replace(/\n/g, " ");
10579              var hasNonSpaceChars = /[^ ]/.test(text);
10580              var hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text);
10581
10582              if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) {
10583                text = text.substring(1, text.length - 1);
10584              }
10585
10586              text = escape(text, true);
10587              return {
10588                type: "codespan",
10589                raw: cap[0],
10590                text: text,
10591              };
10592            }
10593          };
10594
10595          _proto.br = function br(src) {
10596            var cap = this.rules.inline.br.exec(src);
10597
10598            if (cap) {
10599              return {
10600                type: "br",
10601                raw: cap[0],
10602              };
10603            }
10604          };
10605
10606          _proto.del = function del(src) {
10607            var cap = this.rules.inline.del.exec(src);
10608
10609            if (cap) {
10610              return {
10611                type: "del",
10612                raw: cap[0],
10613                text: cap[2],
10614                tokens: this.lexer.inlineTokens(cap[2], []),
10615              };
10616            }
10617          };
10618
10619          _proto.autolink = function autolink(src, mangle) {
10620            var cap = this.rules.inline.autolink.exec(src);
10621
10622            if (cap) {
10623              var text, href;
10624
10625              if (cap[2] === "@") {
10626                text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]);
10627                href = "mailto:" + text;
10628              } else {
10629                text = escape(cap[1]);
10630                href = text;
10631              }
10632
10633              return {
10634                type: "link",
10635                raw: cap[0],
10636                text: text,
10637                href: href,
10638                tokens: [
10639                  {
10640                    type: "text",
10641                    raw: text,
10642                    text: text,
10643                  },
10644                ],
10645              };
10646            }
10647          };
10648
10649          _proto.url = function url(src, mangle) {
10650            var cap;
10651
10652            if ((cap = this.rules.inline.url.exec(src))) {
10653              var text, href;
10654
10655              if (cap[2] === "@") {
10656                text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]);
10657                href = "mailto:" + text;
10658              } else {
10659                // do extended autolink path validation
10660                var prevCapZero;
10661
10662                do {
10663                  prevCapZero = cap[0];
10664                  cap[0] = this.rules.inline._backpedal.exec(cap[0])[0];
10665                } while (prevCapZero !== cap[0]);
10666
10667                text = escape(cap[0]);
10668
10669                if (cap[1] === "www.") {
10670                  href = "http://" + text;
10671                } else {
10672                  href = text;
10673                }
10674              }
10675
10676              return {
10677                type: "link",
10678                raw: cap[0],
10679                text: text,
10680                href: href,
10681                tokens: [
10682                  {
10683                    type: "text",
10684                    raw: text,
10685                    text: text,
10686                  },
10687                ],
10688              };
10689            }
10690          };
10691
10692          _proto.inlineText = function inlineText(src, smartypants) {
10693            var cap = this.rules.inline.text.exec(src);
10694
10695            if (cap) {
10696              var text;
10697
10698              if (this.lexer.state.inRawBlock) {
10699                text = this.options.sanitize
10700                  ? this.options.sanitizer
10701                    ? this.options.sanitizer(cap[0])
10702                    : escape(cap[0])
10703                  : cap[0];
10704              } else {
10705                text = escape(
10706                  this.options.smartypants ? smartypants(cap[0]) : cap[0],
10707                );
10708              }
10709
10710              return {
10711                type: "text",
10712                raw: cap[0],
10713                text: text,
10714              };
10715            }
10716          };
10717
10718          return Tokenizer;
10719        })();
10720
10721        /**
10722         * Block-Level Grammar
10723         */
10724
10725        var block = {
10726          newline: /^(?: *(?:\n|$))+/,
10727          code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,
10728          fences:
10729            /^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/,
10730          hr: /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,
10731          heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,
10732          blockquote: /^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,
10733          list: /^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,
10734          html:
10735            "^ {0,3}(?:" + // optional indentation
10736            "<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)" + // (1)
10737            "|comment[^\\n]*(\\n+|$)" + // (2)
10738            "|<\\?[\\s\\S]*?(?:\\?>\\n*|$)" + // (3)
10739            "|<![A-Z][\\s\\S]*?(?:>\\n*|$)" + // (4)
10740            "|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)" + // (5)
10741            "|</?(tag)(?: +|\\n|/?>
10741)[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (6)
10742            "|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (7) open tag
10743            "|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (7) closing tag
10744            ")",
10745          def: /^ {0,3}\[(label)\]: *(?:\n *)?<?([^\s>]+)>?(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,
10746          table: noopTest,
10747          lheading: /^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/,
10748          // regex template, placeholders will be replaced according to different paragraph
10749          // interruption rules of commonmark and the original markdown spec:
10750          _paragraph:
10751            /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,
10752          text: /^[^\n]+/,
10753        };
10754        block._label = /(?!\s*\])(?:\\.|[^\[\]\\])+/;
10755        block._title =
10756          /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/;
10757        block.def = edit(block.def)
10758          .replace("label", block._label)
10759          .replace("title", block._title)
10760          .getRegex();
10761        block.bullet = /(?:[*+-]|\d{1,9}[.)])/;
10762        block.listItemStart = edit(/^( *)(bull) */)
10763          .replace("bull", block.bullet)
10764          .getRegex();
10765        block.list = edit(block.list)
10766          .replace(/bull/g, block.bullet)
10767          .replace(
10768            "hr",
10769            "\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))",
10770          )
10771          .replace("def", "\\n+(?=" + block.def.source + ")")
10772          .getRegex();
10773        block._tag =
10774          "address|article|aside|base|basefont|blockquote|body|caption" +
10775          "|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption" +
10776          "|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe" +
10777          "|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option" +
10778          "|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr" +
10779          "|track|ul";
10780        block._comment = /<!--(?!-?>)[\s\S]*?(?:-->|$)/;
10781        block.html = edit(block.html, "i")
10782          .replace("comment", block._comment)
10783          .replace("tag", block._tag)
10784          .replace(
10785            "attribute",
10786            / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/,
10787          )
10788          .getRegex();
10789        block.paragraph = edit(block._paragraph)
10790          .replace("hr", block.hr)
10791          .replace("heading", " {0,3}#{1,6} ")
10792          .replace("|lheading", "") // setex headings don't interrupt commonmark paragraphs
10793          .replace("|table", "")
10794          .replace("blockquote", " {0,3}>")
10795          .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n")
10796          .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt
10797          .replace(
10798            "html",
10799            "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)",
10800          )
10801          .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks
10802          .getRegex();
10803        block.blockquote = edit(block.blockquote)
10804          .replace("paragraph", block.paragraph)
10805          .getRegex();
10806        /**
10807         * Normal Block Grammar
10808         */
10809
10810        block.normal = merge({}, block);
10811        /**
10812         * GFM Block Grammar
10813         */
10814
10815        block.gfm = merge({}, block.normal, {
10816          table:
10817            "^ *([^\\n ].*\\|.*)\\n" + // Header
10818            " {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?" + // Align
10819            "(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)", // Cells
10820        });
10821        block.gfm.table = edit(block.gfm.table)
10822          .replace("hr", block.hr)
10823          .replace("heading", " {0,3}#{1,6} ")
10824          .replace("blockquote", " {0,3}>")
10825          .replace("code", " {4}[^\\n]")
10826          .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n")
10827          .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt
10828          .replace(
10829            "html",
10830            "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)",
10831          )
10832          .replace("tag", block._tag) // tables can be interrupted by type (6) html blocks
10833          .getRegex();
10834        block.gfm.paragraph = edit(block._paragraph)
10835          .replace("hr", block.hr)
10836          .replace("heading", " {0,3}#{1,6} ")
10837          .replace("|lheading", "") // setex headings don't interrupt commonmark paragraphs
10838          .replace("table", block.gfm.table) // interrupt paragraphs with table
10839          .replace("blockquote", " {0,3}>")
10840          .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n")
10841          .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt
10842          .replace(
10843            "html",
10844            "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)",
10845          )
10846          .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks
10847          .getRegex();
10848        /**
10849         * Pedantic grammar (original John Gruber's loose markdown specification)
10850         */
10851
10852        block.pedantic = merge({}, block.normal, {
10853          html: edit(
10854            "^ *(?:comment *(?:\\n|\\s*$)" +
10855              "|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)" + // closed tag
10856              "|<tag(?:\"[^\"]*\"|'[^']*'|\\s[^'\"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))",
10857          )
10858            .replace("comment", block._comment)
10859            .replace(
10860              /tag/g,
10861              "(?!(?:" +
10862                "a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub" +
10863                "|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)" +
10864                "\\b)\\w+(?!:|[^\\w\\s@]*@)\\b",
10865            )
10866            .getRegex(),
10867          def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,
10868          heading: /^(#{1,6})(.*)(?:\n+|$)/,
10869          fences: noopTest,
10870          // fences not supported
10871          paragraph: edit(block.normal._paragraph)
10872            .replace("hr", block.hr)
10873            .replace("heading", " *#{1,6} *[^\n]")
10874            .replace("lheading", block.lheading)
10875            .replace("blockquote", " {0,3}>")
10876            .replace("|fences", "")
10877            .replace("|list", "")
10878            .replace("|html", "")
10879            .getRegex(),
10880        });
10881        /**
10882         * Inline-Level Grammar
10883         */
10884
10885        var inline = {
10886          escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,
10887          autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/,
10888          url: noopTest,
10889          tag:
10890            "^comment" +
10891            "|^</[a-zA-Z][\\w:-]*\\s*>" + // self-closing tag
10892            "|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>" + // open tag
10893            "|^<\\?[\\s\\S]*?\\?>" + // processing instruction, e.g. <?php ?>
10894            "|^<![a-zA-Z]+\\s[\\s\\S]*?>" + // declaration, e.g. <!DOCTYPE html>
10895            "|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>",
10896          // CDATA section
10897          link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,
10898          reflink: /^!?\[(label)\]\[(ref)\]/,
10899          nolink: /^!?\[(ref)\](?:\[\])?/,
10900          reflinkSearch: "reflink|nolink(?!\\()",
10901          emStrong: {
10902            lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,
10903            //        (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left.  (5) and (6) can be either Left or Right.
10904            //          () Skip orphan inside strong  () Consume to delim (1) #***                (2) a***#, a***                   (3) #***a, ***a                 (4) ***#              (5) #***#                 (6) a***a
10905            rDelimAst:
10906              /^[^_*]*?\_\_[^_*]*?\*[^_*]*?(?=\_\_)|[^*]+(?=[^*])|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/,
10907            rDelimUnd:
10908              /^[^_*]*?\*\*[^_*]*?\_[^_*]*?(?=\*\*)|[^_]+(?=[^_])|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/, // ^- Not allowed for _
10909          },
10910          code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,
10911          br: /^( {2,}|\\)\n(?!\s*$)/,
10912          del: noopTest,
10913          text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,
10914          punctuation: /^([\spunctuation])/,
10915        }; // list of punctuation marks from CommonMark spec
10916        // without * and _ to handle the different emphasis markers * and _
10917
10918        inline._punctuation = "!\"#$%&'()+\\-.,/:;<=>?@\\[\\]`^{|}~";
10919        inline.punctuation = edit(inline.punctuation)
10920          .replace(/punctuation/g, inline._punctuation)
10921          .getRegex(); // sequences em should skip over [title](link), `code`, <html>
10922
10923        inline.blockSkip = /\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g;
10924        inline.escapedEmSt = /\\\*|\\_/g;
10925        inline._comment = edit(block._comment)
10926          .replace("(?:-->|$)", "-->")
10927          .getRegex();
10928        inline.emStrong.lDelim = edit(inline.emStrong.lDelim)
10929          .replace(/punct/g, inline._punctuation)
10930          .getRegex();
10931        inline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, "g")
10932          .replace(/punct/g, inline._punctuation)
10933          .getRegex();
10934        inline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, "g")
10935          .replace(/punct/g, inline._punctuation)
10936          .getRegex();
10937        inline._escapes = /\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g;
10938        inline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/;
10939        inline._email =
10940          /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/;
10941        inline.autolink = edit(inline.autolink)
10942          .replace("scheme", inline._scheme)
10943          .replace("email", inline._email)
10944          .getRegex();
10945        inline._attribute =
10946          /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/;
10947        inline.tag = edit(inline.tag)
10948          .replace("comment", inline._comment)
10949          .replace("attribute", inline._attribute)
10950          .getRegex();
10951        inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/;
10952        inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/;
10953        inline._title =
10954          /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/;
10955        inline.link = edit(inline.link)
10956          .replace("label", inline._label)
10957          .replace("href", inline._href)
10958          .replace("title", inline._title)
10959          .getRegex();
10960        inline.reflink = edit(inline.reflink)
10961          .replace("label", inline._label)
10962          .replace("ref", block._label)
10963          .getRegex();
10964        inline.nolink = edit(inline.nolink)
10965          .replace("ref", block._label)
10966          .getRegex();
10967        inline.reflinkSearch = edit(inline.reflinkSearch, "g")
10968          .replace("reflink", inline.reflink)
10969          .replace("nolink", inline.nolink)
10970          .getRegex();
10971        /**
10972         * Normal Inline Grammar
10973         */
10974
10975        inline.normal = merge({}, inline);
10976        /**
10977         * Pedantic Inline Grammar
10978         */
10979
10980        inline.pedantic = merge({}, inline.normal, {
10981          strong: {
10982            start: /^__|\*\*/,
10983            middle:
10984              /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,
10985            endAst: /\*\*(?!\*)/g,
10986            endUnd: /__(?!_)/g,
10987          },
10988          em: {
10989            start: /^_|\*/,
10990            middle:
10991              /^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,
10992            endAst: /\*(?!\*)/g,
10993            endUnd: /_(?!_)/g,
10994          },
10995          link: edit(/^!?\[(label)\]\((.*?)\)/)
10996            .replace("label", inline._label)
10997            .getRegex(),
10998          reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/)
10999            .replace("label", inline._label)
11000            .getRegex(),
11001        });
11002        /**
11003         * GFM Inline Grammar
11004         */
11005
11006        inline.gfm = merge({}, inline.normal, {
11007          escape: edit(inline.escape).replace("])", "~|])").getRegex(),
11008          _extended_email:
11009            /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,
11010          url: /^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,
11011          _backpedal:
11012            /(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,
11013          del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,
11014          text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/,
11015        });
11016        inline.gfm.url = edit(inline.gfm.url, "i")
11017          .replace("email", inline.gfm._extended_email)
11018          .getRegex();
11019        /**
11020         * GFM + Line Breaks Inline Grammar
11021         */
11022
11023        inline.breaks = merge({}, inline.gfm, {
11024          br: edit(inline.br).replace("{2,}", "*").getRegex(),
11025          text: edit(inline.gfm.text)
11026            .replace("\\b_", "\\b_| {2,}\\n")
11027            .replace(/\{2,\}/g, "*")
11028            .getRegex(),
11029        });
11030
11031        /**
11032         * smartypants text replacement
11033         * @param {string} text
11034         */
11035
11036        function smartypants(text) {
11037          return text // em-dashes
11038            .replace(/---/g, "\u2014") // en-dashes
11039            .replace(/--/g, "\u2013") // opening singles
11040            .replace(/(^|[-\u2014/(\[{"\s])'/g, "$1\u2018") // closing singles & apostrophes
11041            .replace(/'/g, "\u2019") // opening doubles
11042            .replace(/(^|[-\u2014/(\[{\u2018\s])"/g, "$1\u201C") // closing doubles
11043            .replace(/"/g, "\u201D") // ellipses
11044            .replace(/\.{3}/g, "\u2026");
11045        }
11046        /**
11047         * mangle email addresses
11048         * @param {string} text
11049         */
11050
11051        function mangle(text) {
11052          var out = "",
11053            i,
11054            ch;
11055          var l = text.length;
11056
11057          for (i = 0; i < l; i++) {
11058            ch = text.charCodeAt(i);
11059
11060            if (Math.random() > 0.5) {
11061              ch = "x" + ch.toString(16);
11062            }
11063
11064            out += "&#" + ch + ";";
11065          }
11066
11067          return out;
11068        }
11069        /**
11070         * Block Lexer
11071         */
11072
11073        var Lexer = /*#__PURE__*/ (function () {
11074          function Lexer(options) {
11075            this.tokens = [];
11076            this.tokens.links = Object.create(null);
11077            this.options = options || exports.defaults;
11078            this.options.tokenizer = this.options.tokenizer || new Tokenizer();
11079            this.tokenizer = this.options.tokenizer;
11080            this.tokenizer.options = this.options;
11081            this.tokenizer.lexer = this;
11082            this.inlineQueue = [];
11083            this.state = {
11084              inLink: false,
11085              inRawBlock: false,
11086              top: true,
11087            };
11088            var rules = {
11089              block: block.normal,
11090              inline: inline.normal,
11091            };
11092
11093            if (this.options.pedantic) {
11094              rules.block = block.pedantic;
11095              rules.inline = inline.pedantic;
11096            } else if (this.options.gfm) {
11097              rules.block = block.gfm;
11098
11099              if (this.options.breaks) {
11100                rules.inline = inline.breaks;
11101              } else {
11102                rules.inline = inline.gfm;
11103              }
11104            }
11105
11106            this.tokenizer.rules = rules;
11107          }
11108          /**
11109           * Expose Rules
11110           */
11111
11112          /**
11113           * Static Lex Method
11114           */
11115          Lexer.lex = function lex(src, options) {
11116            var lexer = new Lexer(options);
11117            return lexer.lex(src);
11118          };
11119          /**
11120           * Static Lex Inline Method
11121           */
11122
11123          Lexer.lexInline = function lexInline(src, options) {
11124            var lexer = new Lexer(options);
11125            return lexer.inlineTokens(src);
11126          };
11127          /**
11128           * Preprocessing
11129           */
11130
11131          var _proto = Lexer.prototype;
11132
11133          _proto.lex = function lex(src) {
11134            src = src.replace(/\r\n|\r/g, "\n");
11135            this.blockTokens(src, this.tokens);
11136            var next;
11137
11138            while ((next = this.inlineQueue.shift())) {
11139              this.inlineTokens(next.src, next.tokens);
11140            }
11141
11142            return this.tokens;
11143          };
11144          /**
11145           * Lexing
11146           */
11147
11148          _proto.blockTokens = function blockTokens(src, tokens) {
11149            var _this = this;
11150
11151            if (tokens === void 0) {
11152              tokens = [];
11153            }
11154
11155            if (this.options.pedantic) {
11156              src = src.replace(/\t/g, "    ").replace(/^ +$/gm, "");
11157            } else {
11158              src = src.replace(/^( *)(\t+)/gm, function (_, leading, tabs) {
11159                return leading + "    ".repeat(tabs.length);
11160              });
11161            }
11162
11163            var token, lastToken, cutSrc, lastParagraphClipped;
11164
11165            while (src) {
11166              if (
11167                this.options.extensions &&
11168                this.options.extensions.block &&
11169                this.options.extensions.block.some(function (extTokenizer) {
11170                  if (
11171                    (token = extTokenizer.call(
11172                      {
11173                        lexer: _this,
11174                      },
11175                      src,
11176                      tokens,
11177                    ))
11178                  ) {
11179                    src = src.substring(token.raw.length);
11180                    tokens.push(token);
11181                    return true;
11182                  }
11183
11184                  return false;
11185                })
11186              ) {
11187                continue;
11188              } // newline
11189
11190              if ((token = this.tokenizer.space(src))) {
11191                src = src.substring(token.raw.length);
11192
11193                if (token.raw.length === 1 && tokens.length > 0) {
11194                  // if there's a single \n as a spacer, it's terminating the last line,
11195                  // so move it there so that we don't get unecessary paragraph tags
11196                  tokens[tokens.length - 1].raw += "\n";
11197                } else {
11198                  tokens.push(token);
11199                }
11200
11201                continue;
11202              } // code
11203
11204              if ((token = this.tokenizer.code(src))) {
11205                src = src.substring(token.raw.length);
11206                lastToken = tokens[tokens.length - 1]; // An indented code block cannot interrupt a paragraph.
11207
11208                if (
11209                  lastToken &&
11210                  (lastToken.type === "paragraph" || lastToken.type === "text")
11211                ) {
11212                  lastToken.raw += "\n" + token.raw;
11213                  lastToken.text += "\n" + token.text;
11214                  this.inlineQueue[this.inlineQueue.length - 1].src =
11215                    lastToken.text;
11216                } else {
11217                  tokens.push(token);
11218                }
11219
11220                continue;
11221              } // fences
11222
11223              if ((token = this.tokenizer.fences(src))) {
11224                src = src.substring(token.raw.length);
11225                tokens.push(token);
11226                continue;
11227              } // heading
11228
11229              if ((token = this.tokenizer.heading(src))) {
11230                src = src.substring(token.raw.length);
11231                tokens.push(token);
11232                continue;
11233              } // hr
11234
11235              if ((token = this.tokenizer.hr(src))) {
11236                src = src.substring(token.raw.length);
11237                tokens.push(token);
11238                continue;
11239              } // blockquote
11240
11241              if ((token = this.tokenizer.blockquote(src))) {
11242                src = src.substring(token.raw.length);
11243                tokens.push(token);
11244                continue;
11245              } // list
11246
11247              if ((token = this.tokenizer.list(src))) {
11248                src = src.substring(token.raw.length);
11249                tokens.push(token);
11250                continue;
11251              } // html
11252
11253              if ((token = this.tokenizer.html(src))) {
11254                src = src.substring(token.raw.length);
11255                tokens.push(token);
11256                continue;
11257              } // def
11258
11259              if ((token = this.tokenizer.def(src))) {
11260                src = src.substring(token.raw.length);
11261                lastToken = tokens[tokens.length - 1];
11262
11263                if (
11264                  lastToken &&
11265                  (lastToken.type === "paragraph" || lastToken.type === "text")
11266                ) {
11267                  lastToken.raw += "\n" + token.raw;
11268                  lastToken.text += "\n" + token.raw;
11269                  this.inlineQueue[this.inlineQueue.length - 1].src =
11270                    lastToken.text;
11271                } else if (!this.tokens.links[token.tag]) {
11272                  this.tokens.links[token.tag] = {
11273                    href: token.href,
11274                    title: token.title,
11275                  };
11276                }
11277
11278                continue;
11279              } // table (gfm)
11280
11281              if ((token = this.tokenizer.table(src))) {
11282                src = src.substring(token.raw.length);
11283                tokens.push(token);
11284                continue;
11285              } // lheading
11286
11287              if ((token = this.tokenizer.lheading(src))) {
11288                src = src.substring(token.raw.length);
11289                tokens.push(token);
11290                continue;
11291              } // top-level paragraph
11292              // prevent paragraph consuming extensions by clipping 'src' to extension start
11293
11294              cutSrc = src;
11295
11296              if (
11297                this.options.extensions &&
11298                this.options.extensions.startBlock
11299              ) {
11300                (function () {
11301                  var startIndex = Infinity;
11302                  var tempSrc = src.slice(1);
11303                  var tempStart = void 0;
11304
11305                  _this.options.extensions.startBlock.forEach(
11306                    function (getStartIndex) {
11307                      tempStart = getStartIndex.call(
11308                        {
11309                          lexer: this,
11310                        },
11311                        tempSrc,
11312                      );
11313
11314                      if (typeof tempStart === "number" && tempStart >= 0) {
11315                        startIndex = Math.min(startIndex, tempStart);
11316                      }
11317                    },
11318                  );
11319
11320                  if (startIndex < Infinity && startIndex >= 0) {
11321                    cutSrc = src.substring(0, startIndex + 1);
11322                  }
11323                })();
11324              }
11325
11326              if (
11327                this.state.top &&
11328                (token = this.tokenizer.paragraph(cutSrc))
11329              ) {
11330                lastToken = tokens[tokens.length - 1];
11331
11332                if (lastParagraphClipped && lastToken.type === "paragraph") {
11333                  lastToken.raw += "\n" + token.raw;
11334                  lastToken.text += "\n" + token.text;
11335                  this.inlineQueue.pop();
11336                  this.inlineQueue[this.inlineQueue.length - 1].src =
11337                    lastToken.text;
11338                } else {
11339                  tokens.push(token);
11340                }
11341
11342                lastParagraphClipped = cutSrc.length !== src.length;
11343                src = src.substring(token.raw.length);
11344                continue;
11345              } // text
11346
11347              if ((token = this.tokenizer.text(src))) {
11348                src = src.substring(token.raw.length);
11349                lastToken = tokens[tokens.length - 1];
11350
11351                if (lastToken && lastToken.type === "text") {
11352                  lastToken.raw += "\n" + token.raw;
11353                  lastToken.text += "\n" + token.text;
11354                  this.inlineQueue.pop();
11355                  this.inlineQueue[this.inlineQueue.length - 1].src =
11356                    lastToken.text;
11357                } else {
11358                  tokens.push(token);
11359                }
11360
11361                continue;
11362              }
11363
11364              if (src) {
11365                var errMsg = "Infinite loop on byte: " + src.charCo
11365deAt(0);
11366
11367                if (this.options.silent) {
11368                  console.error(errMsg);
11369                  break;
11370                } else {
11371                  throw new Error(errMsg);
11372                }
11373              }
11374            }
11375
11376            this.state.top = true;
11377            return tokens;
11378          };
11379
11380          _proto.inline = function inline(src, tokens) {
11381            if (tokens === void 0) {
11382              tokens = [];
11383            }
11384
11385            this.inlineQueue.push({
11386              src: src,
11387              tokens: tokens,
11388            });
11389            return tokens;
11390          };
11391          /**
11392           * Lexing/Compiling
11393           */
11394
11395          _proto.inlineTokens = function inlineTokens(src, tokens) {
11396            var _this2 = this;
11397
11398            if (tokens === void 0) {
11399              tokens = [];
11400            }
11401
11402            var token, lastToken, cutSrc; // String with links masked to avoid interference with em and strong
11403
11404            var maskedSrc = src;
11405            var match;
11406            var keepPrevChar, prevChar; // Mask out reflinks
11407
11408            if (this.tokens.links) {
11409              var links = Object.keys(this.tokens.links);
11410
11411              if (links.length > 0) {
11412                while (
11413                  (match =
11414                    this.tokenizer.rules.inline.reflinkSearch.exec(
11415                      maskedSrc,
11416                    )) != null
11417                ) {
11418                  if (
11419                    links.includes(
11420                      match[0].slice(match[0].lastIndexOf("[") + 1, -1),
11421                    )
11422                  ) {
11423                    maskedSrc =
11424                      maskedSrc.slice(0, match.index) +
11425                      "[" +
11426                      repeatString("a", match[0].length - 2) +
11427                      "]" +
11428                      maskedSrc.slice(
11429                        this.tokenizer.rules.inline.reflinkSearch.lastIndex,
11430                      );
11431                  }
11432                }
11433              }
11434            } // Mask out other blocks
11435
11436            while (
11437              (match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) !=
11438              null
11439            ) {
11440              maskedSrc =
11441                maskedSrc.slice(0, match.index) +
11442                "[" +
11443                repeatString("a", match[0].length - 2) +
11444                "]" +
11445                maskedSrc.slice(
11446                  this.tokenizer.rules.inline.blockSkip.lastIndex,
11447                );
11448            } // Mask out escaped em & strong delimiters
11449
11450            while (
11451              (match =
11452                this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null
11453            ) {
11454              maskedSrc =
11455                maskedSrc.slice(0, match.index) +
11456                "++" +
11457                maskedSrc.slice(
11458                  this.tokenizer.rules.inline.escapedEmSt.lastIndex,
11459                );
11460            }
11461
11462            while (src) {
11463              if (!keepPrevChar) {
11464                prevChar = "";
11465              }
11466
11467              keepPrevChar = false; // extensions
11468
11469              if (
11470                this.options.extensions &&
11471                this.options.extensions.inline &&
11472                this.options.extensions.inline.some(function (extTokenizer) {
11473                  if (
11474                    (token = extTokenizer.call(
11475                      {
11476                        lexer: _this2,
11477                      },
11478                      src,
11479                      tokens,
11480                    ))
11481                  ) {
11482                    src = src.substring(token.raw.length);
11483                    tokens.push(token);
11484                    return true;
11485                  }
11486
11487                  return false;
11488                })
11489              ) {
11490                continue;
11491              } // escape
11492
11493              if ((token = this.tokenizer.escape(src))) {
11494                src = src.substring(token.raw.length);
11495                tokens.push(token);
11496                continue;
11497              } // tag
11498
11499              if ((token = this.tokenizer.tag(src))) {
11500                src = src.substring(token.raw.length);
11501                lastToken = tokens[tokens.length - 1];
11502
11503                if (
11504                  lastToken &&
11505                  token.type === "text" &&
11506                  lastToken.type === "text"
11507                ) {
11508                  lastToken.raw += token.raw;
11509                  lastToken.text += token.text;
11510                } else {
11511                  tokens.push(token);
11512                }
11513
11514                continue;
11515              } // link
11516
11517              if ((token = this.tokenizer.link(src))) {
11518                src = src.substring(token.raw.length);
11519                tokens.push(token);
11520                continue;
11521              } // reflink, nolink
11522
11523              if ((token = this.tokenizer.reflink(src, this.tokens.links))) {
11524                src = src.substring(token.raw.length);
11525                lastToken = tokens[tokens.length - 1];
11526
11527                if (
11528                  lastToken &&
11529                  token.type === "text" &&
11530                  lastToken.type === "text"
11531                ) {
11532                  lastToken.raw += token.raw;
11533                  lastToken.text += token.text;
11534                } else {
11535                  tokens.push(token);
11536                }
11537
11538                continue;
11539              } // em & strong
11540
11541              if ((token = this.tokenizer.emStrong(src, maskedSrc, prevChar))) {
11542                src = src.substring(token.raw.length);
11543                tokens.push(token);
11544                continue;
11545              } // code
11546
11547              if ((token = this.tokenizer.codespan(src))) {
11548                src = src.substring(token.raw.length);
11549                tokens.push(token);
11550                continue;
11551              } // br
11552
11553              if ((token = this.tokenizer.br(src))) {
11554                src = src.substring(token.raw.length);
11555                tokens.push(token);
11556                continue;
11557              } // del (gfm)
11558
11559              if ((token = this.tokenizer.del(src))) {
11560                src = src.substring(token.raw.length);
11561                tokens.push(token);
11562                continue;
11563              } // autolink
11564
11565              if ((token = this.tokenizer.autolink(src, mangle))) {
11566                src = src.substring(token.raw.length);
11567                tokens.push(token);
11568                continue;
11569              } // url (gfm)
11570
11571              if (
11572                !this.state.inLink &&
11573                (token = this.tokenizer.url(src, mangle))
11574              ) {
11575                src = src.substring(token.raw.length);
11576                tokens.push(token);
11577                continue;
11578              } // text
11579              // prevent inlineText consuming extensions by clipping 'src' to extension start
11580
11581              cutSrc = src;
11582
11583              if (
11584                this.options.extensions &&
11585                this.options.extensions.startInline
11586              ) {
11587                (function () {
11588                  var startIndex = Infinity;
11589                  var tempSrc = src.slice(1);
11590                  var tempStart = void 0;
11591
11592                  _this2.options.extensions.startInline.forEach(
11593                    function (getStartIndex) {
11594                      tempStart = getStartIndex.call(
11595                        {
11596                          lexer: this,
11597                        },
11598                        tempSrc,
11599                      );
11600
11601                      if (typeof tempStart === "number" && tempStart >= 0) {
11602                        startIndex = Math.min(startIndex, tempStart);
11603                      }
11604                    },
11605                  );
11606
11607                  if (startIndex < Infinity && startIndex >= 0) {
11608                    cutSrc = src.substring(0, startIndex + 1);
11609                  }
11610                })();
11611              }
11612
11613              if ((token = this.tokenizer.inlineText(cutSrc, smartypants)
11613)) {
11614                src = src.substring(token.raw.length);
11615
11616                if (token.raw.slice(-1) !== "_") {
11617                  // Track prevChar before string of ____ started
11618                  prevChar = token.raw.slice(-1);
11619                }
11620
11621                keepPrevChar = true;
11622                lastToken = tokens[tokens.length - 1];
11623
11624                if (lastToken && lastToken.type === "text") {
11625                  lastToken.raw += token.raw;
11626                  lastToken.text += token.text;
11627                } else {
11628                  tokens.push(token);
11629                }
11630
11631                continue;
11632              }
11633
11634              if (src) {
11635                var errMsg = "Infinite loop on byte: " + src.charCodeAt(0);
11636
11637                if (this.options.silent) {
11638                  console.error(errMsg);
11639                  break;
11640                } else {
11641                  throw new Error(errMsg);
11642                }
11643              }
11644            }
11645
11646            return tokens;
11647          };
11648
11649          _createClass(Lexer, null, [
11650            {
11651              key: "rules",
11652              get: function get() {
11653                return {
11654                  block: block,
11655                  inline: inline,
11656                };
11657              },
11658            },
11659          ]);
11660
11661          return Lexer;
11662        })();
11663
11664        /**
11665         * Renderer
11666         */
11667
11668        var Renderer = /*#__PURE__*/ (function () {
11669          function Renderer(options) {
11670            this.options = options || exports.defaults;
11671          }
11672
11673          var _proto = Renderer.prototype;
11674
11675          _proto.code = function code(_code, infostring, escaped) {
11676            var lang = (infostring || "").match(/\S*/)[0];
11677
11678            if (this.options.highlight) {
11679              var out = this.options.highlight(_code, lang);
11680
11681              if (out != null && out !== _code) {
11682                escaped = true;
11683                _code = out;
11684              }
11685            }
11686
11687            _code = _code.replace(/\n$/, "") + "\n";
11688
11689            if (!lang) {
11690              return (
11691                "<pre><code>" +
11692                (escaped ? _code : escape(_code, true)) +
11693                "</code></pre>\n"
11694              );
11695            }
11696
11697            return (
11698              '<pre><code class="' +
11699              this.options.langPrefix +
11700              escape(lang, true) +
11701              '">' +
11702              (escaped ? _code : escape(_code, true)) +
11703              "</code></pre>\n"
11704            );
11705          };
11706          /**
11707           * @param {string} quote
11708           */
11709
11710          _proto.blockquote = function blockquote(quote) {
11711            return "<blockquote>\n" + quote + "</blockquote>\n";
11712          };
11713
11714          _proto.html = function html(_html) {
11715            return _html;
11716          };
11717          /**
11718           * @param {string} text
11719           * @param {string} level
11720           * @param {string} raw
11721           * @param {any} slugger
11722           */
11723
11724          _proto.heading = function heading(text, level, raw, slugger) {
11725            if (this.options.headerIds) {
11726              var id = this.options.headerPrefix + slugger.slug(raw);
11727              return (
11728                "<h" +
11729                level +
11730                ' id="' +
11731                id +
11732                '">' +
11733                text +
11734                "</h" +
11735                level +
11736                ">\n"
11737              );
11738            } // ignore IDs
11739
11740            return "<h" + level + ">" + text + "</h" + level + ">\n";
11741          };
11742
11743          _proto.hr = function hr() {
11744            return this.options.xhtml ? "<hr/>\n" : "<hr>\n";
11745          };
11746
11747          _proto.list = function list(body, ordered, start) {
11748            var type = ordered ? "ol" : "ul",
11749              startatt = ordered && start !== 1 ? ' start="' + start + '"' : "";
11750            return "<" + type + startatt + ">\n" + body + "</" + type + ">\n";
11751          };
11752          /**
11753           * @param {string} text
11754           */
11755
11756          _proto.listitem = function listitem(text) {
11757            return "<li>" + text + "</li>\n";
11758          };
11759
11760          _proto.checkbox = function checkbox(checked) {
11761            return (
11762              "<input " +
11763              (checked ? 'checked="" ' : "") +
11764              'disabled="" type="checkbox"' +
11765              (this.options.xhtml ? " /" : "") +
11766              "> "
11767            );
11768          };
11769          /**
11770           * @param {string} text
11771           */
11772
11773          _proto.paragraph = function paragraph(text) {
11774            return "<p>" + text + "</p>\n";
11775          };
11776          /**
11777           * @param {string} header
11778           * @param {string} body
11779           */
11780
11781          _proto.table = function table(header, body) {
11782            if (body) body = "<tbody>" + body + "</tbody>";
11783            return (
11784              "<table>\n" +
11785              "<thead>\n" +
11786              header +
11787              "</thead>\n" +
11788              body +
11789              "</table>\n"
11790            );
11791          };
11792          /**
11793           * @param {string} content
11794           */
11795
11796          _proto.tablerow = function tablerow(content) {
11797            return "<tr>\n" + content + "</tr>\n";
11798          };
11799
11800          _proto.tablecell = function tablecell(content, flags) {
11801            var type = flags.header ? "th" : "td";
11802            var tag = flags.align
11803              ? "<" + type + ' align="' + flags.align + '">'
11804              : "<" + type + ">";
11805            return tag + content + ("</" + type + ">\n");
11806          };
11807          /**
11808           * span level renderer
11809           * @param {string} text
11810           */
11811
11812          _proto.strong = function strong(text) {
11813            return "<strong>
11813" + text + "</strong>";
11814          };
11815          /**
11816           * @param {string} text
11817           */
11818
11819          _proto.em = function em(text) {
11820            return "<em>" + text + "</em>";
11821          };
11822          /**
11823           * @param {string} text
11824           */
11825
11826          _proto.codespan = function codespan(text) {
11827            return "<code>" + text + "</code>";
11828          };
11829
11830          _proto.br = function br() {
11831            return this.options.xhtml ? "<br/>" : "<br>";
11832          };
11833          /**
11834           * @param {string} text
11835           */
11836
11837          _proto.del = function del(text) {
11838            return "<del>" + text + "</del>";
11839          };
11840          /**
11841           * @param {string} href
11842           * @param {string} title
11843           * @param {string} text
11844           */
11845
11846          _proto.link = function link(href, title, text) {
11847            href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
11848
11849            if (href === null) {
11850              return text;
11851            }
11852
11853            var out = '<a href="' + escape(href) + '"';
11854
11855            if (title) {
11856              out += ' title="' + title + '"';
11857            }
11858
11859            out += ">" + text + "</a>";
11860            return out;
11861          };
11862          /**
11863           * @param {string} href
11864           * @param {string} title
11865           * @param {string} text
11866           */
11867
11868          _proto.image = function image(href, title, text) {
11869            href = cleanUrl(this.options.sanitize, this.options.baseUrl, href);
11870
11871            if (href === null) {
11872              return text;
11873            }
11874
11875            var out = '<img src="' + href + '" alt="' + text + '"';
11876
11877            if (title) {
11878              out += ' title="' + title + '"';
11879            }
11880
11881            out += this.options.xhtml ? "/>" : ">";
11882            return out;
11883          };
11884
11885          _proto.text = function text(_text) {
11886            return _text;
11887          };
11888
11889          return Renderer;
11890        })();
11891
11892        /**
11893         * TextRenderer
11894         * returns only the textual part of the token
11895         */
11896        var TextRenderer = /*#__PURE__*/ (function () {
11897          function TextRenderer() {}
11898
11899          var _proto = TextRenderer.prototype;
11900
11901          // no need for block level renderers
11902          _proto.strong = function strong(text) {
11903            return text;
11904          };
11905
11906          _proto.em = function em(text) {
11907            return text;
11908          };
11909
11910          _proto.codespan = function codespan(text) {
11911            return text;
11912          };
11913
11914          _proto.del = function del(text) {
11915            return text;
11916          };
11917
11918          _proto.html = function html(text) {
11919            return text;
11920          };
11921
11922          _proto.text = function text(_text) {
11923            return _text;
11924          };
11925
11926          _proto.link = function link(href, title, text) {
11927            return "" + text;
11928          };
11929
11930          _proto.image = function image(href, title, text) {
11931            return "" + text;
11932          };
11933
11934          _proto.br = function br() {
11935            return "";
11936          };
11937
11938          return TextRenderer;
11939        })();
11940
11941        /**
11942         * Slugger generates header id
11943         */
11944        var Slugger = /*#__PURE__*/ (function () {
11945          function Slugger() {
11946            this.seen = {};
11947          }
11948          /**
11949           * @param {string} value
11950           */
11951
11952          var _proto = Slugger.prototype;
11953
11954          _proto.serialize = function serialize(value) {
11955            return value
11956              .toLowerCase()
11957              .trim() // remove html tags
11958              .replace(/<[!\/a-z].*?>/gi, "") // remove unwanted chars
11959              .replace(
11960                /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,
11961                "",
11962              )
11963              .replace(/\s/g, "-");
11964          };
11965          /**
11966           * Finds the next safe (unique) slug to use
11967           * @param {string} originalSlug
11968           * @param {boolean} isDryRun
11969           */
11970
11971          _proto.getNextSafeSlug = function getNextSafeSlug(
11972            originalSlug,
11973            isDryRun,
11974          ) {
11975            var slug = originalSlug;
11976            var occurenceAccumulator = 0;
11977
11978            if (this.seen.hasOwnProperty(slug)) {
11979              occurenceAccumulator = this.seen[originalSlug];
11980
11981              do {
11982                occurenceAccumulator++;
11983                slug = originalSlug + "-" + occurenceAccumulator;
11984              } while (this.seen.hasOwnProperty(slug));
11985            }
11986
11987            if (!isDryRun) {
11988              this.seen[originalSlug] = occurenceAccumulator;
11989              this.seen[slug] = 0;
11990            }
11991
11992            return slug;
11993          };
11994          /**
11995           * Convert string to unique id
11996           * @param {object} [options]
11997           * @param {boolean} [options.dryrun] Generates the next unique slug without
11998           * updating the internal accumulator.
11999           */
12000
12001          _proto.slug = function slug(value, options) {
12002            if (options === void 0) {
12003              options = {};
12004            }
12005
12006            var slug = this.serialize(value);
12007            return this.getNextSafeSlug(slug, options.dryrun);
12008          };
12009
12010          return Slugger;
12011        })();
12012
12013        /**
12014         * Parsing & Compiling
12015         */
12016
12017        var Parser = /*#__PURE__*/ (function () {
12018          function Parser(options) {
12019            this.options = options || exports.defaults;
12020            this.options.renderer = this.options.renderer || new Renderer();
12021            this.renderer = this.options.renderer;
12022            this.renderer.options = this.options;
12023            this.textRenderer = new TextRenderer();
12024            this.slugger = new Slugger();
12025          }
12026          /**
12027           * Static Parse Method
12028           */
12029
12030          Parser.parse = function parse(tokens, options) {
12031            var parser = new Parser(options);
12032            return parser.parse(tokens);
12033          };
12034          /**
12035           * Static Parse Inline Method
12036           */
12037
12038          Parser.parseInline = function parseInline(tokens, options) {
12039            var parser = new Parser(options);
12040            return parser.parseInline(tokens);
12041          };
12042          /**
12043           * Parse Loop
12044           */
12045
12046          var _proto = Parser.prototype;
12047
12048          _proto.parse = function parse(tokens, top) {
12049            if (top === void 0) {
12050              top = true;
12051            }
12052
12053            var out = "",
12054              i,
12055              j,
12056              k,
12057              l2,
12058              l3,
12059              row,
12060              cell,
12061              header,
12062              body,
12063              token,
12064              ordered,
12065              start,
12066              loose,
12067              itemBody,
12068              item,
12069              checked,
12070              task,
12071              checkbox,
12072              ret;
12073            var l = tokens.length;
12074
12075            for (i = 0; i < l; i++) {
12076              token = tokens[i]; // Run any renderer extensions
12077
12078              if (
12079                this.options.extensions &&
12080                this.options.extensions.renderers &&
12081                this.options.extensions.renderers[token.type]
12082              ) {
12083                ret = this.options.extensions.renderers[token.type].call(
12084                  {
12085                    parser: this,
12086                  },
12087                  token,
12088                );
12089
12090                if (
12091                  ret !== false ||
12092                  ![
12093                    "space",
12094                    "hr",
12095                    "heading",
12096                    "code",
12097                    "table",
12098                    "blockquote",
12099                    "list",
12100                    "html",
12101                    "paragraph",
12102                    "text",
12103                  ].includes(token.type)
12104                ) {
12105                  out += ret || "";
12106                  continue;
12107                }
12108              }
12109
12110              switch (token.type) {
12111                case "space": {
12112                  continue;
12113                }
12114
12115                case "hr": {
12116                  out += this.renderer.hr();
12117                  continue;
12118                }
12119
12120                case "heading": {
12121                  out += this.renderer.heading(
12122                    this.parseInline(token.tokens),
12123                    token.depth,
12124                    unescape(this.parseInline(token.tokens, this.textRenderer)),
12125                    this.slugger,
12126                  );
12127                  continue;
12128                }
12129
12130                case "code": {
12131                  out += this.renderer.code(
12132                    token.text,
12133                    token.lang,
12134                    token.escaped,
12135                  );
12136                  continue;
12137                }
12138
12139                case "table": {
12140                  header = ""; // header
12141
12142                  cell = "";
12143                  l2 = token.header.length;
12144
12145                  for (j = 0; j < l2; j++) {
12146                    cell += this.renderer.tablecell(
12147                      this.parseInline(token.header[j].tokens),
12148                      {
12149                        header: true,
12150                        align: token.align[j],
12151                      },
12152                    );
12153                  }
12154
12155                  header += this.renderer.tablerow(cell);
12156                  body = "";
12157                  l2 = token.rows.length;
12158
12159                  for (j = 0; j < l2; j++) {
12160                    row = token.rows[j];
12161                    cell = "";
12162                    l3 = row.length;
12163
12164                    for (k = 0; k < l3; k++) {
12165                      cell += this.renderer.tablecell(
12166                        this.parseInline(row[k].tokens),
12167                        {
12168                          header: false,
12169                          align: token.align[k],
12170                        },
12171                      );
12172                    }
12173
12174                    body += this.renderer.tablerow(cell);
12175                  }
12176
12177                  out += this.renderer.table(header, body);
12178                  continue;
12179                }
12180
12181                case "blockquote": {
12182                  body = this.parse(token.tokens);
12183                  out += this.renderer.blockquote(body);
12184                  continue;
12185                }
12186
12187                case "list": {
12188                  ordered = token.ordered;
12189                  start = token.start;
12190                  loose = token.loose;
12191                  l2 = token.items.length;
12192                  body = "";
12193
12194                  for (j = 0; j < l2; j++) {
12195                    item = token.items[j];
12196                    checked = item.checked;
12197                    task = item.task;
12198                    itemBody = "";
12199
12200                    if (item.task) {
12201                      checkbox = this.renderer.checkbox(checked);
12202
12203                      if (loose) {
12204                        if (
12205                          item.tokens.length > 0 &&
12206                          item.tokens[0].type === "paragraph"
12207                        ) {
12208                          item.tokens[0].text =
12209                            checkbox + " " + item.tokens[0].text;
12210
12211                          if (
12212                            item.tokens[0].tokens &&
12213                            item.tokens[0].tokens.length > 0 &&
12214                            item.tokens[0].tokens[0].type === "text"
12215                          ) {
12216                            item.tokens[0].tokens[0].text =
12217                              checkbox + " " + item.tokens[0].tokens[0].text;
12218                          }
12219                        } else {
12220                          item.tokens.unshift({
12221                            type: "text",
12222                            text: checkbox,
12223                          });
12224                        }
12225                      } else {
12226                        itemBody += checkbox;
12227                      }
12228                    }
12229
12230                    itemBody += this.parse(item.tokens, loose);
12231                    body += this.renderer.listitem(itemBody, task, checked);
12232                  }
12233
12234                  out += this.renderer.list(body, ordered, start);
12235                  continue;
12236                }
12237
12238                case "html": {
12239                  // TODO parse inline content if parameter markdown=1
12240                  out += this.renderer.html(token.text);
12241                  continue;
12242                }
12243
12244                case "paragraph": {
12245                  out += this.renderer.paragraph(
12246                    this.parseInline(token.tokens),
12247                  );
12248                  continue;
12249                }
12250
12251                case "text": {
12252                  body = token.tokens
12253                    ? this.parseInline(token.tokens)
12254                    : token.text;
12255
12256                  while (i + 1 < l && tokens[i + 1].type === "text") {
12257                    token = tokens[++i];
12258                    body +=
12259                      "\n" +
12260                      (token.tokens
12261                        ? this.parseInline(token.tokens)
12262                        : token.text);
12263                  }
12264
12265                  out += top ? this.renderer.paragraph(body) : body;
12266                  continue;
12267                }
12268
12269                default: {
12270                  var errMsg =
12271                    'Token with "' + token.type + '" type was not found.';
12272
12273                  if (this.options.silent) {
12274                    console.error(errMsg);
12275                    return;
12276                  } else {
12277                    throw new Error(errMsg);
12278                  }
12279                }
12280              }
12281            }
12282
12283            return out;
12284          };
12285          /**
12286           * Parse Inline Tokens
12287           */
12288
12289          _proto.parseInline = function parseInline(tokens, renderer) {
12290            renderer = renderer || this.renderer;
12291            var out = "",
12292              i,
12293              token,
12294              ret;
12295            var l = tokens.length;
12296
12297            for (i = 0; i < l; i++) {
12298              token = tokens[i]; // Run any renderer extensions
12299
12300              if (
12301                this.options.extensions &&
12302                this.options.extensions.renderers &&
12303                this.options.extensions.renderers[token.type]
12304              ) {
12305                ret = this.options.extensions.renderers[token.type].call(
12306                  {
12307                    parser: this,
12308                  },
12309                  token,
12310                );
12311
12312                if (
12313                  ret !== false ||
12314                  ![
12315                    "escape",
12316                    "html",
12317                    "link",
12318                    "image",
12319                    "strong",
12320                    "em",
12321                    "codespan",
12322                    "br",
12323                    "del",
12324                    "text",
12325                  ].includes(token.type)
12326                ) {
12327                  out += ret || "";
12328                  continue;
12329                }
12330              }
12331
12332              switch (token.type) {
12333                case "escape": {
12334                  out += renderer.text(token.text);
12335                  break;
12336                }
12337
12338                case "html": {
12339                  out += renderer.html(token.text);
12340                  break;
12341                }
12342
12343                case "link": {
12344                  out += renderer.link(
12345                    token.href,
12346                    token.title,
12347                    this.parseInline(token.tokens, renderer),
12348                  );
12349                  break;
12350                }
12351
12352                case "image": {
12353                  out += renderer.image(token.href, token.title, token.text);
12354                  break;
12355                }
12356
12357                case "strong": {
12358                  out += renderer.strong(
12359                    this.parseInline(token.tokens, renderer),
12360                  );
12361                  break;
12362                }
12363
12364                case "em": {
12365                  out += renderer.em(this.parseInline(token.tokens, renderer));
12366                  break;
12367                }
12368
12369                case "codespan": {
12370                  out += renderer.codespan(token.text);
12371                  break;
12372                }
12373
12374                case "br": {
12375                  out += renderer.br();
12376                  break;
12377                }
12378
12379                case "del": {
12380                  out += renderer.del(this.parseInline(token.tokens, renderer));
12381                  break;
12382                }
12383
12384                case "text": {
12385                  out += renderer.text(token.text);
12386                  break;
12387                }
12388
12389                default: {
12390                  var errMsg =
12391                    'Token with "' + token.type + '" type was not found.';
12392
12393                  if (this.options.silent) {
12394                    console.error(errMsg);
12395                    return;
12396                  } else {
12397                    throw new Error(errMsg);
12398                  }
12399                }
12400              }
12401            }
12402
12403            return out;
12404          };
12405
12406          return Parser;
12407        })();
12408
12409        /**
12410         * Marked
12411         */
12412
12413        function marked(src, opt, callback) {
12414          // throw error in case of non string input
12415          if (typeof src === "undefined" || src === null) {
12416            throw new Error("marked(): input parameter is undefined or null");
12417          }
12418
12419          if (typeof src !== "string") {
12420            throw new Error(
12421              "marked(): input parameter is of type " +
12422                Object.prototype.toString.call(src) +
12423                ", string expected",
12424            );
12425          }
12426
12427          if (typeof opt === "function") {
12428            callback = opt;
12429            opt = null;
12430          }
12431
12432          opt = merge({}, marked.defaults, opt || {});
12433          checkSanitizeDeprecation(opt);
12434
12435          if (callback) {
12436            var highlight = opt.highlight;
12437            var tokens;
12438
12439            try {
12440              tokens = Lexer.lex(src, opt);
12441            } catch (e) {
12442              return callback(e);
12443            }
12444
12445            var done = function done(err) {
12446              var out;
12447
12448              if (!err) {
12449                try {
12450                  if (opt.walkTokens) {
12451                    marked.walkTokens(tokens, opt.walkTokens);
12452                  }
12453
12454                  out = Parser.parse(tokens, opt);
12455                } catch (e) {
12456                  err = e;
12457                }
12458              }
12459
12460              opt.highlight = highlight;
12461              return err ? callback(err) : callback(null, out);
12462            };
12463
12464            if (!highlight || highlight.length < 3) {
12465              return done();
12466            }
12467
12468            delete opt.highlight;
12469            if (!tokens.length) return done();
12470            var pending = 0;
12471            marked.walkTokens(tokens, function (token) {
12472              if (token.type === "code") {
12473                pending++;
12474                setTimeout(function () {
12475                  highlight(token.text, token.lang, function (err, code) {
12476                    if (err) {
12477                      return done(err);
12478                    }
12479
12480                    if (code != null && code !== token.text) {
12481                      token.text = code;
12482                      token.escaped = true;
12483                    }
12484
12485                    pending--;
12486
12487                    if (pending === 0) {
12488                      done();
12489                    }
12490                  });
12491                }, 0);
12492              }
12493            });
12494
12495            if (pending === 0) {
12496              done();
12497            }
12498
12499            return;
12500          }
12501
12502          try {
12503            var _tokens = Lexer.lex(src, opt);
12504
12505            if (opt.walkTokens) {
12506              marked.walkTokens(_tokens, opt.walkTokens);
12507            }
12508
12509            return Parser.parse(_tokens, opt);
12510          } catch (e) {
12511            e.message +=
12512              "\nPlease report this to https://github.com/markedjs/marked.";
12513
12514            if (opt.silent) {
12515              return (
12516                "<p>An error occurred:</p><pre>" +
12517                escape(e.message + "", true) +
12518                "</pre>"
12519              );
12520            }
12521
12522            throw e;
12523          }
12524        }
12525        /**
12526         * Options
12527         */
12528
12529        marked.options = marked.setOptions = function (opt) {
12530          merge(marked.defaults, opt);
12531          changeDefaults(marked.defaults);
12532          return marked;
12533        };
12534
12535        marked.getDefaults = getDefaults;
12536        marked.defaults = exports.defaults;
12537        /**
12538         * Use Extension
12539         */
12540
12541        marked.use = function () {
12542          for (
12543            var _len = arguments.length, args = new Array(_len), _key = 0;
12544            _key < _len;
12545            _key++
12546          ) {
12547            args[_key] = arguments[_key];
12548          }
12549
12550          var opts = merge.apply(void 0, [{}].concat(args));
12551          var extensions = marked.defaults.extensions || {
12552            renderers: {},
12553            childTokens: {},
12554          };
12555          var hasExtensions;
12556          args.forEach(function (pack) {
12557            // ==-- Parse "addon" extensions --== //
12558            if (pack.extensions) {
12559              hasExtensions = true;
12560              pack.extensions.forEach(function (ext) {
12561                if (!ext.name) {
12562                  throw new Error("extension name required");
12563                }
12564
12565                if (ext.renderer) {
12566                  // Renderer extensions
12567                  var prevRenderer = extensions.renderers
12568                    ? extensions.renderers[ext.name]
12569                    : null;
12570
12571                  if (prevRenderer) {
12572                    // Replace extension with func to run new extension but fall back if false
12573                    extensions.renderers[ext.name] = function () {
12574                      for (
12575                        var _len2 = arguments.length,
12576                          args = new Array(_len2),
12577                          _key2 = 0;
12578                        _key2 < _len2;
12579                        _key2++
12580                      ) {
12581                        args[_key2] = arguments[_key2];
12582                      }
12583
12584                      var ret = ext.renderer.apply(this, args);
12585
12586                      if (ret === false) {
12587                        ret = prevRenderer.apply(this, args);
12588                      }
12589
12590                      return ret;
12591                    };
12592                  } else {
12593                    extensions.renderers[ext.name] = ext.renderer;
12594                  }
12595                }
12596
12597                if (ext.tokenizer) {
12598                  // Tokenizer Extensions
12599                  if (
12600                    !ext.level ||
12601                    (ext.level !== "block" && ext.level !== "inline")
12602                  ) {
12603                    throw new Error(
12604                      "extension level must be 'block' or 'inline'",
12605                    );
12606                  }
12607
12608                  if (extensions[ext.level]) {
12609                    extensions[ext.level].unshift(ext.tokenizer);
12610                  } else {
12611                    extensions[ext.level] = [ext.tokenizer];
12612                  }
12613
12614                  if (ext.start) {
12615                    // Function to check for start of token
12616                    if (ext.level === "block") {
12617                      if (extensions.startBlock) {
12618                        extensions.startBlock.push(ext.start);
12619                      } else {
12620                        extensions.startBlock = [ext.start];
12621                      }
12622                    } else if (ext.level === "inline") {
12623                      if (extensions.startInline) {
12624                        extensions.startInline.push(ext.start);
12625                      } else {
12626                        extensions.startInline = [ext.start];
12627                      }
12628                    }
12629                  }
12630                }
12631
12632                if (ext.childTokens) {
12633                  // Child tokens to be visited by walkTokens
12634                  extensions.childTokens[ext.name] = ext.childTokens;
12635                }
12636              });
12637            } // ==-- Parse "overwrite" extensions --== //
12638
12639            if (pack.renderer) {
12640              (function () {
12641                var renderer = marked.defaults.renderer || new Renderer();
12642
12643                var _loop = function _loop(prop) {
12644                  var prevRenderer = renderer[prop]; // Replace renderer with func to run extension, but fall back if false
12645
12646                  renderer[prop] = function () {
12647                    for (
12648                      var _len3 = arguments.length,
12649                        args = new Array(_len3),
12650                        _key3 = 0;
12651                      _key3 < _len3;
12652                      _key3++
12653                    ) {
12654                      args[_key3] = arguments[_key3];
12655                    }
12656
12657                    var ret = pack.renderer[prop].apply(renderer, args);
12658
12659                    if (ret === false) {
12660                      ret = prevRenderer.apply(renderer, args);
12661                    }
12662
12663                    return ret;
12664                  };
12665                };
12666
12667                for (var prop in pack.renderer) {
12668                  _loop(prop);
12669                }
12670
12671                opts.renderer = renderer;
12672              })();
12673            }
12674
12675            if (pack.tokenizer) {
12676              (function () {
12677                var tokenizer = marked.defaults.tokenizer || new Tokenizer();
12678
12679                var _loop2 = function _loop2(prop) {
12680                  var prevTokenizer = tokenizer[prop]; // Replace tokenizer with func to run extension, but fall back if false
12681
12682                  tokenizer[prop] = function () {
12683                    for (
12684                      var _len4 = arguments.length,
12685                        args = new Array(_len4),
12686                        _key4 = 0;
12687                      _key4 < _len4;
12688                      _key4++
12689                    ) {
12690                      args[_key4] = arguments[_key4];
12691                    }
12692
12693                    var ret = pack.tokenizer[prop].apply(tokenizer, args);
12694
12695                    if (ret === false) {
12696                      ret = prevTokenizer.apply(tokenizer, args);
12697                    }
12698
12699                    return ret;
12700                  };
12701                };
12702
12703                for (var prop in pack.tokenizer) {
12704                  _loop2(prop);
12705                }
12706
12707                opts.tokenizer = tokenizer;
12708              })();
12709            } // ==-- Parse WalkTokens extensions --== //
12710
12711            if (pack.walkTokens) {
12712              var _walkTokens = marked.defaults.walkTokens;
12713
12714              opts.walkTokens = function (token) {
12715                pack.walkTokens.call(this, token);
12716
12717                if (_walkTokens) {
12718                  _walkTokens.call(this, token);
12719                }
12720              };
12721            }
12722
12723            if (hasExtensions) {
12724              opts.extensions = extensions;
12725            }
12726
12727            marked.setOptions(opts);
12728          });
12729        };
12730        /**
12731         * Run callback for every token
12732         */
12733
12734        marked.walkTokens = function (tokens, callback) {
12735          var _loop3 = function _loop3() {
12736            var token = _step.value;
12737            callback.call(marked, token);
12738
12739            switch (token.type) {
12740              case "table": {
12741                for (
12742                  var _iterator2 = _createForOfIteratorHelperLoose(
12743                      token.header,
12744                    ),
12745                    _step2;
12746                  !(_step2 = _iterator2()).done;
12747                ) {
12748                  var cell = _step2.value;
12749                  marked.walkTokens(cell.tokens, callback);
12750                }
12751
12752                for (
12753                  var _iterator3 = _createForOfIteratorHelperLoose(token.rows),
12754                    _step3;
12755                  !(_step3 = _iterator3()).done;
12756                ) {
12757                  var row = _step3.value;
12758
12759                  for (
12760                    var _iterator4 = _createForOfIteratorHelperLoose(row),
12761                      _step4;
12762                    !(_step4 = _iterator4()).done;
12763                  ) {
12764                    var _cell = _step4.value;
12765                    marked.walkTokens(_cell.tokens, callback);
12766                  }
12767                }
12768
12769                break;
12770              }
12771
12772              case "list": {
12773                marked.walkTokens(token.items, callback);
12774                break;
12775              }
12776
12777              default: {
12778                if (
12779                  marked.defaults.extensions &&
12780                  marked.defaults.extensions.childTokens &&
12781                  marked.defaults.extensions.childTokens[token.type]
12782                ) {
12783                  // Walk any extensions
12784                  marked.defaults.extensions.childTokens[token.type].forEach(
12785                    function (childTokens) {
12786                      marked.walkTokens(token[childTokens], callback);
12787                    },
12788                  );
12789                } else if (token.tokens) {
12790                  marked.walkTokens(token.tokens, callback);
12791                }
12792              }
12793            }
12794          };
12795
12796          for (
12797            var _iterator = _createForOfIteratorHelperLoose(tokens), _step;
12798            !(_step = _iterator()).done;
12799          ) {
12800            _loop3();
12801          }
12802        };
12803        /**
12804         * Parse Inline
12805         * @param {string} src
12806         */
12807
12808        marked.parseInline = function (src, opt) {
12809          // throw error in case of non string input
12810          if (typeof src === "undefined" || src === null) {
12811            throw new Error(
12812              "marked.parseInline(): input parameter is undefined or null",
12813            );
12814          }
12815
12816          if (typeof src !== "string") {
12817            throw new Error(
12818              "marked.parseInline(): input parameter is of type " +
12819                Object.prototype.toString.call(src) +
12820                ", string expected",
12821            );
12822          }
12823
12824          opt = merge({}, marked.defaults, opt || {});
12825          checkSanitizeDeprecation(opt);
12826
12827          try {
12828            var tokens = Lexer.lexInline(src, opt);
12829
12830            if (opt.walkTokens) {
12831              marked.walkTokens(tokens, opt.walkTokens);
12832            }
12833
12834            return Parser.parseInline(tokens, opt);
12835          } catch (e) {
12836            e.message +=
12837              "\nPlease report this to https://github.com/markedjs/marked.";
12838
12839            if (opt.silent) {
12840              return (
12841                "<p>An error occurred:</p><pre>" +
12842                escape(e.message + "", true) +
12843                "</pre>"
12844              );
12845            }
12846
12847            throw e;
12848          }
12849        };
12850        /**
12851         * Expose
12852         */
12853
12854        marked.Parser = Parser;
12855        marked.parser = Parser.parse;
12856        marked.Renderer = Renderer;
12857        marked.TextRenderer = TextRenderer;
12858        marked.Lexer = Lexer;
12859        marked.lexer = Lexer.lex;
12860        marked.Tokenizer = Tokenizer;
12861        marked.Slugger = Slugger;
12862        marked.parse = marked;
12863        var options = marked.options;
12864        var setOptions = marked.setOptions;
12865        var use = marked.use;
12866        var walkTokens = marked.walkTokens;
12867        var parseInline = marked.parseInline;
12868        var parse = marked;
12869        var parser = Parser.parse;
12870        var lexer = Lexer.lex;
12871
12872        exports.Lexer = Lexer;
12873        exports.Parser = Parser;
12874        exports.Renderer = Renderer;
12875        exports.Slugger = Slugger;
12876        exports.TextRenderer = TextRenderer;
12877        exports.Tokenizer = Tokenizer;
12878        exports.getDefaults = getDefaults;
12879        exports.lexer = lexer;
12880        exports.marked = marked;
12881        exports.options = options;
12882        exports.parse = parse;
12883        exports.parseInline = parseInline;
12884        exports.parser = parser;
12885        exports.setOptions = setOptions;
12886        exports.use = use;
12887        exports.walkTokens = walkTokens;
12888
12889        Object.defineProperty(exports, "__esModule", { value: true });
12890      });
12891    </script>
12891
12892
12893    
12893<script>
12894      (function () {
12895        "use strict";
12896
12897        // Respects OS-level reduced-motion preference when invoking
12898        // programmatic smooth scrolls. Browser UAs are inconsistent
12899        // about honoring this for `scrollIntoView({behavior:"smooth"})`,
12900        // so we downgrade explicitly.
12901        function prefersReducedMotion() {
12902          return !!(
12903            window.matchMedia &&
12904            window.matchMedia("(prefers-reduced-motion: reduce)").matches
12905          );
12906        }
12907
12908        const SESSION_CONTENT_KEY = "fast-md-viewer-session-content";
12909        const SESSION_META_KEY = "fast-md-viewer-session-meta";
12910        const READING_SETTINGS_KEY = "md-viewer-reading";
12911        const READER_PALETTE_STORAGE_KEY =
12912          (window.MarkVReaderPalettes &&
12913            window.MarkVReaderPalettes.STORAGE_KEY) ||
12914          "md-viewer-reader-palette";
12915        const READER_LIGHT_PALETTE_STORAGE_KEY =
12916          "md-viewer-reader-light-palette";
12917        const THEME_KEY = "md-viewer-theme";
12918        const HIGHLIGHT_STORAGE_KEY = "md-viewer-highlights";
12919        const DRAFT_HIGHLIGHT_STORAGE_KEY = "md-viewer-draft-highlights";
12920        const LIBRARY_DB_NAME = "md-viewer-library";
12921        const LIBRARY_DB_VERSION = 2;
12922        const LIBRARY_FOLDER_STORE = "folders";
12923        const LIBRARY_META_STORE = "libraryMeta";
12924        const UNTITLED_DRAFT_DB_NAME = "md-viewer-untitled-drafts";
12925        const UNTITLED_DRAFT_DB_VERSION = 1;
12926        const UNTITLED_DRAFT_STORE = "drafts";
12927        const UNTITLED_DRAFT_BACKUP_KEY = "md-viewer-untitled-drafts-backup";
12928        const LIBRARY_LIMITS = Object.freeze({
12929          maxDepth: 12,
12930          maxFiles: 2000,
12931          maxFolders: 12,
12932          yieldEvery: 40,
12933        });
12934        const MAX_SHARE_URL_LENGTH = 32768;
12935        const LONG_SHARE_URL_WARNING_LENGTH = 12000;
12936        const LIBRARY_IGNORED_NAMES = new Set([
12937          ".git",
12938          "node_modules",
12939          ".next",
12940          "dist",
12941          "build",
12942          ".turbo",
12943          ".DS_Store",
12944        ]);
12945        const MAX_HIGHLIGHTS_PER_DOCUMENT = 200;
12946        const MIN_HIGHLIGHT_SELECTION_LENGTH = 3;
12947        const HIGHLIGHT_CONTEXT_WINDOW = 32;
12948        const MERMAID_SCRIPT_URL = "./vendor/mermaid.min.js";
12949        const BEAUTIFUL_MERMAID_SCRIPT_URL =
12950          "./vendor/beautiful-mermaid.min.js";
12951        const MERMAID_RENDERER_NAME = "mermaid";
12952        const BEAUTIFUL_MERMAID_RENDERER_NAME = "beautiful-mermaid";
12953        const HLJS_CDN =
12954          "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1";
12955        const HLJS_LIGHT_THEME_URL = HLJS_CDN + "/styles/github.min.css";
12956        const HLJS_DARK_THEME_URL = HLJS_CDN + "/styles/github-dark.min.css";
12957        const KATEX_CDN = "https://cdn.jsdelivr.net/npm/[email protected]/dist";
12958        const libraryCore = window.MarkVLibraryCore || {};
12959        const readerPaletteApi = window.MarkVReaderPalettes || null;
12960        const REMOTE_ASSET_INTEGRITY = Object.freeze({
12961          [HLJS_CDN + "/highlight.min.js"]:
12962            "sha384-RH2xi4eIQ/gjtbs9fUXM68sLSi99C7ZWBRX1vDrVv6GQXRibxXLbwO2NGZB74MbU",
12963          [HLJS_CDN + "/styles/github.min.css"]:
12964            "sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L",
12965          [HLJS_CDN + "/styles/github-dark.min.css"]:
12966            "sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH",
12967          [KATEX_CDN + "/katex.min.css"]:
12968            "sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP",
12969          [KATEX_CDN + "/katex.min.js"]:
12970            "sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6",
12971        });
12972        const HIGHLIGHT_EXCLUDED_SELECTOR =
12973          "pre, code, .raw-html, .blocked-resource, .mermaid-block";
12974        const READING_PRESETS = {
12975          compact: { fontSize: 18, lineHeight: 1.58, measure: 620 },
12976          balanced: { fontSize: 19, lineHeight: 1.72, measure: 680 },
12977          spacious: { fontSize: 20, lineHeight: 1.82, measure: 740 },
12978        };
12979        const READING_DEFAULTS = {
12980          preset: "balanced",
12981          fontSize: 19,
12982          lineHeight: 1.72,
12983          measure: 680,
12984        };
12985        const DEFAULT_SAMPLE_FALLBACK = `# Fast Markdown Viewer
12986
12987Paste markdown on the left, open a local file, or drag one into the page.
12988
12989## Why this helps
12990
12991- Reload button to bypass stale previews
12992- Auto refresh for opened file handles
12993- Untitled drafts restore after reopen
12994- Readable typography for long documents
12995
12996## Table
12997
12998| Feature | What it does |
12999| --- | --- |
13000| Open .md | Loads a local file directly |
13001| Live preview | Renders while you type |
13002| Privacy mode | Keeps local drafts private and blocks remote images |
13003
13004> This page is intentionally simple and privacy-first.`;
13005
13006        async function loadDefaultSample() {
13007          try {
13008            const res = await fetch("assets/sample-developer.md");
13009            if (!res.ok) throw new Error(res.statusText);
13010            return await res.text();
13011          } catch (_) {
13012            return DEFAULT_SAMPLE_FALLBACK;
13013          }
13014        }
13015
13016        /* ============================================================
13017         * Concept Mindmap — schema and fixture
13018         * ============================================================ */
13019
13020        /**
13021         * @typedef {Object} ConceptNode
13022         * @property {string} id
13023         * @property {string} label
13024         * @property {"goal"|"non_goal"|"feature"|"constraint"|"depen
13024dency"|"actor"|"assumption"|"open_question"|"success_metric"} type
13025         * @property {string} summary
13026         * @property {string|null} sourceAnchor
13027         * @property {string} parent
13028         */
13029
13030        /**
13031         * @typedef {Object} ConceptGraph
13032         * @property {{id: "root", label: string, summary: string}} root
13033         * @property {ConceptNode[]} nodes
13034         * @property {Array<{from: string, to: string, kind: string}>} edges
13035         */
13036
13037        const MINDMAP_TYPE_COLOR_VAR = {
13038          goal: "--ont-intro",
13039          non_goal: "--ont-definition",
13040          feature: "--ont-instruction",
13041          constraint: "--ont-argument",
13042          dependency: "--ont-argument",
13043          actor: "--ont-narrative",
13044          assumption: "--ont-example",
13045          open_question: "--ont-unknown",
13046          success_metric: "--ont-explanation",
13047        };
13048
13049        const MINDMAP_TYPE_LABEL = {
13050          goal: "Goal",
13051          non_goal: "Non-goal",
13052          feature: "Feature",
13053          constraint: "Constraint",
13054          dependency: "Dependency",
13055          actor: "Actor",
13056          assumption: "Assumption",
13057          open_question: "Open question",
13058          success_metric: "Success metric",
13059        };
13060
13061        /** @type {ConceptGraph} */
13062        const MINDMAP_FIXTURE = {
13063          root: {
13064            id: "root",
13065            label: "Sample PRD",
13066            summary:
13067              "A concept map fixture used while the LLM extractor is offline.",
13068          },
13069          nodes: [
13070            {
13071              id: "g-reader-orient",
13072              label: "Reader Orientation",
13073              type: "goal",
13074              summary:
13075                "Let a reader see the shape of a dense PRD within 10 seconds of opening.",
13076              sourceAnchor: "goals",
13077              parent: "root",
13078            },
13079            {
13080              id: "g-jump-nav",
13081              label: "Jump Navigation",
13082              type: "goal",
13083              summary:
13084                "Click any concept to scroll the source section into view.",
13085              sourceAnchor: "goals",
13086              parent: "root",
13087            },
13088            {
13089              id: "f-extract",
13090              label: "Concept Extraction",
13091              type: "feature",
13092              summary:
13093                "LLM converts the markdown into a structured concept graph.",
13094              sourceAnchor: "features",
13095              parent: "root",
13096            },
13097            {
13098              id: "f-render",
13099              label: "SVG Renderer",
13100              type: "feature",
13101              summary:
13102                "Custom radial SVG with editorial typography and inked edges.",
13103              sourceAnchor: "features",
13104              parent: "root",
13105            },
13106            {
13107              id: "c-single-file",
13108              label: "Single HTML File",
13109              type: "constraint",
13110              summary:
13111                "All code must live inside the existing index.html; no new dependencies.",
13112              sourceAnchor: "constraints",
13113              parent: "root",
13114            },
13115            {
13116              id: "c-theme",
13117              label: "Theme Fidelity",
13118              type: "constraint",
13119              summary:
13120                "Mindmap must feel like the reading pane in both light and dark themes.",
13121              sourceAnchor: "constraints",
13122              parent: "root",
13123            },
13124            {
13125              id: "q-accuracy",
13126              label: "Extraction Accuracy",
13127              type: "open_question",
13128              summary:
13129                "How do we evaluate whether the LLM-produced graph is trustworthy?",
13130              sourceAnchor: "open-questions",
13131              parent: "root",
13132            },
13133            {
13134              id: "m-time-to-shape",
13135              label: "Time to Shape",
13136              type: "success_metric",
13137              summary:
13138                "Median time from click to interactive mindmap, target under 4 seconds.",
13139              sourceAnchor: "success-metrics",
13140              parent: "root",
13141            },
13142
13143            {
13144              id: "f-extract-prompt",
13145              label: "Prompt Contract",
13146              type: "feature",
13147              summary: "Strict JSON schema output from the LLM, no free prose.",
13148              sourceAnchor: "features",
13149              parent: "f-extract",
13150            },
13151            {
13152              id: "f-extract-cache",
13153              label: "Per-doc Cache",
13154              type: "feature",
13155              summary:
13156                "Cache by content hash so re-opening the same doc is instant.",
13157              sourceAnchor: "features",
13158              parent: "f-extract",
13159            },
13160
13161            {
13162              id: "f-render-layout",
13163              label: "Radial Layout",
13164              type: "feature",
13165              summary:
13166                "Custom tree layout; subtree weight drives angular allocation.",
13167              sourceAnchor: "features",
13168              parent: "f-render",
13169            },
13170            {
13171              id: "f-render-motion",
13172              label: "Staggered Reveal",
13173              type: "feature",
13174              summary:
13175                "Nodes fade up in rings; root first, primaries next, secondaries last.",
13176              sourceAnchor: "features",
13177              parent: "f-render",
13178            },
13179          ],
13180          edges: [
13181            { from: "g-reader-orient", to: "f-render", kind: "supp
13181orts" },
13182            { from: "g-jump-nav", to: "f-render", kind: "supports" },
13183            { from: "c-single-file", to: "f-render", kind: "blocks" },
13184            {
13185              from: "m-time-to-shape",
13186              to: "f-extract-cache",
13187              kind: "measured_by",
13188            },
13189          ],
13190        };
13191
13192        /* ============================================================
13193         * Concept Mindmap — layout engine (pure)
13194         * ============================================================ */
13195
13196        /**
13197         * Builds a tree rooted at graph.root and computes radial positions.
13198         * Returns a new object; does not mutate the input.
13199         * @param {ConceptGraph} graph
13200         * @param {{ring1:number, ring2:number, ring3:number}} [config]
13201         */
13202        function computeRadialLayout(graph, config) {
13203          // Ring radii are computed further down from the real card widths,
13204          // not fixed values — see "Width-aware ring radii" below. This is
13205          // what prevents long labels from overlapping on the same ring.
13206
13207          // Index nodes by id. Root is synthetic from graph.root.
13208          const nodeById = new Map();
13209          const rootNode = {
13210            id: "root",
13211            label: (graph.root && graph.root.label) || "Your Document",
13212            type: "root",
13213            summary: (graph.root && graph.root.summary) || "",
13214            sourceAnchor: null,
13215            parent: null,
13216            children: [],
13217            depth: 0,
13218            x: 0,
13219            y: 0,
13220            angle: 0,
13221            leaves: 0,
13222          };
13223          nodeById.set("root", rootNode);
13224
13225          for (const n of graph.nodes) {
13226            nodeById.set(n.id, {
13227              ...n,
13228              children: [],
13229              depth: 0,
13230              x: 0,
13231              y: 0,
13232              angle: 0,
13233              leaves: 0,
13234            });
13235          }
13236
13237          // Link children to parents.
13238          for (const n of graph.nodes) {
13239            const parent = nodeById.get(n.parent);
13240            const child = nodeById.get(n.id);
13241            if (!parent) continue; // orphan — skipped
13242            parent.children.push(child);
13243          }
13244
13245          // Depth assignment and leaf count (two passes).
13246          function setDepth(node, d) {
13247            node.depth = d;
13248            for (const c of node.children) setDepth(c, d + 1);
13249          }
13250          setDepth(rootNode, 0);
13251
13252          function countLeaves(node) {
13253            if (node.children.length === 0) {
13254              node.leaves = 1;
13255              return 1;
13256            }
13257            let total = 0;
13258            for (const c of node.children) total += countLeaves(c);
13259            node.leaves = total;
13260            return total;
13261          }
13262          countLeaves(rootNode);
13263
13264          // --- Width-aware ring radii ---
13265          // Each ring grows until its circumference fits every card on that
13266          // depth plus breathing room, and stays far enough from the inner
13267          // ring that edges remain legible. Reacting to real card widths is
13268          // what kills overlap for any label length.
13269          const MIN_RING_RADII = [0, 360, 680, 1000];
13270          const MIN_INTER_RING_GAP = 240;
13271          const CARD_ANGULAR_GAP = 48; // px of breathing room between siblings
13272
13273          const nodesByDepth = [[], [], [], []];
13274          for (const n of nodeById.values()) {
13275            const d = Math.min(n.depth, nodesByDepth.length - 1);
13276            nodesByDepth[d].push(n);
13277          }
13278          const cardWidthById = new Map();
13279          for (const n of nodeById.values()) {
13280            cardWidthById.set(n.id, measureNodeCard(n).width);
13281          }
13282
13283          const ringRadii = [0];
13284          for (let d = 1; d < nodesByDepth.length; d++) {
13285            const nodes = nodesByDepth[d];
13286            const totalW = nodes.reduce(
13287              (s, n) => s + cardWidthById.get(n.id) + CARD_ANGULAR_GAP,
13288              0,
13289            );
13290            const circFit = totalW / (2 * Math.PI);
13291            const floor = Math.max(
13292              MIN_RING_RADII[d],
13293              ringRadii[d - 1] + MIN_INTER_RING_GAP,
13294            );
13295            ringRadii.push(Math.max(circFit, floor));
13296          }
13297
13298          // Honour explicit config overrides when the caller wants bigger rings.
13299          if (config) {
13300            if (config.ring1)
13301              ringRadii[1] = Math.max(ringRadii[1], config.ring1);
13302            if (config.ring2)
13303              ringRadii[2] = Math.max(ringRadii[2], config.ring2);
13304            if (config.ring3)
13305              ringRadii[3] = Math.max(ringRadii[3], config.ring3);
13306          }
13307
13308          // --- Demand-weighted angle assignment ---
13309          // Each subtree asks for an angular share proportional to the widest
13310          // angular footprint anywhere inside it (its own card vs. the sum of
13311          // its children). Wide labels get a wide slice; short ones don't
13312          // steal angle they don't need.
13313          function subtreeAngularDemand(node) {
13314            const r =
13315              ringRadii[Math.min(node.depth, ringRadii.length - 1)] || 1;
13316            const ownArc =
13317              node.depth === 0
13318                ? 0
13319                : (cardWidthById.get(node.id) + CARD_ANGULAR_GAP) / r;
13320            if (node.children.length === 0) return ownArc || 0.0001;
13321            let childSum = 0;
13322            for (const c of node.children) childSum += subtreeAngularDemand(c);
13323            return Math.max(ownArc, childSum);
13324          }
13325
13326          function assign(node, startAngle, endAngle) {
13327            const midAngle = (startAngle + endAngle) / 2;
13328            const r = ringRadii[Math.min(node.depth, ringRadii.length - 1)];
13329            node.angle = midAngle;
13330            node.x = r * Math.cos(midAngle - Math.PI / 2);
13331            node.y = r * Math.sin(midAngle - Math.PI / 2);
13332
13333            if (node.children.length === 0) return;
13334            const span = endAngle - startAngle;
13335            const demands = node.children.map(subtreeAngularDemand);
13336            const total = demands.reduce((s, v) => s + v, 0) || 1;
13337            let cursor = startAngle;
13338            for (let i = 0; i < node.children.length; i++) {
13339              const childSpan = (demands[i] / total) * span;
13340              assign(node.children[i], cursor, cursor + childSpan);
13341              cursor += childSpan;
13342            }
13343          }
13344          assign(rootNode, 0, Math.PI * 2);
13345
13346          // Collect into a flat positioned list plus edge list.
13347          const positionedNodes = [];
13348          function collect(node) {
13349            positionedNodes.push(node);
13350            for (const c of node.children) collect(c);
13351          }
13352          collect(rootNode);
13353
13354          const treeEdges = [];
13355          for (const n of positionedNodes) {
13356            if (n.parent == null) continue;
13357            const parent = nodeById.get(n.parent);
13358            treeEdges.push({ from: parent, to: n, depth: n.depth });
13359          }
13360
13361          return {
13362            root: rootNode,
13363            nodes: positionedNodes,
13364            treeEdges,
13365            crossEdges: (graph.edges || [])
13366              .map((e) => ({
13367                from: nodeById.get(e.from),
13368                to: nodeById.get(e.to),
13369                kind: e.kind,
13370              }))
13371              .filter((e) => e.from && e.to),
13372          };
13373        }
13374
13375        /**
13376         * Quadratic bezier edge path, with control point pulled toward origin.
13377         * Includes deterministic per-edge jitter so the inked feel is stable
13378         * across re-renders but varied across edges.
13379         */
13380        function edgePath(from, to, seed) {
13381          const midX = (from.x + to.x) / 2;
13382          const midY = (from.y + to.y) / 2;
13383          // Pull control point 30% toward origin.
13384          let cx = midX * 0.7;
13385          let cy = midY * 0.7;
13386          // Seeded jitter, stable per edge.
13387          const jitter = seededJitter(seed);
13388          cx += jitter.x * 8;
13389          cy += jitter.y * 8;
13390          return `M ${from.x.toFixed(2)},${from.y.toFixed(2)} Q ${cx.toFixed(2)},${cy.toFixed(2)} ${to.x.toFixed(2)},${to.y.toFixed(2)}`;
13391        }
13392
13393        function seededJitter(seed) {
13394          // Mulberry32 one-shot.
13395          let s = 0;
13396          for (let i = 0; i < seed.length; i++)
13397            s = (s * 31 + seed.charCodeAt(i)) | 0;
13398          s = (s + 0x6d2b79f5) | 0;
13399          let t = s;
13400          t = Math.imul(t ^ (t >>> 15), t | 1);
13401          t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
13402          const r1 = (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1;
13403          s = (s + 0x9e3779b9) | 0;
13404          t = s;
13405          t = Math.imul(t ^ (t >>> 15), t | 1);
13406          t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
13407          const r2 = (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1;
13408          return { x: r1, y: r2 };
13409        }
13410
13411        /**
13412         * Compute the axis-aligned bounding box of all positioned nodes,
13413         * with estimated node half-widths included.
13414         */
13415        function computeBounds(positionedNodes) {
13416          if (positionedNodes.length === 0) {
13417            return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
13418          }
13419          let minX = Infinity,
13420            minY = Infinity,
13421            maxX = -Infinity,
13422            maxY = -Infinity;
13423          for (const n of positionedNodes) {
13424            const dims = measureNodeCard(n);
13425            const halfW = dims.width / 2;
13426            const halfH = dims.height / 2;
13427            if (n.x - halfW < minX) minX = n.x - halfW;
13428            if (n.x + halfW > maxX) maxX = n.x + halfW;
13429            if (n.y - halfH < minY) minY = n.y - halfH;
13430            if (n.y + halfH > maxY) maxY = n.y + halfH;
13431          }
13432          return { minX, minY, maxX, maxY };
13433        }
13434
13435        function estimateNodeHalfWidth(node) {
13436          return measureNodeCard(node).width / 2;
13437        }
13438
13439        /* ============================================================
13440         * Concept Mindmap — SVG renderer
13441         * ============================================================ */
13442
13443        const SVG_NS = "http://www.w3.org/2000/svg";
13444
13445        /**
13446         * Render a laid-out graph into the #mindmapSvg element.
13447         * Clears previous content first.
13448         * @param {ReturnType<typeof computeRadialLayout>} laid
13449         */
13450        function renderMindmap(laid) {
13451          const svg = document.getElementById("mindmapSvg");
13452          const rootG = document.getElementById("mindmapSvgRoot");
13453          const edgesG = document.getElementById("mindmapEdges");
13454          const nodesG = document.getElementById("mindmapNodes");
13455          if (!svg || !edgesG || !nodesG || !rootG) return;
13456
13457          edgesG.textContent = "";
13458          nodesG.textContent = "";
13459
13460          // --- Edges first so nodes render on top ---
13461          laid.treeEdges.forEach((e, idx) => {
13462            const path = document.createElementNS(SVG_NS, "path");
13463            path.setAttribute("class", "mm-edge");
13464            path.setAttribute("data-depth", String(e.depth));
13465            path.setAttribute("data-from", e.from.id);
13466            path.setAttribute("data-to", e.to.id);
13467            path.setAttribute("data-kind", "parent");
13468            path.setAttribute(
13469              "d",
13470              edgePath(e.from, e.to, `tree-${idx}-${e.to.id}`),
13471            );
13472            path.style.setProperty("--mm-stagger", String(idx));
13473            edgesG.appendChild(path);
13474          });
13475
13476          // Cross edges (optional visual layer — lighter, dashed)
13477          laid.crossEdges.forEach((e, idx) => {
13478            const path = document.createElementNS(SVG_NS, "path");
13479            path.setAttribute("class", "mm-edge mm-edge-cross");
13480            path.setAttribute("data-depth", "3");
13481            path.setAttribute("data-from", e.from.id);
13482            path.setAttribute("data-to", e.to.id);
13483            path.setAttribute("data-kind", e.kind || "related");
13484            path.setAttribute("stroke-dasharray", "3 5");
13485            path.setAttribute(
13486              "d",
13487              edgePath(e.from, e.to, `cross-${idx}-${e.from.id}-${e.to.id}`),
13488            );
13489            edgesG.appendChild(path);
13490          });
13491
13492          // --- Nodes ---
13493          laid.nodes.forEach((node, idx) => {
13494            const g = document.createElementNS(SVG_NS, "g");
13495            g.setAttribute("class", "mm-node");
13496            g.setAttribute("data-depth", String(node.depth));
13497            g.setAttribute("data-id", node.id);
13498            g.setAttribute("data-type", node.type);
13499            if (node.depth > 0) {
13500              g.classList.add("is-interactive");
13501              g.setAttribute("role", "button");
13502              g.setAttribute("tabindex", "0");
13503              g.setAttribute(
13504                "aria-label",
13505                `${MINDMAP_TYPE_LABEL[node.type] || node.type || "Concept"}: ${node.label}. Inspect concept.`,
13506              );
13507            }
13508            g.style.setProperty("--mm-stagger", String(idx));
13509
13510            // Measure the label to size the card.
13511            const dims = measureNodeCard(node);
13512
13513            // Background card
13514            const card = document.createElementNS(SVG_NS, "rect");
13515            card.setAttribute("class", "mm-node-card");
13516            card.setAttribute("x", String(node.x - dims.width / 2));
13517            card.setAttribute("y", String(node.y - dims.height / 2));
13518            card.setAttribute("width", String(dims.width));
13519            card.setAttribute("height", String(dims.height));
13520            card.setAttribute("rx", "12");
13521            card.setAttribute("ry", "12");
13522            g.appendChild(card);
13523
13524            // Left-edge semantic bar (non-root only)
13525            if (
13526              node.depth > 0 &&
13527              node.type &&
13528              MINDMAP_TYPE_COLOR_VAR[node.type]
13529            ) {
13530              const bar = document.createElementNS(SVG_NS, "rect");
13531              bar.setAttribute("class", "mm-node-typebar");
13532              bar.setAttribute("x", String(node.x - dims.width / 2));
13533              bar.setAttribute("y", String(node.y - dims.height / 2));
13534              bar.setAttribute("width", "3");
13535              bar.setAttribute("height", String(dims.height));
13536              bar.setAttribute("rx", "1.5");
13537              bar.setAttribute("ry", "1.5");
13538              bar.setAttribute(
13539                "fill",
13540                `var(${MINDMAP_TYPE_COLOR_VAR[node.type]})`,
13541              );
13542              g.appendChild(bar);
13543            }
13544
13545            // Type badge (non-root only)
13546            if (node.depth > 0 && node.type && MINDMAP_TYPE_LABEL[node.type]) {
13547              const badge = document.createElementNS(SVG_NS, "text");
13548              badge.setAttribute("class", "mm-node-badge");
13549              badge.setAttribute("x", String(node.x));
13550              badge.setAttribute("y", String(node.y - dims.height / 2 + 14));
13551              badge.setAttribute("text-anchor", "middle");
13552              badge.textContent = MINDMAP_TYPE_LABEL[node.type].toUpperCase();
13553              g.appendChild(badge);
13554            }
13555
13556            // Main label — multi-line via <tspan>s. dims.lines is the
13557            // pre-wrapped array from measureNodeCard; vertically center
13558            // the whole block in the card, shifted down slightly when a
13559            // type badge is sitting at the top.
13560            const label = document.createElementNS(SVG_NS, "text");
13561            label.setAttribute("class", "mm-node-label");
13562            label.setAttribute("x", String(node.x));
13563            label.setAttribute("text-anchor", "middle");
13564
13565            const labelLines =
13566              dims.lines && dims.lines.length ? dims.lines : [node.label || ""];
13567            const isRootNode = node.depth === 0;
13568            const lineHeight = isRootNode ? 30 : node.depth === 1 ? 24 : 22;
13569            const hasBadge =
13570              !isRootNode && node.type && MINDMAP_TYPE_LABEL[node.type];
13571            const badgePush = hasBadge ? 12 : 0;
13572            const firstLineY =
13573              node.y - ((labelLines.length - 1) * lineHeight) / 2 + badgePush;
13574
13575            labelLines.forEach((line, li) => {
13576              const tspan = document.createElementNS(SVG_NS, "tspan");
13577              tspan.setAttribute("x", String(node.x));
13578              tspan.setAttribute("y", String(firstLineY + li * lineHeight));
13579              tspan.textContent = line;
13580              label.appendChild(tspan);
13581            });
13582            g.appendChild(label);
13583
13584            nodesG.appendChild(g);
13585          });
13586
13587          // Render the screen-reader-only concept list and legend as side-effects.
13588          renderMindmapSrList(laid);
13589          renderMindmapLegend(laid);
13590          if (
13591            mindmapState.activeNodeId &&
13592            !laid.nodes.some((node) => node.id === mindmapState.activeNodeId)
13593          ) {
13594            mindmapState.activeNodeId = null;
13595          }
13596          updateMindmapFocusSurface();
13597
13598          return { bounds: computeBounds(laid.nodes) };
13599        }
13600
13601        /**
13602         * Render a nested <ul> mirror of the concept tree for assistive tech.
13603         * Root > primaries > secondaries. Each item shows "{type}: {label} — {summary}".
13604         */
13605        function renderMindmapSrList(laid) {
13606          const target = document.getElementById("mindmapSrList");
13607          if (!target) return;
13608          function renderNode(node) {
13609            const li = document.createElement("li");
13610            const typeText =
13611              node.depth === 0
13612                ? "Document"
13613                : MINDMAP_TYPE_LABEL[node.type] || "Concept";
13614            const summary = node.summary ? ` — ${node.summary}` : "";
13615            li.textContent = `${typeText}: ${node.label || ""}${summary}`;
13616            if (node.children && node.children.length > 0) {
13617              const ul = document.createElement("ul");
13618              for (const c of node.children) ul.appendChild(renderNode(c));
13619              li.appendChild(ul);
13620            }
13621            return li;
13622          }
13623          const ul = document.createElement("ul");
13624          ul.appendChild(renderNode(laid.root));
13625          target.textContent = "";
13626          target.appendChild(ul);
13627        }
13628
13629        /**
13630         * Render the type legend below the header. Only types that actually
13631         * appear in the current graph are shown.
13632         */
13633        function renderMindmapLegend(laid) {
13634          const target = document.getElementById("mindmapLegend");
13635          if (!target) return;
13636          const seen = new Set();
13637          for (const n of laid.nodes) {
13638            if (n.depth > 0 && n.type && MINDMAP_TYPE_LABEL[n.type])
13639              seen.add(n.type);
13640          }
13641          target.textContent = "";
13642          const order = [
13643            "goal",
13644            "non_goal",
13645            "feature",
13646            "constraint",
13647            "dependency",
13648            "actor",
13649            "assumption",
13650            "success_metric",
13651            "open_question",
13652          ];
13653          for (const type of order) {
13654            if (!seen.has(type)) continue;
13655            const chip = document.createElement("span");
13656            chip.className = "mindmap-legend-chip";
13657            const dot = document.createElement("span");
13658            dot.className = "mindmap-legend-dot";
13659            dot.style.background = `var(${MINDMAP_TYPE_COLOR_VAR[type]})`;
13660            const label = document.createElement("span");
13661            label.textContent = MINDMAP_TYPE_LABEL[type];
13662            chip.appendChild(dot);
13663            chip.appendChild(label);
13664            target.appendChild(chip);
13665          }
13666        }
13667
13668        /* ----------------------------------------------------------
13669         * Real text measurement — hidden <text> elements styled like
13670         * each depth's labels, so getComputedTextLength() returns the
13671         * exact on-screen width. This is what we use to size cards and
13672         * wrap long labels so text never overflows its card.
13673         * ---------------------------------------------------------- */
13674        const _mmMeasureElements = {};
13675        function getMMMeasureText(depth) {
13676          const key = depth >= 2 ? 2 : depth;
13677          if (_mmMeasureElements[key] && _mmMeasureElements[key].isConnected) {
13678            return _mmMeasureElements[key];
13679          }
13680          const svg = document.getElementById("mindmapSvg");
13681          if (!svg) return null;
13682          const g = document.createElementNS(SVG_NS, "g");
13683          g.setAttribute("class", "mm-node");
13684          g.setAttribute("data-depth", String(key));
13685          g.setAttribute("aria-hidden", "true");
13686          g.setAttribute("pointer-events", "none");
13687          g.setAttribute("visibility", "hidden");
13688          const t = document.createElementNS(SVG_NS, "text");
13689          t.setAttribute("class", "mm-node-label");
13690          t.setAttribute("x", "-99999");
13691          t.setAttribute("y", "-99999");
13692          g.appendChild(t);
13693          svg.appendChild(g);
13694          _mmMeasureElements[key] = t;
13695          return t;
13696        }
13697
13698        function measureMMText(text, depth) {
13699          const el = getMMMeasureText(depth);
13700          const safe = String(text || "");
13701          if (!el) {
13702            const per = depth === 0 ? 18 : depth === 1 ? 11 : 8.5;
13703            return safe.length * per;
13704          }
13705          el.textContent = safe;
13706          try {
13707            return el.getComputedTextLength();
13708          } catch {
13709            const per = depth === 0 ? 18 : depth === 1 ? 11 : 8.5;
13710            return safe.length * per;
13711          }
13712        }
13713
13714        /**
13715         * Greedy word-wrap at a real pixel width. Packs words until the
13716         * next word would overflow, then starts a new line. The final
13717         * allowed line absorbs any remaining words and ellipsizes if they
13718         * still don't fit. Single words wider than maxWidth are kept on
13719         * their own line — the card will widen to accommodate them.
13720         */
13721        function wrapMMLabel(text, depth, maxWidth, maxLines) {
13722          const words = String(text || "")
13723            .trim()
13724            .split(/\s+/);
13725          if (words.length === 0 || (words.length === 1 && words[0] === "")) {
13726            return [""];
13727          }
13728          const lines = [];
13729          let current = "";
13730          for (let i = 0; i < words.length; i++) {
13731            const candidate = current ? current + " " + words[i] : words[i];
13732            if (measureMMText(candidate, depth) <= maxWidth) {
13733              current = candidate;
13734              continue;
13735            }
13736            if (current) lines.push(current);
13737            if (lines.length >= maxLines - 1) {
13738              let last = words[i];
13739              for (let j = i + 1; j < words.length; j++) {
13740                const tryAdd = last + " " + words[j];
13741                if (measureMMText(tryAdd, depth) <= maxWidth) {
13742                  last = tryAdd;
13743                } else {
13744                  while (
13745                    last.length > 1 &&
13746                    measureMMText(last + "…", depth) > maxWidth
13747                  ) {
13748                    last = last.slice(0, -1);
13749                  }
13750                  last += "…";
13751                  break;
13752                }
13753              }
13754              lines.push(last);
13755              return lines;
13756            }
13757            current = words[i];
13758          }
13759          if (current) lines.push(current);
13760          return lines;
13761        }
13762
13763        /**
13764         * Measure a node's card using real SVG text metrics and wrap long
13765         * labels across up to two lines. Returns width, height, and the
13766         * pre-wrapped lines array to render as <tspan>s.
13767         */
13768        function measureNodeCard(node) {
13769          const label = node.label || "";
13770          const isRoot = node.depth === 0;
13771          const isPrimary = node.depth === 1;
13772          const paddingX = isRoot ? 48 : 36;
13773          const paddingY = isRoot ? 32 : 26;
13774          const lineHeight = isRoot ? 30 : isPrimary ? 24 : 22;
13775          const badgeHeight =
13776            !isRoot && node.type && MINDMAP_TYPE_LABEL[node.type] ? 22 : 0;
13777          const minWidth = isRoot ? 260 : 180;
13778          const maxWidth = isRoot ? 560 : isPrimary ? 380 : 300;
13779          const maxLines = 2;
13780
13781          const singleLineWidth = measureMMText(label, node.depth) + paddingX;
13782          if (singleLineWidth <= maxWidth) {
13783            return {
13784              width: Math.max(minWidth, singleLineWidth),
13785              height: badgeHeight + lineHeight + paddingY,
13786              lines: [label],
13787            };
13788          }
13789
13790          const lines = wrapMMLabel(
13791            label,
13792            node.depth,
13793            maxWidth - paddingX,
13794            maxLines,
13795          );
13796          let widest = 0;
13797          for (const ln of lines) {
13798            const w = measureMMText(ln, node.depth);
13799            if (w > widest) widest = w;
13800          }
13801          return {
13802            width: Math.max(minWidth, widest + paddingX),
13803            height: badgeHeight + lines.length * lineHeight + paddingY,
13804            lines,
13805          };
13806        }
13807
13808        /* ============================================================
13809         * Concept Mindmap — viewer controller
13810         * ============================================================ */
13811
13812        const mindmapState = {
13813          laid: null,
13814          scale: 1,
13815          tx: 0,
13816          ty: 0,
13817          baseScale: 1,
13818          isPanning: false,
13819          panStartX: 0,
13820          panStartY: 0,
13821          panStartTx: 0,
13822          panStartTy: 0,
13823          currentGraph: null,
13824          currentDocHash: null,
13825          previouslyFocused: null,
13826          hideTimer: 0,
13827          isLoading: false,
13828          rateLimitUntil: 0,
13829          activeNodeId: null,
13830        };
13831
13832        const MINDMAP_MAX_MARKDOWN_CHARS = 20000;
13833
13834        function getMindmapEls() {
13835          return {
13836            viewer: document.getElementById("mindmapViewer"),
13837            stage: document.getElementById("mindmapStage"),
13838            svg: document.getElementById("mindmapSvg"),
13839            svgRoot: document.getElementById("mindmapSvgRoot"),
13840            zoomLabel: document.getElementById("mindmapZoomLabel"),
13841            tooltip: document.getElementById("mindmapTooltip"),
13842            inspector: document.getElementById("mindmapInspector"),
13843            searchInput: document.getElementById("mindmapSearchInput"),
13844            searchResults: document.getElementById("mindmapSearchResults"),
13845            loading: document.getElementById("mindmapLoading"),
13846            errorPane: document.getElementById("mindmapError"),
13847            errorMsg: document.getElementById("mindmapErrorMessage"),
13848          };
13849        }
13850
13851        function clearMindmapSurface() {
13852          const {
13853            svgRoot,
13854            zoomLabel,
13855            tooltip,
13856            inspector,
13857            searchInput,
13858            searchResults,
13859            loading,
13860            errorPane,
13861            errorMsg,
13862          } = getMindmapEls();
13863          const edgesG = document.getElementById("mindmapEdges");
13864          const nodesG = document.getElementById("mindmapNodes");
13865          const legend = document.getElementById("mindmapLegend");
13866          const srList = document.getElementById("mindmapSrList");
13867          if (edgesG) edgesG.textContent = "";
13868          if (nodesG) nodesG.textContent = "";
13869          if (legend) legend.textContent = "";
13870          if (srList) srList.textContent = "";
13871          if (svgRoot) {
13872            svgRoot.classList.remove("is-interactive");
13873            svgRoot.setAttribute("transform", "translate(0 0) scale(1)");
13874          }
13875          if (zoomLabel) zoomLabel.textContent = "100%";
13876          if (tooltip) tooltip.classList.remove("is-visible");
13877          if (inspector) {
13878            inspector.classList.remove("is-visible");
13879            inspector.setAttribute("aria-hidden", "true");
13880            inspector.textContent = "";
13881          }
13882          if (searchInput) searchInput.value = "";
13883          if (searchResults) {
13884            searchResults.classList.add("is-hidden");
13885            searchResults.textContent = "";
13886          }
13887          if (loading) loading.classList.add("is-hidden");
13888          if (errorPane) {
13889            errorPane.classList.add("is-hidden");
13890            errorPane.dataset.kind = "";
13891          }
13892          if (errorMsg) errorMsg.textContent = "Something went wrong.";
13893        }
13894
13895        function resetMindmapState(options) {
13896          const closeViewer = !!(options && options.closeViewer);
13897          const forgetDocHash = !options || options.forgetDocHash !== false;
13898          const { viewer } = getMindmapEls();
13899          if (mindmapState.hideTimer) {
13900            clearTimeout(mindmapState.hideTimer);
13901            mindmapState.hideTimer = 0;
13902          }
13903          if (closeViewer && viewer) {
13904            viewer.setAttribute("aria-hidden", "true");
13905            viewer.hidden = true;
13906            document.body.classList.remove("mindmap-open");
13907          }
13908          clearMindmapSurface();
13909          mindmapState.laid = null;
13910          mindmapState.scale = 1;
13911          mindmapState.tx = 0;
13912          mindmapState.ty = 0;
13913          mindmapState.baseScale = 1;
13914          mindmapState.isPanning = false;
13915          mindmapState.panStartX = 0;
13916          mindmapState.panStartY = 0;
13917          mindmapState.panStartTx = 0;
13918          mindmapState.panStartTy = 0;
13919          mindmapState.currentGraph = null;
13920          mindmapState.activeNodeId = null;
13921          if (forgetDocHash) {
13922            mindmapState.currentDocHash = null;
13923          }
13924          if (closeViewer) {
13925            mindmapState.previouslyFocused = null;
13926          }
13927        }
13928
13929        function openMindmapViewer() {
13930          const { viewer } = getMindmapEls();
13931          if (!viewer) return;
13932          if (mindmapState.hideTimer) {
13933            clearTimeout(mindmapState.hideTimer);
13934            mindmapState.hideTimer = 0;
13935          }
13936          mindmapState.previouslyFocused = document.activeElement;
13937          viewer.hidden = false;
13938          requestAnimationFrame(() => {
13939            viewer.setAttribute("aria-hidden", "false");
13940            const closeBtn = document.getElementById("mindmapCloseBtn");
13941            if (closeBtn) closeBtn.focus();
13942          });
13943          document.body.classList.add("mindmap-open");
13944        }
13945
13946        function closeMindmapViewer() {
13947          const { viewer } = getMindmapEls();
13948          if (!viewer) return;
13949          if (mindmapState.hideTimer) {
13950            clearTimeout(mindmapState.hideTimer);
13951          }
13952          viewer.setAttribute("aria-hidden", "true");
13953          document.body.classList.remove("mindmap-open");
13954          mindmapState.hideTimer = setTimeout(() => {
13955            if (viewer.getAttribute("aria-hidden") === "true") {
13956              viewer.hidden = true;
13957            }
13958            mindmapState.hideTimer = 0;
13959          }, 260);
13960          // Restore focus to whatever had it before open.
13961          if (
13962            mindmapState.previouslyFocused &&
13963            typeof mindmapState.previouslyFocused.focus === "function"
13964          ) {
13965            try {
13966              mindmapState.previouslyFocused.focus();
13967            } catch (_) {}
13968          }
13969        }
13970
13971        function isMindmapFocusable(element) {
13972          if (!element) return false;
13973          if (element.hasAttribute("disabled")) return false;
13974          if (element.closest('[hidden], .is-hidden, [aria-hidden="true"]')) {
13975            return false;
13976          }
13977          const style = window.getComputedStyle(element);
13978          if (style.display === "none" || style.visibility === "hidden") {
13979            return false;
13980          }
13981          return typeof element.getClientRects === "function"
13982            ? element.getClientRects().length > 0
13983            : true;
13984        }
13985
13986        function getMindmapFocusableElements() {
13987          const { viewer } = getMindmapEls();
13988          if (!viewer) return [];
13989          return Array.from(
13990            viewer.querySelectorAll(
13991              'button, input, [tabindex]:not([tabindex="-1"])',
13992            ),
13993          ).filter(isMindmapFocusable);
13994        }
13995
13996        /**
13997         * Trap focus inside the modal — Tab cycles through the focusable
13998         * descendants of the viewer. Shift+Tab cycles backward.
13999         */
14000        function trapMindmapFocus(event) {
14001          if (event.key !== "Tab") return;
14002          const { viewer } = getMindmapEls();
14003          if (!viewer || viewer.getAttribute("aria-hidden") === "true") return;
14004          const focusables = getMindmapFocusableElements();
14005          if (focusables.length === 0) return;
14006          const first = focusables[0];
14007          const last = focusables[focusables.length - 1];
14008          if (event.shiftKey && document.activeElement === first) {
14009            event.preventDefault();
14010            last.focus();
14011          } else if (!event.shiftKey && document.activeElement === last) {
14012            event.preventDefault();
14013            first.focus();
14014          }
14015        }
14016
14017        function applyMindmapTransform() {
14018          const { svgRoot, zoomLabel } = getMindmapEls();
14019          if (!svgRoot) return;
14020          svgRoot.setAttribute(
14021            "transform",
14022            `translate(${mindmapState.tx} ${mindmapState.ty}) scale(${mindmapState.scale})`,
14023          );
14024          if (zoomLabel) {
14025            zoomLabel.textContent = `${Math.round(mindmapState.scale * 100)}%`;
14026          }
14027        }
14028
14029        function fitMindmapToView(animated) {
14030          const { stage, svgRoot } = getMindmapEls();
14031          if (!stage || !svgRoot || !mindmapState.laid) return;
14032
14033          const bounds = computeBounds(mindmapState.laid.nodes);
14034          const w = bounds.maxX - bounds.minX;
14035          const h = bounds.maxY - bounds.minY;
14036          const rect = stage.getBoundingClientRect();
14037          const pad = 120;
14038          const scaleX = (rect.width - pad * 2) / Math.max(1, w);
14039          const scaleY = (rect.height - pad * 2) / Math.max(1, h);
14040          const scale = Math.min(scaleX, scaleY, 1.1);
14041
14042          // Center the bounds in the stage.
14043          const boundsCx = (bounds.minX + bounds.maxX) / 2;
14044          const boundsCy = (bounds.minY + bounds.maxY) / 2;
14045          const tx = rect.width / 2 - boundsCx * scale;
14046          const ty = rect.height / 2 - boundsCy * scale;
14047
14048          if (animated) {
14049            svgRoot.classList.remove("is-interactive");
14050          } else {
14051            svgRoot.classList.add("is-interactive");
14052          }
14053          mindmapState.scale = scale;
14054          mindmapState.baseScale = scale;
14055          mindmapState.tx = tx;
14056          mindmapState.ty = ty;
14057          applyMindmapTransform();
14058        }
14059
14060        function setMindmapZoom(nextScale, focalX, focalY) {
14061          const clamped = Math.max(0.3, Math.min(2.5, nextScale));
14062          if (typeof focalX === "number" && typeof focalY === "number") {
14063            // Zoom around the focal point (screen coords inside stage).
14064            const { tx, ty, scale } = mindmapState;
14065            const worldX = (focalX - tx) / scale;
14066            const worldY = (focalY - ty) / scale;
14067            mindmapState.scale = clamped;
14068            mindmapState.tx = focalX - worldX * clamped;
14069            mindmapState.ty = focalY - worldY * clamped;
14070          } else {
14071            mindmapState.scale = clamped;
14072          }
14073          applyMindmapTransform();
14074        }
14075
14076        function getMindmapTypeColor(type) {
14077          return MINDMAP_TYPE_COLOR_VAR[type]
14078            ? `var(${MINDMAP_TYPE_COLOR_VAR[type]})`
14079            : "var(--fg-subtle)";
14080        }
14081
14082        function describeMindmapEdgeKind(kind) {
14083          const labels = {
14084            parent: "Parent link",
14085            child: "Child",
14086            part_of: "Part of",
14087            depends_on: "Depends on",
14088            blocks: "Blocks",
14089            supports: "Supports",
14090            contradicts: "Contradicts",
14091            measured_by: "Measured by",
14092            related: "Related",
14093          };
14094          return labels[kind] || "Related";
14095        }
14096
14097        function getMindmapNodeMap() {
14098          const map = new Map();
14099          if (!mindmapState.laid || !mindmapState.laid.nodes) return map;
14100          for (const node of mindmapState.laid.nodes) {
14101            map.set(node.id, node);
14102          }
14103          return map;
14104        }
14105
14106        function getMindmapFocusNeighborhood(nodeId) {
14107          const laid = mindmapState.laid;
14108          const related = new Set([nodeId]);
14109          const activeEdges = new Set();
14110          if (!laid) return { related, activeEdges };
14111
14112          for (const edge of laid.treeEdges || []) {
14113            if (edge.from.id === nodeId || edge.to.id === nodeId) {
14114              related.add(edge.from.id);
14115              related.add(edge.to.id);
14116              activeEdges.add(`${edge.from.id}->${edge.to.id}`);
14117            }
14118          }
14119          for (const edge of laid.crossEdges || []) {
14120            if (edge.from.id === nodeId || edge.to.id === nodeId) {
14121              related.add(edge.from.id);
14122              related.add(edge.to.id);
14123              activeEdges.add(`${edge.from.id}->${edge.to.id}`);
14124            }
14125          }
14126
14127          return { related, activeEdges };
14128        }
14129
14130        function getMindmapConnectedNodes(node) {
14131          const laid = mindmapState.laid;
14132          if (!node || !laid) return [];
14133          const nodeMap = getMindmapNodeMap();
14134          const byId = new Map();
14135
14136          function addConnection(id, relation, kind) {
14137            if (!id || id === node.id || byId.has(id)) return;
14138            const target = nodeMap.get(id);
14139            if (!target) return;
14140            byId.set(id, {
14141              node: target,
14142              relation,
14143              kind: kind || relation,
14144            });
14145          }
14146
14147          addConnection(node.parent, "Parent", "parent");
14148          for (const child of node.children || []) {
14149            addConnection(child.id, "Child", "child");
14150          }
14151          for (const edge of laid.crossEdges || []) {
14152            if (edge.from.id === node.id) {
14153              addConnection(
14154                edge.to.id,
14155                describeMindmapEdgeKind(edge.kind),
14156                edge.kind,
14157              );
14158            } else if (edge.to.id === node.id) {
14159              addConnection(
14160                edge.from.id,
14161                describeMindmapEdgeKind(edge.kind),
14162                edge.kind,
14163              );
14164            }
14165          }
14166
14167          return Array.from(byId.values()).sort((a, b) => {
14168            if (a.node.depth !== b.node.depth)
14169              return a.node.depth - b.node.depth;
14170            return String(a.node.label || "").localeCompare(
14171              String(b.node.label || ""),
14172            );
14173          });
14174        }
14175
14176        function renderMindmapInspector(node) {
14177          const { inspector } = getMindmapEls();
14178          if (!inspector) return;
14179          inspector.textContent = "";
14180          if (!node) {
14181            inspector.classList.remove("is-visible");
14182            inspector.setAttribute("aria-hidden", "true");
14183            return;
14184          }
14185
14186          const header = document.createElement("div");
14187          header.className = "mindmap-inspector-header";
14188
14189          const titleWrap = document.createElement("div");
14190          const type = document.createElement("div");
14191          type.className = "mindmap-inspector-type";
14192          const dot = document.createElement("span");
14193          dot.className = "mindmap-inspector-dot";
14194          dot.style.background = getMindmapTypeColor(node.type);
14195          const typeLabel = document.createElement("span");
14196          typeLabel.textContent =
14197            node.depth === 0
14198              ? "Document"
14199              : MINDMAP_TYPE_LABEL[node.type] || "Concept";
14200          type.appendChild(dot);
14201          type.appendChild(typeLabel);
14202
14203          const title = document.createElement("h3");
14204          title.className = "mindmap-inspector-title";
14205          title.textContent = node.label || "Untitled concept";
14206          titleWrap.appendChild(type);
14207          titleWrap.appendChild(title);
14208
14209          const close = document.createElement("button");
14210          close.type = "button";
14211          close.className = "mindmap-inspector-close";
14212          close.setAttribute("aria-label", "Close concept details");
14213          close.innerHTML =
14214            '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
14215          close.addEventListener("click", clearMindmapFocusedNode);
14216
14217          header.appendChild(titleWrap);
14218          header.appendChild(close);
14219          inspector.appendChild(header);
14220
14221          if (node.summary) {
14222            const summary = document.createElement("p");
14223            summary.className = "mindmap-inspector-summary";
14224            summary.textContent = node.summary;
14225            inspector.appendChild(summary);
14226          }
14227
14228          const sourceTarget =
14229            node.depth > 0
14230              ? resolveMindmapTarget(node, document.getElementById("preview"))
14231              : null;
14232          if (sourceTarget) {
14233            const actions = document.createElement("div");
14234            actions.className = "mindmap-inspector-actions";
14235            const jump = document.createElement("button");
14236            jump.type = "button";
14237            jump.className = "mindmap-inspector-action";
14238            jump.textContent = "Open section";
14239            jump.addEventListener("click", () => jumpToMindmapSource(node));
14240            actions.appendChild(jump);
14241            inspector.appendChild(actions);
14242          }
14243
14244          const connections = getMindmapConnectedNodes(node);
14245          if (connections.length) {
14246            const section = document.createElement("div");
14247            section.className = "mindmap-inspector-section";
14248            const sectionTitle = document.createElement("p");
14249            sectionTitle.className = "mindmap-inspector-section-title";
14250            sectionTitle.textContent = `Connections (${connections.length})`;
14251            section.appendChild(sectionTitle);
14252
14253            const list = document.createElement("div");
14254            list.className = "mindmap-connection-list";
14255            for (const item of connections) {
14256              const button = document.createElement("button");
14257              button.type = "button";
14258              button.className = "mindmap-connection";
14259              button.addEventListener("click", () => {
14260                focusMindmapNode(item.node.id, { center: true });
14261              });
14262
14263              const itemDot = document.createElement("span");
14264              itemDot.className = "mindmap-connection-dot";
14265              itemDot.style.background = getMindmapTypeColor(item.node.type);
14266
14267              const body = document.createElement("span");
14268              const label = document.createElement("span");
14269              label.className = "mindmap-connection-label";
14270              label.textContent = item.node.label || "Untitled concept";
14271              const meta = document.createElement("span");
14272              meta.className = "mindmap-connection-meta";
14273              meta.textContent = item.relation;
14274              body.appendChild(label);
14275              body.appendChild(meta);
14276
14277              button.appendChild(itemDot);
14278              button.appendChild(body);
14279              list.appendChild(button);
14280            }
14281            section.appendChild(list);
14282            inspector.appendChild(section);
14283          }
14284
14285          inspector.classList.add("is-visible");
14286          inspector.setAttribute("aria-hidden", "false");
14287        }
14288
14289        function updateMindmapFocusSurface() {
14290          const activeId = mindmapState.activeNodeId;
14291          const activeNode = activeId ? getMindmapNodeById(activeId) : null;
14292          const focus = activeNode
14293            ? getMindmapFocusNeighborhood(activeId)
14294            : null;
14295          const nodeEls = document.querySelectorAll("#mindmapNodes .mm-node");
14296          const edgeEls = document.querySelectorAll("#mindmapEdges .mm-edge");
14297
14298          nodeEls.forEach((el) => {
14299            const id = el.getAttribute("data-id");
14300            const isFocused = !!activeNode && id === activeId;
14301            const isRelated = !!focus && focus.related.has(id);
14302            el.classList.toggle("is-focused", isFocused);
14303            el.classList.toggle(
14304              "is-related",
14305              !!activeNode && isRelated && !isFocused,
14306            );
14307            el.classList.toggle("is-muted", !!activeNode && !isRelated);
14308            if (isFocused) {
14309              el.setAttribute("aria-pressed", "true");
14310            } else {
14311              el.removeAttribute("aria-pressed");
14312            }
14313          });
14314
14315          edgeEls.forEach((el) => {
14316            const from = el.getAttribute("data-from");
14317            const to = el.getAttribute("data-to");
14318            const isActive = !!focus && focus.activeEdges.has(`${from}->${to}`);
14319            el.classList.toggle("is-active", isActive);
14320            el.classList.toggle("is-muted", !!activeNode && !isActive);
14321          });
14322
14323          renderMindmapInspector(activeNode);
14324        }
14325
14326        function centerMindmapNode(node) {
14327          const { stage, svgRoot } = getMindmapEls();
14328          if (!stage || !svgRoot || !node) return;
14329          const rect = stage.getBoundingClientRect();
14330          const targetScale = Math.max(
14331            mindmapState.scale,
14332            Math.min(
14333              1.25,
14334              Math.max(mindmapState.baseScale * 1.55, mindmapState.baseScale),
14335            ),
14336          );
14337          svgRoot.classList.remove("is-interactive");
14338          mindmapState.scale = targetScale;
14339          mindmapState.tx = rect.width / 2 - node.x * targetScale;
14340          mindmapState.ty = rect.height / 2 - node.y * targetScale;
14341          applyMindmapTransform();
14342        }
14343
14344        function focusMindmapNode(id, options) {
14345          const node = getMindmapNodeById(id);
14346          if (!node) return;
14347          mindmapState.activeNodeId = id;
14348          updateMindmapFocusSurface();
14349          if (!options || options.center !== false) {
14350            centerMindmapNode(node);
14351          }
14352        }
14353
14354        function clearMindmapFocusedNode() {
14355          if (!mindmapState.activeNodeId) return;
14356          mindmapState.activeNodeId = null;
14357          updateMindmapFocusSurface();
14358        }
14359
14360        function getMindmapSearchResults(query) {
14361          if (!mindmapState.laid || !query.trim()) return [];
14362          const tokens = query
14363            .toLowerCase()
14364            .split(/\s+/)
14365            .map((token) => token.trim())
14366            .filter(Boolean);
14367          if (!tokens.length) return [];
14368
14369          return mindmapState.laid.nodes
14370            .filter((node) => node.depth > 0)
14371            .map((node) => {
14372              const label = String(node.label || "");
14373              const typeLabel = MINDMAP_TYPE_LABEL[node.type] || "";
14374              const haystack =
14375                `${label} ${typeLabel} ${node.summary || ""}`.toLowerCase();
14376              if (!tokens.every((token) => haystack.includes(token)))
14377                return null;
14378              let score = 0;
14379              const lowerLabel = label.toLowerCase();
14380              if (lowerLabel === query.toLowerCase()) score += 10;
14381              if (lowerLabel.startsWith(query.toLowerCase())) score += 6;
14382              if (lowerLabel.includes(query.toLowerCase())) score += 3;
14383              score += Math.max(0, 4 - node.depth);
14384              return { node, score };
14385            })
14386            .filter(Boolean)
14387            .sort(
14388              (a, b) =>
14389                b.score - a.score || a.node.label.localeCompare(b.node.label),
14390            )
14391            .slice(0, 8)
14392            .map((entry) => entry.node);
14393        }
14394
14395        function hideMindmapSearchResults() {
14396          const { searchResults } = getMindmapEls();
14397          if (!searchResults) return;
14398          searchResults.classList.add("is-hidden");
14399          searchResults.textContent = "";
14400        }
14401
14402        function renderMindmapSearchResults() {
14403          const { searchInput, searchResults } = getMindmapEls();
14404          if (!searchInput || !searchResults) return;
14405          const query = searchInput.value.trim();
14406          searchResults.textContent = "";
14407          if (!query) {
14408            hideMindmapSearchResults();
14409            return;
14410          }
14411
14412          const results = getMindmapSearchResults(query);
14413          if (!results.length) {
14414            const empty = document.createElement("div");
14415            empty.className = "mindmap-search-result";
14416            empty.setAttribute("aria-disabled", "true");
14417            const spacer = document.createElement("span");
14418            const label = document.createElement("span");
14419            label.className = "mindmap-search-result-label";
14420            label.textContent = "No matches";
14421            empty.appendChild(spacer);
14422            empty.appendChild(label);
14423            searchResults.appendChild(empty);
14424          } else {
14425            for (const node of results) {
14426              const button = document.createElement("button");
14427              button.type = "button";
14428              button.className = "mindmap-search-result";
14429              button.setAttribute("role", "option");
14430              button.addEventListener("click", (event) => {
14431                event.preventDefault();
14432                event.stopPropagation();
14433                searchInput.value = "";
14434                hideMindmapSearchResults();
14435                focusMindmapNode(node.id, { center: true });
14436              });
14437
14438              const dot = document.createElement("span");
14439              dot.className = "mindmap-search-result-dot";
14440              dot.style.background = getMindmapTypeColor(node.type);
14441              const body = document.createElement("span");
14442              const label = document.createElement("span");
14443              label.className = "mindmap-search-result-label";
14444              label.textContent = node.label || "Untitled concept";
14445              const type = document.createElement("span");
14446              type.className = "mindmap-search-result-type";
14447              type.textContent = MINDMAP_TYPE_LABEL[node.type] || "Concept";
14448              body.appendChild(label);
14449              body.appendChild(type);
14450              button.appendChild(dot);
14451              button.appendChild(body);
14452              searchResults.appendChild(button);
14453            }
14454          }
14455          searchResults.classList.remove("is-hidden");
14456        }
14457
14458        function wireMindmapInteractions() {
14459          const els = getMindmapEls();
14460          const { viewer, stage, svgRoot, searchInput } = els;
14461          if (!viewer || !stage) return;
14462
14463          // Close button
14464          document
14465            .getElementById("mindmapCloseBtn")
14466            .addEventListener("click", closeMindmapViewer);
14467
14468          // Zoom buttons
14469          document
14470            .getElementById("mindmapZoomInBtn")
14471            .addEventListener("click", () => {
14472              const rect = stage.getBoundingClientRect();
14473              setMindmapZoom(
14474                mindmapState.scale * 1.18,
14475                rect.width / 2,
14476                rect.height / 2,
14477              );
14478            });
14479          document
14480            .getElementById("mindmapZoomOutBtn")
14481            .addEventListener("click", () => {
14482              const rect = stage.getBoundingClientRect();
14483              setMindmapZoom(
14484                mindmapState.scale / 1.18,
14485                rect.width / 2,
14486                rect.height / 2,
14487              );
14488            });
14489          document
14490            .getElementById("mindmapFitBtn")
14491            .addEventListener("click", () => {
14492              svgRoot.classList.remove("is-interactive");
14493              fitMindmapToView(true);
14494            });
14495
14496          // Keyboard — local (modal scope)
14497          viewer.addEventListener("keydown", (event) => {
14498            if (event.key === "Escape") {
14499              closeMindmapViewer();
14500              return;
14501            }
14502            trapMindmapFocus(event);
14503            if (event.key === "+" || event.key === "=") {
14504              const rect = stage.getBoundingClientRect();
14505              setMindmapZoom(
14506                mindmapState.scale * 1.18,
14507                rect.width / 2,
14508                rect.height / 2,
14509              );
14510            } else if (event.key === "-") {
14511              const rect = stage.getBoundingClientRect();
14512              setMindmapZoom(
14513                mindmapState.scale / 1.18,
14514                rect.width / 2,
14515                rect.height / 2,
14516              );
14517            } else if (event.key === "f" || event.key === "F") {
14518              fitMindmapToView(true);
14519            }
14520          });
14521
14522          if (searchInput) {
14523            searchInput.addEventListener("input", renderMindmapSearchResults);
14524            searchInput.addEventListener("focus", renderMindmapSearchResults);
14525            searchInput.addEventListener("keydown", (event) => {
14526              if (event.key === "Escape") {
14527                searchInput.value = "";
14528                hideMindmapSearchResults();
14529                searchInput.blur();
14530                return;
14531              }
14532              if (event.key !== "Enter") return;
14533              const first = getMindmapSearchResults(
14534                searchInput.value.trim(),
14535              )[0];
14536              if (!first) return;
14537              event.preventDefault();
14538              searchInput.value = "";
14539              hideMindmapSearchResults();
14540              focusMindmapNode(first.id, { center: true });
14541            });
14542          }
14543
14544          // Keyboard — global shortcut to open (Cmd/Ctrl+M)
14545          document.addEventListener("keydown", (event) => {
14546            const mod = event.metaKey || event.ctrlKey;
14547            if (!mod || event.key.toLowerCase() !== "m") return;
14548            // Skip if user is typing in the editor textarea.
14549            const tag = (event.target && event.target.tagName) || "";
14550            if (tag === "TEXTAREA" || tag === "INPUT") return;
14551            event.preventDefault();
14552            const btn = document.getElementById("mindmapBtn");
14553            if (btn) btn.click();
14554          });
14555
14556          // Wheel zoom
14557          stage.addEventListener(
14558            "wheel",
14559            (event) => {
14560              if (viewer.getAttribute("aria-hidden") === "true") return;
14561              event.preventDefault();
14562              const rect = stage.getBoundingClientRect();
14563              const focalX = event.clientX - rect.left;
14564              const focalY = event.clientY - rect.top;
14565              const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
14566              svgRoot.classList.add("is-interactive");
14567              setMindmapZoom(mindmapState.scale * factor, focalX, focalY);
14568            },
14569            { passive: false },
14570          );
14571
14572          // Drag to pan (only when starting on empty space, not on a node).
14573          stage.addEventListener("pointerdown", (event) => {
14574            if (event.target.closest(".mm-node")) return;
14575            if (event.target.closest(".mindmap-search, .mindmap-inspector"))
14576              return;
14577            mindmapState.isPanning = true;
14578            mindmapState.panStartX = event.clientX;
14579            mindmapState.panStartY = event.clientY;
14580            mindmapState.panStartTx = mindmapState.tx;
14581            mindmapState.panStartTy = mindmapState.ty;
14582            stage.classList.add("is-panning");
14583            svgRoot.classList.add("is-interactive");
14584            stage.setPointerCapture(event.pointerId);
14585          });
14586          stage.addEventListener("pointermove", (event) => {
14587            if (!mindmapState.isPanning) return;
14588            const dx = event.clientX - mindmapState.panStartX;
14589            const dy = event.clientY - mindmapState.panStartY;
14590            mindmapState.tx = mindmapState.panStartTx + dx;
14591            mindmapState.ty = mindmapState.panStartTy + dy;
14592            applyMindmapTransform();
14593          });
14594          const endPan = (event) => {
14595            if (!mindmapState.isPanning) return;
14596            mindmapState.isPanning = false;
14597            stage.classList.remove("is-panning");
14598            try {
14599              stage.releasePointerCapture(event.pointerId);
14600            } catch (_) {}
14601          };
14602          stage.addEventListener("pointerup", endPan);
14603          stage.addEventListener("pointercancel", endPan);
14604
14605          // Backdrop click closes
14606          viewer.addEventListener("click", (event) => {
14607            if (event.target === viewer) closeMindmapViewer();
14608          });
14609
14610          stage.addEventListener("click", (event) => {
14611            if (event.target.closest(".mm-node")) return;
14612            if (event.target.closest(".mindmap-search, .mindmap-inspector"))
14613              return;
14614            clearMindmapFocusedNode();
14615            hideMindmapSearchResults();
14616          });
14617        }
14618
14619        /* ============================================================
14620         * Concept Mindmap — preview navigation
14621         * ============================================================ */
14622
14623        /**
14624         * Jump to the source section corresponding to a node.
14625         * Scrolls the preview, applies a transient highlight to the heading.
14626         */
14627        function slugifyMindmapText(text) {
14628          return String(text || "")
14629            .toLowerCase()
14630            .normalize("NFKD")
14631            .replace(/[\u0300-\u036f]/g, "")
14632            .replace(/&/g, " and ")
14633            .replace(/[^a-z0-9\s-]/g, " ")
14634            .trim()
14635            .replace(/\s+/g, "-")
14636            .replace(/-+/g, "-");
14637        }
14638
14639        function normalizeMindmapToken(token) {
14640          const value = String(token || "").toLowerCase();
14641          if (value.endsWith("ies") && value.length > 4)
14642            return value.slice(0, -3) + "y";
14643          if (value.endsWith("s") && value.length > 3)
14644            return value.slice(0, -1);
14645          return value;
14646        }
14647
14648        function getMindmapNodeById(id) {
14649          return mindmapState.laid && mindmapState.laid.nodes
14650            ? mindmapState.laid.nodes.find((n) => n.id === id) || null
14651            : null;
14652        }
14653
14654        function findMindmapTargetByAnchor(preview, anchor) {
14655          if (!anchor) return null;
14656          let target = preview.querySelector(`#${CSS.escape(anchor)}`);
14657          if (target) return target;
14658
14659          const slug = String(anchor).toLowerCase();
14660          const headings = preview.querySelectorAll("h1, h2, h3, h4, h5, h6");
14661          for (const heading of headings) {
14662            if (heading.id && heading.id.toLowerCase() === slug) {
14663              return heading;
14664            }
14665          }
14666          return null;
14667        }
14668
14669        function resolveMindmapTarget(node, preview) {
14670          if (!node || !preview) return null;
14671
14672          const triedAnchors = new Set();
14673          const tryAnchor = (anchor) => {
14674            if (!anchor || triedAnchors.has(anchor)) return null;
14675            triedAnchors.add(anchor);
14676            return findMindmapTargetByAnchor(preview, anchor);
14677          };
14678
14679          let target = tryAnchor(node.sourceAnchor);
14680          if (target) return target;
14681
14682          target = tryAnchor(slugifyMindmapText(node.label));
14683          if (target) return target;
14684
14685          const headings = Array.from(
14686            preview.querySelectorAll("h1, h2, h3, h4, h5, h6"),
14687          );
14688          if (headings.length === 0) return null;
14689
14690          const labelTokens = Array.from(
14691            new Set(
14692              String(node.label || "")
14693                .toLowerCase()
14694                .replace(/[^a-z0-9\s]/g, " ")
14695                .split(/\s+/)
14696                .map(normalizeMindmapToken)
14697                .filter((token) => token.length > 2),
14698            ),
14699          );
14700          const summaryTokens = Array.from(
14701            new Set(
14702              String(node.summary || "")
14703                .toLowerCase()
14704                .replace(/[^a-z0-9\s]/g, " ")
14705                .split(/\s+/)
14706                .map(normalizeMindmapToken)
14707                .filter((token) => token.length > 3),
14708            ),
14709          );
14710
14711          let best = null;
14712          for (const heading of headings) {
14713            const headingText = heading.textContent.trim();
14714            const headingSlug = slugifyMindmapText(headingText);
14715            let score = 0;
14716            let labelMatches = 0;
14717
14718            if (headingSlug && headingSlug === slugifyMindmapText(node.label))
14719              score += 8;
14720
14721            const headingTokens = new Set(
14722              headingText
14723                .toLowerCase()
14724                .replace(/[^a-z0-9\s]/g, " ")
14725                .split(/\s+/)
14726                .map(normalizeMindmapToken)
14727                .filter((token) => token.length > 2),
14728            );
14729
14730            for (const token of labelTokens) {
14731              if (headingTokens.has(token)) {
14732                score += 3;
14733                labelMatches += 1;
14734              }
14735            }
14736            for (const token of summaryTokens) {
14737              if (headingTokens.has(token)) score += 1;
14738            }
14739
14740            if (!best || score > best.score) {
14741              best = { heading, score, labelMatches };
14742            }
14743          }
14744
14745          if (best && best.score >= 3 && best.labelMatches > 0) {
14746            return best.heading;
14747          }
14748
14749          let ancestorId = node.parent;
14750          while (ancestorId) {
14751            const ancestor = getMindmapNodeById(ancestorId);
14752            if (!ancestor) break;
14753            const ancestorTokens = new Set(
14754              String(ancestor.label || "")
14755                .toLowerCase()
14756                .replace(/[^a-z0-9\s]/g, " ")
14757                .split(/\s+/)
14758                .map(normalizeMindmapToken)
14759                .filter((token) => token.length > 2),
14760            );
14761            const hasAncestorOverlap = labelTokens.some((token) =>
14762              ancestorTokens.has(token),
14763            );
14764            target = tryAnchor(ancestor.sourceAnchor);
14765            if (target && hasAncestorOverlap) return target;
14766            ancestorId = ancestor.parent;
14767          }
14768
14769          return null;
14770        }
14771
14772        function scrollMindmapSourceTarget(target) {
14773          if (!target) return;
14774          const toolbarHeight =
14775            Number.parseFloat(
14776              getComputedStyle(document.documentElement).getPropertyValue(
14777                "--toolbar-h",
14778              ),
14779            ) || 52;
14780          const targetTop =
14781            target.getBoundingClientRect().top +
14782            window.scrollY -
14783            toolbarHeight -
14784            24;
14785          window.scrollTo({
14786            top: Math.max(0, targetTop),
14787            behavior: "auto",
14788          });
14789          target.classList.add("mm-jump-flash");
14790          setTimeout(() => target.classList.remove("mm-jump-flash"), 1800);
14791        }
14792
14793        function jumpToMindmapSource(node) {
14794          if (!node) return;
14795
14796          const preview = document.getElementById("preview");
14797          if (!preview) return;
14798          const target = resolveMindmapTarget(node, preview);
14799          if (!target) return;
14800
14801          closeMindmapViewer();
14802          requestAnimationFrame(() => {
14803            requestAnimationFrame(() => scrollMindmapSourceTarget(target));
14804          });
14805        }
14806
14807        function wireMindmapNodeClicks() {
14808          const nodesG = document.getElementById("mindmapNodes");
14809          if (!nodesG) return;
14810          nodesG.addEventListener("click", (event) => {
14811            const g = event.target.closest(".mm-node");
14812            if (!g) return;
14813            event.preventDefault();
14814            event.stopPropagation();
14815            const id = g.getAttribute("data-id");
14816            focusMindmapNode(id, { center: true });
14817          });
14818          nodesG.addEventListener("dblclick", (event) => {
14819            const g = event.target.closest(".mm-node");
14820            if (!g) return;
14821            event.preventDefault();
14822            event.stopPropagation();
14823            const id = g.getAttribute("data-id");
14824            const node = getMindmapNodeById(id);
14825            if (node && node.depth > 0) jumpToMindmapSource(node);
14826          });
14827          nodesG.addEventListener("keydown", (event) => {
14828            if (event.key !== "Enter" && event.key !== " ") return;
14829            const g = event.target.closest(".mm-node");
14830            if (!g) return;
14831            event.preventDefault();
14832            const id = g.getAttribute("data-id");
14833            const node = getMindmapNodeById(id);
14834            if (!node) return;
14835            if (
14836              event.key === "Enter" &&
14837              mindmapState.activeNodeId === id &&
14838              node.depth > 0
14839            ) {
14840              jumpToMindmapSource(node);
14841              return;
14842            }
14843            focusMindmapNode(id, { center: true });
14844          });
14845        }
14846
14847        /* ============================================================
14848         * Concept Mindmap — hover tooltip
14849         * ============================================================ */
14850
14851        function wireMindmapHover() {
14852          const nodesG = document.getElementById("mindmapNodes");
14853          const { tooltip, stage } = getMindmapEls();
14854          if (!nodesG || !tooltip || !stage) return;
14855
14856          const typeEl = tooltip.querySelector(".mm-tooltip-type");
14857          const labelEl = tooltip.querySelector(".mm-tooltip-label");
14858          const summaryEl = tooltip.querySelector(".mm-tooltip-summary");
14859
14860          function showTooltip(node, clientX, clientY) {
14861            const label = MINDMAP_TYPE_LABEL[node.type] || "Concept";
14862            typeEl.textContent = label.toUpperCase();
14863            labelEl.textContent = node.label || "";
14864            summaryEl.textContent = node.summary || "";
14865            const stageRect = stage.getBoundingClientRect();
14866            const x = clientX - stageRect.left + 14;
14867            const y = clientY - stageRect.top + 14;
14868            tooltip.style.left = `${x}px`;
14869            tooltip.style.top = `${y}px`;
14870            tooltip.classList.add("is-visible");
14871          }
14872          function hideTooltip() {
14873            tooltip.classList.remove("is-visible");
14874          }
14875
14876          nodesG.addEventListener("pointerover", (event) => {
14877            const g = event.target.closest(".mm-node");
14878            if (!g) return;
14879            const id = g.getAttribute("data-id");
14880            const node =
14881              mindmapState.laid &&
14882              mindmapState.laid.nodes.find((n) => n.id === id);
14883            if (node) showTooltip(node, event.clientX, event.clientY);
14884          });
14885          nodesG.addEventListener("pointermove", (event) => {
14886            if (!tooltip.classList.contains("is-visible")) return;
14887            const g = event.target.closest(".mm-node");
14888            if (!g) {
14889              hideTooltip();
14890              return;
14891            }
14892            const stageRect = stage.getBoundingClientRect();
14893            tooltip.style.left = `${event.clientX - stageRect.left + 14}px`;
14894            tooltip.style.top = `${event.clientY - stageRect.top + 14}px`;
14895          });
14896          nodesG.addEventListener("pointerout", (event) => {
14897            if (
14898              event.relatedTarget &&
14899              event.relatedTarget.closest &&
14900              event.relatedTarget.closest(".mm-node")
14901            )
14902              return;
14903            hideTooltip();
14904          });
14905          stage.addEventListener("pointerdown", hideTooltip);
14906        }
14907
14908        /* ============================================================
14909         * Concept Mindmap — content hash + local cache
14910         * ============================================================ */
14911
14912        // v3 invalidates older entries generated before digest compaction and
14913        // edge-kind normalization were added.
14914        const MINDMAP_CACHE_KEY = "markv:mindmap-cache:v3";
14915        const MINDMAP_CACHE_MAX = 12;
14916
14917        async function hashString(str) {
14918          if (window.crypto && window.crypto.subtle) {
14919            const buf = new TextEncoder().encode(str);
14920            const digest = await window.crypto.subtle.digest("SHA-256", buf);
14921            const bytes = new Uint8Array(digest);
14922            let out = "";
14923            for (let i = 0; i < bytes.length; i++) {
14924              out += bytes[i].toString(16).padStart(2, "0");
14925            }
14926            return out.slice(0, 16);
14927          }
14928          // Fallback: DJB2
14929          let h = 5381;
14930          for (let i = 0; i < str.length; i++)
14931            h = ((h << 5) + h + str.charCodeAt(i)) | 0;
14932          return String(h >>> 0);
14933        }
14934
14935        function loadMindmapCache() {
14936          try {
14937            const raw = window.localStorage.getItem(MINDMAP_CACHE_KEY);
14938            if (!raw) return {};
14939            const parsed = JSON.parse(raw);
14940            return parsed && typeof parsed === "object" ? parsed : {};
14941          } catch (_) {
14942            return {};
14943          }
14944        }
14945
14946        function saveMindmapCache(cache) {
14947          try {
14948            // Trim to most recent MINDMAP_CACHE_MAX entries.
14949            const keys = Object.keys(cache).sort(
14950              (a, b) => (cache[b].ts || 0) - (cache[a].ts || 0),
14951            );
14952            const trimmed = {};
14953            for (const k of keys.slice(0, MINDMAP_CACHE_MAX))
14954              trimmed[k] = cache[k];
14955            window.localStorage.setItem(
14956              MINDMAP_CACHE_KEY,
14957              JSON.stringify(trimmed),
14958            );
14959          } catch (_) {}
14960        }
14961
14962        function getCachedGraph(docHash) {
14963          const cache = loadMindmapCache();
14964          const entry = cache[docHash];
14965          return entry ? entry.graph : null;
14966        }
14967
14968        function setCachedGraph(docHash, graph) {
14969          const cache = loadMindmapCache();
14970          cache[docHash] = { graph, ts: Date.now() };
14971          saveMindmapCache(cache);
14972        }
14973
14974        /* ============================================================
14975         * Concept Mindmap — LLM extractor
14976         * ============================================================ */
14977
14978        const MINDMAP_SYSTEM_PROMPT = `You are a concept extractor for technical documents. Read the markdown document and return a JSON concept graph that helps a reader understand the document's shape.
14979
14980REQUIREMENTS:
14981- Return ONLY valid JSON matching the schema. No prose, no markdown fences.
14982- The supplied document may be raw markdown or a structure-focused digest. Treat headings, anchor lines, and excerpts as authoritative.
14983- If LIBRARY_CONTEXT is present, treat it as deterministic context from the active library folder.
14984- sourceAnchor must be null or one of the listed available_anchors. Do not invent anchors.
14985- Prefer explicit outbound_links, backlinks, Related Concepts, and neighbors when choosing cross-tree edges.
14986- Use linked library pages to improve labels and summaries, but keep the map centered on the current document.
14987- Root = the document itself. Label: 1-4 words capturing what this doc IS.
14988- Primary nodes (parent: "root"): 4-7 load-bearing concepts a reader must understand.
14989- Secondary nodes: decomposition of a primary, max 3-4 children per primary.
14990- Total non-root nodes: max 24. Quality over coverage.
14991- Each node.type must be one of: goal, non_goal, feature, constraint, dependency, actor, assumption, open_question, success_metric.
14992- Each node.label: 1-5 words, human phrasing, no trailing punctuation.
14993- Each node.summary: one sentence, max 180 characters.
14994- Each node.sourceAnchor: the heading slug (kebab-case, lowercase, no #) if the concept maps to a specific heading; else null.
14995- edges: cross-tree relationships only. Kinds: part_of, depends_on, blocks, supports, contradicts, measured_by.
14996- "dependency" is a valid node.type but NOT a valid edge.kind. For dependency relationships, edge.kind must be "depends_on".
14997- If you are unsure about an edge kind, omit the edge instead of inventing a new enum value.
14998- Do not restate the parent-child tree in edges; edges are only for cross-tree relationships.
14999
15000SCHEMA:
15001{
15002  "root": { "id": "root", "label": string, "summary": string },
15003  "nodes": [ { "id": string, "label": string, "type": string, "summary": string, "sourceAnchor": string|null, "parent": string } ],
15004  "edges": [ { "from": string, "to": string, "kind": string } ]
15005}
15006
15007STYLE:
15008- Plain editorial English.
15009- Never "The user shall be able to" — write the noun.
15010- Never generic labels like "Implementation" or "Requirements" — be specific.
15011
15012Return JSON only.`;
15013
15014        const MINDMAP_SOURCE_CHAR_LIMIT = 9000;
15015        const MINDMAP_SOURCE_SECTION_LIMIT = 18;
15016        const MINDMAP_SOURCE_SECTION_SNIPPET_CHARS = 320;
15017        const MINDMAP_SOURCE_OVERVIEW_CHARS = 900;
15018        const MINDMAP_GROQ_LOW_TPM_MODEL_RE =
15019          /^(?:qwen\/qwen3-32b|llama-3\.1-8b-instant|allam-2-7b)$/i;
15020        const MINDMAP_MAX_COMPLETION_TOKENS = 1400;
15021
15022        const MINDMAP_EDGE_KIND_ALIASES = {
15023          part_of: "part_of",
15024          partof: "part_of",
15025          depends_on: "depends_on",
15026          dependson: "depends_on",
15027          dependency: "depends_on",
15028          dependencies: "depends_on",
15029          dependent_on: "depends_on",
15030          depends: "depends_on",
15031          requires: "depends_on",
15032          requirement: "depends_on",
15033          blocked: "blocks",
15034          blocks: "blocks",
15035          supports: "supports",
15036          support: "supports",
15037          contradicts: "contradicts",
15038          contradict: "contradicts",
15039          measured_by: "measured_by",
15040          measuredby: "measured_by",
15041          measures: "measured_by",
15042        };
15043
15044        const MINDMAP_NODE_TYPE_ENUM = [
15045          "goal",
15046          "non_goal",
15047          "feature",
15048          "constraint",
15049          "dependency",
15050          "actor",
15051          "assumption",
15052          "open_question",
15053          "success_metric",
15054        ];
15055
15056        const MINDMAP_EDGE_KIND_ENUM = [
15057          "part_of",
15058          "depends_on",
15059          "blocks",
15060          "supports",
15061          "contradicts",
15062          "measured_by",
15063        ];
15064
15065        const MINDMAP_JSON_SCHEMA = {
15066          type: "object",
15067          additionalProperties: false,
15068          required: ["root", "nodes", "edges"],
15069          properties: {
15070            root: {
15071              type: "object",
15072              additionalProperties: false,
15073              required: ["id", "label", "summary"],
15074              properties: {
15075                id: { type: "string", enum: ["root"] },
15076                label: { type: "string" },
15077                summary: { type: "string" },
15078              },
15079            },
15080            nodes: {
15081              type: "array",
15082              items: {
15083                type: "object",
15084                additionalProperties: false,
15085                required: [
15086                  "id",
15087                  "label",
15088                  "type",
15089                  "summary",
15090                  "sourceAnchor",
15091                  "parent",
15092                ],
15093                properties: {
15094                  id: { type: "string" },
15095                  label: { type: "string" },
15096                  type: { type: "string", enum: MINDMAP_NODE_TYPE_ENUM },
15097                  summary: { type: "string" },
15098                  sourceAnchor: { type: ["string", "null"] },
15099                  parent: { type: "string" },
15100                },
15101              },
15102            },
15103            edges: {
15104              type: "array",
15105              items: {
15106                type: "object",
15107                additionalProperties: false,
15108                required: ["from", "to", "kind"],
15109                properties: {
15110                  from: { type: "string" },
15111                  to: { type: "string" },
15112                  kind: { type: "string", enum: MINDMAP_EDGE_KIND_ENUM },
15113                },
15114              },
15115            },
15116          },
15117        };
15118
15119        function getMindmapLlmSettings() {
15120          if (typeof window.markvGetLlmSettings === "function") {
15121            try {
15122              const settings = window.markvGetLlmSettings();
15123              return settings && typeof settings === "object" ? settings : null;
15124            } catch (_) {}
15125          }
15126          return null;
15127        }
15128
15129        function normalizeMindmapWhitespace(text) {
15130          return String(text || "")
15131            .replace(/\s+/g, " ")
15132            .trim();
15133        }
15134
15135        function clipMindmapText(text, maxChars) {
15136          const normalized = normalizeMindmapWhitespace(text);
15137          if (!normalized || normalized.length <= maxChars) return normalized;
15138          const clipped = normalized.slice(0, Math.max(0, maxChars - 1));
15139          const boundary = clipped.lastIndexOf(" ");
15140          return (
15141            (boundary > 48 ? clipped.slice(0, boundary) : clipped).trim() + "…"
15142          );
15143        }
15144
15145        function cleanMindmapInlineMarkdown(text) {
15146          return normalizeMindmapWhitespace(
15147            String(text || "")
15148              .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
15149              .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_match, alt) {
15150                return alt ? `[image: ${alt}]` : "[image]";
15151              })
15152              .replace(/`([^`]+)`/g, "$1")
15153              .replace(/[*_~]/g, "")
15154              .replace(/<\/?[^>]+>/g, ""),
15155          );
15156        }
15157
15158        function simplifyMindmapMarkdown(text) {
15159          return normalizeMindmapWhitespace(
15160            String(text || "")
15161              .replace(/```([^\n`]*)\n[\s\S]*?```/g, function (_match, lang) {
15162                const label = normalizeMindmapWhitespace(lang).toLowerCase();
15163                return label ? `\n[${label} code block]\n` : "\n[code block]\n";
15164              })
15165              .replace(/~~~([^\n~]*)\n[\s\S]*?~~~/g, function (_match, lang) {
15166                const label = normalizeMindmapWhitespace(lang).toLowerCase();
15167                return label ? `\n[${label} code block]\n` : "\n[code block]\n";
15168              })
15169              .replace(/^ {0,3}\|(?:[-: ]+\|)+\s*$/gm, "")
15170              .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_match, alt) {
15171                return alt ? `[image: ${alt}]` : "[image]";
15172              })
15173              .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
15174              .replace(/<https?:\/\/[^>]+>/g, "[link]")
15175              .replace(/`([^`]+)`/g, "$1")
15176              .replace(/^ {0,3}>\s?/gm, "")
15177              .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, "$1- "),
15178          );
15179        }
15180
15181        function parseMindmapMarkdownSections(markdown) {
15182          const lines = String(markdown || "").split(/\r?\n/);
15183          const sections = [];
15184          let current = {
15185            heading: "Document",
15186            level: 0,
15187            anchor: null,
15188            lines: [],
15189          };
15190          let inFence = false;
15191
15192          function pushCurrent() {
15193            const rawText = current.lines.join("\n");
15194            const text = simplifyMindmapMarkdown(rawText);
15195            if (!text && current.level === 0 && current.heading === "Document")
15196              return;
15197            if (!current.heading && !text) return;
15198            sections.push({
15199              heading: current.heading || "Document",
15200              level: current.level || 0,
15201              anchor: current.anchor || null,
15202              text: text,
15203            });
15204          }
15205
15206          for (let i = 0; i < lines.length; i++) {
15207            const line = lines[i];
15208            if (/^(```|~~~)/.test(line.trim())) {
15209              inFence = !inFence;
15210              current.lines.push(line);
15211              continue;
15212            }
15213            if (!inFence) {
15214              const headingMatch = line.match(
15215                /^ {0,3}(#{1,6})[ \t]+(.+?)\s*#*\s*$/,
15216              );
15217              if (headingMatch) {
15218                pushCurrent();
15219                const headingText = cleanMindmapInlineMarkdown(headingMatch[2]);
15220                current = {
15221                  heading: headingText || "Untitled Section",
15222                  level: headingMatch[1].length,
15223                  anchor: slugifyMindmapText(headingText),
15224                  lines: [],
15225                };
15226                continue;
15227              }
15228            }
15229            current.lines.push(line);
15230          }
15231
15232          pushCurrent();
15233          return sections.filter(function (section) {
15234            return section.heading || section.text;
15235          });
15236        }
15237
15238        function shouldCompactMindmapSource(markdown) {
15239          const settings = getMindmapLlmSettings();
15240          const model =
15241            settings && settings.provider === "groq"
15242              ? String(settings.groqModel || "").trim()
15243              : "";
15244          if (
15245            settings &&
15246            settings.provider === "groq" &&
15247            MINDMAP_GROQ_LOW_TPM_MODEL_RE.test(model)
15248          ) {
15249            return true;
15250          }
15251          return String(markdown || "").length > 12000;
15252        }
15253
15254        function buildMindmapDocumentSource(markdown) {
15255          const raw = String(markdown || "");
15256          if (!shouldCompactMindmapSource(raw)) {
15257            return {
15258              mode: "raw_markdown",
15259              text: raw.slice(0, MINDMAP_MAX_MARKDOWN_CHARS),
15260              truncated: raw.length > MINDMAP_MAX_MARKDOWN_CHARS,
15261            };
15262          }
15263
15264          const simplified = simplifyMindmapMarkdown(raw);
15265          const sections = parseMindmapMarkdownSections(raw);
15266          const preview = document.getElementById("preview");
15267          const title =
15268            normalizeMindmapWhitespace(
15269              sections[0] && sections[0].level === 1
15270                ? sections[0].heading
15271                : preview && preview.dataset && preview.dataset.documentLabel,
15272            ) || "Document";
15273
15274          const parts = [
15275            "# " + title,
15276            "",
15277            "Document digest for concept extraction. Heading text, anchor lines, and excerpts are authoritative.",
15278            "",
15279            "## Overview",
15280            clipMindmapText(simplified, MINDMAP_SOURCE_OVERVIEW_CHARS) ||
15281              "No overview available.",
15282          ];
15283
15284          let usedChars = parts.join("\n").length;
15285          const maxSections = Math.min(
15286            MINDMAP_SOURCE_SECTION_LIMIT,
15287            sections.length,
15288          );
15289
15290          for (let i = 0; i < maxSections; i++) {
15291            const section = sections[i];
15292            const snippet = clipMindmapText(
15293              section.text,
15294              MINDMAP_SOURCE_SECTION_SNIPPET_CHARS,
15295            );
15296            if (!snippet) continue;
15297            const depth = Math.min(Math.max(section.level || 2, 2), 6);
15298            const block = [
15299              `${"#".repeat(depth)} ${section.heading}`,
15300              section.anchor ? `anchor: ${section.anchor}` : "anchor: null",
15301              snippet,
15302            ].join("\n");
15303            if (usedChars + block.length > MINDMAP_SOURCE_CHAR_LIMIT) break;
15304            parts.push("", block);
15305            usedChars += block.length;
15306          }
15307
15308          return {
15309            mode: "digest",
15310            text: parts.join("\n").trim(),
15311            truncated: true,
15312          };
15313        }
15314
15315        function getMindmapFallbackPath() {
15316          const rawName = String(state.currentName || "document.md").trim();
15317          return rawName || "document.md";
15318        }
15319
15320        function getOrderedLibraryMindmapPaths(folder, currentPath) {
15321          if (
15322            !folder ||
15323            !folder.fileHandlesByPath ||
15324            typeof folder.fileHandlesByPath.keys !== "function"
15325          ) {
15326            return [];
15327          }
15328          const currentDir = String(currentPath || "")
15329            .split("/")
15330            .slice(0, -1)
15331            .join("/");
15332          return Array.from(folder.fileHandlesByPath.keys())
15333            .filter(function (path) {
15334              return path && path !== currentPath;
15335            })
15336            .sort(function (a, b) {
15337              const aDir = String(a).split("/").slice(0, -1).join("/");
15338              const bDir = String(b).split("/").slice(0, -1).join("/");
15339              const aSame = aDir === currentDir ? 0 : 1;
15340              const bSame = bDir === currentDir ? 0 : 1;
15341              if (aSame !== bSame) return aSame - bSame;
15342              if (typeof libraryCore.compareLibraryNames === "function") {
15343                return libraryCore.compareLibraryNames(a, b);
15344              }
15345              return String(a).localeCompare(String(b), undefined, {
15346                numeric: true,
15347                sensitivity: "base",
15348              });
15349            });
15350        }
15351
15352        async function readLibraryMindmapFile(folder, path) {
15353          if (!folder || !folder.fileHandlesByPath) return null;
15354          const handle = folder.fileHandlesByPath.get(path);
15355          if (!handle || typeof handle.getFile !== "function") return null;
15356          try {
15357            const file = await handle.getFile();
15358            return {
15359              path: path,
15360              text: await file.text(),
15361            };
15362          } catch (error) {
15363            console.warn("[mindmap] skipped unreadable library context file", {
15364              path: path,
15365              error: error && error.message ? error.message : String(error),
15366            });
15367            return null;
15368          }
15369        }
15370
15371        async function buildActiveLibraryMindmapContext(markdown) {
15372          if (typeof libraryCore.buildLibraryMindmapContext !== "function") {
15373            return null;
15374          }
15375
15376          const currentPath =
15377            state.sourceMode === "library" && state.libraryActiveFilePath
15378              ? state.libraryActiveFilePath
15379              : getMindmapFallbackPath();
15380          const files = [];
15381          const folder =
15382            state.sourceMode === "library" && state.libraryActiveFolderId
15383              ? getLibraryFolderById(state.libraryActiveFolderId)
15384              : null;
15385
15386          if (folder && folder.status === "ready") {
15387            const paths = getOrderedLibraryMindmapPaths(folder, currentPath);
15388            for (const path of paths) {
15389              const item = await readLibraryMindmapFile(folder, path);
15390              if (item) files.push(item);
15391              if (files.length % 20 === 0) await yieldToUi();
15392            }
15393          }
15394
15395          return libraryCore.buildLibraryMindmapContext({
15396            currentPath: currentPath,
15397            currentMarkdown: markdown,
15398            files: files,
15399          });
15400        }
15401
15402        function getMindmapLibraryContextCacheKey(context) {
15403          if (!context || typeof context !== "object") return "";
15404          const compact = {
15405            current: context.current && context.current.path,
15406            outbound: (context.outboundLinks || []).map(function (item) {
15407              return [item.path, item.related ? 1 : 0];
15408            }),
15409            backlinks: (context.backlinks || []).map(function (item) {
15410              return [item.path, item.occurrenceCount || 1];
15411            }),
15412            neighbors: (context.neighbors || []).map(function (item) {
15413              return [item.path, item.direction, item.summary || ""];
15414            }),
15415          };
15416          try {
15417            return JSON.stringify(compact);
15418          } catch (_error) {
15419            return "";
15420          }
15421        }
15422
15423        function formatMindmapLibraryContext(context) {
15424          if (
15425            !context ||
15426            typeof libraryCore.formatLibraryMindmapContextForPrompt !==
15427              "function"
15428          ) {
15429            return "";
15430          }
15431          return libraryCore.formatLibraryMindmapContextForPrompt(context);
15432        }
15433
15434        function groundMindmapGraphToLibraryContext(graph, context) {
15435          if (
15436            context &&
15437            typeof libraryCore.groundMindmapGraphWithLibraryContext ===
15438              "function"
15439          ) {
15440            return libraryCore.groundMindmapGraphWithLibraryContext(
15441              graph,
15442              context,
15443            );
15444          }
15445          return graph;
15446        }
15447
15448        function buildMindmapExtractionSource(markdown, libraryContext) {
15449          const source = buildMindmapDocumentSource(markdown);
15450          const formattedContext = formatMindmapLibraryContext(libraryContext);
15451          if (!formattedContext) return source;
15452          return {
15453            mode: source.mode + "+library_context",
15454            text: source.text + "\n\n---\n\n" + formattedContext,
15455            truncated: source.truncated,
15456          };
15457        }
15458
15459        function getMindmapGroqResponseFormat(model) {
15460          const normalized = String(model || "")
15461            .trim()
15462            .toLowerCase();
15463          if (
15464            normalized === "openai/gpt-oss-20b" ||
15465            normalized === "openai/gpt-oss-120b"
15466          ) {
15467            return {
15468              type: "json_schema",
15469              json_schema: {
15470                name: "concept_graph",
15471                strict: true,
15472                schema: MINDMAP_JSON_SCHEMA,
15473              },
15474            };
15475          }
15476          if (
15477            normalized === "openai/gpt-oss-safeguard-20b" ||
15478            normalized === "meta-llama/llama-4-scout-17b-16e-instruct"
15479          ) {
15480            return {
15481              type: "json_schema",
15482              json_schema: {
15483                name: "concept_graph",
15484                strict: false,
15485                schema: MINDMAP_JSON_SCHEMA,
15486              },
15487            };
15488          }
15489          return { type: "json_object" };
15490        }
15491
15492        window.markvGetMindmapGroqResponseFormat = getMindmapGroqResponseFormat;
15493
15494        /**
15495         * Invokes the shared LLM client (wired by chat feature) and returns a
15496         * validated ConceptGraph. The client must be exposed as
15497         * window.markvLLM = async ({system, user}) => string  returning raw text.
15498         * Throws on any validation failure.
15499         * @param {string} markdown
15500         * @param {ReturnType<typeof buildActiveLibraryMindmapContext>} [libraryContext]
15501         * @returns {Promise<ConceptGraph>}
15502         */
15503        async function extractConcepts(markdown, libraryContext) {
15504          if (typeof window.markvLLM !== "function") {
15505            throw new Error(
15506              "No LLM client registered (window.markvLLM is not set)",
15507            );
15508          }
15509          const source = buildMindmapExtractionSource(markdown, libraryContext);
15510          const userPrompt =
15511            `Extract the concept graph for the following technical document.\n\n` +
15512            `SOURCE_MODE: ${source.mode}\n\nDOCUMENT:\n\n${source.text}`;
15513          const raw = await window.markvLLM({
15514            system: MINDMAP_SYSTEM_PROMPT,
15515            user: userPrompt,
15516            maxCompletionTokens: MINDMAP_MAX_COMPLETION_TOKENS,
15517          });
15518          return groundMindmapGraphToLibraryContext(
15519            parseAndValidateGraph(raw),
15520            libraryContext,
15521          );
15522        }
15523
15524        /**
15525         * Accepts raw LLM output (potentially with markdown fences) and returns
15526         * a validated ConceptGraph. Throws with a helpful message on failure.
15527         */
15528        function extractBalancedJsonObject(text) {
15529          const firstBrace = text.indexOf("{");
15530          if (firstBrace === -1) return text;
15531
15532          let start = firstBrace;
15533          let depth = 0;
15534          let inString = false;
15535          let escaped = false;
15536
15537          for (let i = firstBrace; i < text.length; i++) {
15538            const ch = text[i];
15539            if (inString) {
15540              if (escaped) {
15541                escaped = false;
15542              } else if (ch === "\\") {
15543                escaped = true;
15544              } else if (ch === '"') {
15545                inString = false;
15546              }
15547              continue;
15548            }
15549
15550            if (ch === '"') {
15551              inString = true;
15552              continue;
15553            }
15554            if (ch === "{") {
15555              if (depth === 0) start = i;
15556              depth += 1;
15557            } else if (ch === "}") {
15558              depth -= 1;
15559              if (depth === 0) {
15560                return text.slice(start, i + 1);
15561              }
15562            }
15563          }
15564
15565          return text;
15566        }
15567
15568        function stripJsonTrailingCommas(text) {
15569          let out = "";
15570          let inString = false;
15571          let escaped = false;
15572
15573          for (let i = 0; i < text.length; i++) {
15574            const ch = text[i];
15575            if (inString) {
15576              out += ch;
15577              if (escaped) {
15578                escaped = false;
15579              } else if (ch === "\\") {
15580                escaped = true;
15581              } else if (ch === '"') {
15582                inString = false;
15583              }
15584              continue;
15585            }
15586
15587            if (ch === '"') {
15588              inString = true;
15589              out += ch;
15590              continue;
15591            }
15592
15593            if (ch === ",") {
15594              let j = i + 1;
15595              while (j < text.length && /\s/.test(text[j])) j += 1;
15596              if (text[j] === "}" || text[j] === "]") {
15597                continue;
15598              }
15599            }
15600
15601            out += ch;
15602          }
15603
15604          return out;
15605        }
15606
15607        function normalizeMindmapJsonText(raw) {
15608          let text = String(raw || "")
15609            .trim()
15610            .replace(/^\uFEFF/, "");
15611          const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
15612          if (fenceMatch) text = fenceMatch[1].trim();
15613          return extractBalancedJsonObject(text).trim();
15614        }
15615
15616        const MINDMAP_TYPE_ALIASES = {
15617          goal: "goal",
15618          goals: "goal",
15619          non_goal: "non_goal",
15620          non_goals: "non_goal",
15621          nongoal: "non_goal",
15622          nongoals: "non_goal",
15623          feature: "feature",
15624          features: "feature",
15625          constraint: "constraint",
15626          constraints: "constraint",
15627          dependency: "dependency",
15628          dependencies: "dependency",
15629          actor: "actor",
15630          actors: "actor",
15631          assumption: "assumption",
15632          assumptions: "assumption",
15633          open_question: "open_question",
15634          open_questions: "open_question",
15635          question: "open_question",
15636          questions: "open_question",
15637          success_metric: "success_metric",
15638          success_metrics: "success_metric",
15639          metric: "success_metric",
15640          metrics: "success_metric",
15641          kpi: "success_metric",
15642          kpis: "success_metric",
15643        };
15644
15645        function normalizeMindmapNodeType(type) {
15646          if (typeof type !== "string") return type;
15647          const canonical = type
15648            .trim()
15649            .toLowerCase()
15650            .replace(/[\s-]+/g, "_");
15651          return MINDMAP_TYPE_ALIASES[canonical] || canonical;
15652        }
15653
15654        function normalizeMindmapEdgeKind(kind) {
15655          if (typeof kind !== "string") return kind;
15656          const canonical = kind
15657            .trim()
15658            .toLowerCase()
15659            .replace(/[\s-]+/g, "_");
15660          const collapsed = canonical.replace(/_/g, "");
15661          return (
15662            MINDMAP_EDGE_KIND_ALIASES[canonical] ||
15663            MINDMAP_EDGE_KIND_ALIASES[collapsed] ||
15664            canonical
15665          );
15666        }
15667
15668        function parseAndValidateGraph(raw) {
15669          if (typeof raw !== "string")
15670            throw new Error("LLM returned non-string output");
15671          const text = normalizeMindmapJsonText(raw);
15672          let parsed;
15673          try {
15674            parsed = JSON.parse(text);
15675          } catch (e) {
15676            try {
15677              parsed = JSON.parse(stripJsonTrailingCommas(text));
15678            } catch (_) {
15679              throw new Error(`LLM output is not valid JSON: ${e.message}`);
15680            }
15681          }
15682          if (!parsed || typeof parsed !== "object")
15683            throw new Error("Graph must be an object");
15684          if (
15685            !parsed.root ||
15686            parsed.root.id !== "root" ||
15687            typeof parsed.root.label !== "string"
15688          ) {
15689            throw new Error("Graph.root is missing or malformed");
15690          }
15691          if (!Array.isArray(parsed.nodes))
15692            throw new Error("Graph.nodes must be an array");
15693          if (parsed.nodes.length === 0) throw new Error("Graph has no nodes");
15694          if (parsed.nodes.length > 30)
15695            throw new Error(
15696              `Graph has too many nodes (${parsed.nodes.length})`,
15697            );
15698
15699          if (
15700            typeof libraryCore.normalizeMindmapGraphReferences === "function"
15701          ) {
15702            parsed = libraryCore.normalizeMindmapGraphReferences(parsed, {
15703              maxDepth: 3,
15704              allowedNodeTypes: MINDMAP_NODE_TYPE_ENUM,
15705              fallbackNodeType: "feature",
15706              normalizeNodeType: normalizeMindmapNodeType,
15707              allowedEdgeKinds: MINDMAP_EDGE_KIND_ENUM,
15708              fallbackEdgeKind: "supports",
15709              normalizeEdgeKind: normalizeMindmapEdgeKind,
15710            });
15711          }
15712
15713          const seen = new Set(["root"]);
15714          const allowedTypes = new Set(Object.keys(MINDMAP_TYPE_LABEL));
15715          const allowedEdgeKinds = new Set([
15716            "part_of",
15717            "depends_on",
15718            "blocks",
15719            "supports",
15720            "contradicts",
15721            "measured_by",
15722          ]);
15723          const parentById = new Map([["root", null]]);
15724          for (const n of parsed.nodes) {
15725            if (!n.id || typeof n.id !== "string")
15726              throw new Error("Node missing id");
15727            if (seen.has(n.id)) throw new Error(`Duplicate node id: ${n.id}`);
15728            seen.add(n.id);
15729            parentById.set(n.id, n.parent);
15730            if (!n.label || typeof n.label !== "string")
15731              throw new Error(`Node ${n.id} missing label`);
15732            n.type = normalizeMindmapNodeType(n.type);
15733            if (!allowedTypes.has(n.type))
15734              throw new Error(`Node ${n.id} has invalid type: ${n.type}`);
15735            if (!n.parent || typeof n.parent !== "string") n.parent = "root";
15736            if (typeof n.summary !== "string") n.summary = "";
15737            if (typeof n.sourceAnchor !== "string" && n.sourceAnchor !== null)
15738              n.sourceAnchor = null;
15739          }
15740          // Every non-root parent must resolve to a known id.
15741          for (const n of parsed.nodes) {
15742            if (n.parent !== "root" && !seen.has(n.parent)) {
15743              throw new Error(
15744                `Node ${n.id} references unknown parent ${n.parent}`,
15745              );
15746            }
15747          }
15748          if (!Array.isArray(parsed.edges)) parsed.edges = [];
15749          for (const edge of parsed.edges) {
15750            if (!edge || typeof edge !== "object")
15751              throw new Error("Edge must be an object");
15752            if (!seen.has(edge.from))
15753              throw new Error(`Edge references unknown node ${edge.from}`);
15754            if (!seen.has(edge.to))
15755              throw new Error(`Edge references unknown node ${edge.to}`);
15756            edge.kind = normalizeMindmapEdgeKind(edge.kind);
15757            if (!allowedEdgeKinds.has(edge.kind))
15758              throw new Error(`Edge has invalid kind: ${edge.kind}`);
15759          }
15760
15761          const visitState = new Map();
15762          const depthById = new Map([["root", 0]]);
15763          function resolveDepth(id) {
15764            if (id === "root") return 0;
15765            const state = visitState.get(id);
15766            if (state === "visiting") {
15767              throw new Error(`Cycle detected in parent chain at ${id}`);
15768            }
15769            if (state === "done") return depthById.get(id);
15770
15771            visitState.set(id, "visiting");
15772            const parentId = parentById.get(id);
15773            if (!parentById.has(parentId)) {
15774              throw new Error(`Node ${id} is not rooted under root`);
15775            }
15776
15777            const depth = resolveDepth(parentId) + 1;
15778            if (depth > 3) {
15779              throw new Error(`Node ${id}
15779 exceeds supported depth`);
15780            }
15781            depthById.set(id, depth);
15782            visitState.set(id, "done");
15783            return depth;
15784          }
15785
15786          for (const n of parsed.nodes) {
15787            resolveDepth(n.id);
15788          }
15789          return parsed;
15790        }
15791
15792        const els = {
15793          brandName: document.getElementById("brandName"),
15794          brandDirty: document.getElementById("brandDirty"),
15795          editor: document.getElementById("editor"),
15796          preview: document.getElementById("preview"),
15797          readerConnections: document.getElementById("readerConnections"),
15798          newDocumentBtn: document.getElementById("newDocumentBtn"),
15799          openFileBtn: document.getElementById("openFileBtn"),
15800          saveBtn: document.getElementById("saveBtn"),
15801          exportBtn: document.getElementById("exportBtn"),
15802          exportMenu: document.getElementById("exportMenu"),
15803          exportPdfBtn: document.getElementById("exportPdfBtn"),
15804          exportMarkdownBtn: document.getElementById("exportMarkdownBtn"),
15805          shareBtn: document.getElementById("shareBtn"),
15806          shareBtnLabel: document.getElementById("shareBtnLabel"),
15807          modeToggleBtn: document.getElementById("modeToggleBtn"),
15808          libraryToggleBtn: document.getElementById("libraryToggleBtn"),
15809          reloadBtn: document.getElementById("reloadBtn"),
15810          autoRefreshToggle: document.getElementById("autoRefreshToggle"),
15811          clearBtn: document.getElementById("clearBtn"),
15812          readingToggleBtn: document.getElementById("readingToggleBtn"),
15813          readingPanel: document.getElementById("readingPanel"),
15814          readingPresetGroup: document.getElementById("readingPresetGroup"),
15815          readingPresetButtons: Array.from(
15816            document.querySelectorAll(".reading-preset"),
15817          ),
15818          readingPaletteGroup: document.getElementById("readingPaletteGroup"),
15819          readingPaletteButtons: Array.from(
15820            document.querySelectorAll(".reading-palette"),
15821          ),
15822          readingPresetCustom: document.getElementById("readingPresetCustom"),
15823          readingFontSizeInput: document.getElementById("readingFontSize"),
15824          readingFontSizeValue: document.getElementById("readingFontSizeValue"),
15825          readingLineHeightInput: document.getElementById("readingLineHeight"),
15826          readingLineHeightValue: document.getElementById(
15827            "readingLineHeightValue",
15828          ),
15829          readingMeasureInput: document.getElementById("readingMeasure"),
15830          readingMeasureValue: document.getElementById("readingMeasureValue"),
15831          readingResetBtn: document.getElementById("readingResetBtn"),
15832          readingCloseBtn: document.getElementById("readingCloseBtn"),
15833          status: document.getElementById("status"),
15834          wordCount: document.getElementById("wordCount"),
15835          liveRegion: document.getElementById("liveRegion"),
15836          appToast: document.getElementById("appToast"),
15837          appToastEyebrow: document.getElementById("appToastEyebrow"),
15838          appToastTitle: document.getElementById("appToastTitle"),
15839          appToastMessage: document.getElementById("appToastMessage"),
15840          appToastDismiss: document.getElementById("appToastDismiss"),
15841          fileInput: document.getElementById("fileInput"),
15842          libraryFolderInput: document.getElementById("libraryFolderInput"),
15843          previewPane: document.querySelector(".preview-pane"),
15844          selectionBubble: document.getElementById("selectionBubble"),
15845          selectionCopyBtn: document.getElementById("selectionCopyBtn"),
15846          selectionHighlightBtn: document.getElementById(
15847            "selectionHighlightBtn",
15848          ),
15849          selectionAskBtn: document.getElementById("selectionAskBtn"),
15850          selectionSummarizeBtn: document.getElementById(
15851            "selectionSummarizeBtn",
15852          ),
15853          highlightRemoveChip: document.getElementById("highlightRemoveChip"),
15854          highlightUndoToast: document.getElementById("highlightUndoToast"),
15855          highlightUndoMessage: document.getElementById("highlightUndoMessage"),
15856          highlightUndoBtn: document.getElementById("highlightUndoBtn"),
15857          ctxMenu: document.getElementById("ctxMenu"),
15858          ctxHighlight: document.getElementById("ctxHighlight"),
15859          ctxAsk: document.getElementById("ctxAsk"),
15860          ctxSummarize: document.getElementById("ctxSummarize"),
15861          mermaidViewer: document.getElementById("mermaidViewer"),
15862          mermaidViewerStage: document.getElementById("mermaidViewerStage"),
15863          mermaidViewerZoomable: document.getElementById(
15864            "mermaidViewerZoomable",
15865          ),
15866          mermaidViewerZoomInBtn: document.getElementById(
15867            "mermaidViewerZoomInBtn",
15868          ),
15869          mermaidViewerZoomOutBtn: document.getElementById(
15870            "mermaidViewerZoomOutBtn",
15871          ),
15872          mermaidViewerResetBtn: document.getElementById(
15873            "mermaidViewerResetBtn",
15874          ),
15875          mermaidViewerFitBtn: document.getElementById("mermaidViewerFitBtn"),
15876          mermaidViewerDownloadBtn: document.getElementById(
15877            "mermaidViewerDownloadBtn",
15878          ),
15879          mermaidViewerCloseBtn: document.getElementById(
15880            "mermaidViewerCloseBtn",
15881          ),
15882          mermaidViewerZoomLabel: document.getElementById(
15883            "mermaidViewerZoomLabel",
15884          ),
15885          mermaidViewerMeta: document.getElementById("mermaidViewerMeta"),
15886          libraryOverlay: document.getElementById("libraryOverlay"),
15887          libraryDrawer: document.getElementById("libraryDrawer"),
15888          libraryAddBtn: document.getElementById("libraryAddBtn"),
15889          libraryToggleAllBtn: document.getElementById("libraryToggleAllBtn"),
15890          libraryCloseBtn: document.getElementById("libraryCloseBtn"),
15891          libraryBody: document.getElementById("libraryBody"),
15892          libraryEmpty: document.getElementById("libraryEmpty"),
15893          librarySupportedNote: document.getElementById("librarySupportedNote"),
15894        };
15895        let libraryUiWired = false;
15896
15897        function syncLibraryRefs() {
15898          els.libraryOverlay =
15899            els.libraryOverlay || document.getElementById("libraryOverlay");
15900          els.libraryDrawer =
15901            els.libraryDrawer || document.getElementById("libraryDrawer");
15902          els.openFileBtn =
15903            els.openFileBtn || document.getElementById("openFileBtn");
15904          els.libraryAddBtn =
15905            els.libraryAddBtn || document.getElementById("libraryAddBtn");
15906          els.libraryToggleAllBtn =
15907            els.libraryToggleAllBtn ||
15908            document.getElementById("libraryToggleAllBtn");
15909          els.libraryCloseBtn =
15910            els.libraryCloseBtn || document.getElementById("libraryCloseBtn");
15911          els.libraryBody =
15912            els.libraryBody || document.getElementById("libraryBody");
15913          els.libraryEmpty =
15914            els.libraryEmpty || document.getElementById("libraryEmpty");
15915          els.librarySupportedNote =
15916            els.librarySupportedNote ||
15917            document.getElementById("librarySupportedNote");
15918
15919          return Boolean(
15920            els.libraryOverlay &&
15921            els.libraryDrawer &&
15922            els.openFileBtn &&
15923            els.libraryAddBtn &&
15924            els.libraryToggleAllBtn &&
15925            els.libraryCloseBtn &&
15926            els.libraryBody &&
15927            els.libraryEmpty &&
15928            els.librarySupportedNote,
15929          );
15930        }
15931
15932        const state = {
15933          fileHandle: null,
15934          sourceMode: "editor",
15935          currentName: "",
15936          baseHref: window.location.href,
15937          contentVersion: 0,
15938          lastRenderedContentVersion: -1,
15939          lastRenderedDocumentToken: "",
15940          renderTimer: null,
15941          autoRefreshTimer: null,
15942          autoRefreshInFlight: false,
15943          dirty: false,
15944          dragDepth: 0,
15945          readingSettings: null,
15946          readerPalette: "original",
15947          readerPaletteInitialized: false,
15948          readerPaletteScheme: "light",
15949          highlightDocKey: "",
15950          currentHighlights: [],
15951          resolvedHighlights: [],
15952          highlightTextModel: null,
15953          activeHighlightId: "",
15954          selectionController: null,
15955          selectionBubbleTimer: null,
15956          highlightUndo: null,
15957          highlightUndoTimer: null,
15958          appToastTimer: null,
15959          mermaidApiPromise: null,
15960          beautifulMermaidApiPromise: null,
15961          hljsPromise: null,
15962          katexPromise: null,
15963          previewRenderGeneration: 0,
15964          themeMermaidRenderTimer: null,
15965          themeRefreshToken: 0,
15966          editorSelection: 0,
15967          mermaidViewerOpen: false,
15968          mermaidViewerScale: 1,
15969          mermaidViewerBaseWidth: 1,
15970          mermaidViewerBaseHeight: 1,
15971          mermaidViewerDownloadUrl: "",
15972          mermaidViewerLastFocused: null,
15973          mermaidViewerDrag: null,
15974          hasSessionDraft: false,
15975          untitledDraftDbPromise: null,
15976          untitledDrafts: [],
15977          untitledDraftId: "",
15978          untitledDraftWriteTimer: null,
15979          sharedSnapshotMeta: null,
15980          shareInFlight: false,
15981          sharePendingMayTakeTime: false,
15982          exportInFlight: false,
15983          libraryRestorePending: false,
15984          libraryDbPromise: null,
15985          libraryDrawerLastFocused: null,
15986          libraryFolders: [],
15987          libraryFolderOrder: [],
15988          libraryExpandedPathsByFolder: {},
15989          libraryCollapsedFolderIds: [],
15990          libraryActiveFolderId: "",
15991          libraryActiveFilePath: "",
15992          libraryCurrentFileHandle: null,
15993          libraryCurrentFolderHandle: null,
15994          libraryPendingImportTargetFolderId: "",
15995          libraryPendingImportToken: 0,
15996          libraryIsOpen: false,
15997          libraryNextScanRevision: 0,
15998          highlightMemoryBuckets: {
15999            local: {},
16000            session: {},
16001          },
16002        };
16003        let readingLastFocusedElement = null;
16004
16005        const attrMap = {
16006          "&": "&amp;",
16007          "<": "&lt;",
16008          ">": "&gt;",
16009          '"': "&quot;",
16010          "'": "&#39;",
16011        };
16012
16013        function escapeHtml(value) {
16014          return String(value).replace(/[&<>"']/g, function (char) {
16015            return attrMap[char];
16016          });
16017        }
16018
16019        const READER_CONNECTIONS_MAX_NEIGHBORS = 10;
16020        let readerConnectionsRequestToken = 0;
16021        let readerOutlineHeadings = [];
16022        let readerOutlineRaf = 0;
16023
16024        function hideReaderRail() {
16025          readerOutlineHeadings = [];
16026          if (els.readerConnections) {
16027            els.readerConnections.hidden = true;
16028            els.readerConnections.textContent = "";
16029            els.readerConnections.classList.remove("has-reader-connections");
16030          }
16031          if (els.previewPane) {
16032            els.previewPane.classList.remove("has-reader-rail");
16033          }
16034        }
16035
16036        function getReaderHeadingText(heading) {
16037          if (!heading) return "";
16038          const clone = heading.cloneNode(true);
16039          clone.querySelectorAll(".heading-anchor").forEach(function (anchor) {
16040            anchor.remove();
16041          });
16042          return String(clone.textContent || "")
16043            .replace(/\s+/g, " ")
16044            .replace(/^#\s*/, "")
16045            .trim();
16046        }
16047
16048        function collectReaderOutlineItems() {
16049          if (!els.preview) return [];
16050          let headings = Array.from(
16051            els.preview.querySelectorAll(":scope > :is(h2, h3, h4)[id]"),
16052          );
16053          if (!headings.length) {
16054            headings = Array.from(
16055              els.preview.querySelectorAll(":scope > :is(h1, h2, h3)[id]"),
16056            );
16057          }
16058          return headings
16059            .map(function (heading) {
16060              return {
16061                id: heading.id,
16062                text: getReaderHeadingText(heading),
16063                level: Number(heading.tagName.slice(1)) || 2,
16064              };
16065            })
16066            .filter(function (item) {
16067              return item.id && item.text;
16068            });
16069        }
16070
16071        function setReaderOutlineActive(id) {
16072          if (!els.readerConnections) return;
16073          const links = Array.from(
16074            els.readerConnections.querySelectorAll("[data-reader-outline-id]"),
16075          );
16076          let activeText = "";
16077          let activeLink = null;
16078          links.forEach(function (link) {
16079            const isActive = link.getAttribute("data-reader-outline-id") === id;
16080            if (isActive) {
16081              link.setAttribute("aria-current", "true");
16082              activeText = link.textContent.trim();
16083              activeLink = link;
16084            } else {
16085              link.removeAttribute("aria-current");
16086            }
16087          });
16088          const summary = els.readerConnections.querySelector(
16089            "[data-reader-outline-summary]",
16090          );
16091          if (summary) {
16092            const count = links.length;
16093            const countText = count === 1 ? "1 section" : count + " sections";
16094            summary.textContent = activeText
16095              ? activeText + " · " + countText
16096              : countText;
16097          }
16098          scrollReaderOutlineItemIntoView(activeLink);
16099        }
16100
16101        function scrollReaderOutlineItemIntoView(link) {
16102          if (!link) return;
16103          const list = link.closest(".reader-outline");
16104          if (!list || list.scrollHeight <= list.clientHeight + 1) return;
16105          const linkTop = link.offsetTop;
16106          const linkBottom = linkTop + link.offsetHeight;
16107          const viewTop = list.scrollTop;
16108          const viewBottom = viewTop + list.clientHeight;
16109          const buffer = 18;
16110
16111          if (linkTop < viewTop + buffer) {
16112            list.scrollTop = Math.max(0, linkTop - buffer);
16113          } else if (linkBottom > viewBottom - buffer) {
16114            list.scrollTop = linkBottom - list.clientHeight + buffer;
16115          }
16116        }
16117
16118        function getReaderActiveHeading(headings) {
16119          const list = Array.from(headings || []).filter(function (heading) {
16120            return heading && document.contains(heading);
16121          });
16122          if (!list.length) return null;
16123          const toolbarHeight =
16124            Number.parseFloat(
16125              getComputedStyle(document.documentElement).getPropertyValue(
16126                "--toolbar-h",
16127              ),
16128            ) || 52;
16129          const threshold = Math.max(
16130            toolbarHeight + 48,
16131            window.innerHeight * 0.42,
16132          );
16133          let active = list[0];
16134          for (const heading of list) {
16135            if (heading.getBoundingClientRect().top <= threshold) {
16136              active = heading;
16137            } else {
16138              break;
16139            }
16140          }
16141          return active;
16142        }
16143
16144        function updateReaderOutlineActive() {
16145          readerOutlineRaf = 0;
16146          if (
16147            !els.readerConnections ||
16148            els.readerConnections.hidden ||
16149            !readerOutlineHeadings.length
16150          ) {
16151            return;
16152          }
16153          readerOutlineHeadings = readerOutlineHeadings.filter(
16154            function (heading) {
16155              return heading && document.contains(heading);
16156            },
16157          );
16158          if (!readerOutlineHeadings.length) return;
16159          const active = getReaderActiveHeading(readerOutlineHeadings);
16160          if (active) setReaderOutlineActive(active.id);
16161        }
16162
16163        function scheduleReaderOutlineActiveUpdate() {
16164          if (readerOutlineRaf) return;
16165          readerOutlineRaf = window.requestAnimationFrame(
16166            updateReaderOutlineActive,
16167          );
16168        }
16169
16170        function renderReaderOutlineSection(items) {
16171          if (!items.length) return "";
16172          const countText =
16173            items.length === 1 ? "1 section" : items.length + " sections";
16174          const list = items
16175            .map(function (item, index) {
16176              const level = Math.min(4, Math.max(2, item.level || 2));
16177              const text = String(item.text || "");
16178              return (
16179                '<li class="reader-outline__item">' +
16180                '<a class="reader-outline__link" href="#' +
16181                encodeURIComponent(item.id) +
16182                '" data-reader-outline-id="' +
16183                escapeHtml(item.id) +
16184                '" data-level="' +
16185                escapeHtml(String(level)) +
16186                '" title="' +
16187                escapeHtml(text) +
16188                '"' +
16189                (index === 0 ? ' aria-current="true"' : "") +
16190                ">" +
16191                '<span class="reader-outline__text">' +
16192                escapeHtml(text) +
16193                "</span>" +
16194                "</a></li>"
16195              );
16196            })
16197            .join("");
16198
16199          return (
16200            '<section class="reader-rail__section reader-outline-section" data-reader-outline-section aria-label="On this page">' +
16201            '<div class="reader-rail__header">' +
16202            '<h2 id="readerOutlineTitle" class="reader-rail__title">On this page</h2>' +
16203            "</div>" +
16204            '<button class="reader-outline-toggle" type="button" data-reader-outline-toggle aria-expanded="false" aria-controls="readerOutlineList">' +
16205            "<span>" +
16206            '<span class="reader-outline-toggle__kicker">On this page</span>' +
16207            '<span class="reader-outline-toggle__summary" data-reader-outline-summary>' +
16208            escapeHtml(countText) +
16209            "</span>" +
16210            "</span>" +
16211            '<span class="reader-outline-toggle__chevron" aria-hidden="true"></span>' +
16212            "</button>" +
16213            '<ol id="readerOutlineList" class="reader-outline">' +
16214            list +
16215            "</ol></section>"
16216          );
16217        }
16218
16219        function clipReaderConnectionLabel(value, maxChars) {
16220          const text = String(value || "")
16221            .replace(/\s+/g, " ")
16222            .trim();
16223          if (!text || text.length <= maxChars) return text;
16224          return text.slice(0, Math.max(0, maxChars - 1)).trim() + "…";
16225        }
16226
16227        function getReaderConnectionLabel(item) {
16228          if (!item) return "Connection";
16229          if (item.related) return "Related concept";
16230          if (item.direction === "inbound") {
16231            const count = Number(item.backlinkCount) || 1;
16232            return count === 1 ? "Backlink" : count + " backlinks";
16233          }
16234          if (item.direction === "bidirectional") return "Two-way link";
16235          return "Linked page";
16236        }
16237
16238        function canOpenReaderConnection(path) {
16239          if (state.sourceMode !== "library" || !state.libraryActiveFolderId) {
16240            return false;
16241          }
16242          const folder = getLibraryFolderById(state.libraryActiveFolderId);
16243          return Boolean(
16244            folder &&
16245            folder.fileHandlesByPath &&
16246            typeof folder.fileHandlesByPath.has === "function" &&
16247            folder.fileHandlesByPath.has(path),
16248          );
16249        }
16250
16251        function renderReaderConnectionsGraph(model) {
16252          const nodes = model.neighbors || [];
16253          const count = nodes.length;
16254          const centerX = 110;
16255          const centerY = 76;
16256          const layoutNodes =
16257            typeof libraryCore.layoutReaderConnectionGraph === "function"
16258              ? libraryCore.layoutReaderConnectionGraph(nodes, {
16259                  width: 220,
16260                  height: 150,
16261                  centerX: centerX,
16262                  centerY: centerY,
16263                })
16264              : nodes.map(function (item, index) {
16265                  const radius = count <= 4 ? 42 : 53;
16266                  const angle =
16267                    -Math.PI / 2 + (index / Math.max(1, count)) * Math.PI * 2;
16268                  return {
16269                    item: item,
16270                    index: index,
16271                    radius: item && item.related ? 5.5 : 4.4,
16272                    x: centerX + Math.cos(angle) * radius,
16273                    y: centerY + Math.sin(angle) * radius * 0.82,
16274                  };
16275                });
16276          const edgeParts = [];
16277          const nodeParts = [];
16278
16279          layoutNodes.forEach(function (layoutNode) {
16280            const item = layoutNode.item || nodes[layoutNode.index] || {};
16281            const x = Number(layoutNode.x) || centerX;
16282            const y = Number(layoutNode.y) || centerY;
16283            const nodeRadius =
16284              Number(layoutNode.radius) || (item.related ? 5.5 : 4.4);
16285            const openable = canOpenReaderConnection(item.path);
16286            const itemTitle = item.title || item.path || "Connection";
16287            const pointLabel = clipReaderConnectionLabel(itemTitle, 30);
16288            edgeParts.push(
16289              '<line class="reader-connections__edge" data-direction="' +
16290                escapeHtml(item.direction || "related") +
16291                '" x1="' +
16292                centerX +
16293                '" y1="' +
16294                centerY +
16295                '" x2="' +
16296                x.toFixed(1) +
16297                '" y2="' +
16298                y.toFixed(1) +
16299                '"></line>',
16300            );
16301            nodeParts.push(
16302              '<g class="reader-connections__point" data-reader-graph-node data-openable="' +
16303                (openable ? "true" : "false") +
16304                '" data-x="' +
16305                x.toFixed(1) +
16306                '" data-y="' +
16307                y.toFixed(1) +
16308                '" data-reader-label="' +
16309                escapeHtml(pointLabel) +
16310                '"' +
16311                (openable
16312                  ? ' role="link" tabindex="0" data-reader-path="' +
16313                    escapeHtml(item.path) +
16314                    '" aria-label="Open ' +
16315                    escapeHtml(itemTitle) +
16316                    '"'
16317                  : ' aria-label="' + escapeHtml(itemTitle) + '"') +
16318                ">" +
16319                '<circle class="reader-connections__node" data-direction="' +
16320                escapeHtml(item.direction || "related") +
16321                '" data-related="' +
16322                (item.related ? "true" : "false") +
16323                '" cx="' +
16324                x.toFixed(1) +
16325                '" cy="' +
16326                y.toFixed(1) +
16327                '" r="' +
16328                nodeRadius +
16329                '"></circle></g>',
16330            );
16331          });
16332
16333          const label = clipReaderConnectionLabel(model.current.title, 24);
16334          return (
16335            '<svg class="reader-connections__graph" viewBox="0 0 220 150" role="img" aria-label="Connections around the current document" data-reader-root-label="' +
16336            escapeHtml(label) +
16337            '">' +
16338            '<g aria-hidden="true" class="reader-connections__edges">' +
16339            edgeParts.join("") +
16340            "</g>" +
16341            '<g aria-hidden="true" class="reader-connections__root-group">' +
16342            '<circle class="reader-connections__root" cx="' +
16343            centerX +
16344            '" cy="' +
16345            centerY +
16346            '" r="7"></circle>' +
16347            '<text class="reader-connections__root-label" x="' +
16348            centerX +
16349            '" y="96" text-anchor="middle">' +
16350            escapeHtml(label) +
16351            "</text>" +
16352            "</g>" +
16353            '<g class="reader-connections__points">' +
16354            nodeParts.join("") +
16355            "</g></svg>"
16356          );
16357        }
16358
16359        function renderReaderConnectionsSection(model) {
16360          if (!model) return "";
16361          const stats = model.stats || {};
16362          const categoryText = (model.current.categories || [])
16363            .slice(0, 2)
16364            .join(", ");
16365          const list = (model.neighbors || [])
16366            .slice(0, 4)
16367            .map(function (item) {
16368              const openable = canOpenReaderConnection(item.path);
16369              const tag = openable ? "button" : "div";
16370              const attrs = openable
16371                ? ' type="button" data-reader-path="' +
16372                  escapeHtml(item.path) +
16373                  '"'
16374                : "";
16375              return (
16376                "<" +
16377                tag +
16378                ' class="reader-connection"' +
16379                attrs +
16380                ">" +
16381                '<span class="reader-connection__dot" data-direction="' +
16382                escapeHtml(item.direction || "related") +
16383                '"></span>' +
16384                "<span>" +
16385                '<span class="reader-connection__title">' +
16386                escapeHtml(item.title || item.path || "Connection") +
16387                "</span>" +
16388                '<span class="reader-connection__detail">' +
16389                escapeHtml(getReaderConnectionLabel(item)) +
16390                (item.summary
16391                  ? " · " +
16392                    escapeHtml(clipReaderConnectionLabel(item.summary, 56))
16393                  : "") +
16394                "</span>" +
16395                "</span>" +
16396                "</" +
16397                tag +
16398                ">"
16399              );
16400            })
16401            .join("");
16402
16403          const more =
16404            stats.hiddenCount > 0
16405              ? '<p class="reader-connections__more">+' +
16406                escapeHtml(String(stats.hiddenCount)) +
16407                " more local connection" +
16408                (stats.hiddenCount === 1 ? "" : "s") +
16409                "</p>"
16410              : "";
16411
16412          return (
16413            '<section class="reader-rail__section reader-connections" aria-labelledby="readerConnectionsTitle">' +
16414            '<div class="reader-connections__header">' +
16415            '<h2 id="readerConnectionsTitle" class="reader-connections__title">Connections</h2>' +
16416            "</div>" +
16417            '<div class="reader-connections__panel">' +
16418            renderReaderConnectionsGraph(model) +
16419            '<div class="reader-connections__meta" aria-label="Connection summary">' +
16420            '<span class="reader-connections__stat" data-empty="' +
16421            (stats.outboundCount ? "false" : "true") +
16422            '"><strong>' +
16423            escapeHtml(String(stats.outboundCount || 0)) +
16424            "</strong><span>links</span></span>" +
16425            '<span class="reader-connections__stat" data-empty="' +
16426            (stats.backlinkCount ? "false" : "true") +
16427            '"><strong>' +
16428            escapeHtml(String(stats.backlinkCount || 0)) +
16429            "</strong><span>backlinks</span></span>" +
16430            '<span class="reader-connections__stat" data-empty="' +
16431            (stats.relatedCount ? "false" : "true") +
16432            '"><strong>' +
16433            escapeHtml(String(stats.relatedCount || 0)) +
16434            "</strong><span>related</span></span>" +
16435            "</div></div>" +
16436            (categoryText
16437              ? '<p class="reader-connections__more">Tags: ' +
16438                escapeHtml(categoryText) +
16439                "</p>"
16440              : "") +
16441            '<div class="reader-connections__list">' +
16442            list +
16443            "</div>" +
16444            more +
16445            "</section>"
16446          );
16447        }
16448
16449        function renderReaderRail(outlineItems, connectionsModel) {
16450          if (!els.readerConnections || !els.previewPane) return;
16451          const outline = Array.isArray(outlineItems) ? outlineItems : [];
16452          const connectionsHtml =
16453            renderReaderConnectionsSection(connectionsModel);
16454          const hasConnections = Boolean(connectionsHtml);
16455          const html = renderReaderOutlineSection(outline) + connectionsHtml;
16456          if (!html) {
16457            hideReaderRail();
16458            return;
16459          }
16460          readerOutlineHeadings = outline
16461            .map(function (item) {
16462              return els.preview.querySelector("#" + CSS.escape(item.id));
16463            })
16464            .filter(Boolean);
16465          els.readerConnections.innerHTML = html;
16466          els.readerConnections.hidden = false;
16467          els.readerConnections.classList.toggle(
16468            "has-reader-connections",
16469            hasConnections,
16470          );
16471          els.previewPane.classList.add("has-reader-rail");
16472          scheduleReaderOutlineActiveUpdate();
16473        }
16474
16475        async function refreshReaderRail(markdown, generation) {
16476          const token = ++readerConnectionsRequestToken;
16477          if (!markdown.trim()) {
16478            hideReaderRail();
16479            return;
16480          }
16481
16482          const outlineItems = collectReaderOutlineItems();
16483          renderReaderRail(outlineItems, null);
16484
16485          if (
16486            state.sourceMode !== "library" ||
16487            !state.libraryActiveFolderId ||
16488            typeof libraryCore.createReaderConnectionsModel !== "function"
16489          ) {
16490            return;
16491          }
16492
16493          try {
16494            const context = await buildActiveLibraryMindmapContext(markdown);
16495            if (
16496              token !== readerConnectionsRequestToken ||
16497              generation !== state.previewRenderGeneration
16498            ) {
16499              return;
16500            }
16501            const model = libraryCore.createReaderConnectionsModel(context, {
16502              maxNeighbors: READER_CONNECTIONS_MAX_NEIGHBORS,
16503            });
16504            renderReaderRail(outlineItems, model);
16505          } catch (error) {
16506            if (
16507              token !== readerConnectionsRequestToken ||
16508              generation !== state.previewRenderGeneration
16509            ) {
16510              return;
16511            }
16512            console.warn("[reader-rail] connections failed", error);
16513            renderReaderRail(outlineItems, null);
16514          }
16515        }
16516
16517        async function openReaderConnectionPath(path) {
16518          if (!path || !canOpenReaderConnection(path)) return false;
16519          await loadFromLibrarySelection(state.libraryActiveFolderId, path, {
16520            userInitiated: true,
16521          });
16522          return true;
16523        }
16524
16525        function resetReaderConnectionsGraphMotion(graph) {
16526          if (!graph) return;
16527          graph
16528            .querySelectorAll("[data-reader-graph-node]")
16529            .forEach(function (node) {
16530              node.style.transform = "";
16531            });
16532        }
16533
16534        function setReaderConnectionsGraphLabel(graph, label) {
16535          if (!graph) return;
16536          const labelEl = graph.querySelector(
16537            ".reader-connections__root-label",
16538          );
16539          if (!labelEl) return;
16540          labelEl.textContent =
16541            label || graph.getAttribute("data-reader-root-label") || "";
16542        }
16543
16544        function updateReaderConnectionsGraphMotion(event, graph) {
16545          if (!graph || prefersReducedMotion()) return;
16546          const rect = graph.getBoundingClientRect();
16547          if (!rect.width || !rect.height) return;
16548          const pointerX = ((event.clientX - rect.left) / rect.width) * 220;
16549          const pointerY = ((event.clientY - rect.top) / rect.height) * 150;
16550          graph
16551            .querySelectorAll("[data-reader-graph-node]")
16552            .forEach(function (node) {
16553              const nodeX = Number(node.getAttribute("data-x")) || 0;
16554              const nodeY = Number(node.getAttribute("data-y")) || 0;
16555              const dx = pointerX - nodeX;
16556              const dy = pointerY - nodeY;
16557              const distance = Math.sqrt(dx * dx + dy * dy);
16558              const influence = Math.max(0, 1 - distance / 82);
16559              const maxShift =
16560                node.getAttribute("data-openable") === "true" ? 5 : 3;
16561              const x = clampNumber(dx * influence * 0.07, -maxShift, maxShift);
16562              const y = clampNumber(dy * influence * 0.07, -maxShift, maxShift);
16563              node.style.transform =
16564                "translate(" + x.toFixed(2) + "px, " + y.toFixed(2) + "px)";
16565            });
16566        }
16567
16568        function wireReaderRail() {
16569          if (!els.readerConnections) return;
16570          els.readerConnections.addEventListener(
16571            "click",
16572            async function (event) {
16573              const outlineToggle = event.target.closest(
16574                "[data-reader-outline-toggle]",
16575              );
16576              if (
16577                outlineToggle &&
16578                els.readerConnections.contains(outlineToggle)
16579              ) {
16580                const section = outlineToggle.closest(
16581                  "[data-reader-outline-section]",
16582                );
16583                if (!section) return;
16584                const isOpen = !section.classList.contains("is-open");
16585                section.classList.toggle("is-open", isOpen);
16586                outlineToggle.setAttribute(
16587                  "aria-expanded",
16588                  isOpen ? "true" : "false",
16589                );
16590                return;
16591              }
16592              const outlineLink = event.target.closest(
16593                "[data-reader-outline-id]",
16594              );
16595              if (outlineLink && els.readerConnections.contains(outlineLink)) {
16596                event.preventDefault();
16597                const id =
16598                  outlineLink.getAttribute("data-reader-outline-id") || "";
16599                const target = id
16600                  ? els.preview.querySelector("#" + CSS.escape(id))
16601                  : null;
16602                if (target) {
16603                  target.scrollIntoView({
16604                    behavior: prefersReducedMotion() ? "auto" : "smooth",
16605                    block: "start",
16606                  });
16607                  setReaderOutlineActive(id);
16608                }
16609                if (
16610                  window.matchMedia &&
16611                  window.matchMedia("(max-width: 720px)").matches
16612                ) {
16613                  const section = outlineLink.closest(
16614                    "[data-reader-outline-section]",
16615                  );
16616                  const toggle = section
16617                    ? section.querySelector("[data-reader-outline-toggle]")
16618                    : null;
16619                  if (section && toggle) {
16620                    section.classList.remove("is-open");
16621                    toggle.setAttribute("aria-expanded", "false");
16622                  }
16623                }
16624                return;
16625              }
16626              const trigger = event.target.closest("[data-reader-path]");
16627              if (!trigger || !els.readerConnections.contains(trigger)) return;
16628              const path = trigger.getAttribute("data-reader-path") || "";
16629              await openReaderConnectionPath(path);
16630            },
16631          );
16632          els.readerConnections.addEventListener(
16633            "keydown",
16634            async function (event) {
16635              if (event.key !== "Enter" && event.key !== " ") return;
16636              const trigger = event.target.closest("[data-reader-path]");
16637              if (!trigger || !els.readerConnections.contains(trigger)) return;
16638              event.preventDefault();
16639              const path = trigger.getAttribute("data-reader-path") || "";
16640              await openReaderConnectionPath(path);
16641            },
16642          );
16643          els.readerConnections.addEventListener(
16644            "pointermove",
16645            function (event) {
16646              const graph = event.target.closest(".reader-connections__graph");
16647              if (!graph || !els.readerConnections.contains(graph)) return;
16648              const point = event.target.closest("[data-reader-label]");
16649              setReaderConnectionsGraphLabel(
16650                graph,
16651                point && graph.contains(point)
16652                  ? point.getAttribute("data-reader-label")
16653                  : "",
16654              );
16655              updateReaderConnectionsGraphMotion(event, graph);
16656            },
16657          );
16658          els.readerConnections.addEventListener(
16659            "pointerout",
16660            function (event) {
16661              const graph = event.target.closest(".reader-connections__graph");
16662              if (
16663                !graph ||
16664                !els.readerConnections.contains(graph) ||
16665                graph.contains(event.relatedTarget)
16666              ) {
16667                return;
16668              }
16669              resetReaderConnectionsGraphMotion(graph);
16670              setReaderConnectionsGraphLabel(graph, "");
16671            },
16672          );
16673          els.readerConnections.addEventListener("focusin", function (event) {
16674            const point = event.target.closest("[data-reader-label]");
16675            if (!point || !els.readerConnections.contains(point)) return;
16676            const graph = point.closest(".reader-connections__graph");
16677            setReaderConnectionsGraphLabel(
16678              graph,
16679              point.getAttribute("data-reader-label"),
16680            );
16681          });
16682          els.readerConnections.addEventListener("focusout", function (event) {
16683            const graph = event.target.closest(".reader-connections__graph");
16684            if (
16685              !graph ||
16686              !els.readerConnections.contains(graph) ||
16687              graph.contains(event.relatedTarget)
16688            ) {
16689              return;
16690            }
16691            setReaderConnectionsGraphLabel(graph, "");
16692          });
16693          window.addEventListener("scroll", scheduleReaderOutlineActiveUpdate, {
16694            passive: true,
16695          });
16696          window.addEventListener("resize", scheduleReaderOutlineActiveUpdate, {
16697            passive: true,
16698          });
16699        }
16700
16701        function markEditorContentChanged() {
16702          state.contentVersion += 1;
16703          clearSelectionController();
16704          state.highlightTextModel = null;
16705        }
16706
16707        function setEditorMarkdown(markdown) {
16708          const next = String(markdown || "");
16709          if (els.editor.value !== next) {
16710            els.editor.value = next;
16711            markEditorContentChanged();
16712            return;
16713          }
16714          els.editor.value = next;
16715        }
16716
16717        function invalidateLastRenderedDocument() {
16718          state.lastRenderedContentVersion = -1;
16719          state.lastRenderedDocumentToken = "";
16720        }
16721
16722        function readStorage(storage, key, fallback) {
16723          try {
16724            const value = storage.getItem(key);
16725            return value === null ? fallback : value;
16726          } catch (_error) {
16727            return fallback;
16728          }
16729        }
16730
16731        function writeStorage(storage, key, value) {
16732          try {
16733            storage.setItem(key, value);
16734            return true;
16735          } catch (_error) {
16736            return false;
16737          }
16738        }
16739
16740        function removeStorage(storage, key) {
16741          try {
16742            storage.removeItem(key);
16743          } catch (_error) {}
16744        }
16745
16746        // Session-content persistence is a best-effort restore buffer:
16747        // writing the whole editor string on every debounced render
16748        // was hot on the typing path for long docs. Defer to idle time,
16749        // cancelling the previous scheduled write so only the newest
16750        // snapshot lands. `pagehide` flushes synchronously if a write
16751        // is still pending when the tab is closing.
16752        var __pendingSessionWrite = null;
16753        var __latestSessionContent = null;
16754        function __flushSessionContent() {
16755          if (__latestSessionContent == null) return;
16756          writeStorage(
16757            window.sessionStorage,
16758            SESSION_CONTENT_KEY,
16759            __latestSessionContent,
16760          );
16761          __latestSessionContent = null;
16762          __pendingSessionWrite = null;
16763        }
16764        function scheduleSessionContentWrite(markdown) {
16765          __latestSessionContent = markdown;
16766          if (__pendingSessionWrite != null) {
16767            if (window.cancelIdleCallback) {
16768              window.cancelIdleCallback(__pendingSessionWrite);
16769            } else {
16770              clearTimeout(__pendingSessionWrite);
16771            }
16772          }
16773          if (window.requestIdleCallback) {
16774            __pendingSessionWrite = window.requestIdleCallback(
16775              __flushSessionContent,
16776              { timeout: 1500 },
16777            );
16778          } else {
16779            __pendingSessionWrite = setTimeout(__flushSessionContent, 250);
16780          }
16781        }
16782        window.addEventListener("pagehide", __flushSessionContent);
16783
16784        var __pendingUntitledDraftWrites = new Map();
16785        var __pendingUntitledDraftFlush = null;
16786
16787        function canUseUntitledDraftStorage() {
16788          return typeof indexedDB !== "undefined";
16789        }
16790
16791        function createUntitledDraftId() {
16792          return (
16793            "draft_" +
16794            Date.now().toString(36) +
16795            "_" +
16796            Math.random().toString(36).slice(2, 8)
16797          );
16798        }
16799
16800        function normalizeUntitledDraftRecord(raw, fallbackTitle) {
16801          const source = raw && typeof raw === "object" ? raw : {};
16802          const now = Date.now();
16803          const id =
16804            typeof source.id === "string" && source.id
16805              ? source.id
16806              : createUntitledDraftId();
16807          const title =
16808            typeof source.title === "string" && source.title.trim()
16809              ? source.title.trim()
16810              : fallbackTitle || "Untitled 1";
16811          const createdAt = Number(source.createdAt) || now;
16812          const updatedAt = Number(source.updatedAt) || createdAt;
16813          const lastActive = Number(source.lastActive) || updatedAt;
16814          return {
16815            id: id,
16816            title: title,
16817            content: typeof source.content === "string" ? source.content : "",
16818            createdAt: createdAt,
16819            updatedAt: updatedAt,
16820            lastActive: lastActive,
16821            dirty: Boolean(source.dirty),
16822          };
16823        }
16824
16825        function sortUntitledDrafts(list) {
16826          return (Array.isArray(list) ? list.slice() : []).sort(
16827            function (a, b) {
16828              return (
16829                (Number(b.lastActive) || 0) - (Number(a.lastActive) || 0) ||
16830                (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0) ||
16831                String(a.title || "").localeCompare(String(b.title || ""))
16832              );
16833            },
16834          );
16835        }
16836
16837        function upsertUntitledDraftInState(record) {
16838          const normalized = normalizeUntitledDraftRecord(record);
16839          const next = state.untitledDrafts.slice();
16840          const index = next.findIndex(function (draft) {
16841            return draft.id === normalized.id;
16842          });
16843          if (index === -1) {
16844            next.push(normalized);
16845          } else {
16846            next[index] = normalized;
16847          }
16848          state.untitledDrafts = sortUntitledDrafts(next);
16849          return normalized;
16850        }
16851
16852        function getUntitledDraftById(id) {
16853          return (
16854            state.untitledDrafts.find(function (draft) {
16855              return draft.id === id;
16856            }) || null
16857          );
16858        }
16859
16860        function isUntitledDraftMode() {
16861          return (
16862            state.sourceMode === "untitled" && Boolean(state.untitledDraftId)
16863          );
16864        }
16865
16866        function getActiveUntitledDraft() {
16867          return isUntitledDraftMode()
16868            ? getUntitledDraftById(state.untitledDraftId)
16869            : null;
16870        }
16871
16872        function getNextUntitledDraftTitle() {
16873          let max = 0;
16874          state.untitledDrafts.forEach(function (draft) {
16875            const match = /^Untitled\s+(\d+)$/i.exec(String(draft.title || ""));
16876            if (!match) return;
16877            const value = Number(match[1]);
16878            if (Number.isFinite(value)) max = Math.max(max, value);
16879          });
16880          return "Untitled " + (max + 1);
16881        }
16882
16883        function writeUntitledDraftBackup() {
16884          writeStorage(
16885            window.localStorage,
16886            UNTITLED_DRAFT_BACKUP_KEY,
16887            JSON.stringify({
16888              drafts: state.untitledDrafts.map(function (draft) {
16889                return normalizeUntitledDraftRecord(draft);
16890              }),
16891              updatedAt: Date.now(),
16892            }),
16893          );
16894        }
16895
16896        function readUntitledDraftBackup() {
16897          const raw = readStorage(
16898            window.localStorage,
16899            UNTITLED_DRAFT_BACKUP_KEY,
16900            "",
16901          );
16902          if (!raw) return [];
16903          try {
16904            const parsed = JSON.parse(raw);
16905            const list = Array.isArray(parsed)
16906              ? parsed
16907              : Array.isArray(parsed && parsed.drafts)
16908                ? parsed.drafts
16909                : [];
16910            return list.map(function (item, index) {
16911              return normalizeUntitledDraftRecord(
16912                item,
16913                "Untitled " + (index + 1),
16914              );
16915            });
16916          } catch (_error) {
16917            return [];
16918          }
16919        }
16920
16921        function openUntitledDraftDb() {
16922          if (!canUseUntitledDraftStorage()) {
16923            return Promise.reject(new Error("IndexedDB is unavailable"));
16924          }
16925          if (state.untitledDraftDbPromise) return state.untitledDraftDbPromise;
16926          state.untitledDraftDbPromise = new Promise(function (
16927            resolve,
16928            reject,
16929          ) {
16930            const request = indexedDB.open(
16931              UNTITLED_DRAFT_DB_NAME,
16932              UNTITLED_DRAFT_DB_VERSION,
16933            );
16934            request.onupgradeneeded = function () {
16935              const db = request.result;
16936              if (!db.objectStoreNames.contains(UNTITLED_DRAFT_STORE)) {
16937                db.createObjectStore(UNTITLED_DRAFT_STORE, { keyPath: "id" });
16938              }
16939            };
16940            request.onsuccess = function () {
16941              resolve(request.result);
16942            };
16943            request.onerror = function () {
16944              reject(request.error);
16945            };
16946          });
16947          return state.untitledDraftDbPromise;
16948        }
16949
16950        function untitledDraftGetAllFromDb() {
16951          return openUntitledDraftDb().then(function (db) {
16952            return new Promise(function (resolve, reject) {
16953              const tx = db.transaction(UNTITLED_DRAFT_STORE, "readonly");
16954              const request = tx.objectStore(UNTITLED_DRAFT_STORE).getAll();
16955              request.onsuccess = function () {
16956                resolve(request.result || []);
16957              };
16958              request.onerror = function () {
16959                reject(request.error);
16960              };
16961            });
16962          });
16963        }
16964
16965        function untitledDraftPutInDb(record) {
16966          const normalized = normalizeUntitledDraftRecord(record);
16967          return openUntitledDraftDb().then(function (db) {
16968            return new Promise(function (resolve, reject) {
16969              const tx = db.transaction(UNTITLED_DRAFT_STORE, "readwrite");
16970              tx.objectStore(UNTITLED_DRAFT_STORE).put(normalized);
16971              tx.oncomplete = function () {
16972                resolve(normalized);
16973              };
16974              tx.onerror = function () {
16975                reject(tx.error);
16976              };
16977            });
16978          });
16979        }
16980
16981        function untitledDraftDeleteFromDb(id) {
16982          if (!canUseUntitledDraftStorage()) return Promise.resolve();
16983          return openUntitledDraftDb().then(function (db) {
16984            return new Promise(function (resolve, reject) {
16985              const tx = db.transaction(UNTITLED_DRAFT_STORE, "readwrite");
16986              tx.objectStore(UNTITLED_DRAFT_STORE).delete(id);
16987              tx.oncomplete = function () {
16988                resolve();
16989              };
16990              tx.onerror = function () {
16991                reject(tx.error);
16992              };
16993            });
16994          });
16995        }
16996
16997        function mergeUntitledDraftLists(primary, backup) {
16998          const byId = new Map();
16999          (Array.isArray(primary) ? primary : []).forEach(
17000            function (item, index) {
17001              const draft = normalizeUntitledDraftRecord(
17002                item,
17003                "Untitled " + (index + 1),
17004              );
17005              byId.set(draft.id, draft);
17006            },
17007          );
17008          (Array.isArray(backup) ? backup : []).forEach(function (item, index) {
17009            const draft = normalizeUntitledDraftRecord(
17010              item,
17011              "Untitled " + (index + 1),
17012            );
17013            const existing = byId.get(draft.id);
17014            if (!existing || draft.updatedAt > existing.updatedAt) {
17015              byId.set(draft.id, draft);
17016            }
17017          });
17018          return sortUntitledDrafts(Array.from(byId.values()));
17019        }
17020
17021        async function restoreUntitledDraftsFromStorage(sessionMeta) {
17022          const backupDrafts = readUntitledDraftBackup();
17023          let storedDrafts = [];
17024          if (canUseUntitledDraftStorage()) {
17025            try {
17026              storedDrafts = await untitledDraftGetAllFromDb();
17027            } catch (error) {
17028              console.warn("[drafts] restore failed", error);
17029            }
17030          }
17031
17032          state.untitledDrafts = mergeUntitledDraftLists(
17033            storedDrafts,
17034            backupDrafts,
17035          );
17036
17037          if (state.untitledDrafts.length) {
17038            const preferredId =
17039              sessionMeta &&
17040              typeof sessionMeta.untitledDraftId === "string" &&
17041              sessionMeta.untitledDraftId
17042                ? sessionMeta.untitledDraftId
17043                : "";
17044            state.untitledDraftId = getUntitledDraftById(preferredId)
17045              ? preferredId
17046              : state.untitledDrafts[0].id;
17047            writeUntitledDraftBackup();
17048          } else {
17049            state.untitledDraftId = "";
17050          }
17051
17052          renderLibraryDrawer();
17053          return state.untitledDrafts.length > 0;
17054        }
17055
17056        function scheduleUntitledDraftWrite(record) {
17057          const normalized = normalizeUntitledDraftRecord(record);
17058          __pendingUntitledDraftWrites.set(normalized.id, normalized);
17059          writeUntitledDraftBackup();
17060          if (__pendingUntitledDraftFlush != null) {
17061            window.clearTimeout(__pendingUntitledDraftFlush);
17062          }
17063          __pendingUntitledDraftFlush = window.setTimeout(function () {
17064            flushUntitledDraftWrites().catch(function (error) {
17065              console.warn("[drafts] persist failed", error);
17066            });
17067          }, 180);
17068        }
17069
17070        function flushUntitledDraftWrites() {
17071          if (__pendingUntitledDraftFlush != null) {
17072            window.clearTimeout(__pendingUntitledDraftFlush);
17073            __pendingUntitledDraftFlush = null;
17074          }
17075          if (!__pendingUntitledDraftWrites.size) {
17076            return Promise.resolve(false);
17077          }
17078
17079          const records = Array.from(__pendingUntitledDraftWrites.values());
17080          __pendingUntitledDraftWrites.clear();
17081          writeUntitledDraftBackup();
17082          if (!canUseUntitledDraftStorage()) {
17083            return Promise.resolve(false);
17084          }
17085
17086          return Promise.all(
17087            records.map(function (record) {
17088              return untitledDraftPutInDb(record);
17089            }),
17090          ).then(function () {
17091            return true;
17092          });
17093        }
17094
17095        function captureActiveUntitledDraft(options) {
17096          options = options || {};
17097          const draft = getActiveUntitledDraft();
17098          if (!draft) return null;
17099
17100          const now = Date.now();
17101          const next = upsertUntitledDraftInState(
17102            Object.assign({}, draft, {
17103              content: els.editor.value || "",
17104              updatedAt: now,
17105              lastActive:
17106                options.touchLastActive === false ? draft.lastActive : now,
17107              dirty:
17108                options.dirty == null
17109                  ? Boolean(state.dirty)
17110                  : Boolean(options.dirty),
17111            }),
17112          );
17113          scheduleUntitledDraftWrite(next);
17114          renderLibraryDrawer();
17115          return next;
17116        }
17117
17118        function flushActiveUntitledDraft() {
17119          captureActiveUntitledDraft({ touchLastActive: true });
17120          return flushUntitledDraftWrites();
17121        }
17122
17123        window.addEventListener("pagehide", function () {
17124          if (isUntitledDraftMode()) {
17125            captureActiveUntitledDraft({ touchLastActive: true });
17126          }
17127          flushUntitledDraftWrites().catch(function () {});
17128        });
17129
17130        function canUsePersistentLibrary() {
17131          return Boolean(window.showDirectoryPicker && window.isSecureContext);
17132        }
17133
17134        function canUseLibraryStorage() {
17135          return typeof indexedDB !== "undefined";
17136        }
17137
17138        function canUseLibraryFolderImport() {
17139          const input =
17140            els.libraryFolderInput || document.createElement("input");
17141          return Boolean(input && "webkitdirectory" in input);
17142        }
17143
17144        function canUseAnyLibrary() {
17145          return canUsePersistentLibrary() || canUseLibraryFolderImport();
17146        }
17147
17148        async function persistLibraryFolderBestEffort(record, label) {
17149          if (!canUseLibraryStorage()) return false;
17150          try {
17151            await libraryPutFolderInDb(record);
17152            await persistLibraryMeta();
17153            return true;
17154          } catch (error) {
17155            console.warn(
17156              "[library] failed to persist " + (label || "folder"),
17157              error,
17158            );
17159            return false;
17160          }
17161        }
17162
17163        function libraryIsSupportedFile(name) {
17164          if (typeof libraryCore.isSupportedLibraryFile === "function") {
17165            return libraryCore.isSupportedLibraryFile(name);
17166          }
17167          return /\.(md|markdown|mdown|mkdn|txt)$/i.test(String(name || ""));
17168        }
17169
17170        function normalizeLibraryMetaState(raw) {
17171          if (typeof libraryCore.normalizeLibraryMeta === "function") {
17172            return libraryCore.normalizeLibraryMeta(raw);
17173          }
17174          const source = raw && typeof raw === "object" ? raw : {};
17175          return {
17176            folderOrder: Array.isArray(source.folderOrder)
17177              ? source.folderOrder.slice()
17178              : [],
17179            expandedPathsByFolder:
17180              source.expandedPathsByFolder &&
17181              typeof source.expandedPathsByFolder === "object"
17182                ? source.expandedPathsByFolder
17183                : {},
17184            collapsedFolderIds: Array.isArray(source.collapsedFolderIds)
17185              ? source.collapsedFolderIds.filter(function (item) {
17186                  return typeof item === "string" && item.length > 0;
17187                })
17188              : [],
17189            lastActiveFile:
17190              source.lastActiveFile &&
17191              typeof source.lastActiveFile.folderId === "string" &&
17192              typeof source.lastActiveFile.path === "string"
17193                ? {
17194                    folderId: source.lastActiveFile.folderId,
17195                    path: source.lastActiveFile.path,
17196                  }
17197                : null,
17198          };
17199        }
17200
17201        function shouldAutoRestoreLastFile(input) {
17202          if (typeof libraryCore.shouldAutoRestoreLastFile === "function") {
17203            return libraryCore.shouldAutoRestoreLastFile(input);
17204          }
17205          const source = input && typeof input === "object" ? input : {};
17206          return Boolean(!source.hasSessionDraft && source.lastActiveFile);
17207        }
17208
17209        function shapeLibraryTree(entries, options) {
17210          if (typeof libraryCore.shapeLibraryTree === "function") {
17211            return libraryCore.shapeLibraryTree(entries, options);
17212          }
17213          return [];
17214        }
17215
17216        function sortLibraryEntries(entries) {
17217          if (typeof libraryCore.sortLibraryEntries === "function") {
17218            return libraryCore.sortLibraryEntries(entries);
17219          }
17220          return Array.isArray(entries) ? entries.slice() : [];
17221        }
17222
17223        function orderLibraryFolders(folders, folderOrder) {
17224          if (typeof libraryCore.orderLibraryFolders === "function") {
17225            return libraryCore.orderLibraryFolders(folders, folderOrder);
17226          }
17227          const order = Array.isArray(folderOrder) ? folderOrder.slice() : [];
17228          return (Array.isArray(folders) ? folders.slice() : []).sort(
17229            function (a, b) {
17230              const aIndex = order.indexOf(a.id);
17231              const bIndex = order.indexOf(b.id);
17232              if (aIndex === -1 && bIndex === -1) {
17233                return (b.addedAt || 0) - (a.addedAt || 0);
17234              }
17235              if (aIndex === -1) return -1;
17236              if (bIndex === -1) return 1;
17237              return aIndex - bIndex;
17238            },
17239          );
17240        }
17241
17242        function getLibraryDirectoryPaths(nodes) {
17243          if (typeof libraryCore.getLibraryDirectoryPaths === "function") {
17244            return libraryCore.getLibraryDirectoryPaths(nodes);
17245          }
17246          const paths = [];
17247
17248          function walk(list) {
17249            (Array.isArray(list) ? list : []).forEach(function (node) {
17250              if (!node || node.type !== "directory") return;
17251              if (typeof node.path === "string" && node.path) {
17252                paths.push(node.path);
17253              }
17254              walk(node.children);
17255            });
17256          }
17257
17258          walk(nodes);
17259          return paths;
17260        }
17261
17262        function getClearedLibraryCollectionState(input) {
17263          if (
17264            typeof libraryCore.getClearedLibraryCollectionState === "function"
17265          ) {
17266            return libraryCore.getClearedLibraryCollectionState(input);
17267          }
17268          const source = input && typeof input === "object" ? input : {};
17269          const currentRevision = Number(source.libraryNextScanRevision);
17270          return {
17271            libraryFolders: [],
17272            libraryFolderOrder: [],
17273            libraryExpandedPathsByFolder: {},
17274            libraryCollapsedFolderIds: [],
17275            libraryActiveFolderId: "",
17276            libraryActiveFilePath: "",
17277            libraryCurrentFileHandle: null,
17278            libraryCurrentFolderHandle: null,
17279            libraryNextScanRevision: Number.isFinite(currentRevision)
17280              ? currentRevision + 1
17281              : 1,
17282          };
17283        }
17284
17285        function prepareImportedLibraryEntries(files) {
17286          if (typeof libraryCore.prepareImportedLibraryEntries === "function") {
17287            return libraryCore.prepareImportedLibraryEntries(files);
17288          }
17289          const list = Array.isArray(files) ? files : [];
17290          let rootName = "";
17291          const entries = [];
17292          list.forEach(function (file) {
17293            if (!file) return;
17294            const rawPath =
17295              typeof file.webkitRelativePath === "string" &&
17296              file.webkitRelativePath
17297                ? file.webkitRelativePath
17298                : String(file.name || "");
17299            const parts = rawPath.split("/").filter(Boolean);
17300            if (!parts.length) return;
17301            if (!rootName) rootName = parts[0];
17302            const relativePath =
17303              parts.length > 1 ? parts.slice(1).join("/") : parts[0];
17304            if (!relativePath || !libraryIsSupportedFile(relativePath)) return;
17305            entries.push({
17306              path: relativePath,
17307              name: parts[parts.length - 1],
17308            });
17309          });
17310          return {
17311            rootName: rootName,
17312            entries: entries,
17313          };
17314        }
17315
17316        function getActiveLibrarySourceType() {
17317          const folder = state.libraryActiveFolderId
17318            ? getLibraryFolderById(state.libraryActiveFolderId)
17319            : null;
17320          return folder && typeof folder.sourceType === "string"
17321            ? folder.sourceType
17322            : "";
17323        }
17324
17325        function getReaderRefreshState() {
17326          if (typeof libraryCore.getReaderRefreshState === "function") {
17327            return libraryCore.getReaderRefreshState({
17328              sourceMode: state.sourceMode,
17329              hasFileHandle: Boolean(state.fileHandle),
17330              hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle),
17331              librarySourceType: getActiveLibrarySourceType(),
17332            });
17333          }
17334
17335          const sourceMode = String(state.sourceMode || "editor");
17336          const hasFileHandle = Boolean(state.fileHandle);
17337          const hasLibraryFileHandle = Boolean(state.libraryCurrentFileHandle);
17338          const librarySourceType = getActiveLibrarySourceType();
17339          let reloadAction = "none";
17340
17341          if (hasFileHandle) {
17342            reloadAction = "file-handle";
17343          } else if (
17344            sourceMode === "library" &&
17345            hasLibraryFileHandle &&
17346            librarySourceType === "persistent"
17347          ) {
17348            reloadAction = "library-persistent";
17349          } else if (
17350            sourceMode === "library" &&
17351            hasLibraryFileHandle &&
17352            librarySourceType === "snapshot"
17353          ) {
17354            reloadAction = "library-snapshot";
17355          } else if (sourceMode === "file") {
17356            reloadAction = "file-picker";
17357          }
17358
17359          return {
17360            canReload: reloadAction !== "none",
17361            canAutoRefresh:
17362              hasFileHandle ||
17363              (sourceMode === "library" &&
17364                hasLibraryFileHandle &&
17365                librarySourceType === "persistent"),
17366            reloadAction: reloadAction,
17367          };
17368        }
17369
17370        function getReaderSaveState() {
17371          const hasContent = Boolean((els.editor.value || "").trim());
17372          if (typeof libraryCore.getReaderSaveState === "function") {
17373            return libraryCore.getReaderSaveState({
17374              sourceMode: state.sourceMode,
17375              hasFileHandle: Boolean(state.fileHandle),
17376              hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle),
17377              librarySourceType: getActiveLibrarySourceType(),
17378              hasContent: hasContent,
17379              currentName: state.currentName,
17380            });
17381          }
17382
17383          if (state.sourceMode === "untitled") {
17384            return {
17385              canSave: true,
17386              saveAction: "pick-save-target",
17387              saveLabel: "Save",
17388            };
17389          }
17390
17391          if (state.fileHandle) {
17392            return {
17393              canSave: true,
17394              saveAction: "overwrite-handle",
17395              saveLabel: "Save",
17396            };
17397          }
17398
17399          if (
17400            state.libraryCurrentFileHandle &&
17401            getActiveLibrarySourceType() === "persistent"
17402          ) {
17403            return {
17404              canSave: true,
17405              saveAction: "overwrite-library-handle",
17406              saveLabel: "Save",
17407            };
17408          }
17409
17410          if (hasContent || state.currentName) {
17411            return {
17412              canSave: true,
17413              saveAction: "pick-save-target",
17414              saveLabel: "Save",
17415            };
17416          }
17417
17418          return {
17419            canSave: false,
17420            saveAction: "none",
17421            saveLabel: "Save",
17422          };
17423        }
17424
17425        function getReaderAuthoringState() {
17426          const input = {
17427            sourceMode: state.sourceMode,
17428            hasFileHandle: Boolean(state.fileHandle),
17429            hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle),
17430            librarySourceType: getActiveLibrarySourceType(),
17431            hasContent: Boolean((els.editor.value || "").trim()),
17432            currentName: state.currentName,
17433            editMode: isEditModeActive(),
17434            dirty: state.dirty,
17435            hasSessionDraft: state.hasSessionDraft,
17436          };
17437
17438          if (typeof libraryCore.getReaderAuthoringState === "function") {
17439            return libraryCore.getReaderAuthoringState(input);
17440          }
17441
17442          const saveState = getReaderSaveState();
17443          return {
17444            showSaveButton: input.editMode && saveState.canSave,
17445            showDirtyCue:
17446              !input.editMode &&
17447              saveState.canSave &&
17448              (input.dirty || input.hasSessionDraft),
17449            saveAction: saveState.saveAction,
17450            saveLabel: saveState.saveLabel,
17451          };
17452        }
17453
17454        function canUseNativeSavePicker() {
17455          return Boolean(window.showSaveFilePicker && window.isSecureContext);
17456        }
17457
17458        function getSuggestedSaveName() {
17459          const activePath = state.libraryActiveFilePath
17460            ? String(state.libraryActiveFilePath).split("/").pop()
17461            : "";
17462          let name = String(
17463            state.currentName || activePath || "untitled.md",
17464          ).trim();
17465          if (!/\.[a-z0-9]+$/i.test(name)) {
17466            name += ".md";
17467          }
17468          return name;
17469        }
17470
17471        function createMarkdownFileTypes() {
17472          return [
17473            {
17474              description: "Markdown files",
17475              accept: {
17476                "text/markdown": [".md", ".markdown", ".mdown", ".mkdn"],
17477                "text/plain": [".txt"],
17478              },
17479            },
17480          ];
17481        }
17482
17483        async function queryWritableHandlePermission(handle) {
17484          if (!handle || typeof handle.createWritable !== "function") {
17485            return "denied";
17486          }
17487
17488          if (typeof handle.queryPermission !== "function") {
17489            return "granted";
17490          }
17491
17492          try {
17493            return await handle.queryPermission({ mode: "readwrite" });
17494          } catch (_error) {
17495            return "prompt";
17496          }
17497        }
17498
17499        async function ensureWritableHandle(handle) {
17500          if (!handle || typeof handle.createWritable !== "function") {
17501            return false;
17502          }
17503
17504          let permission = await queryWritableHandlePermission(handle);
17505          if (
17506            permission !== "granted" &&
17507            typeof handle.requestPermission === "function"
17508          ) {
17509            try {
17510              permission = await handle.requestPermission({
17511                mode: "readwrite",
17512              });
17513            } catch (_error) {
17514              permission = "denied";
17515            }
17516          }
17517
17518          return permission === "granted";
17519        }
17520
17521        async function writeMarkdownToHandle(handle, markdown) {
17522          const writable = await handle.createWritable();
17523          await writable.write(markdown);
17524          await writable.close();
17525        }
17526
17527        function downloadMarkdown(markdown, fileName) {
17528          const blob = new Blob([markdown], {
17529            type: "text/markdown;charset=utf-8",
17530          });
17531          const url = URL.createObjectURL(blob);
17532          const link = document.createElement("a");
17533          link.href = url;
17534          link.download = fileName;
17535          document.body.appendChild(link);
17536          link.click();
17537          link.remove();
17538          window.setTimeout(function () {
17539            URL.revokeObjectURL(url);
17540          }, 1000);
17541        }
17542
17543        function syncPrimaryFileHandlePersistence(handle) {
17544          if (
17545            !window.MarkVPWA ||
17546            typeof window.MarkVPWA.rememberPrimaryFileHandle !== "function" ||
17547            typeof window.MarkVPWA.forgetPrimaryFileHandle !== "function"
17548          ) {
17549            return;
17550          }
17551
17552          if (handle) {
17553            window.MarkVPWA.rememberPrimaryFileHandle(handle);
17554          } else {
17555            window.MarkVPWA.forgetPrimaryFileHandle();
17556          }
17557        }
17558
17559        function setPrimaryFileHandle(handle, options) {
17560          state.fileHandle = handle || null;
17561          if (!(options && options.skipPersistence)) {
17562            syncPrimaryFileHandlePersistence(state.fileHandle);
17563          }
17564        }
17565
17566        function getWritableSaveTarget(saveState) {
17567          if (saveState.saveAction === "overwrite-handle" && state.fileHandle) {
17568            return {
17569              handle: state.fileHandle,
17570              sourceMode: "handle",
17571            };
17572          }
17573
17574          if (
17575            saveState.saveAction === "overwrite-library-handle" &&
17576            state.libraryCurrentFileHandle
17577          ) {
17578            return {
17579              handle: state.libraryCurrentFileHandle,
17580              sourceMode: "library",
17581            };
17582          }
17583
17584          return null;
17585        }
17586
17587        async function flushWritableHandleIfPermitted() {
17588          const saveState = getReaderSaveState();
17589          const target = getWritableSaveTarget(saveState);
17590          if (!target || (!state.dirty && !state.hasSessionDraft)) {
17591            return false;
17592          }
17593
17594          const permission = await queryWritableHandlePermission(target.handle);
17595          if (permission !== "granted") {
17596            return false;
17597          }
17598
17599          await writeMarkdownToHandle(target.handle, els.editor.value || "");
17600          state.currentName = target.handle.name || state.currentName;
17601          markDocumentSaved(target.sourceMode, { rerender: false });
17602          return true;
17603        }
17604
17605        function markDocumentSaved(nextSourceMode, options) {
17606          options = options || {};
17607          if (nextSourceMode) {
17608            state.sourceMode = nextSourceMode;
17609          }
17610          state.dirty = false;
17611          state.hasSessionDraft = false;
17612          updateUiState();
17613
17614          if (options.rerender === false) {
17615            writeSessionMeta();
17616            return;
17617          }
17618
17619          if (nextSourceMode === "handle" || nextSourceMode === "library") {
17620            invalidateLastRenderedDocument();
17621            render(nextSourceMode);
17622          } else {
17623            writeSessionMeta();
17624          }
17625        }
17626
17627        function requestLibraryFolderImport(options) {
17628          options = options || {};
17629          if (!els.libraryFolderInput) return;
17630
17631          state.libraryPendingImportTargetFolderId = String(
17632            options.replaceFolderId || "",
17633          );
17634          state.libraryPendingImportToken = Date.now() + Math.random();
17635          const token = state.libraryPendingImportToken;
17636
17637          window.addEventListener(
17638            "focus",
17639            function () {
17640              window.setTimeout(function () {
17641                if (state.libraryPendingImportToken !== token) return;
17642                state.libraryPendingImportTargetFolderId = "";
17643                state.libraryPendingImportToken = 0;
17644              }, 150);
17645            },
17646            { once: true },
17647          );
17648
17649          els.libraryFolderInput.click();
17650        }
17651
17652        function readSessionMeta() {
17653          const raw = readStorage(window.sessionStorage, SESSION_META_KEY, "");
17654          if (!raw) {
17655            return {
17656              hasSessionDraft: false,
17657              sourceMode: "editor",
17658              untitledDraftId: "",
17659            };
17660          }
17661          try {
17662            const parsed = JSON.parse(raw);
17663            return {
17664              hasSessionDraft: Boolean(parsed && parsed.hasSessionDraft),
17665              sourceMode:
17666                parsed && typeof parsed.sourceMode === "string"
17667                  ? parsed.sourceMode
17668                  : "editor",
17669              untitledDraftId:
17670                parsed && typeof parsed.untitledDraftId === "string"
17671                  ? parsed.untitledDraftId
17672                  : "",
17673            };
17674          } catch (_error) {
17675            return {
17676              hasSessionDraft: false,
17677              sourceMode: "editor",
17678              untitledDraftId: "",
17679            };
17680          }
17681        }
17682
17683        function writeSessionMeta() {
17684          writeStorage(
17685            window.sessionStorage,
17686            SESSION_META_KEY,
17687            JSON.stringify({
17688              hasSessionDraft: Boolean(state.hasSessionDraft),
17689              sourceMode: state.sourceMode,
17690              untitledDraftId: state.untitledDraftId || "",
17691            }),
17692          );
17693        }
17694
17695        function nextLibraryFolderId() {
17696          return (
17697            "folder_" +
17698            Date.now().toString(36) +
17699            "_" +
17700            Math.random().toString(36).slice(2, 7)
17701          );
17702        }
17703
17704        function cloneExpandedPathsByFolder(map) {
17705          const output = {};
17706          Object.keys(map || {}).forEach(function (key) {
17707            output[key] = Array.isArray(map[key]) ? map[key].slice() : [];
17708          });
17709          return output;
17710        }
17711
17712        function cloneLibraryCollapsedIds(value) {
17713          if (typeof libraryCore.normalizeLibraryFolderIdSet === "function") {
17714            return libraryCore.normalizeLibraryFolderIdSet(value);
17715          }
17716          if (!Array.isArray(value)) return [];
17717          const seen = Object.create(null);
17718          const result = [];
17719          value.forEach(function (item) {
17720            if (typeof item !== "string" || !item) return;
17721            if (seen[item]) return;
17722            seen[item] = true;
17723            result.push(item);
17724          });
17725          return result;
17726        }
17727
17728        function isLibraryFolderCollapsed(folderId) {
17729          if (!folderId) return false;
17730          const list = state.libraryCollapsedFolderIds;
17731          return Array.isArray(list) && list.indexOf(folderId) !== -1;
17732        }
17733
17734        function setLibraryFolderCollapsed(folderId, collapsed) {
17735          if (!folderId) return false;
17736          const current = Array.isArray(state.libraryCollapsedFolderIds)
17737            ? state.libraryCollapsedFolderIds.slice()
17738            : [];
17739          const index = current.indexOf(folderId);
17740          if (collapsed && index === -1) {
17741            current.push(folderId);
17742            state.libraryCollapsedFolderIds = current;
17743            return true;
17744          }
17745          if (!collapsed && index !== -1) {
17746            current.splice(index, 1);
17747            state.libraryCollapsedFolderIds = current;
17748            return true;
17749          }
17750          return false;
17751        }
17752
17753        function computeLibraryDisplayLabels() {
17754          const seen = Object.create(null);
17755          state.libraryFolders.forEach(function (folder) {
17756            const base = String(folder.name || "Untitled folder");
17757            seen[base] = (seen[base] || 0) + 1;
17758            folder.displayLabel =
17759              seen[base] === 1 ? base : base + " (" + seen[base] + ")";
17760          });
17761        }
17762
17763        function applyLibraryFolderOrder() {
17764          const order = Array.isArray(state.libraryFolderOrder)
17765            ? state.libraryFolderOrder.slice()
17766            : [];
17767          state.libraryFolders = orderLibraryFolders(
17768            state.libraryFolders,
17769            order,
17770          );
17771          state.libraryFolderOrder = state.libraryFolders.map(
17772            function (folder) {
17773              return folder.id;
17774            },
17775          );
17776          computeLibraryDisplayLabels();
17777        }
17778
17779        function getLibraryFolderById(folderId) {
17780          return state.libraryFolders.find(function (folder) {
17781            return folder.id === folderId;
17782          });
17783        }
17784
17785        function getLibraryExpandedPaths(folderId) {
17786          return Array.isArray(state.libraryExpandedPathsByFolder[folderId])
17787            ? state.libraryExpandedPathsByFolder[folderId].slice()
17788            : [];
17789        }
17790
17791        async function clearActiveLibrarySelection(options) {
17792          options = options || {};
17793          const hadActiveSelection = Boolean(
17794            state.libraryActiveFolderId ||
17795            state.libraryActiveFilePath ||
17796            state.libraryCurrentFileHandle ||
17797            state.libraryCurrentFolderHandle,
17798          );
17799
17800          state.libraryActiveFolderId = "";
17801          state.libraryActiveFilePath = "";
17802          state.libraryCurrentFileHandle = null;
17803          state.libraryCurrentFolderHandle = null;
17804
17805          if (options.renderDrawer) {
17806            renderLibraryDrawer();
17807          }
17808
17809          if (
17810            !options.persist ||
17811            !hadActiveSelection ||
17812            !canUseLibraryStorage()
17813          ) {
17814            return hadActiveSelection;
17815          }
17816
17817          try {
17818            await persistLibraryMeta();
17819          } catch (error) {
17820            console.warn(
17821              "[library] failed to persist cleared active file",
17822              error,
17823            );
17824          }
17825
17826          return hadActiveSelection;
17827        }
17828
17829        async function clearLibraryCollection(options) {
17830          options = options || {};
17831          const nextState = getClearedLibraryCollectionState({
17832            libraryFolders: state.libraryFolders,
17833            libraryFolderOrder: state.libraryFolderOrder,
17834            libraryExpandedPathsByFolder: state.libraryExpandedPathsByFolder,
17835            libraryCollapsedFolderIds: state.libraryCollapsedFolderIds,
17836            libraryActiveFolderId: state.libraryActiveFolderId,
17837            libraryActiveFilePath: state.libraryActiveFilePath,
17838            libraryCurrentFileHandle: state.libraryCurrentFileHandle,
17839            libraryCurrentFolderHandle: state.libraryCurrentFolderHandle,
17840            libraryNextScanRevision: state.libraryNextScanRevision,
17841          });
17842
17843          state.libraryFolders = nextState.libraryFolders;
17844          state.libraryFolderOrder = nextState.libraryFolderOrder;
17845          state.libraryExpandedPathsByFolder =
17846            nextState.libraryExpandedPathsByFolder;
17847          state.libraryCollapsedFolderIds = Array.isArray(
17848            nextState.libraryCollapsedFolderIds,
17849          )
17850            ? nextState.libraryCollapsedFolderIds.slice()
17851            : [];
17852          state.libraryActiveFolderId = nextState.libraryActiveFolderId;
17853          state.libraryActiveFilePath = nextState.libraryActiveFilePath;
17854          state.libraryCurrentFileHandle = nextState.libraryCurrentFileHandle;
17855          state.libraryCurrentFolderHandle =
17856            nextState.libraryCurrentFolderHandle;
17857          state.libraryNextScanRevision = nextState.libraryNextScanRevision;
17858          state.libraryPendingImportTargetFolderId = "";
17859          state.libraryPendingImportToken = 0;
17860
17861          if (options.renderDrawer) {
17862            renderLibraryDrawer();
17863          }
17864
17865          if (!options.persist || !canUseLibraryStorage()) {
17866            return;
17867          }
17868
17869          try {
17870            await libraryClearAllFromDb();
17871          } catch (error) {
17872            console.warn("[library] failed to clear library from db", error);
17873          }
17874        }
17875
17876        function createLibraryFolderRecord(data) {
17877          const record = {
17878            id: data.id,
17879            name: data.name,
17880            displayLabel: data.name,
17881            handle: data.handle || null,
17882            sourceType:
17883              data.sourceType || (data.handle ? "persistent" : "snapshot"),
17884            snapshotFiles: Array.isArray(data.snapshotFiles)
17885              ? data.snapshotFiles.map(function (item) {
17886                  return {
17887                    path: String(item && item.path ? item.path : ""),
17888                    name: String(item && item.name ? item.name : ""),
17889                    text: String(item && item.text ? item.text : ""),
17890                    lastModified: Number(item && item.lastModified) || 0,
17891                  };
17892                })
17893              : [],
17894            addedAt: Number(data.addedAt) || Date.now(),
17895            status: "loading",
17896            note: "",
17897            tree: [],
17898            fileHandlesByPath: new Map(),
17899            scanRevision: 0,
17900            lastScannedAt: 0,
17901          };
17902          return record;
17903        }
17904
17905        function openLibraryDb() {
17906          if (state.libraryDbPromise) return state.libraryDbPromise;
17907          state.libraryDbPromise = new Promise(function (resolve, reject) {
17908            const request = indexedDB.open(LIBRARY_DB_NAME, LIBRARY_DB_VERSION);
17909            request.onupgradeneeded = function () {
17910              const db = request.result;
17911              if (!db.objectStoreNames.contains(LIBRARY_FOLDER_STORE)) {
17912                db.createObjectStore(LIBRARY_FOLDER_STORE, { keyPath: "id" });
17913              }
17914              if (!db.objectStoreNames.contains(LIBRARY_META_STORE)) {
17915                db.createObjectStore(LIBRARY_META_STORE, { keyPath: "key" });
17916              }
17917            };
17918            request.onsuccess = function () {
17919              resolve(request.result);
17920            };
17921            request.onerror = function () {
17922              reject(request.error);
17923            };
17924          });
17925          return state.libraryDbPromise;
17926        }
17927
17928        function libraryGetAllFoldersFromDb() {
17929          return openLibraryDb().then(function (db) {
17930            return new Promise(function (resolve, reject) {
17931              const tx = db.transaction(LIBRARY_FOLDER_STORE, "readonly");
17932              const request = tx.objectStore(LIBRARY_FOLDER_STORE).getAll();
17933              request.onsuccess = function () {
17934                resolve(request.result || []);
17935              };
17936              request.onerror = function () {
17937                reject(request.error);
17938              };
17939            });
17940          });
17941        }
17942
17943        function libraryPutFolderInDb(record) {
17944          return openLibraryDb().then(function (db) {
17945            return new Promise(function (resolve, reject) {
17946              const tx = db.transaction(LIBRARY_FOLDER_STORE, "readwrite");
17947              tx.objectStore(LIBRARY_FOLDER_STORE).put({
17948                id: record.id,
17949                name: record.name,
17950                handle:
17951                  record.sourceType === "persistent" ? record.handle : null,
17952                sourceType: record.sourceType,
17953                snapshotFiles:
17954                  record.sourceType === "snapshot"
17955                    ? record.snapshotFiles.map(function (item) {
17956                        return {
17957                          path: item.path,
17958                          name: item.name,
17959                          text: item.text,
17960                          lastModified: item.lastModified,
17961                        };
17962                      })
17963                    : [],
17964                addedAt: record.addedAt,
17965              });
17966              tx.oncomplete = function () {
17967                resolve();
17968              };
17969              tx.onerror = function () {
17970                reject(tx.error);
17971              };
17972            });
17973          });
17974        }
17975
17976        function libraryDeleteFolderFromDb(folderId) {
17977          return openLibraryDb().then(function (db) {
17978            return new Promise(function (resolve, reject) {
17979              const tx = db.transaction(LIBRARY_FOLDER_STORE, "readwrite");
17980              tx.objectStore(LIBRARY_FOLDER_STORE).delete(folderId);
17981              tx.oncomplete = function () {
17982                resolve();
17983              };
17984              tx.onerror = function () {
17985                reject(tx.error);
17986              };
17987            });
17988          });
17989        }
17990
17991        function libraryClearAllFromDb() {
17992          return openLibraryDb().then(function (db) {
17993            return new Promise(function (resolve, reject) {
17994              const tx = db.transaction(
17995                [LIBRARY_FOLDER_STORE, LIBRARY_META_STORE],
17996                "readwrite",
17997              );
17998              tx.objectStore(LIBRARY_FOLDER_STORE).clear();
17999              tx.objectStore(LIBRARY_META_STORE).clear();
18000              tx.oncomplete = function () {
18001                resolve();
18002              };
18003              tx.onerror = function () {
18004                reject(tx.error);
18005              };
18006            });
18007          });
18008        }
18009
18010        function libraryReadMetaFromDb() {
18011          return openLibraryDb().then(function (db) {
18012            return new Promise(function (resolve, reject) {
18013              const tx = db.transaction(LIBRARY_META_STORE, "readonly");
18014              const request = tx.objectStore(LIBRARY_META_STORE).getAll();
18015              request.onsuccess = function () {
18016                const meta = {};
18017                (request.result || []).forEach(function (item) {
18018                  meta[item.key] = item.value;
18019                });
18020                resolve(
18021                  normalizeLibraryMetaState({
18022                    folderOrder: meta.folderOrder,
18023                    expandedPathsByFolder: meta.expandedPathsByFolder,
18024                    collapsedFolderIds: meta.collapsedFolderIds,
18025                    lastActiveFile: meta.lastActiveFile,
18026                  }),
18027                );
18028              };
18029              request.onerror = function () {
18030                reject(request.error);
18031              };
18032            });
18033          });
18034        }
18035
18036        function persistLibraryMeta() {
18037          const payload = {
18038            folderOrder: state.libraryFolderOrder.slice(),
18039            expandedPathsByFolder: cloneExpandedPathsByFolder(
18040              state.libraryExpandedPathsByFolder,
18041            ),
18042            collapsedFolderIds: cloneLibraryCollapsedIds(
18043              state.libraryCollapsedFolderIds,
18044            ),
18045            lastActiveFile:
18046              state.libraryActiveFolderId && state.libraryActiveFilePath
18047                ? {
18048                    folderId: state.libraryActiveFolderId,
18049                    path: state.libraryActiveFilePath,
18050                  }
18051                : null,
18052          };
18053
18054          return openLibraryDb().then(function (db) {
18055            return new Promise(function (resolve, reject) {
18056              const tx = db.transaction(LIBRARY_META_STORE, "readwrite");
18057              const store = tx.objectStore(LIBRARY_META_STORE);
18058              store.put({ key: "folderOrder", value: payload.folderOrder });
18059              store.put({
18060                key: "expandedPathsByFolder",
18061                value: payload.expandedPathsByFolder,
18062              });
18063              store.put({
18064                key: "collapsedFolderIds",
18065                value: payload.collapsedFolderIds,
18066              });
18067              store.put({
18068                key: "lastActiveFile",
18069                value: payload.lastActiveFile,
18070              });
18071              tx.oncomplete = function () {
18072                resolve(payload);
18073              };
18074              tx.onerror = function () {
18075                reject(tx.error);
18076              };
18077            });
18078          });
18079        }
18080
18081        async function queryLibraryPermission(handle) {
18082          if (!handle || typeof handle.queryPermission !== "function") {
18083            return "denied";
18084          }
18085          try {
18086            return await handle.queryPermission({ mode: "read" });
18087          } catch (_error) {
18088            return "prompt";
18089          }
18090        }
18091
18092        async function requestLibraryPermission(handle) {
18093          if (!handle || typeof handle.requestPermission !== "function") {
18094            return "denied";
18095          }
18096          try {
18097            return await handle.requestPermission({ mode: "read" });
18098          } catch (_error) {
18099            return "denied";
18100          }
18101        }
18102
18103        function yieldToUi() {
18104          return new Promise(function (resolve) {
18105            window.setTimeout(resolve, 0);
18106          });
18107        }
18108
18109        function attachLibraryFileHandles(nodes, byPath) {
18110          return nodes.map(function (node) {
18111            if (node.type === "file") {
18112              return Object.assign({}, node, {
18113                handle: byPath.get(node.path) || null,
18114              });
18115            }
18116            return Object.assign({}, node, {
18117              children: attachLibraryFileHandles(node.children || [], byPath),
18118            });
18119          });
18120        }
18121
18122        function stripLibraryFileHandles(nodes) {
18123          return (nodes || []).map(function (node) {
18124            if (node.type === "file") {
18125              return Object.assign({}, node, {
18126                handle: null,
18127              });
18128            }
18129            return Object.assign({}, node, {
18130              children: stripLibraryFileHandles(node.children || []),
18131            });
18132          });
18133        }
18134
18135        function createSnapshotLibraryFileHandle(snapshotFile) {
18136          return {
18137            kind: "file",
18138            name: snapshotFile.name,
18139            getFile: async function () {
18140              return new File([snapshotFile.text], snapshotFile.name, {
18141                type: "text/plain",
18142                lastModified: snapshotFile.lastModified || Date.now(),
18143              });
18144            },
18145          };
18146        }
18147
18148        async function hydrateSnapshotLibraryFolder(folder) {
18149          const snapshotFiles = Array.isArray(folder.snapshotFiles)
18150            ? folder.snapshotFiles.filter(function (item) {
18151                return (
18152                  item &&
18153                  typeof item.path === "string" &&
18154                  item.path &&
18155                  libraryIsSupportedFile(item.path)
18156                );
18157              })
18158            : [];
18159
18160          const fileHandlesByPath = new Map();
18161          snapshotFiles.forEach(function (item) {
18162            fileHandlesByPath.set(
18163              item.path,
18164              createSnapshotLibraryFileHandle(item),
18165            );
18166          });
18167
18168          const tree = attachLibraryFileHandles(
18169            shapeLibraryTree(
18170              snapshotFiles.map(function (item) {
18171                return {
18172                  kind: "file",
18173                  path: item.path,
18174                };
18175              }),
18176              {
18177                expandedPaths: getLibraryExpandedPaths(folder.id),
18178              },
18179            ),
18180            fileHandlesByPath,
18181          );
18182
18183          folder.tree = tree;
18184          folder.fileHandlesByPath = fileHandlesByPath;
18185          folder.lastScannedAt = Date.now();
18186          folder.status = tree.length ? "ready" : "empty";
18187          folder.note = tree.length
18188            ? "Imported snapshot"
18189            : "No markdown files found in this folder.";
18190
18191          if (
18192            state.libraryActiveFolderId === folder.id &&
18193            state.libraryActiveFilePath &&
18194            !findLibraryFileNode(tree, state.libraryActiveFilePath)
18195          ) {
18196            await clearActiveLibrarySelection({
18197              persist: canUseLibraryStorage(),
18198            });
18199            if (state.sourceMode === "library") {
18200              state.sourceMode = "editor";
18201              updateUiState();
18202            }
18203          }
18204
18205          renderLibraryDrawer();
18206        }
18207
18208        async function scanLibraryFolder(folder) {
18209          if (!folder) return;
18210          if (folder.sourceType === "snapshot") {
18211            await hydrateSnapshotLibraryFolder(folder);
18212            return;
18213          }
18214
18215          const revision = ++state.libraryNextScanRevision;
18216          folder.scanRevision = revision;
18217          folder.status = "loading";
18218          folder.note = "";
18219          renderLibraryDrawer();
18220
18221          const permission = await queryLibraryPermission(folder.handle);
18222          if (permission !== "granted") {
18223            folder.status = "needs-permission";
18224            folder.tree = stripLibraryFileHandles(folder.tree);
18225            folder.fileHandlesByPath = new Map();
18226            folder.note = "Access needs to be granted again.";
18227            renderLibraryDrawer();
18228            return;
18229          }
18230
18231          const result = {
18232            entries: [],
18233            fileHandlesByPath: new Map(),
18234            truncated: false,
18235          };
18236
18237          async function walk(handle, parentPath, depth) {
18238            if (folder.scanRevision !== revision) {
18239              throw new Error("scan-stale");
18240            }
18241            if (depth > LIBRARY_LIMITS.maxDepth) {
18242              result.truncated = true;
18243              return;
18244            }
18245
18246            let index = 0;
18247            for await (const [name, childHandle] of handle.entries()) {
18248              if (folder.scanRevision !== revision) {
18249                throw new Error("scan-stale");
18250              }
18251              if (LIBRARY_IGNORED_NAMES.has(name)) continue;
18252
18253              const nextPath = parentPath ? parentPath + "/" + name : name;
18254              if (childHandle.kind === "directory") {
18255                await walk(childHandle, nextPath, depth + 1);
18256              } else {
18257                if (name.startsWith(".") && !libraryIsSupportedFile(name))
18258                  continue;
18259                if (!libraryIsSupportedFile(name)) continue;
18260                if (result.entries.length >= LIBRARY_LIMITS.maxFiles) {
18261                  result.truncated = true;
18262                  break;
18263                }
18264                result.entries.push({
18265                  kind: "file",
18266                  path: nextPath,
18267                });
18268                result.fileHandlesByPath.set(nextPath, childHandle);
18269              }
18270
18271              index += 1;
18272              if (index % LIBRARY_LIMITS.yieldEvery === 0) {
18273                await yieldToUi();
18274              }
18275            }
18276          }
18277
18278          try {
18279            await walk(folder.handle, "", 0);
18280          } catch (error) {
18281            if (error && error.message === "scan-stale") {
18282              return;
18283            }
18284            folder.status = "needs-permission";
18285            folder.tree = stripLibraryFileHandles(folder.tree);
18286            folder.fileHandlesByPath = new Map();
18287            folder.note = "This folder could not be read.";
18288            console.warn("[library] folder scan failed", {
18289              folderId: folder.id,
18290              error: error && error.message ? error.message : String(error),
18291            });
18292            renderLibraryDrawer();
18293            return;
18294          }
18295
18296          const tree = attachLibraryFileHandles(
18297            shapeLibraryTree(result.entries, {
18298              expandedPaths: getLibraryExpandedPaths(folder.id),
18299            }),
18300            result.fileHandlesByPath,
18301          );
18302
18303          folder.tree = tree;
18304          folder.fileHandlesByPath = result.fileHandlesByPath;
18305          folder.lastScannedAt = Date.now();
18306          folder.status = tree.length ? "ready" : "empty";
18307          folder.note = result.truncated
18308            ? "Showing a bounded slice of this folder."
18309            : tree.length
18310              ? ""
18311              : "No markdown files found in this folder.";
18312
18313          if (
18314            state.libraryActiveFolderId === folder.id &&
18315            state.libraryActiveFilePath &&
18316            !findLibraryFileNode(tree, state.libraryActiveFilePath)
18317          ) {
18318            await clearActiveLibrarySelection({ persist: true });
18319            if (state.sourceMode === "library") {
18320              state.sourceMode = "editor";
18321              updateUiState();
18322            }
18323          }
18324
18325          renderLibraryDrawer();
18326        }
18327
18328        function clampNumber(value, min, max) {
18329          return Math.min(max, Math.max(min, value));
18330        }
18331
18332        function roundToStep(value, step, min) {
18333          const rounded = Math.round((value - min) / step) * step + min;
18334          return Number(rounded.toFixed(2));
18335        }
18336
18337        function findMatchingReadingPreset(settings) {
18338          return Object.keys(READING_PRESETS).find(function (name) {
18339            const preset = READING_PRESETS[name];
18340            return (
18341              Math.abs(preset.fontSize - settings.fontSize) < 0.001 &&
18342              Math.abs(preset.lineHeight - settings.lineHeight) < 0.001 &&
18343              Math.abs(preset.measure - settings.measure) < 0.001
18344            );
18345          });
18346        }
18347
18348        function normalizeReadingSettings(raw) {
18349          const source = raw && typeof raw === "object" ? raw : {};
18350          const normalized = {
18351            fontSize: roundToStep(
18352              clampNumber(
18353                Number(source.fontSize) || READING_DEFAULTS.fontSize,
18354                16,
18355                22,
18356              ),
18357              0.5,
18358              16,
18359            ),
18360            lineHeight: roundToStep(
18361              clampNumber(
18362                Number(source.lineHeight) || READING_DEFAULTS.lineHeight,
18363                1.45,
18364                1.9,
18365              ),
18366              0.02,
18367              1.45,
18368            ),
18369            measure: roundToStep(
18370              clampNumber(
18371                Number(source.measure) || READING_DEFAULTS.measure,
18372                560,
18373                820,
18374              ),
18375              20,
18376              560,
18377            ),
18378          };
18379
18380          // Trust an explicitly-provided named preset (set by preset buttons).
18381          // Only fall back to float-comparison when the source is "custom" or absent.
18382          const srcPreset = source.preset;
18383          if (srcPreset && READING_PRESETS[srcPreset]) {
18384            normalized.preset = srcPreset;
18385          } else {
18386            normalized.preset =
18387              findMatchingReadingPreset(normalized) || "custom";
18388          }
18389
18390          return normalized;
18391        }
18392
18393        function readReadingSettings() {
18394          const saved = readStorage(
18395            window.localStorage,
18396            READING_SETTINGS_KEY,
18397            "",
18398          );
18399          if (!saved) {
18400            return normalizeReadingSettings(READING_DEFAULTS);
18401          }
18402          try {
18403            return normalizeReadingSettings(JSON.parse(saved));
18404          } catch (_error) {
18405            return normalizeReadingSettings(READING_DEFAULTS);
18406          }
18407        }
18408
18409        function saveReadingSettings(settings) {
18410          writeStorage(
18411            window.localStorage,
18412            READING_SETTINGS_KEY,
18413            JSON.stringify({
18414              preset: settings.preset,
18415              fontSize: settings.fontSize,
18416              lineHeight: settings.lineHeight,
18417              measure: settings.measure,
18418            }),
18419          );
18420        }
18421
18422        function normalizeReaderPalette(value) {
18423          return readerPaletteApi &&
18424            typeof readerPaletteApi.normalizeKey === "function"
18425            ? readerPaletteApi.normalizeKey(value)
18426            : "original";
18427        }
18428
18429        function readReaderPalette() {
18430          if (
18431            readerPaletteApi &&
18432            typeof readerPaletteApi.readSavedPalette === "function"
18433          ) {
18434            return readerPaletteApi.readSavedPalette(window.localStorage);
18435          }
18436          return normalizeReaderPalette(
18437            readStorage(
18438              window.localStorage,
18439              READER_PALETTE_STORAGE_KEY,
18440              "original",
18441            ),
18442          );
18443        }
18444
18445        function writeReaderPalette(value) {
18446          if (
18447            readerPaletteApi &&
18448            typeof readerPaletteApi.writeSavedPalette === "function"
18449          ) {
18450            return readerPaletteApi.writeSavedPalette(
18451              window.localStorage,
18452              value,
18453            );
18454          }
18455          return writeStorage(
18456            window.localStorage,
18457            READER_PALETTE_STORAGE_KEY,
18458            normalizeReaderPalette(value),
18459          );
18460        }
18461
18462        function readPreferredLightReaderPalette() {
18463          return normalizeReaderPalette(
18464            readStorage(
18465              window.localStorage,
18466              READER_LIGHT_PALETTE_STORAGE_KEY,
18467              "original",
18468            ),
18469          );
18470        }
18471
18472        function writePreferredLightReaderPalette(value) {
18473          writeStorage(
18474            window.localStorage,
18475            READER_LIGHT_PALETTE_STORAGE_KEY,
18476            normalizeReaderPalette(value),
18477          );
18478        }
18479
18480        function getGlobalTheme() {
18481          return document.documentElement.getAttribute("data-theme") || "light";
18482        }
18483
18484        function getEffectiveReaderScheme(palette) {
18485          if (
18486            readerPaletteApi &&
18487            typeof readerPaletteApi.getEffectiveScheme === "function"
18488          ) {
18489            return readerPaletteApi.getEffectiveScheme(
18490              palette || state.readerPalette,
18491              getGlobalTheme(),
18492            );
18493          }
18494          return getGlobalTheme() === "dark" ? "dark" : "light";
18495        }
18496
18497        function coerceReaderPaletteForTheme(value, theme) {
18498          const normalized = normalizeReaderPalette(value);
18499          if (
18500            readerPaletteApi &&
18501            typeof readerPaletteApi.coercePaletteForTheme === "function"
18502          ) {
18503            return readerPaletteApi.coercePaletteForTheme(
18504              normalized,
18505              theme,
18506              readPreferredLightReaderPalette(),
18507            );
18508          }
18509          if (theme === "dark" && normalized !== "original") return "slate";
18510          if (theme === "light" && normalized === "slate") {
18511            return readPreferredLightReaderPalette();
18512          }
18513          return normalized;
18514        }
18515
18516        function syncReaderPaletteControls() {
18517          if (!els.readingPaletteButtons.length) return;
18518          const selected = normalizeReaderPalette(state.readerPalette);
18519          els.readingPaletteButtons.forEach(function (button) {
18520            const key = normalizeReaderPalette(button.dataset.readerPalette);
18521            const isSelected = key === selected;
18522            button.setAttribute("aria-checked", String(isSelected));
18523            button.tabIndex = isSelected ? 0 : -1;
18524          });
18525        }
18526
18527        function applyReaderPalette(value, options) {
18528          options = options || {};
18529          const previousPalette = state.readerPalette;
18530          const previousScheme = state.readerPaletteScheme;
18531          const nextPalette = normalizeReaderPalette(value);
18532          state.readerPalette = nextPalette;
18533          state.readerPaletteInitialized = true;
18534
18535          if (
18536            readerPaletteApi &&
18537            typeof readerPaletteApi.applyToRoot === "function"
18538          ) {
18539            state.readerPaletteScheme = readerPaletteApi.applyToRoot(
18540              document.documentElement,
18541              nextPalette,
18542              { globalTheme: getGlobalTheme() },
18543            );
18544          } else {
18545            state.readerPaletteScheme = getEffectiveReaderScheme(nextPalette);
18546            document.documentElement.setAttribute(
18547              "data-reader-palette",
18548              nextPalette,
18549            );
18550            document.documentElement.setAttribute(
18551              "data-reader-scheme",
18552              state.readerPaletteScheme,
18553            );
18554          }
18555
18556          syncReaderPaletteControls();
18557
18558          if (options.persist) {
18559            writeReaderPalette(nextPalette);
18560            if (state.readerPaletteScheme === "light") {
18561              writePreferredLightReaderPalette(nextPalette);
18562            }
18563          }
18564          if (options.announce) {
18565            const palette =
18566              readerPaletteApi &&
18567              typeof readerPaletteApi.getPalette === "function"
18568                ? readerPaletteApi.getPalette(nextPalette)
18569                : null;
18570            announce(
18571              "Reader palette set to " +
18572                (palette && palette.label ? palette.label : nextPalette) +
18573                ".",
18574            );
18575          }
18576          if (options.refresh !== false && previousScheme) {
18577            const schemeChanged = previousScheme !== state.readerPaletteScheme;
18578            const paletteChanged = previousPalette !== nextPalette;
18579            if (schemeChanged || paletteChanged) {
18580              scheduleReaderAssetRefresh(schemeChanged ? previousScheme : null);
18581            }
18582          }
18583          if (
18584            options.syncTheme &&
18585            state.readerPaletteScheme !== getGlobalTheme()
18586          ) {
18587            applyTheme(state.readerPaletteScheme, {
18588              persist: true,
18589              skipReaderPalette: true,
18590            });
18591          }
18592        }
18593
18594        function updateRangeProgress(input) {
18595          const min = Number(input.min) || 0;
18596          const max = Number(input.max) || 100;
18597          const value = Number(input.value) || min;
18598          const percent =
18599            ((value - min) / Math.max(Number.EPSILON, max - min)) * 100;
18600          input.style.setProperty("--range-progress", percent + "%");
18601        }
18602
18603        function formatReadingValue(key, value) {
18604          if (key === "fontSize")
18605            return value.toFixed(1).replace(".0", "") + " px";
18606          if (key === "lineHeight") return value.toFixed(2);
18607          return Math.round(value) + " px";
18608        }
18609
18610        function syncReadingControls() {
18611          const settings = state.readingSettings;
18612          if (!settings) return;
18613
18614          els.readingFontSizeInput.value = String(settings.fontSize);
18615          els.readingLineHeightInput.value = String(settings.lineHeight);
18616          els.readingMeasureInput.value = String(settings.measure);
18617
18618          els.readingFontSizeValue.value = formatReadingValue(
18619            "fontSize",
18620            settings.fontSize,
18621          );
18622          els.readingFontSizeValue.textContent = els.readingFontSizeValue.value;
18623          els.readingLineHeightValue.value = formatReadingValue(
18624            "lineHeight",
18625            settings.lineHeight,
18626          );
18627          els.readingLineHeightValue.textContent =
18628            els.readingLineHeightValue.value;
18629          els.readingMeasureValue.value = formatReadingValue(
18630            "measure",
18631            settings.measure,
18632          );
18633          els.readingMeasureValue.textContent = els.readingMeasureValue.value;
18634
18635          els.readingFontSizeInput.setAttribute(
18636            "aria-valuetext",
18637            formatReadingValue("fontSize", settings.fontSize),
18638          );
18639          els.readingLineHeightInput.setAttribute(
18640            "aria-valuetext",
18641            formatReadingValue("lineHeight", settings.lineHeight),
18642          );
18643          els.readingMeasureInput.setAttribute(
18644            "aria-valuetext",
18645            formatReadingValue("measure", settings.measure),
18646          );
18647
18648          updateRangeProgress(els.readingFontSizeInput);
18649          updateRangeProgress(els.readingLineHeightInput);
18650          updateRangeProgress(els.readingMeasureInput);
18651
18652          const selectedPreset =
18653            settings.preset === "custom" ? "" : settings.preset;
18654          const tabStopPreset = selectedPreset || "balanced";
18655
18656          els.readingPresetButtons.forEach(function (button) {
18657            const isSelected = button.dataset.preset === selectedPreset;
18658            const isTabStop = button.dataset.preset === tabStopPreset;
18659            button.classList.toggle("is-active", isSelected);
18660            button.setAttribute("aria-checked", String(isSelected));
18661            button.tabIndex = isTabStop ? 0 : -1;
18662          });
18663
18664          els.readingPresetCustom.hidden = settings.preset !== "custom";
18665        }
18666
18667        function applyReadingSettings(settings) {
18668          state.readingSettings = normalizeReadingSettings(settings);
18669          document.documentElement.style.setProperty(
18670            "--reading-font-size",
18671            state.readingSettings.fontSize + "px",
18672          );
18673          document.documentElement.style.setProperty(
18674            "--reading-line-height",
18675            String(state.readingSettings.lineHeight),
18676          );
18677          document.documentElement.style.setProperty(
18678            "--reading-measure",
18679            state.readingSettings.measure + "px",
18680          );
18681          syncReadingControls();
18682        }
18683
18684        function updateReadingSetting(key, rawValue, options) {
18685          options = options || {};
18686          const next = Object.assign({}, state.readingSettings, {
18687            [key]: Number(rawValue),
18688            preset: "custom",
18689          });
18690          applyReadingSettings(next);
18691          if (options.persist) {
18692            saveReadingSettings(state.readingSettings);
18693          }
18694          if (options.announce) {
18695            announce(options.announce);
18696          }
18697        }
18698
18699        function applyReadingPreset(name, options) {
18700          if (!READING_PRESETS[name]) return;
18701          options = options || {};
18702          applyReadingSettings(
18703            Object.assign({ preset: name }, READING_PRESETS[name]),
18704          );
18705          if (options.persist !== false) {
18706            saveReadingSettings(state.readingSettings);
18707          }
18708          if (options.announce) {
18709            announce("Reading preset set to " + name + ".");
18710          }
18711        }
18712
18713        function showReadingPanel() {
18714          if (!els.readingPanel.hidden) return;
18715          readingLastFocusedElement =
18716            document.activeElement instanceof HTMLElement
18717              ? document.activeElement
18718              : null;
18719          els.readingPanel.hidden = false;
18720          els.readingPanel.setAttribute("aria-hidden", "false");
18721          els.readingToggleBtn.setAttribute("aria-expanded", "true");
18722
18723          window.requestAnimationFrame(function () {
18724            const focusTarget =
18725              els.readingPresetButtons.find(function (button) {
18726                return button.tabIndex === 0;
18727              }) || els.readingFontSizeInput;
18728            focusTarget.focus();
18729          });
18730        }
18731
18732        function hideReadingPanel(options) {
18733          options = options || {};
18734          if (els.readingPanel.hidden) return;
18735          els.readingPanel.hidden = true;
18736          els.readingPanel.setAttribute("aria-hidden", "true");
18737          els.readingToggleBtn.setAttribute("aria-expanded", "false");
18738          if (options.restoreFocus === false) return;
18739          if (
18740            readingLastFocusedElement &&
18741            typeof readingLastFocusedElement.focus === "function"
18742          ) {
18743            readingLastFocusedElement.focus();
18744          } else {
18745            els.readingToggleBtn.focus();
18746          }
18747        }
18748
18749        function announce(message) {
18750          if (!els.liveRegion) return;
18751          els.liveRegion.textContent = "";
18752          window.requestAnimationFrame(function () {
18753            els.liveRegion.textContent = message;
18754          });
18755        }
18756
18757        function isCoarsePointer() {
18758          return (
18759            window.matchMedia && window.matchMedia("(pointer: coarse)").matches
18760          );
18761        }
18762
18763        function isEditableElement(element) {
18764          return Boolean(
18765            element &&
18766            (element.matches("input, textarea, select, option") ||
18767              element.isContentEditable),
18768          );
18769        }
18770
18771        function formatHighlightCount(count) {
18772          return count + (count === 1 ? " highlight" : " highlights");
18773        }
18774
18775        function setHighlightStatus(message, options) {
18776          const count = state.currentHighlights.length;
18777          const suffix =
18778            count > 0
18779              ? " • " + formatHighlightCount(count)
18780              : " • no highlights";
18781          setStatus(message + suffix, options);
18782        }
18783
18784        function randomId(prefix) {
18785          return (
18786            prefix +
18787            "_" +
18788            Math.random().toString(36).slice(2, 10) +
18789            Date.now().toString(36).slice(-4)
18790          );
18791        }
18792
18793        function hideHighlightUndoToast() {
18794          window.clearTimeout(state.highlightUndoTimer);
18795          state.highlightUndoTimer = null;
18796          state.highlightUndo = null;
18797          els.highlightUndoToast.hidden = true;
18798          els.highlightUndoToast.classList.add("hidden");
18799          els.highlightUndoToast.setAttribute("aria-hidden", "true");
18800        }
18801
18802        function showHighlightUndoToast(message, undoFn) {
18803          hideHighlightUndoToast();
18804          state.highlightUndo = typeof undoFn === "function" ? undoFn : null;
18805          els.highlightUndoMessage.textContent = message;
18806          els.highlightUndoToast.hidden = false;
18807          els.highlightUndoToast.classList.remove("hidden");
18808          els.highlightUndoToast.setAttribute("aria-hidden", "false");
18809          state.highlightUndoTimer = window.setTimeout(function () {
18810            hideHighlightUndoToast();
18811          }, 4000);
18812        }
18813
18814        function getHighlightStorageTarget() {
18815          if (state.sourceMode === "untitled" && state.untitledDraftId) {
18816            return {
18817              storage: window.localStorage,
18818              key: DRAFT_HIGHLIGHT_STORAGE_KEY,
18819              bucket: "untitled::" + state.untitledDraftId,
18820              scope: "local",
18821            };
18822          }
18823
18824          if (state.currentName) {
18825            return {
18826              storage: window.localStorage,
18827              key: HIGHLIGHT_STORAGE_KEY,
18828              bucket: "named::" + state.currentName,
18829              scope: "local",
18830            };
18831          }
18832
18833          return {
18834            storage: window.sessionStorage,
18835            key: DRAFT_HIGHLIGHT_STORAGE_KEY,
18836            bucket: "draft::current",
18837            scope: "session",
18838          };
18839        }
18840
18841        function readHighlightBuckets(storage, key) {
18842          const raw = readStorage(storage, key, "{}");
18843          if (!raw) return {};
18844          try {
18845            const parsed = JSON.parse(raw);
18846            return parsed && typeof parsed === "object" ? parsed : {};
18847          } catch (_error) {
18848            return {};
18849          }
18850        }
18851
18852        function writeHighlightBuckets(storage, key, buckets) {
18853          return writeStorage(storage, key, JSON.stringify(buckets));
18854        }
18855
18856        function readHighlightsForCurrentDocument() {
18857          const target = getHighlightStorageTarget();
18858          const buckets = readHighlightBuckets(target.storage, target.key);
18859          const list = buckets[target.bucket];
18860          if (Array.isArray(list)) return list.slice();
18861          const memoryList =
18862            state.highlightMemoryBuckets[target.scope][target.bucket];
18863          return Array.isArray(memoryList) ? memoryList.slice() : [];
18864        }
18865
18866        function saveHighlightsForCurrentDocument(records) {
18867          const target = getHighlightStorageTarget();
18868          const buckets = readHighlightBuckets(target.storage, target.key);
18869          if (records.length) {
18870            buckets[target.bucket] = records;
18871          } else {
18872            delete buckets[target.bucket];
18873          }
18874          const didPersist = writeHighlightBuckets(
18875            target.storage,
18876            target.key,
18877            buckets,
18878          );
18879          if (didPersist) {
18880            delete state.highlightMemoryBuckets[target.scope][target.bucket];
18881            return true;
18882          }
18883
18884          if (records.length) {
18885            state.highlightMemoryBuckets[target.scope][target.bucket] =
18886              records.slice();
18887          } else {
18888            delete state.highlightMemoryBuckets[target.scope][target.bucket];
18889          }
18890          return false;
18891        }
18892
18893        function isSelectionInPreview(range) {
18894          if (!range) return false;
18895          const container =
18896            range.commonAncestorContainer.nodeType === Node.TEXT_NODE
18897              ? range.commonAncestorContainer.parentElement
18898              : range.commonAncestorContainer;
18899          return Boolean(container && els.preview.contains(container));
18900        }
18901
18902        function selectionIntersectsExcludedContent(range) {
18903          return Array.from(
18904            els.preview.querySelectorAll(HIGHLIGHT_EXCLUDED_SELECTOR),
18905          ).some(function (node) {
18906            try {
18907              return range.intersectsNode(node);
18908            } catch (_error) {
18909              return false;
18910            }
18911          });
18912        }
18913
18914        function findTextModelOffset(model, container, offset) {
18915          if (container.nodeType === Node.TEXT_NODE) {
18916            for (var i = 0; i < model.segments.length; i++) {
18917              if (model.segments[i].node === container) {
18918                return (
18919                  model.segments[i].start + Math.min(offset, container.length)
18920                );
18921              }
18922            }
18923            return -1;
18924          }
18925          if (offset < container.childNodes.length) {
18926            var child = container.childNodes[offset];
18927            for (var i = 0; i < model.segments.length; i++) {
18928              if (
18929                child === model.segments[i].node ||
18930                child.contains(model.segments[i].node)
18931              ) {
18932                return model.segments[i].start;
18933              }
18934            }
18935          }
18936          if (offset > 0) {
18937            for (var i = model.segments.length - 1; i >= 0; i--) {
18938              if (container.contains(model.segments[i].node)) {
18939                return model.segments[i].end;
18940              }
18941            }
18942          }
18943          return -1;
18944        }
18945
18946        function getPreviewSelectionOffsets(range) {
18947          var model = state.highlightTextModel || getPreviewTextModel();
18948          var selectedText = range.toString();
18949          var startOff = findTextModelOffset(
18950            model,
18951            range.startContainer,
18952            range.startOffset,
18953          );
18954          var endOff = findTextModelOffset(
18955            model,
18956            range.endContainer,
18957            range.endOffset,
18958          );
18959
18960          if (startOff !== -1 && endOff !== -1 && endOff > startOff) {
18961            return { start: startOff, end: endOff, text: selectedText };
18962          }
18963
18964          var idx = model.text.indexOf(selectedText);
18965          if (idx !== -1) {
18966            return {
18967              start: idx,
18968              end: idx + selectedText.length,
18969              text: selectedText,
18970            };
18971          }
18972          return null;
18973        }
18974
18975        function clearSelectionController() {
18976          state.selectionController = null;
18977        }
18978
18979        function captureSelectionController() {
18980          const selection = window.getSelection();
18981          if (!selection || selection.rangeCount === 0) {
18982            clearSelectionController();
18983            return null;
18984          }
18985
18986          const range = selection.getRangeAt(0);
18987          if (range.collapsed || !isSelectionInPreview(range)) {
18988            clearSelectionController();
18989            return null;
18990          }
18991
18992          if (selectionIntersectsExcludedContent(range)) {
18993            clearSelectionController();
18994            return null;
18995          }
18996
18997          const rawText = range.toString();
18998          const trimmedText = rawText.trim();
18999          if (trimmedText.length < MIN_HIGHLIGHT_SELECTION_LENGTH) {
19000            clearSelectionController();
19001            return null;
19002          }
19003
19004          const offsets = getPreviewSelectionOffsets(range);
19005          if (!offsets) {
19006            clearSelectionController();
19007            return null;
19008          }
19009
19010          const rect = range.getBoundingClientRect();
19011          if (!rect || (!rect.width && !rect.height)) {
19012            clearSelectionController();
19013            return null;
19014          }
19015
19016          state.selectionController = {
19017            text: rawText,
19018            trimmedText: trimmedText,
19019            range: range.cloneRange(),
19020            rect: rect,
19021            start: offsets.start,
19022            end: offsets.end,
19023          };
19024          return state.selectionController;
19025        }
19026
19027        function hideSelectionBubble() {
19028          window.clearTimeout(state.selectionBubbleTimer);
19029          state.selectionBubbleTimer = null;
19030          els.selectionBubble.classList.remove("is-visible");
19031          els.selectionBubble.classList.add("hidden");
19032          els.selectionBubble.hidden = true;
19033          els.selectionBubble.setAttribute("aria-hidden", "true");
19034        }
19035
19036        function hideContextMenu() {
19037          els.ctxMenu.classList.add("hidden");
19038          els.ctxMenu.hidden = true;
19039          els.ctxMenu.setAttribute("aria-hidden", "true");
19040        }
19041
19042        function hideActiveHighlight() {
19043          state.activeHighlightId = "";
19044          els.highlightRemoveChip.classList.add("hidden");
19045          els.highlightRemoveChip.hidden = true;
19046          els.highlightRemoveChip.setAttribute("aria-hidden", "true");
19047          els.preview
19048            .querySelectorAll(".doc-highlight.is-active")
19049            .forEach(function (mark) {
19050              mark.classList.remove("is-active");
19051            });
19052        }
19053
19054        function dismissSelectionUi(options) {
19055          options = options || {};
19056          hideSelectionBubble();
19057          hideContextMenu();
19058          hideActiveHighlight();
19059          if (options.clearSelection !== false) {
19060            clearSelectionController();
19061          }
19062        }
19063
19064        function clampToViewport(value, size, viewportSize, gutter) {
19065          const min = gutter;
19066          const max = Math.max(gutter, viewportSize - size - gutter);
19067          return Math.min(max, Math.max(min, value));
19068        }
19069
19070        function positionBubbleAtRect(element, rect, options) {
19071          const opts = options || {};
19072          const gutter = opts.gutter || 8;
19073          const offset = opts.offset || 10;
19074          const preferBelow = Boolean(opts.preferBelow);
19075          const width = element.offsetWidth;
19076          const height = element.offsetHeight;
19077          const centerX = rect.left + rect.width / 2;
19078          const topAbove = rect.top - height - offset;
19079          const topBelow = rect.bottom + offset;
19080          let top = preferBelow ? topBelow : topAbove;
19081
19082          if (!preferBelow && top < gutter) {
19083            top = topBelow;
19084          } else if (
19085            preferBelow &&
19086            top + height > window.innerHeight - gutter
19087          ) {
19088            top = topAbove;
19089          }
19090
19091          top = clampToViewport(top, height, window.innerHeight, gutter);
19092          const left = clampToViewport(
19093            centerX - width / 2,
19094            width,
19095            window.innerWidth,
19096            gutter,
19097          );
19098
19099          element.style.left = Math.round(left) + "px";
19100          element.style.top = Math.round(top) + "px";
19101        }
19102
19103        function showSelectionBubble() {
19104          if (isCoarsePointer()) return;
19105          const controller =
19106            state.selectionController || captureSelectionController();
19107          if (!controller) {
19108            hideSelectionBubble();
19109            return;
19110          }
19111
19112          hideActiveHighlight();
19113          hideContextMenu();
19114          els.selectionBubble.hidden = false;
19115          els.selectionBubble.classList.remove("hidden");
19116          els.selectionBubble.setAttribute("aria-hidden", "false");
19117
19118          window.requestAnimationFrame(function () {
19119            positionBubbleAtRect(els.selectionBubble, controller.rect, {
19120              offset: 12,
19121            });
19122            els.selectionBubble.classList.add("is-visible");
19123          });
19124        }
19125
19126        function scheduleSelectionBubble() {
19127          if (isCoarsePointer()) return;
19128          window.clearTimeout(state.selectionBubbleTimer);
19129          state.selectionBubbleTimer = window.setTimeout(function () {
19130            if (captureSelectionController()) {
19131              showSelectionBubble();
19132            } else {
19133              hideSelectionBubble();
19134            }
19135          }, 80);
19136        }
19137
19138        function showContextMenu(x, y) {
19139          const controller =
19140            state.selectionController || captureSelectionController();
19141          if (!controller) {
19142            hideContextMenu();
19143            return;
19144          }
19145
19146          hideActiveHighlight();
19147          hideSelectionBubble();
19148          els.ctxMenu.hidden = false;
19149          els.ctxMenu.classList.remove("hidden");
19150          els.ctxMenu.setAttribute("aria-hidden", "false");
19151          els.ctxMenu.style.left = Math.round(x) + "px";
19152          els.ctxMenu.style.top = Math.round(y) + "px";
19153
19154          window.requestAnimationFrame(function () {
19155            const rect = els.ctxMenu.getBoundingClientRect();
19156            const left = clampToViewport(
19157              rect.left,
19158              rect.width,
19159              window.innerWidth,
19160              8,
19161            );
19162            const top = clampToViewport(
19163              rect.top,
19164              rect.height,
19165              window.innerHeight,
19166              8,
19167            );
19168            els.ctxMenu.style.left = Math.round(left) + "px";
19169            els.ctxMenu.style.top = Math.round(top) + "px";
19170          });
19171        }
19172
19173        async function copySelectedTextToClipboard() {
19174          const controller =
19175            state.selectionController || captureSelectionController();
19176          if (!controller) {
19177            setStatus("Select text to copy.", { announce: true });
19178            return;
19179          }
19180
19181          const text = String(controller.text || controller.trimmedText || "");
19182          const copied = await copyTextToClipboard(text);
19183          if (copied) {
19184            dismissSelectionUi({ clearSelection: false });
19185            setStatus("Copied selection.", { announce: true });
19186            return;
19187          }
19188
19189          setStatus("Copy failed. Use Cmd+C or the browser menu.", {
19190            announce: true,
19191          });
19192        }
19193
19194        function getPreviewTextModel() {
19195          const walker = document.createTreeWalker(
19196            els.preview,
19197            NodeFilter.SHOW_TEXT,
19198            {
19199              acceptNode: function (node) {
19200                if (!node.nodeValue || !node.parentElement) {
19201                  return NodeFilter.FILTER_REJECT;
19202                }
19203                if (node.parentElement.closest(HIGHLIGHT_EXCLUDED_SELECTOR)) {
19204                  return NodeFilter.FILTER_REJECT;
19205                }
19206                return NodeFilter.FILTER_ACCEPT;
19207              },
19208            },
19209          );
19210
19211          const segments = [];
19212          let text = "";
19213          let node = walker.nextNode();
19214
19215          while (node) {
19216            const value = node.nodeValue;
19217            const start = text.length;
19218            text += value;
19219            segments.push({
19220              node: node,
19221              start: start,
19222              end: text.length,
19223            });
19224            node = walker.nextNode();
19225          }
19226
19227          state.highlightTextModel = {
19228            text: text,
19229            segments: segments,
19230          };
19231          return state.highlightTextModel;
19232        }
19233
19234        function unwrapHighlights() {
19235          const marks = Array.from(
19236            els.preview.querySelectorAll("mark.doc-highlight"),
19237          );
19238          if (!marks.length) return;
19239
19240          marks.forEach(function (mark) {
19241            const parent = mark.parentNode;
19242            if (!parent) return;
19243            while (mark.firstChild) {
19244              parent.insertBefore(mark.firstChild, mark);
19245            }
19246            parent.removeChild(mark);
19247          });
19248          els.preview.normalize();
19249        }
19250
19251        function textMatchesAt(text, record, start, end) {
19252          if (start < 0 || end > text.length || end <= start) return false;
19253          return text.slice(start, end) === record.quote;
19254        }
19255
19256        function findResolvedHighlight(record, model) {
19257          if (!record || !record.quote) return null;
19258          if (textMatchesAt(model.text, record, record.start, record.end)) {
19259            return {
19260              id: record.id,
19261              start: record.start,
19262              end: record.end,
19263            };
19264          }
19265
19266          const occurrences = [];
19267          let fromIndex = 0;
19268          while (fromIndex <= model.text.length) {
19269            const idx = model.text.indexOf(record.quote, fromIndex);
19270            if (idx === -1) break;
19271            occurrences.push(idx);
19272            fromIndex = idx + Math.max(1, record.quote.length);
19273          }
19274
19275          if (!occurrences.length) {
19276            return null;
19277          }
19278
19279          let best = null;
19280          occurrences.forEach(function (start) {
19281            const end = start + record.quote.length;
19282            let score = 0;
19283
19284            if (record.prefix) {
19285              const actualPrefix = model.text.slice(
19286                Math.max(0, start - record.prefix.length),
19287                start,
19288              );
19289              if (actualPrefix === record.prefix) score += 4;
19290            }
19291
19292            if (record.suffix) {
19293              const actualSuffix = model.text.slice(
19294                end,
19295                Math.min(model.text.length, end + record.suffix.length),
19296              );
19297              if (actualSuffix === record.suffix) score += 4;
19298            }
19299
19300            score -= Math.abs(start - (record.start || 0)) / 1000;
19301            if (!best || score > best.score) {
19302              best = {
19303                score: score,
19304                start: start,
19305                end: end,
19306              };
19307            }
19308          });
19309
19310          if (!best) return null;
19311          return {
19312            id: record.id,
19313            start: best.start,
19314            end: best.end,
19315          };
19316        }
19317
19318        function createWrapOperationsForRange(model, resolved) {
19319          const ops = [];
19320          model.segments.forEach(function (segment) {
19321            if (
19322              resolved.end <= segment.start ||
19323              resolved.start >= segment.end
19324            ) {
19325              return;
19326            }
19327
19328            const startOffset = Math.max(0, resolved.start - segment.start);
19329            const endOffset = Math.min(
19330              segment.end - segment.start,
19331              resolved.end - segment.start,
19332            );
19333
19334            if (endOffset <= startOffset) return;
19335            ops.push({
19336              node: segment.node,
19337              startOffset: startOffset,
19338              endOffset: endOffset,
19339              globalStart: segment.start + startOffset,
19340              id: resolved.id,
19341            });
19342          });
19343          return ops;
19344        }
19345
19346        function applyWrapOperations(operations) {
19347          operations
19348            .slice()
19349            .sort(function (a, b) {
19350              return b.globalStart - a.globalStart;
19351            })
19352            .forEach(function (operation) {
19353              if (!operation.node || !operation.node.isConnected) return;
19354              const range = document.createRange();
19355              range.setStart(operation.node, operation.startOffset);
19356              range.setEnd(operation.node, operation.endOffset);
19357              const mark = document.createElement("mark");
19358              mark.className = "doc-highlight";
19359              mark.dataset.highlightId = operation.id;
19360              range.surroundContents(mark);
19361            });
19362        }
19363
19364        function syncActiveHighlightState() {
19365          els.preview
19366            .querySelectorAll(".doc-highlight")
19367            .forEach(function (mark) {
19368              mark.classList.toggle(
19369                "is-active",
19370                mark.dataset.highlightId === state.activeHighlightId,
19371              );
19372            });
19373        }
19374
19375        function refreshHighlightRemoveChip() {
19376          if (!state.activeHighlightId) {
19377            hideActiveHighlight();
19378            return;
19379          }
19380
19381          const marks = Array.from(
19382            els.preview.querySelectorAll(
19383              '.doc-highlight[data-highlight-id="' +
19384                state.activeHighlightId +
19385                '"]',
19386            ),
19387          );
19388          if (!marks.length) {
19389            hideActiveHighlight();
19390            return;
19391          }
19392
19393          let union = null;
19394          marks.forEach(function (mark) {
19395            const rect = mark.getBoundingClientRect();
19396            if (!union) {
19397              union = {
19398                left: rect.left,
19399                top: rect.top,
19400                right: rect.right,
19401                bottom: rect.bottom,
19402                width: rect.width,
19403                height: rect.height,
19404              };
19405              return;
19406            }
19407            union.left = Math.min(union.left, rect.left);
19408            union.top = Math.min(union.top, rect.top);
19409            union.right = Math.max(union.right, rect.right);
19410            union.bottom = Math.max(union.bottom, rect.bottom);
19411            union.width = union.right - union.left;
19412            union.height = union.bottom - union.top;
19413          });
19414
19415          els.highlightRemoveChip.hidden = false;
19416          els.highlightRemoveChip.classList.remove("hidden");
19417          els.highlightRemoveChip.setAttribute("aria-hidden", "false");
19418
19419          window.requestAnimationFrame(function () {
19420            positionBubbleAtRect(els.highlightRemoveChip, union, {
19421              offset: 12,
19422            });
19423          });
19424        }
19425
19426        function activateHighlight(id) {
19427          hideSelectionBubble();
19428          hideContextMenu();
19429          state.activeHighlightId = id || "";
19430          syncActiveHighlightState();
19431          refreshHighlightRemoveChip();
19432        }
19433
19434        function applyHighlightsToPreview() {
19435          dismissSelectionUi({ clearSelection: false });
19436          syncCurrentHighlights();
19437          unwrapHighlights();
19438
19439          const model = getPreviewTextModel();
19440          const resolved = [];
19441
19442          state.currentHighlights.forEach(function (record) {
19443            const match = findResolvedHighlight(record, model);
19444            if (match) resolved.push(match);
19445          });
19446
19447          resolved.sort(function (a, b) {
19448            return a.start - b.start;
19449          });
19450
19451          const nonOverlapping = [];
19452          let lastEnd = -1;
19453          resolved.forEach(function (item) {
19454            if (item.start < lastEnd) return;
19455            nonOverlapping.push(item);
19456            lastEnd = item.end;
19457          });
19458
19459          state.resolvedHighlights = nonOverlapping;
19460
19461          const operations = [];
19462          nonOverlapping.forEach(function (item) {
19463            operations.push.apply(
19464              operations,
19465              createWrapOperationsForRange(model, item),
19466            );
19467          });
19468          applyWrapOperations(operations);
19469
19470          if (state.activeHighlightId) {
19471            syncActiveHighlightState();
19472            refreshHighlightRemoveChip();
19473          }
19474        }
19475
19476        function syncCurrentHighlights() {
19477          const bucket = getHighlightStorageTarget().bucket;
19478          if (bucket !== state.highlightDocKey) {
19479            state.highlightDocKey = bucket;
19480            state.currentHighlights = readHighlightsForCurrentDocument();
19481            hideHighlightUndoToast();
19482          } else {
19483            state.currentHighlights = readHighlightsForCurrentDocument();
19484          }
19485        }
19486
19487        function createHighlightRecordFromSelection(controller) {
19488          const model = state.highlightTextModel || getPreviewTextModel();
19489          const start = controller.start;
19490          const end = controller.end;
19491          return {
19492            id: randomId("hl"),
19493            quote: controller.text,
19494            prefix: model.text.slice(
19495              Math.max(0, start - HIGHLIGHT_CONTEXT_WINDOW),
19496              start,
19497            ),
19498            suffix: model.text.slice(
19499              end,
19500              Math.min(model.text.length, end + HIGHLIGHT_CONTEXT_WINDOW),
19501            ),
19502            start: start,
19503            end: end,
19504            createdAt: new Date().toISOString(),
19505            updatedAt: new Date().toISOString(),
19506          };
19507        }
19508
19509        function clearBrowserSelection() {
19510          const selection = window.getSelection();
19511          if (selection) selection.removeAllRanges();
19512        }
19513
19514        function selectionOverlapsHighlight(start, end) {
19515          return state.resolvedHighlights.some(function (item) {
19516            return start < item.end && end > item.start;
19517          });
19518        }
19519
19520        function persistCurrentHighlights(records) {
19521          state.currentHighlights = records.slice();
19522          return saveHighlightsForCurrentDocument(state.currentHighlights);
19523        }
19524
19525        function removeHighlightById(id, options) {
19526          options = options || {};
19527          syncCurrentHighlights();
19528          const removed = state.currentHighlights.find(function (record) {
19529            return record.id === id;
19530          });
19531          if (!removed) return;
19532
19533          const next = state.currentHighlights.filter(function (record) {
19534            return record.id !== id;
19535          });
19536          const didPersist = persistCurrentHighlights(next);
19537          hideActiveHighlight();
19538          applyHighlightsToPreview();
19539
19540          if (!options.skipUndo) {
19541            showHighlightUndoToast("Highlight removed.", function () {
19542              const restored = readHighlightsForCurrentDocument();
19543              if (
19544                restored.some(function (record) {
19545                  return record.id === removed.id;
19546                })
19547              ) {
19548                return;
19549              }
19550              restored.push(removed);
19551              const didPersist = persistCurrentHighlights(restored);
19552              applyHighlightsToPreview();
19553              setHighlightStatus(
19554                didPersist
19555                  ? "Highlight restored."
19556                  : "Highlight restored. Saved only for this session.",
19557                { announce: true },
19558              );
19559            });
19560          }
19561
19562          setHighlightStatus(
19563            didPersist
19564              ? "Highlight removed."
19565              : "Highlight removed. Saved only for this session.",
19566            { announce: true },
19567          );
19568        }
19569
19570        function addHighlightFromSelection() {
19571          const controller =
19572            state.selectionController || captureSelectionController();
19573          if (!controller) return;
19574
19575          if (selectionIntersectsExcludedContent(controller.range)) {
19576            dismissSelectionUi();
19577            setStatus("Highlighting code blocks is not supported yet.", {
19578              announce: true,
19579              kind: "error",
19580            });
19581            return;
19582          }
19583
19584          syncCurrentHighlights();
19585          if (state.currentHighlights.length >= MAX_HIGHLIGHTS_PER_DOCUMENT) {
19586            dismissSelectionUi();
19587            setStatus("Highlight limit reached for this document.", {
19588              announce: true,
19589              kind: "error",
19590            });
19591            return;
19592          }
19593
19594          if (selectionOverlapsHighlight(controller.start, controller.end)) {
19595            dismissSelectionUi();
19596            setStatus("That selection overlaps an existing highlight.", {
19597              announce: true,
19598              kind: "error",
19599            });
19600            return;
19601          }
19602
19603          const record = createHighlightRecordFromSelection(controller);
19604          const next = state.currentHighlights.concat(record);
19605          const didPersist = persistCurrentHighlights(next);
19606          dismissSelectionUi();
19607          clearBrowserSelection();
19608          applyHighlightsToPreview();
19609          activateHighlight(record.id);
19610          showHighlightUndoToast("Highlight added.", function () {
19611            removeHighlightById(record.id, { skipUndo: true });
19612            setHighlightStatus("Highlight undone.", { announce: true });
19613          });
19614          setHighlightStatus(
19615            didPersist
19616              ? "Highlight added."
19617              : "Highlight added. Saved only for this session.",
19618            { announce: true },
19619          );
19620        }
19621
19622        function dispatchSelectionAction(type) {
19623          const controller =
19624            state.selectionController || captureSelectionController();
19625          if (!controller) return;
19626          dismissSelectionUi({ clearSelection: false });
19627          document.dispatchEvent(
19628            new CustomEvent("markv:selection-action", {
19629              detail: {
19630                type: type,
19631                text: controller.trimmedText,
19632              },
19633            }),
19634          );
19635          clearBrowserSelection();
19636          clearSelectionController();
19637        }
19638
19639        function cleanUrl(href) {
19640          if (!href) return "";
19641          const collapsed = String(href)
19642            .trim()
19643            .replace(/[\u0000-\u001F\u007F-\u009F\s]+/g, "");
19644          const lower = collapsed.toLowerCase();
19645          if (
19646            lower.startsWith("javascript:") ||
19647            lower.startsWith("vbscript:") ||
19648            lower.startsWith("data:")
19649          ) {
19650            return "";
19651          }
19652          return String(href).trim();
19653        }
19654
19655        function isLoopbackHostname(hostname) {
19656          const normalized = String(hostname || "")
19657            .replace(/^\[|\]$/g, "")
19658            .toLowerCase();
19659          return (
19660            normalized === "localhost" ||
19661            normalized === "127.0.0.1" ||
19662            normalized === "::1"
19663          );
19664        }
19665
19666        function isPrivateUrl(url) {
19667          if (!url) return false;
19668          if (url.origin === window.location.origin) return true;
19669          return (
19670            (url.protocol === "http:" || url.protocol === "https:") &&
19671            isLoopbackHostname(url.hostname)
19672          );
19673        }
19674
19675        const TRUSTED_EXTERNAL_MEDIA_HOSTS = Object.freeze([
19676          "github.com",
19677          "raw.githubusercontent.com",
19678          "user-images.githubusercontent.com",
19679          "private-user-images.githubusercontent.com",
19680          "media.githubusercontent.com",
19681          "avatars.githubusercontent.com",
19682        ]);
19683        const TRUSTED_EXTERNAL_MEDIA_HOST_SET = new Set(
19684          TRUSTED_EXTERNAL_MEDIA_HOSTS,
19685        );
19686        const IMAGE_MEDIA_EXTENSION_PATTERN =
19687          /\.(?:avif|bmp|gif|ico|jpe?g|png|webp)$/i;
19688        const VIDEO_MEDIA_EXTENSION_PATTERN =
19689          /\.(?:m4v|mov|mp4|ogv|webm)$/i;
19690        const EXTERNAL_SVG_EXTENSION_PATTERN = /\.(?:svg|svgz)$/i;
19691
19692        function isTrustedExternalMediaUrl(url) {
19693          return Boolean(
19694            url &&
19695              url.protocol === "https:" &&
19696              TRUSTED_EXTERNAL_MEDIA_HOST_SET.has(
19697                String(url.hostname || "").toLowerCase(),
19698              ),
19699          );
19700        }
19701
19702        function isTrustedMarkdownMediaUrl(url) {
19703          return isPrivateUrl(url) || isTrustedExternalMediaUrl(url);
19704        }
19705
19706        function getMarkdownMediaKind(url) {
19707          return VIDEO_MEDIA_EXTENSION_PATTERN.test(url.pathname || "")
19708            ? "video"
19709            : "image";
19710        }
19711
19712        function resolveUrl(href) {
19713          const cleaned = cleanUrl(href);
19714          if (!cleaned) return null;
19715          try {
19716            return new URL(cleaned, state.baseHref || window.location.href);
19717          } catch (_error) {
19718            return null;
19719          }
19720        }
19721
19722        function resolveLinkHref(href) {
19723          var raw = String(href || "").trim();
19724          // Keep fragment-only links as-is (anchor navigation)
19725          if (raw.startsWith("#")) return raw;
19726          const resolved = resolveUrl(href);
19727          if (resolved) return resolved.toString();
19728          return cleanUrl(href);
19729        }
19730
19731        function resolveMarkdownMedia(href) {
19732          const resolved = resolveUrl(href);
19733          if (!resolved || !isTrustedMarkdownMediaUrl(resolved)) return null;
19734          const isExternal = isTrustedExternalMediaUrl(resolved);
19735          if (
19736            isExternal &&
19737            EXTERNAL_SVG_EXTENSION_PATTERN.test(resolved.pathname || "")
19738          ) {
19739            return null;
19740          }
19741          if (
19742            isExternal &&
19743            !IMAGE_MEDIA_EXTENSION_PATTERN.test(resolved.pathname || "") &&
19744            !VIDEO_MEDIA_EXTENSION_PATTERN.test(resolved.pathname || "")
19745          ) {
19746            return null;
19747          }
19748          return {
19749            href: resolved.toString(),
19750            kind: getMarkdownMediaKind(resolved),
19751          };
19752        }
19753
19754        function clampNumber(value, min, max) {
19755          return Math.min(max, Math.max(min, value));
19756        }
19757
19758        function createIdentityMappedDocument(text) {
19759          var value = String(text || "");
19760          return {
19761            text: value,
19762            segments: [
19763              {
19764                outStart: 0,
19765                outEnd: value.length,
19766                srcStart: 0,
19767                srcEnd: value.length,
19768              },
19769            ],
19770          };
19771        }
19772
19773        function createMappedBuilder() {
19774          return {
19775            parts: [],
19776            segments: [],
19777            outLength: 0,
19778          };
19779        }
19780
19781        function appendMappedText(builder, text, srcStart, srcEnd) {
19782          if (!text) return;
19783          builder.parts.push(text);
19784          builder.segments.push({
19785            outStart: builder.outLength,
19786            outEnd: builder.outLength + text.length,
19787            srcStart: srcStart,
19788            srcEnd: srcEnd,
19789          });
19790          builder.outLength += text.length;
19791        }
19792
19793        function finishMappedDocument(builder) {
19794          return {
19795            text: builder.parts.join(""),
19796            segments: builder.segments,
19797          };
19798        }
19799
19800        function findMappedSegmentIndex(segments, offset) {
19801          var low = 0;
19802          var high = segments.length - 1;
19803
19804          while (low <= high) {
19805            var mid = Math.floor((low + high) / 2);
19806            var segment = segments[mid];
19807
19808            if (offset < segment.outStart) {
19809              high = mid - 1;
19810            } else if (offset > segment.outEnd) {
19811              low = mid + 1;
19812            } else {
19813              return mid;
19814            }
19815          }
19816
19817          return clampNumber(low, 0, Math.max(0, segments.length - 1));
19818        }
19819
19820        function mapSegmentOffsetToSource(segment, outOffset) {
19821          if (!segment) return 0;
19822          if (
19823            segment.outEnd <= segment.outStart ||
19824            segment.srcEnd <= segment.srcStart
19825          ) {
19826            return segment.srcStart;
19827          }
19828
19829          var clampedOffset = clampNumber(
19830            outOffset,
19831            segment.outStart,
19832            segment.outEnd,
19833          );
19834          var fraction =
19835            (clampedOffset - segment.outStart) /
19836            Math.max(1, segment.outEnd - segment.outStart);
19837
19838          return Math.round(
19839            segment.srcStart + fraction * (segment.srcEnd - segment.srcStart),
19840          );
19841        }
19842
19843        function mapDocumentOffsetToSource(doc, offset) {
19844          var segments = doc && doc.segments ? doc.segments : [];
19845          if (!segments.length) return 0;
19846          var maxOffset = segments[segments.length - 1].outEnd;
19847          var clampedOffset = clampNumber(offset, 0, maxOffset);
19848          return mapSegmentOffsetToSource(
19849            segments[findMappedSegmentIndex(segments, clampedOffset)],
19850            clampedOffset,
19851          );
19852        }
19853
19854        function mapDocumentRangeToSource(doc, start, end) {
19855          return {
19856            start: mapDocumentOffsetToSource(doc, start),
19857            end: mapDocumentOffsetToSource(doc, end),
19858          };
19859        }
19860
19861        function appendDocumentSlice(builder, doc, start, end) {
19862          if (!doc || end <= start) return;
19863
19864          var segments = doc.segments || [];
19865          if (!segments.length) return;
19866
19867          var index = findMappedSegmentIndex(segments, start);
19868          while (index < segments.length) {
19869            var segment = segments[index];
19870            var sliceStart = Math.max(start, segment.outStart);
19871            var sliceEnd = Math.min(end, segment.outEnd);
19872
19873            if (sliceEnd > sliceStart) {
19874              appendMappedText(
19875                builder,
19876                doc.text.slice(sliceStart, sliceEnd),
19877                mapSegmentOffsetToSource(segment, sliceStart),
19878                mapSegmentOffsetToSource(segment, sliceEnd),
19879              );
19880            }
19881
19882            if (segment.outEnd >= end) break;
19883            index += 1;
19884          }
19885        }
19886
19887        function getSourceAttrString(node) {
19888          if (!node || typeof node.__sourceStart !== "number") return "";
19889          var end =
19890            typeof node.__sourceEnd === "number"
19891              ? node.__sourceEnd
19892              : node.__sourceStart;
19893          return (
19894            ' data-source-start="' +
19895            node.__sourceStart +
19896            '" data-source-end="' +
19897            end +
19898            '"'
19899          );
19900        }
19901
19902        function injectSourceAttrs(html, node) {
19903          var attrs = getSourceAttrString(node);
19904          if (!attrs) return html;
19905          return String(html || "").replace(
19906            /^<([a-z][a-z0-9:-]*)(?=[\s>])/i,
19907            "<$1" + attrs,
19908          );
19909        }
19910
19911        const renderer = new marked.Renderer();
19912        const defaultCodeRenderer = renderer.code.bind(renderer);
19913        renderer.code = function (code, infostring, escaped) {
19914          var csvSheet = window.MarkVCsvSheet;
19915
19916          if (
19917            csvSheet &&
19918            typeof csvSheet.isCsvInfoString === "function" &&
19919            csvSheet.isCsvInfoString(infostring)
19920          ) {
19921            try {
19922              var rendered = csvSheet.renderCsvSheet(
19923                code,
19924                infostring,
19925                escapeHtml,
19926              );
19927              if (rendered) return rendered;
19928            } catch (_error) {
19929              // Fall through to the normal code block renderer.
19930            }
19931          }
19932
19933          return defaultCodeRenderer(code, infostring, escaped);
19934        };
19935        const MARKDOWN_ALERTS = {
19936          note: {
19937            label: "Note",
19938            icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5"/><path d="M8 7.1V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="4.7" r="0.9" fill="currentColor"/></svg>',
19939          },
19940          tip: {
19941            label: "Tip",
19942            icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5.5 10.5C5.74 9.76 4 8.66 4 6.5a4 4 0 1 1 8 0c0 2.16-1.74 3.26-1.5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
19942<path d="M6.1 12.25h3.8M6.55 14h2.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>',
19943          },
19944          important: {
19945            label: "Important",
19946            icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 1.75 13 5v6L8 14.25 3 11V5L8 1.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 5.2v3.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r="0.85" fill="currentColor"/></svg>',
19947          },
19948          warning: {
19949            label: "Warning",
19950            icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 2.2 14 13H2L8 2.2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.5" r="0.85" fill="currentColor"/></svg>',
19951          },
19952          caution: {
19953            label: "Caution",
19954            icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 1.75h6L14.25 5v6L11 14.25H5L1.75 11V5L5 1.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="m6.1 6.1 3.8 3.8M9.9 6.1l-3.8 3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>',
19955          },
19956        };
19957        const MARKDOWN_ALERT_KINDS = Object.keys(MARKDOWN_ALERTS);
19958        const MARKDOWN_ALERT_KIND_GROUP = MARKDOWN_ALERT_KINDS.map(
19959          function (kind) {
19960            return kind.toUpperCase();
19961          },
19962        ).join("|");
19963        const MARKDOWN_ALERT_START_PATTERN = new RegExp(
19964          "^ {0,3}>[ \\t]?\\[!(" + MARKDOWN_ALERT_KIND_GROUP + ")\\]",
19965          "im",
19966        );
19967        const MARKDOWN_ALERT_MARKER_PATTERN = new RegExp(
19968          "^ {0,3}>[ \\t]?\\[!((?:" +
19969            MARKDOWN_ALERT_KIND_GROUP +
19970            "))\\][ \\t]*(.*)$",
19971          "i",
19972        );
19973        const MARKDOWN_ALERT_BLOCK_PATTERN = /^(?: {0,3}>[^\n]*(?:\n|$))+/;
19974
19975        function renderAnchoredHeadingToken(token) {
19976          if (!token || typeof token.__sourceStart !== "number") return false;
19977          var parser = this.parser;
19978          return injectSourceAttrs(
19979            parser.renderer.heading(
19980              parser.parseInline(token.tokens || []),
19981              token.depth,
19982              unescape(
19983                parser.parseInline(token.tokens || [], parser.textRenderer),
19984              ),
19985              parser.slugger,
19986            ),
19987            token,
19988          );
19989        }
19990
19991        function renderAnchoredCodeToken(token) {
19992          if (!token || typeof token.__sourceStart !== "number") return false;
19993          return injectSourceAttrs(
19994            this.parser.renderer.code(token.text, token.lang, token.escaped),
19995            token,
19996          );
19997        }
19998
19999        function renderAnchoredHrToken(token) {
20000          if (!token || typeof token.__sourceStart !== "number") return false;
20001          return injectSourceAttrs(this.parser.renderer.hr(), token);
20002        }
20003
20004        function renderAnchoredTableToken(token) {
20005          if (!token || typeof token.__sourceStart !== "number") return false;
20006          var parser = this.parser;
20007          var header = "";
20008          var body = "";
20009          var cell = "";
20010          var row = "";
20011          var j = 0;
20012          var k = 0;
20013
20014          for (j = 0; j < token.header.length; j += 1) {
20015            cell += parser.renderer.tablecell(
20016              parser.parseInline(token.header[j].tokens),
20017              {
20018                header: true,
20019                align: token.align[j],
20020              },
20021            );
20022          }
20023          header += parser.renderer.tablerow(cell);
20024
20025          for (j = 0; j < token.rows.length; j += 1) {
20026            row = token.rows[j];
20027            cell = "";
20028            for (k = 0; k < row.length; k += 1) {
20029              cell += parser.renderer.tablecell(
20030                parser.parseInline(row[k].tokens),
20031                {
20032                  header: false,
20033                  align: token.align[k],
20034                },
20035              );
20036            }
20037            body += parser.renderer.tablerow(cell);
20038          }
20039
20040          return injectSourceAttrs(parser.renderer.table(header, body), token);
20041        }
20042
20043        function renderAnchoredBlockquoteToken(token) {
20044          if (!token || typeof token.__sourceStart !== "number") return false;
20045          return injectSourceAttrs(
20046            this.parser.renderer.blockquote(
20047              this.parser.parse(token.tokens || []),
20048            ),
20049            token,
20050          );
20051        }
20052
20053        function renderAnchoredHtmlToken(token) {
20054          if (!token || typeof token.__sourceStart !== "number") return false;
20055          return injectSourceAttrs(
20056            this.parser.renderer.html(token.text),
20057            token,
20058          );
20059        }
20060
20061        function renderAnchoredParagraphToken(token) {
20062          if (!token || typeof token.__sourceStart !== "number") return false;
20063          return injectSourceAttrs(
20064            this.parser.renderer.paragraph(
20065              this.parser.parseInline(token.tokens || []),
20066            ),
20067            token,
20068          );
20069        }
20070
20071        function renderAnchoredTextToken(token) {
20072          if (!token || typeof token.__sourceStart !== "number") return false;
20073          var parser = this.parser;
20074          var body = token.tokens
20075            ? parser.parseInline(token.tokens)
20076            : token.text;
20077          return injectSourceAttrs(parser.renderer.paragraph(body), token);
20078        }
20079
20080        function renderAnchoredListToken(token) {
20081          if (!token || typeof token.__sourceStart !== "number") return false;
20082          var parser = this.parser;
20083          var ordered = token.ordered;
20084          var start = token.start;
20085          var loose = token.loose;
20086          var body = "";
20087
20088          for (var j = 0; j < token.items.length; j += 1) {
20089            var item = token.items[j];
20090            var checked = item.checked;
20091            var task = item.task;
20092            var itemBody = "";
20093
20094            if (item.task) {
20095              var checkbox = parser.renderer.checkbox(checked);
20096
20097              if (loose) {
20098                if (
20099                  item.tokens.length > 0 &&
20100                  item.tokens[0].type === "paragraph"
20101                ) {
20102                  item.tokens[0].text = checkbox + " " + item.tokens[0].text;
20103
20104                  if (
20105                    item.tokens[0].tokens &&
20106                    item.tokens[0].tokens.length > 0 &&
20107                    item.tokens[0].tokens[0].type === "text"
20108                  ) {
20109                    item.tokens[0].tokens[0].text =
20110                      checkbox + " " + item.tokens[0].tokens[0].text;
20111                  }
20112                } else {
20113                  item.tokens.unshift({
20114                    type: "text",
20115                    text: checkbox,
20116                  });
20117                }
20118              } else {
20119                itemBody += checkbox;
20120              }
20121            }
20122
20123            itemBody += parser.parse(item.tokens, loose);
20124            body += injectSourceAttrs(
20125              parser.renderer.listitem(itemBody, task, checked),
20126              item,
20127            );
20128          }
20129
20130          return injectSourceAttrs(
20131            parser.renderer.list(body, ordered, start),
20132            token,
20133          );
20134        }
20135
20136        marked.use({
20137          extensions: [
20138            {
20139              name: "markdown-alert",
20140              level: "block",
20141              start: function (src) {
20142                const match = src.match(MARKDOWN_ALERT_START_PATTERN);
20143                return match ? match.index : undefined;
20144              },
20145              tokenizer: function (src) {
20146                const blockMatch = MARKDOWN_ALERT_BLOCK_PATTERN.exec(src);
20147                if (!blockMatch) return;
20148
20149                const raw = blockMatch[0];
20150                const lines = raw.replace(/\n$/, "").split("\n");
20151                const markerMatch = MARKDOWN_ALERT_MARKER_PATTERN.exec(
20152                  lines[0],
20153                );
20154                if (!markerMatch) return;
20155
20156                const kind = markerMatch[1].toLowerCase();
20157                const bodyLines = [];
20158                const leadingText = String(markerMatch[2] || "").trim();
20159
20160                if (leadingText) bodyLines.push(leadingText);
20161                for (let i = 1; i < lines.length; i += 1) {
20162                  bodyLines.push(lines[i].replace(/^ {0,3}> ?/, ""));
20163                }
20164
20165                const body = bodyLines.join("\n").replace(/^\n+|\n+$/g, "");
20166
20167                return {
20168                  type: "markdown-alert",
20169                  raw: raw,
20170                  kind: kind,
20171                  tokens: body ? this.lexer.blockTokens(body) : [],
20172                };
20173              },
20174              renderer: function (token) {
20175                const config =
20176                  MARKDOWN_ALERTS[token.kind] || MARKDOWN_ALERTS.note;
20177                const content = this.parser.parse(token.tokens || []);
20178                return (
20179                  '<aside class="markdown-alert" data-alert-kind="' +
20180                  token.kind +
20181                  '"' +
20182                  getSourceAttrString(token) +
20183                  ">" +
20184                  '<p class="markdown-alert-title">' +
20185                  config.icon +
20186                  "<span>" +
20187                  config.label +
20188                  "</span></p>" +
20189                  '<div class="markdown-alert-content">' +
20190                  content +
20191                  "</div></aside>\n"
20192                );
20193              },
20194            },
20195            {
20196              name: "heading",
20197              renderer: renderAnchoredHeadingToken,
20198            },
20199            {
20200              name: "code",
20201              renderer: renderAnchoredCodeToken,
20202            },
20203            {
20204              name: "hr",
20205              renderer: renderAnchoredHrToken,
20206            },
20207            {
20208              name: "table",
20209              renderer: renderAnchoredTableToken,
20210            },
20211            {
20212              name: "blockquote",
20213              renderer: renderAnchoredBlockquoteToken,
20214            },
20215            {
20216              name: "list",
20217              renderer: renderAnchoredListToken,
20218            },
20219            {
20220              name: "html",
20221              renderer: renderAnchoredHtmlToken,
20222            },
20223            {
20224              name: "paragraph",
20225              renderer: renderAnchoredParagraphToken,
20226            },
20227            {
20228              name: "text",
20229              renderer: renderAnchoredTextToken,
20230            },
20231          ],
20232        });
20233
20234        // Safe inline & block HTML tags that are standard in markdown documents.
20235        // Anything not on this list gets escaped into a visible raw-html block.
20236        var SAFE_HTML_TAGS = [
20237          // inline semantic
20238          "kbd",
20239          "sup",
20240          "sub",
20241          "mark",
20242          "abbr",
20243          "ins",
20244          "del",
20245          "small",
20246          "br",
20247          "wbr",
20248          "time",
20249          "cite",
20250          "dfn",
20251          "var",
20252          "samp",
20253          "ruby",
20254          "rt",
20255          "rp",
20256          "bdo",
20257          "bdi",
20258          "q",
20259          "s",
20260          "u",
20261          "b",
20262          "i",
20263          "em",
20264          "strong",
20265          "span",
20266          // disclosure
20267          "details",
20268          "summary",
20269          // description lists
20270          "dl",
20271          "dt",
20272          "dd",
20273          // figures
20274          "figure",
20275          "figcaption",
20276          // footnotes & lists
20277          "section",
20278          "ol",
20279          "ul",
20280          "li",
20281          "a",
20282          // misc safe block
20283          "div",
20284          "p",
20285          "hr",
20286        ];
20287        var SAFE_TAG_PATTERN = new RegExp(
20288          "^\\s*<\\/?(" + SAFE_HTML_TAGS.join("|") + ")(\\s|>|\\/|$)",
20289          "i",
20290        );
20291        var DANGEROUS_TAG_PATTERN =
20292          /<(script|style|iframe|object|embed|form|input|textarea|
20292select|button|link|meta|base)\b/i;
20293        var DANGEROUS_ATTR_PATTERN = /\b(on\w+|srcdoc|formaction)\s*=/i;
20294
20295        renderer.html = function (html) {
20296          if (
20297            SAFE_TAG_PATTERN.test(html) &&
20298            !DANGEROUS_TAG_PATTERN.test(html) &&
20299            !DANGEROUS_ATTR_PATTERN.test(html)
20300          ) {
20301            return html;
20302          }
20303          return (
20304            '<pre class="raw-html"><code>' +
20305            escapeHtml(html) +
20306            "</code></pre>\n"
20307          );
20308        };
20309
20310        renderer.link = function (href, title, text) {
20311          const resolved = resolveLinkHref(href);
20312          if (!resolved) return text;
20313          let out = '<a href="' + escapeHtml(resolved) + '"';
20314          if (title) out += ' title="' + escapeHtml(title) + '"';
20315          out += ' referrerpolicy="no-referrer"';
20316          out += ">" + text + "</a>";
20317          return out;
20318        };
20319
20320        renderer.image = function (href, title, text) {
20321          const media = resolveMarkdownMedia(href);
20322          if (!media) {
20323            return (
20324              '<span class="blocked-resource">Remote media blocked for privacy' +
20325              (text ? ": " + escapeHtml(text) : ".") +
20326              "</span>"
20327            );
20328          }
20329          if (media.kind === "video") {
20330            let video =
20331              '<video src="' +
20332              escapeHtml(media.href) +
20333              '" controls preload="metadata" playsinline';
20334            if (title) video += ' title="' + escapeHtml(title) + '"';
20335            if (text) video += ' aria-label="' + escapeHtml(text) + '"';
20336            video += "></video>";
20337            return video;
20338          }
20339          let out =
20340            '<img src="' +
20341            escapeHtml(media.href) +
20342            '" alt="' +
20343            escapeHtml(text || "") +
20344            '"';
20345          if (title) out += ' title="' + escapeHtml(title) + '"';
20346          out +=
20347            ' loading="lazy" decoding="async" referrerpolicy="no-referrer">';
20348          return out;
20349        };
20350
20351        renderer.listitem = function (text, task) {
20352          return task
20353            ? '<li class="task-list-item">' + text + "</li>\n"
20354            : "<li>" + text + "</li>\n";
20355        };
20356
20357        // Task markers are intentionally static glyphs. The preview is read-only,
20358        // so we avoid interactive form controls and expose only status semantics.
20359        renderer.checkbox = function (checked) {
20360          return (
20361            '<span class="task-list-item-checkbox' +
20362            (checked ? " is-checked" : "") +
20363            '" role="img" aria-label="' +
20364            (checked ? "Checked" : "Not checked") +
20365            '">' +
20366            (checked
20367              ? '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M3.75 8.25L6.5 11L12.25 5.25" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
20368              : "") +
20369            "</span>"
20370          );
20371        };
20372
20373        const markedOptions = {
20374          gfm: true,
20375          breaks: true,
20376          mangle: false,
20377          headerIds: true,
20378          smartLists: true,
20379          renderer: renderer,
20380        };
20381
20382        function nowString() {
20383          return new Date().toLocaleTimeString([], {
20384            hour: "2-digit",
20385            minute: "2-digit",
20386            second: "2-digit",
20387          });
20388        }
20389
20390        function setStatus(message, options) {
20391          options = options || {};
20392          els.status.textContent = message;
20393          els.status.title = message;
20394          els.status.dataset.kind = options.kind || "default";
20395          if (options.announce) announce(message);
20396        }
20397
20398        function hideAppToast(immediate) {
20399          if (!els.appToast) return;
20400          window.clearTimeout(state.appToastTimer);
20401          state.appToastTimer = null;
20402          els.appToast.classList.remove("is-visible");
20403          els.appToast.setAttribute("aria-hidden", "true");
20404
20405          if (immediate) {
20406            els.appToast.hidden = true;
20407            return;
20408          }
20409
20410          state.appToastTimer = window.setTimeout(function () {
20411            els.appToast.hidden = true;
20412          }, 240);
20413        }
20414
20415        function showAppToast(options) {
20416          options = options || {};
20417          if (!els.appToast || !els.appToastTitle || !els.appToastMessage) {
20418            if (options.message) announce(options.message);
20419            return;
20420          }
20421
20422          const kind = options.kind || "default";
20423          const eyebrow = options.eyebrow || "MarkV";
20424          const title = options.title || "";
20425          const message = options.message || "";
20426          window.clearTimeout(state.appToastTimer);
20427          state.appToastTimer = null;
20428
20429          if (els.appToastEyebrow) {
20430            els.appToastEyebrow.textContent = eyebrow;
20431          }
20432          els.appToastTitle.textContent = title;
20433          els.appToastMessage.textContent = message;
20434          els.appToast.dataset.kind = kind;
20435          els.appToast.setAttribute(
20436            "role",
20437            kind === "error" ? "alert" : "status",
20438          );
20439          els.appToast.setAttribute(
20440            "aria-live",
20441            kind === "error" ? "assertive" : "polite",
20442          );
20443          els.appToast.setAttribute("aria-hidden", "false");
20444          els.appToast.hidden = false;
20445
20446          window.requestAnimationFrame(function () {
20447            els.appToast.classList.add("is-visible");
20448          });
20449
20450          if (options.announce) {
20451            announce([title, message].filter(Boolean).join(". "));
20452          }
20453
20454          state.appToastTimer = window.setTimeout(function () {
20455            hideAppToast(false);
20456          }, options.duration || 5200);
20457        }
20458
20459        function showShareFailureToast(message) {
20460          const detail = String(message || "Snapshot sharing failed.").trim();
20461          setStatus("Share failed", { kind: "error" });
20462          showAppToast({
20463            eyebrow: "Share",
20464            title: "Share failed",
20465            message: detail,
20466            kind: "error",
20467            duration: 6500,
20468            announce: true,
20469          });
20470        }
20471
20472        function waitForNextPaint() {
20473          return new Promise(function (resolve) {
20474            if (typeof requestAnimationFrame !== "function") {
20475              window.setTimeout(resolve, 0);
20476              return;
20477            }
20478            requestAnimationFrame(function () {
20479              requestAnimationFrame(resolve);
20480            });
20481          });
20482        }
20483
20484        function getSnapshotBaseUrl() {
20485          const url = new URL(window.location.href);
20486          url.search = "";
20487          url.hash = "";
20488          return url.toString();
20489        }
20490
20491        function updateAddressBarHash(hash) {
20492          const nextHash = String(hash || "");
20493          const url = new URL(window.location.href);
20494          url.hash = nextHash ? nextHash.slice(1) : "";
20495          history.replaceState(null, "", url.toString());
20496        }
20497
20498        function clearSnapshotHashIfPresent() {
20499          const legacyPrefix =
20500            typeof libraryCore.SHARE_FRAGMENT_PREFIX === "string"
20501              ? libraryCore.SHARE_FRAGMENT_PREFIX
20502              : "#mkv=";
20503          const shortPrefix =
20504            typeof libraryCore.SHARE_SHORT_FRAGMENT_PREFIX === "string"
20505              ? libraryCore.SHARE_SHORT_FRAGMENT_PREFIX
20506              : "#m:";
20507          const hash = String(window.location.hash || "");
20508          if (!hash.startsWith(legacyPrefix) && !hash.startsWith(shortPrefix)) {
20509            return;
20510          }
20511          updateAddressBarHash("");
20512        }
20513
20514        function detachSharedSnapshotContext(options) {
20515          options = options || {};
20516          state.sharedSnapshotMeta = null;
20517          if (!options.preserveHash) {
20518            clearSnapshotHashIfPresent();
20519          }
20520        }
20521
20522        function legacyCopyText(text) {
20523          const input = document.createElement("textarea");
20524          input.value = text;
20525          input.setAttribute("readonly", "readonly");
20526          input.style.position = "fixed";
20527          input.style.left = "-9999px";
20528          input.style.top = "0";
20529          document.body.appendChild(input);
20530          input.focus();
20531          input.select();
20532
20533          let copied = false;
20534          try {
20535            copied = document.execCommand("copy");
20536          } catch (_error) {
20537            copied = false;
20538          }
20539
20540          input.remove();
20541          return copied;
20542        }
20543
20544        async function copyTextToClipboard(text) {
20545          if (
20546            navigator.clipboard &&
20547            typeof navigator.clipboard.writeText === "function" &&
20548            window.isSecureContext
20549          ) {
20550            try {
20551              await navigator.clipboard.writeText(text);
20552              return true;
20553            } catch (_error) {}
20554          }
20555
20556          return legacyCopyText(text);
20557        }
20558
20559        async function buildSnapshotShareUrl() {
20560          const text = String(els.editor.value || "");
20561          if (!text.trim()) {
20562            throw new Error("There is no markdown to share yet.");
20563          }
20564
20565          if (typeof libraryCore.createShareSnapshotFragment !== "function") {
20566            throw new Error("Snapshot sharing is unavailable in this build.");
20567          }
20568
20569          const fragment = await libraryCore.createShareSnapshotFragment({
20570            text: text,
20571            name: state.currentName,
20572            view: isEditModeActive() ? "edit" : "preview",
20573          });
20574          const shareUrl = getSnapshotBaseUrl() + fragment;
20575
20576          if (shareUrl.length > MAX_SHARE_URL_LENGTH) {
20577            throw new Error(
20578              "This document is too large for link sharing. Use Save or Download instead.",
20579            );
20580          }
20581
20582          return {
20583            fragment: fragment,
20584            url: shareUrl,
20585          };
20586        }
20587
20588        async function applySharedSnapshotPayload(payload) {
20589          const next = payload && typeof payload === "object" ? payload : null;
20590          if (!next || typeof next.text !== "string") return false;
20591
20592          await flushActiveUntitledDraft().catch(function (error) {
20593            console.warn("[drafts] active draft flush failed", error);
20594          });
20595          stopAutoRefresh();
20596          els.autoRefreshToggle.checked = false;
20597
20598          setPrimaryFileHandle(null);
20599          state.untitledDraftId = "";
20600          state.sourceMode = "editor";
20601          state.currentName = String(next.name || "").trim();
20602          state.baseHref = getSnapshotBaseUrl();
20603          state.dirty = false;
20604          state.hasSessionDraft = true;
20605          detachSharedSnapshotContext({ preserveHash: true });
20606          state.sharedSnapshotMeta = {
20607            loadedAt: Date.now(),
20608            view: next.view === "edit" ? "edit" : "preview",
20609          };
20610
20611          await clearActiveLibrarySelection({
20612            persist: true,
20613            renderDrawer: true,
20614          });
20615
20616          setEditorMarkdown(next.text);
20617          updateUiState();
20618          render("editor");
20619          setMode(state.sharedSnapshotMeta.view);
20620
20621          setStatus(
20622            (state.currentName || "Shared snapshot") +
20623              " • loaded from shared link • use Share to create a fresh link",
20624            { announce: true },
20625          );
20626          return true;
20627        }
20628
20629        function clearLocationSearch() {
20630          const url = new URL(window.location.href);
20631          if (!url.search) return;
20632          url.search = "";
20633          history.replaceState(null, "", url.toString());
20634        }
20635
20636        async function applySharedIncomingContent(payload) {
20637          const next = payload && typeof payload === "object" ? payload : null;
20638          if (!next) return false;
20639
20640          const title =
20641            typeof next.title === "string" ? String(next.title).trim() : "";
20642          const text = typeof next.text === "string" ? next.text : "";
20643          const sharedUrl =
20644            typeof next.url === "string" ? String(next.url).trim() : "";
20645          if (!title && !text && !sharedUrl) return false;
20646
20647          const sections = [];
20648          if (title) sections.push("# " + title);
20649          if (text) sections.push(text);
20650          if (sharedUrl) sections.push("<" + sharedUrl + ">");
20651
20652          const markdown = sections.join("\n\n").trim();
20653          if (!markdown) return false;
20654
20655          await flushActiveUntitledDraft().catch(function (error) {
20656            console.warn("[drafts] active draft flush failed", error);
20657          });
20658          stopAutoRefresh();
20659          els.autoRefreshToggle.checked = false;
20660          detachSharedSnapshotContext();
20661
20662          setPrimaryFileHandle(null);
20663          state.untitledDraftId = "";
20664          state.sourceMode = "editor";
20665          state.currentName = title || "Shared note";
20666          state.baseHref = getSnapshotBaseUrl();
20667          state.dirty = false;
20668          state.hasSessionDraft = true;
20669
20670          await clearActiveLibrarySelection({
20671            persist: true,
20672            renderDrawer: true,
20673          });
20674
20675          setEditorMarkdown(markdown);
20676          updateUiState();
20677          render("editor");
20678          clearLocationSearch();
20679          setStatus(
20680            (state.currentName || "Shared note") +
20681              " • loaded from share target • " +
20682              nowString(),
20683            { announce: true },
20684          );
20685          return true;
20686        }
20687
20688        async function shareCurrentSnapshot() {
20689          if (state.shareInFlight) return;
20690
20691          const shareState =
20692            typeof libraryCore.getShareSnapshotState === "function"
20693              ? libraryCore.getShareSnapshotState({
20694                  text: els.editor.value || "",
20695                  currentName: state.currentName,
20696                  view: isEditModeActive() ? "edit" : "preview",
20697                })
20698              : {
20699                  canShare: Boolean((els.editor.value || "").trim()),
20700                  reason: (els.editor.value || "").trim() ? "ok" : "empty",
20701                  mayTakeTime: false,
20702                };
20703
20704          if (!shareState.canShare) {
20705            showShareFailureToast(
20706              shareState.reason === "unsupported"
20707                ? "Snapshot sharing is unavailable in this browser."
20708                : "There is no markdown to share yet.",
20709            );
20710            return;
20711          }
20712
20713          state.shareInFlight = true;
20714          state.sharePendingMayTakeTime = Boolean(shareState.mayTakeTime);
20715          updateUiState();
20716          setStatus(
20717            shareState.mayTakeTime
20718              ? "Preparing snapshot link for a large document..."
20719              : "Preparing snapshot link...",
20720            {
20721              announce: true,
20722              kind: "progress",
20723            },
20724          );
20725
20726          try {
20727            if (shareState.mayTakeTime) {
20728              await waitForNextPaint();
20729            }
20730
20731            const snapshot = await buildSnapshotShareUrl();
20732            const isLongShareUrl =
20733              snapshot.url.length > LONG_SHARE_URL_WARNING_LENGTH;
20734            updateAddressBarHash(snapshot.fragment);
20735
20736            const shareData = {
20737              title: state.currentName || "MarkV snapshot",
20738              text: "Open this markdown snapshot in MarkV",
20739              url: snapshot.url,
20740            };
20741            let canUseNativeShare = Boolean(navigator.share);
20742            if (canUseNativeShare && typeof navigator.canShare === "function") {
20743              try {
20744                canUseNativeShare =
20745                  navigator.canShare({ url: snapshot.url }) ||
20746                  navigator.canShare(shareData);
20747              } catch (_error) {
20748                canUseNativeShare = false;
20749              }
20750            }
20751
20752            if (canUseNativeShare) {
20753              try {
20754                await navigator.share(shareData);
20755                setStatus(
20756                  (isLongShareUrl
20757                    ? "Long snapshot link shared"
20758                    : "Snapshot link shared") +
20759                    " • " +
20760                    nowString(),
20761                  {
20762                    announce: true,
20763                  },
20764                );
20765                return;
20766              } catch (error) {
20767                if (error && error.name === "AbortError") {
20768                  setStatus("Share cancelled • " + nowString(), {
20769                    announce: true,
20770                  });
20771                  return;
20772                }
20773              }
20774            }
20775
20776            const copied = await copyTextToClipboard(snapshot.url);
20777            if (!copied) {
20778              throw new Error(
20779                "Clipboard access is unavailable in this browser.",
20780              );
20781            }
20782
20783            setStatus(
20784              (isLongShareUrl
20785                ? "Long snapshot link copied"
20786                : "Snapshot link copied • share again after edits") +
20787                " • " +
20788                nowString(),
20789              {
20790                announce: true,
20791              },
20792            );
20793          } catch (error) {
20794            if (error && error.name === "AbortError") {
20795              setStatus("Share cancelled • " + nowString(), {
20796                announce: true,
20797              });
20798              return;
20799            }
20800            showShareFailureToast(
20801              error && error.message ? error.message : String(error),
20802            );
20803          } finally {
20804            state.shareInFlight = false;
20805            state.sharePendingMayTakeTime = false;
20806            updateUiState();
20807          }
20808        }
20809
20810        function closeGlobalSettingsSurface() {
20811          const drawer = document.getElementById("globalSettingsDrawer");
20812          const overlay = document.getElementById("globalSettingsOverlay");
20813          const openBtn = document.getElementById("globalSettingsBtn");
20814          if (!drawer || !overlay || !openBtn) return;
20815          drawer.classList.remove("is-open");
20816          overlay.classList.remove("is-open");
20817          drawer.setAttribute("aria-hidden", "true");
20818          overlay.setAttribute("aria-hidden", "true");
20819          openBtn.setAttribute("aria-expanded", "false");
20820        }
20821
20822        function getLibraryFocusableElements() {
20823          if (!syncLibraryRefs()) return [];
20824          return Array.prototype.slice
20825            .call(
20826              els.libraryDrawer.querySelectorAll(
20827                'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
20828              ),
20829            )
20830            .filter(function (element) {
20831              return !element.hidden && element.offsetParent !== null;
20832            });
20833        }
20834
20835        function getLibraryCollapseSummary() {
20836          const summary = {
20837            hasFolders: false,
20838            hasCollapsed: false,
20839            hasUncollapsed: false,
20840          };
20841
20842          state.libraryFolders.forEach(function (folder) {
20843            if (!folder || !folder.id) return;
20844            summary.hasFolders = true;
20845            if (isLibraryFolderCollapsed(folder.id)) {
20846              summary.hasCollapsed = true;
20847            } else {
20848              summary.hasUncollapsed = true;
20849            }
20850          });
20851
20852          return summary;
20853        }
20854
20855        function updateLibraryToggleAllBtn(summary) {
20856          const btn = els.libraryToggleAllBtn;
20857          if (!btn) return;
20858          if (!summary.hasFolders) {
20859            btn.hidden = true;
20860            return;
20861          }
20862          btn.hidden = false;
20863          const allCollapsed = summary.hasCollapsed && !summary.hasUncollapsed;
20864          const label = allCollapsed ? "Expand all" : "Collapse all";
20865          const path = allCollapsed ? "M3 4.5 6 7.5l3-3" : "M3 7.5 6 4.5l3 3";
20866          btn.dataset.mode = allCollapsed ? "expand" : "collapse";
20867          btn.setAttribute("aria-label", label);
20868          btn.setAttribute("title", label);
20869          const labelEl = btn.querySelector(".lib-toggle-all-label");
20870          if (labelEl) labelEl.textContent = label;
20871          const pathEl = btn.querySelector("svg path");
20872          if (pathEl) pathEl.setAttribute("d", path);
20873        }
20874
20875        function renderLibraryDraftsSection() {
20876          const drafts = sortUntitledDrafts(state.untitledDrafts
20876);
20877          if (!drafts.length) return "";
20878
20879          const rows = drafts
20880            .map(function (draft) {
20881              const isActive =
20882                state.sourceMode === "untitled" &&
20883                state.untitledDraftId === draft.id;
20884              const title = draft.title || "Untitled";
20885              const discardLabel = "Discard " + title;
20886              return (
20887                '<div class="lib-draft-node">' +
20888                '<button type="button" class="lib-draft-row' +
20889                (isActive ? " is-active" : "") +
20890                '" data-kind="draft" data-action="open-draft" data-draft-id="' +
20891                escapeHtml(draft.id) +
20892                '" data-dirty="' +
20893                (draft.dirty ? "true" : "false") +
20894                '" title="' +
20895                escapeHtml(title) +
20896                '">' +
20897                '<span class="lib-draft-dot" aria-hidden="true"></span>' +
20898                '<span class="lib-draft-label">' +
20899                escapeHtml(title) +
20900                "</span>" +
20901                "</button>" +
20902                '<button type="button" class="lib-draft-discard" data-action="discard-draft" data-draft-id="' +
20903                escapeHtml(draft.id) +
20904                '" aria-label="' +
20905                escapeHtml(discardLabel) +
20906                '" title="' +
20907                escapeHtml(discardLabel) +
20908                '">' +
20909                '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>' +
20910                "</button>" +
20911                "</div>"
20912              );
20913            })
20914            .join("");
20915
20916          return (
20917            '<section class="lib-drafts" aria-label="Drafts">' +
20918            '<span class="lib-section-label">Drafts</span>' +
20919            rows +
20920            "</section>"
20921          );
20922        }
20923
20924        function renderLibraryTreeNodes(nodes, folderId, depth) {
20925          return (nodes || [])
20926            .map(function (node) {
20927              const padding = 32 + depth * 18;
20928              if (node.type === "directory") {
20929                return (
20930                  '<div class="lib-tree-node">' +
20931                  '<button type="button" class="lib-tree-row" data-kind="directory" data-action="toggle-dir" data-folder-id="' +
20932                  escapeHtml(folderId) +
20933                  '" data-path="' +
20934                  escapeHtml(node.path) +
20935                  '" aria-expanded="' +
20936                  String(Boolean(node.expanded)) +
20937                  '" style="padding-left:' +
20938                  padding +
20939                  'px">' +
20940                  '<span class="lib-tree-caret" aria-hidden="true">
20940▸</span>' +
20941                  '<span class="lib-tree-label">' +
20942                  escapeHtml(node.name) +
20943                  "</span>" +
20944                  "</button>" +
20945                  (node.expanded
20946                    ? '<div class="lib-tree-children">' +
20947                      renderLibraryTreeNodes(
20948                        node.children || [],
20949                        folderId,
20950                        depth + 1,
20951                      ) +
20952                      "</div>"
20953                    : "") +
20954                  "</div>"
20955                );
20956              }
20957
20958              const isActive =
20959                state.libraryActiveFolderId === folderId &&
20960                state.libraryActiveFilePath === node.path;
20961
20962              return (
20963                '<div class="lib-tree-node">' +
20964                '<button type="button" class="lib-tree-row' +
20965                (isActive ? " is-active" : "") +
20966                '" data-kind="file" data-action="open-file" data-folder-id="' +
20967                escapeHtml(folderId) +
20968                '" data-path="' +
20969                escapeHtml(node.path) +
20970                '" style="padding-left:' +
20971                padding +
20972                'px">' +
20973                '<span class="lib-tree-caret" aria-hidden="true">·</span>' +
20974                '<span class="lib-tree-label">' +
20975                escapeHtml(node.name) +
20976                "</span>" +
20977                "</button>" +
20978                "</div>"
20979              );
20980            })
20981            .join("");
20982        }
20983
20984        function renderLibraryDrawer() {
20985          if (!syncLibraryRefs()) return;
20986          if (!els.libraryBody) return;
20987
20988          const persistentSupported = canUsePersistentLibrary();
20989          const importSupported = canUseLibraryFolderImport();
20990          const supported = persistentSupported || importSupported;
20991          els.libraryAddBtn.disabled =
20992            !supported ||
20993            state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders;
20994          const collapseSummary = getLibraryCollapseSummary();
20995          updateLibraryToggleAllBtn(collapseSummary);
20996
20997          if (!supported) {
20998            els.librarySupportedNote.hidden = false;
20999            els.librarySupportedNote.textContent =
21000              "This browser cannot open folders here yet.";
21001          } else if (state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders) {
21002            els.librarySupportedNote.hidden = false;
21003            els.librarySupportedNote.textContent =
21004              "The v1 library is capped at " +
21005              LIBRARY_LIMITS.maxFolders +
21006              " folders.";
21007          } else if (!persistentSupported && importSupported) {
21008            els.librarySupportedNote.hidden = false;
21009            els.librarySupportedNote.textContent =
21010              "This browser imports a folder snapshot rather than keeping a live folder connection.";
21011          } else {
21012            els.librarySupportedNote.hidden = true;
21013            els.librarySupportedNote.textContent = "";
21014          }
21015
21016          const draftsHtml = renderLibraryDraftsSection();
21017          if (!state.libraryFolders.length) {
21018            els.libraryBody.innerHTML =
21019              draftsHtml || els.libraryEmpty.outerHTML;
21020            return;
21021          }
21022
21023          const html = state.libraryFolders
21024            .map(function (folder) {
21025              const statusLabel =
21026                folder.status === "loading"
21027                  ? "Scanning folder"
21028                  : folder.status === "needs-permission"
21029                    ? "Permission needed"
21030                    : folder.status === "empty"
21031                      ? "Empty folder"
21032                      : folder.sourceType === "snapshot"
21033                        ? "Imported snapshot"
21034                        : "Ready";
21035              const collapsed = isLibraryFolderCollapsed(folder.id);
21036              const toggleLabel =
21037                (collapsed ? "Expand " : "Collapse ") +
21038                (folder.displayLabel || folder.name || "folder");
21039              const toggleBtn =
21040                '<button type="button" class="lib-folder-toggle" data-action="toggle-folder" data-folder-id="' +
21041                escapeHtml(folder.id) +
21042                '" aria-expanded="' +
21043                (collapsed ? "false" : "true") +
21044                '" aria-label="' +
21045                escapeHtml(toggleLabel) +
21046                '" title="' +
21047                escapeHtml(toggleLabel) +
21048                '">' +
21049                '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 3.5 8 6l-3.5 2.5"/></svg>
21049' +
21050                "</button>";
21051              const actions =
21052                '<div class="lib-folder-actions">' +
21053                (folder.status === "needs-permission"
21054                  ? '<button type="button" class="lib-action-btn" data-action="reauthorize-folder" data-folder-id="' +
21055                    escapeHtml(folder.id) +
21056                    '">Reauthorize</button>'
21057                  : folder.sourceType === "persistent"
21058                    ? '<button type="button" class="lib-action-btn" data-action="refresh-folder" data-folder-id="' +
21059                      escapeHtml(folder.id) +
21060                      '">Refresh</button>'
21061                    : "") +
21062                '<button type="button" class="lib-action-btn" data-action="remove-folder" data-folder-id="' +
21063                escapeHtml(folder.id) +
21064                '">Remove</button>' +
21065                "</div>";
21066
21067              const content = collapsed
21068                ? ""
21069                : folder.status === "ready"
21070                  ? '<div class="lib-tree">' +
21071                    renderLibraryTreeNodes(folder.tree || [], folder.id, 0
21071) +
21072                    "</div>"
21073                  : folder.status === "empty"
21074                    ? '<div class="lib-tree-empty">' +
21075                      escapeHtml(
21076                        folder.note ||
21077                          "No markdown files found in this folder.",
21078                      ) +
21079                      "</div>"
21080                    : folder.note
21081                      ? '<div class="lib-tree-empty">' +
21082                        escapeHtml(folder.note) +
21083                        "</div>"
21084                      : "";
21085
21086              return (
21087                '<section class="lib-folder" data-folder-id="' +
21088                escapeHtml(folder.id) +
21089                '" data-collapsed="' +
21090                (collapsed ? "true" : "false") +
21091                '" draggable="true">' +
21092                '<div class="lib-folder-head" data-action="toggle-folder" data-folder-id="' +
21093                escapeHtml(folder.id) +
21094                '">' +
21095                '<div class="lib-folder-top">' +
21096                toggleBtn +
21097                '<div class="lib-folder-meta">' +
21098                '<h3 class="lib-folder-name">' +
21099                escapeHtml(folder.displayLabel || folder.name) +
21100                "</h3>" +
21101                '<p class="lib-folder-sub">' +
21102                escapeHtml(folder.name) +
21103                "</p>" +
21104                '<span class="lib-folder-status" data-status="' +
21105                escapeHtml(folder.status) +
21106                '">' +
21107                escapeHtml(statusLabel) +
21108                "</span>" +
21109                actions +
21110                "</div>" +
21111                "</div>" +
21112                "</div>" +
21113                content +
21114                "</section>"
21115              );
21116            })
21117            .join("");
21118
21119          els.libraryBody.innerHTML = draftsHtml + html;
21120        }
21121
21122        function showLibraryDrawer() {
21123          if (!syncLibraryRefs()) return;
21124          if (!els.libraryDrawer || state.libraryIsOpen) return;
21125          closeGlobalSettingsSurface();
21126          state.libraryDrawerLastFocused =
21127            document.activeElement instanceof HTMLElement
21128              ? document.activeElement
21129              : null;
21130          state.libraryIsOpen = true;
21131          els.libraryDrawer.classList.add("is-open");
21132          els.libraryOverlay.classList.add("is-open");
21133          els.libraryDrawer.setAttribute("aria-hidden", "false");
21134          els.libraryOverlay.setAttribute("aria-hidden", "false");
21135          els.libraryToggleBtn.setAttribute("aria-expanded", "true");
21136          document.body.classList.add("library-open");
21137          renderLibraryDrawer();
21138          window.requestAnimationFrame(function () {
21139            const focusTarget =
21140              els.openFileBtn ||
21141              (els.libraryAddBtn.disabled
21142                ? els.libraryCloseBtn
21143                : els.libraryAddBtn);
21144            focusTarget.focus();
21145          });
21146        }
21147
21148        function hideLibraryDrawer(options) {
21149          options = options || {};
21150          if (!syncLibraryRefs()) return;
21151          if (!els.libraryDrawer || !state.libraryIsOpen) return;
21152          let target =
21153            options.restoreFocus === false
21154              ? null
21155              : state.libraryDrawerLastFocused;
21156          if (
21157            target &&
21158            (!document.contains(target) ||
21159              target === document.body ||
21160              els.libraryDrawer.contains(target))
21161          ) {
21162            target = null;
21163          }
21164          if (!target && options.restoreFocus !== false) {
21165            target = els.libraryToggleBtn;
21166          }
21167          if (
21168            document.activeElement instanceof HTMLElement &&
21169            els.libraryDrawer.contains(document.activeElement)
21170          ) {
21171            document.activeElement.blur();
21172          }
21173          if (target && typeof target.focus === "function") {
21174            target.focus();
21175          }
21176          state.libraryIsOpen = false;
21177          els.libraryDrawer.classList.remove("is-open");
21178          els.libraryOverlay.classList.remove("is-open");
21179          els.libraryDrawer.setAttribute("aria-hidden", "true");
21180          els.libraryOverlay.setAttribute("aria-hidden", "true");
21181          els.libraryToggleBtn.setAttribute("aria-expanded", "false");
21182          document.body.classList.remove("library-open");
21183        }
21184
21185        function wireLibraryUi() {
21186          if (libraryUiWired || !syncLibraryRefs()) return;
21187          libraryUiWired = true;
21188
21189          els.libraryToggleBtn.addEventListener("click", function () {
21190            if (state.libraryIsOpen) {
21191              hideLibraryDrawer();
21192              return;
21193            }
21194            showLibraryDrawer();
21195          });
21196          els.libraryCloseBtn.addEventListener("click", function () {
21197            hideLibraryDrawer();
21198          });
21199          els.libraryOverlay.addEventListener("click", function () {
21200            hideLibraryDrawer();
21201          });
21202          els.openFileBtn.addEventListener("click", async function () {
21203            await openFile();
21204          });
21205          els.libraryAddBtn.addEventListener("click", async function () {
21206            await addLibraryFolder();
21207          });
21208          els.libraryToggleAllBtn.addEventListener("click", function () {
21209            const summary = getLibraryCollapseSummary();
21210            const allCollapsed =
21211              summary.hasCollapsed && !summary.hasUncollapsed;
21212            setAllLibraryFoldersCollapsed(!allCollapsed);
21213          });
21214          els.libraryBody.addEventListener("click", async function (event) {
21215            const trigger = event.target.closest("[data-action]");
21216            if (!trigger || !els.libraryBody.contains(trigger)) return;
21217
21218            const action = trigger.getAttribute("data-action");
21219            const folderId = trigger.getAttribute("data-folder-id") || "";
21220            const path = trigger.getAttribute("data-path") || "";
21221            const draftId = trigger.getAttribute("data-draft-id") || "";
21222
21223            if (action === "open-draft") {
21224              await loadUntitledDraft(draftId, {
21225                userInitiated: true,
21226              });
21227              return;
21228            }
21229            if (action === "discard-draft") {
21230              await discardUntitledDraft(draftId);
21231              return;
21232            }
21233            if (action === "toggle-folder") {
21234              toggleLibraryFolderCollapsed(folderId);
21235              return;
21236            }
21237            if (action === "toggle-dir") {
21238              toggleLibraryDirectory(folderId, path);
21239              return;
21240            }
21241            if (action === "open-file") {
21242              await loadFromLibrarySelection(folderId, path, {
21243                userInitiated: true,
21244              });
21245              return;
21246            }
21247            if (action === "refresh-folder") {
21248              await refreshLibraryFolder(folderId);
21249              return;
21250            }
21251            if (action === "reauthorize-folder") {
21252              await reauthorizeLibraryFolder(folderId);
21253              return;
21254            }
21255            if (action === "remove-folder") {
21256              await removeLibraryFolder(folderId);
21257            }
21258          });
21259          let libraryDragId = "";
21260          function cleanupLibraryDragState() {
21261            libraryDragId = "";
21262            els.libraryBody
21263              .querySelectorAll(
21264                ".lib-folder[data-dragging], .lib-folder[data-drop]",
21265              )
21266              .forEach(function (s) {
21267                s.removeAttribute("data-dragging");
21268                s.removeAttribute("data-drop");
21269              });
21270          }
21271
21272          els.libraryBody.addEventListener("dragstart", function (event) {
21273            const section =
21274              event.target.closest && event.target.closest(".lib-folder");
21275            if (!section) return;
21276            // Don't start a drag from inner buttons; let them handle clicks.
21277            if (event.target.closest && event.target.closest("button")) {
21278              event.preventDefault();
21279              return;
21280            }
21281            const id = section.getAttribute("data-folder-id") || "";
21282            if (!id) return;
21283            libraryDragId = id;
21284            section.setAttribute("data-dragging", "true");
21285            if (event.dataTransfer) {
21286              event.dataTransfer.effectAllowed = "move";
21287              try {
21288                event.dataTransfer.setData("text/plain", id);
21289              } catch (_) {}
21290            }
21291          });
21292
21293          els.libraryBody.addEventListener("dragover", function (event) {
21294            if (!libraryDragId) return;
21295            const section =
21296              event.target.closest && event.target.closest(".lib-folder");
21297            if (!section) return;
21298            const targetId = section.getAttribute("data-folder-id");
21299            if (!targetId || targetId === libraryDragId) return;
21300            event.preventDefault();
21301            const rect = section.getBoundingClientRect();
21302            const isAfter = event.clientY > rect.top + rect.height / 2;
21303            els.libraryBody
21304              .querySelectorAll(".lib-folder[data-drop]")
21305              .forEach(function (s) {
21306                if (s !== section) s.removeAttribute("data-drop");
21307              });
21308            section.setAttribute("data-drop", isAfter ? "after" : "before");
21309            if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
21310          });
21311
21312          els.libraryBody.addEventListener("dragleave", function (event) {
21313            const section =
21314              event.target.closest && event.target.closest(".lib-folder");
21315            if (!section) return;
21316            if (!section.contains(event.relatedTarget)) {
21317              section.removeAttribute("data-drop");
21318            }
21319          });
21320
21321          els.libraryBody.addEventListener("drop", function (event) {
21322            if (!libraryDragId) return;
21323            const section =
21324              event.target.closest && event.target.closest(".lib-folder");
21325            if (!section) {
21326              cleanupLibraryDragState();
21327              return;
21328            }
21329            const targetId = section.getAttribute("data-folder-id");
21330            const position = section.getAttribute("data-drop") || "before";
21331            event.preventDefault();
21332            const fromId = libraryDragId;
21333            cleanupLibraryDragState();
21334            if (targetId && targetId !== fromId) {
21335              if (moveLibraryFolder(fromId, targetId, position)) {
21336                setStatus("Library folders reordered", { announce: true });
21337              }
21338            }
21339          });
21340
21341          els.libraryBody.addEventListener("dragend", function () {
21342            cleanupLibraryDragState();
21343          });
21344
21345          els.libraryBody.addEventListener("keydown", function (event) {
21346            if (!event.altKey) return;
21347            if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
21348            const head =
21349              event.target.closest && event.target.closest(".lib-folder-head");
21350            if (!head) return;
21351            const folderId = head.getAttribute("data-folder-id");
21352            if (!folderId) return;
21353            event.preventDefault();
21354            const offset = event.key === "ArrowUp" ? -1 : 1;
21355            if (moveLibraryFolderByOffset(folderId, offset)) {
21356              setStatus("Library folders reordered", { announce: true });
21357              window.requestAnimationFrame(function () {
21358                const next = els.libraryBody.querySelector(
21359                  '.lib-folder[data-folder-id="' +
21360                    folderId.replace(/"/g, '\\"') +
21361                    '"] .lib-folder-toggle',
21362                );
21363                if (next && typeof next.focus === "function") next.focus();
21364              });
21365            }
21366          });
21367
21368          els.libraryDrawer.addEventListener("keydown", function (event) {
21369            if (event.key === "Escape") {
21370              event.preventDefault();
21371              event.stopPropagation();
21372              hideLibraryDrawer();
21373              return;
21374            }
21375            if (event.key !== "Tab") return;
21376
21377            const focusables = getLibraryFocusableElements();
21378            if (!focusables.length) return;
21379
21380            const first = focusables[0];
21381            const last = focusables[focusables.length - 1];
21382            if (event.shiftKey && document.activeElement === first) {
21383              event.preventDefault();
21384              last.focus();
21385            } else if (!event.shiftKey && document.activeElement === last) {
21386              event.preventDefault();
21387              first.focus();
21388            }
21389          });
21390
21391          renderLibraryDrawer();
21392        }
21393
21394        function rebuildLibraryFolderTree(folder, expandedPaths) {
21395          const fileHandlesByPath =
21396            folder &&
21397            folder.fileHandlesByPath &&
21398            typeof folder.fileHandlesByPath.keys === "function"
21399              ? folder.fileHandlesByPath
21400              : new Map();
21401
21402          return attachLibraryFileHandles(
21403            shapeLibraryTree(
21404              Array.from(fileHandlesByPath.keys()).map(function (item) {
21405                return { kind: "file", path: item };
21406              }),
21407              { expandedPaths: expandedPaths },
21408            ),
21409            fileHandlesByPath,
21410          );
21411        }
21412
21413        function setAllLibraryFoldersCollapsed(collapsed) {
21414          let changed = false;
21415
21416          state.libraryFolders.forEach(function (folder) {
21417            if (!folder || !folder.id) return;
21418            if (setLibraryFolderCollapsed(folder.id, collapsed)) {
21419              changed = true;
21420            }
21421          });
21422
21423          if (!changed) return;
21424          renderLibraryDrawer();
21425
21426          if (canUseLibraryStorage()) {
21427            persistLibraryMeta().catch(function (error) {
21428              console.warn(
21429                "[library] could not persist bulk collapse state",
21430                error,
21431              );
21432            });
21433          }
21434          setStatus(
21435            collapsed
21436              ? "Library folders collapsed"
21437              : "Library folders expanded",
21438            { announce: true },
21439          );
21440        }
21441
21442        function toggleLibraryFolderCollapsed(folderId) {
21443          if (!folderId) return;
21444          const next = !isLibraryFolderCollapsed(folderId);
21445          if (!setLibraryFolderCollapsed(folderId, next)) return;
21446          renderLibraryDrawer();
21447          if (canUseLibraryStorage()) {
21448            persistLibraryMeta().catch(function (error) {
21449              console.warn(
21450                "[library] could not persist folder collapse state",
21451                error,
21452              );
21453            });
21454          }
21455        }
21456
21457        function moveLibraryFolder(fromId, toId, position) {
21458          if (!fromId || !toId || fromId === toId) return false;
21459          const ids = state.libraryFolders.map(function (f) {
21460            return f.id;
21461          });
21462          const fromIdx = ids.indexOf(fromId);
21463          if (fromIdx < 0) return false;
21464          ids.splice(fromIdx, 1);
21465          let toIdx = ids.indexOf(toId);
21466          if (toIdx < 0) return false;
21467          if (position === "after") toIdx += 1;
21468          ids.splice(toIdx, 0, fromId);
21469          const byId = new Map(
21470            state.libraryFolders.map(function (f) {
21471              return [f.id, f];
21472            }),
21473          );
21474          state.libraryFolders = ids
21475            .map(function (id) {
21476              return byId.get(id);
21477            })
21478            .filter(Boolean);
21479          state.libraryFolderOrder = ids.slice();
21480          applyLibraryFolderOrder();
21481          renderLibraryDrawer();
21482          if (canUseLibraryStorage()) {
21483            persistLibraryMeta().catch(function (error) {
21484              console.warn("[library] could not persist folder reorder", error);
21485            });
21486          }
21487          return true;
21488        }
21489
21490        function moveLibraryFolderByOffset(folderId, offset) {
21491          if (!folderId || !offset) return false;
21492          const ids = state.libraryFolders.map(function (f) {
21493            return f.id;
21494          });
21495          const idx = ids.indexOf(folderId);
21496          if (idx < 0) return false;
21497          const targetIdx = idx + offset;
21498          if (targetIdx < 0 || targetIdx >= ids.length) return false;
21499          const targetId = ids[targetIdx];
21500          const position = offset < 0 ? "before" : "after";
21501          return moveLibraryFolder(folderId, targetId, position);
21502        }
21503
21504        function toggleLibraryDirectory(folderId, path) {
21505          const paths = getLibraryExpandedPaths(folderId);
21506          const next = paths.includes(path)
21507            ? paths.filter(function (item) {
21508                return item !== path;
21509              })
21510            : paths.concat(path);
21511          state.libraryExpandedPathsByFolder[folderId] = next;
21512          const folder = getLibraryFolderById(folderId);
21513          if (!folder) return;
21514          folder.tree = rebuildLibraryFolderTree(folder, next);
21515          if (canUseLibraryStorage()) {
21516            persistLibraryMeta().catch(function (error) {
21517              console.warn("[library] could not persist expanded paths", error);
21518            });
21519          }
21520          renderLibraryDrawer();
21521        }
21522
21523        function findLibraryFileNode(nodes, path) {
21524          for (const node of nodes || []) {
21525            if (node.type === "file" && node.path === path) return node;
21526            if (node.type === "directory") {
21527              const found = findLibraryFileNode(node.children || [], path);
21528              if (found) return found;
21529            }
21530          }
21531          return null;
21532        }
21533
21534        async function loadFromLibrarySelection(folderId, path, options) {
21535          options = options || {};
21536          const folder = getLibraryFolderById(folderId);
21537          if (!folder) return false;
21538
21539          await flushActiveUntitledDraft().catch(function (error) {
21540            console.warn("[drafts] active draft flush failed", error);
21541          });
21542
21543          if (folder.status === "needs-permission" && folder.handle) {
21544            if (!options.userInitiated) return false;
21545            const permission = await requestLibraryPermission(folder.handle);
21546            if (permission !== "granted") {
21547              folder.note = "Access still needs to be granted.";
21548              renderLibraryDrawer();
21549              setStatus("Library access was not granted.", { announce: true });
21550              console.warn("[library] permission request denied", {
21551                folderId: folder.id,
21552              });
21553              return false;
21554            }
21555            await scanLibraryFolder(folder);
21556          }
21557
21558          const node = findLibraryFileNode(folder.tree, path);
21559          if (!node || !node.handle) {
21560            setStatus("That library file is no longer available.", {
21561              announce: true,
21562            });
21563            console.warn("[library] missing file in tree", {
21564              folderId: folder.id,
21565              path: path,
21566            });
21567            return false;
21568          }
21569
21570          const file = await node.handle.getFile();
21571          const markdown = await file.text();
21572
21573          stopAutoRefresh();
21574          els.autoRefreshToggle.checked = false;
21575          detachSharedSnapshotContext();
21576
21577          setPrimaryFileHandle(null);
21578          state.untitledDraftId = "";
21579          state.libraryCurrentFileHandle = node.handle;
21580          state.libraryCurrentFolderHandle = folder.handle || null;
21581          state.libraryActiveFolderId = folder.id;
21582          state.libraryActiveFilePath = path;
21583          state.sourceMode = "library";
21584          state.currentName = file.name;
21585          state.baseHref = window.location.href;
21586          state.dirty = false;
21587          state.hasSessionDraft = false;
21588
21589          setEditorMarkdown(markdown);
21590          updateUiState();
21591          render("library");
21592          renderLibraryDrawer();
21593
21594          if (canUseLibraryStorage()) {
21595            await persistLibraryMeta().catch(function (error) {
21596              console.warn("[library] failed to persist active file", error);
21597            });
21598          }
21599
21600          if (!options.quiet) {
21601            setStatus("Loaded • " + nowString(), {
21602              announce: true,
21603            });
21604          }
21605          if (options.closeDrawerOnMobile !== false && isCoarsePointer()) {
21606            hideLibraryDrawer({ restoreFocus: false });
21607          }
21608          return true;
21609        }
21610
21611        function clearUntitledDraftHighlightBucket(draftId) {
21612          if (!draftId) return;
21613          const buckets = readHighlightBuckets(
21614            window.localStorage,
21615            DRAFT_HIGHLIGHT_STORAGE_KEY,
21616          );
21617          delete buckets["untitled::" + draftId];
21618          writeHighlightBuckets(
21619            window.localStorage,
21620            DRAFT_HIGHLIGHT_STORAGE_KEY,
21621            buckets,
21622          );
21623          delete state.highlightMemoryBuckets.local["untitled::" + draftId];
21624        }
21625
21626        async function deleteUntitledDraftRecord(draftId, options) {
21627          options = options || {};
21628          if (!draftId) return false;
21629          const previousLength = state.untitledDrafts.length;
21630          state.untitledDrafts = state.untitledDrafts.filter(function (draft) {
21631            return draft.id !== draftId;
21632          });
21633          __pendingUntitledDraftWrites.delete(draftId);
21634          if (state.untitledDraftId === draftId) {
21635            state.untitledDraftId = "";
21636          }
21637          writeUntitledDraftBackup();
21638          clearUntitledDraftHighlightBucket(draftId);
21639
21640          if (canUseUntitledDraftStorage()) {
21641            await untitledDraftDeleteFromDb(draftId).catch(function (error) {
21642              console.warn("[drafts] delete failed", error);
21643            });
21644          }
21645
21646          if (options.renderDrawer !== false) {
21647            renderLibraryDrawer();
21648          }
21649          return state.untitledDrafts.length !== previousLength;
21650        }
21651
21652        async function detachUntitledDraftAfterSave() {
21653          if (!isUntitledDraftMode()) return;
21654          const draftId = state.untitledDraftId;
21655          if (!draftId) return;
21656          await deleteUntitledDraftRecord(draftId, { renderDrawer: true });
21657        }
21658
21659        async function loadUntitledDraft(draftId, options) {
21660          options = options || {};
21661          let draft = getUntitledDraftById(draftId);
21662          if (!draft) return false;
21663
21664          await flushActiveUntitledDraft().catch(function (error) {
21665            console.warn("[drafts] active draft flush failed", error);
21666          });
21667          draft = getUntitledDraftById(draftId);
21668          if (!draft) return false;
21669
21670          stopAutoRefresh();
21671          els.autoRefreshToggle.checked = false;
21672          detachSharedSnapshotContext();
21673          setPrimaryFileHandle(null);
21674          await clearActiveLibrarySelection({
21675            persist: true,
21676            renderDrawer: false,
21677          });
21678
21679          const now = Date.now();
21680          const next = upsertUntitledDraftInState(
21681            Object.assign({}, draft, {
21682              lastActive: now,
21683              updatedAt: draft.updatedAt || now,
21684            }),
21685          );
21686          scheduleUntitledDraftWrite(next);
21687
21688          state.untitledDraftId = next.id;
21689          state.sourceMode = "untitled";
21690          state.currentName = next.title;
21691          state.baseHref = window.location.href;
21692          state.dirty = Boolean(next.dirty);
21693          state.hasSessionDraft = false;
21694          removeStorage(window.sessionStorage, SESSION_CONTENT_KEY);
21695
21696          setEditorMarkdown(next.content);
21697          updateUiState();
21698          render("untitled");
21699          renderLibraryDrawer();
21700
21701          if (!options.quiet) {
21702            setStatus(next.title + " • loaded", { announce: true });
21703          }
21704          if (options.closeDrawerOnMobile !== false && isCoarsePointer()) {
21705            hideLibraryDrawer({ restoreFocus: false });
21706          }
21707          return true;
21708        }
21709
21710        async function createUntitledDocument() {
21711          await flushActiveUntitledDraft().catch(function (error) {
21712            console.warn("[drafts] active draft flush failed", error);
21713          });
21714
21715          stopAutoRefresh();
21716          els.autoRefreshToggle.checked = false;
21717          detachSharedSnapshotContext();
21718          setPrimaryFileHandle(null);
21719          await clearActiveLibrarySelection({
21720            persist: true,
21721            renderDrawer: false,
21722          });
21723
21724          const now = Date.now();
21725          const draft = upsertUntitledDraftInState({
21726            id: createUntitledDraftId(),
21727            title: getNextUntitledDraftTitle(),
21728            content: "",
21729            createdAt: now,
21730            updatedAt: now,
21731            lastActive: now,
21732            dirty: false,
21733          });
21734
21735          state.untitledDraftId = draft.id;
21736          state.sourceMode = "untitled";
21737          state.currentName = draft.title;
21738          state.baseHref = window.location.href;
21739          state.dirty = false;
21740          state.hasSessionDraft = false;
21741          removeStorage(window.sessionStorage, SESSION_CONTENT_KEY);
21742          removeStorage(window.sessionStorage, SESSION_META_KEY);
21743
21744          setEditorMarkdown("");
21745          updateUiState();
21746          render("untitled");
21747          renderLibraryDrawer();
21748          scheduleUntitledDraftWrite(draft);
21749          flushUntitledDraftWrites().catch(function (error) {
21750            console.warn("[drafts] initial persist failed", error);
21751          });
21752
21753          setMode("edit");
21754          setStatus(draft.title + " • created", { announce: true });
21755        }
21756
21757        async function discardUntitledDraft(draftId) {
21758          const draft = getUntitledDraftById(draftId);
21759          if (!draft) return false;
21760          const wasActive =
21761            state.sourceMode === "untitled" &&
21762            state.untitledDraftId === draftId;
21763          const contentToDiscard = wasActive ? els.editor.value : draft.content;
21764
21765          if (String(contentToDiscard || "").trim()) {
21766            const confirmed = window.confirm(
21767              "Discard " + draft.title + "? This cannot be undone.",
21768            );
21769            if (!confirmed) return false;
21770          }
21771
21772          await deleteUntitledDraftRecord(draftId, { renderDrawer: false });
21773
21774          if (wasActive) {
21775            const nextDraft = state.untitledDrafts[0];
21776            if (nextDraft) {
21777              await loadUntitledDraft(nextDraft.id, {
21778                quiet: true,
21779                closeDrawerOnMobile: false,
21780              });
21781            } else {
21782              stopAutoRefresh();
21783              els.autoRefreshToggle.checked = false;
21784              detachSharedSnapshotContext();
21785              setPrimaryFileHandle(null);
21786              state.sourceMode = "editor";
21787              state.currentName = "";
21788              state.baseHref = window.location.href;
21789              state.dirty = false;
21790              state.hasSessionDraft = false;
21791              state.highlightDocKey = "";
21792              state.currentHighlights = [];
21793              state.resolvedHighlights = [];
21794              state.highlightTextModel = null;
21795              removeStorage(window.sessionStorage, SESSION_CONTENT_KEY);
21796              removeStorage(window.sessionStorage, SESSION_META_KEY);
21797              setEditorMarkdown("");
21798              updateUiState();
21799              render("force");
21800            }
21801          }
21802          renderLibraryDrawer();
21803
21804          setStatus(draft.title + " • discarded", { announce: true });
21805          return true;
21806        }
21807
21808        async function refreshLibraryFolder(folderId) {
21809          const folder = getLibraryFolderById(folderId);
21810          if (!folder) return;
21811          if (folder.sourceType === "snapshot") {
21812            setStatus(
21813              "This folder was imported as a snapshot • open it again to capture newer changes.",
21814              { announce: true },
21815            );
21816            return;
21817          }
21818          await scanLibraryFolder(folder);
21819          renderLibraryDrawer();
21820          setStatus((folder.displayLabel || folder.name) + " • refreshed", {
21821            announce: true,
21822          });
21823        }
21824
21825        async function reauthorizeLibraryFolder(folderId) {
21826          const folder = getLibraryFolderById(folderId);
21827          if (!folder || !folder.handle) return;
21828          const permission = await requestLibraryPermission(folder.handle);
21829          if (permission !== "granted") {
21830            folder.status = "needs-permission";
21831            folder.note = "Access still needs to be granted.";
21832            renderLibraryDrawer();
21833            setStatus("Library access was not granted.", { announce: true });
21834            console.warn("[library] reauthorization denied", {
21835              folderId: folder.id,
21836            });
21837            return;
21838          }
21839          await scanLibraryFolder(folder);
21840          setStatus((folder.displayLabel || folder.name) + " • reauthorized", {
21841            announce: true,
21842          });
21843        }
21844
21845        async function removeLibraryFolder(folderId) {
21846          const nextFolders = state.libraryFolders.filter(function (folder) {
21847            return folder.id !== folderId;
21848          });
21849          const removed = getLibraryFolderById(folderId);
21850          state.libraryFolders = nextFolders;
21851          applyLibraryFolderOrder();
21852
21853          delete state.libraryExpandedPathsByFolder[folderId];
21854          setLibraryFolderCollapsed(folderId, false);
21855          if (state.libraryActiveFolderId === folderId) {
21856            state.libraryActiveFolderId = "";
21857            state.libraryActiveFilePath = "";
21858            state.libraryCurrentFileHandle = null;
21859            state.libraryCurrentFolderHandle = null;
21860            if (state.sourceMode === "library") {
21861              state.sourceMode = "editor";
21862              updateUiState();
21863            }
21864          }
21865
21866          if (canUseLibraryStorage()) {
21867            await libraryDeleteFolderFromDb(folderId).catch(function (error) {
21868              console.warn("[library] failed to delete folder from db", error);
21869            });
21870            await persistLibraryMeta().catch(function (error) {
21871              console.warn("[library] failed to persist removal", error);
21872            });
21873          }
21874
21875          renderLibraryDrawer();
21876          setStatus(
21877            (removed ? removed.displayLabel || removed.name : "Folder") +
21878              " • removed from library",
21879            { announce: true },
21880          );
21881        }
21882
21883        async function getLibraryHandleConflict(targetHandle) {
21884          for (const folder of state.libraryFolders) {
21885            if (!folder.handle || folder.sourceType !== "persistent") continue;
21886            try {
21887              if (await folder.handle.isSameEntry(targetHandle)) {
21888                return "duplicate";
21889              }
21890              const childOfExisting = await folder.handle.resolve(targetHandle);
21891              if (Array.isArray(childOfExisting)) return "overlap";
21892              const parentOfExisting = await targetHandle.resolve(
21893                folder.handle,
21894              );
21895              if (Array.isArray(parentOfExisting)) return "overlap";
21896            } catch (_error) {
21897              // Ignore unsupported relationship checks.
21898            }
21899          }
21900          return null;
21901        }
21902
21903        function getImportedLibraryRelativePath(file) {
21904          const rawPath =
21905            typeof file.webkitRelativePath === "string" &&
21906            file.webkitRelativePath
21907              ? file.webkitRelativePath
21908              : String(file.name || "");
21909          const parts = rawPath.split("/").filter(Boolean);
21910          if (!parts.length) return "";
21911          return parts.length > 1 ? parts.slice(1).join("/") : parts[0];
21912        }
21913
21914        async function importLibraryFolderSnapshot(files, options) {
21915          options = options || {};
21916          const replaceFolderId = String(options.replaceFolderId || "");
21917          const activePath =
21918            replaceFolderId && state.libraryActiveFolderId === replaceFolderId
21919              ? state.libraryActiveFilePath
21920              : "";
21921          const selectedFiles = Array.from(files || []);
21922          const prepared = prepareImportedLibraryEntries(selectedFiles);
21923          if (!prepared.rootName) {
21924            setStatus(
21925              "That folder could not be imported • Safari needs at least one file to build the library snapshot.",
21926              { announce: true },
21927            );
21928            return;
21929          }
21930
21931          const allowedPaths = new Set(
21932            prepared.entries.map(function (entry) {
21933              return entry.path;
21934            }),
21935          );
21936          const snapshotFiles = [];
21937          const seenPaths = new Set();
21938
21939          for (const file of selectedFiles) {
21940            if (snapshotFiles.length >= LIBRARY_LIMITS.maxFiles) {
21941              break;
21942            }
21943            const relativePath = getImportedLibraryRelativePath(file);
21944            if (
21945              !relativePath ||
21946              !allowedPaths.has(relativePath) ||
21947              seenPaths.has(relativePath)
21948            ) {
21949              continue;
21950            }
21951            seenPaths.add(relativePath);
21952            snapshotFiles.push({
21953              path: relativePath,
21954              name: String(
21955                file.name || relativePath.split("/").pop() || "Untitled",
21956              ),
21957              text: await file.text(),
21958              lastModified: Number(file.lastModified) || Date.now(),
21959            });
21960            if (snapshotFiles.length % LIBRARY_LIMITS.yieldEvery === 0) {
21961              await yieldToUi();
21962            }
21963          }
21964
21965          let record = null;
21966          if (replaceFolderId) {
21967            record = getLibraryFolderById(replaceFolderId);
21968          }
21969
21970          if (record) {
21971            record.name = prepared.rootName;
21972            record.snapshotFiles = snapshotFiles;
21973            record.status = "loading";
21974            record.note = "";
21975          } else {
21976            record = createLibraryFolderRecord({
21977              id: nextLibraryFolderId(),
21978              name: prepared.rootName,
21979              sourceType: "snapshot",
21980              snapshotFiles: snapshotFiles,
21981              addedAt: Date.now(),
21982            });
21983            state.libraryFolders.push(record);
21984          }
21985
21986          applyLibraryFolderOrder();
21987          const persisted = await persistLibraryFolderBestEffort(
21988            record,
21989            "folder snapshot",
21990          );
21991          renderLibraryDrawer();
21992          await scanLibraryFolder(record);
21993
21994          let restoredActiveFile = false;
21995          if (record.id === replaceFolderId && activePath) {
21996            restoredActiveFile = await loadFromLibrarySelection(
21997              record.id,
21998              activePath,
21999              {
22000                closeDrawerOnMobile: false,
22001                quiet: false,
22002                userInitiated: true,
22003              },
22004            );
22005          }
22006
22007          if (!restoredActiveFile) {
22008            setStatus(
22009              record.displayLabel +
22010                (replaceFolderId
22011                  ? persisted
22012                    ? " • snapshot refreshed"
22013                    : " • snapshot refreshed for this session"
22014                  : persisted
22015                    ? " • added as a folder snapshot"
22016                    : " • added as a folder snapshot for this session"),
22017              {
22018                announce: true,
22019              },
22020            );
22021          }
22022        }
22023
22024        async function addLibraryFolder() {
22025          if (!canUseAnyLibrary()) {
22026            renderLibraryDrawer();
22027            return;
22028          }
22029          if (state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders) {
22030            setStatus(
22031              "Library limit reached • remove a folder before opening another.",
22032              { announce: true },
22033            );
22034            return;
22035          }
22036
22037          if (!canUsePersistentLibrary()) {
22038            requestLibraryFolderImport();
22039            return;
22040          }
22041
22042          try {
22043            const handle = await window.showDirectoryPicker({ mode: "read" });
22044            if (!handle) return;
22045
22046            const conflict = await getLibraryHandleConflict(handle);
22047            if (conflict) {
22048              setStatus(
22049                conflict === "duplicate"
22050                  ? "That folder is already open in Files."
22051                  : "That folder overlaps an existing Files root.",
22052                { announce: true },
22053              );
22054              console.warn("[library] rejected folder open", {
22055                conflict: conflict,
22056                name: handle.name,
22057              });
22058              return;
22059            }
22060
22061            const record = createLibraryFolderRecord({
22062              id: nextLibraryFolderId(),
22063              name: handle.name,
22064              handle: handle,
22065              sourceType: "persistent",
22066              addedAt: Date.now(),
22067            });
22068            state.libraryFolders.push(record);
22069            applyLibraryFolderOrder();
22070            const persisted = await persistLibraryFolderBestEffort(
22071              record,
22072              "folder open",
22073            );
22074            renderLibraryDrawer();
22075            await scanLibraryFolder(record);
22076            setStatus(
22077              record.displayLabel +
22078                (persisted
22079                  ? " • opened in Files"
22080                  : " • opened in Files for this session"),
22081              {
22082                announce: true,
22083              },
22084            );
22085          } catch (error) {
22086            if (error && error.name === "AbortError") return;
22087            setStatus(
22088              "Could not open folder • " +
22089                (error && error.message ? error.message : String(error)),
22090              { announce: true },
22091            );
22092            console.warn("[library] open folder failed", error);
22093          }
22094        }
22095
22096        async function restoreLibraryFromStorage(sessionMeta) {
22097          renderLibraryDrawer();
22098          if (!canUseLibraryStorage()) return;
22099
22100          let meta;
22101          let folders;
22102          try {
22103            meta = await libraryReadMetaFromDb();
22104            folders = await libraryGetAllFoldersFromDb();
22105          } catch (error) {
22106            console.warn("[library] restore failed", error);
22107            setStatus("Library restore skipped • storage unavailable", {
22108              announce: true,
22109            });
22110            return;
22111          }
22112
22113          state.libraryFolderOrder = meta.folderOrder.slice();
22114          state.libraryExpandedPathsByFolder = cloneExpandedPathsByFolder(
22115            meta.expandedPathsByFolder,
22116          );
22117          state.libraryCollapsedFolderIds = cloneLibraryCollapsedIds(
22118            meta.collapsedFolderIds,
22119          );
22120          state.libraryFolders = folders.map(function (item) {
22121            return createLibraryFolderRecord(item);
22122          });
22123          applyLibraryFolderOrder();
22124          renderLibraryDrawer();
22125
22126          for (const folder of state.libraryFolders) {
22127            await scanLibraryFolder(folder);
22128          }
22129
22130          if (
22131            shouldAutoRestoreLastFile({
22132              hasSessionDraft: Boolean(
22133                sessionMeta && sessionMeta.hasSessionDraft,
22134              ),
22135              lastActiveFile: meta.lastActiveFile,
22136            })
22137          ) {
22138            await loadFromLibrarySelection(
22139              meta.lastActiveFile.folderId,
22140              meta.lastActiveFile.path,
22141              { quiet: true, userInitiated: false },
22142            );
22143          }
22144        }
22145
22146        function updateWordCount() {
22147          var text = els.editor.value.trim();
22148          if (!text) {
22149            els.wordCount.hidden = true;
22150            return;
22151          }
22152          var count = text.split(/\s+/).length;
22153          els.wordCount.textContent =
22154            count.toLocaleString() + (count === 1 ? " word" : " words");
22155          els.wordCount.hidden = false;
22156        }
22157
22158        function withShortcutLabel(label, shortcutId) {
22159          const shortcuts = window.MarkVShortcuts;
22160          const shortcut =
22161            shortcuts && typeof shortcuts.describe === "function"
22162              ? shortcuts.describe(shortcutId)
22163              : "";
22164          return shortcut ? label + " (" + shortcut + ")" : label;
22165        }
22166
22167        function shortcutMatches(event, shortcutId) {
22168          const shortcuts = window.MarkVShortcuts;
22169          return Boolean(
22170            shortcuts &&
22171            typeof shortcuts.matches === "function" &&
22172            shortcuts.matches(event, shortcutId),
22173          );
22174        }
22175
22176        function updateBrandDisplay() {
22177          if (!els.brandName) return;
22178          const rawName = String(state.currentName || "").trim();
22179          const stripped = rawName.replace(
22180            /\.(md|markdown|mdown|mkdn|txt)$/i,
22181            "",
22182          );
22183          const isUntitled = state.sourceMode === "untitled";
22184          const hasFile = Boolean(stripped);
22185          const hasContent = Boolean((els.editor.value || "").trim());
22186
22187          if (isUntitled) {
22188            els.brandName.textContent = rawName || "Untitled";
22189            els.brandName.className = "brand-name is-untitled";
22190            els.brandName.title = rawName || "Untitled";
22191            document.title = rawName || "Untitled";
22192          } else if (hasFile) {
22193            els.brandName.textContent = stripped;
22194            els.brandName.className = "brand-name is-file";
22195            els.brandName.title = rawName;
22196            document.title = stripped;
22197          } else if (hasContent) {
22198            els.brandName.textContent = "Untitled";
22199            els.brandName.className = "brand-name is-untitled";
22200            els.brandName.removeAttribute("title");
22201            document.title = "Untitled";
22202          } else {
22203            els.brandName.textContent = "MarkV";
22204            els.brandName.className = "brand-name";
22205            els.brandName.removeAttribute("title");
22206            document.title = "Markdown Viewer";
22207          }
22208
22209          if (els.brandDirty) {
22210            els.brandDirty.hidden = !((hasFile || isUntitled) && state.dirty);
22211          }
22212        }
22213
22214        function updateUiState() {
22215          const refreshState = getReaderRefreshState();
22216          const saveState = getReaderSaveState();
22217          const authoringState = getReaderAuthoringState();
22218          const editModeActive = isEditModeActive();
22219          const hasContent = Boolean((els.editor.value || "").trim());
22220          const canOpenMindmap = hasContent;
22221          updateBrandDisplay();
22222          els.reloadBtn.disabled = !refreshState.canReload;
22223          const reloadLabel =
22224            refreshState.reloadAction === "file-handle"
22225              ? "Reload current file"
22226              : refreshState.reloadAction === "library-persistent"
22227                ? "Reload current library file"
22228                : refreshState.reloadAction === "library-snapshot"
22229                  ? "Choose the folder again to refresh this imported snapshot"
22230                  : refreshState.reloadAction === "file-picker"
22231                    ? "Choose the file again"
22232                    : "Reload current file";
22233          els.reloadBtn.title = reloadLabel;
22234          els.reloadBtn.setAttribute("aria-label", reloadLabel);
22235
22236          els.saveBtn.hidden = !authoringState.showSaveButton;
22237          els.saveBtn.style.display = authoringState.showSaveButton
22238            ? ""
22239            : "none";
22240          els.saveBtn.disabled = !saveState.canSave;
22241          els.saveBtn.textContent = saveState.saveLabel;
22242          const saveLabel = withShortcutLabel(
22243            "Save current document",
22244            "global.save",
22245          );
22246          els.saveBtn.title = saveLabel;
22247          els.saveBtn.setAttribute("aria-label", saveLabel);
22248
22249          if (els.shareBtn) {
22250            const shareSupported =
22251              typeof libraryCore.createShareSnapshotFragment === "function";
22252            const shareBusy = state.shareInFlight;
22253            const shareTitle = shareBusy
22254              ? state.sharePendingMayTakeTime
22255                ? "Preparing a snapshot link for this large markdown document"
22256                : "Preparing a snapshot link for the current markdown"
22257              : !shareSupported
22258                ? "Snapshot sharing is unavailable in this build"
22259                : hasContent
22260                  ? "Share a snapshot link for the current markdown"
22261                  : "Add markdown content before sharing";
22262            els.shareBtn.disabled = !shareSupported || !hasContent || shareBusy;
22263            if (els.shareBtnLabel) {
22264              els.shareBtnLabel.textContent = shareBusy
22265                ? "Preparing..."
22266                : "Share";
22267            } else {
22268              els.shareBtn.textContent = shareBusy ? "Preparing..." : "Share";
22269            }
22270            els.shareBtn.classList.toggle("is-busy", shareBusy);
22271            els.shareBtn.setAttribute(
22272              "aria-busy",
22273              shareBusy ? "true" : "false",
22274            );
22275            els.shareBtn.title = shareTitle;
22276            els.shareBtn.setAttribute("aria-label", shareTitle);
22277          }
22278
22279          if (
22280            window.MarkVExportHost &&
22281            typeof window.MarkVExportHost.syncUi === "function"
22282          ) {
22283            window.MarkVExportHost.syncUi();
22284          }
22285
22286          if (els.modeToggleBtn) {
22287            // Detect the unsaved→saved transition so the breathing
22288            // dot morphs into a calm checkmark confirmation for ~1.4s.
22289            // `.just-saved` is a transient class — added only when the
22290            // dirty cue goes away WHILE it was previously showing.
22291            const wasUnsaved =
22292              els.modeToggleBtn.classList.contains("has-unsaved");
22293            if (wasUnsaved && !authoringState.showDirtyCue) {
22294              els.modeToggleBtn.classList.add("just-saved");
22295              window.clearTimeout(state.justSavedTimer);
22296              state.justSavedTimer = window.setTimeout(function () {
22297                els.modeToggleBtn.classList.remove("just-saved");
22298              }, 1400);
22299            }
22300            els.modeToggleBtn.classList.toggle(
22301              "has-unsaved",
22302              authoringState.showDirtyCue,
22303            );
22304            const modeActionBase = editModeActive
22305              ? "Show preview"
22306              : authoringState.showDirtyCue
22307                ? "Edit markdown • unsaved changes"
22308                : "Edit markdown";
22309            const modeActionLabel = withShortcutLabel(
22310              modeActionBase,
22311              "global.mode",
22312            );
22313            els.modeToggleBtn.title = modeActionLabel;
22314            els.modeToggleBtn.setAttribute("aria-label", modeActionLabel);
22315          }
22316
22317          const canAutoRefresh = refreshState.canAutoRefresh;
22318          els.autoRefreshToggle.disabled = !canAutoRefresh;
22319          if (els.autoRefreshToggle.parentElement) {
22320            els.autoRefreshToggle.parentElement.title = canAutoRefresh
22321              ? refreshState.reloadAction === "library-persistent"
22322                ? "Auto refresh works for live library files"
22323                : "Auto refresh works for opened file handles"
22324              : refreshState.reloadAction === "library-snapshot"
22325                ? "Auto refresh is unavailable for imported folder snapshots"
22326                : refreshState.reloadAction === "file-picker"
22327                  ? "Auto refresh is unavailable for picked files without a live handle"
22328                  : "Auto refresh works for opened file handles";
22329          }
22330          const mindmapBtn = document.getElementById("mindmapBtn");
22331          if (mindmapBtn) {
22332            mindmapBtn.disabled = !canOpenMindmap;
22333          }
22334          if (!canAutoRefresh) {
22335            els.autoRefreshToggle.checked = false;
22336            stopAutoRefresh();
22337          }
22338        }
22339
22340        function wrapTables() {
22341          els.preview.querySelectorAll("table").forEach(function (table) {
22342            const parent = table.parentElement;
22343            if (parent && parent.classList.contains("table-wrap")) return;
22344            if (table.classList.contains("csv-sheet")) return;
22345            if (table.closest(".csv-sheet-wrap")) return;
22346            const wrapper = document.createElement("div");
22347            wrapper.className = "table-wrap";
22348            table.parentNode.insertBefore(wrapper, table);
22349            wrapper.appendChild(table);
22350          });
22351        }
22352
22353        function decorateCodeBlocks() {
22354          els.preview
22355            .querySelectorAll('pre > code[class*="language-"]')
22356            .forEach(function (code) {
22357              const match = code.className.match(/language-([a-z0-9_+.-]+)/i);
22358              if (match && code.parentElement) {
22359                code.parentElement.setAttribute("data-lang", match[1]);
22360              }
22361            });
22362        }
22363
22364        function decorateLinks() {
22365          els.preview.querySelectorAll("a[href]").forEach(function (link) {
22366            const href = link.getAttribute("href") || "";
22367            link.referrerPolicy = "no-referrer";
22368            if (href.startsWith("#")) {
22369              // Anchor link: scroll within the preview pane
22370              link.addEventListener("click", function (e) {
22371                e.preventDefault();
22372                var targetId = href.slice(1);
22373                var target = els.preview.querySelector(
22374                  "#" + CSS.escape(targetId),
22375                );
22376                if (target) {
22377                  target.scrollIntoView({
22378                    behavior: prefersReducedMotion() ? "auto" : "smooth",
22379                    block: "start",
22380                  });
22381                }
22382              });
22383            } else if (/^(https?:)?\/\//i.test(href)) {
22384              link.target = "_blank";
22385              link.rel = "noreferrer noopener";
22386            }
22387          });
22388        }
22389
22390        function readReaderCssVar(name, fallback) {
22391          const value = getComputedStyle(document.documentElement)
22392            .getPropertyValue(name)
22393            .trim();
22394          return value || fallback;
22395        }
22396
22397        function getMermaidPaletteColors() {
22398          const darkMode = getEffectiveReaderScheme() === "dark";
22399          return {
22400            canvas: readReaderCssVar(
22401              "--reader-code-bg",
22402              darkMode ? "#111111" : "#f5f4f2",
22403            ),
22404            node: readReaderCssVar(
22405              "--reader-bg-raised",
22406              darkMode ? "#2f2f2d" : "#ffffff",
22407            ),
22408            nodeAlt: readReaderCssVar(
22409              "--reader-bg-subtle",
22410              darkMode ? "#262625" : "#f4f3f1",
22411            ),
22412            ink: readReaderCssVar(
22413              "--reader-code-fg",
22414              darkMode ? "#f2f0ea" : "#222221",
22415            ),
22416            muted: readReaderCssVar(
22417              "--reader-fg-muted",
22418              darkMode ? "#bfbfba" : "#71706e",
22419            ),
22420            border: readReaderCssVar(
22421              "--reader-border-strong",
22422              darkMode ? "rgba(255, 255, 255, 0.22)" : "rgba(0, 0, 0, 0.11)",
22423            ),
22424            accent: readReaderCssVar(
22425              "--reader-accent",
22426              darkMode ? "#61aaf2" : "#5f8f80",
22427            ),
22428          };
22429        }
22430
22431        function getMermaidThemeConfig() {
22432          const darkMode = getEffectiveReaderScheme() === "dark";
22433          const colors = getMermaidPaletteColors();
22434          const fontFamily = readReaderCssVar(
22435            "--font-sans",
22436            "ui-sans-serif, system-ui, sans-serif",
22437          );
22438
22439          return {
22440            theme: "base",
22441            themeVariables: {
22442              darkMode: darkMode,
22443              background: colors.canvas,
22444              mainBkg: colors.node,
22445              nodeBkg: colors.node,
22446              nodeTextColor: colors.ink,
22447              nodeBorder: colors.border,
22448              primaryColor: colors.node,
22449              primaryTextColor: colors.ink,
22450              primaryBorderColor: colors.border,
22451              secondaryColor: colors.nodeAlt,
22452              secondaryTextColor: colors.ink,
22453              secondaryBorderColor: colors.border,
22454              tertiaryColor: colors.canvas,
22455              tertiaryTextColor: colors.ink,
22456              tertiaryBorderColor: colors.border,
22457              lineColor: colors.muted,
22458              defaultLinkColor: colors.muted,
22459              arrowheadColor: colors.muted,
22460              textColor: colors.ink,
22461              titleColor: colors.ink,
22462              edgeLabelBackground: colors.canvas,
22463              labelBackground: colors.canvas,
22464              clusterBkg: colors.canvas,
22465              clusterBorder: colors.border,
22466              noteBkgColor: colors.node,
22467              noteTextColor: colors.ink,
22468              noteBorderColor: colors.border,
22469              actorBkg: colors.node,
22470              actorTextColor: colors.ink,
22471              actorBorder: colors.border,
22472              activationBkgColor: colors.nodeAlt,
22473              activationBorderColor: colors.border,
22474              labelBoxBkgColor: colors.node,
22475              labelBoxBorderColor: colors.border,
22476              labelTextColor: colors.ink,
22477              loopTextColor: colors.ink,
22478              signalColor: colors.muted,
22479              signalTextColor: colors.ink,
22480              sequenceNumberColor: darkMode ? "#111111" : "#ffffff",
22481              cScale0: colors.accent,
22482              cScale1: colors.nodeAlt,
22483              cScale2: colors.node,
22484              fontFamily: fontFamily,
22485            },
22486          };
22487        }
22488
22489        function getBeautifulMermaidRenderOptions() {
22490          const colors = getMermaidPaletteColors();
22491
22492          return {
22493            bg: colors.canvas,
22494            fg: colors.ink,
22495            line: colors.muted,
22496            accent: colors.accent,
22497            muted: colors.muted,
22498            surface: colors.node,
22499            border: colors.border,
22500            transparent: true,
22501          };
22502        }
22503
22504        function applyReadableMermaidPalette(svg) {
22505          if (!svg) return;
22506          const colors = getMermaidPaletteColors();
22507          svg.dataset.markvMermaidPalette = "true";
22508          svg
22509            .querySelectorAll("style[data-markv-mermaid-palette]")
22510            .forEach(function (style) {
22511              style.remove();
22512            });
22513
22514          const style = document.createElementNS(
22515            "http://www.w3.org/2000/svg",
22516            "style",
22517          );
22518          style.setAttribute("data-markv-mermaid-palette", "true");
22519          style.textContent = [
22520            ".node rect, .node circle, .node ellipse, .node polygon, .node path,",
22521            ".flowchart-label rect, .er.entityBox, .actor, .note, .labelBox,",
22522            ".state-start, .state-end {",
22523            "  fill: " + colors.node + " !important;",
22524            "  stroke: " + colors.border + " !important;",
22525            "}",
22526            ".cluster rect, .cluster polygon {",
22527            "  fill: " + colors.canvas + " !important;",
22528            "  stroke: " + colors.border + " !important;",
22529            "}",
22530            ".edgeLabel, .edgeLabel p, .edgeLabel span {",
22531            "  background-color: " + colors.canvas + " !important;",
22532            "  color: " + colors.ink + " !important;",
22533            "}",
22534            ".edgeLabel rect, .labelBkg {",
22535            "  fill: " + colors.canvas + " !important;",
22536            "}",
22537            ".label, .nodeLabel, .nodeLabel p, .nodeLabel span,",
22538            ".edgeLabel, .edgeLabel p, .edgeLabel span {",
22539            "  color: " + colors.ink + " !important;",
22540            "}",
22541            ".label text, text, tspan {",
22542            "  fill: " + colors.ink + " !important;",
22543            "}",
22544            ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1,",
22545            ".relation, .transition, line {",
22546            "  stroke: " + colors.muted + " !important;",
22547            "}",
22548            ".arrowheadPath, marker path {",
22549            "  fill: " + colors.muted + " !important;",
22550            "  stroke: " + colors.muted + " !important;",
22551            "}",
22552          ].join("\n");
22553          svg.appendChild(style);
22554        }
22555
22556        function hasMermaidBlocks(markdown) {
22557          return /(^|\n)```mermaid(?:\s|$)/i.test(String(markdown || ""));
22558        }
22559
22560        function getMermaidFirstMeaningfulLine(source) {
22561          const lines = String(source || "").trim().split(/\r?\n/);
22562          for (const line of lines) {
22563            const trimmed = line.trim();
22564            if (!trimmed || trimmed.startsWith("%%")) continue;
22565            return trimmed;
22566          }
22567          return "";
22568        }
22569
22570        function getMermaidRendererType(source) {
22571          const firstLine = getMermaidFirstMeaningfulLine(source);
22572          if (
22573            /^(?:flowchart|graph)\s+(?:TD|TB|LR|BT|RL)$/i.test(firstLine) ||
22574            /^stateDiagram(?:-v2)?$/i.test(firstLine) ||
22575            /^sequenceDiagram$/i.test(firstLine) ||
22576            /^classDiagram$/i.test(firstLine) ||
22577            /^erDiagram$/i.test(firstLine) ||
22578            /^xychart-beta$/i.test(firstLine)
22579          ) {
22580            return BEAUTIFUL_MERMAID_RENDERER_NAME;
22581          }
22582          return MERMAID_RENDERER_NAME;
22583        }
22584
22585        function getMermaidApi() {
22586          if (window.mermaid) {
22587            return Promise.resolve(window.mermaid);
22588          }
22589          if (state.mermaidApiPromise) {
22590            return state.mermaidApiPromise;
22591          }
22592
22593          state.mermaidApiPromise = new Promise(function (resolve, reject) {
22594            const script = document.createElement("script");
22595            script.src = MERMAID_SCRIPT_URL;
22596            script.async = true;
22597            script.onload = function () {
22598              if (window.mermaid) {
22599                resolve(window.mermaid);
22600                return;
22601              }
22602              state.mermaidApiPromise = null;
22603              reject(new Error("Mermaid loaded but did not initialize."));
22604            };
22605            script.onerror = function () {
22606              state.mermaidApiPromise = null;
22607              reject(new Error("Failed to load Mermaid."));
22608            };
22609            document.head.appendChild(script);
22610          });
22611
22612          return state.mermaidApiPromise;
22613        }
22614
22615        function getBeautifulMermaidApi() {
22616          if (
22617            window.BeautifulMermaid &&
22618            typeof window.BeautifulMermaid.renderMermaidSVG === "function"
22619          ) {
22620            return Promise.resolve(window.BeautifulMermaid);
22621          }
22622          if (state.beautifulMermaidApiPromise) {
22623            return state.beautifulMermaidApiPromise;
22624          }
22625
22626          state.beautifulMermaidApiPromise = new Promise(function (
22627            resolve,
22628            reject,
22629          ) {
22630            const script = document.createElement("script");
22631            script.src = BEAUTIFUL_MERMAID_SCRIPT_URL;
22632            script.async = true;
22633            script.onload = function () {
22634              if (
22635                window.BeautifulMermaid &&
22636                typeof window.BeautifulMermaid.renderMermaidSVG === "function"
22637              ) {
22638                resolve(window.BeautifulMermaid);
22639                return;
22640              }
22641              state.beautifulMermaidApiPromise = null;
22642              reject(
22643                new Error("Beautiful Mermaid loaded but did not initialize."),
22644              );
22645            };
22646            script.onerror = function () {
22647              state.beautifulMermaidApiPromise = null;
22648              reject(new Error("Failed to load Beautiful Mermaid."));
22649            };
22650            document.head.appendChild(script);
22651          });
22652
22653          return state.beautifulMermaidApiPromise;
22654        }
22655
22656        function buildMermaidFallback(source, message, detail) {
22657          const detailHtml = detail
22658            ? '<pre class="mermaid-error-detail"><code>' +
22659              escapeHtml(detail) +
22660              "</code></pre>"
22661            : "";
22662          return (
22663            '<div class="mermaid-error">' +
22664            escapeHtml(message) +
22665            "</div>" +
22666            detailHtml +
22667            "<pre><code>" +
22668            escapeHtml(source) +
22669            "</code></pre>"
22670          );
22671        }
22672
22673        function revokeMermaidViewerDownloadUrl() {
22674          if (!state.mermaidViewerDownloadUrl) return;
22675          URL.revokeObjectURL(state.mermaidViewerDownloadUrl);
22676          state.mermaidViewerDownloadUrl = "";
22677        }
22678
22679        function createMermaidInlineAction() {
22680          const actions = document.createElement("div");
22681          actions.className = "mermaid-inline-actions";
22682
22683          const button = document.createElement("button");
22684          button.type = "button";
22685          button.className = "mermaid-inline-action";
22686          button.title = "Open diagram viewer";
22687          button.setAttribute("aria-label", "Open diagram viewer");
22688          button.innerHTML =
22689            '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
22690            '<path d="M15 3h6v6"></path>' +
22691            '<path d="M9 21H3v-6"></path>' +
22692            '<path d="M21 3 14 10"></path>' +
22693            '<path d="m3 21 7-7"></path>' +
22694            "</svg>" +
22695            "<span>Open diagram</span>";
22696          actions.appendChild(button);
22697          return actions;
22698        }
22699
22700        function ensureMermaidBlockActions(host) {
22701          if (!host || host.classList.contains("is-error")) return;
22702          if (host.querySelector(".mermaid-inline-actions")) return;
22703          host.prepend(createMermaidInlineAction());
22704        }
22705
22706        function getRenderedMermaidSvgFromHost(host) {
22707          if (!host) return null;
22708          return (
22709            Array.from(host.children).find(function (child) {
22710              return child.tagName === "svg" || child.tagName === "SVG";
22711            }) || null
22712          );
22713        }
22714
22715        function getMermaidViewerSizeFromSvg(svg) {
22716          const viewBox = svg && svg.viewBox ? svg.viewBox.baseVal : null;
22717          if (viewBox && viewBox.width && viewBox.height) {
22718            return {
22719              width: viewBox.width,
22720              height: viewBox.height,
22721            };
22722          }
22723
22724          const rect = svg ? svg.getBoundingClientRect() : null;
22725          return {
22726            width: Math.max(1, rect ? rect.width : 1200),
22727            height: Math.max(1, rect ? rect.height : 800),
22728          };
22729        }
22730
22731        function syncMermaidViewerZoomLabel() {
22732          els.mermaidViewerZoomLabel.textContent =
22733            Math.round(state.mermaidViewerScale * 100) + "%";
22734        }
22735
22736        function applyMermaidViewerScale(nextScale, options) {
22737          options = options || {};
22738          const stage = els.mermaidViewerStage;
22739          if (!stage || !state.mermaidViewerOpen) return;
22740
22741          const prevScale = state.mermaidViewerScale || 1;
22742          const scale = clampNumber(Number(nextScale) || 1, 0.2, 6);
22743          const contentX =
22744            options.contentX != null
22745              ? options.contentX
22746              : (stage.scrollLeft + stage.clientWidth / 2) / prevScale;
22747          const contentY =
22748            options.contentY != null
22749              ? options.contentY
22750              : (stage.scrollTop + stage.clientHeight / 2) / prevScale;
22751
22752          state.mermaidViewerScale = scale;
22753          els.mermaidViewerZoomable.style.transform = "scale(" + scale + ")";
22754          syncMermaidViewerZoomLabel();
22755
22756          window.requestAnimationFrame(function () {
22757            stage.scrollLeft = Math.max(
22758              0,
22759              contentX * scale - stage.clientWidth / 2,
22760            );
22761            stage.scrollTop = Math.max(
22762              0,
22763              contentY * scale - stage.clientHeight / 2,
22764            );
22765          });
22766        }
22767
22768        function computeMermaidViewerFitScale() {
22769          const stage = els.mermaidViewerStage;
22770          if (!stage) return 1;
22771
22772          var padding = 48;
22773          var availableWidth = Math.max(1, stage.clientWidth - padding);
22774          var availableHeight = Math.max(1, stage.clientHeight - padding);
22775          var baseW = Math.max(1, state.mermaidViewerBaseWidth);
22776          var baseH = Math.max(1, state.mermaidViewerBaseHeight);
22777          var widthRatio = availableWidth / baseW;
22778          var heightRatio = availableHeight / baseH;
22779          var aspectRatio = baseH / baseW;
22780
22781          var fitScale;
22782          if (aspectRatio > 2.5) {
22783            // Tall narrow diagrams: fit to width so content is readable,
22784            // let the user scroll vertically.
22785            fitScale = Math.min(widthRatio, 1);
22786          } else {
22787            // Normal/landscape diagrams: fit entirely in view.
22788            fitScale = Math.min(widthRatio, heightRatio);
22789          }
22790
22791          return clampNumber(fitScale, 0.2, 6);
22792        }
22793
22794        function fitMermaidViewer() {
22795          var baseW = Math.max(1, state.mermaidViewerBaseWidth);
22796          var baseH = Math.max(1, state.mermaidViewerBaseHeight);
22797          var isTall = baseH / baseW > 2.5;
22798          applyMermaidViewerScale(computeMermaidViewerFitScale(), {
22799            contentX: baseW / 2,
22800            // For tall diagrams, anchor at top so user sees the start
22801            contentY: isTall ? 0 : baseH / 2,
22802          });
22803        }
22804
22805        function closeMermaidViewer(options) {
22806          options = options || {};
22807          if (els.mermaidViewer.hidden) return;
22808
22809          revokeMermaidViewerDownloadUrl();
22810          els.mermaidViewer.hidden = true;
22811          els.mermaidViewer.setAttribute("aria-hidden", "true");
22812          els.mermaidViewerZoomable.innerHTML = "";
22813          els.mermaidViewerMeta.textContent = "No diagram selected.";
22814          els.mermaidViewerStage.classList.remove("is-dragging");
22815          state.mermaidViewerOpen = false;
22816          state.mermaidViewerDrag = null;
22817
22818          if (options.restoreFocus === false) return;
22819          if (
22820            state.mermaidViewerLastFocused &&
22821            typeof state.mermaidViewerLastFocused.focus === "function"
22822          ) {
22823            state.mermaidViewerLastFocused.focus();
22824          }
22825        }
22826
22827        function openMermaidViewerFromHost(host) {
22828          const svg = getRenderedMermaidSvgFromHost(host);
22829          if (!host || !svg) return;
22830
22831          revokeMermaidViewerDownloadUrl();
22832          state.mermaidViewerLastFocused =
22833            document.activeElement instanceof HTMLElement
22834              ? document.activeElement
22835              : null;
22836
22837          const clone = svg.cloneNode(true);
22838          const size = getMermaidViewerSizeFromSvg(svg);
22839          clone.style.maxWidth = "none";
22840          clone.style.width = size.width + "px";
22841          clone.style.height = size.height + "px";
22842
22843          state.mermaidViewerBaseWidth = size.width;
22844          state.mermaidViewerBaseHeight = size.height;
22845          state.mermaidViewerScale = 1;
22846
22847          els.mermaidViewerZoomable.innerHTML = "";
22848          els.mermaidViewerZoomable.appendChild(clone);
22849          els.mermaidViewer.hidden = false;
22850          els.mermaidViewer.setAttribute("aria-hidden", "false");
22851          els.mermaidViewerMeta.textContent =
22852            Math.round(size.width) + " × " + Math.round(size.height) + " px";
22853          state.mermaidViewerOpen = true;
22854
22855          const blob = new Blob([clone.outerHTML], {
22856            type: "image/svg+xml;charset=utf-8",
22857          });
22858          state.mermaidViewerDownloadUrl = URL.createObjectURL(blob);
22859
22860          syncMermaidViewerZoomLabel();
22861          fitMermaidViewer();
22862
22863          window.requestAnimationFrame(function () {
22864            els.mermaidViewerStage.scrollTop = 0;
22865            els.mermaidViewerStage.scrollLeft = 0;
22866            fitMermaidViewer();
22867            els.mermaidViewerStage.focus();
22868          });
22869        }
22870
22871        function normalizeMermaidSource(source) {
22872          const text = String(source || "");
22873          if (!/^\s*(?:flowchart|graph)\b/i.test(text)) {
22874            return text;
22875          }
22876
22877          // Mermaid 11 treats literal braces inside square-bracket node labels
22878          // as shape tokens. Escape common placeholder braces so flowchart labels
22879          // like "phase-{N}-spec.md" render as text instead of failing to parse.
22880          return text.replace(/\[(.*?)\]/gs, function (_match, inner) {
22881            return (
22882              "[" +
22883              inner.replace(/\{/g, "&#123;").replace(/\}/g, "&#125;") +
22884              "]"
22885            );
22886          });
22887        }
22888
22889        function validateBeautifulMermaidSvg(svgText) {
22890          const template = document.createElement("template");
22891          template.innerHTML = String(svgText || "").trim();
22892
22893          if (template.content.children.length !== 1) {
22894            throw new Error("Beautiful Mermaid returned unexpected markup.");
22895          }
22896
22897          const svg = template.content.firstElementChild;
22898          if (!svg || svg.tagName.toLowerCase() !== "svg") {
22899            throw new Error("Beautiful Mermaid did not return an SVG.");
22900          }
22901
22902          if (
22903            svg.querySelector(
22904              "script, foreignObject, iframe, object, embed, image",
22905            )
22906          ) {
22907            throw new Error("Beautiful Mermaid returned unsafe SVG markup.");
22908          }
22909
22910          const elements = [svg].concat(Array.from(svg.querySelectorAll("*")));
22911          for (const element of elements) {
22912            for (const attr of Array.from(element.attributes)) {
22913              const name = attr.name.toLowerCase();
22914              const value = String(attr.value || "").trim().toLowerCase();
22915              if (name.startsWith("on")) {
22916                throw new Error(
22917                  "Beautiful Mermaid returned event handler attributes.",
22918                );
22919              }
22920              if (
22921                (name === "href" || name === "xlink:href") &&
22922                !value.startsWith("#")
22923              ) {
22924                throw new Error("Beautiful Mermaid returned unsafe links.");
22925              }
22926              if (name === "style" && /(?:javascript:|@import)/i.test(value)) {
22927                throw new Error("Beautiful Mermaid returned unsafe styles.");
22928              }
22929            }
22930          }
22931
22932          svg.querySelectorAll("style").forEach(function (style) {
22933            style.textContent = String(style.textContent || "")
22934              .split(/\r?\n/)
22935              .filter(function (line) {
22936                return !/^\s*@import\b/i.test(line);
22937              })
22938              .join("\n");
22939            if (/(?:javascript:|@import)/i.test(style.textContent || "")) {
22940              throw new Error("Beautiful Mermaid returned unsafe styles.");
22941            }
22942          });
22943
22944          return svg.outerHTML;
22945        }
22946
22947        async function renderBeautifulMermaidSource(source) {
22948          const api = await getBeautifulMermaidApi();
22949          const svg = api.renderMermaidSVG(
22950            source,
22951            getBeautifulMermaidRenderOptions(),
22952          );
22953          return {
22954            renderer: BEAUTIFUL_MERMAID_RENDERER_NAME,
22955            svg: validateBeautifulMermaidSvg(svg),
22956          };
22957        }
22958
22959        function collectMermaidBlocks() {
22960          return Array.from(
22961            els.preview.querySelectorAll("pre > code.language-mermaid"),
22962          )
22963            .map(function (code, index) {
22964              const pre = code.parentElement;
22965              if (!pre) return null;
22966              const source = String(code.textContent || "").replace(/\s+$/, "");
22967              if (!source) return null;
22968
22969              const host = document.createElement("div");
22970              host.className = "mermaid-block is-loading";
22971              host.dataset.mermaidSource = source;
22972              host.dataset.mermaidIndex = String(index);
22973              host.textContent = "Rendering diagram…";
22974              pre.replaceWith(host);
22975
22976              return {
22977                host: host,
22978                source: source,
22979                index: index,
22980              };
22981            })
22982            .filter(Boolean);
22983        }
22984
22985        function collectRenderedMermaidBlocks() {
22986          return Array.from(
22987            els.preview.querySelectorAll(".mermaid-block[data-mermaid-source]"),
22988          )
22989            .map(function (host, index) {
22990              const source = String(host.dataset.mermaidSource || "").replace(
22991                /\s+$/,
22992                "",
22993              );
22994              if (!source) return null;
22995              const blockIndex = Number(host.dataset.mermaidIndex);
22996
22997              return {
22998                host: host,
22999                source: source,
23000                index: Number.isFinite(blockIndex) ? blockIndex : index,
23001              };
23002            })
23003            .filter(Boolean);
23004        }
23005
23006        function applyRenderedMermaidSvg(block, rendered) {
23007          const rendererName = rendered.renderer || MERMAID_RENDERER_NAME;
23008          block.host.classList.remove("is-loading", "is-error");
23009          block.host.dataset.mermaidRenderer = rendererName;
23010          block.host.innerHTML = rendered.svg;
23011
23012          const svg = block.host.querySelector("svg");
23013          if (svg) {
23014            svg.dataset.markvMermaidRenderer = rendererName;
23015            svg.setAttribute("role", "img");
23016            svg.setAttribute("aria-label", "Mermaid diagram");
23017            if (rendererName === MERMAID_RENDERER_NAME) {
23018              applyReadableMermaidPalette(svg);
23019            }
23020          }
23021          ensureMermaidBlockActions(block.host);
23022
23023          if (rendered.bindFunctions) {
23024            rendered.bindFunctions(block.host);
23025          }
23026        }
23027
23028        function applyMermaidRenderError(block, error) {
23029          block.host.className = "mermaid-block is-error";
23030          block.host.innerHTML = buildMermaidFallback(
23031            block.source,
23032            "Diagram could not be rendered. Showing source instead.",
23033            error && error.message ? error.message : String(error),
23034          );
23035        }
23036
23037        async function renderMermaidBlockCollection(
23038          blocks,
23039          generation,
23040          options,
23041        ) {
23042          options = options || {};
23043          const keepExistingOnError = Boolean(options.keepExistingOnError);
23044          if (!blocks.length) return;
23045
23046          let originalMermaid = null;
23047          const renderWithOriginalMermaid = async function (id, source) {
23048            if (!originalMermaid) {
23049              originalMermaid = await getMermaidApi();
23050              originalMermaid.initialize({
23051                startOnLoad: false,
23052                securityLevel: "strict",
23053                ...getMermaidThemeConfig(),
23054              });
23055            }
23056            const rendered = await originalMermaid.render(id, source);
23057            rendered.renderer = MERMAID_RENDERER_NAME;
23058            return rendered;
23059          };
23060
23061          try {
23062            for (const block of blocks) {
23063              const normalizedSource = normalizeMermaidSource(block.source);
23064              const rendererName = getMermaidRendererType(normalizedSource);
23065              const renderId =
23066                "markv-mermaid-" + generation + "-" + block.index;
23067              const hadExistingContent = Boolean(block.host.innerHTML.trim());
23068              try {
23069                let rendered;
23070                if (rendererName === BEAUTIFUL_MERMAID_RENDERER_NAME) {
23071                  try {
23072                    rendered =
23073                      await renderBeautifulMermaidSource(normalizedSource);
23074                  } catch (_beautifulError) {
23075                    rendered = await renderWithOriginalMermaid(
23076                      renderId,
23077                      normalizedSource,
23078                    );
23079                  }
23080                } else {
23081                  rendered = await renderWithOriginalMermaid(
23082                    renderId,
23083                    normalizedSource,
23084                  );
23085                }
23086                if (generation !== state.previewRenderGeneration) return;
23087                applyRenderedMermaidSvg(block, rendered);
23088              } catch (_error) {
23089                if (generation !== state.previewRenderGeneration) return;
23090                if (keepExistingOnError && hadExistingContent) continue;
23091                applyMermaidRenderError(block, _error);
23092              }
23093            }
23094          } catch (_error) {
23095            if (generation !== state.previewRenderGeneration) return;
23096            blocks.forEach(function (block) {
23097              const hadExistingContent = Boolean(block.host.innerHTML.trim());
23098              if (keepExistingOnError && hadExistingContent) return;
23099              applyMermaidRenderError(block, _error);
23100            });
23101          }
23102        }
23103
23104        async function renderMermaidBlocks(generation) {
23105          const blocks = collectMermaidBlocks();
23106          if (!blocks.length) return;
23107          await renderMermaidBlockCollection(blocks, generation);
23108        }
23109
23110        async function renderMermaidForExport() {
23111          const blocks = collectRenderedMermaidBlocks();
23112          if (!blocks.length) return;
23113          const generation = state.previewRenderGeneration + 1;
23114          state.previewRenderGeneration = generation;
23115          await renderMermaidBlockCollection(blocks, generation, {
23116            keepExistingOnError: false,
23117          });
23118        }
23119
23120        // ── highlight.js lazy loader ──────────────────────────────
23121        function decorateRemoteAsset(element, href) {
23122          var integrity = REMOTE_ASSET_INTEGRITY[href];
23123          if (!integrity) return;
23124          element.integrity = integrity;
23125          element.crossOrigin = "anonymous";
23126          element.referrerPolicy = "no-referrer";
23127        }
23128
23129        function loadCss(href) {
23130          if (document.querySelector('link[href="' + href + '"]')) return;
23131          var link = document.createElement("link");
23132          link.rel = "stylesheet";
23133          link.href = href;
23134          decorateRemoteAsset(link, href);
23135          document.head.appendChild(link);
23136        }
23137
23138        function ensureCssLink(href, attrs) {
23139          var link =
23140            (attrs &&
23141              attrs.theme &&
23142              document.querySelector(
23143                'link[data-markv-hljs-theme="' + attrs.theme + '"]',
23144              )) ||
23145            document.querySelector('link[href="' + href + '"]');
23146          if (!link) {
23147            link = document.createElement("link");
23148            link.rel = "stylesheet";
23149            link.href = href;
23150            decorateRemoteAsset(link, href);
23151            document.head.appendChild(link);
23152          }
23153          if (attrs && attrs.theme) {
23154            link.setAttribute("data-markv-hljs-theme", attrs.theme);
23155          }
23156          return link;
23157        }
23158
23159        function ensureHljsThemeLinks() {
23160          var darkActive = getEffectiveReaderScheme() === "dark";
23161          var light = ensureCssLink(HLJS_LIGHT_THEME_URL, { theme: "light" });
23162          var dark = ensureCssLink(HLJS_DARK_THEME_URL, { theme: "dark" });
23163          light.disabled = darkActive;
23164          dark.disabled = !darkActive;
23165        }
23166
23167        function getHighlightApi() {
23168          if (window.hljs) return Promise.resolve(window.hljs);
23169          if (state.hljsPromise) return state.hljsPromise;
23170
23171          state.hljsPromise = new Promise(function (resolve, reject) {
23172            var script = document.createElement("script");
23173            script.src = HLJS_CDN + "/highlight.min.js";
23174            script.async = true;
23175            decorateRemoteAsset(script, script.src);
23176            script.onload = function () {
23177              if (window.hljs) {
23178                ensureHljsThemeLinks();
23179                resolve(window.hljs);
23180              } else {
23181                state.hljsPromise = null;
23182                reject(new Error("highlight.js loaded but not initialized."));
23183              }
23184            };
23185            script.onerror = function () {
23186              state.hljsPromise = null;
23187              reject(new Error("Failed to load highlight.js."));
23188            };
23189            document.head.appendChild(script);
23190          });
23191          return state.hljsPromise;
23192        }
23193
23194        function applyHighlightJs(generation) {
23195          var blocks = els.preview.querySelectorAll(
23196            'pre > code[class*="language-"]',
23197          );
23198          if (!blocks.length) return;
23199
23200          getHighlightApi()
23201            .then(function (hljs) {
23202              if (generation !== state.previewRenderGeneration) return;
23203              blocks.forEach(function (block) {
23204                if (block.classList.contains("language-mermaid")) return;
23205                hljs.highlightElement(block);
23206              });
23207            })
23208            .catch(function () {
23209              // Syntax highlighting is best-effort
23210            });
23211        }
23212
23213        function swapHljsTheme() {
23214          if (window.hljs) ensureHljsThemeLinks();
23215        }
23216
23217        // ── KaTeX lazy loader ─────────────────────────────────────
23218        function hasLatex(markdown) {
23219          return /\$\$[\s\S]+?\$\$|\$[^\s$].*?[^\s$]\$/.test(
23220            String(markdown || ""),
23221          );
23222        }
23223
23224        function getKatexApi() {
23225          if (window.katex) return Promise.resolve(window.katex);
23226          if (state.katexPromise) return state.katexPromise;
23227
23228          loadCss(KATEX_CDN + "/katex.min.css");
23229
23230          state.katexPromise = new Promise(function (resolve, reject) {
23231            var script = document.createElement("script");
23232            script.src = KATEX_CDN + "/katex.min.js";
23233            script.async = true;
23234            decorateRemoteAsset(script, script.src);
23235            script.onload = function () {
23236              if (window.katex) {
23237                resolve(window.katex);
23238              } else {
23239                state.katexPromise = null;
23240                reject(new Error("KaTeX loaded but not initialized."));
23241              }
23242            };
23243            script.onerror = function () {
23244              state.katexPromise = null;
23245              reject(new Error("Failed to load KaTeX."));
23246            };
23247            document.head.appendChild(script);
23248          });
23249          return state.katexPromise;
23250        }
23251
23252        function renderKatexInPreview(generation) {
23253          getKatexApi()
23254            .then(function (katex) {
23255              if (generation !== state.previewRenderGeneration) return;
23256
23257              var walker = document.createTreeWalker(
23258                els.preview,
23259                NodeFilter.SHOW_TEXT,
23260                {
23261                  acceptNode: function (node) {
23262                    var parent = node.parentElement;
23263                    if (!parent) return NodeFilter.FILTER_REJECT;
23264                    var tag = parent.tagName;
23265                    if (
23266                      tag === "CODE" ||
23267                      tag === "PRE" ||
23268                      tag === "SCRIPT" ||
23269                      tag === "STYLE" ||
23270                      tag === "TEXTAREA"
23271                    )
23272                      return NodeFilter.FILTER_REJECT;
23273                    if (parent.closest("pre, code, .mermaid-block"))
23274                      return NodeFilter.FILTER_REJECT;
23275                    if (/\$/.test(node.textContent))
23276                      return NodeFilter.FILTER_ACCEPT;
23277                    return NodeFilter.FILTER_REJECT;
23278                  },
23279                },
23280              );
23281
23282              var textNodes = [];
23283              while (walker.nextNode()) textNodes.push(walker.currentNode);
23284
23285              textNodes.forEach(function (textNode) {
23286                var text = textNode.textContent;
23287                var parts = [];
23288                var re = /\$\$([\s\S]+?)\$\$|\$([^\s$](?:[^$]*[^\s$])?)\$/g;
23289                var lastIndex = 0;
23290                var match;
23291
23292                while ((match = re.exec(text)) !== null) {
23293                  if (match.index > lastIndex) {
23294                    parts.push({
23295                      type: "text",
23296                      value: text.slice(lastIndex, match.index),
23297                    });
23298                  }
23299                  parts.push({
23300                    type: match[1] != null ? "display" : "inline",
23301                    value: match[1] != null ? match[1] : match[2],
23302                  });
23303                  lastIndex = re.lastIndex;
23304                }
23305
23306                if (!parts.length) return;
23307                if (lastIndex < text.length) {
23308                  parts.push({ type: "text", value: text.slice(lastIndex) });
23309                }
23310
23311                var frag = document.createDocumentFragment();
23312                parts.forEach(function (part) {
23313                  if (part.type === "text") {
23314                    frag.appendChild(document.createTextNode(part.value));
23315                    return;
23316                  }
23317                  var wrapper = document.createElement(
23318                    part.type === "display" ? "div" : "span",
23319                  );
23320                  wrapper.className =
23321                    part.type === "display"
23322                      ? "katex-display-block"
23323                      : "katex-inline";
23324                  try {
23325                    katex.render(part.value, wrapper, {
23326                      displayMode: part.type === "display",
23327                      throwOnError: false,
23328                      trust: false,
23329                    });
23330                  } catch (_e) {
23331                    wrapper.textContent = part.value;
23332                  }
23333                  frag.appendChild(wrapper);
23334                });
23335                textNode.parentNode.replaceChild(frag, textNode);
23336              });
23337            })
23338            .catch(function () {
23339              // KaTeX is best-effort
23340            });
23341        }
23342
23343        // ── Markdown pre-processing (math + footnotes) ──────────
23344        function preprocessMath(doc) {
23345          // Replace $$...$$ blocks with placeholder divs while preserving
23346          // a mapping back to the original markdown offsets.
23347          var source = doc.text;
23348          var match;
23349          var lastIndex = 0;
23350          var builder = createMappedBuilder();
23351          var re = /\$\$([\s\S]+?)\$\$/g;
23352          var changed = false;
23353
23354          while ((match = re.exec(source)) !== null) {
23355            changed = true;
23356            appendDocumentSlice(builder, doc, lastIndex, match.index);
23357
23358            var encoded = match[1]
23359              .replace(/&/g, "&amp;")
23360              .replace(/</g, "&lt;")
23361              .replace(/>/g, "&gt;")
23362              .replace(/"/g, "&quot;");
23363            var range = mapDocumentRangeToSource(
23364              doc,
23365              match.index,
23366              match.index + match[0].length,
23367            );
23368
23369            appendMappedText(
23370              builder,
23371              '<div class="katex-display-block" data-katex-display="' +
23372                encoded +
23373                '"></div>',
23374              range.start,
23375              range.end,
23376            );
23377            lastIndex = match.index + match[0].length;
23378          }
23379
23380          if (!changed) return doc;
23381
23382          appendDocumentSlice(builder, doc, lastIndex, source.length);
23383          return finishMappedDocument(builder);
23384        }
23385
23386        function preprocessFootnotes(doc) {
23387          // Collect footnote definitions, replace inline references, and keep a
23388          // source map so preview blocks can still point back to the editor.
23389          var source = doc.text;
23390          var definitionRegex =
23391            /^\[\^([^\]]+)\]:\s+([\s\S]*?)(?=\n\[\^|\n\n(?!\s)|$)/gm;
23392          var definitionMatch;
23393          var definitions = {};
23394          var definitionList = [];
23395
23396          while ((definitionMatch = definitionRegex.exec(source)) !== null) {
23397            var mappedRange = mapDocumentRangeToSource(
23398              doc,
23399              definitionMatch.index,
23400              definitionMatch.index + definitionMatch[0].length,
23401            );
23402
23403            definitions[definitionMatch[1]] = {
23404              text: definitionMatch[2].replace(/\n\s{2,}/g, " ").trim(),
23405              start: definitionMatch.index,
23406              end: definitionMatch.index + definitionMatch[0].length,
23407              srcStart: mappedRange.start,
23408              srcEnd: mappedRange.end,
23409            };
23410            definitionList.push(definitions[definitionMatch[1]]);
23411          }
23412
23413          if (!definitionList.length) return doc;
23414
23415          var builder = createMappedBuilder();
23416          var usedOrder = [];
23417          var cursor = 0;
23418
23419          function appendSliceWithReferences(start, end) {
23420            if (end <= start) return;
23421
23422            var localSource = source.slice(start, end);
23423            var localCursor = 0;
23424            var referenceRegex = /\[\^([^\]]+)\]/g;
23425            var referenceMatch;
23426
23427            while (
23428              (referenceMatch = referenceRegex.exec(localSource)) !== null
23429            ) {
23430              var refStart = start + referenceMatch.index;
23431              var refEnd = refStart + referenceMatch[0].length;
23432              var id = referenceMatch[1];
23433
23434              appendDocumentSlice(builder, doc, start + localCursor, refStart);
23435
23436              if (!definitions[id]) {
23437                appendDocumentSlice(builder, doc, refStart, refEnd);
23438                localCursor = referenceMatch.index + referenceMatch[0].length;
23439                continue;
23440              }
23441
23442              var idx = usedOrder.indexOf(id);
23443              if (idx === -1) {
23444                usedOrder.push(id);
23445                idx = usedOrder.length - 1;
23446              }
23447
23448              var num = idx + 1;
23449              appendMappedText(
23450                builder,
23451                '<sup class="footnote-ref"><a href="#fn-' +
23452                  id +
23453                  '" id="fnref-' +
23454                  id +
23455                  '">' +
23456                  num +
23457                  "</a></sup>",
23458                definitions[id].srcStart,
23459                definitions[id].srcEnd,
23460              );
23461              localCursor = referenceMatch.index + referenceMatch[0].length;
23462            }
23463
23464            appendDocumentSlice(builder, doc, start + localCursor, end);
23465          }
23466
23467          definitionList.forEach(function (definition) {
23468            appendSliceWithReferences(cursor, definition.start);
23469            cursor = definition.end;
23470          });
23471          appendSliceWithReferences(cursor, source.length);
23472
23473          if (usedOrder.length) {
23474            var sectionRange = definitionList[0];
23475            appendMappedText(
23476              builder,
23477              "\n\n---\n\n",
23478              sectionRange.srcStart,
23479              sectionRange.srcEnd,
23480            );
23481            appendMappedText(
23482              builder,
23483              '<section class="footnotes" role="doc-endnotes">\n<ol>\n',
23484              sectionRange.srcStart,
23485              sectionRange.srcEnd,
23486            );
23487            usedOrder.forEach(function (id, i) {
23488              appendMappedText(
23489                builder,
23490                '<li id="fn-' +
23491                  id +
23492                  '" value="' +
23493                  (i + 1) +
23494                  '">' +
23495                  definitions[id].text +
23496                  ' <a href="#fnref-' +
23497                  id +
23498                  '" class="footnote-backref" aria-label="Back to reference">↩</a></li>\n',
23499                definitions[id].srcStart,
23500                definitions[id].srcEnd,
23501              );
23502            });
23503            appendMappedText(
23504              builder,
23505              "</ol>\n</section>",
23506              sectionRange.srcStart,
23507              sectionRange.srcEnd,
23508            );
23509          }
23510
23511          return finishMappedDocument(builder);
23512        }
23513
23514        function preprocessMarkdownWithSourceMap(markdown) {
23515          var doc = createIdentityMappedDocument(markdown);
23516          doc = preprocessFootnotes(doc);
23517          doc = preprocessMath(doc);
23518          return doc;
23519        }
23520
23521        function annotateTokenSourceRanges(tokens, sourceDoc) {
23522          var cursor = 0;
23523
23524          tokens.forEach(function (token) {
23525            var raw = String(token.raw || "");
23526            token.__preStart = cursor;
23527            token.__preEnd = cursor + raw.length;
23528            token.__sourceStart = mapDocumentOffsetToSource(
23529              sourceDoc,
23530              token.__preStart,
23531            );
23532            token.__sourceEnd = mapDocumentOffsetToSource(
23533              sourceDoc,
23534              token.__preEnd,
23535            );
23536
23537            if (token.type === "list" && Array.isArray(token.items)) {
23538              var itemCursor = token.__preStart;
23539              token.items.forEach(function (item) {
23540                var itemRaw = String(item.raw || "");
23541                item.__preStart = itemCursor;
23542                item.__preEnd = itemCursor + itemRaw.length;
23543                item.__sourceStart = mapDocumentOffsetToSource(
23544                  sourceDoc,
23545                  item.__preStart,
23546                );
23547                item.__sourceEnd = mapDocumentOffsetToSource(
23548                  sourceDoc,
23549                  item.__preEnd,
23550                );
23551                itemCursor = item.__preEnd;
23552              });
23553            }
23554
23555            cursor = token.__preEnd;
23556          });
23557
23558          return tokens;
23559        }
23560
23561        function renderKatexPlaceholders(generation) {
23562          var placeholders = els.preview.querySelectorAll(
23563            "[data-katex-display]",
23564          );
23565          if (!placeholders.length) return;
23566
23567          getKatexApi()
23568            .then(function (katex) {
23569              if (generation !== state.previewRenderGeneration) return;
23570              placeholders.forEach(function (el) {
23571                var latex = el.getAttribute("data-katex-display");
23572                if (!latex) return;
23573                // Decode HTML entities
23574                var decoded = latex
23575                  .replace(/&lt;/g, "<")
23576                  .replace(/&gt;/g, ">")
23577                  .replace(/&quot;/g, '"')
23578                  .replace(/&amp;/g, "&");
23579                try {
23580                  katex.render(decoded, el, {
23581                    displayMode: true,
23582                    throwOnError: false,
23583                    trust: false,
23584                  });
23585                } catch (_e) {
23586                  el.textContent = decoded;
23587                }
23588                el.removeAttribute("data-katex-display");
23589              });
23590            })
23591            .catch(function () {});
23592        }
23593
23594        function preservePreviewScroll(renderFn) {
23595          var shouldRestore = !isEditModeActive();
23596          var previousOffset = shouldRestore
23597            ? getPreviewViewportSourceOffset()
23598            : null;
23599          var previousRatio = shouldRestore ? captureWindowScrollRatio() : 0;
23600
23601          renderFn();
23602
23603          if (!shouldRestore) return;
23604
23605          if (typeof previousOffset === "number") {
23606            scrollPreviewToSourceOffset(previousOffset);
23607          } else {
23608            applyWindowScrollRatio(previousRatio);
23609          }
23610        }
23611
23612        function render(source) {
23613          const markdown = els.editor.value;
23614          const documentKey =
23615            state.sourceMode === "untitled"
23616              ? state.untitledDraftId || state.currentName
23617              : state.currentName;
23618          const documentToken =
23619            (documentKey || "__draft__") + "::" + state.sourceMode;
23620          const generation = state.previewRenderGeneration + 1;
23621          const editModeActive = isEditModeActive();
23622          const preserveEditorFocus =
23623            source === "editor" &&
23624            editModeActive &&
23625            document.activeElement === els.editor;
23626          const editorSelectionStart = preserveEditorFocus
23627            ? els.editor.selectionStart
23628            : null;
23629          const editorSelectionEnd = preserveEditorFocus
23630            ? els.editor.selectionEnd
23631            : null;
23632          const editorScrollTop = preserveEditorFocus
23633            ? els.editor.scrollTop
23634            : 0;
23635
23636          if (
23637            state.contentVersion === state.lastRenderedContentVersion &&
23638            documentToken === state.lastRenderedDocumentToken &&
23639            source !== "force"
23640          ) {
23641            syncCurrentHighlights();
23642            setStatus(
23643              state.currentName ? state.currentName + " • ready" : "Ready",
23644            );
23645            return;
23646          }
23647
23648          let html = "";
23649          if (markdown.trim()) {
23650            try {
23651              var preprocessed = preprocessMarkdownWithSourceMap(markdown);
23652              var parseOptions = Object.assign(
23653                {},
23654                marked.defaults,
23655                markedOptions,
23656              );
23657              var tokens = annotateTokenSourceRanges(
23658                marked.lexer(preprocessed.text, parseOptions),
23659                preprocessed,
23660              );
23661              html = marked.parser(tokens, parseOptions);
23662            } catch (error) {
23663              html =
23664                "<pre><code>" +
23665                escapeHtml(
23666                  String(error && error.message ? error.message : error),
23667                ) +
23668                "</code></pre>";
23669            }
23670          } else {
23671            resetMindmapState({ closeViewer: false });
23672            html =
23673              '<div class="empty-state">' +
23674              '<div class="empty-state-mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="currentColor"><path d="M46 0L46 46L0 46Q6 6 46 0Z"/><path d="M54 0L54 46L100 46Q94 6 54 0Z"/><path d="M46 100L46 54L0 54Q6 94 46 100Z"/><path d="M54 100L54 54L100 54Q94 94 54 100Z"/></svg></div>' +
23675              '<h2 class="empty-state-title">Begin reading</h2>' +
23676              '<p class="empty-state-hint">Drop a markdown file here, paste content, or open a .md file to start.</p>' +
23677              '<div class="empty-state-keys"><span><kbd>Ctrl</kbd><kbd>V</kbd> paste</span><span><kbd>Ctrl</kbd><kbd>E</kbd> edit</span></div>' +
23678              "</div>";
23679          }
23680
23681          preservePreviewScroll(function () {
23682            if (!editModeActive) {
23683              dismissSelectionUi();
23684              clearBrowserSelection();
23685            }
23686            state.highlightTextModel = null;
23687            state.resolvedHighlights = [];
23688            els.preview.innerHTML = html;
23689            document.documentElement.classList.toggle(
23690              "empty-preview-state",
23691              !markdown.trim(),
23692            );
23693            document.body.classList.toggle(
23694              "empty-preview-state",
23695              !markdown.trim(),
23696            );
23697            els.previewPane.classList.toggle(
23698              "is-empty-state",
23699              !markdown.trim(),
23700            );
23701            els.preview.dataset.documentLabel =
23702              state.currentName || "current document";
23703            wrapTables();
23704            decorateCodeBlocks();
23705            decorateLinks();
23706            applyHighlightsToPreview();
23707          });
23708          state.previewRenderGeneration = generation;
23709          refreshReaderRail(markdown, generation).catch(function (error) {
23710            console.warn("[reader-rail] refresh failed", error);
23711            hideReaderRail();
23712          });
23713          if (hasMermaidBlocks(markdown)) {
23714            renderMermaidBlocks(generation);
23715          }
23716          applyHighlightJs(generation);
23717          if (hasLatex(markdown)) {
23718            renderKatexPlaceholders(generation);
23719            renderKatexInPreview(generation);
23720          }
23721
23722          state.lastRenderedContentVersion = state.contentVersion;
23723          state.lastRenderedDocumentToken = documentToken;
23724          if (state.sourceMode === "untitled") {
23725            state.hasSessionDraft = false;
23726            removeStorage(window.sessionStorage, SESSION_CONTENT_KEY);
23727            captureActiveUntitledDraft({ touchLastActive: false });
23728          } else {
23729            scheduleSessionContentWrite(markdown);
23730          }
23731          writeSessionMeta();
23732          updateUiState();
23733
23734          const label = state.currentName ? state.currentName + " • " : "";
23735          const action =
23736            source === "editor"
23737              ? state.dirty &&
23738                (state.fileHandle ||
23739                  state.currentName ||
23740                  state.libraryCurrentFileHandle)
23741                ? "edited locally"
23742                : "edited"
23743              : source === "handle"
23744                ? "reloaded"
23745                : source === "file"
23746                  ? "loaded"
23747                  : source === "library"
23748                    ? "loaded"
23749                    : source === "untitled"
23750                      ? "loaded"
23751                      : "rendered";
23752
23753          const detail = source === "editor" ? "" : " • " + nowString();
23754          setStatus(label + action + detail);
23755          updateWordCount();
23756          if (preserveEditorFocus && document.activeElement !== els.editor) {
23757            requestAnimationFrame(function () {
23758              try {
23759                els.editor.focus({ preventScroll: true });
23760              } catch (_error) {
23761                els.editor.focus();
23762              }
23763              try {
23764                els.editor.setSelectionRange(
23765                  editorSelectionStart,
23766                  editorSelectionEnd,
23767                );
23768              } catch (_error) {}
23769              els.editor.scrollTop = editorScrollTop;
23770            });
23771          }
23772          document.dispatchEvent(
23773            new CustomEvent("markv:render-complete", {
23774              detail: {
23775                documentName: state.currentName || "",
23776                renderSource: source,
23777                timestamp: Date.now(),
23778              },
23779            }),
23780          );
23781        }
23782
23783        function scheduleRender() {
23784          window.clearTimeout(state.renderTimer);
23785          state.renderTimer = window.setTimeout(function () {
23786            if (isUntitledDraftMode()) {
23787              state.dirty = true;
23788              state.hasSessionDraft = false;
23789              captureActiveUntitledDraft({ dirty: true });
23790              render("editor");
23791              return;
23792            }
23793
23794            if (
23795              state.fileHandle ||
23796              state.sourceMode === "file" ||
23797              state.sourceMode === "library"
23798            ) {
23799              state.dirty = true;
23800              if (els.autoRefreshToggle.checked) {
23801                els.autoRefreshToggle.checked = false;
23802                stopAutoRefresh();
23803              }
23804            }
23805            state.hasSessionDraft = true;
23806            state.sourceMode = "editor";
23807            state.currentName = state.currentName || "";
23808            render("editor");
23809          }, 90);
23810        }
23811
23812        async function loadFromHandle(quiet) {
23813          if (!state.fileHandle) return;
23814
23815          await flushActiveUntitledDraft().catch(function (error) {
23816            console.warn("[drafts] active draft flush failed", error);
23817          });
23818          const file = await state.fileHandle.getFile();
23819          const markdown = await file.text();
23820          detachSharedSnapshotContext();
23821
23822          state.untitledDraftId = "";
23823          state.sourceMode = "handle";
23824          state.currentName = file.name;
23825          state.baseHref = window.location.href;
23826          state.dirty = false;
23827          state.hasSessionDraft = false;
23828          await clearActiveLibrarySelection({
23829            persist: true,
23830            renderDrawer: true,
23831          });
23832
23833          setEditorMarkdown(markdown);
23834          updateUiState();
23835
23836          render("handle");
23837
23838          if (!quiet) {
23839            setStatus("Reloaded • " + nowString(), {
23840              announce: true,
23841            });
23842          }
23843        }
23844
23845        async function loadFromFile(file) {
23846          if (!file) return;
23847
23848          await flushActiveUntitledDraft().catch(function (error) {
23849            console.warn("[drafts] active draft flush failed", error);
23850          });
23851          const markdown = await file.text();
23852
23853          stopAutoRefresh();
23854          els.autoRefreshToggle.checked = false;
23855          detachSharedSnapshotContext();
23856
23857          setPrimaryFileHandle(null);
23858          state.untitledDraftId = "";
23859          state.sourceMode = "file";
23860          state.currentName = file.name;
23861          state.baseHref = window.location.href;
23862          state.dirty = false;
23863          state.hasSessionDraft = false;
23864          await clearActiveLibrarySelection({
23865            persist: true,
23866            renderDrawer: true,
23867          });
23868
23869          setEditorMarkdown(markdown);
23870          updateUiState();
23871
23872          render("file");
23873          setStatus("Loaded • " + nowString(), {
23874            announce: true,
23875          });
23876        }
23877
23878        function stopAutoRefresh() {
23879          if (state.autoRefreshTimer) {
23880            window.clearTimeout(state.autoRefreshTimer);
23881            state.autoRefreshTimer = null;
23882          }
23883          state.autoRefreshInFlight = false;
23884        }
23885
23886        function scheduleNextAutoRefresh() {
23887          if (!els.autoRefreshToggle.checked) return;
23888          if (!getReaderRefreshState().canAutoRefresh) return;
23889          state.autoRefreshTimer = window.setTimeout(runAutoRefreshTick, 1500);
23890        }
23891
23892        async function runAutoRefreshTick() {
23893          if (!els.autoRefreshToggle.checked) {
23894            stopAutoRefresh();
23895            return;
23896          }
23897          if (state.autoRefreshInFlight) {
23898            scheduleNextAutoRefresh();
23899            return;
23900          }
23901
23902          state.autoRefreshInFlight = true;
23903          try {
23904            const refreshState = getReaderRefreshState();
23905            if (refreshState.reloadAction === "file-handle") {
23906              await loadFromHandle(true);
23907            } else if (
23908              refreshState.reloadAction === "library-persistent" &&
23909              state.libraryActiveFolderId &&
23910              state.libraryActiveFilePath
23911            ) {
23912              await loadFromLibrarySelection(
23913                state.libraryActiveFolderId,
23914                state.libraryActiveFilePath,
23915                {
23916                  closeDrawerOnMobile: false,
23917                  quiet: true,
23918                  userInitiated: false,
23919                },
23920              );
23921            } else {
23922              stopAutoRefresh();
23923              els.autoRefreshToggle.checked = false;
23924              updateUiState();
23925              return;
23926            }
23927          } catch (error) {
23928            stopAutoRefresh();
23929            els.autoRefreshToggle.checked = false;
23930            updateUiState();
23931            setStatus(
23932              "Auto refresh stopped • " +
23933                (error && error.message ? error.message : String(error)),
23934              { announce: true },
23935            );
23936            return;
23937          } finally {
23938            state.autoRefreshInFlight = false;
23939          }
23940
23941          scheduleNextAutoRefresh();
23942        }
23943
23944        function startAutoRefresh() {
23945          stopAutoRefresh();
23946
23947          if (!els.autoRefreshToggle.checked) return;
23948          if (!getReaderRefreshState().canAutoRefresh) {
23949            els.autoRefreshToggle.checked = false;
23950            updateUiState();
23951            return;
23952          }
23953
23954          scheduleNextAutoRefresh();
23955        }
23956
23957        async function openFile() {
23958          if (window.showOpenFilePicker && window.isSecureContext) {
23959            try {
23960              const handles = await window.showOpenFilePicker({
23961                multiple: false,
23962                types: [
23963                  {
23964                    description: "Markdown files",
23965                    accept: {
23966                      "text/markdown": [".md", ".markdown", ".mdown", ".mkdn"],
23967                      "text/plain": [".txt"],
23968                    },
23969                  },
23970                ],
23971              });
23972
23973              if (handles && handles[0]) {
23974                stopAutoRefresh();
23975                els.autoRefreshToggle.checked = false;
23976
23977                setPrimaryFileHandle(handles[0]);
23978                state.sourceMode = "handle";
23979                state.currentName = handles[0].name;
23980                state.baseHref = window.location.href;
23981
23982                updateUiState();
23983                await loadFromHandle(false);
23984                if (state.libraryIsOpen) {
23985                  hideLibraryDrawer({ restoreFocus: false });
23986                }
23987                return;
23988              }
23989            } catch (error) {
23990              if (error && error.name !== "AbortError") {
23991                els.fileInput.click();
23992              }
23993              return;
23994            }
23995          }
23996
23997          els.fileInput.click();
23998        }
23999
24000        async function reloadCurrent() {
24001          try {
24002            const refreshState = getReaderRefreshState();
24003
24004            if (refreshState.reloadAction === "file-handle") {
24005              await loadFromHandle(false);
24006              return;
24007            }
24008
24009            if (
24010              refreshState.reloadAction === "library-persistent" &&
24011              state.libraryActiveFolderId &&
24012              state.libraryActiveFilePath
24013            ) {
24014              await loadFromLibrarySelection(
24015                state.libraryActiveFolderId,
24016                state.libraryActiveFilePath,
24017                {
24018                  closeDrawerOnMobile: false,
24019                  quiet: false,
24020                  userInitiated: true,
24021                },
24022              );
24023              return;
24024            }
24025
24026            if (
24027              refreshState.reloadAction === "library-snapshot" &&
24028              state.libraryActiveFolderId
24029            ) {
24030              requestLibraryFolderImport({
24031                replaceFolderId: state.libraryActiveFolderId,
24032              });
24033              setStatus(
24034                "Choose the folder again to refresh this imported snapshot.",
24035                { announce: true },
24036              );
24037              return;
24038            }
24039
24040            if (refreshState.reloadAction === "file-picker") {
24041              els.fileInput.click();
24042              return;
24043            }
24044
24045            render("force");
24046          } catch (error) {
24047            setStatus(
24048              "Reload failed • " +
24049                (error && error.message ? error.message : String(error)),
24050              { announce: true },
24051            );
24052          }
24053        }
24054
24055        async function saveCurrentDocument() {
24056          const saveState = getReaderSaveState();
24057          if (!saveState.canSave) return;
24058
24059          const markdown = els.editor.value;
24060          const suggestedName = getSuggestedSaveName();
24061
24062          try {
24063            if (saveState.saveAction === "overwrite-handle") {
24064              const canWrite = await ensureWritableHandle(state.fileHandle);
24065              if (!canWrite) {
24066                setStatus("Save cancelled • write access was not granted.", {
24067                  announce: true,
24068                });
24069                return;
24070              }
24071              await writeMarkdownToHandle(state.fileHandle, markdown);
24072              state.currentName = state.fileHandle.name || suggestedName;
24073              markDocumentSaved("handle");
24074              setStatus("Saved • " + nowString(), {
24075                announce: true,
24076              });
24077              return;
24078            }
24079
24080            if (saveState.saveAction === "overwrite-library-handle") {
24081              const canWrite = await ensureWritableHandle(
24082                state.libraryCurrentFileHandle,
24083              );
24084              if (!canWrite) {
24085                setStatus("Save cancelled • write access was not granted.", {
24086                  announce: true,
24087                });
24088                return;
24089              }
24090              await writeMarkdownToHandle(
24091                state.libraryCurrentFileHandle,
24092                markdown,
24093              );
24094              state.currentName =
24095                state.libraryCurrentFileHandle.name || suggestedName;
24096              markDocumentSaved("library");
24097              setStatus("Saved • " + nowString(), {
24098                announce: true,
24099              });
24100              return;
24101            }
24102
24103            if (saveState.saveAction === "pick-save-target") {
24104              if (canUseNativeSavePicker()) {
24105                const handle = await window.showSaveFilePicker({
24106                  suggestedName: suggestedName,
24107                  types: createMarkdownFileTypes(),
24108                });
24109                const canWrite = await ensureWritableHandle(handle);
24110                if (!canWrite) {
24111                  setStatus("Save cancelled • write access was not granted.", {
24112                    announce: true,
24113                  });
24114                  return;
24115                }
24116                await writeMarkdownToHandle(handle, markdown);
24117                stopAutoRefresh();
24118                els.autoRefreshToggle.checked = false;
24119                setPrimaryFileHandle(handle);
24120                await detachUntitledDraftAfterSave();
24121                state.currentName = handle.name || suggestedName;
24122                state.baseHref = window.location.href;
24123                await clearActiveLibrarySelection({
24124                  persist: true,
24125                  renderDrawer: true,
24126                });
24127                markDocumentSaved("handle");
24128                setStatus("Saved • " + nowString(), {
24129                  announce: true,
24130                });
24131                return;
24132              }
24133
24134              downloadMarkdown(markdown, suggestedName);
24135              await detachUntitledDraftAfterSave();
24136              state.sourceMode = "editor";
24137              state.currentName = suggestedName;
24138              markDocumentSaved(null);
24139              setStatus(suggestedName + " • downloaded • " + nowString(), {
24140                announce: true,
24141              });
24142            }
24143          } catch (error) {
24144            if (error && error.name === "AbortError") return;
24145            setStatus(
24146              "Save failed • " +
24147                (error && error.message ? error.message : String(error)),
24148              { announce: true },
24149            );
24150          }
24151        }
24152
24153        async function clearAll() {
24154          const activeDraft = getActiveUntitledDraft();
24155          if (
24156            activeDraft &&
24157            String(activeDraft.content || els.editor.value || "").trim()
24158          ) {
24159            const confirmed = window.confirm(
24160              "Discard " + activeDraft.title + "? This cannot be undone.",
24161            );
24162            if (!confirmed) return;
24163          }
24164
24165          stopAutoRefresh();
24166          els.autoRefreshToggle.checked = false;
24167          detachSharedSnapshotContext();
24168          dismissSelectionUi();
24169          hideHighlightUndoToast();
24170
24171          setPrimaryFileHandle(null);
24172          state.sourceMode = "editor";
24173          state.currentName = "";
24174          state.baseHref = window.location.href;
24175          invalidateLastRenderedDocument();
24176          state.dirty = false;
24177          state.hasSessionDraft = false;
24178          state.highlightDocKey = "";
24179          state.currentHighlights = [];
24180          state.resolvedHighlights = [];
24181          state.highlightTextModel = null;
24182          if (activeDraft) {
24183            await deleteUntitledDraftRecord(activeDraft.id, {
24184              renderDrawer: false,
24185            });
24186          }
24187          await clearLibraryCollection({
24188            persist: true,
24189            renderDrawer: true,
24190          });
24191
24192          setEditorMarkdown("");
24193          resetMindmapState({ closeViewer: true });
24194
24195          removeStorage(window.sessionStorage, SESSION_CONTENT_KEY);
24196          removeStorage(window.sessionStorage, SESSION_META_KEY);
24197          updateUiState();
24198          render("force");
24199          setStatus("Cleared", { announce: true });
24200        }
24201
24202        function syncEditorSelectionState() {
24203          state.editorSelection = els.editor.selectionStart || 0;
24204        }
24205
24206        els.editor.addEventListener("input", function () {
24207          markEditorContentChanged();
24208          syncEditorSelectionState();
24209          updateUiState();
24210          scheduleRender();
24211        });
24212
24213        function applyEditorMarkdownEdit(edit) {
24214          if (!edit) return false;
24215          els.editor.focus();
24216          els.editor.setSelectionRange(edit.start, edit.end);
24217          let ok = false;
24218          try {
24219            ok = document.execCommand("insertText", false, edit.text);
24220          } catch (_error) {
24221            ok = false;
24222          }
24223          if (!ok) {
24224            els.editor.setRangeText(edit.text, edit.start, edit.end, "end");
24225            els.editor.dispatchEvent(new Event("input", { bubbles: true }));
24226          }
24227          if (typeof edit.selectionStart === "number") {
24228            els.editor.setSelectionRange(
24229              edit.selectionStart,
24230              typeof edit.selectionEnd === "number"
24231                ? edit.selectionEnd
24232                : edit.selectionStart,
24233            );
24234          }
24235          markEditorContentChanged();
24236          syncEditorSelectionState();
24237          updateUiState();
24238          scheduleRender();
24239          return true;
24240        }
24241
24242        function isEditorCompositionEvent(event) {
24243          return Boolean(event && (event.isComposing || event.keyCode === 229));
24244        }
24245
24246        els.editor.addEventListener("keydown", function (event) {
24247          if (isEditorCompositionEvent(event)) return;
24248          const markdownActions = window.MarkVEditorMarkdownActions;
24249          if (!markdownActions) return;
24250
24251          if (
24252            event.key === "Enter" &&
24253            !event.metaKey &&
24254            !event.ctrlKey &&
24255            !event.altKey
24256          ) {
24257            const edit = markdownActions.handleEnter(
24258              els.editor.value,
24259              els.editor.selectionStart,
24260              els.editor.selectionEnd,
24261            );
24262            if (edit && applyEditorMarkdownEdit(edit)) {
24263              event.preventDefault();
24264            }
24265            return;
24266          }
24267
24268          if (event.key === "Tab") {
24269            const edit = markdownActions.handleTab(
24270              els.editor.value,
24271              els.editor.selectionStart,
24272              els.editor.selectionEnd,
24273              event.shiftKey,
24274            );
24275            if (edit && applyEditorMarkdownEdit(edit)) {
24276              event.preventDefault();
24277              return;
24278            }
24279            if (!event.shiftKey) event.preventDefault();
24280          }
24281        });
24282
24283        ["click", "keyup", "select"].forEach(function (eventName) {
24284          els.editor.addEventListener(eventName, syncEditorSelectionState);
24285        });
24286
24287        document.addEventListener("selectionchange", function () {
24288          const controller = captureSelectionController();
24289          if (!controller) {
24290            hideSelectionBubble();
24291            hideContextMenu();
24292          }
24293        });
24294
24295        els.previewPane.addEventListener("mouseup", function () {
24296          scheduleSelectionBubble();
24297        });
24298
24299        document.addEventListener("contextmenu", function (event) {
24300          if (!event.target.closest(".preview-pane, .markdown-body")) return;
24301          hideSelectionBubble();
24302          hideContextMenu();
24303        });
24304
24305        document.addEventListener("pointerdown", function (event) {
24306          const target = event.target;
24307          const insideMermaidViewer =
24308            state.mermaidViewerOpen && els.mermaidViewer.contains(target);
24309          const insideSelectionUi =
24310            els.selectionBubble.contains(target) ||
24311            els.ctxMenu.contains(target) ||
24312            els.highlightRemoveChip.contains(target);
24313
24314          if (
24315            state.mermaidViewerOpen &&
24316            !insideMermaidViewer &&
24317            !target.closest(".mermaid-inline-action")
24318          ) {
24319            closeMermaidViewer({ restoreFocus: false });
24320          }
24321
24322          if (insideMermaidViewer) {
24323            return;
24324          }
24325
24326          if (!insideSelectionUi) {
24327            hideSelectionBubble();
24328            hideContextMenu();
24329          }
24330
24331          if (
24332            !insideSelectionUi &&
24333            !target.closest(".doc-highlight") &&
24334            !target.closest(".preview-pane, .markdown-body")
24335          ) {
24336            hideActiveHighlight();
24337          }
24338        });
24339
24340        window.addEventListener("scroll", function () {
24341          hideSelectionBubble();
24342          hideContextMenu();
24343          hideActiveHighlight();
24344        });
24345
24346        window.addEventListener("resize", function () {
24347          if (state.mermaidViewerOpen) {
24348            fitMermaidViewer();
24349          }
24350          hideSelectionBubble();
24351          hideContextMenu();
24352          hideActiveHighlight();
24353        });
24354
24355        els.preview.addEventListener("click", function (event) {
24356          const mermaidAction = event.target.closest(".mermaid-inline-action");
24357          if (mermaidAction) {
24358            event.preventDefault();
24359            event.stopPropagation();
24360            openMermaidViewerFromHost(mermaidAction.closest(".mermaid-block"));
24361            return;
24362          }
24363
24364          const mermaidSvg = event.target.closest(".mermaid-block svg");
24365          if (mermaidSvg) {
24366            event.preventDefault();
24367            openMermaidViewerFromHost(mermaidSvg.closest(".mermaid-block"));
24368            return;
24369          }
24370
24371          if (event.target.closest("a[href]")) return;
24372
24373          const mark = event.target.closest(".doc-highlight");
24374          if (!mark) {
24375            hideActiveHighlight();
24376            return;
24377          }
24378
24379          event.preventDefault();
24380          event.stopPropagation();
24381          activateHighlight(mark.dataset.highlightId || "");
24382        });
24383
24384        [
24385          els.selectionCopyBtn,
24386          els.selectionHighlightBtn,
24387          els.selectionAskBtn,
24388          els.selectionSummarizeBtn,
24389          els.highlightRemoveChip,
24390          els.highlightUndoBtn,
24391          els.ctxHighlight,
24392          els.ctxAsk,
24393          els.ctxSummarize,
24394        ].forEach(function (button) {
24395          button.addEventListener("pointerdown", function (event) {
24396            event.preventDefault();
24397          });
24398        });
24399
24400        els.selectionCopyBtn.addEventListener("click", function () {
24401          copySelectedTextToClipboard();
24402        });
24403        els.selectionHighlightBtn.addEventListener("click", function () {
24404          addHighlightFromSelection();
24405        });
24406        els.selectionAskBtn.addEventListener("click", function () {
24407          dispatchSelectionAction("ask");
24408        });
24409        els.selectionSummarizeBtn.addEventListener("click", function () {
24410          dispatchSelectionAction("summarize");
24411        });
24412
24413        els.ctxHighlight.addEventListener("click", function () {
24414          hideContextMenu();
24415          addHighlightFromSelection();
24416        });
24417        els.ctxAsk.addEventListener("click", function () {
24418          hideContextMenu();
24419          dispatchSelectionAction("ask");
24420        });
24421        els.ctxSummarize.addEventListener("click", function () {
24422          hideContextMenu();
24423          dispatchSelectionAction("summarize");
24424        });
24425
24426        els.highlightRemoveChip.addEventListener("click", function () {
24427          if (!state.activeHighlightId) return;
24428          removeHighlightById(state.activeHighlightId);
24429        });
24430
24431        els.highlightUndoBtn.addEventListener("click", function () {
24432          if (!state.highlightUndo) return;
24433          const undo = state.highlightUndo;
24434          hideHighlightUndoToast();
24435          undo();
24436        });
24437
24438        els.mermaidViewerCloseBtn.addEventListener("click", function () {
24439          closeMermaidViewer();
24440        });
24441        els.mermaidViewerZoomInBtn.addEventListener("click", function () {
24442          applyMermaidViewerScale(state.mermaidViewerScale * 1.15);
24443        });
24444        els.mermaidViewerZoomOutBtn.addEventListener("click", function () {
24445          applyMermaidViewerScale(state.mermaidViewerScale / 1.15);
24446        });
24447        els.mermaidViewerResetBtn.addEventListener("click", function () {
24448          applyMermaidViewerScale(1, {
24449            contentX: state.mermaidViewerBaseWidth / 2,
24450            contentY: state.mermaidViewerBaseHeight / 2,
24451          });
24452        });
24453        els.mermaidViewerFitBtn.addEventListener("click", function () {
24454          fitMermaidViewer();
24455        });
24456        els.mermaidViewerDownloadBtn.addEventListener("click", function () {
24457          if (!state.mermaidViewerDownloadUrl) return;
24458          const link = document.createElement("a");
24459          link.href = state.mermaidViewerDownloadUrl;
24460          link.download = "diagram.svg";
24461          document.body.appendChild(link);
24462          link.click();
24463          link.remove();
24464        });
24465        els.mermaidViewer.addEventListener("click", function (event) {
24466          if (event.target === els.mermaidViewer) {
24467            closeMermaidViewer();
24468          }
24469        });
24470        els.mermaidViewerStage.addEventListener("wheel", function (event) {
24471          if (!state.mermaidViewerOpen) return;
24472          event.preventDefault();
24473          const rect = els.mermaidViewerStage.getBoundingClientRect();
24474          const offsetX = event.clientX - rect.left;
24475          const offsetY = event.clientY - rect.top;
24476          const contentX =
24477            (els.mermaidViewerStage.scrollLeft + offsetX) /
24478            state.mermaidViewerScale;
24479          const contentY =
24480            (els.mermaidViewerStage.scrollTop + offsetY) /
24481            state.mermaidViewerScale;
24482          const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
24483          applyMermaidViewerScale(state.mermaidViewerScale * factor, {
24484            contentX: contentX,
24485            contentY: contentY,
24486          });
24487        });
24488        els.mermaidViewerStage.addEventListener(
24489          "pointerdown",
24490          function (event) {
24491            if (!state.mermaidViewerOpen) return;
24492            if (event.button !== 0) return;
24493            state.mermaidViewerDrag = {
24494              pointerId: event.pointerId,
24495              startX: event.clientX,
24496              startY: event.clientY,
24497              scrollLeft: els.mermaidViewerStage.scrollLeft,
24498              scrollTop: els.mermaidViewerStage.scrollTop,
24499            };
24500            els.mermaidViewerStage.classList.add("is-dragging");
24501            els.mermaidViewerStage.setPointerCapture(event.pointerId);
24502          },
24503        );
24504        els.mermaidViewerStage.addEventListener(
24505          "pointermove",
24506          function (event) {
24507            const drag = state.mermaidViewerDrag;
24508            if (!drag || drag.pointerId !== event.pointerId) return;
24509            els.mermaidViewerStage.scrollLeft =
24510              drag.scrollLeft - (event.clientX - drag.startX);
24511            els.mermaidViewerStage.scrollTop =
24512              drag.scrollTop - (event.clientY - drag.startY);
24513          },
24514        );
24515        function stopMermaidViewerDrag(event) {
24516          const drag = state.mermaidViewerDrag;
24517          if (!drag) return;
24518          if (event && drag.pointerId !== event.pointerId) return;
24519          state.mermaidViewerDrag = null;
24520          els.mermaidViewerStage.classList.remove("is-dragging");
24521        }
24522        els.mermaidViewerStage.addEventListener(
24523          "pointerup",
24524          stopMermaidViewerDrag,
24525        );
24526        els.mermaidViewerStage.addEventListener(
24527          "pointercancel",
24528          stopMermaidViewerDrag,
24529        );
24530
24531        els.readingToggleBtn.addEventListener("click", function () {
24532          if (els.readingPanel.hidden) {
24533            showReadingPanel();
24534          } else {
24535            hideReadingPanel();
24536          }
24537        });
24538
24539        els.readingCloseBtn.addEventListener("click", function () {
24540          hideReadingPanel();
24541        });
24542
24543        els.readingResetBtn.addEventListener("click", function () {
24544          applyReadingPreset("balanced", { announce: true });
24545        });
24546
24547        els.readingPresetButtons.forEach(function (button) {
24548          button.addEventListener("click", function () {
24549            applyReadingPreset(button.dataset.preset, { announce: true });
24550            button.focus();
24551          });
24552        });
24553
24554        els.readingPresetGroup.addEventListener("keydown", function (event) {
24555          const buttons = els.readingPresetButtons;
24556          const currentIndex = buttons.indexOf(document.activeElement);
24557          if (currentIndex === -1) return;
24558
24559          let nextIndex = currentIndex;
24560          if (event.key === "ArrowRight" || event.key === "ArrowDown") {
24561            nextIndex = (currentIndex + 1) % buttons.length;
24562          } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
24563            nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
24564          } else if (event.key === "Home") {
24565            nextIndex = 0;
24566          } else if (event.key === "End") {
24567            nextIndex = buttons.length - 1;
24568          } else {
24569            return;
24570          }
24571
24572          event.preventDefault();
24573          const nextButton = buttons[nextIndex];
24574          applyReadingPreset(nextButton.dataset.preset);
24575          nextButton.focus();
24576        });
24577
24578        els.readingPaletteButtons.forEach(function (button) {
24579          button.addEventListener("click", function () {
24580            applyReaderPalette(button.dataset.readerPalette, {
24581              persist: true,
24582              announce: true,
24583              syncTheme: true,
24584            });
24585            button.focus();
24586          });
24587        });
24588
24589        els.readingPaletteGroup.addEventListener("keydown", function (event) {
24590          const buttons = els.readingPaletteButtons;
24591          const currentIndex = buttons.indexOf(document.activeElement);
24592          if (currentIndex === -1) return;
24593
24594          let nextIndex = currentIndex;
24595          if (event.key === "ArrowRight" || event.key === "ArrowDown") {
24596            nextIndex = (currentIndex + 1) % buttons.length;
24597          } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
24598            nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
24599          } else if (event.key === "Home") {
24600            nextIndex = 0;
24601          } else if (event.key === "End") {
24602            nextIndex = buttons.length - 1;
24603          } else {
24604            return;
24605          }
24606
24607          event.preventDefault();
24608          const nextButton = buttons[nextIndex];
24609          applyReaderPalette(nextButton.dataset.readerPalette, {
24610            persist: true,
24611            syncTheme: true,
24612          });
24613          nextButton.focus();
24614        });
24615
24616        [
24617          {
24618            input: els.readingFontSizeInput,
24619            key: "fontSize",
24620            announcePrefix: "Text size",
24621          },
24622          {
24623            input: els.readingLineHeightInput,
24624            key: "lineHeight",
24625            announcePrefix: "Line spacing",
24626          },
24627          {
24628            input: els.readingMeasureInput,
24629            key: "measure",
24630            announcePrefix: "Reading width",
24631          },
24632        ].forEach(function (control) {
24633          control.input.addEventListener("input", function () {
24634            updateReadingSetting(control.key, control.input.value, {
24635              persist: false,
24636            });
24637          });
24638
24639          control.input.addEventListener("change", function () {
24640            updateReadingSetting(control.key, control.input.value, {
24641              persist: true,
24642              announce:
24643                control.announcePrefix +
24644                " set to " +
24645                formatReadingValue(
24646                  control.key,
24647                  state.readingSettings[control.key],
24648                ) +
24649                ".",
24650            });
24651          });
24652        });
24653
24654        document.addEventListener("pointerdown", function (event) {
24655          if (els.readingPanel.hidden) return;
24656          if (
24657            els.readingPanel.contains(event.target) ||
24658            els.readingToggleBtn.contains(event.target)
24659          ) {
24660            return;
24661          }
24662          hideReadingPanel({ restoreFocus: false });
24663        });
24664
24665        els.newDocumentBtn.addEventListener("click", createUntitledDocument);
24666        els.saveBtn.addEventListener("click", saveCurrentDocument);
24667        els.shareBtn.addEventListener("click", shareCurrentSnapshot);
24668        if (els.appToastDismiss) {
24669          els.appToastDismiss.addEventListener("click", function () {
24670            hideAppToast(false);
24671          });
24672        }
24673        els.reloadBtn.addEventListener("click", reloadCurrent);
24674        els.clearBtn.addEventListener("click", clearAll);
24675        els.autoRefreshToggle.addEventListener("change", startAutoRefresh);
24676        if (document.readyState === "loading") {
24677          document.addEventListener("DOMContentLoaded", wireLibraryUi, {
24678            once: true,
24679          });
24680        } else {
24681          wireLibraryUi();
24682        }
24683
24684        wireMindmapInteractions();
24685        wireMindmapNodeClicks();
24686        wireMindmapHover();
24687        wireReaderRail();
24688
24689        function parseMindmapRetryDelayMs(value) {
24690          const raw = String(value || "").trim();
24691          if (!raw) return 0;
24692          if (/^\d+(?:\.\d+)?$/.test(raw)) {
24693            return Math.max(0, Math.round(parseFloat(raw) * 1000));
24694          }
24695          let total = 0;
24696          const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/gi;
24697          let match;
24698          while ((match = regex.exec(raw))) {
24699            const amount = parseFloat(match[1]);
24700            const unit = match[2].toLowerCase();
24701            if (unit === "ms") total += amount;
24702            else if (unit === "s") total += amount * 1000;
24703            else if (unit === "m") total += amount * 60000;
24704            else if (unit === "h") total += amount * 3600000;
24705          }
24706          return Math.max(0, Math.round(total));
24707        }
24708
24709        function formatMindmapRetryDelay(ms) {
24710          const seconds = Math.max(1, Math.ceil(ms / 1000));
24711          if (seconds < 60) return `${seconds}s`;
24712          const minutes = Math.floor(seconds / 60);
24713          const remainder = seconds % 60;
24714          return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
24715        }
24716
24717        function isMindmapRateLimitError(err) {
24718          if (!err) return false;
24719          if (err.status === 429) return true;
24720          const message = String(err.message || "").toLowerCase();
24721          return (
24722            message.includes("rate limit") ||
24723            message.includes("rate-limit") ||
24724            message.includes("too many requests")
24725          );
24726        }
24727
24728        function getMindmapRetryDelayMs(err) {
24729          if (!err) return 0;
24730          const fromRetryAfter = parseMindmapRetryDelayMs(err.retryAfter);
24731          if (fromRetryAfter > 0) return fromRetryAfter;
24732          const fromReset = parseMindmapRetryDelayMs(err.rateLimitResetTokens);
24733          if (fromReset > 0) return fromReset;
24734          return 15000;
24735        }
24736
24737        async function runMindmapPipeline(options) {
24738          const force = !!(options && options.force);
24739          const { loading, errorPane } = getMindmapEls();
24740          const editor = document.getElementById("editor");
24741          const rawMarkdown = editor ? editor.value : "";
24742
24743          openMindmapViewer();
24744          if (errorPane) errorPane.classList.add("is-hidden");
24745          // Start every run with a clean loading pane so a prior run's spinner
24746          // can't linger into an early-return branch (empty doc / no client).
24747          if (loading) loading.classList.add("is-hidden");
24748
24749          if (!rawMarkdown.trim()) {
24750            resetMindmapState({ closeViewer: false });
24751            showMindmapError(
24752              "Load a markdown document first, then open the mindmap.",
24753              "empty_document",
24754            );
24755            return;
24756          }
24757
24758          const libraryContext =
24759            await buildActiveLibraryMindmapContext(rawMarkdown);
24760          const libraryContextCacheKey =
24761            getMindmapLibraryContextCacheKey(libraryContext);
24762          const docHash = await hashString(
24763            rawMarkdown + "\n\n::library-context::" + libraryContextCacheKey,
24764          );
24765          if (docHash !== mindmapState.currentDocHash) {
24766            resetMindmapState({ closeViewer: false });
24767          }
24768          mindmapState.currentDocHash = docHash;
24769
24770          const cached = getCachedGraph(docHash);
24771
24772          // Cache check
24773          if (!force) {
24774            if (cached) {
24775              mindmapState.currentGraph = cached;
24776              mindmapState.laid = computeRadialLayout(cached);
24777              renderMindmap(mindmapState.laid);
24778              requestAnimationFrame(() => fitMindmapToView(false));
24779              return;
24780            }
24781          }
24782
24783          if (mindmapState.isLoading) {
24784            return;
24785          }
24786
24787          if (mindmapState.rateLimitUntil > Date.now()) {
24788            if (cached) {
24789              mindmapState.currentGraph = cached;
24790              mindmapState.laid = computeRadialLayout(cached);
24791              renderMindmap(mindmapState.laid);
24792              requestAnimationFrame(() => fitMindmapToView(false));
24793            }
24794            showMindmapError(
24795              `Rate limited by the LLM. Try again in about ${formatMindmapRetryDelay(
24796                mindmapState.rateLimitUntil - Date.now(),
24797              )}.`,
24798              "rate_limited",
24799            );
24800            return;
24801          }
24802
24803          if (typeof window.markvLLM !== "function") {
24804            showMindmapError(
24805              "Configure an LLM client for AI-assisted concept extraction. Local deterministic connections are shown on the reading page when this document is opened from a library folder.",
24806              "no_client",
24807            );
24808            return;
24809          }
24810
24811          // LLM path
24812          mindmapState.isLoading = true;
24813          if (loading) loading.classList.remove("is-hidden");
24814          try {
24815            const graph = await extractConcepts(rawMarkdown, libraryContext);
24816            if (!graph.nodes || graph.nodes.length < 3) {
24817              throw new Error(
24818                "This document is too short or too abstract to map meaningfully. Try a document with at least a few sections.",
24819              );
24820            }
24821            mindmapState.rateLimitUntil = 0;
24822            setCachedGraph(docHash, graph);
24823            mindmapState.currentGraph = graph;
24824            mindmapState.laid = computeRadialLayout(graph);
24825            renderMindmap(mindmapState.laid);
24826            requestAnimationFrame(() => fitMindmapToView(false));
24827          } catch (err) {
24828            if (isMindmapRateLimitError(err)) {
24829              mindmapState.rateLimitUntil =
24830                Date.now() + getMindmapRetryDelayMs(err);
24831              const fallback = cached || mindmapState.currentGraph;
24832              if (fallback) {
24833                mindmapState.currentGraph = fallback;
24834                mindmapState.laid = computeRadialLayout(fallback);
24835                renderMindmap(mindmapState.laid);
24836                requestAnimationFrame(() => fitMindmapToView(false));
24837              }
24838            }
24839            showMindmapError(humanizeMindmapError(err), "extract_failed");
24840          } finally {
24841            mindmapState.isLoading = false;
24842            if (loading) loading.classList.add("is-hidden");
24843          }
24844        }
24845
24846        function humanizeMindmapError(err) {
24847          const msg = (err && err.message) || "";
24848          const lower = msg.toLowerCase();
24849          if (lower.includes("groq api key")) {
24850            return "Add a Groq API key in AI settings, then try again.";
24851          }
24852          if (lower.includes("cors_blocked")) {
24853            return "Browser access to Ollama is blocked by CORS. Allow this viewer origin in OLLAMA_ORIGINS, then try again.";
24854          }
24855          if (
24856            lower.includes("fetch") ||
24857            lower.includes("network") ||
24858            lower.includes("failed to fetch")
24859          ) {
24860            return "Couldn't reach the LLM. Check your connection and try again.";
24861          }
24862          if (isMindmapRateLimitError(err) || lower.includes("429")) {
24863            const retryMs = getMindmapRetryDelayMs(err);
24864            if (retryMs > 0) {
24865              return `Rate limited by the LLM. Try again in about ${formatMindmapRetryDelay(retryMs)}.`;
24866            }
24867            return "Rate limited by the LLM. Wait a moment and retry.";
24868          }
24869          if (
24870            lower.includes("401") ||
24871            lower.includes("unauthorized") ||
24872            lower.includes("api key")
24873          ) {
24874            return "The LLM rejected the request. Check your API key in the chat settings.";
24875          }
24876          if (lower.includes("timeout") || lower.includes("timed out")) {
24877            return "The LLM took too long to respond. Try again, or regenerate with a smaller document.";
24878          }
24879          if (msg) return msg;
24880          return "Could not extract concepts. Try regenerating.";
24881        }
24882
24883        function showMindmapError(msg, kind) {
24884          const { errorPane, errorMsg } = getMindmapEls();
24885          if (!errorPane || !errorMsg) return;
24886          errorMsg.textContent = msg;
24887          errorPane.dataset.kind = kind || "generic";
24888          errorPane.classList.remove("is-hidden");
24889        }
24890
24891        document.getElementById("mindmapBtn").addEventListener("click", () => {
24892          runMindmapPipeline({ force: false });
24893        });
24894
24895        document
24896          .getElementById("mindmapRegenerateBtn")
24897          .addEventListener("click", () => {
24898            runMindmapPipeline({ force: true });
24899          });
24900
24901        document
24902          .getElementById("mindmapRetryBtn")
24903          .addEventListener("click", () => {
24904            runMindmapPipeline({ force: true });
24905          });
24906
24907        els.fileInput.addEventListener("change", async function (event) {
24908          const file = event.target.files && event.target.files[0];
24909          try {
24910            await loadFromFile(file);
24911            if (file && state.libraryIsOpen) {
24912              hideLibraryDrawer({ restoreFocus: false });
24913            }
24914          } catch (error) {
24915            setStatus(
24916              "File load failed • " +
24917                (error && error.message ? error.message : String(error)),
24918              { announce: true },
24919            );
24920          } finally {
24921            els.fileInput.value = "";
24922          }
24923        });
24924
24925        els.libraryFolderInput.addEventListener(
24926          "change",
24927          async function (event) {
24928            const replaceFolderId = state.libraryPendingImportTargetFolderId;
24929            state.libraryPendingImportTargetFolderId = "";
24930            state.libraryPendingImportToken = 0;
24931            try {
24932              await importLibraryFolderSnapshot(event.target.files || [], {
24933                replaceFolderId: replaceFolderId,
24934              });
24935            } catch (error) {
24936              setStatus(
24937                "Folder import failed • " +
24938                  (error && error.message ? error.message : String(error)),
24939                { announce: true },
24940              );
24941              console.warn("[library] folder import failed", error);
24942            } finally {
24943              els.libraryFolderInput.value = "";
24944            }
24945          },
24946        );
24947
24948        window.addEventListener("dragenter", function (event) {
24949          event.preventDefault();
24950          state.dragDepth += 1;
24951          document.body.classList.add("dragging");
24952        });
24953
24954        window.addEventListener("dragover", function (event) {
24955          event.preventDefault();
24956        });
24957
24958        window.addEventListener("dragleave", function (event) {
24959          event.preventDefault();
24960          state.dragDepth = Math.max(0, state.dragDepth - 1);
24961          if (state.dragDepth === 0) {
24962            document.body.classList.remove("dragging");
24963          }
24964        });
24965
24966        window.addEventListener("drop", async function (event) {
24967          event.preventDefault();
24968          state.dragDepth = 0;
24969          document.body.classList.remove("dragging");
24970
24971          const files = Array.from(
24972            event.dataTransfer && event.dataTransfer.files
24973              ? event.dataTransfer.files
24974              : [],
24975          );
24976          const file = files.find(function (item) {
24977            return /\.(md|markdown|mdown|mkdn|txt)$/i.test(item.name);
24978          });
24979
24980          if (file) {
24981            try {
24982              await loadFromFile(file);
24983              return;
24984            } catch (error) {
24985              setStatus(
24986                "Drop failed • " +
24987                  (error && error.message ? error.message : String(error)),
24988                { announce: true },
24989              );
24990              return;
24991            }
24992          }
24993
24994          const text = event.dataTransfer
24995            ? event.dataTransfer.getData("text/plain")
24996            : "";
24997          if (text) {
24998            await flushActiveUntitledDraft().catch(function (error) {
24999              console.warn("[drafts] active draft flush failed", error);
25000            });
25001            stopAutoRefresh();
25002            els.autoRefreshToggle.checked = false;
25003            state.untitledDraftId = "";
25004            state.sourceMode = "editor";
25005            state.currentName = "";
25006            setPrimaryFileHandle(null);
25007            state.baseHref = window.location.href;
25008            state.dirty = false;
25009            state.hasSessionDraft = true;
25010            await clearActiveLibrarySelection({
25011              persist: true,
25012              renderDrawer: true,
25013            });
25014            updateUiState();
25015
25016            setEditorMarkdown(text);
25017            render("editor");
25018          }
25019        });
25020
25021        async function readSharedSnapshotFromLocation() {
25022          if (
25023            typeof libraryCore.parseShareSnapshotFragment !== "function" ||
25024            !window.location.hash
25025          ) {
25026            return null;
25027          }
25028
25029          const parsed = await libraryCore.parseShareSnapshotFragment(
25030            window.location.hash,
25031          );
25032          return parsed && parsed.payload ? parsed.payload : null;
25033        }
25034
25035        let pendingOpenerSnapshotPayload = null;
25036        let acceptsOpenerSnapshotPayloads = false;
25037
25038        function normalizeOpenerSnapshotPayload(data) {
25039          const message = data && typeof data === "object" ? data : null;
25040          if (
25041            !message ||
25042            message.type !== "markv:open-snapshot" ||
25043            message.source !== "pagecrumb"
25044          ) {
25045            return null;
25046          }
25047
25048          const payload =
25049            message.payload && typeof message.payload === "object"
25050              ? message.payload
25051              : {};
25052          const text = typeof payload.text === "string" ? payload.text : "";
25053          if (!text.trim()) return null;
25054
25055          return {
25056            text: text,
25057            name:
25058              typeof payload.name === "string"
25059                ? payload.name.slice(0, 240)
25060                : "",
25061            view: payload.view === "edit" ? "edit" : "preview",
25062          };
25063        }
25064
25065        async function applyOpenerSnapshotPayload(payload) {
25066          const applied = await applySharedSnapshotPayload(payload);
25067          if (applied) {
25068            setStatus(
25069              (state.currentName || "Pagecrumb snapshot") +
25070                " • loaded from Pagecrumb • use Share to create a link",
25071              { announce: true },
25072            );
25073          }
25074          return applied;
25075        }
25076
25077        function flushPendingOpenerSnapshotPayload() {
25078          if (!pendingOpenerSnapshotPayload) return Promise.resolve(false);
25079          const payload = pendingOpenerSnapshotPayload;
25080          pendingOpenerSnapshotPayload = null;
25081          return applyOpenerSnapshotPayload(payload);
25082        }
25083
25084        window.addEventListener("message", function (event) {
25085          if (!window.opener || event.source !== window.opener) return;
25086
25087          const payload = normalizeOpenerSnapshotPayload(event.data);
25088          if (!payload) return;
25089
25090          if (!acceptsOpenerSnapshotPayloads) {
25091            pendingOpenerSnapshotPayload = payload;
25092            return;
25093          }
25094
25095          applyOpenerSnapshotPayload(payload).catch(function (error) {
25096            console.warn("[share] opener snapshot failed", error);
25097            setStatus(
25098              "Pagecrumb import failed • " +
25099                (error && error.message ? error.message : String(error)),
25100              { announce: true },
25101            );
25102          });
25103        });
25104
25105        async function init() {
25106          const sessionMeta = readSessionMeta();
25107          const savedContent = readStorage(
25108            window.sessionStorage,
25109            SESSION_CONTENT_KEY,
25110            "",
25111          );
25112          const pendingSharedContent =
25113            window.MarkVPWA &&
25114            typeof window.MarkVPWA.consumePendingSharedContent === "function"
25115              ? window.MarkVPWA.consumePendingSharedContent()
25116              : null;
25117          const hasPendingPrimaryHandle =
25118            window.MarkVPWA &&
25119            typeof window.MarkVPWA.shouldRestorePrimaryFileHandle === "function"
25120              ? window.MarkVPWA.shouldRestorePrimaryFileHandle()
25121              : false;
25122          const sharedSnapshot = await readSharedSnapshotFromLocation();
25123
25124          state.hasSessionDraft = Boolean(sessionMeta.hasSessionDraft);
25125          const hasStoredUntitledDrafts =
25126            await restoreUntitledDraftsFromStorage(sessionMeta);
25127          applyReadingSettings(readReadingSettings());
25128          applyReaderPalette(
25129            coerceReaderPaletteForTheme(readReaderPalette(), getGlobalTheme()),
25130            { refresh: false },
25131          );
25132          renderLibraryDrawer();
25133          updateUiState();
25134
25135          if (pendingSharedContent) {
25136            await applySharedIncomingContent(pendingSharedContent);
25137            await restoreLibraryFromStorage({
25138              hasSessionDraft: true,
25139            });
25140            return;
25141          }
25142
25143          if (sharedSnapshot) {
25144            await applySharedSnapshotPayload(sharedSnapshot);
25145            await restoreLibraryFromStorage({
25146              hasSessionDraft: true,
25147            });
25148            return;
25149          }
25150
25151          if (savedContent || state.hasSessionDraft) {
25152            setEditorMarkdown(savedContent || "");
25153            render("force");
25154            await restoreLibraryFromStorage({
25155              hasSessionDraft: true,
25156            });
25157          } else {
25158            setEditorMarkdown("");
25159            render("force");
25160            await restoreLibraryFromStorage({
25161              hasSessionDraft: hasStoredUntitledDrafts,
25162            });
25163          }
25164
25165          if (
25166            state.sourceMode !== "library" &&
25167            hasStoredUntitledDrafts &&
25168            !hasPendingPrimaryHandle &&
25169            !savedContent &&
25170            !state.hasSessionDraft
25171          ) {
25172            await loadUntitledDraft(state.untitledDraftId, {
25173              quiet: true,
25174              closeDrawerOnMobile: false,
25175            });
25176          }
25177
25178          if (
25179            state.sourceMode !== "library" &&
25180            state.sourceMode !== "untitled" &&
25181            !state.hasSessionDraft &&
25182            !els.editor.value.trim() &&
25183            !hasPendingPrimaryHandle
25184          ) {
25185            setEditorMarkdown(await loadDefaultSample());
25186            render("force");
25187          }
25188        }
25189
25190        window.markvPWAHooks = {
25191          restorePrimaryFileHandle: function (handle) {
25192            if (
25193              !handle ||
25194              state.fileHandle ||
25195              state.libraryCurrentFileHandle ||
25196              state.sourceMode === "library"
25197            ) {
25198              return false;
25199            }
25200
25201            setPrimaryFileHandle(handle, { skipPersistence: true });
25202            if (!state.currentName) {
25203              state.currentName = handle.name || "";
25204            }
25205            updateUiState();
25206            return true;
25207          },
25208          flushSessionDraft: function () {
25209            __flushSessionContent();
25210          },
25211          flushPendingWrites: function () {
25212            return flushActiveUntitledDraft().then(function () {
25213              return flushWritableHandleIfPermitted();
25214            });
25215          },
25216        };
25217
25218        init()
25219          .catch(function (error) {
25220            console.warn("[markv] init failed", error);
25221            setStatus(
25222              "Startup failed • " +
25223                (error && error.message ? error.message : String(error)),
25224              { announce: true },
25225            );
25226          })
25227          .finally(function () {
25228            acceptsOpenerSnapshotPayloads = true;
25229            flushPendingOpenerSnapshotPayload().catch(function (error) {
25230              console.warn("[share] pending opener snapshot failed", error);
25231            });
25232            if (window.opener && typeof window.opener.postMessage === "function") {
25233              try {
25234                window.opener.postMessage(
25235                  { type: "markv:ready", source: "markv" },
25236                  "*",
25237                );
25238              } catch (_error) {}
25239            }
25240            document.dispatchEvent(new CustomEvent("markv:ready"));
25241          });
25242
25243        // ---------- Theme toggle ----------
25244        const themeBtn = document.getElementById("themeToggleBtn");
25245        const sunIcon = document.getElementById("themeIconSun");
25246        const moonIcon = document.getElementById("themeIconMoon");
25247
25248        function applyTheme(theme, options) {
25249          options = options || {};
25250          document.documentElement.setAttribute("data-theme", theme);
25251          if (theme === "dark") {
25252            sunIcon.style.display = "none";
25253            moonIcon.style.display = "";
25254          } else {
25255            sunIcon.style.display = "";
25256            moonIcon.style.display = "none";
25257          }
25258          if (options.persist) {
25259            writeStorage(window.localStorage, THEME_KEY, theme);
25260          }
25261          if (options.skipReaderPalette) return;
25262          applyReaderPalette(
25263            coerceReaderPaletteForTheme(
25264              options.readerPalette ||
25265                (state.readerPaletteInitialized
25266                  ? state.readerPalette
25267                  : readReaderPalette()),
25268              theme,
25269            ),
25270            {
25271              refresh: false,
25272              persist: options.persistReaderPalette,
25273            },
25274          );
25275        }
25276
25277        const savedTheme = readStorage(window.localStorage, THEME_KEY, "");
25278        const systemDark =
25279          window.matchMedia &&
25280          window.matchMedia("(prefers-color-scheme: dark)").matches;
25281        applyTheme(savedTheme || (systemDark ? "dark" : "light"));
25282
25283        function runAfterNextPaint(callback) {
25284          if (typeof requestAnimationFrame !== "function") {
25285            window.setTimeout(callback, 0);
25286            return;
25287          }
25288          requestAnimationFrame(function () {
25289            requestAnimationFrame(callback);
25290          });
25291        }
25292
25293        function scheduleThemeDependentRefresh(token, previousScheme) {
25294          window.clearTimeout(state.themeMermaidRenderTimer);
25295          state.themeMermaidRenderTimer = window.setTimeout(function () {
25296            state.themeMermaidRenderTimer = null;
25297            if (token !== state.themeRefreshToken) return;
25298
25299            if (
25300              previousScheme &&
25301              previousScheme === getEffectiveReaderScheme()
25302            ) {
25303              swapHljsTheme();
25304              return;
25305            }
25306
25307            swapHljsTheme();
25308
25309            const blocks = collectRenderedMermaidBlocks();
25310            if (!blocks.length) return;
25311
25312            const generation = state.previewRenderGeneration + 1;
25313            state.previewRenderGeneration = generation;
25314            renderMermaidBlockCollection(blocks, generation, {
25315              keepExistingOnError: true,
25316            });
25317          }, 180);
25318        }
25319
25320        function queueThemeDependentRefreshAfter(
25321          transition,
25322          token,
25323          previousScheme,
25324        ) {
25325          const schedule = function () {
25326            if (token !== state.themeRefreshToken) return;
25327            runAfterNextPaint(function () {
25328              scheduleThemeDependentRefresh(token, previousScheme);
25329            });
25330          };
25331
25332          if (
25333            transition &&
25334            transition.finished &&
25335            typeof transition.finished.finally === "function"
25336          ) {
25337            transition.finished.finally(schedule);
25338            return;
25339          }
25340
25341          schedule();
25342        }
25343
25344        function scheduleReaderAssetRefresh(previousScheme) {
25345          const refreshToken = state.themeRefreshToken + 1;
25346          state.themeRefreshToken = refreshToken;
25347          scheduleThemeDependentRefresh(refreshToken, previousScheme);
25348        }
25349
25350        function animateThemeReveal(transition, originX, originY, endRadius) {
25351          if (
25352            !transition ||
25353            !transition.ready ||
25354            typeof transition.ready.then !== "function"
25355          ) {
25356            return;
25357          }
25358
25359          transition.ready
25360            .then(function () {
25361              document.documentElement.animate(
25362                {
25363                  clipPath: [
25364                    "circle(0px at " + originX + "px " + originY + "px)",
25365                    "circle(" +
25366                      endRadius +
25367                      "px at " +
25368                      originX +
25369                      "px " +
25370                      originY +
25371                      "px)",
25372                  ],
25373                },
25374                {
25375                  duration: 520,
25376                  easing: "cubic-bezier(0.22, 1, 0.36, 1)",
25377                  pseudoElement: "::view-transition-new(root)",
25378                },
25379              );
25380            })
25381            .catch(function () {
25382              // If the custom pseudo-element animation fails, keep the
25383              // default view transition instead of breaking theme toggle.
25384            });
25385        }
25386
25387        themeBtn.addEventListener("click", function (event) {
25388          const current =
25389            document.documentElement.getAttribute("data-theme") || "light";
25390          const next = current === "dark" ? "light" : "dark";
25391          const previousReaderScheme = getEffectiveReaderScheme();
25392          const nextReaderPalette = coerceReaderPaletteForTheme(
25393            state.readerPaletteInitialized
25394              ? state.readerPalette
25395              : readReaderPalette(),
25396            next,
25397          );
25398
25399          // Capture the button origin so the radial reveal can
25400          // emanate from the icon itself rather than the screen
25401          // center — feels causal, like the click spread the theme.
25402          const rect = themeBtn.getBoundingClientRect();
25403          const originX = rect.left + rect.width / 2;
25404          const originY = rect.top + rect.height / 2;
25405          document.documentElement.style.setProperty(
25406            "--vt-origin-x",
25407            originX + "px",
25408          );
25409          document.documentElement.style.setProperty(
25410            "--vt-origin-y",
25411            originY + "px",
25412          );
25413          const maxDx = Math.max(originX, window.innerWidth - originX);
25414          const maxDy = Math.max(originY, window.innerHeight - originY);
25415          const endRadius = Math.hypot(maxDx, maxDy);
25416          document.documentElement.style.setProperty(
25417            "--vt-end-radius",
25418            endRadius + "px",
25419          );
25420          const refreshToken = state.themeRefreshToken + 1;
25421          state.themeRefreshToken = refreshToken;
25422
25423          const commitTheme = function () {
25424            applyTheme(next, {
25425              persist: true,
25426              persistReaderPalette: true,
25427              readerPalette: nextReaderPalette,
25428            });
25429          };
25430
25431          // Progressive enhancement: browsers that implement the
25432          // View Transitions API (Chromium, Safari ≥18) get a
25433          // radial clip-path reveal from the button. Firefox and
25434          // older Safari fall through to the existing behavior —
25435          // no regression.
25436          const reduceMotion =
25437            window.matchMedia &&
25438            window.matchMedia("(prefers-reduced-motion: reduce)").matches;
25439          if (
25440            !reduceMotion &&
25441            typeof document.startViewTransition === "function"
25442          ) {
25443            const transition = document.startViewTransition(commitTheme);
25444            animateThemeReveal(transition, originX, originY, endRadius);
25445            queueThemeDependentRefreshAfter(
25446              transition,
25447              refreshToken,
25448              previousReaderScheme,
25449            );
25450          } else {
25451            commitTheme();
25452            queueThemeDependentRefreshAfter(
25453              null,
25454              refreshToken,
25455              previousReaderScheme,
25456            );
25457          }
25458        });
25459
25460        // ---------- Mode toggle (preview <-> edit) ----------
25461        const mainEl = document.getElementById("main");
25462        const modeBtn = document.getElementById("modeToggleBtn");
25463        const modeLabel = document.getElementById("modeToggleLabel");
25464        const modeIconEdit = document.getElementById("modeIconEdit");
25465        const modeIconPreview = document.getElementById("modeIconPreview");
25466
25467        function isEditModeActive() {
25468          return Boolean(
25469            document.getElementById("main") &&
25470            document.getElementById("main").classList.contains("edit-mode"),
25471          );
25472        }
25473
25474        function getMaxWindowScrollY() {
25475          return Math.max(
25476            0,
25477            document.documentElement.scrollHeight - window.innerHeight,
25478          );
25479        }
25480
25481        function captureWindowScrollRatio() {
25482          var maxY = getMaxWindowScrollY();
25483          return maxY > 0 ? window.scrollY / maxY : 0;
25484        }
25485
25486        function applyWindowScrollRatio(ratio) {
25487          window.scrollTo(
25488            0,
25489            Math.round(clampNumber(ratio, 0, 1) * getMaxWindowScrollY()),
25490          );
25491        }
25492
25493        function applyWindowScrollY(offset) {
25494          window.scrollTo(
25495            0,
25496            clampNumber(Math.round(offset), 0, getMaxWindowScrollY()),
25497          );
25498        }
25499
25500        function interpolateSourceOffset(start, end, fraction) {
25501          if (typeof start !== "number") return 0;
25502          if (typeof end !== "number" || end <= start) return start;
25503          return Math.round(
25504            start + clampNumber(fraction, 0, 1) * (end - start),
25505          );
25506        }
25507
25508        function getPreviewSourceAnchors() {
25509          return Array.from(
25510            els.preview.querySelectorAll("[data-source-start]"),
25511          );
25512        }
25513
25514        function getSourceRangeFromElement(element) {
25515          if (!element) return null;
25516          var start = Number(element.getAttribute("data-source-start"));
25517          var end = Number(element.getAttribute("data-source-end"));
25518
25519          if (!Number.isFinite(start)) return null;
25520          if (!Number.isFinite(end)) end = start;
25521
25522          return { start: start, end: end };
25523        }
25524
25525        function findBestPreviewAnchorForViewportY(viewportY) {
25526          var best = null;
25527
25528          getPreviewSourceAnchors().forEach(function (element) {
25529            var range = getSourceRangeFromElement(element);
25530            if (!range) return;
25531
25532            var rect = element.getBoundingClientRect();
25533            if (rect.height <= 0 && rect.width <= 0) return;
25534
25535            var distance = 0;
25536            if (viewportY < rect.top) {
25537              distance = rect.top - viewportY;
25538            } else if (viewportY > rect.bottom) {
25539              distance = viewportY - rect.bottom;
25540            }
25541
25542            var height = Math.max(1, rect.height);
25543
25544            if (
25545              !best ||
25546              distance < best.distance ||
25547              (distance === best.distance && height < best.height)
25548            ) {
25549              best = {
25550                element: element,
25551                range: range,
25552                rect: rect,
25553                distance: distance,
25554                height: height,
25555              };
25556            }
25557          });
25558
25559          return best;
25560        }
25561
25562        function getPreviewViewportSourceOffset() {
25563          var centerY = window.innerHeight / 2;
25564          var match = findBestPreviewAnchorForViewportY(centerY);
25565          if (!match) return null;
25566
25567          var fraction =
25568            match.rect.height > 1
25569              ? (centerY - match.rect.top) / match.rect.height
25570              : 0;
25571
25572          return interpolateSourceOffset(
25573            match.range.start,
25574            match.range.end,
25575            fraction,
25576          );
25577        }
25578
25579        function findBestPreviewAnchorForSourceOffset(offset) {
25580          var bestContaining = null;
25581          var bestNearest = null;
25582
25583          getPreviewSourceAnchors().forEach(function (element) {
25584            var range = getSourceRangeFromElement(element);
25585            if (!range) return;
25586
25587            var span = Math.max(1, range.end - range.start);
25588            if (offset >= range.start && offset <= range.end) {
25589              if (!bestContaining || span < bestContaining.span) {
25590                bestContaining = {
25591                  element: element,
25592                  range: range,
25593                  span: span,
25594                };
25595              }
25596              return;
25597            }
25598
25599            var distance = Math.min(
25600              Math.abs(offset - range.start),
25601              Math.abs(offset - range.end),
25602            );
25603            if (
25604              !bestNearest ||
25605              distance < bestNearest.distance ||
25606              (distance === bestNearest.distance && span < bestNearest.span)
25607            ) {
25608              bestNearest = {
25609                element: element,
25610                range: range,
25611                distance: distance,
25612                span: span,
25613              };
25614            }
25615          });
25616
25617          return bestContaining || bestNearest;
25618        }
25619
25620        function scrollPreviewToSourceOffset(offset) {
25621          if (typeof offset !== "number") return;
25622          var match = findBestPreviewAnchorForSourceOffset(offset);
25623          if (!match) return;
25624
25625          var rect = match.element.getBoundingClientRect();
25626          var pageTop = window.scrollY + rect.top;
25627          var fraction =
25628            match.range.end > match.range.start
25629              ? (offset - match.range.start) /
25630                Math.max(1, match.range.end - match.range.start)
25631              : 0;
25632
25633          applyWindowScrollY(
25634            pageTop +
25635              rect.height * clampNumber(fraction, 0, 1) -
25636              window.innerHeight / 2,
25637          );
25638        }
25639
25640        function createTextareaMeasurementMirror(textarea) {
25641          var mirror = document.createElement("div");
25642          var computed = window.getComputedStyle(textarea);
25643          var properties = [
25644            "boxSizing",
25645            "borderTopWidth",
25646            "borderRightWidth",
25647            "borderBottomWidth",
25648            "borderLeftWidth",
25649            "paddingTop",
25650            "paddingRight",
25651            "paddingBottom",
25652            "paddingLeft",
25653            "fontStyle",
25654            "fontVariant",
25655            "fontWeight",
25656            "fontStretch",
25657            "fontSize",
25658            "fontFamily",
25659            "fontFeatureSettings",
25660            "fontVariationSettings",
25661            "lineHeight",
25662            "letterSpacing",
25663            "tabSize",
25664            "textIndent",
25665            "textTransform",
25666            "textRendering",
25667          ];
25668
25669          mirror.setAttribute("aria-hidden", "true");
25670          mirror.style.position = "absolute";
25671          mirror.style.visibility = "hidden";
25672          mirror.style.pointerEvents = "none";
25673          mirror.style.left = "-99999px";
25674          mirror.style.top = "0";
25675          mirror.style.whiteSpace = "pre-wrap";
25676          mirror.style.overflowWrap = "break-word";
25677          mirror.style.wordWrap = "break-word";
25678          mirror.style.width = textarea.clientWidth + "px";
25679
25680          properties.forEach(function (property) {
25681            mirror.style[property] = computed[property];
25682          });
25683
25684          return mirror;
25685        }
25686
25687        function measureTextareaCaretTop(textarea, position) {
25688          var clampedPosition = clampNumber(position, 0, textarea.value.length);
25689          var mirror = createTextareaMeasurementMirror(textarea);
25690          var before = textarea.value.slice(0, clampedPosition);
25691          var after = textarea.value.slice(clampedPosition);
25692          var marker = document.createElement("span");
25693
25694          marker.textContent = after || "\u200b";
25695          mirror.textContent = before;
25696          mirror.appendChild(marker);
25697          document.body.appendChild(mirror);
25698
25699          var top = marker.offsetTop;
25700          mirror.remove();
25701          return top;
25702        }
25703
25704        function getEditorViewportSourceOffset() {
25705          var editor = els.editor;
25706          var valueLength = editor.value.length;
25707          if (!valueLength || editor.clientHeight <= 0) {
25708            return state.editorSelection || 0;
25709          }
25710
25711          var targetY = editor.scrollTop + editor.clientHeight / 2;
25712          var cache = Object.create(null);
25713
25714          function topFor(position) {
25715            var clampedPosition = clampNumber(position, 0, valueLength);
25716            if (cache[clampedPosition] == null) {
25717              cache[clampedPosition] = measureTextareaCaretTop(
25718                editor,
25719                clampedPosition,
25720              );
25721            }
25722            return cache[clampedPosition];
25723          }
25724
25725          var low = 0;
25726          var high = valueLength;
25727          while (low < high) {
25728            var mid = Math.floor((low + high) / 2);
25729            if (topFor(mid) < targetY) {
25730              low = mid + 1;
25731            } else {
25732              high = mid;
25733            }
25734          }
25735
25736          var candidates = [low, Math.max(0, low - 1)];
25737          var bestOffset = candidates[0];
25738          var bestDistance = Infinity;
25739
25740          candidates.forEach(function (candidate) {
25741            var distance = Math.abs(topFor(candidate) - targetY);
25742            if (distance < bestDistance) {
25743              bestDistance = distance;
25744              bestOffset = candidate;
25745            }
25746          });
25747
25748          return bestOffset;
25749        }
25750
25751        function scrollEditorToSourceOffset(offset) {
25752          var editor = els.editor;
25753          var clampedOffset = clampNumber(offset, 0, editor.value.length);
25754          var caretTop;
25755          var lineHeight =
25756            parseFloat(window.getComputedStyle(editor).lineHeight) || 20;
25757
25758          state.editorSelection = clampedOffset;
25759
25760          try {
25761            editor.setSelectionRange(clampedOffset, clampedOffset);
25762          } catch (_error) {}
25763
25764          caretTop = measureTextareaCaretTop(editor, clampedOffset);
25765          editor.scrollTop = clampNumber(
25766            Math.round(caretTop - editor.clientHeight / 2 + lineHeight / 2),
25767            0,
25768            Math.max(0, editor.scrollHeight - editor.clientHeight),
25769          );
25770        }
25771
25772        function setMode(mode) {
25773          const wasEdit = mainEl.classList.contains("edit-mode");
25774
25775          if (mode === "edit") {
25776            const sourceOffset =
25777              getPreviewViewportSourceOffset() ?? state.editorSelection ?? 0;
25778            dismissSelectionUi();
25779            clearBrowserSelection();
25780            mainEl.classList.add("edit-mode");
25781            document.body.classList.add("edit-mode-active");
25782            document.body.classList.remove("preview-mode");
25783            modeLabel.textContent = "Preview";
25784            modeBtn.setAttribute("aria-pressed", "true");
25785            modeBtn.title = withShortcutLabel("Show preview", "global.mode");
25786            if (modeIconEdit) modeIconEdit.style.display = "none";
25787            if (modeIconPreview) modeIconPreview.style.display = "";
25788            requestAnimationFrame(function () {
25789              try {
25790                els.editor.focus({ preventScroll: true });
25791              } catch (_error) {
25792                els.editor.focus();
25793              }
25794              scrollEditorToSourceOffset(sourceOffset);
25795            });
25796          } else {
25797            const sourceOffset = wasEdit
25798              ? getEditorViewportSourceOffset()
25799              : (getPreviewViewportSourceOffset() ??
25800                state.editorSelection ??
25801                0);
25802            state.editorSelection = sourceOffset;
25803            mainEl.classList.remove("edit-mode");
25804            document.body.classList.add("preview-mode");
25805            document.body.classList.remove("edit-mode-active");
25806            modeLabel.textContent = "Edit";
25807            modeBtn.setAttribute("aria-pressed", "false");
25808            modeBtn.title = withShortcutLabel("Edit markdown", "global.mode");
25809            if (modeIconEdit) modeIconEdit.style.display = "";
25810            if (modeIconPreview) modeIconPreview.style.display = "none";
25811            requestAnimationFrame(function () {
25812              scrollPreviewToSourceOffset(sourceOffset);
25813            });
25814          }
25815
25816          updateUiState();
25817        }
25818
25819        modeBtn.addEventListener("click", function () {
25820          const isEdit = mainEl.classList.contains("edit-mode");
25821          setMode(isEdit ? "preview" : "edit");
25822        });
25823
25824        window.addEventListener("keydown", function (e) {
25825          const hasSelectionUi =
25826            !els.selectionBubble.hidden ||
25827            !els.ctxMenu.hidden ||
25828            !els.highlightRemoveChip.hidden;
25829
25830          if (e.key === "Escape" && state.mermaidViewerOpen) {
25831            e.preventDefault();
25832            closeMermaidViewer();
25833            return;
25834          }
25835
25836          if (
25837            state.mermaidViewerOpen &&
25838            !isEditableElement(document.activeElement) &&
25839            !e.metaKey &&
25840            !e.ctrlKey &&
25841            !e.altKey
25842          ) {
25843            if (e.key === "+" || e.key === "=") {
25844              e.preventDefault();
25845              applyMermaidViewerScale(state.mermaidViewerScale * 1.15);
25846              return;
25847            }
25848            if (e.key === "-" || e.key === "_") {
25849              e.preventDefault();
25850              applyMermaidViewerScale(state.mermaidViewerScale / 1.15);
25851              return;
25852            }
25853            if (e.key === "0") {
25854              e.preventDefault();
25855              applyMermaidViewerScale(1, {
25856                contentX: state.mermaidViewerBaseWidth / 2,
25857                contentY: state.mermaidViewerBaseHeight / 2,
25858              });
25859              return;
25860            }
25861            if (e.key.toLowerCase() === "f") {
25862              e.preventDefault();
25863              fitMermaidViewer();
25864              return;
25865            }
25866          }
25867
25868          if (e.key === "Escape" && hasSelectionUi) {
25869            e.preventDefault();
25870            e.stopImmediatePropagation();
25871            dismissSelectionUi();
25872            return;
25873          }
25874
25875          if (e.key === "Escape" && !els.readingPanel.hidden) {
25876            e.preventDefault();
25877            hideReadingPanel();
25878            return;
25879          }
25880
25881          if (
25882            (e.metaKey || e.ctrlKey) &&
25883            e.shiftKey &&
25884            e.key.toLowerCase() === "h" &&
25885            !isEditableElement(document.activeElement)
25886          ) {
25887            const controller =
25888              state.selectionController || captureSelectionController();
25889            if (controller) {
25890              e.preventDefault();
25891              addHighlightFromSelection();
25892            }
25893            return;
25894          }
25895
25896          if (shortcutMatches(e, "global.mode")) {
25897            e.preventDefault();
25898            modeBtn.click();
25899            return;
25900          }
25901
25902          if (shortcutMatches(e, "global.save")) {
25903            e.preventDefault();
25904            saveCurrentDocument();
25905          }
25906        });
25907
25908        if (window.MarkVExportHostFactory) {
25909          window.MarkVExportHost = window.MarkVExportHostFactory({
25910            state,
25911            els,
25912            isEditModeActive,
25913            setMode,
25914            renderCurrentPreview: function () {
25915              render("force");
25916            },
25917            renderMermaidForExport,
25918            setStatus,
25919          });
25920          document.dispatchEvent(new CustomEvent("markv:export-host-ready"));
25921        }
25922
25923        if (window.MarkVCommandPaletteHostFactory) {
25924          window.MarkVCommandPaletteHost =
25925            window.MarkVCommandPaletteHostFactory({
25926              state,
25927              els,
25928              exportHost: window.MarkVExportHost,
25929              libraryCore,
25930              sortUntitledDrafts,
25931              getReaderSaveState,
25932              getReaderRefreshState,
25933              isEditModeActive,
25934              setMode,
25935              showLibraryDrawer,
25936              openFile,
25937              addLibraryFolder,
25938              saveCurrentDocument,
25939              reloadCurrent,
25940              startAutoRefresh,
25941              showReadingPanel,
25942              createUntitledDocument,
25943              loadFromLibrarySelection,
25944              loadUntitledDraft,
25945              shareCurrentSnapshot,
25946              runMindmapPipeline,
25947              clearAll,
25948              setStatus,
25949            });
25950          document.dispatchEvent(
25951            new CustomEvent("markv:command-palette-host-ready"),
25952          );
25953        }
25954
25955        document.addEventListener("markv:announce", function (e) {
25956          if (e.detail && e.detail.message) announce(e.detail.message);
25957        });
25958
25959        window.addEventListener("storage", function (event) {
25960          if (event.key !== READER_PALETTE_STORAGE_KEY) return;
25961          applyReaderPalette(event.newValue, {
25962            persist: false,
25963            announce: false,
25964          });
25965        });
25966
25967        document.addEventListener(
25968          "markv:request-highlight-refresh",
25969          function () {
25970            applyHighlightsToPreview();
25971          },
25972        );
25973
25974        // Start in preview mode.
25975        setMode("preview");
25976
25977        // ── Scroll-reveal typography ────────────────────────────
25978        // Gentle fade+rise on h1–h4 when they enter the viewport.
25979        // Honors prefers-reduced-motion via the global reset above.
25980        // Safe-by-default: the CSS rules are gated on
25981        // `html.js-scroll-reveal` which only applies when THIS
25982        // script wires things up — if JS or IO fails, headings
25983        // stay fully visible.
25984        (function setupScrollReveal() {
25985          if (!("IntersectionObserver" in window)) return;
25986          if (!("MutationObserver" in window)) return;
25987          const previewRoot = document.querySelector(".preview-pane");
25988          if (!previewRoot) return;
25989
25990          const io = new IntersectionObserver(
25991            function (entries) {
25992              entries.forEach(function (e) {
25993                if (e.isIntersecting) {
25994                  e.target.setAttribute("data-revealed", "");
25995                  io.unobserve(e.target);
25996                }
25997              });
25998            },
25999            { rootMargin: "0px 0px -8% 0px", threshold: 0.01 },
26000          );
26001
26002          function observeNew() {
26003            const headings = previewRoot.querySelectorAll(
26004              ".markdown-body h1:not([data-observing])," +
26005                " .markdown-body h2:not([data-observing])," +
26006                " .markdown-body h3:not([data-observing])," +
26007                " .markdown-body h4:not([data-observing])",
26008            );
26009            headings.forEach(function (h) {
26010              h.setAttribute("data-observing", "");
26011              io.observe(h);
26012            });
26013          }
26014
26015          observeNew();
26016          const mo = new MutationObserver(observeNew);
26017          mo.observe(previewRoot, { childList: true, subtree: true });
26018
26019          document.documentElement.classList.add("js-scroll-reveal");
26020        })();
26021
26022        // ── Comprehension micro-features ─────────────────────────
26023        // Groups #1 progress gutter, #2 heading anchors,
26024        // #3 code-block chrome, #4 link previews, #5 breadcrumb.
26025        // Global prefers-reduced-motion reset collapses transitions
26026        // in CSS; JS logic still runs but transitions become
26027        // instantaneous, which is correct: users still get the
26028        // feature (copy, preview, progress) without motion.
26029        (function setupComprehensionFeatures() {
26030          const previewRoot = document.querySelector(".preview-pane");
26031          if (!previewRoot) return;
26032
26033          // Small helper for building SVG icons without touching
26034          // innerHTML (stays XSS-safe and satisfies security scan).
26035          function makeSvg(pathSpec) {
26036            const svgNS = "http://www.w3.org/2000/svg";
26037            const svg = document.createElementNS(svgNS, "svg");
26038            svg.setAttribute("viewBox", "0 0 16 16");
26039            svg.setAttribute("fill", "none");
26040            svg.setAttribute("stroke", "currentColor");
26041            svg.setAttribute("stroke-linecap", "round");
26042            svg.setAttribute("stroke-linejoin", "round");
26043            svg.setAttribute("aria-hidden", "true");
26044            pathSpec.forEach(function (p) {
26045              const el = document.createElementNS(svgNS, p.tag);
26046              Object.keys(p.attrs).forEach(function (k) {
26047                el.setAttribute(k, p.attrs[k]);
26048              });
26049              svg.appendChild(el);
26050            });
26051            return svg;
26052          }
26053          function copyIconSvg() {
26054            const svg = makeSvg([
26055              {
26056                tag: "rect",
26057                attrs: { x: "4", y: "4", width: "9", height: "9", rx: "1.5" },
26058              },
26059              {
26060                tag: "path",
26061                attrs: {
26062                  d: "M10 4V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1",
26063                },
26064              },
26065            ]);
26066            svg.setAttribute("stroke-width", "1.5");
26067            return svg;
26068          }
26069          function checkIconSvg() {
26070            const svg = makeSvg([
26071              { tag: "path", attrs: { d: "M3 8.5 L6.5 12 L13 4.5" } },
26072            ]);
26073            svg.setAttribute("stroke-width", "2");
26074            return svg;
26075          }
26076
26077          // ---------- #1 Reading progress gutter ----------
26078          const gutter = document.createElement("div");
26079          gutter.id = "readingProgressGutter";
26080          gutter.setAttribute("aria-hidden", "true");
26081          const gutterFill = document.createElement("div");
26082          gutterFill.className = "fill";
26083          gutter.appendChild(gutterFill);
26084          document.body.appendChild(gutter);
26085
26086          // ---------- #5 Breadcrumb trail ----------
26087          // Publish the toolbar's rendered height as --toolbar-h so
26088          // the breadcrumb can sit just below it. Narrow viewports
26089          // wrap the toolbar (44×44px tap targets, status row on a
26090          // new line); a fixed `top` would then hide behind it.
26091          const toolbarEl = document.querySelector(".toolbar");
26092          if (toolbarEl) {
26093            const syncToolbarHeight = function () {
26094              document.documentElement.style.setProperty(
26095                "--toolbar-h",
26096                toolbarEl.offsetHeight + "px",
26097              );
26098            };
26099            syncToolbarHeight();
26100            // Listen on both: ResizeObserver catches content-driven size
26101            // changes (status row toggling, font load) without a viewport
26102            // change; resize covers breakpoint transitions and
26103            // environments where RO delivery is flaky.
26104            if (typeof ResizeObserver !== "undefined") {
26105              new ResizeObserver(syncToolbarHeight).observe(toolbarEl);
26106            }
26107            window.addEventListener("resize", syncToolbarHeight, {
26108              passive: true,
26109            });
26110          }
26111
26112          const breadcrumb = document.createElement("div");
26113          breadcrumb.id = "readingBreadcrumb";
26114          breadcrumb.setAttribute("aria-hidden", "true");
26115          document.body.appendChild(breadcrumb);
26116          let scrollTopBtn = null;
26117          let scrollTopButtonReady = false;
26118
26119          function getScrollTopButton() {
26120            if (!scrollTopBtn) {
26121              scrollTopBtn = document.getElementById("scrollTopBtn");
26122            }
26123            return scrollTopBtn;
26124          }
26125
26126          function scrollToDocumentTop() {
26127            const btn = getScrollTopButton();
26128            window.scrollTo({
26129              top: 0,
26130              behavior: prefersReducedMotion() ? "auto" : "smooth",
26131            });
26132            if (btn && typeof btn.blur === "function") {
26133              btn.blur();
26134            }
26135          }
26136
26137          function syncScrollTopButton(maxY) {
26138            const btn = getScrollTopButton();
26139            if (!btn) return;
26140            const blocked =
26141              document.body.classList.contains("library-open") ||
26142              document.body.classList.contains("mindmap-open") ||
26143              document.body.classList.contains("mv-more-open") ||
26144              (document.body.classList.contains("chat-open") &&
26145                window.matchMedia("(max-width: 600px)").matches);
26146            const scrollable =
26147              (typeof maxY === "number"
26148                ? maxY
26149                : document.documentElement.scrollHeight - window.innerHeight) >
26150              window.innerHeight * 0.75;
26151            const threshold = Math.min(
26152              720,
26153              Math.max(360, window.innerHeight * 0.55),
26154            );
26155            const visible =
26156              !blocked && scrollable && window.scrollY > threshold;
26157            btn.classList.toggle("is-visible", visible);
26158            btn.tabIndex = visible ? 0 : -1;
26159            btn.setAttribute("aria-hidden", String(!visible));
26160          }
26161
26162          function setupScrollTopButton() {
26163            const btn = getScrollTopButton();
26164            if (!btn || scrollTopButtonReady) return;
26165            scrollTopButtonReady = true;
26166            btn.addEventListener("click", scrollToDocumentTop);
26167            new MutationObserver(function () {
26168              syncScrollTopButton();
26169            }).observe(document.body, {
26170              attributes: true,
26171              attributeFilter: ["class"],
26172            });
26173            syncScrollTopButton();
26174          }
26175
26176          setupScrollTopButton();
26177          if (!scrollTopButtonReady && document.readyState === "loading") {
26178            document.addEventListener(
26179              "DOMContentLoaded",
26180              setupScrollTopButton,
26181              {
26182                once: true,
26183              },
26184            );
26185          } else if (!scrollTopButtonReady) {
26186            window.requestAnimationFrame(setupScrollTopButton);
26187          }
26188
26189          function renderBreadcrumb(headings, activeHeading) {
26190            if (!activeHeading) {
26191              breadcrumb.classList.remove("is-visible");
26192              return;
26193            }
26194            // Walk up the heading levels: for the active H3, find the
26195            // nearest preceding H2 and H1. Gives a contextual trail.
26196            const levels = { 1: null, 2: null, 3: null };
26197            const activeLevel = Number(activeHeading.tagName[1]);
26198            for (const h of headings) {
26199              if (h === activeHeading) break;
26200              const l = Number(h.tagName[1]);
26201              if (l >= 1 && l <= 3) {
26202                levels[l] = h;
26203                for (let k = l + 1; k <= 3; k++) levels[k] = null;
26204              }
26205            }
26206            levels[activeLevel] = activeHeading;
26207            const chain = [];
26208            for (let l = 1; l <= activeLevel; l++) {
26209              if (levels[l]) chain.push(levels[l]);
26210            }
26211            // Rebuild via DOM methods (no innerHTML).
26212            while (breadcrumb.firstChild)
26213              breadcrumb.removeChild(breadcrumb.firstChild);
26214            chain.forEach(function (h, i) {
26215              if (i > 0) {
26216                const sep = document.createElement("span");
26217                sep.className = "sep";
26218                sep.textContent = "›";
26219                breadcrumb.appendChild(sep);
26220              }
26221              const c = document.createElement("span");
26222              c.className = "crumb";
26223              const text = h.textContent.replace(/^#\s*/, "").trim();
26224              c.textContent = text.length > 50 ? text.slice(0, 48) + "…" : text;
26225              breadcrumb.appendChild(c);
26226            });
26227            breadcrumb.classList.add("is-visible");
26228          }
26229
26230          // Update progress + breadcrumb on scroll. rAF-coalesced so
26231          // rapid wheel/flick events never stall the main thread.
26232          let scrollScheduled = false;
26233          function updateOnScroll() {
26234            if (scrollScheduled) return;
26235            scrollScheduled = true;
26236            window.requestAnimationFrame(function () {
26237              scrollScheduled = false;
26238
26239              // Progress
26240              const maxY = Math.max(
26241                0,
26242                document.documentElement.scrollHeight - window.innerHeight,
26243              );
26244              const progress = maxY > 0 ? window.scrollY / maxY : 0;
26245              gutter.style.setProperty("--progress", progress.toFixed(4));
26246              gutter.classList.toggle(
26247                "is-active",
26248                window.scrollY > 40 && maxY > window.innerHeight * 0.4,
26249              );
26250              syncScrollTopButton(maxY);
26251
26252              // Breadcrumb — use the same reading anchor line as the
26253              // right rail so both current-section cues stay in sync.
26254              const headings = Array.from(
26255                previewRoot.querySelectorAll(".markdown-body :is(h1, h2, h3)"),
26256              );
26257              if (!headings.length) {
26258                breadcrumb.classList.remove("is-visible");
26259                return;
26260              }
26261              const active = getReaderActiveHeading(headings);
26262              if (
26263                active &&
26264                (active !== headings[0] ||
26265                  headings[0].getBoundingClientRect().top < -20)
26266              ) {
26267                renderBreadcrumb(headings, active);
26268              } else {
26269                breadcrumb.classList.remove("is-visible");
26270              }
26271            });
26272          }
26273          window.addEventListener("scroll", updateOnScroll, { passive: true });
26274          window.addEventListener("resize", updateOnScroll, { passive: true });
26275          updateOnScroll();
26276
26277          // ---------- #2 Heading anchors ----------
26278          function addHeadingAnchors() {
26279            const headings = previewRoot.querySelectorAll(
26280              ".markdown-body :is(h1, h2, h3, h4, h5, h6)[id]:not([data-anchor-ready])",
26281            );
26282            headings.forEach(function (h) {
26283              h.setAttribute("data-anchor-ready", "");
26284              const a = document.createElement("a");
26285              a.className = "heading-anchor";
26286              a.href = "#" + h.id;
26287              a.setAttribute("aria-label", "Link to " + h.textContent.trim());
26288              a.textContent = "#";
26289              h.insertBefore(a, h.firstChild);
26290            });
26291          }
26292          addHeadingAnchors();
26293
26294          // ---------- #3 Code block chrome ----------
26295          function addCodeChrome() {
26296            const pres = previewRoot.querySelectorAll(
26297              ".markdown-body pre:not([data-chrome-ready])",
26298            );
26299            pres.forEach(function (pre) {
26300              pre.setAttribute("data-chrome-ready", "");
26301              const code = pre.querySelector("code");
26302              if (!code) return;
26303              const lang = (code.className.match(/language-(\w+)/) || [])[1];
26304              const chrome = document.createElement("div");
26305              chrome.className = "code-chrome";
26306              if (lang) {
26307                const badge = document.createElement("span");
26308                badge.className = "code-lang-badge";
26309                badge.textContent = lang;
26310                chrome.appendChild(badge);
26311              }
26312              const btn = document.createElement("button");
26313              btn.type = "button";
26314              btn.className = "code-copy-btn";
26315              btn.setAttribute("aria-label", "Copy code");
26316              btn.title = "Copy code";
26317              btn.appendChild(copyIconSvg());
26318              btn.addEventListener("click", function () {
26319                const text = code.textContent || "";
26320                const done = function () {
26321                  btn.classList.add("is-copied");
26322                  while (btn.firstChild) btn.removeChild(btn.firstChild);
26323                  btn.appendChild(checkIconSvg());
26324                  btn.setAttribute("aria-label", "Copied");
26325                  btn.title = "Copied";
26326                  window.clearTimeout(btn.__copyTimer);
26327                  btn.__copyTimer = window.setTimeout(function () {
26328                    btn.classList.remove("is-copied");
26329                    while (btn.firstChild) btn.removeChild(btn.firstChild);
26330                    btn.appendChild(copyIconSvg());
26331                    btn.setAttribute("aria-label", "Copy code");
26332                    btn.title = "Copy code";
26333                  }, 1400);
26334                };
26335                const fail = function () {
26336                  btn.setAttribute("aria-label", "Copy failed");
26337                  btn.title = "Copy failed";
26338                };
26339                if (navigator.clipboard && navigator.clipboard.writeText) {
26340                  navigator.clipboard.writeText(text).then(done).catch(fail);
26341                } else {
26342                  try {
26343                    const ta = document.createElement("textarea");
26344                    ta.value = text;
26345                    ta.setAttribute("readonly", "");
26346                    ta.style.position = "fixed";
26347                    ta.style.opacity = "0";
26348                    document.body.appendChild(ta);
26349                    ta.select();
26350                    document.execCommand("copy");
26351                    document.body.removeChild(ta);
26352                    done();
26353                  } catch (e) {
26354                    fail();
26355                  }
26356                }
26357              });
26358              chrome.appendChild(btn);
26359              pre.appendChild(chrome);
26360            });
26361          }
26362          addCodeChrome();
26363
26364          // Re-run anchor + chrome wiring when preview content changes.
26365          const contentMo = new MutationObserver(function () {
26366            addHeadingAnchors();
26367            addCodeChrome();
26368          });
26369          contentMo.observe(previewRoot, { childList: true, subtree: true });
26370
26371          // ---------- #4 Link preview on long hover ----------
26372          const preview = document.createElement("div");
26373          preview.className = "link-preview";
26374          preview.setAttribute("role", "tooltip");
26375          preview.setAttribute("aria-hidden", "true");
26376          document.body.appendChild(preview);
26377
26378          function buildPreviewCard(kindText) {
26379            while (preview.firstChild) preview.removeChild(preview.firstChild);
26380            const kind = document.createElement("span");
26381            kind.className = "lp-kind";
26382            kind.textContent = kindText;
26383            preview.appendChild(kind);
26384            const title = document.createElement("div");
26385            title.className = "lp-title";
26386            preview.appendChild(title);
26387            return { kind: kind, title: title };
26388          }
26389
26390          let hoverTimer = 0;
26391          let activeLink = null;
26392
26393          function positionPreview(link) {
26394            const linkRect = link.getBoundingClientRect();
26395            const cardW = Math.min(340, window.innerWidth - 24);
26396            let left = Math.max(
26397              12,
26398              Math.min(linkRect.left, window.innerWidth - cardW - 12),
26399            );
26400            let top = linkRect.bottom + 8;
26401            const approxH = 120;
26402            if (top + approxH > window.innerHeight - 12) {
26403              top = linkRect.top - approxH - 8;
26404              if (top < 12) top = 12;
26405            }
26406            preview.style.left = left + "px";
26407            preview.style.top = top + "px";
26408            preview.style.maxWidth = cardW + "px";
26409          }
26410
26411          function hidePreview() {
26412            preview.classList.remove("is-visible");
26413            preview.setAttribute("aria-hidden", "true");
26414          }
26415
26416          function showInternalPreview(link, targetId) {
26417            const target = document.getElementById(targetId);
26418            if (!target) return showExternalPreview(link);
26419            const titleText = target.textContent.replace(/^#\s*/, "").trim();
26420            let excerptText = "";
26421            let next = target.nextElementSibling;
26422            while (next && /^H[1-6]$/.test(next.tagName)) {
26423              next = next.nextElementSibling;
26424            }
26425            if (next) {
26426              excerptText = (next.textContent || "").trim().slice(0, 200);
26427            }
26428            const refs = buildPreviewCard("Section");
26429            refs.title.textContent = titleText;
26430            const excerpt = document.createElement("div");
26431            excerpt.className = "lp-excerpt";
26432            excerpt.textContent = excerptText;
26433            preview.appendChild(excerpt);
26434            positionPreview(link);
26435            preview.classList.add("is-visible");
26436            preview.setAttribute("aria-hidden", "false");
26437          }
26438
26439          function showExternalPreview(link) {
26440            let url;
26441            try {
26442              url = new URL(link.href, location.href);
26443            } catch (e) {
26444              return;
26445            }
26446            const domain = url.hostname;
26447            const path = (url.pathname + url.search).replace(/\/$/, "");
26448            const label = link.textContent.trim();
26449            const refs = buildPreviewCard("External link");
26450            refs.title.textContent = label || domain;
26451            const urlEl = document.createElement("div");
26452            urlEl.className = "lp-url";
26453            urlEl.textContent = domain + (path || "");
26454            preview.appendChild(urlEl);
26455            positionPreview(link);
26456            preview.classList.add("is-visible");
26457            preview.setAttribute("aria-hidden", "false");
26458          }
26459
26460          previewRoot.addEventListener("mouseover", function (e) {
26461            const link = e.target.closest("a[href]");
26462            if (!link || !previewRoot.contains(link)) return;
26463            if (link.classList.contains("heading-anchor")) return;
26464            activeLink = link;
26465            window.clearTimeout(hoverTimer);
26466            hoverTimer = window.setTimeout(function () {
26467              if (activeLink !== link) return;
26468              const href = link.getAttribute("href") || "";
26469              const hashMatch = href.match(/#([^\s?]+)$/);
26470              const sameDoc =
26471                href.startsWith("#") || link.pathname === location.pathname;
26472              if (hashMatch && sameDoc) {
26473                showInternalPreview(link, hashMatch[1]);
26474              } else if (link.href && !sameDoc) {
26475                showExternalPreview(link);
26476              }
26477            }, 500);
26478          });
26479          previewRoot.addEventListener("mouseout", function (e) {
26480            const link = e.target.closest("a[href]");
26481            if (!link) return;
26482            if (activeLink === link) activeLink = null;
26483            window.clearTimeout(hoverTimer);
26484            hidePreview();
26485          });
26486          window.addEventListener("scroll", hidePreview, { passive: true });
26487        })();
26488      })();
26489    </script>
26489
26490
26491    <!-- Document Ontology -->
26492    
26492<script>
26493      (function () {
26494        "use strict";
26495
26496        function prefersReducedMotion() {
26497          return !!(
26498            window.matchMedia &&
26499            window.matchMedia("(prefers-reduced-motion: reduce)").matches
26500          );
26501        }
26502
26503        // ── Constants ────────────────────────────────────────────────────────
26504        var EXCLUDED_SELECTOR =
26505          "pre, code, .raw-html, .blocked-resource, .mermaid-block";
26506        var LLM_STORAGE_KEY = "md-viewer-llm";
26507        var LEGACY_OLLAMA_STORAGE_KEY = "md-viewer-ollama";
26508        var CACHE_PREFIX = "ont-classify::";
26509        var CACHE_VERSION = 2;
26510
26511        var KNOWN_TYPES = [
26512          "introduction",
26513          "definition",
26514          "explanation",
26515          "instruction",
26516          "argument",
26517          "narrative",
26518          "example",
26519        ];
26520
26521        var TYPE_COLORS = {
26522          introduction: "var(--ont-intro, #6366f1)",
26523          definition: "var(--ont-definition, #0ea5e9)",
26524          explanation: "var(--ont-explanation, #10b981)",
26525          instruction: "var(--ont-instruction, #f59e0b)",
26526          argument: "var(--ont-argument, #ef4444)",
26527          narrative: "var(--ont-narrative, #8b5cf6)",
26528          example: "var(--ont-example, #14b8a6)",
26529          unknown: "var(--ont-unknown, #94a3b8)",
26530        };
26531
26532        var STOPWORDS = new Set([
26533          "a",
26534          "about",
26535          "above",
26536          "after",
26537          "again",
26538          "against",
26539          "all",
26540          "am",
26541          "an",
26542          "and",
26543          "any",
26544          "are",
26545          "aren",
26546          "as",
26547          "at",
26548          "be",
26549          "because",
26550          "been",
26551          "before",
26552          "being",
26553          "below",
26554          "between",
26555          "both",
26556          "but",
26557          "by",
26558          "can",
26559          "cannot",
26560          "could",
26561          "did",
26562          "do",
26563          "does",
26564          "doing",
26565          "down",
26566          "during",
26567          "each",
26568          "few",
26569          "for",
26570          "from",
26571          "further",
26572          "get",
26573          "got",
26574          "had",
26575          "has",
26576          "have",
26577          "having",
26578          "he",
26579          "her",
26580          "here",
26581          "hers",
26582          "herself",
26583          "him",
26584          "himself",
26585          "his",
26586          "how",
26587          "i",
26588          "if",
26589          "in",
26590          "into",
26591          "is",
26592          "it",
26593          "its",
26594          "itself",
26595          "just",
26596          "ll",
26597          "me",
26598          "might",
26599          "more",
26600          "most",
26601          "must",
26602          "my",
26603          "myself",
26604          "no",
26605          "nor",
26606          "not",
26607          "now",
26608          "of",
26609          "off",
26610          "on",
26611          "once",
26612          "only",
26613          "or",
26614          "other",
26615          "our",
26616          "ours",
26617          "ourselves",
26618          "out",
26619          "over",
26620          "own",
26621          "re",
26622          "s",
26623          "same",
26624          "she",
26625          "should",
26626          "so",
26627          "some",
26628          "such",
26629          "t",
26630          "than",
26631          "that",
26632          "the",
26633          "their",
26634          "theirs",
26635          "them",
26636          "themselves",
26637          "then",
26638          "there",
26639          "these",
26640          "they",
26641          "this",
26642          "those",
26643          "through",
26644          "to",
26645          "too",
26646          "under",
26647          "until",
26648          "up",
26649          "us",
26650          "ve",
26651          "very",
26652          "was",
26653          "we",
26654          "were",
26655          "what",
26656          "when",
26657          "where",
26658          "which",
26659          "while",
26660          "who",
26661          "whom",
26662          "why",
26663          "will",
26664          "with",
26665          "won",
26666          "would",
26667          "you",
26668          "your",
26669          "yours",
26670          "yourself",
26671          "yourselves",
26672          "also",
26673          "use",
26674          "used",
26675          "using",
26676          "via",
26677          "etc",
26678          "may",
26679          "can",
26680          "way",
26681          "ways",
26682          "new",
26683          "well",
26684          "one",
26685          "two",
26686          "three",
26687          "four",
26688          "five",
26689          "let",
26690          "see",
26691          "look",
26692          "make",
26693          "made",
26694          "need",
26695          "needs",
26696          "able",
26697          "want",
26698          "wants",
26699          "set",
26700          "sets",
26701          "include",
26702          "includes",
26703          "included",
26704        ]);
26705
26706        // ── State ─────────────────────────────────────────────────────────────
26707        var sections = [];
26708        var termData = [];
26709        var classifications = {};
26710        var documentShape = {};
26711        var classifyGeneration = 0;
26712        var classifyStale = false;
26713        var panelOpen = false;
26714        var currentDocumentId = null;
26715        var lastTextHash = null;
26716
26717        // ── Utility ───────────────────────────────────────────────────────────
26718        function releaseDocumentAnalysis(options) {
26719          options = options || {};
26720          sections = [];
26721          termData = [];
26722          lastTextHash = null;
26723          if (!options.keepClassifications) {
26724            classifications = {};
26725            documentShape = {};
26726          }
26727        }
26728
26729        function simpleHash(text) {
26730          var h = 5381;
26731          for (var i = 0; i < text.length; i++) {
26732            h = ((h << 5) + h) ^ text.charCodeAt(i);
26733            h = h >>> 0;
26734          }
26735          return h.toString(36);
26736        }
26737
26738        function escapeHtml(text) {
26739          var d = document.createElement("div");
26740          d.textContent = text;
26741          return d.innerHTML;
26742        }
26743
26744        function emitAnnounce(message) {
26745          document.dispatchEvent(
26746            new CustomEvent("markv:announce", { detail: { message: message } }),
26747          );
26748        }
26749
26750        function getDocumentId(name) {
26751          if (name && name !== "Untitled" && name !== "") {
26752            return "named::" + name;
26753          }
26754          return "draft::current";
26755        }
26756
26757        var TYPE_LABELS = {
26758          introduction: "intro",
26759          definition: "define",
26760          explanation: "explain",
26761          instruction: "how-to",
26762          argument: "argument",
26763          narrative: "narrative",
26764          example: "example",
26765          unknown: "other",
26766        };
26767
26768        function getTypeLabel(type) {
26769          return TYPE_LABELS[type] || type;
26770        }
26771
26772        function getTypeColor(type) {
26773          return TYPE_COLORS[type] || TYPE_COLORS["unknown"];
26774        }
26775
26776        function getSectionId(index) {
26777          return "section::" + index;
26778        }
26779
26780        function normalizeProvider(value) {
26781          return value === "groq" ? "groq" : "ollama";
26782        }
26783
26784        function getLlmConfig() {
26785          try {
26786            var raw = localStorage.getItem(LLM_STORAGE_KEY);
26787            if (raw) {
26788              var parsed = JSON.parse(raw);
26789              if (parsed && typeof parsed === "object") {
26790                parsed.provider = normalizeProvider(parsed.provider);
26791                return parsed;
26792              }
26793            }
26794          } catch (e) {}
26795          try {
26796            var legacyRaw = localStorage.getItem(LEGACY_OLLAMA_STORAGE_KEY);
26797            if (legacyRaw) {
26798              var legacy = JSON.parse(legacyRaw);
26799              if (legacy && typeof legacy === "object") {
26800                return {
26801                  provider: "ollama",
26802                  endpoint: legacy.endpoint || "",
26803                  ollamaModel: legacy.model || "",
26804                };
26805              }
26806            }
26807          } catch (e) {}
26808          return null;
26809        }
26810
26811        function hasConfiguredLlm() {
26812          if (typeof window.markvHasConfiguredLlm === "function") {
26813            return window.markvHasConfiguredLlm();
26814          }
26815          var config = getLlmConfig();
26816          if (!config) return false;
26817          var provider = normalizeProvider(config.provider);
26818          if (provider === "groq") {
26819            return Boolean(
26820              String(config.groqApiKey || "").trim() &&
26821              String(config.groqModel || config.model || "").trim(),
26822            );
26823          }
26824          return Boolean(
26825            String(config.endpoint || "").trim() &&
26826            String(config.ollamaModel || config.model || "").trim(),
26827          );
26828        }
26829
26830        // ── Stemmer (lightweight suffix stripping) ────────────────────────────
26831        var SUFFIXES = [
26832          "tion",
26833          "ing",
26834          "ed",
26835          "ly",
26836          "ness",
26837          "ment",
26838          "er",
26839          "ful",
26840          "ous",
26841          "ive",
26842          "able",
26843          "ible",
26844          "al",
26845          "ial",
26846        ];
26847
26848        function stem(word) {
26849          if (word.length < 6) return word;
26850          for (var i = 0; i < SUFFIXES.length; i++) {
26851            var s = SUFFIXES[i];
26852            if (word.length > s.length + 3 && word.slice(-s.length) === s) {
26853              return word.slice(0, -s.length);
26854            }
26855          }
26856          return word;
26857        }
26858
26859        // ── Text extraction ───────────────────────────────────────────────────
26860        function isExcluded(node) {
26861          var el = node.nodeType === 1 ? node : node.parentElement;
26862          while (el) {
26863            if (el.matches && el.matches(EXCLUDED_SELECTOR)) return true;
26864            el = el.parentElement;
26865          }
26866          return false;
26867        }
26868
26869        function extractTextFromElement(container) {
26870          var text = "";
26871          var walker = document.createTreeWalker(
26872            container,
26873            NodeFilter.SHOW_TEXT,
26874            {
26875              acceptNode: function (node) {
26876                if (isExcluded(node)) return NodeFilter.FILTER_REJECT;
26877                return NodeFilter.FILTER_ACCEPT;
26878              },
26879            },
26880          );
26881          var node;
26882          while ((node = walker.nextNode())) {
26883            text += node.nodeValue + " ";
26884          }
26885          return text;
26886        }
26887
26888        function tokenize(text) {
26889          return text
26890            .toLowerCase()
26891            .split(/[^a-z]+/)
26892            .filter(function (w) {
26893              return w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w);
26894            });
26895        }
26896
26897        // ── Phase 1: Section Model ─────────────────────────────────────────────
26898        function buildSectionModel() {
26899          var preview = document.getElementById("preview");
26900          if (!preview) return;
26901
26902          var headings = Array.from(
26903            preview.querySelectorAll("h1,h2,h3,h4,h5,h6"),
26904          );
26905          sections = [];
26906
26907          if (headings.length === 0) {
26908            var allText = extractTextFromElement(preview);
26909            var words = tokenize(allText);
26910            sections.push({
26911              id: getSectionId(0),
26912              index: 0,
26913              heading: "Document",
26914              level: 0,
26915              headingElement: null,
26916              text: allText,
26917              previewText: allText.slice(0, 500),
26918              wordCount: words.length,
26919              words: words,
26920            });
26921          } else {
26922            for (var i = 0; i < headings.length; i++) {
26923              var hEl = headings[i];
26924              var level = parseInt(hEl.tagName.slice(1), 10);
26925              var nextH = headings[i + 1] || null;
26926
26927              var sectionText = hEl.textContent + " ";
26928              var node = hEl.nextSibling;
26929              while (node) {
26930                if (node === nextH) break;
26931                if (node.nodeType === 1) {
26932                  if (!node.matches(EXCLUDED_SELECTOR)) {
26933                    sectionText += extractTextFromElement(node) + " ";
26934                  }
26935                } else if (node.nodeType === 3) {
26936                  sectionText += node.nodeValue + " ";
26937                }
26938                node = node.nextSibling;
26939              }
26940
26941              var words = tokenize(sectionText);
26942              sections.push({
26943                id: getSectionId(i),
26944                index: i,
26945                heading: hEl.textContent.trim(),
26946                level: level,
26947                headingElement: hEl,
26948                text: sectionText,
26949                previewText: sectionText.slice(0, 500),
26950                wordCount: words.length,
26951                words: words,
26952              });
26953            }
26954          }
26955        }
26956
26957        // ── Phase 1: TF-IDF Term Extraction ────────────────────────────────────
26958        function extractTerms() {
26959          if (sections.length === 0) {
26960            termData = [];
26961            return;
26962          }
26963
26964          var totalWords = 0;
26965          sections.forEach(function (s) {
26966            totalWords += s.wordCount;
26967          });
26968
26969          if (totalWords === 0) {
26970            termData = [];
26971            return;
26972          }
26973
26974          var globalFreq = {};
26975          var sectionFreqs = sections.map(function (s) {
26976            var freq = {};
26977            s.words.forEach(function (w) {
26978              var stemmed = stem(w);
26979              freq[stemmed] = (freq[stemmed] || 0) + 1;
26980              if (!globalFreq[stemmed]) {
26981                globalFreq[stemmed] = { count: 0, surface: w };
26982              }
26983              globalFreq[stemmed].count += 1;
26984            });
26985            return freq;
26986          });
26987
26988          var numSections = sections.length;
26989          var scores = {};
26990
26991          Object.keys(globalFreq).forEach(function (stemmed) {
26992            var containingCount = sectionFreqs.filter(function (f) {
26993              return !!f[stemmed];
26994            }).length;
26995            var idf = Math.log((numSections + 1) / (containingCount + 1));
26996
26997            sectionFreqs.forEach(function (freq, idx) {
26998              if (!freq[stemmed]) return;
26999              var tf = freq[stemmed] / Math.max(sections[idx].wordCount, 1);
27000              var score = tf * (idf + 1);
27001              if (!scores[stemmed] || scores[stemmed].score < score) {
27002                scores[stemmed] = {
27003                  score: score,
27004                  count: globalFreq[stemmed].count,
27005                  surface: globalFreq[stemmed].surface,
27006                };
27007              }
27008            });
27009          });
27010
27011          var topN = Math.min(12, Math.max(4, Math.floor(totalWords / 100)));
27012          var sorted = Object.keys(scores)
27013            .filter(function (stemmed) {
27014              return scores[stemmed].count >= 2;
27015            })
27016            .sort(function (a, b) {
27017              return scores[b].score - scores[a].score;
27018            });
27019
27020          termData = sorted
27021            .filter(function (stemmed) {
27022              return scores[stemmed].count >= 2;
27023            })
27024            .slice(0, topN)
27025            .map(function (stemmed) {
27026              return {
27027                stemmed: stemmed,
27028                surface: scores[stemmed].surface,
27029                count: scores[stemmed].count,
27030                score: scores[stemmed].score,
27031              };
27032            });
27033        }
27034
27035        // ── Hash helpers ──────────────────────────────────────────────────────
27036        function computeDocumentTextHash() {
27037          var combined = sections
27038            .map(function (s) {
27039              return s.text;
27040            })
27041            .join("|");
27042          return simpleHash(combined);
27043        }
27044
27045        function computeDocumentShape(classifs, sects) {
27046          var typeTotals = {};
27047          var total = 0;
27048          sects.forEach(function (s) {
27049            var type = classifs[s.id] || "explanation";
27050            typeTotals[type] = (typeTotals[type] || 0) + s.wordCount;
27051            total += s.wordCount;
27052          });
27053          if (total === 0) return {};
27054          var shape = {};
27055          Object.keys(typeTotals).forEach(function (t) {
27056            shape[t] = typeTotals[t] / total;
27057          });
27058          return shape;
27059        }
27060
27061        // ── Cache ─────────────────────────────────────────────────────────────
27062        function loadCache(docId) {
27063          try {
27064            var raw = localStorage.getItem(CACHE_PREFIX + docId);
27065            if (!raw) return null;
27066            var parsed = JSON.parse(raw);
27067            if (parsed && parsed.cacheVersion === CACHE_VERSION) {
27068              return parsed;
27069            }
27070          } catch (e) {}
27071          return null;
27072        }
27073
27074        function saveCache(docId, textHash, classifs, shape) {
27075          try {
27076            localStorage.setItem(
27077              CACHE_PREFIX + docId,
27078              JSON.stringify({
27079                cacheVersion: CACHE_VERSION,
27080                textHash: textHash,
27081                classifications: classifs,
27082                documentShape: shape,
27083                cachedAt: Date.now(),
27084              }),
27085            );
27086          } catch (e) {}
27087        }
27088
27089        // ── Phase 2: LLM Classification ────────────────────────────────────────
27090        function classifySections(docId, textHash, generation) {
27091          if (
27092            !hasConfiguredLlm() ||
27093            typeof window.markvCallTextLlm !== "function"
27094          ) {
27095            return;
27096          }
27097
27098          var cached = loadCache(docId);
27099          if (cached && cached.textHash === textHash) {
27100            classifications = cached.classifications || {};
27101            documentShape = cached.documentShape || {};
27102            classifyStale = false;
27103            if (panelOpen) {
27104              renderPanel();
27105              applyPreviewHints();
27106            }
27107            return;
27108          }
27109
27110          // Build single batched prompt — one LLM call for all sections
27111          var listing = sections
27112            .map(function (s, idx) {
27113              var snippet = (s.previewText || "")
27114                .replace(/"/g, "'")
27115                .slice(0, 150);
27116              return (
27117                idx + 1 + ". " + s.heading.replace(/"/g, "'") + ": " + snippet
27118              );
27119            })
27120            .join("\n");
27121
27122          var systemPrompt =
27123            "Classify each document section into exactly one category. " +
27124            "Allowed categories: introduction, definition, explanation, instruction, argument, narrative, example. " +
27125            "Respond with ONLY a numbered list. Each line must be: number. category";
27126          var userPrompt = "Sections:\n" + listing;
27127
27128          window
27129            .markvCallTextLlm(
27130              [
27131                { role: "system", content: systemPrompt },
27132                { role: "user", content: userPrompt },
27133              ],
27134              { stream: false },
27135            )
27136            .then(function (content) {
27137              if (generation !== classifyGeneration) return;
27138
27139              var localClassifs = {};
27140              var lines = String(content || "")
27141                .trim()
27142                .split("\n");
27143
27144              lines.forEach(function (line) {
27145                var m = line.match(/^\s*(\d+)\.\s*(\w+)/);
27146                if (!m) return;
27147                var idx = parseInt(m[1], 10) - 1;
27148                var raw = m[2].toLowerCase();
27149                if (
27150                  idx >= 0 &&
27151                  idx < sections.length &&
27152                  KNOWN_TYPES.indexOf(raw) >= 0
27153                ) {
27154                  localClassifs[sections[idx].id] = raw;
27155                }
27156              });
27157
27158              // Fill unmatched sections
27159              sections.forEach(function (s) {
27160                if (!localClassifs[s.id]) localClassifs[s.id] = "explanation";
27161              });
27162
27163              classifications = localClassifs;
27164              documentShape = computeDocumentShape(localClassifs, sections);
27165              classifyStale = false;
27166              saveCache(docId, textHash, classifications, documentShape);
27167              if (panelOpen) {
27168                renderPanel();
27169                applyPreviewHints();
27170              }
27171            })
27172            .catch(function (err) {
27173              if (err && err.name === "AbortError") return;
27174              if (generation !== classifyGeneration) return;
27175              // Fallback: all explanation
27176              var localClassifs = {};
27177              sections.forEach(function (s) {
27178                localClassifs[s.id] = "explanation";
27179              });
27180              classifications = localClassifs;
27181              documentShape = computeDocumentShape(localClassifs, sections);
27182              if (panelOpen) renderPanel();
27183            });
27184        }
27185
27186        // ── Panel Rendering ───────────────────────────────────────────────────
27187        function renderPanel() {
27188          var body = document.getElementById("ontBody");
27189          var summary = document.getElementById("ontSummary");
27190          if (!body) return;
27191
27192          var totalWords = sections.reduce(function (acc, s) {
27193            return acc + s.wordCount;
27194          }, 0);
27195
27196          if (sections.length === 0 || totalWords === 0) {
27197            body.innerHTML = '<p class="ont-empty">No content to analyze.</p>';
27198            if (summary) summary.textContent = "";
27199            return;
27200          }
27201
27202          if (summary) {
27203            summary.textContent =
27204              sections.length +
27205              " section" +
27206              (sections.length !== 1 ? "s" : "") +
27207              " \u00b7 " +
27208              totalWords +
27209              " words";
27210          }
27211
27212          // All values inserted into HTML are escaped via escapeHtml()
27213          var html = "";
27214
27215          // Heuristic dot colors when no classification available
27216          var LEVEL_COLORS = [
27217            "var(--ont-intro)",
27218            "var(--ont-explanation)",
27219            "var(--ont-instruction)",
27220            "var(--ont-argument)",
27221            "var(--ont-narrative)",
27222            "var(--ont-example)",
27223            "var(--ont-definition)",
27224          ];
27225          var llmConfigured = hasConfiguredLlm();
27226          var classifying =
27227            llmConfigured &&
27228            Object.keys(classifications).length < sections.length &&
27229            !classifyStale;
27230
27231          // Sections
27232          html += '<div class="ont-section-label">Sections</div>';
27233          sections.forEach(function (s, idx) {
27234            var type = classifications[s.id];
27235            var color = type
27236              ? getTypeColor(type)
27237              : LEVEL_COLORS[idx % LEVEL_COLORS.length];
27238            var badge = "";
27239
27240            if (type) {
27241              badge =
27242                '<span class="ont-section-badge" style="background:color-mix(in srgb, ' +
27243                color +
27244                ' 15%, transparent)">' +
27245                escapeHtml(getTypeLabel(type)) +
27246                "</span>";
27247            } else if (classifying) {
27248              badge = '<span class="ont-section-badge is-loading"></span>';
27249            }
27250
27251            var indent = Math.max(0, (s.level - 1) * 12);
27252            html +=
27253              '<button class="ont-section-row" data-section-id="' +
27254              escapeHtml(s.id) +
27255              '" style="padding-left:' +
27256              (14 + indent) +
27257              'px">' +
27258              '<span class="ont-section-dot" style="background:' +
27259              color +
27260              '"></span>' +
27261              '<span class="ont-section-name">' +
27262              escapeHtml(s.heading) +
27263              "</span>" +
27264              badge +
27265              "</button>";
27266          });
27267
27268          // Divider
27269          html += '<div class="ont-divider"></div>';
27270
27271          // Key Concepts
27272          html += '<div class="ont-section-label">Key Concepts</div>';
27273          html += '<div class="ont-concepts">';
27274          if (termData.length > 0) {
27275            termData.forEach(function (t) {
27276              html +=
27277                '<button class="ont-concept-pill" data-term="' +
27278                escapeHtml(t.stemmed) +
27279                '" data-surface="' +
27280                escapeHtml(t.surface) +
27281                '" aria-label="' +
27282                escapeHtml(t.surface) +
27283                ", " +
27284                t.count +
27285                ' occurrences">' +
27286                escapeHtml(t.surface) +
27287                ' <span class="ont-concept-count">\u00d7' +
27288                t.count +
27289                "</span></button>";
27290            });
27291          } else {
27292            html +=
27293              '<span style="color:var(--fg-subtle);font-size:11px;">No key terms extracted.</span>';
27294          }
27295          html += "</div>";
27296
27297          // Divider
27298          html += '<div class="ont-divider"></div>';
27299
27300          // Document Shape
27301          html += '<div class="ont-shape-section">';
27302          html +=
27303            '<div class="ont-section-label" style="padding-top:8px;">Document Shape</div>';
27304
27305          if (!llmConfigured) {
27306            html +=
27307              '<div class="ont-fallback" style="padding:6px 0 12px;text-align:left;">Open AI settings for section analysis.</div>';
27308          } else if (classifyStale) {
27309            html +=
27310              '<div style="padding:6px 0 12px;"><button class="ont-stale-link ont-reanalyze-btn">Stale \u2014 re-analyze</button></div>';
27311          } else if (Object.keys(documentShape).length === 0) {
27312            html +=
27313              '<div style="padding:6px 0 12px;color:var(--fg-subtle);font-size:10px;">Analyzing\u2026</div>';
27314          } else {
27315            var shapeKeys = Object.keys(documentShape)
27316              .filter(function (k) {
27317                return documentShape[k] > 0.01;
27318              })
27319              .sort(function (a, b) {
27320                return documentShape[b] - documentShape[a];
27321              });
27322            var shapeLabel = shapeKeys
27323              .map(function (type) {
27324                return Math.round(documentShape[type] * 100) + "% " + type;
27325              })
27326              .join(", ");
27327            html +=
27328              '<div class="ont-shape-bar" role="img" aria-label="' +
27329              escapeHtml("Document composition: " + shapeLabel) +
27330              '">';
27331            shapeKeys.forEach(function (type) {
27332              html +=
27333                '<div class="ont-shape-segment" style="flex:' +
27334                documentShape[type] +
27335                ";background:" +
27336                getTypeColor(type) +
27337                '"></div>';
27338            });
27339            html += "</div>";
27340            html += '<div class="ont-shape-labels">';
27341            shapeKeys.forEach(function (type) {
27342              html += "<span>" + escapeHtml(getTypeLabel(type)) + "</span>";
27343            });
27344            html += "</div>";
27345          }
27346          html += "</div>";
27347
27348          body.innerHTML = html; // all values are escaped via escapeHtml()
27349          bindPanelEvents();
27350        }
27351
27352        function bindPanelEvents() {
27353          var body = document.getElementById("ontBody");
27354          if (!body) return;
27355
27356          body.querySelectorAll(".ont-section-row").forEach(function (btn) {
27357            btn.addEventListener("click", function () {
27358              var sectionId = this.getAttribute("data-section-id");
27359              var section = sections.find(function (s) {
27360                return s.id === sectionId;
27361              });
27362              if (section && section.headingElement) {
27363                section.headingElement.scrollIntoView({
27364                  behavior: prefersReducedMotion() ? "auto" : "smooth",
27365                  block: "start",
27366                });
27367              }
27368            });
27369          });
27370
27371          body.querySelectorAll(".ont-concept-pill").forEach(function (pill) {
27372            pill.addEventListener("click", function () {
27373              var surface = this.getAttribute("data-surface");
27374              flashTerm(surface);
27375            });
27376          });
27377
27378          var reanalyzeBtn = body.querySelector(".ont-reanalyze-btn");
27379          if (reanalyzeBtn) {
27380            reanalyzeBtn.addEventListener("click", function () {
27381              classifyStale = false;
27382              classifications = {};
27383              documentShape = {};
27384              classifyGeneration++;
27385              renderPanel();
27386              var docId = currentDocumentId;
27387              var hash = computeDocumentTextHash();
27388              classifySections(docId, hash, classifyGeneration);
27389            });
27390          }
27391        }
27392
27393        function flashTerm(surface) {
27394          var spans = Array.from(document.querySelectorAll(".ont-term")).filter(
27395            function (s) {
27396              return (
27397                s.textContent.toLowerCase() === surface.toLowerCase() ||
27398                s.getAttribute("data-surface") === surface
27399              );
27400            },
27401          );
27402          if (spans.length === 0) return;
27403          spans.forEach(function (s) {
27404            s.classList.add("ont-term-flash");
27405            setTimeout(function () {
27406              s.classList.remove("ont-term-flash");
27407            }, 1200);
27408          });
27409          spans[0].scrollIntoView({
27410            behavior: prefersReducedMotion() ? "auto" : "smooth",
27411            block: "nearest",
27412          });
27413        }
27414
27415        // ── Preview Hints ─────────────────────────────────────────────────────
27416        function applyPreviewHints() {
27417          var preview = document.getElementById("preview");
27418          if (!preview) return;
27419
27420          preview.classList.add("ontology-active");
27421
27422          sections.forEach(function (s) {
27423            if (!s.headingElement) return;
27424            if (s.headingElement.querySelector(".ont-preview-dot")) return;
27425            var type = classifications[s.id] || null;
27426            var color = type ? getTypeColor(type) : "#94a3b8";
27427            var dot = document.createElement("span");
27428            dot.className = "ont-preview-dot";
27429            dot.style.background = color;
27430            dot.setAttribute("aria-hidden", "true");
27431            s.headingElement.prepend(dot);
27432          });
27433
27434          applyTermUnderlines();
27435        }
27436
27437        function applyTermUnderlines() {
27438          var preview = document.getElementById("preview");
27439          if (!preview || termData.length === 0) return;
27440
27441          var surfaceForms = termData.map(function (t) {
27442            return t.surface;
27443          });
27444
27445          var walker = document.createTreeWalker(
27446            preview,
27447            NodeFilter.SHOW_TEXT,
27448            {
27449              acceptNode: function (node) {
27450                if (isExcluded(node)) return NodeFilter.FILTER_REJECT;
27451                var p = node.parentElement;
27452                while (p && p !== preview) {
27453                  if (
27454                    p.classList &&
27455                    (p.classList.contains("doc-highlight") ||
27456                      p.classList.contains("ont-term"))
27457                  ) {
27458                    return NodeFilter.FILTER_REJECT;
27459                  }
27460                  p = p.parentElement;
27461                }
27462                return NodeFilter.FILTER_ACCEPT;
27463              },
27464            },
27465          );
27466
27467          var textNodes = [];
27468          var node;
27469          while ((node = walker.nextNode())) {
27470            textNodes.push(node);
27471          }
27472
27473          textNodes.forEach(function (textNode) {
27474            wrapTermsInNode(textNode, surfaceForms);
27475          });
27476        }
27477
27478        function wrapTermsInNode(textNode, surfaceForms) {
27479          var text = textNode.nodeValue;
27480          if (!text || text.trim() === "") return;
27481
27482          var matches = [];
27483          surfaceForms.forEach(function (surface) {
27484            var escaped = surface.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27485            var re = new RegExp("\\b" + escaped + "\\b", "gi");
27486            var m;
27487            while ((m = re.exec(text)) !== null) {
27488              matches.push({
27489                start: m.index,
27490                end: m.index + m[0].length,
27491                surface: surface,
27492                matched: m[0],
27493              });
27494            }
27495          });
27496
27497          if (matches.length === 0) return;
27498
27499          // Sort descending by start position
27500          matches.sort(function (a, b) {
27501            return b.start - a.start;
27502          });
27503
27504          // Deduplicate overlapping matches
27505          var deduped = [];
27506          var lastStart = Infinity;
27507          for (var i = 0; i < matches.length; i++) {
27508            if (matches[i].end <= lastStart) {
27509              deduped.push(matches[i]);
27510              lastStart = matches[i].start;
27511            }
27512          }
27513
27514          if (deduped.length === 0) return;
27515
27516          var parent = textNode.parentNode;
27517          if (!parent) return;
27518
27519          var currentNode = textNode;
27520          deduped.forEach(function (match) {
27521            if (!currentNode || currentNode.nodeType !== 3) return;
27522            var nodeText = currentNode.nodeValue;
27523            if (match.start >= nodeText.length) return;
27524
27525            var after = currentNode.splitText(match.end);
27526            var matchNode = currentNode.splitText(match.start);
27527
27528            var span = document.createElement("span");
27529            span.className = "ont-term";
27530            span.setAttribute("data-surface", match.surface);
27531            parent.insertBefore(span, after);
27532            span.appendChild(matchNode);
27533            // currentNode is now the left prefix — correct for next right-to-left match
27534          });
27535        }
27536
27537        function removePreviewHints() {
27538          var preview = document.getElementById("preview");
27539          if (!preview) return;
27540
27541          preview.classList.remove("ontology-active");
27542
27543          Array.from(preview.querySelectorAll(".ont-preview-dot")).forEac
27543h(
27544            function (dot) {
27545              dot.remove();
27546            },
27547          );
27548
27549          var termSpans = Array.from(preview.querySelectorAll(".ont-term"));
27550          var unwrapped = termSpans.length > 0;
27551          termSpans.forEach(function (span) {
27552            var par = span.parentNode;
27553            if (!par) return;
27554            while (span.firstChild) {
27555              par.insertBefore(span.firstChild, span);
27556            }
27557            par.removeChild(span);
27558          });
27559
27560          if (unwrapped) {
27561            preview.normalize();
27562            document.dispatchEvent(
27563              new CustomEvent("markv:request-highlight-refresh"),
27564            );
27565          }
27566        }
27567
27568        // ── Panel Show / Hide ─────────────────────────────────────────────────
27569        function showPanel() {
27570          var panel = document.getElementById("ontologyPanel");
27571          var toggleBtn = document.getElementById("ontologyToggleBtn");
27572          if (!panel) return;
27573
27574          panel.hidden = false;
27575          panel.classList.remove("hidden");
27576          panel.setAttribute("aria-hidden", "false");
27577          panelOpen = true;
27578          if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "true");
27579
27580          // While the panel was closed, the render listener skipped
27581          // the section/term build. Rebuild now so the UI reflects
27582          // the current document.
27583          if (sections.length === 0 || classifyStale) {
27584            buildSectionModel();
27585            extractTerms();
27586            lastTextHash = computeDocumentTextHash();
27587          }
27588
27589          renderPanel();
27590          applyPreviewHints();
27591
27592          if (sections.length > 0) {
27593            var hash = computeDocumentTextHash();
27594            var docId = currentDocumentId;
27595            var cached = loadCache(docId);
27596            if (cached && cached.textHash === hash) {
27597              classifications = cached.classifications || {};
27598              documentShape = cached.documentShape || {};
27599              classifyStale = false;
27600              renderPanel();
27601              applyPreviewHints();
27602            } else if (
27603              Object.keys(classifications).length === 0 ||
27604              classifyStale
27605            ) {
27606              classifyGeneration++;
27607              classifySections(docId, hash, classifyGeneration);
27608            }
27609          }
27610
27611          emitAnnounce("Document ontology panel opened.");
27612        }
27613
27614        function hidePanel(options) {
27615          options = options || {};
27616          var panel = document.getElementById("ontologyPanel");
27617          var toggleBtn = document.getElementById("ontologyToggleBtn");
27618          if (!panel) return;
27619
27620          panel.hidden = true;
27621          panel.classList.add("hidden");
27622          panel.setAttribute("aria-hidden", "true");
27623          panelOpen = false;
27624          if (!options.skipGenerationBump) classifyGeneration++;
27625          if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "false");
27626          removePreviewHints();
27627          releaseDocumentAnalysis();
27628          if (options.announce !== false) {
27629            emitAnnounce("Document ontology panel closed.");
27630          }
27631        }
27632
27633        // ── Toolbar toggle button ─────────────────────────────────────────────
27634        var toggleBtn = document.getElementById("ontologyToggleBtn");
27635        if (toggleBtn) {
27636          toggleBtn.addEventListener("click", function () {
27637            if (panelOpen) {
27638              hidePanel();
27639            } else {
27640              showPanel();
27641            }
27642          });
27643        }
27644
27645        // ── Close button ──────────────────────────────────────────────────────
27646        var closeBtn = document.getElementById("ontCloseBtn");
27647        if (closeBtn) {
27648          closeBtn.addEventListener("click", function () {
27649            hidePanel();
27650          });
27651        }
27652
27653        // ── Click outside to close ────────────────────────────────────────────
27654        document.addEventListener("click", function (e) {
27655          if (!panelOpen) return;
27656          var panel = document.getElementById("ontologyPanel");
27657          var tBtn = document.getElementById("ontologyToggleBtn");
27658          if (!panel || !tBtn) return;
27659          if (
27660            !panel.contains(e.target) &&
27661            tBtn !== e.target &&
27662            !tBtn.contains(e.target)
27663          ) {
27664            hidePanel();
27665          }
27666        });
27667
27668        // ── Escape key ────────────────────────────────────────────────────────
27669        document.addEventListener("keydown", function (e) {
27670          if (e.key !== "Escape" || !panelOpen) return;
27671          var bubble = document.getElementById("selectionBubble");
27672          var ctx = document.getElementById("ctxMenu");
27673          var chip = document.getElementById("highlightRemoveChip");
27674          function isVisible(el) {
27675            return el && !el.hidden && el.style.display !== "none";
27676          }
27677          if (isVisible(bubble) || isVisible(ctx) || isVisible(chip)) return;
27678          hidePanel();
27679        });
27680
27681        // ── markv:render-complete listener ────────────────────────────────────
27682        document.addEventListener("markv:render-complete", function (e) {
27683          var detail = (e && e.detail) || {};
27684          var docName = detail.documentName || "";
27685          var newDocId = getDocumentId(docName);
27686
27687          if (newDocId !== currentDocumentId) {
27688            currentDocumentId = newDocId;
27689            releaseDocumentAnalysis();
27690            classifyStale = false;
27691            classifyGeneration++;
27692            if (panelOpen) {
27693              hidePanel({ announce: false, skipGenerationBump: true });
27694            }
27695          }
27696
27697          // When the panel is closed, skip walking/tokenizing the
27698          // whole preview on every keystroke — the data only feeds
27699          // the ontology UI. Mark stale so the next `showPanel()`
27700          // rebuilds from current DOM.
27701          if (!panelOpen) {
27702            classifyStale = true;
27703            releaseDocumentAnalysis();
27704            return;
27705          }
27706
27707          buildSectionModel();
27708          extractTerms();
27709
27710          var newHash = computeDocumentTextHash();
27711          if (lastTextHash !== null && newHash !== lastTextHash) {
27712            classifyStale = true;
27713            classifyGeneration++;
27714          }
27715          lastTextHash = newHash;
27716
27717          removePreviewHints();
27718          renderPanel();
27719          applyPreviewHints();
27720        });
27721
27722        // ── Initial build (the first markv:render-complete fires before this script loads) ──
27723        if (
27724          document.getElementById("preview") &&
27725          document.getElementById("preview").textContent.trim()
27726        ) {
27727          var docName =
27728            document.getElementById("preview").dataset.documentLabel || "";
27729          if (docName === "current document") docName = "";
27730          currentDocumentId = getDocumentId(docName);
27731          classifyStale = true;
27732        }
27733      })();
27734    </script>
27734
27735
27736    <!-- Scroll to top -->
27737    <button
27738      id="scrollTopBtn"
27739      class="scroll-top-btn"
27740      type="button"
27741      title="Scroll to top"
27742      aria-label="Scroll to top"
27743      aria-hidden="true"
27744      tabindex="-1"
27745    >
27746      <svg
27747        viewBox="0 0 24 24"
27748        fill="none"
27749        stroke="currentColor"
27750        stroke-width="2"
27751        stroke-linecap="round"
27752        stroke-linejoin="round"
27753        aria-hidden="true"
27754      >
27755        <path d="M12 19V5" />
27756        <path d="M5 12l7-7 7 7" />
27757      </svg>
27758    </button>
27759
27760    <!-- Chat FAB -->
27761    <button
27762      id="chatFab"
27763      class="chat-fab"
27764      title="Ask about this document"
27765      aria-label="Open document Q&A"
27766      aria-haspopup="dialog"
27767      aria-controls="chatPanel"
27768    >
27769      <svg
27770        width="22"
27771        height="22"
27772        viewBox="0 0 24 24"
27773        fill="none"
27774        stroke="currentColor"
27775        stroke-width="2"
27776        stroke-linecap="round"
27777        stroke-linejoin="round"
27778      >
27779        <path
27780          d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
27781        />
27782      </svg>
27783    </button>
27784
27785    <!-- Chat Panel -->
27786    <div
27787      id="chatPanel"
27788      class="chat-panel hidden"
27789      role="dialog"
27790      aria-modal="true"
27791      aria-labelledby="chatTitle"
27792      aria-describedby="chatHelp"
27793      aria-hidden="true"
27794      hidden
27795    >
27796      <div class="chat-header">
27797        <svg
27798          width="16"
27799          height="16"
27800          viewBox="0 0 24 24"
27801          fill="none"
27802          stroke="currentColor"
27803          stroke-width="2"
27804          stroke-linecap="round"
27805          stroke-linejoin="round"
27806        >
27807          <path
27808            d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
27809          />
27810        </svg>
27811        <span id="chatTitle" class="chat-header-title">Document Q&A</span>
27812        <button
27813          id="chatSettingsBtn"
27814          class="chat-header-btn"
27815          title="AI settings"
27816          aria-label="Settings"
27817          aria-controls="chatSettings"
27818          aria-expanded="false"
27819        >
27820          <svg
27821            width="16"
27822            height="16"
27823            viewBox="0 0 24 24"
27824            fill="none"
27825            stroke="currentColor"
27826            stroke-width="2"
27827            stroke-linecap="round"
27828            stroke-linejoin="round"
27829          >
27830            <circle cx="12" cy="12" r="3" />
27831            <path
27832              d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
27833            />
27834          </svg>
27835        </button>
27836        <button
27837          id="chatCloseBtn"
27838          class="chat-header-btn"
27839          title="Close"
27840          aria-label="Close chat"
27841        >
27842          <svg
27843            width="16"
27844            height="16"
27845            viewBox="0 0 24 24"
27846            fill="none"
27847            stroke="currentColor"
27848            stroke-width="2"
27849            stroke-linecap="round"
27850            stroke-linejoin="round"
27851          >
27852            <line x1="18" y1="6" x2="6" y2="18" />
27853            <line x1="6" y1="6" x2="18" y2="18" />
27854          </svg>
27855        </button>
27856      </div>
27857      <div
27858        id="chatMessages"
27859        class="chat-messages"
27860        role="log"
27861        aria-label="Document Q&A conversation"
27862        aria-live="off"
27863        aria-relevant="additions text"
27864      >
27865        <div id="chatHelp" class="chat-msg system">
27866          Load a document, then ask questions about it. Powered by Ollama or
27867          Groq.
27868        </div>
27869      </div>
27870      <div
27871        id="chatLiveRegion"
27872        class="visually-hidden"
27873        aria-live="polite"
27874        aria-atomic="true"
27875      ></div>
27876      <div
27877        id="chatSettings"
27878        class="chat-settings hidden"
27879        aria-hidden="true"
27880        hidden
27881      >
27882        <label>
27883          <span>Provider</span>
27884          <select id="llmProvider" aria-label="AI provider">
27885            <option value="ollama">Ollama</option>
27886            <option value="groq">Groq</option>
27887          </select>
27888        </label>
27889        <label id="ollamaEndpointField">
27890          <span>Local Ollama Endpoint</span>
27891          <input
27892            id="ollamaEndpoint"
27893            type="text"
27894            value="http://localhost:11434"
27895            placeholder="http://localhost:11434"
27896          />
27897        </label>
27898        <label id="ollamaModelField">
27899          <span>Ollama Model</span>
27900          <input
27901            id="ollamaModel"
27902            type="text"
27903            value="gemma4:26b"
27904            placeholder="gemma4:26b"
27905          />
27906        </label>
27907        <label id="groqApiKeyField" hidden>
27908          <span>Groq API Key</span>
27909          <input
27910            id="groqApiKey"
27911            type="password"
27912            placeholder="gsk_..."
27913            autocomplete="off"
27914            spellcheck="false"
27915          />
27916        </label>
27917        <label id="groqModelField" hidden>
27918          <span>Groq Model</span>
27919          <input
27920            id="groqModel"
27921            type="text"
27922            value="qwen/qwen3-32b"
27923            placeholder="qwen/qwen3-32b"
27924          />
27925        </label>
27926        <p id="chatSettingsNote" class="chat-settings-note">
27927          Groq needs an API key. Ollama stays local and needs a loopback
27928          endpoint.
27929        </p>
27930      </div>
27931      <div class="chat-input-area">
27932        <div class="chat-input-card">
27933          <textarea
27934            id="chatInput"
27935            class="chat-input"
27936            rows="2"
27937            placeholder="Ask about this document..."
27938            aria-label="Ask about the current document"
27939          ></textarea>
27940          <div class="chat-input-actions">
27941            <span class="chat-input-hint">↵ send · shift+↵ newline</span>
27942            <button
27943              id="chatSendBtn"
27944              class="chat-send-btn"
27945              title="Send"
27946              aria-label="Send"
27947              disabled
27948            >
27949              <svg
27950                width="14"
27951                height="14"
27952                viewBox="0 0 24 24"
27953                fill="none"
27954                stroke="currentColor"
27955                stroke-width="2.5"
27956                stroke-linecap="round"
27957                stroke-linejoin="round"
27958              >
27959                <line x1="22" y1="2" x2="11" y2="13" />
27960                <polygon points="22 2 15 22 11 13 2 9 22 2" />
27961              </svg>
27962            </button>
27963          </div>
27964        </div>
27965      </div>
27966    </div>
27967
27968    <!-- Files Drawer -->
27969    <div id="libraryOverlay" class="lib-overlay" aria-hidden="true"></div>
27970    <aside
27971      id="libraryDrawer"
27972      class="lib-drawer"
27973      role="dialog"
27974      aria-modal="true"
27975      aria-labelledby="libraryDrawerTitle"
27976      aria-hidden="true"
27977      tabindex="-1"
27978    >
27979      <header class="lib-header">
27980        <div class="lib-header-copy">
27981          <h2 id="libraryDrawerTitle" class="lib-header-title">Files</h2>
27982        </div>
27983        <button
27984          id="libraryCloseBtn"
27985          type="button"
27986          class="lib-close"
27987          aria-label="Close files"
27988        >
27989          <svg
27990            width="16"
27991            height="16"
27992            viewBox="0 0 24 24"
27993            fill="none"
27994            stroke="currentColor"
27995            stroke-width="2.2"
27996            stroke-linecap="round"
27997            stroke-linejoin="round"
27998            aria-hidden="true"
27999          >
28000            <line x1="18" y1="6" x2="6" y2="18" />
28001            <line x1="6" y1="6" x2="18" y2="18" />
28002          </svg>
28003        </button>
28004      </header>
28005      <div class="lib-actions">
28006        <button id="openFileBtn" type="button" class="lib-file-action">
28007          <span class="lib-file-action-icon" aria-hidden="true">
28008            <svg
28009              viewBox="0 0 24 24"
28010              fill="none"
28011              stroke="currentColor"
28012              stroke-width="1.8"
28013              stroke-linecap="round"
28014              stroke-linejoin="round"
28015            >
28016              <path
28017                d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z"
28018              />
28019              <path d="M14 2v5h5" />
28020            </svg>
28021          </span>
28022          <span>Open file</span>
28023        </button>
28024        <button id="libraryAddBtn" type="button" class="lib-file-action">
28025          <span class="lib-file-action-icon" aria-hidden="true">
28026            <svg
28027              viewBox="0 0 24 24"
28028              fill="none"
28029              stroke="currentColor"
28030              stroke-width="1.8"
28031              stroke-linecap="round"
28032              stroke-linejoin="round"
28033            >
28034              <path
28035                d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"
28036              />
28037            </svg>
28038          </span>
28039          <span>Open folder</span>
28040        </button>
28041        <button
28042          id="libraryToggleAllBtn"
28043          type="button"
28044          class="lib-toggle-all"
28045          data-mode="collapse"
28046          title="Collapse all"
28047          aria-label="Collapse all"
28048          hidden
28049        >
28050          <svg
28051            viewBox="0 0 12 12"
28052            fill="none"
28053            stroke="currentColor"
28054            stroke-width="1.4"
28055            stroke-linecap="round"
28056            stroke-linejoin="round"
28057            aria-hidden="true"
28058          >
28059            <path d="M3 7.5 6 4.5l3 3" />
28060          </svg>
28061          <span class="lib-toggle-all-label">Collapse all</span>
28062        </button>
28063      </div>
28064      <div id="librarySupportedNote" class="lib-supported-note" hidden></div>
28065      <div id="libraryBody" class="lib-body">
28066        <div id="libraryEmpty" class="lib-empty">
28067          <p class="lib-empty-title">Nothing here yet</p>
28068          <p class="lib-empty-copy">Open a folder to begin reading.</p>
28069        </div>
28070      </div>
28071    </aside>
28072
28073    <!-- Global Settings Drawer -->
28074    <div id="globalSettingsOverlay" class="gs-overlay" aria-hidden="true"></div>
28075    <aside
28076      id="globalSettingsDrawer"
28077      class="gs-drawer"
28078      role="dialog"
28079      aria-modal="true"
28080      aria-labelledby="globalSettingsTitle"
28081      aria-hidden="true"
28082      tabindex="-1"
28083    >
28084      <header class="gs-header">
28085        <h2 id="globalSettingsTitle" class="gs-header-title">Settings</h2>
28086        <button
28087          type="button"
28088          id="globalSettingsCloseBtn"
28089          class="gs-close"
28090          aria-label="Close settings"
28091        >
28092          <svg
28093            width="16"
28094            height="16"
28095            viewBox="0 0 24 24"
28096            fill="none"
28097            stroke="currentColor"
28098            stroke-width="2.2"
28099            stroke-linecap="round"
28100            stroke-linejoin="round"
28101            aria-hidden="true"
28102          >
28103            <line x1="18" y1="6" x2="6" y2="18" />
28104            <line x1="6" y1="6" x2="18" y2="18" />
28105          </svg>
28106        </button>
28107      </header>
28108
28109      <div class="gs-body">
28110        <section aria-labelledby="gsAiProviderLabel">
28111          <p id="gsAiProviderLabel" class="gs-section-label">AI Provider</p>
28112
28113          <div
28114            id="gsStatusChip"
28115            class="gs-status"
28116            role="status"
28117            aria-live="polite"
28118          >
28119            <span class="gs-status-dot" aria-hidden="true"></span>
28120            <span id="gsStatusText">Not configured</span>
28121          </div>
28122
28123          <div
28124            class="gs-segmented"
28125            role="tablist"
28126            aria-label="AI provider"
28127            style="margin-top: 12px"
28128          >
28129            <button
28130              type="button"
28131              class="gs-seg-btn"
28132              id="gsSegOllama"
28133              role="tab"
28134              aria-pressed="true"
28135              aria-controls="gsCardOllama"
28136              data-provider="ollama"
28137            >
28138              <svg
28139                class="gs-seg-icon"
28140                viewBox="0 0 24 24"
28141                fill="none"
28142                stroke="currentColor"
28143                stroke-width="2"
28144                stroke-linecap="round"
28145                stroke-linejoin="round"
28146                aria-hidden="true"
28147              >
28148                <rect x="3" y="4" width="18" height="12" rx="2" />
28149                <line x1="8" y1="20" x2="16" y2="20" />
28150                <line x1="12" y1="16" x2="12" y2="20" />
28151              </svg>
28152              Ollama
28153            </button>
28154            <button
28155              type="button"
28156              class="gs-seg-btn"
28157              id="gsSegGroq"
28158              role="tab"
28159              aria-pressed="false"
28160              aria-controls="gsCardGroq"
28161              data-provider="groq"
28162            >
28163              <svg
28164                class="gs-seg-icon"
28165                viewBox="0 0 24 24"
28166                fill="none"
28167                stroke="currentColor"
28168                stroke-width="2"
28169                stroke-linecap="round"
28170                stroke-linejoin="round"
28171                aria-hidden="true"
28172              >
28173                <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
28174              </svg>
28175              Groq
28176            </button>
28177          </div>
28178
28179          <!-- Ollama card -->
28180          <div
28181            id="gsCardOllama"
28182            class="gs-card"
28183            role="tabpanel"
28184            aria-labelledby="gsSegOllama"
28185            style="margin-top: 14px"
28186          >
28187            <div class="gs-card-head">
28188              <p class="gs-card-sub">
28189                Runs on your machine — your document never leaves it.
28190              </p>
28191              <span id="gsOllamaInUse" class="gs-in-use-badge" hidden
28192                >In use</span
28193              >
28194              <button
28195                type="button"
28196                id="gsActivateOllama"
28197                class="gs-activate-btn"
28198              >
28199                Use Ollama
28200              </button>
28201            </div>
28202            <div class="gs-field">
28203              <label class="gs-field-label" for="gsOllamaEndpoint">
28204                Endpoint
28205              </label>
28206              <div class="gs-input-wrap">
28207                <input
28208                  id="gsOllamaEndpoint"
28209                  class="gs-input"
28210                  type="text"
28211                  placeholder="http://localhost:11434"
28212                  autocomplete="off"
28213                  spellcheck="false"
28214                />
28215              </div>
28216              <p class="gs-field-hint">
28217                Must be a loopback address. Ollama runs on your machine — your
28218                document never leaves it.
28219              </p>
28220            </div>
28221
28222            <div class="gs-field">
28223              <label class="gs-field-label" for="gsOllamaModel"> Model </label>
28224              <div class="gs-input-wrap">
28225                <input
28226                  id="gsOllamaModel"
28227                  class="gs-input"
28228                  type="text"
28229                  placeholder="gemma4:26b"
28230                  autocomplete="off"
28231                  spellcheck="false"
28232                  list="gsOllamaModelList"
28233                />
28234              </div>
28235              <datalist id="gsOllamaModelList">
28236                <option value="gemma4:26b"></option>
28237                <option value="llama3.1:8b"></option>
28238                <option value="llama3.1:70b"></option>
28239                <option value="qwen2.5:14b"></option>
28240                <option value="qwen2.5:32b"></option>
28241                <option value="mistral:7b"></option>
28242                <option value="phi3.5:3.8b"></option>
28243              </datalist>
28244              <p class="gs-field-hint">
28245                Any model you've pulled with <code>ollama pull</code>.
28246              </p>
28247            </div>
28248
28249            <div class="gs-actions-row">
28250              <button type="button" id="gsTestOllama" class="gs-test-btn">
28251                Test connection
28252              </button>
28253              <span id="gsTestOllamaResult" class="gs-test-result"></span>
28254            </div>
28255          </div>
28256
28257          <!-- Groq card -->
28258          <div
28259            id="gsCardGroq"
28260            class="gs-card"
28261            role="tabpanel"
28262            aria-labelledby="gsSegGroq"
28263            hidden
28264            style="margin-top: 14px"
28265          >
28266            <div class="gs-card-head">
28267              <p class="gs-card-sub">Hosted by Groq — fast cloud inference.</p>
28268              <span id="gsGroqInUse" class="gs-in-use-badge" hidden
28269                >In use</span
28270              >
28271              <button type="button" id="gsActivateGroq" class="gs-activate-btn">
28272                Use Groq
28273              </button>
28274            </div>
28275            <div class="gs-field">
28276              <label class="gs-field-label" for="gsGroqApiKey"> API Key </label>
28277              <div class="gs-input-wrap">
28278                <input
28279                  id="gsGroqApiKey"
28280                  class="gs-input has-reveal"
28281                  type="password"
28282                  placeholder="gsk_..."
28283                  autocomplete="off"
28284                  spellcheck="false"
28285                />
28286                <button
28287                  type="button"
28288                  id="gsGroqRevealBtn"
28289                  class="gs-reveal-btn"
28290                  aria-label="Show API key"
28291                  aria-pressed="false"
28292                >
28293                  <svg
28294                    id="gsRevealIconShow"
28295                    width="15"
28296                    height="15"
28297                    viewBox="0 0 24 24"
28298                    fill="none"
28299                    stroke="currentColor"
28300                    stroke-width="2"
28301                    stroke-linecap="round"
28302                    stroke-linejoin="round"
28303                    aria-hidden="true"
28304                  >
28305                    <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
28306                    <circle cx="12" cy="12" r="3" />
28307                  </svg>
28308                  <svg
28309                    id="gsRevealIconHide"
28310                    width="15"
28311                    height="15"
28312                    viewBox="0 0 24 24"
28313                    fill="none"
28314                    stroke="currentColor"
28315                    stroke-width="2"
28316                    stroke-linecap="round"
28317                    stroke-linejoin="round"
28318                    aria-hidden="true"
28319                    style="display: none"
28320                  >
28321                    <path
28322                      d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a19.77 19.77 0 0 1 4.22-5.19M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a19.86 19.86 0 0 1-3.17 4.19M9.53 9.53a3 3 0 1 0 4.24 4.24"
28323                    />
28324                    <line x1="1" y1="1" x2="23" y2="23" />
28325                  </svg>
28326                </button>
28327              </div>
28328              <p class="gs-field-hint">
28329                Encrypted at rest with AES-GCM and stored only in this browser.
28330                Get a key at
28331                <a
28332                  href="https://console.groq.com/keys"
28333                  target="_blank"
28334                  rel="noopener noreferrer"
28335                  >console.groq.com/keys</a
28336                >.
28337              </p>
28338            </div>
28339
28340            <div class="gs-field">
28341              <label class="gs-field-label" for="gsGroqModel"> Model </label>
28342              <div class="gs-input-wrap">
28343                <input
28344                  id="gsGroqModel"
28345                  class="gs-input"
28346                  type="text"
28347                  placeholder="qwen/qwen3-32b"
28348                  autocomplete="off"
28349                  spellcheck="false"
28350                  list="gsGroqModelList"
28351                />
28352              </div>
28353              <datalist id="gsGroqModelList">
28354                <option value="qwen/qwen3-32b"></option>
28355                <option value="llama-3.3-70b-versatile"></option>
28356                <option value="llama-3.1-8b-instant"></option>
28357                <option value="llama-3.1-70b-versatile"></option>
28358                <option value="mixtral-8x7b-32768"></option>
28359                <option value="gemma2-9b-it"></option>
28360                <option value="deepseek-r1-distill-llama-70b"></option>
28361              </datalist>
28362              <p class="gs-field-hint">
28363                Pick a curated model or type any Groq-hosted model id.
28364              </p>
28365            </div>
28366
28367            <div class="gs-actions-row">
28368              <button type="button" id="gsTestGroq" class="gs-test-btn">
28369                Test connection
28370              </button>
28371              <span id="gsTestGroqResult" class="gs-test-result"></span>
28372            </div>
28373          </div>
28374        </section>
28375      </div>
28376
28377      <footer class="gs-foot">
28378        Changes save automatically. AI config is shared across chat, mindmap,
28379        ontology and summary.
28380        <div style="margin-top: 12px; font-size: 11px; opacity: 0.5">
28381          Built by
28382          <a
28383            href="https://anees.xyz"
28384            target="_blank"
28385            rel="noopener"
28386            style="color: inherit; text-decoration: underline"
28387            >Anees</a
28388          >
28389        </div>
28390      </footer>
28391    </aside>
28392
28393    
28393<script>
28394      /* ============================================================
28395       Global Settings Drawer — AI provider config, unified across
28396       chat, mindmap, ontology and summary features.
28397       ============================================================ */
28398      (function () {
28399        "use strict";
28400
28401        const drawer = document.getElementById("globalSettingsDrawer");
28402        const overlay = document.getElementById("globalSettingsOverlay");
28403        const openBtn = document.getElementById("globalSettingsBtn");
28404        const closeBtn = document.getElementById("globalSettingsCloseBtn");
28405
28406        const statusChip = document.getElementById("gsStatusChip");
28407        const statusText = document.getElementById("gsStatusText");
28408
28409        const segOllama = document.getElementById("gsSegOllama");
28410        const segGroq = document.getElementById("gsSegGroq");
28411        const cardOllama = document.getElementById("gsCardOllama");
28412        const cardGroq = document.getElementById("gsCardGroq");
28413
28414        const gsOllamaEndpoint = document.getElementById("gsOllamaEndpoint");
28415        const gsOllamaModel = document.getElementById("gsOllamaModel");
28416        const gsGroqApiKey = document.getElementById("gsGroqApiKey");
28417        const gsGroqModel = document.getElementById("gsGroqModel");
28418
28419        const activateOllamaBtn = document.getElementById("gsActivateOllama");
28420        const activateGroqBtn = document.getElementById("gsActivateGroq");
28421        const inUseOllamaBadge = document.getElementById("gsOllamaInUse");
28422        const inUseGroqBadge = document.getElementById("gsGroqInUse");
28423
28424        // The drawer tracks two separate concepts: which provider card
28425        // is currently being VIEWED (purely a navigation state inside
28426        // the drawer) and which provider is ACTIVE (the real app
28427        // config, read from the legacy hidden input). Keeping them
28428        // separate lets a user peek at Groq's settings without
28429        // inadvertently switching their active AI.
28430        let viewedProvider = "ollama";
28431
28432        const revealBtn = document.getElementById("gsGroqRevealBtn");
28433        const revealIconShow = document.getElementById("gsRevealIconShow");
28434        const revealIconHide = document.getElementById("gsRevealIconHide");
28435
28436        const testOllamaBtn = document.getElementById("gsTestOllama");
28437        const testOllamaResult = document.getElementById("gsTestOllamaResult");
28438        const testGroqBtn = document.getElementById("gsTestGroq");
28439        const testGroqResult = document.getElementById("gsTestGroqResult");
28440
28441        // Legacy inline panel inputs — the existing chat/mindmap/ontology
28442        // code reads from these. The drawer mirrors values both ways so
28443        // we don't have to touch any downstream call sites.
28444        const legacyProvider = document.getElementById("llmProvider");
28445        const legacyEndpoint = document.getElementById("ollamaEndpoint");
28446        const legacyOllamaModel = document.getElementById("ollamaModel");
28447        const legacyGroqKey = document.getElementById("groqApiKey");
28448        const legacyGroqModel = document.getElementById("groqModel");
28449
28450        if (!drawer || !openBtn || !legacyProvider) return;
28451
28452        let lastFocused = null;
28453        let returnFocusEl = null;
28454
28455        function currentProvider() {
28456          return legacyProvider.value === "groq" ? "groq" : "ollama";
28457        }
28458
28459        function fireLegacyInput(el) {
28460          // The existing chat IIFE listens for 'change' + 'input' on the
28461          // legacy inputs to persist LLM settings. Dispatching these
28462          // events lets the mirror update re-run its save pipeline.
28463          el.dispatchEvent(new Event("input", { bubbles: true }));
28464          el.dispatchEvent(new Event("change", { bubbles: true }));
28465        }
28466
28467        function pullFromLegacy() {
28468          gsOllamaEndpoint.value = legacyEndpoint.value || "";
28469          gsOllamaModel.value = legacyOllamaModel.value || "";
28470          gsGroqApiKey.value = legacyGroqKey.value || "";
28471          gsGroqModel.value = legacyGroqModel.value || "";
28472          // When the drawer opens, default the viewed card to the
28473          // provider that's actually active so the user lands on
28474          // what they're using right now.
28475          viewedProvider = currentProvider();
28476          renderAll();
28477        }
28478
28479        function pushProvider(provider) {
28480          if (legacyProvider.value !== provider) {
28481            legacyProvider.value = provider;
28482            fireLegacyInput(legacyProvider);
28483          }
28484        }
28485
28486        function pushField(legacyEl, value) {
28487          if (legacyEl.value !== value) {
28488            legacyEl.value = value;
28489            fireLegacyInput(legacyEl);
28490          }
28491        }
28492
28493        function isProviderReady(provider) {
28494          if (provider === "groq") {
28495            return Boolean(
28496              gsGroqApiKey.value.trim() && gsGroqModel.value.trim(),
28497            );
28498          }
28499          return Boolean(
28500            gsOllamaEndpoint.value.trim() && gsOllamaModel.value.trim(),
28501          );
28502        }
28503
28504        function updateSegmented() {
28505          const isGroq = viewedProvider === "groq";
28506          segOllama.setAttribute("aria-pressed", String(!isGroq));
28507          segGroq.setAttribute("aria-pressed", String(isGroq));
28508          cardOllama.hidden = isGroq;
28509          cardGroq.hidden = !isGroq;
28510          // Restart the card intro animation so the swap feels deliberate.
28511          const activeCard = isGroq ? cardGroq : cardOllama;
28512          activeCard.style.animation = "none";
28513          // eslint-disable-next-line no-unused-expressions
28514          activeCard.offsetHeight;
28515          activeCard.style.animation = "";
28516        }
28517
28518        function updateActivationUI() {
28519          // Show the "In use" badge on whichever card is the active
28520          // provider, and the "Use X" button on the other. Both
28521          // controls live in the same slot so they never coexist.
28522          const active = currentProvider();
28523          const ollamaActive = active === "ollama";
28524          inUseOllamaBadge.hidden = !ollamaActive;
28525          activateOllamaBtn.hidden = ollamaActive;
28526          inUseGroqBadge.hidden = ollamaActive;
28527          activateGroqBtn.hidden = !ollamaActive;
28528          // Disable activate buttons until the target provider has
28529          // the minimum config it needs to actually run.
28530          activateOllamaBtn.disabled = !isProviderReady("ollama");
28531          activateGroqBtn.disabled = !isProviderReady("groq");
28532          activateOllamaBtn.title = activateOllamaBtn.disabled
28533            ? "Add endpoint and model first"
28534            : "Switch the app to Ollama";
28535          activateGroqBtn.title = activateGroqBtn.disabled
28536            ? "Add an API key first"
28537            : "Switch the app to Groq";
28538        }
28539
28540        function updateStatus() {
28541          // The chip ALWAYS describes the active provider, regardless
28542          // of which card is currently being viewed. That's the whole
28543          // point of separating viewed from active: the chip is the
28544          // single source of truth for "what is this app using?".
28545          const active = currentProvider();
28546          const ready = isProviderReady(active);
28547          let label;
28548          if (active === "groq") {
28549            label = ready ? "Groq is ready" : "Groq needs an API key";
28550          } else {
28551            label = ready
28552              ? "Ollama is ready"
28553              : "Ollama needs endpoint and model";
28554          }
28555          statusChip.classList.toggle("is-ready", ready);
28556          statusText.textContent = label;
28557        }
28558
28559        function renderAll() {
28560          updateSegmented();
28561          updateActivationUI();
28562          updateStatus();
28563        }
28564
28565        // ---- Segmented control: navigation only, no commit ----
28566        function viewProvider(provider) {
28567          viewedProvider = provider;
28568          renderAll();
28569        }
28570        segOllama.addEventListener("click", function () {
28571          viewProvider("ollama");
28572        });
28573        segGroq.addEventListener("click", function () {
28574          viewProvider("groq");
28575        });
28576
28577        // ---- Activation: the only thing that flips the active provider ----
28578        function activateProvider(provider) {
28579          if (!isProviderReady(provider)) return;
28580          pushProvider(provider);
28581          viewedProvider = provider;
28582          renderAll();
28583        }
28584        activateOllamaBtn.addEventListener("click", function () {
28585          activateProvider("ollama");
28586        });
28587        activateGroqBtn.addEventListener("click", function () {
28588          activateProvider("groq");
28589        });
28590
28591        // ---- Field mirroring ----
28592        function bindMirror(drawerEl, legacyEl) {
28593          drawerEl.addEventListener("input", function () {
28594            pushField(legacyEl, drawerEl.value);
28595            renderAll();
28596          });
28597          // If something else in the app edits the legacy input, reflect it
28598          // back into the drawer on next open.
28599          legacyEl.addEventListener("input", function () {
28600            if (drawerEl.value !== legacyEl.value) {
28601              drawerEl.value = legacyEl.value;
28602              renderAll();
28603            }
28604          });
28605        }
28606        bindMirror(gsOllamaEndpoint, legacyEndpoint);
28607        bindMirror(gsOllamaModel, legacyOllamaModel);
28608        bindMirror(gsGroqApiKey, legacyGroqKey);
28609        bindMirror(gsGroqModel, legacyGroqModel);
28610
28611        // ---- Reveal toggle for API key ----
28612        revealBtn.addEventListener("click", function () {
28613          const showing = gsGroqApiKey.type === "text";
28614          gsGroqApiKey.type = showing ? "password" : "text";
28615          revealBtn.setAttribute("aria-pressed", String(!showing));
28616          revealBtn.setAttribute(
28617            "aria-label",
28618            showing ? "Show API key" : "Hide API key",
28619          );
28620          revealIconShow.style.display = showing ? "" : "none";
28621          revealIconHide.style.display = showing ? "none" : "";
28622        });
28623
28624        // ---- Test connection ----
28625        function setTestResult(el, state, text) {
28626          el.classList.remove("is-ok", "is-err");
28627          if (state) el.classList.add(state);
28628          el.textContent = text;
28629        }
28630
28631        testOllamaBtn.addEventListener("click", async function () {
28632          const endpoint = gsOllamaEndpoint.value.trim();
28633          if (!endpoint) {
28634            setTestResult(
28635              testOllamaResult,
28636              "is-err",
28637              "Enter an endpoint first",
28638            );
28639            return;
28640          }
28641          testOllamaBtn.disabled = true;
28642          setTestResult(testOllamaResult, null, "Testing…");
28643          try {
28644            const base = endpoint.replace(/\/+$/, "");
28645            const res = await fetch(base + "/api/tags", { method: "GET" });
28646            if (!res.ok) throw new Error("HTTP " + res.status);
28647            setTestResult(testOllamaResult, "is-ok", "✓ Reachable");
28648          } catch (err) {
28649            setTestResult(
28650              testOllamaResult,
28651              "is-err",
28652              "✗ " + (err && err.message ? err.message : "Unreachable"),
28653            );
28654          } finally {
28655            testOllamaBtn.disabled = false;
28656          }
28657        });
28658
28659        testGroqBtn.addEventListener("click", async function () {
28660          const key = gsGroqApiKey.value.trim();
28661          const model = gsGroqModel.value.trim() || "llama-3.1-8b-instant";
28662          if (!key) {
28663            setTestResult(testGroqResult, "is-err", "Enter an API key first");
28664            return;
28665          }
28666          testGroqBtn.disabled = true;
28667          setTestResult(testGroqResult, null, "Testing…");
28668          try {
28669            const res = await fetch(
28670              "https://api.groq.com/openai/v1/chat/completions",
28671              {
28672                method: "POST",
28673                headers: {
28674                  "Content-Type": "application/json",
28675                  Authorization: "Bearer " + key,
28676                },
28677                body: JSON.stringify({
28678                  model: model,
28679                  messages: [{ role: "user", content: "ping" }],
28680                  max_tokens: 1,
28681                }),
28682              },
28683            );
28684            if (res.status === 401) {
28685              setTestResult(testGroqResult, "is-err", "✗ Invalid API key");
28686            } else if (!res.ok) {
28687              const body = await res.text().catch(function () {
28688                return "";
28689              });
28690              setTestResult(
28691                testGroqResult,
28692                "is-err",
28693                "✗ HTTP " + res.status + (body ? " — check model id" : ""),
28694              );
28695            } else {
28696              setTestResult(testGroqResult, "is-ok", "✓ Key + model valid");
28697            }
28698          } catch (err) {
28699            setTestResult(
28700              testGroqResult,
28701              "is-err",
28702              "✗ " + (err && err.message ? err.message : "Network error"),
28703            );
28704          } finally {
28705            testGroqBtn.disabled = false;
28706          }
28707        });
28708
28709        // ---- Open / close ----
28710        function getFocusableInside(root) {
28711          return Array.prototype.slice
28712            .call(
28713              root.querySelectorAll(
28714                'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
28715              ),
28716            )
28717            .filter(function (el) {
28718              return !el.hidden && el.offsetParent !== null;
28719            });
28720        }
28721
28722        function openDrawer(opts) {
28723          const options = opts || {};
28724          returnFocusEl = options.returnFocusTo || openBtn;
28725          lastFocused =
28726            document.activeElement instanceof HTMLElement
28727              ? document.activeElement
28728              : null;
28729          pullFromLegacy();
28730          drawer.classList.add("is-open");
28731          overlay.classList.add("is-open");
28732          drawer.setAttribute("aria-hidden", "false");
28733          overlay.setAttribute("aria-hidden", "false");
28734          openBtn.setAttribute("aria-expanded", "true");
28735          // Clear any stale test results from the last session.
28736          setTestResult(testOllamaResult, null, "");
28737          setTestResult(testGroqResult, null, "");
28738          // Focus the first meaningful control (the active segment).
28739          window.requestAnimationFrame(function () {
28740            const first = currentProvider() === "groq" ? segGroq : segOllama;
28741            first.focus();
28742          });
28743        }
28744
28745        function closeDrawer() {
28746          drawer.classList.remove("is-open");
28747          overlay.classList.remove("is-open");
28748          drawer.setAttribute("aria-hidden", "true");
28749          overlay.setAttribute("aria-hidden", "true");
28750          openBtn.setAttribute("aria-expanded", "false");
28751          const target = returnFocusEl || lastFocused || openBtn;
28752          if (target && typeof target.focus === "function") target.focus();
28753        }
28754
28755        openBtn.addEventListener("click", function () {
28756          openDrawer({ returnFocusTo: openBtn });
28757        });
28758        closeBtn.addEventListener("click", closeDrawer);
28759        overlay.addEventListener("click", closeDrawer);
28760
28761        // ESC + focus trap
28762        drawer.addEventListener("keydown", function (e) {
28763          if (e.key === "Escape") {
28764            e.stopPropagation();
28765            closeDrawer();
28766            return;
28767          }
28768          if (e.key !== "Tab") return;
28769          const focusables = getFocusableInside(drawer);
28770          if (!focusables.length) return;
28771          const first = focusables[0];
28772          const last = focusables[focusables.length - 1];
28773          if (e.shiftKey && document.activeElement === first) {
28774            e.preventDefault();
28775            last.focus();
28776          } else if (!e.shiftKey && document.activeElement === last) {
28777            e.preventDefault();
28778            first.focus();
28779          }
28780        });
28781
28782        // Expose for the chat panel's legacy settings button.
28783        window.__openGlobalSettings = openDrawer;
28784
28785        // Initial hydration so status chip reflects saved state even
28786        // before the drawer has ever been opened.
28787        pullFromLegacy();
28788      })();
28789    </script>
28789
28790
28791    
28791<script>
28792      /* ============================================================
28793     Document Q&A — RAG with Ollama or Groq
28794     ============================================================ */
28795      (function () {
28796        "use strict";
28797
28798        // ---- DOM refs ----
28799        const fab = document.getElementById("chatFab");
28800        const panel = document.getElementById("chatPanel");
28801        const closeBtn = document.getElementById("chatCloseBtn");
28802        const settingsBtn = document.getElementById("chatSettingsBtn");
28803        const settingsDiv = document.getElementById("chatSettings");
28804        const messagesDiv = document.getElementById("chatMessages");
28805        const chatLiveRegion = document.getElementById("chatLiveRegion");
28806        const chatInput = document.getElementById("chatInput");
28807        const sendBtn = document.getElementById("chatSendBtn");
28808        const providerInput = document.getElementById("llmProvider");
28809        const endpointInput = document.getElementById("ollamaEndpoint");
28810        const ollamaModelInput = document.getElementById("ollamaModel");
28811        const groqApiKeyInput = document.getElementById("groqApiKey");
28812        const groqModelInput = document.getElementById("groqModel");
28813        const ollamaEndpointField = document.getElementById(
28814          "ollamaEndpointField",
28815        );
28816        const ollamaModelField = document.getElementById("ollamaModelField");
28817        const groqApiKeyField = document.getElementById("groqApiKeyField");
28818        const groqModelField = document.getElementById("groqModelField");
28819        const CHAT_HISTORY_LIMIT = 8;
28820        const HISTORY_CHAR_LIMIT = 1200;
28821        const CHAT_MESSAGE_DOM_LIMIT = 40;
28822        const chatHistory = [];
28823        let lastDocumentFingerprint = "";
28824        let lastFocusedElement = null;
28825
28826        // ---- AI config ----
28827        const LLM_SETTINGS_KEY = "md-viewer-llm";
28828        const LEGACY_OLLAMA_SETTINGS_KEY = "md-viewer-ollama";
28829        const LEGACY_GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile";
28830        const DEFAULT_LLM_SETTINGS = {
28831          provider: "ollama",
28832          endpoint: "http://localhost:11434",
28833          ollamaModel: "gemma4:26b",
28834          groqApiKey: "",
28835          groqModel: "qwen/qwen3-32b",
28836        };
28837        function readChatPreference(key, fallback) {
28838          try {
28839            const value = localStorage.getItem(key);
28840            return value === null ? fallback : value;
28841          } catch (_error) {
28842            return fallback;
28843          }
28844        }
28845        function writeChatPreference(key, value) {
28846          try {
28847            localStorage.setItem(key, value);
28848          } catch (_error) {}
28849        }
28850        function normalizeLlmProvider(value) {
28851          return value === "groq" ? "groq" : "ollama";
28852        }
28853        function readStoredLlmSettings() {
28854          let saved = null;
28855          try {
28856            saved = JSON.parse(readChatPreference(LLM_SETTINGS_KEY, "null"));
28857          } catch (_error) {
28858            saved = null;
28859          }
28860          if (!saved || typeof saved !== "object") {
28861            try {
28862              const legacy = JSON.parse(
28863                readChatPreference(LEGACY_OLLAMA_SETTINGS_KEY, "null"),
28864              );
28865              if (legacy && typeof legacy === "object") {
28866                saved = {
28867                  provider: "ollama",
28868                  endpoint: legacy.endpoint || "",
28869                  ollamaModel: legacy.model || "",
28870                };
28871              }
28872            } catch (_error) {
28873              saved = null;
28874            }
28875          }
28876          return Object.assign({}, DEFAULT_LLM_SETTINGS, saved || {}, {
28877            provider: normalizeLlmProvider(saved && saved.provider),
28878          });
28879        }
28880        function migrateLlmSettings(settings) {
28881          const next = Object.assign({}, DEFAULT_LLM_SETTINGS, settings || {});
28882          if (
28883            next.provider === "groq" &&
28884            (!next.groqModel || next.groqModel === LEGACY_GROQ_DEFAULT_MODEL)
28885          ) {
28886            next.groqModel = DEFAULT_LLM_SETTINGS.groqModel;
28887          }
28888          return next;
28889        }
28890        function getCurrentLlmSettings() {
28891          return {
28892            provider: normalizeLlmProvider(providerInput.value),
28893            endpoint: endpointInput.value.trim(),
28894            ollamaModel: ollamaModelInput.value.trim(),
28895            groqApiKey: groqApiKeyInput.value.trim(),
28896            groqModel: groqModelInput.value.trim(),
28897          };
28898        }
28899        function applyLlmSettings(settings) {
28900          const next = migrateLlmSettings(settings);
28901          providerInput.value = normalizeLlmProvider(next.provider);
28902          endpointInput.value = next.endpoint || DEFAULT_LLM_SETTINGS.endpoint;
28903          ollamaModelInput.value =
28904            next.ollamaModel || DEFAULT_LLM_SETTINGS.ollamaModel;
28905          groqApiKeyInput.value = next.groqApiKey || "";
28906          groqModelInput.value =
28907            next.groqModel || DEFAULT_LLM_SETTINGS.groqModel;
28908        }
28909        function syncLlmSettingsUi() {
28910          const provider = normalizeLlmProvider(providerInput.value);
28911          const showOllama = provider === "ollama";
28912          ollamaEndpointField.hidden = !showOllama;
28913          ollamaModelField.hidden = !showOllama;
28914          groqApiKeyField.hidden = showOllama;
28915          groqModelField.hidden = showOllama;
28916        }
28917        function saveLlmSettings() {
28918          const settings = getCurrentLlmSettings();
28919          writeChatPreference(LLM_SETTINGS_KEY, JSON.stringify(settings));
28920          writeChatPreference(
28921            LEGACY_OLLAMA_SETTINGS_KEY,
28922            JSON.stringify({
28923              endpoint: settings.endpoint,
28924              model: settings.ollamaModel,
28925            }),
28926          );
28927          syncLlmSettingsUi();
28928        }
28929        function hasConfiguredLlm() {
28930          const settings = getCurrentLlmSettings();
28931          if (settings.provider === "groq") {
28932            return Boolean(settings.groqApiKey && settings.groqModel);
28933          }
28934          return Boolean(settings.endpoint && settings.ollamaModel);
28935        }
28936        applyLlmSettings(migrateLlmSettings(readStoredLlmSettings()));
28937        syncLlmSettingsUi();
28938        saveLlmSettings();
28939        [
28940          providerInput,
28941          endpointInput,
28942          ollamaModelInput,
28943          groqApiKeyInput,
28944          groqModelInput,
28945        ].forEach(function (input) {
28946          input.addEventListener("change", saveLlmSettings);
28947          input.addEventListener("input", function () {
28948            if (input === providerInput) return;
28949            syncLlmSettingsUi();
28950          });
28951        });
28952
28953        function isLoopbackHostname(hostname) {
28954          const normalized = String(hostname || "")
28955            .replace(/^\[|\]$/g, "")
28956            .toLowerCase();
28957          return (
28958            normalized === "localhost" ||
28959            normalized === "127.0.0.1" ||
28960            normalized === "::1"
28961          );
28962        }
28963
28964        function normalizeOllamaEndpoint(rawValue) {
28965          let parsed;
28966          try {
28967            parsed = new URL(String(rawValue || "").trim());
28968          } catch (_error) {
28969            throw new Error(
28970              "Enter a valid local Ollama endpoint, such as http://localhost:11434",
28971            );
28972          }
28973
28974          if (
28975            !/^https?:$/.test(parsed.protocol) ||
28976            !isLoopbackHostname(parsed.hostname)
28977          ) {
28978            throw new Error(
28979              "Privacy mode only allows local Ollama endpoints on localhost, 127.0.0.1, or ::1",
28980            );
28981          }
28982
28983          return parsed.toString().replace(/\/+$/, "");
28984        }
28985
28986        window.markvHasConfiguredLlm = hasConfiguredLlm;
28987        window.markvGetLlmSettings = getCurrentLlmSettings;
28988
28989        function escapeChatHtml(value) {
28990          const div = document.createElement("div");
28991          div.textContent = String(value == null ? "" : value);
28992          return div.innerHTML;
28993        }
28994
28995        function cleanChatUrl(value) {
28996          const raw = String(value || "").trim();
28997          if (!raw) return "";
28998          try {
28999            const parsed = new URL(raw, window.location.href);
29000            if (/^(javascript|vbscript|data):$/i.test(parsed.protocol)) {
29001              return "";
29002            }
29003            return parsed.toString();
29004          } catch (_error) {
29005            return "";
29006          }
29007        }
29008
29009        function resolveChatUrl(href) {
29010          const cleaned = cleanChatUrl(href);
29011          if (!cleaned) return null;
29012          try {
29013            return new URL(cleaned, window.location.href);
29014          } catch (_error) {
29015            return null;
29016          }
29017        }
29018
29019        function isPrivateChatUrl(url) {
29020          if (!url) return false;
29021          if (url.origin === window.location.origin) return true;
29022          return (
29023            (url.protocol === "http:" || url.protocol === "https:") &&
29024            isLoopbackHostname(url.hostname)
29025          );
29026        }
29027
29028        function resolveChatLinkHref(href) {
29029          const resolved = resolveChatUrl(href);
29030          if (resolved) return resolved.toString();
29031          return cleanChatUrl(href);
29032        }
29033
29034        function resolveChatImageHref(href) {
29035          const resolved = resolveChatUrl(href);
29036          if (!resolved || !isPrivateChatUrl(resolved)) return "";
29037          return resolved.toString();
29038        }
29039
29040        const chatRenderer = new marked.Renderer();
29041
29042        chatRenderer.html = function (html) {
29043          return (
29044            '<pre class="raw-html"><code>' +
29045            escapeChatHtml(html) +
29046            "</code></pre>\n"
29047          );
29048        };
29049
29050        chatRenderer.link = function (href, title, text) {
29051          const resolved = resolveChatLinkHref(href);
29052          if (!resolved) return text;
29053          let out = '<a href="' + escapeChatHtml(resolved) + '"';
29054          if (title) out += ' title="' + escapeChatHtml(title) + '"';
29055          out += ' referrerpolicy="no-referrer"';
29056          out += ">" + text + "</a>";
29057          return out;
29058        };
29059
29060        chatRenderer.image = function (href, title, text) {
29061          const resolved = resolveChatImageHref(href);
29062          if (!resolved) {
29063            return (
29064              '<span class="blocked-resource">Remote image blocked for privacy' +
29065              (text ? ": " + escapeChatHtml(text) : ".") +
29066              "</span>"
29067            );
29068          }
29069          let out =
29070            '<img src="' +
29071            escapeChatHtml(resolved) +
29072            '" alt="' +
29073            escapeChatHtml(text || "") +
29074            '"';
29075          if (title) out += ' title="' + escapeChatHtml(title) + '"';
29076          out +=
29077            ' loading="lazy" decoding="async" referrerpolicy="no-referrer">';
29078          return out;
29079        };
29080
29081        const chatMarkedOptions = {
29082          gfm: true,
29083          breaks: true,
29084          mangle: false,
29085          headerIds: false,
29086          smartLists: true,
29087          renderer: chatRenderer,
29088        };
29089
29090        function decorateChatMessageContent(container) {
29091          if (!container) return;
29092          container
29093            .querySelectorAll('pre > code[class*="language-"]')
29094            .forEach(function (code) {
29095              const match = code.className.match(/language-([a-z0-9_+.-]+)/i);
29096              if (match && code.parentElement) {
29097                code.parentElement.setAttribute("data-lang", match[1]);
29098              }
29099            });
29100          container.querySelectorAll("a[href]").forEach(function (link) {
29101            const href = link.getAttribute("href") || "";
29102            link.referrerPolicy = "no-referrer";
29103            if (/^(https?:)?\/\//i.test(href)) {
29104              link.target = "_blank";
29105              link.rel = "noreferrer noopener";
29106            }
29107          });
29108        }
29109
29110        function renderChatMarkdown(text) {
29111          const source = String(text || "");
29112          if (!source.trim()) return "";
29113          try {
29114            return marked.parse(source, chatMarkedOptions);
29115          } catch (error) {
29116            return (
29117              "<pre><code>" +
29118              escapeChatHtml(
29119                String(error && error.message ? error.message : error),
29120              ) +
29121              "</code></pre>"
29122            );
29123          }
29124        }
29125
29126        function setAssistantMessageContent(messageEl, text) {
29127          if (!messageEl) return;
29128          messageEl.classList.add("markdown-rendered");
29129          messageEl.innerHTML = renderChatMarkdown(text);
29130          decorateChatMessageContent(messageEl);
29131        }
29132
29133        const pendingAssistantRenders = new WeakMap();
29134
29135        function scheduleAssistantMessageContent(messageEl, text) {
29136          if (!messageEl) return;
29137
29138          let pending = pendingAssistantRenders.get(messageEl);
29139          if (!pending) {
29140            pending = {
29141              text: "",
29142              frameId: 0,
29143            };
29144            pendingAssistantRenders.set(messageEl, pending);
29145          }
29146
29147          pending.text = text;
29148          if (pending.frameId) return;
29149
29150          pending.frameId = window.requestAnimationFrame(function () {
29151            pending.frameId = 0;
29152            setAssistantMessageContent(messageEl, pending.text);
29153          });
29154        }
29155
29156        function flushAssistantMessageContent(messageEl, text) {
29157          if (!messageEl) return;
29158
29159          const pending = pendingAssistantRenders.get(messageEl);
29160          if (pending) {
29161            pending.text = text;
29162            if (pending.frameId) {
29163              window.cancelAnimationFrame(pending.frameId);
29164              pending.frameId = 0;
29165            }
29166          }
29167
29168          setAssistantMessageContent(messageEl, text);
29169        }
29170
29171        function getFocusableElements(container) {
29172          return Array.from(
29173            container.querySelectorAll(
29174              'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
29175            ),
29176          ).filter(function (element) {
29177            if (element.hidden) return false;
29178            if (element.closest("[hidden]")) return false;
29179            if (element.getAttribute("aria-hidden") === "true") return false;
29180            return element.offsetParent !== null;
29181          });
29182        }
29183
29184        function announceChatUpdate(text) {
29185          if (!chatLiveRegion) return;
29186          chatLiveRegion.textContent = "";
29187          window.requestAnimationFrame(function () {
29188            chatLiveRegion.textContent = trimForHistory(text, 220);
29189          });
29190        }
29191
29192        function setSettingsOpen(isOpen) {
29193          settingsDiv.classList.toggle("hidden", !isOpen);
29194          settingsDiv.hidden = !isOpen;
29195          settingsDiv.setAttribute("aria-hidden", String(!isOpen));
29196          settingsBtn.setAttribute("aria-expanded", String(isOpen));
29197        }
29198
29199        function showChatPanel(options) {
29200          const opts = options || {};
29201          const shouldFocusInput = opts.focusInput !== false;
29202          if (panel.hidden) {
29203            lastFocusedElement =
29204              document.activeElement instanceof HTMLElement
29205                ? document.activeElement
29206                : null;
29207          }
29208          panel.classList.remove("hidden");
29209          panel.hidden = false;
29210          panel.setAttribute("aria-hidden", "false");
29211          fab.classList.add("hidden");
29212          fab.setAttribute("aria-hidden", "true");
29213          document.body.classList.add("chat-open");
29214          if (shouldFocusInput) {
29215            chatInput.focus();
29216          }
29217        }
29218
29219        function hideChatPanel() {
29220          panel.classList.add("hidden");
29221          panel.hidden = true;
29222          panel.setAttribute("aria-hidden", "true");
29223          setSettingsOpen(false);
29224          document.body.classList.remove("chat-open");
29225          fab.classList.remove("hidden");
29226          fab.removeAttribute("aria-hidden");
29227          if (
29228            lastFocusedElement &&
29229            typeof lastFocusedElement.focus === "function"
29230          ) {
29231            lastFocusedElement.focus();
29232          } else {
29233            fab.focus();
29234          }
29235        }
29236
29237        // ---- Chat panel toggle ----
29238        fab.addEventListener("click", function () {
29239          showChatPanel();
29240        });
29241        closeBtn.addEventListener("click", function () {
29242          hideChatPanel();
29243        });
29244        settingsBtn.addEventListener("click", function () {
29245          // Old inline panel is retired. The chat gear now opens the
29246          // global settings drawer, scrolled to the AI Provider section.
29247          if (typeof window.__openGlobalSettings === "function") {
29248            window.__openGlobalSettings({ returnFocusTo: settingsBtn });
29249          }
29250        });
29251        panel.addEventListener("keydown", function (e) {
29252          if (e.key !== "Tab" || panel.hidden) return;
29253          const focusable = getFocusableElements(panel);
29254          if (!focusable.length) return;
29255          const first = focusable[0];
29256          const last = focusable[focusable.length - 1];
29257
29258          if (e.shiftKey && document.activeElement === first) {
29259            e.preventDefault();
29260            last.focus();
29261          } else if (!e.shiftKey && document.activeElement === last) {
29262            e.preventDefault();
29263            first.focus();
29264          }
29265        });
29266
29267        // ---- Chat input ----
29268        function syncChatInputUi() {
29269          sendBtn.disabled = !chatInput.value.trim();
29270          chatInput.style.height = "auto";
29271          chatInput.style.height = Math.min(chatInput.scrollHeight, 140) + "px";
29272        }
29273
29274        chatInput.addEventListener("input", function () {
29275          syncChatInputUi();
29276        });
29277        chatInput.addEventListener("keydown", function (e) {
29278          if (e.key === "Enter" && !e.shiftKey) {
29279            e.preventDefault();
29280            if (!sendBtn.disabled) sendBtn.click();
29281          }
29282        });
29283
29284        // ---- Message helpers ----
29285        function pruneChatMessageDom(keepNode) {
29286          const nodes = Array.from(messagesDiv.querySelectorAll(".chat-msg"));
29287          let overflow = nodes.length - CHAT_MESSAGE_DOM_LIMIT;
29288          if (overflow <= 0) return;
29289
29290          for (const node of nodes) {
29291            if (overflow <= 0) break;
29292            if (node === keepNode) continue;
29293            node.remove();
29294            overflow -= 1;
29295          }
29296        }
29297
29298        function appendChatMessage(div) {
29299          messagesDiv.appendChild(div);
29300          pruneChatMessageDom(div);
29301          messagesDiv.scrollTop = messagesDiv.scrollHeight;
29302          return div;
29303        }
29304
29305        function addMessage(text, role) {
29306          const div = document.createElement("div");
29307          div.className = "chat-msg " + role;
29308          if (role === "assistant") {
29309            setAssistantMessageContent(div, text);
29310            div.setAttribute("aria-label", "Assistant reply");
29311          } else if (role === "user") {
29312            div.textContent = text;
29313            div.setAttribute("aria-label", "Your message");
29314          } else {
29315            div.textContent = text;
29316          }
29317          return appendChatMessage(div);
29318        }
29319        function addTyping() {
29320          const div = document.createElement("div");
29321          div.className = "chat-msg assistant";
29322          div.innerHTML = '<span class="typing-dots">Thinking</span>';
29323          return appendChatMessage(div);
29324        }
29325
29326        function trimForHistory(text, maxChars) {
29327          const clean = (text || "").trim().replace(/\s+/g, " ");
29328          if (clean.length <= maxChars) return clean;
29329          return clean.slice(0, maxChars - 1) + "…";
29330        }
29331
29332        function addHistory(role, text) {
29333          if (role !== "user" && role !== "assistant") return;
29334          chatHistory.push({
29335            role: role,
29336            content: trimForHistory(text, HISTORY_CHAR_LIMIT),
29337          });
29338          if (chatHistory.length > CHAT_HISTORY_LIMIT) {
29339            chatHistory.splice(0, chatHistory.length - CHAT_HISTORY_LIMIT);
29340          }
29341        }
29342
29343        function getRecentHistory() {
29344          return chatHistory.slice();
29345        }
29346
29347        function getDocumentText() {
29348          const preview = document.getElementById("preview");
29349          return (preview && preview.innerText) || "";
29350        }
29351
29352        function getDocumentFingerprint() {
29353          const label = getDocumentLabel();
29354          const text = getDocumentText();
29355          return label + "::" + text.length + "::" + text.slice(0, 400);
29356        }
29357
29358        function syncHistoryWithCurrentDocument() {
29359          const fingerprint = getDocumentFingerprint();
29360          if (fingerprint !== lastDocumentFingerprint) {
29361            chatHistory.length = 0;
29362            lastDocumentFingerprint = fingerprint;
29363          }
29364        }
29365
29366        function getDocumentLabel() {
29367          const preview = document.getElementById("preview");
29368          const label = preview ? preview.dataset.documentLabel || "" : "";
29369          return label.trim() || "current document";
29370        }
29371
29372        function isGreetingMessage(text) {
29373          const normalized = text.trim().toLowerCase();
29374          return /^(hi|hiya|hello|hey|yo|good morning|good afternoon|good evening|sup|what'?s up|howdy)[!. ]*$/.test(
29375            normalized,
29376          );
29377        }
29378
29379        function isThanksMessage(text) {
29380          const normalized = text.trim().toLowerCase();
29381          return /^(thanks|thank you|thx|ty|cheers|nice one)[!. ]*$/.test(
29382            normalized,
29383          );
29384        }
29385
29386        function isCapabilitiesMessage(text) {
29387          const normalized = text.trim().toLowerCase();
29388          return /^(help|what can you do|how can you help|what do you do|how does this work|what can i ask|who are you)[?.! ]*$/.test(
29389            normalized,
29390          );
29391        }
29392
29393        function isBroadDocumentRequest(text) {
29394          const normalized = text.trim().toLowerCase();
29395          return /(summari[sz]e|summary|overview|main idea|main ideas|key points|takeaways|what is this document about|what is this about|tl;dr|tldr|abstract|outline)/.test(
29396            normalized,
29397          );
29398        }
29399
29400        function looksLikeDocumentTask(text) {
29401          const normalized = text.trim().toLowerCase();
29402          return /(document|file|text|section|chapter|paragraph|excerpt|passage|summary|summari[sz]e|explain|what does|where does|find|list|extract|compare|key points|main idea|quote)/.test(
29403            normalized,
29404          );
29405        }
29406
29407        function getLocalAssistantReply(question, hasDocument) {
29408          if (isGreetingMessage(question)) {
29409            return hasDocument
29410              ? "Hi. I can answer questions about the loaded document, summarize sections, explain passages, and pull out key points."
29411              : "Hi. Load a document and I can answer questions about it, summarize sections, explain passages, and help find key details.";
29412          }
29413          if (isThanksMessage(question)) {
29414            return hasDocument
29415              ? "You’re welcome. Ask about the document whenever you’re ready."
29416              : "You’re welcome. Load a document whenever you want to start.";
29417          }
29418          if (isCapabilitiesMessage(question)) {
29419            return hasDocument
29420              ? "I can answer questions about this document, summarize it, explain specific sections, compare passages, and help locate important details."
29421              : "I can answer questions about a loaded document, summarize it, explain specific sections, compare passages, and help locate important details.";
29422          }
29423          if (!hasDocument && looksLikeDocumentTask(question)) {
29424            return "No document is loaded yet. Load one first, then ask a question, request a summary, or select a passage to discu
29424ss.";
29425          }
29426          return "";
29427        }
29428
29429        // ---- Simple chunking ----
29430        function chunkText(text, chunkSize, overlap) {
29431          chunkSize = chunkSize || 800;
29432          overlap = overlap || 200;
29433          const chunks = [];
29434          let i = 0;
29435          while (i < text.length) {
29436            chunks.push(text.slice(i, i + chunkSize));
29437            i += chunkSize - overlap;
29438          }
29439          return chunks;
29440        }
29441
29442        // ---- TF-IDF-ish retrieval ----
29443        function tokenize(text) {
29444          return text
29445            .toLowerCase()
29446            .replace(/[^a-z0-9\s]/g, "")
29447            .split(/\s+/)
29448            .filter(function (w) {
29449              return w.length > 2;
29450            });
29451        }
29452
29453        function buildTfIdf(chunks) {
29454          const df = {};
29455          const tfs = [];
29456          const N = chunks.length;
29457          for (let i = 0; i < N; i++) {
29458            const words = tokenize(chunks[i]);
29459            const tf = {};
29460            const seen = {};
29461            for (const w of words) {
29462              tf[w] = (tf[w] || 0) + 1;
29463              if (!seen[w]) {
29464                df[w] = (df[w] || 0) + 1;
29465                seen[w] = true;
29466              }
29467            }
29468            // normalize tf
29469            const maxTf = Math.max(...Object.values(tf), 1);
29470            for (const w in tf) tf[w] = tf[w] / maxTf;
29471            tfs.push(tf);
29472          }
29473          return { tfs, df, N };
29474        }
29475
29476        function scoreChunks(query, chunks, index) {
29477          const qWords = tokenize(query);
29478          const scores = [];
29479          for (let i = 0; i < chunks.length; i++) {
29480            let score = 0;
29481            for (const w of qWords) {
29482              const tf = index.tfs[i][w] || 0;
29483              const idf = Math.log((index.N + 1) / ((index.df[w] || 0) + 1));
29484              score += tf * idf;
29485            }
29486            scores.push({ idx: i, score });
29487          }
29488          scores.sort(function (a, b) {
29489            return b.score - a.score;
29490          });
29491          return scores;
29492        }
29493
29494        function buildRepresentativeContext(chunks, topK) {
29495          const limit = Math.max(1, Math.min(topK || 4, chunks.length));
29496          if (chunks.length <= limit) return chunks.join("\n\n---\n\n");
29497
29498          const chosen = [];
29499          for (let i = 0; i < limit; i++) {
29500            const idx = Math.round(
29501              (i * (chunks.length - 1)) / Math.max(1, limit - 1),
29502            );
29503            if (chosen[chosen.length - 1] !== idx) chosen.push(idx);
29504          }
29505          return chosen
29506            .map(function (idx) {
29507              return chunks[idx];
29508            })
29509            .join("\n\n---\n\n");
29510        }
29511
29512        function getRelevantContext(query, topK) {
29513          topK = topK || 4;
29514          const text = getDocumentText();
29515          if (!text.trim()) {
29516            return {
29517              hasDocument: false,
29518              context: "",
29519              strategy: "none",
29520              strongestScore: 0,
29521            };
29522          }
29523          const chunks = chunkText(text);
29524          if (chunks.length === 0) {
29525            return {
29526              hasDocument: false,
29527              context: "",
29528              strategy: "none",
29529              strongestScore: 0,
29530            };
29531          }
29532          if (isBroadDocumentRequest(query)) {
29533            return {
29534              hasDocument: true,
29535              context: buildRepresentativeContext(chunks, topK),
29536              strategy: "overview",
29537              strongestScore: 1,
29538            };
29539          }
29540
29541          const index = buildTfIdf(chunks);
29542          const ranked = scoreChunks(query, chunks, index);
29543          const top = ranked.slice(0, topK).filter(function (s) {
29544            return s.score > 0;
29545          });
29546          if (top.length === 0) {
29547            return {
29548              hasDocument: true,
29549              context: "",
29550              strategy: "none",
29551              strongestScore: 0,
29552            };
29553          }
29554          return {
29555            hasDocument: true,
29556            context: top
29557              .map(function (s) {
29558                return chunks[s.idx];
29559              })
29560              .join("\n\n---\n\n"),
29561            strategy: "retrieved",
29562            strongestScore: top[0].score,
29563          };
29564        }
29565
29566        // ---- LLM API calls ----
29567        async function buildProviderError(response, providerLabel) {
29568          let message = "";
29569          try {
29570            const payload = await response.clone().json();
29571            message =
29572              (payload &&
29573                payload.error &&
29574                typeof payload.error.message === "string" &&
29575                payload.error.message) ||
29576              (payload &&
29577                typeof payload.message === "string" &&
29578                payload.message) ||
29579              "";
29580          } catch (_error) {}
29581          if (!message) {
29582            message = await response.text().catch(function () {
29583              return "";
29584            });
29585          }
29586          const error = new Error(
29587            providerLabel +
29588              " error " +
29589              response.status +
29590              ": " +
29591              (message || response.statusText),
29592          );
29593          error.status = response.status;
29594          error.provider = providerLabel;
29595          error.retryAfter = response.headers.get("retry-after") || "";
29596          error.rateLimitResetTokens =
29597            response.headers.get("x-ratelimit-reset-tokens") || "";
29598          return error;
29599        }
29600
29601        // Try a single endpoint; returns the fetch Response on success.
29602        async function tryFetch(url, body, headers, providerLabel) {
29603          const response = await fetch(url, {
29604            method: "POST",
29605            headers: Object.assign(
29606              { "Content-Type": "application/json" },
29607              headers || {},
29608            ),
29609            body: JSON.stringify(body),
29610          });
29611          if (!response.ok) {
29612            throw await buildProviderError(response, providerLabel || "LLM");
29613          }
29614          return response;
29615        }
29616
29617        async function callOllama(messages, options) {
29618          const opts = options || {};
29619          const raw = endpointInput.value.trim().replace(/\/+$/, "");
29620          const endpoint = normalizeOllamaEndpoint(raw);
29621          const model =
29622            ollamaModelInput.value.trim() || DEFAULT_LLM_SETTINGS.ollamaModel;
29623          const body = {
29624            model: model,
29625            messages: messages,
29626            stream: opts.stream !== false,
29627          };
29628          if (opts.format) {
29629            body.format = opts.format;
29630          }
29631
29632          // Build a list of endpoints to try.
29633          // Browsers treat "localhost" and "127.0.0.1" as different origins;
29634          // Ollama may accept one but not the other depending on OLLAMA_ORIGINS.
29635          const endpoints = [endpoint];
29636          if (endpoint.includes("localhost")) {
29637            endpoints.push(endpoint.replace("localhost", "127.0.0.1"));
29638          } else if (endpoint.includes("127.0.0.1")) {
29639            endpoints.push(endpoint.replace("127.0.0.1", "localhost"));
29640          }
29641
29642          let lastErr = null;
29643          for (const ep of endpoints) {
29644            try {
29645              const resp = await tryFetch(
29646                ep + "/api/chat",
29647                body,
29648                null,
29649                "Ollama",
29650              );
29651              // If we succeeded with an alternate endpoint, persist it
29652              if (ep !== raw) {
29653                endpointInput.value = ep;
29654                saveLlmSettings();
29655              }
29656              return resp;
29657            } catch (err) {
29658              lastErr = err;
29659            }
29660          }
29661
29662          // If we get here, all endpoints failed — it's almost certainly CORS.
29663          const isCors =
29664            lastErr &&
29665            (lastErr.message === "Failed to fetch" ||
29666              lastErr.message === "Load failed" ||
29667              lastErr.message.includes("NetworkError") ||
29668              lastErr.message.includes("network"));
29669
29670          if (isCors) {
29671            throw new Error("CORS_BLOCKED");
29672          }
29673          throw lastErr;
29674        }
29675
29676        async function parseOllamaContent(response) {
29677          let parsed;
29678          try {
29679            parsed = await response.json();
29680          } catch (error) {
29681            throw new Error("LLM returned an unreadable response");
29682          }
29683          const content =
29684            parsed &&
29685            parsed.message &&
29686            typeof parsed.message.content === "string"
29687              ? parsed.message.content
29688              : "";
29689          if (!content.trim()) {
29690            throw new Error("LLM returned no message content");
29691          }
29692          return content;
29693        }
29694
29695        async function callGroq(messages, options) {
29696          const opts = options || {};
29697          const apiKey = groqApiKeyInput.value.trim();
29698          if (!apiKey) {
29699            throw new Error("Groq API key is missing. Add it in AI settings.");
29700          }
29701          const model =
29702            groqModelInput.value.trim() || DEFAULT_LLM_SETTINGS.groqModel;
29703          const body = {
29704            model: model,
29705            messages: messages,
29706            stream: opts.stream !== false,
29707          };
29708          if (/qwen3/i.test(model)) {
29709            body.reasoning_effort = "none";
29710          }
29711          if (
29712            Number.isFinite(opts.maxCompletionTokens) &&
29713            opts.maxCompletionTokens > 0
29714          ) {
29715            body.max_completion_tokens = Math.floor(opts.maxCompletionTokens);
29716          }
29717          if (opts.responseFormat) {
29718            body.response_format = opts.responseFormat;
29719          }
29720          return tryFetch(
29721            "https://api.groq.com/openai/v1/chat/completions",
29722            body,
29723            { Authorization: "Bearer " + apiKey },
29724            "Groq",
29725          );
29726        }
29727
29728        async function parseGroqContent(response) {
29729          let parsed;
29730          try {
29731            parsed = await response.json();
29732          } catch (error) {
29733            throw new Error("LLM returned an unreadable response");
29734          }
29735          const content =
29736            parsed &&
29737            Array.isArray(parsed.choices) &&
29738            parsed.choices[0] &&
29739            parsed.choices[0].message &&
29740            typeof parsed.choices[0].message.content === "string"
29741              ? parsed.choices[0].message.content
29742              : "";
29743          if (!content.trim()) {
29744            throw new Error("LLM returned no message content");
29745          }
29746          return content;
29747        }
29748
29749        async function callConfiguredLlmResponse(messages, options) {
29750          const settings = getCurrentLlmSettings();
29751          return settings.provider === "groq"
29752            ? callGroq(messages, options)
29753            : callOllama(messages, options);
29754        }
29755
29756        async function callConfiguredLlmText(messages, options) {
29757          const settings = getCurrentLlmSettings();
29758          const response = await callConfiguredLlmResponse(
29759            messages,
29760            Object.assign({}, options || {}, { stream: false }),
29761          );
29762          return settings.provider === "groq"
29763            ? parseGroqContent(response)
29764            : parseOllamaContent(response);
29765        }
29766
29767        async function callConfiguredLlmJson(messages, options) {
29768          const settings = getCurrentLlmSettings();
29769          const opts = options || {};
29770          if (settings.provider === "groq") {
29771            return callConfiguredLlmText(
29772              messages,
29773              Object.assign({}, opts, {
29774                responseFormat: opts.responseFormat || { type: "json_object" },
29775              }),
29776            );
29777          }
29778          const response = await callOllama(messages, {
29779            stream: false,
29780            format: "json",
29781          });
29782          return parseOllamaContent(response);
29783        }
29784
29785        window.markvCallTextLlm = callConfiguredLlmText;
29786
29787        if (typeof window.markvLLM !== "function") {
29788          window.markvLLM = async function markvMindmapLLM(payload) {
29789            const request = payload || {};
29790            const system = String(request.system || "").trim();
29791            const user = String(request.user || "").trim();
29792            if (!system || !user) {
29793              throw new Error("Mindmap extraction request was incomplete");
29794            }
29795            const settings = getCurrentLlmSettings();
29796            const groqFormat =
29797              settings.provider === "groq" &&
29798              typeof window.markvGetMindmapGroqResponseFormat === "function"
29799                ? window.markvGetMindmapGroqResponseFormat(
29800                    settings.groqModel || DEFAULT_LLM_SETTINGS.groqModel,
29801                  )
29802                : null;
29803            try {
29804              return await callConfiguredLlmJson(
29805                [
29806                  { role: "system", content: system },
29807                  { role: "user", content: user },
29808                ],
29809                {
29810                  maxCompletionTokens: request.maxCompletionTokens,
29811                  responseFormat: groqFormat,
29812                },
29813              );
29814            } catch (error) {
29815              const usedStructuredOutput =
29816                groqFormat && groqFormat.type === "json_schema";
29817              const shouldFallback =
29818                settings.provider === "groq" &&
29819                usedStructuredOutput &&
29820                error &&
29821                error.status === 400;
29822              if (!shouldFallback) throw error;
29823              return callConfiguredLlmJson(
29824                [
29825                  { role: "system", content: system },
29826                  { role: "user", content: user },
29827                ],
29828                {
29829                  maxCompletionTokens: request.maxCompletionTokens,
29830                  responseFormat: { type: "json_object" },
29831                },
29832              );
29833            }
29834          };
29835        }
29836
29837        function appendStreamText(msgDiv, fullText, chunk) {
29838          fullText += chunk;
29839          scheduleAssistantMessageContent(msgDiv, fullText);
29840          messagesDiv.scrollTop = messagesDiv.scrollHeight;
29841          return fullText;
29842        }
29843
29844        async function streamOllamaResponse(response, msgDiv) {
29845          const reader = response.body.getReader();
29846          const decoder = new TextDecoder();
29847          let fullText = "";
29848          let buffer = "";
29849
29850          while (true) {
29851            const { done, value } = await reader.read();
29852            if (done) break;
29853            buffer += decoder.decode(value, { stream: true });
29854            // Ollama streams newline-delimited JSON
29855            const lines = buffer.split("\n");
29856            buffer = lines.pop() || "";
29857            for (const line of lines) {
29858              if (!line.trim()) continue;
29859              try {
29860                const parsed = JSON.parse(line);
29861                if (parsed.message && parsed.message.content) {
29862                  fullText = appendStreamText(
29863                    msgDiv,
29864                    fullText,
29865                    parsed.message.content,
29866                  );
29867                }
29868              } catch (error) {
29869                console.debug("Skipping malformed streaming chunk.", error);
29870              }
29871            }
29872          }
29873
29874          if (buffer.trim()) {
29875            try {
29876              const parsed = JSON.parse(buffer);
29877              if (parsed.message && parsed.message.content) {
29878                fullText = appendStreamText(
29879                  msgDiv,
29880                  fullText,
29881                  parsed.message.content,
29882                );
29883              }
29884            } catch (error) {
29885              console.debug(
29886                "Skipping malformed trailing streaming chunk.",
29887                error,
29888              );
29889            }
29890          }
29891          if (fullText.trim()) {
29892            flushAssistantMessageContent(msgDiv, fullText);
29893            return fullText;
29894          }
29895          flushAssistantMessageContent(msgDiv, "No reply received.");
29896          return "No reply received.";
29897        }
29898
29899        async function streamGroqResponse(response, msgDiv) {
29900          const reader = response.body.getReader();
29901          const decoder = new TextDecoder();
29902          let fullText = "";
29903          let buffer = "";
29904
29905          while (true) {
29906            const { done, value } = await reader.read();
29907            if (done) break;
29908            buffer += decoder.decode(value, { stream: true });
29909            const events = buffer.split(/\n\n/);
29910            buffer = events.pop() || "";
29911            for (const eventChunk of events) {
29912              const lines = eventChunk.split(/\r?\n/);
29913              for (const line of lines) {
29914                if (!line.startsWith("data:")) continue;
29915                const data = line.slice(5).trim();
29916                if (!data || data === "[DONE]") continue;
29917                try {
29918                  const parsed = JSON.parse(data);
29919                  const delta =
29920                    parsed &&
29921                    Array.isArray(parsed.choices) &&
29922                    parsed.choices[0] &&
29923                    parsed.choices[0].delta &&
29924                    parsed.choices[0].delta.content;
29925                  if (typeof delta === "string" && delta) {
29926                    fullText = appendStreamText(msgDiv, fullText, delta);
29927                  }
29928                } catch (error) {
29929                  console.debug("Skipping malformed SSE chunk.", error);
29930                }
29931              }
29932            }
29933          }
29934
29935          if (buffer.trim()) {
29936            const trailingLines = buffer.split(/\r?\n/);
29937            for (const line of trailingLines) {
29938              if (!line.startsWith("data:")) continue;
29939              const data = line.slice(5).trim();
29940              if (!data || data === "[DONE]") continue;
29941              try {
29942                const parsed = JSON.parse(data);
29943                const delta =
29944                  parsed &&
29945                  Array.isArray(parsed.choices) &&
29946                  parsed.choices[0] &&
29947                  parsed.choices[0].delta &&
29948                  parsed.choices[0].delta.content;
29949                if (typeof delta === "string" && delta) {
29950                  fullText = appendStreamText(msgDiv, fullText, delta);
29951                }
29952              } catch (error) {
29953                console.debug("Skipping malformed trailing SSE chunk.", error);
29954              }
29955            }
29956          }
29957          if (fullText.trim()) {
29958            flushAssistantMessageContent(msgDiv, fullText);
29959            return fullText;
29960          }
29961          flushAssistantMessageContent(msgDiv, "No reply received.");
29962          return "No reply received.";
29963        }
29964
29965        async function streamResponse(response, msgDiv) {
29966          return getCurrentLlmSettings().provider === "groq"
29967            ? streamGroqResponse(response, msgDiv)
29968            : streamOllamaResponse(response, msgDiv);
29969        }
29970
29971        function renderChatError(err) {
29972          if (err.message === "CORS_BLOCKED") {
29973            return (
29974              "<b>Connection blocked (CORS)</b><br><br>" +
29975              "Ollama is running but the browser is blocking the request. " +
29976              "You need to restart Ollama with CORS allowed:<br><br>" +
29977              '<code style="background:var(--code-bg);padding:3px 6px;border-radius:4px;font-size:12px;">' +
29978              "OLLAMA_ORIGINS=* ollama serve" +
29979              "</code><br><br>" +
29980              "Or on macOS with the Ollama app:<br>" +
29981              '<code style="background:var(--code-bg);padding:3px 6px;border-radius:4px;font-size:12px;">' +
29982              'launchctl setenv OLLAMA_ORIGINS "*"' +
29983              "</code><br>" +
29984              "then restart the Ollama app."
29985            );
29986          }
29987          const settings = getCurrentLlmSettings();
29988          if (settings.provider === "groq") {
29989            return escapeChatHtml(
29990              "Error: " +
29991                err.message +
29992                "\n\nCheck your Groq API key and model in AI settings.",
29993            ).replace(/\n/g, "<br>");
29994          }
29995          return escapeChatHtml(
29996            "Error: " +
29997              err.message +
29998              "\n\nMake sure Ollama is running at " +
29999              endpointInput.value.trim(),
30000          ).replace(/\n/g, "<br>");
30001        }
30002
30003        function buildSystemPrompt(question, retrieval) {
30004          const docStatus = retrieval.hasDocument
30005            ? "loaded (" + getDocumentLabel() + ")"
30006            : "not loaded";
30007          const contextStatus = retrieval.context
30008            ? retrieval.strategy + " context available"
30009            : "no relevant retrieved context";
30010
30011          const parts = [
30012            "You are Document Q&A, a production-grade assistant embedded in a markdown viewer.",
30013            "Your job is to be genuinely helpful in conversation while staying accurate and grounded in the loaded document whenever the user asks about it.",
30014            "",
30015            "Operating rules:",
30016            "1. Handle greetings, thanks, help requests, and simple c
30016onversational turns naturally. Do not claim the document is required for small talk.",
30017            "2. For substantive questions that are unrelated to the document, politely redirect the user back to document-focused help instead of acting like a general-purpose web assistant.",
30018            "3. When the user asks about the document and relevant context is provided, answer from that context first and keep the explanation clear and direct.",
30019            "4. When context is partial, answer only the supported part and explicitly say what is missing.",
30020            "5. When no relevant context is available for a document question, say you could not find support for that in the loaded document. Ask the user to rephrase, point you to a section, or load the right file.",
30021            "6. If no document is loaded and the user asks a document question, say so plainly and briefly explain what you can do once one is loaded.",
30022            '7. Use recent chat history to resolve follow-up questions like "what about chapter 2?" or "explain that more".',
30023            "8. If the user asks for a summary, rewrite, extraction, comparison, or explanation, perform that task using the document when available.",
30024            "9. Never invent document facts, quotations, sections, numbers, or conclusions that are not supported.",
30025            "10. Keep answers concise by default, but provide structure when it improves clarity.",
30026            "",
30027            "Current document status: " + docStatus + ".",
30028            "Current retrieval status: " + contextStatus + ".",
30029          ];
30030
30031          if (retrieval.context) {
30032            parts.push(
30033              "",
30034              "--- DOCUMENT CONTEXT ---",
30035              retrieval.context,
30036              "--- END CONTEXT ---",
30037            );
30038          }
30039
30040          return parts.join("\n");
30041        }
30042
30043        // ---- Send question ----
30044        let isSending = false;
30045
30046        async function sendQuestion(question) {
30047          if (isSending || !question.trim()) return;
30048          isSending = true;
30049          sendBtn.disabled = true;
30050          syncHistoryWithCurrentDocument();
30051
30052          addMessage(question, "user");
30053
30054          const retrieval = getRelevantContext(question, 5);
30055          const localReply = getLocalAssistantReply(
30056            question,
30057            retrieval.hasDocument,
30058          );
30059          if (localReply) {
30060            addMessage(localReply, "assistant");
30061            addHistory("user", question);
30062            addHistory("assistant", localReply);
30063            announceChatUpdate(localReply);
30064            isSending = false;
30065            sendBtn.disabled = !chatInput.value.trim();
30066            return;
30067          }
30068
30069          const systemPrompt = buildSystemPrompt(question, retrieval);
30070          const conversation = [
30071            { role: "system", content: systemPrompt },
30072          ].concat(getRecentHistory(), [{ role: "user", content: question }]);
30073
30074          const typingDiv = addTyping();
30075
30076          try {
30077            const response = await callConfiguredLlmResponse(conversation);
30078            const answer = await streamResponse(response, typingDiv);
30079            addHistory("user", question);
30080            addHistory("assistant", answer || typingDiv.textContent || "");
30081            announceChatUpdate(
30082              answer || typingDiv.textContent || "Assistant replied.",
30083            );
30084          } catch (err) {
30085            typingDiv.className = "chat-msg system";
30086            typingDiv.innerHTML = renderChatError(err);
30087            announceChatUpdate(typingDiv.textContent || "Chat error");
30088          }
30089
30090          isSending = false;
30091          sendBtn.disabled = !chatInput.value.trim();
30092        }
30093
30094        sendBtn.addEventListener("click", function () {
30095          const q = chatInput.value.trim();
30096          chatInput.value = "";
30097          chatInput.style.height = "auto";
30098          sendBtn.disabled = true;
30099          sendQuestion(q);
30100        });
30101        document.addEventListener("keydown", function (e) {
30102          if (e.key === "Escape") {
30103            if (!panel.hidden) {
30104              e.preventDefault();
30105              hideChatPanel();
30106            }
30107          }
30108        });
30109
30110        document.addEventListener("markv:selection-action", function (event) {
30111          const detail = event.detail || {};
30112          const selectedText = String(detail.text || "").trim();
30113          if (!selectedText) return;
30114
30115          if (detail.type === "summarize") {
30116            showChatPanel({ focusInput: false });
30117
30118            const truncated =
30119              selectedText.length > 500
30120                ? selectedText.slice(0, 500) + "..."
30121                : selectedText;
30122
30123            sendQuestion(
30124              'Summarize this section from the document:\n\n"' +
30125                truncated +
30126                '"',
30127            );
30128            return;
30129          }
30130
30131          if (detail.type === "ask") {
30132            showChatPanel();
30133
30134            const truncated =
30135              selectedText.length > 300
30136                ? selectedText.slice(0, 300) + "..."
30137                : selectedText;
30138
30139            chatInput.value = 'Regarding this section: "' + truncated + '"\n\n';
30140            chatInput.focus();
30141            chatInput.setSelectionRange(
30142              chatInput.value.length,
30143              chatInput.value.length,
30144            );
30145            syncChatInputUi();
30146          }
30147        });
30148
30149        // ---- Keyboard shortcut: Ctrl+/ to toggle chat ----
30150        window.addEventListener("keydown", function (e) {
30151          if ((e.metaKey || e.ctrlKey) && e.key === "/") {
30152            e.preventDefault();
30153            if (panel.hidden) {
30154              showChatPanel();
30155            } else {
30156              hideChatPanel();
30157            }
30158          }
30159        });
30160      })();
30161    </script>
30161
30162
30163    
30163<script>
30164      /* ============================================================
30165       Secure Groq API key storage (WebCrypto + IndexedDB)
30166
30167       Strategy: the Groq key is encrypted with a non-extractable
30168       AES-GCM CryptoKey stored inside IndexedDB. IDB can hold a
30169       CryptoKey handle directly without ever exposing its raw bytes,
30170       so even with disk access an attacker cannot recover the
30171       encryption key. The encrypted Groq key lives in the same DB.
30172
30173       Plaintext never touches localStorage after first migration.
30174       The existing chat module still reads `groqApiKey` from its
30175       legacy hidden input — this script injects the decrypted value
30176       on load and scrubs any plaintext that other code writes.
30177
30178       Threat model: protects the key at-rest from casual disk
30179       inspection, backup leaks, and shoulder-surfing via devtools
30180       Application tab. It does NOT protect against active XSS on
30181       this origin — any script that can call crypto.subtle.decrypt
30182       can decrypt the key. For that level of protection, proxy
30183       Groq calls through a server-side endpoint.
30184       ============================================================ */
30185      (function () {
30186        "use strict";
30187
30188        const LLM_KEY = "md-viewer-llm";
30189        const DB_NAME = "md-viewer-secrets";
30190        const DB_VERSION = 1;
30191        const STORE = "secrets";
30192        const KEY_ID = "aes-key";
30193        const SECRET_ID = "groq-api-key";
30194
30195        const supported =
30196          typeof indexedDB !== "undefined" &&
30197          typeof crypto !== "undefined" &&
30198          crypto.subtle &&
30199          typeof TextEncoder !== "undefined";
30200
30201        if (!supported) {
30202          console.warn(
30203            "[secure-store] WebCrypto/IndexedDB unavailable — " +
30204              "Groq key stays in localStorage plaintext.",
30205          );
30206          return;
30207        }
30208
30209        // ---- IndexedDB helpers ----
30210        let dbPromise = null;
30211        function openDb() {
30212          if (dbPromise) return dbPromise;
30213          dbPromise = new Promise(function (resolve, reject) {
30214            const req = indexedDB.open(DB_NAME, DB_VERSION);
30215            req.onupgradeneeded = function () {
30216              const db = req.result;
30217              if (!db.objectStoreNames.contains(STORE)) {
30218                db.createObjectStore(STORE, { keyPath: "id" });
30219              }
30220            };
30221            req.onsuccess = function () {
30222              resolve(req.result);
30223            };
30224            req.onerror = function () {
30225              reject(req.error);
30226            };
30227          });
30228          return dbPromise;
30229        }
30230
30231        function idbGet(id) {
30232          return openDb().then(function (db) {
30233            return new Promise(function (resolve, reject) {
30234              const tx = db.transaction(STORE, "readonly");
30235              const req = tx.objectStore(STORE).get(id);
30236              req.onsuccess = function () {
30237                resolve(req.result || null);
30238              };
30239              req.onerror = function () {
30240                reject(req.error);
30241              };
30242            });
30243          });
30244        }
30245
30246        function idbPut(value) {
30247          return openDb().then(function (db) {
30248            return new Promise(function (resolve, reject) {
30249              const tx = db.transaction(STORE, "readwrite");
30250              tx.objectStore(STORE).put(value);
30251              tx.oncomplete = function () {
30252                resolve();
30253              };
30254              tx.onerror = function () {
30255                reject(tx.error);
30256              };
30257            });
30258          });
30259        }
30260
30261        function idbDelete(id) {
30262          return openDb().then(function (db) {
30263            return new Promise(function (resolve, reject) {
30264              const tx = db.transaction(STORE, "readwrite");
30265              tx.objectStore(STORE).delete(id);
30266              tx.oncomplete = function () {
30267                resolve();
30268              };
30269              tx.onerror = function () {
30270                reject(tx.error);
30271              };
30272            });
30273          });
30274        }
30275
30276        // ---- AES-GCM key management ----
30277        async function getOrCreateAesKey() {
30278          const existing = await idbGet(KEY_ID);
30279          if (existing && existing.key) return existing.key;
30280          const key = await crypto.subtle.generateKey(
30281            { name: "AES-GCM", length: 256 },
30282            // extractable=false — the raw bytes cannot be read back,
30283            // even by our own code. Only encrypt/decrypt ops work.
30284            false,
30285            ["encrypt", "decrypt"],
30286          );
30287          await idbPut({ id: KEY_ID, key: key });
30288          return key;
30289        }
30290
30291        async function encryptString(plaintext) {
30292          const key = await getOrCreateAesKey();
30293          const iv = crypto.getRandomValues(new Uint8Array(12));
30294          const bytes = new TextEncoder().encode(plaintext);
30295          const cipher = await crypto.subtle.encrypt(
30296            { name: "AES-GCM", iv: iv },
30297            key,
30298            bytes,
30299          );
30300          return { iv: iv, ct: new Uint8Array(cipher) };
30301        }
30302
30303        async function decryptPayload(payload) {
30304          const key = await getOrCreateAesKey();
30305          const plain = await crypto.subtle.decrypt(
30306            { name: "AES-GCM", iv: payload.iv },
30307            key,
30308            payload.ct,
30309          );
30310          return new TextDecoder().decode(plain);
30311        }
30312
30313        async function saveGroqKey(plaintext) {
30314          if (!plaintext) {
30315            await idbDelete(SECRET_ID);
30316            return;
30317          }
30318          const payload = await encryptString(plaintext);
30319          await idbPut({ id: SECRET_ID, iv: payload.iv, ct: payload.ct });
30320        }
30321
30322        async function loadGroqKey() {
30323          const row = await idbGet(SECRET_ID);
30324          if (!row || !row.iv || !row.ct) return null;
30325          try {
30326            return await decryptPayload({ iv: row.iv, ct: row.ct });
30327          } catch (err) {
30328            console.warn("[secure-store] decrypt failed:", err);
30329            return null;
30330          }
30331        }
30332
30333        // ---- localStorage plaintext scrubbing ----
30334        function stripPlaintextFromLocalStorage() {
30335          try {
30336            const raw = localStorage.getItem(LLM_KEY);
30337            if (!raw) return;
30338            const obj = JSON.parse(raw);
30339            if (!obj || typeof obj !== "object") return;
30340            if (!obj.groqApiKey) return;
30341            obj.groqApiKey = "";
30342            localStorage.setItem(LLM_KEY, JSON.stringify(obj));
30343          } catch (_err) {
30344            /* ignore — corrupt JSON is the chat module's problem */
30345          }
30346        }
30347
30348        function extractPlaintextFromLocalStorage() {
30349          try {
30350            const raw = localStorage.getItem(LLM_KEY);
30351            if (!raw) return "";
30352            const obj = JSON.parse(raw);
30353            return obj && obj.groqApiKey ? String(obj.groqApiKey) : "";
30354          } catch (_err) {
30355            return "";
30356          }
30357        }
30358
30359        // ---- Migration ----
30360        // If this is the first page load after the secure-store
30361        // feature ships, the chat IIFE has just re-written a JSON
30362        // blob containing the plaintext key. Encrypt and scrub.
30363        function migrateIfNeeded() {
30364          const plaintext = extractPlaintextFromLocalStorage();
30365          if (!plaintext) return Promise.resolve();
30366          return saveGroqKey(plaintext)
30367            .then(function () {
30368              stripPlaintextFromLocalStorage();
30369              console.info(
30370                "[secure-store] migrated Groq key to encrypted storage.",
30371              );
30372            })
30373            .catch(function (err) {
30374              console.warn("[secure-store] migration failed:", err);
30375            });
30376        }
30377
30378        // ---- Hydration ----
30379        // Load the encrypted key and inject it into the legacy
30380        // hidden input. Fire input+change so the drawer mirrors it
30381        // and the chat module refreshes its "configured" state.
30382        function hydrateInput() {
30383          const input = document.getElementById("groqApiKey");
30384          if (!input) return Promise.resolve();
30385          return loadGroqKey().then(function (plaintext) {
30386            if (!plaintext) return;
30387            if (input.value === plaintext) return;
30388            input.value = plaintext;
30389            input.dispatchEvent(new Event("input", { bubbles: true }));
30390            input.dispatchEvent(new Event("change", { bubbles: true }));
30391            // The change event will have made chat IIFE write the
30392            // plaintext back into localStorage — scrub it again.
30393            stripPlaintextFromLocalStorage();
30394          });
30395        }
30396
30397        // ---- Forward-save interception ----
30398        // When the user edits the Groq key (via the drawer, which
30399        // mirrors into the legacy input), the chat IIFE's change
30400        // listener runs first and writes plaintext to localStorage.
30401        // Our change listener runs right after and:
30402        //   1. Strips plaintext from localStorage immediately
30403        //   2. Encrypts the new value into IndexedDB
30404        function attachForwardSaveGuard() {
30405          const input = document.getElementById("groqApiKey");
30406          if (!input) return;
30407          input.addEventListener("change", function () {
30408            const value = input.value.trim();
30409            stripPlaintextFromLocalStorage();
30410            saveGroqKey(value).catch(function (err) {
30411              console.warn("[secure-store] save failed:", err);
30412            });
30413          });
30414        }
30415
30416        // ---- Boot ----
30417        // Ordering: the chat IIFE has already run (this script
30418        // follows it in document order), so `groqApiKey` input is
30419        // already populated with whatever was in localStorage.
30420        // Kick off migration + hydration in parallel, then attach
30421        // the save guard for future edits.
30422        attachForwardSaveGuard();
30423        migrateIfNeeded()
30424          .then(hydrateInput)
30425          .catch(function (err) {
30426            console.warn("[secure-store] boot failed:", err);
30427          });
30428      })();
30429    </script>
30429
30430
30431    <!-- ============================================================
30432         Floating editor formatting toolbar.
30433         Medium-style, selection-driven, edit-mode only.
30434         Self-contained IIFE — no external dependencies.
30435         ============================================================ -->
30436    
30436<script>
30437      (function setupEditorToolbar() {
30438        const editor = document.getElementById("editor");
30439        const toolbar = document.getElementById("editorToolbar");
30440        const mainEl = document.getElementById("main");
30441        if (!editor || !toolbar || !mainEl) return;
30442
30443        const markdownActions = window.MarkVEditorMarkdownActions;
30444        const shortcuts = window.MarkVShortcuts;
30445        const linkInput = toolbar.querySelector(".et-link-input");
30446        const arrow = toolbar.querySelector(".et-arrow");
30447        let pendingLinkRange = null;
30448        let repositionRaf = null;
30449        let suppressNextBlurHide = false;
30450
30451        if (shortcuts && typeof shortcuts.applyTitles === "function") {
30452          shortcuts.applyTitles(toolbar);
30453        }
30454
30455        function shortcutMatches(event, shortcutId) {
30456          return Boolean(
30457            shortcuts &&
30458            typeof shortcuts.matches === "function" &&
30459            shortcuts.matches(event, shortcutId),
30460          );
30461        }
30462
30463        // ---------- Mirror div for textarea caret coordinates ----------
30464        const mirror = document.createElement("div");
30465        mirror.setAttribute("aria-hidden", "true");
30466        mirror.style.position = "absolute";
30467        mirror.style.visibility = "hidden";
30468        mirror.style.top = "0";
30469        mirror.style.left = "-9999px";
30470        mirror.style.whiteSpace = "pre-wrap";
30471        mirror.style.wordWrap = "break-word";
30472        mirror.style.overflowWrap = "break-word";
30473        mirror.style.pointerEvents = "none";
30474        document.body.appendChild(mirror);
30475
30476        const MIRROR_PROPS = [
30477          "direction",
30478          "boxSizing",
30479          "width",
30480          "height",
30481          "overflowX",
30482          "overflowY",
30483          "borderTopWidth",
30484          "borderRightWidth",
30485          "borderBottomWidth",
30486          "borderLeftWidth",
30487          "borderStyle",
30488          "paddingTop",
30489          "paddingRight",
30490          "paddingBottom",
30491          "paddingLeft",
30492          "fontStyle",
30493          "fontVariant",
30494          "fontWeight",
30495          "fontStretch",
30496          "fontSize",
30497          "fontSizeAdjust",
30498          "lineHeight",
30499          "fontFamily",
30500          "textAlign",
30501          "textTransform",
30502          "textIndent",
30503          "textDecoration",
30504          "letterSpacing",
30505          "wordSpacing",
30506          "tabSize",
30507          "MozTabSize",
30508        ];
30509
30510        function syncMirrorStyles() {
30511          const cs = window.getComputedStyle(editor);
30512          for (let i = 0; i < MIRROR_PROPS.length; i++) {
30513            const p = MIRROR_PROPS[i];
30514            mirror.style[p] = cs[p];
30515          }
30516          mirror.style.width = editor.offsetWidth + "px";
30517          mirror.style.height = "auto";
30518        }
30519
30520        // Returns viewport-relative caret coordinates for a given char offset.
30521        function getCaretViewportRect(position) {
30522          syncMirrorStyles();
30523          mirror.textContent = editor.value.substring(0, position);
30524          const span = document.createElement("span");
30525          // Use remainder so the span lays out at the correct baseline even
30526          // when the caret is at the very end of the content.
30527          span.textContent = editor.value.substring(position) || ".";
30528          mirror.appendChild(span);
30529
30530          const cs = window.getComputedStyle(editor);
30531          const borderTop = parseFloat(cs.borderTopWidth) || 0;
30532          const borderLeft = parseFloat(cs.borderLeftWidth) || 0;
30533          const lineHeight =
30534            parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 20;
30535
30536          const eRect = editor.getBoundingClientRect();
30537          const caretX =
30538            eRect.left + borderLeft + span.offsetLeft - editor.scrollLeft;
30539          const caretY =
30540            eRect.top + borderTop + span.offsetTop - editor.scrollTop;
30541
30542          return { x: caretX, y: caretY, lineHeight: lineHeight };
30543        }
30544
30545        // ---------- Visibility helpers ----------
30546        function isEditMode() {
30547          return mainEl.classList.contains("edit-mode");
30548        }
30549
30550        function showToolbar() {
30551          if (toolbar.hidden) {
30552            toolbar.hidden = false;
30553            // Force layout so the transition runs.
30554            void toolbar.offsetWidth;
30555          }
30556          toolbar.classList.add("visible");
30557        }
30558
30559        function hideToolbar() {
30560          toolbar.classList.remove("visible");
30561          toolbar.classList.remove("et-link-mode");
30562          toolbar.classList.remove("et-arrow-up");
30563          pendingLinkRange = null;
30564        }
30565
30566        toolbar.addEventListener("transitionend", function (e) {
30567          if (
30568            e.propertyName === "opacity" &&
30569            !toolbar.classList.contains("visible")
30570          ) {
30571            toolbar.hidden = true;
30572          }
30573        });
30574
30575        function positionToolbarForSelection() {
30576          const start = editor.selectionStart;
30577          const end = editor.selectionEnd;
30578          if (start === end) {
30579            hideToolbar();
30580            return;
30581          }
30582
30583          const startPos = getCaretViewportRect(start);
30584          const endPos = getCaretViewportRect(end);
30585          const sameLine =
30586            Math.abs(startPos.y - endPos.y) < startPos.lineHeight * 0.6;
30587
30588          const anchorY = startPos.y;
30589          const anchorX = sameLine ? (startPos.x + endPos.x) / 2 : startPos.x;
30590
30591          // Reveal the toolbar (opacity 0 still paints, so we can measure).
30592          showToolbar();
30593          const tbRect = toolbar.getBoundingClientRect();
30594
30595          const gap = 10;
30596          const margin = 8;
30597          let tx = anchorX - tbRect.width / 2;
30598          let ty = anchorY - tbRect.height - gap;
30599
30600          // Horizontal clamp
30601          const maxX = window.innerWidth - tbRect.width - margin;
30602          if (tx < margin) tx = margin;
30603          if (tx > maxX) tx = Math.max(margin, maxX);
30604
30605          // Vertical flip if near top of viewport
30606          let flipped = false;
30607          if (ty < margin) {
30608            ty = anchorY + startPos.lineHeight + gap;
30609            flipped = true;
30610          }
30611          toolbar.classList.toggle("et-arrow-up", flipped);
30612
30613          // Absolute positioning relative to document
30614          toolbar.style.left = Math.round(tx + window.scrollX) + "px";
30615          toolbar.style.top = Math.round(ty + window.scrollY) + "px";
30616
30617          // Re-anchor the arrow horizontally so it still points at the caret
30618          if (arrow) {
30619            const arrowPx = Math.max(
30620              14,
30621              Math.min(tbRect.width - 14, anchorX - tx),
30622            );
30623            arrow.style.left = arrowPx + "px";
30624            arrow.style.marginLeft = "-6px";
30625          }
30626        }
30627
30628        function updateToolbar() {
30629          if (!isEditMode()) return hideToolbar();
30630          const activeIsEditor = document.activeElement === editor;
30631          const activeIsInToolbar = toolbar.contains(document.activeElement);
30632          if (!activeIsEditor && !activeIsInToolbar) return hideToolbar();
30633          if (toolbar.classList.contains("et-link-mode")) return; // don't reposition during link entry
30634          if (editor.selectionStart === editor.selectionEnd) {
30635            return hideToolbar();
30636          }
30637          positionToolbarForSelection();
30638        }
30639
30640        function scheduleUpdate() {
30641          if (repositionRaf) cancelAnimationFrame(repositionRaf);
30642          repositionRaf = requestAnimationFrame(function () {
30643            repositionRaf = null;
30644            updateToolbar();
30645          });
30646        }
30647
30648        // ---------- Text replacement (preserves undo) ----------
30649        function replaceRange(start, end, replacement, selStart, selEnd) {
30650          editor.focus();
30651          editor.setSelectionRange(start, end);
30652          let ok = false;
30653          try {
30654            ok = document.execCommand("insertText", false, replacement);
30655          } catch (err) {
30656            ok = false;
30657          }
30658          if (!ok) {
30659            editor.setRangeText(replacement, start, end, "end");
30660            editor.dispatchEvent(new Event("input", { bubbles: true }));
30661          }
30662          if (typeof selStart === "number") {
30663            editor.setSelectionRange(
30664              selStart,
30665              typeof selEnd === "number" ? selEnd : selStart,
30666            );
30667          }
30668        }
30669
30670        function applyMarkdownEdit(edit) {
30671          if (!edit) return false;
30672          replaceRange(
30673            edit.start,
30674            edit.end,
30675            edit.text,
30676            edit.selectionStart,
30677            edit.selectionEnd,
30678          );
30679          return true;
30680        }
30681
30682        function runMarkdownEdit(factory) {
30683          if (!markdownActions || typeof factory !== "function") return false;
30684          return applyMarkdownEdit(
30685            factory(editor.value, editor.selectionStart, editor.selectionEnd),
30686          );
30687        }
30688
30689        function isCompositionKeyEvent(event) {
30690          return Boolean(event && (event.isComposing || event.keyCode === 229));
30691        }
30692
30693        // ---------- Inline wrap toggle (bold, italic) ----------
30694        function wrapInline(prefix, suffix) {
30695          const start = editor.selectionStart;
30696          const end = editor.selectionEnd;
30697          if (start === end) return false;
30698          const v = editor.value;
30699          const sel = v.slice(start, end);
30700
30701          // Case A: selection INCLUDES the wrappers -> unwrap.
30702          if (
30703            sel.length >= prefix.length + suffix.length &&
30704            sel.slice(0, prefix.length) === prefix &&
30705            sel.slice(sel.length - suffix.length) === suffix
30706          ) {
30707            const inner = sel.slice(prefix.length, sel.length - suffix.length);
30708            replaceRange(start, end, inner, start, start + inner.length);
30709            return true;
30710          }
30711
30712          // Case B: wrappers are IMMEDIATELY outside the selection -> unwrap.
30713          if (
30714            start >= prefix.length &&
30715            end + suffix.length <= v.length &&
30716            v.slice(start - prefix.length, start) === prefix &&
30717            v.slice(end, end + suffix.length) === suffix
30718          ) {
30719            replaceRange(
30720              start - prefix.length,
30721              end + suffix.length,
30722              sel,
30723              start - prefix.length,
30724              start - prefix.length + sel.length,
30725            );
30726            return true;
30727          }
30728
30729          // Case C: wrap it.
30730          const wrapped = prefix + sel + suffix;
30731          replaceRange(
30732            start,
30733            end,
30734            wrapped,
30735            start + prefix.length,
30736            start + prefix.length + sel.length,
30737          );
30738          return true;
30739        }
30740
30741        // ---------- Line-level toggles (headings, quotes) ----------
30742        function expandToLineBounds(start, end) {
30743          const v = editor.value;
30744          const lineStart = v.lastIndexOf("\n", start - 1) + 1;
30745          let lineEnd = v.indexOf("\n", end);
30746          if (lineEnd === -1) lineEnd = v.length;
30747          return { lineStart: lineStart, lineEnd: lineEnd };
30748        }
30749
30750        function toggleHeading(level) {
30751          const { lineStart, lineEnd } = expandToLineBounds(
30752            editor.selectionStart,
30753            editor.selectionStart,
30754          );
30755          let line = editor.value.slice(lineStart, lineEnd);
30756          const existing = line.match(/^(#{1,6})\s+/);
30757          const targetPrefix = "#".repeat(level) + " ";
30758          if (existing) {
30759            line = line.slice(existing[0].length);
30760            if (existing[1].length === level) {
30761              // Same level -> remove (toggle off)
30762              replaceRange(
30763                lineStart,
30764                lineEnd,
30765                line,
30766                lineStart,
30767                lineStart + line.length,
30768              );
30769              return;
30770            }
30771          }
30772          const newLine = targetPrefix + line;
30773          replaceRange(
30774            lineStart,
30775            lineEnd,
30776            newLine,
30777            lineStart,
30778            lineStart + newLine.length,
30779          );
30780        }
30781
30782        function toggleQuote() {
30783          return runMarkdownEdit(function (value, start, end) {
30784            return markdownActions.toggleLinePrefix(value, start, end, "quote");
30785          });
30786        }
30787
30788        // ---------- Link flow ----------
30789        function startLinkFlow() {
30790          pendingLinkRange = {
30791            start: editor.selectionStart,
30792            end: editor.selectionEnd,
30793          };
30794          if (pendingLinkRange.start === pendingLinkRange.end) return false;
30795          toolbar.classList.add("et-link-mode");
30796          linkInput.value = "";
30797          suppressNextBlurHide = true;
30798          setTimeout(function () {
30799            linkInput.focus();
30800            suppressNextBlurHide = false;
30801          }, 0);
30802          return true;
30803        }
30804
30805        function confirmLink() {
30806          const url = linkInput.value.trim();
30807          toolbar.classList.remove("et-link-mode");
30808          if (!pendingLinkRange) {
30809            editor.focus();
30810            return;
30811          }
30812          if (!url) {
30813            const r = pendingLinkRange;
30814            pendingLinkRange = null;
30815            editor.focus();
30816            editor.setSelectionRange(r.start, r.end);
30817            return;
30818          }
30819          const r = pendingLinkRange;
30820          pendingLinkRange = null;
30821          const text = editor.value.slice(r.start, r.end) || "link";
30822          const replacement = "[" + text + "](" + url + ")";
30823          replaceRange(
30824            r.start,
30825            r.end,
30826            replacement,
30827            r.start + replacement.length,
30828            r.start + replacement.length,
30829          );
30830          editor.focus();
30831          hideToolbar();
30832        }
30833
30834        function cancelLink() {
30835          toolbar.classList.remove("et-link-mode");
30836          const r = pendingLinkRange;
30837          pendingLinkRange = null;
30838          editor.focus();
30839          if (r) editor.setSelectionRange(r.start, r.end);
30840        }
30841
30842        // ---------- Action dispatch ----------
30843        const actions = {
30844          bold: function () {
30845            return wrapInline("**", "**");
30846          },
30847          italic: function () {
30848            return wrapInline("*", "*");
30849          },
30850          "inline-code": function () {
30851            return runMarkdownEdit(function (value, start, end) {
30852              return markdownActions.toggleInline(
30853                value,
30854                start,
30855                end,
30856                "`",
30857                "`",
30858                "code",
30859              );
30860            });
30861          },
30862          link: startLinkFlow,
30863          h1: function () {
30864            toggleHeading(1);
30865          },
30866          h2: function () {
30867            toggleHeading(2);
30868          },
30869          quote: toggleQuote,
30870          "code-block": function () {
30871            return runMarkdownEdit(
30872              markdownActions && markdownActions.toggleCodeBlock,
30873            );
30874          },
30875          "bullet-list": function () {
30876            return runMarkdownEdit(function (value, start, end) {
30877              return markdownActions.toggleLinePrefix(
30878                value,
30879                start,
30880                end,
30881                "unordered-list",
30882              );
30883            });
30884          },
30885          "numbered-list": function () {
30886            return runMarkdownEdit(function (value, start, end) {
30887              return markdownActions.toggleLinePrefix(
30888                value,
30889                start,
30890                end,
30891                "ordered-list",
30892              );
30893            });
30894          },
30895          image: function () {
30896            return runMarkdownEdit(
30897              markdownActions && markdownActions.insertImage,
30898            );
30899          },
30900        };
30901
30902        function runAction(action) {
30903          if (!isEditMode()) return false;
30904          if (!actions[action]) return false;
30905          editor.focus();
30906          const result = actions[action]();
30907          if (result === false) return false;
30908          if (!toolbar.classList.contains("et-link-mode")) {
30909            requestAnimationFrame(updateToolbar);
30910          }
30911          return true;
30912        }
30913
30914        window.MarkVEditorCommands = {
30915          run: runAction,
30916        };
30917
30918        // ---------- Event wiring ----------
30919        toolbar.addEventListener("mousedown", function (e) {
30920          const btn = e.target.closest("button[data-et-action]");
30921          if (btn) {
30922            e.preventDefault(); // keep editor focus
30923            const action = btn.dataset.etAction;
30924            if (action === "link-cancel") {
30925              cancelLink();
30926              return;
30927            }
30928            runAction(action);
30929            return;
30930          }
30931          // Clicks on the input or elsewhere in link mode should not steal focus
30932          // from the input; clicks on the toolbar background should not blur editor.
30933          if (e.target !== linkInput) {
30934            e.preventDefault();
30935          }
30936        });
30937
30938        linkInput.addEventListener("keydown", function (e) {
30939          if (e.key === "Enter") {
30940            e.preventDefault();
30941            confirmLink();
30942          } else if (e.key === "Escape") {
30943            e.preventDefault();
30944            cancelLink();
30945          }
30946        });
30947
30948        linkInput.addEventListener("blur", function () {
30949          // If focus leaves the input without Enter/Escape, treat as cancel.
30950          setTimeout(function () {
30951            if (
30952              toolbar.classList.contains("et-link-mode") &&
30953              document.activeElement !== linkInput
30954            ) {
30955              cancelLink();
30956            }
30957          }, 0);
30958        });
30959
30960        document.addEventListener("selectionchange", function () {
30961          if (document.activeElement === editor) {
30962            scheduleUpdate();
30963          }
30964        });
30965
30966        editor.addEventListener("blur", function () {
30967          setTimeout(function () {
30968            if (suppressNextBlurHide) return;
30969            if (toolbar.contains(document.activeElement)) return;
30970            hideToolbar();
30971          }, 60);
30972        });
30973
30974        window.addEventListener(
30975          "scroll",
30976          function () {
30977            if (toolbar.classList.contains("visible")) scheduleUpdate();
30978          },
30979          { passive: true },
30980        );
30981
30982        window.addEventListener("resize", function () {
30983          if (toolbar.classList.contains("visible")) scheduleUpdate();
30984        });
30985
30986        // Keyboard shortcuts + Escape
30987        document.addEventListener(
30988          "keydown",
30989          function (e) {
30990            if (e.key === "Escape" && toolbar.classList.contains("visible")) {
30991              hideToolbar();
30992              editor.focus();
30993              return;
30994            }
30995            if (!isEditMode()) return;
30996            if (document.activeElement !== editor) return;
30997            if (isCompositionKeyEvent(e)) return;
30998            let action = "";
30999            if (shortcutMatches(e, "editor.bold")) {
31000              action = "bold";
31001            } else if (shortcutMatches(e, "editor.italic")) {
31002              action = "italic";
31003            } else if (shortcutMatches(e, "editor.link")) {
31004              action = "link";
31005            } else if (shortcutMatches(e, "editor.inline-code")) {
31006              action = "inline-code";
31007            } else if (shortcutMatches(e, "editor.code-block")) {
31008              action = "code-block";
31009            } else if (shortcutMatches(e, "editor.bullet-list")) {
31010              action = "bullet-list";
31011            } else if (shortcutMatches(e, "editor.numbered-list")) {
31012              action = "numbered-list";
31013            }
31014            if (action) {
31015              e.preventDefault();
31016              runAction(action);
31017            }
31018          },
31019          true,
31020        );
31021
31022        // Hide immediately when leaving edit mode.
31023        const modeObserver = new MutationObserver(function () {
31024          if (!isEditMode()) hideToolbar();
31025        });
31026        modeObserver.observe(mainEl, {
31027          attributes: true,
31028          attributeFilter: ["class"],
31029        });
31030      })();
31031    </script>
31031
31032
31033    
31033<script src="assets/visual-block-menu.js?v=16"></script>
31033
31034    <!-- Keeps the toolbar's segmented View-mode pill visually in
31035         sync with body.edit-mode-active / body.mindmap-open. -->
31036    
31036<script src="assets/toolbar-sync.js"></script>
31036
31037    <!-- ≤640px: teleports secondary toolbar groups into a bottom
31038         sheet ("⋯ More") so the mobile header collapses to one row. -->
31039    
31039<script src="assets/toolbar-mobile.js?v=2"></script>
31039
31040  </body>
31041</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.