@helping-ai-workflow/md2doc 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/md2doc.js CHANGED
@@ -20,11 +20,65 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
+ const drawio = require('./drawio.js');
23
24
 
24
25
  // How long to let client-side WaveDrom / Mermaid scripts render before we
25
26
  // snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
26
27
  const DIAGRAM_RENDER_WAIT_MS = 2500;
27
28
 
29
+ // v3.4.0 batch2 final review R9 — the deferred-diagram placeholders.
30
+ //
31
+ // A placeholder is written by the synchronous marked() pass and read back by
32
+ // an async post-pass roughly four thousand lines away in this same file
33
+ // (bakeDrawio / bakeGraphviz). The two sites used to agree only by
34
+ // character-for-character attribute ordering, and nothing failed if they
35
+ // drifted: a matcher that no longer matches collects zero jobs, the post-pass
36
+ // returns the string untouched, and the placeholder — which carries the
37
+ // base64 of the WHOLE source .drawio file or dot block — ships inside an
38
+ // empty div. Silent, and invisible to every test that only asserts on the
39
+ // baked shape.
40
+ //
41
+ // So the emitter and the matcher are both generated from one ordered field
42
+ // list. Adding, renaming or reordering an attribute changes both at once;
43
+ // there is no edit that can move one without the other.
44
+ //
45
+ // Two rules the field list relies on:
46
+ // - every value must already be safe inside a double-quoted attribute —
47
+ // base64 for anything free-form (the source, the alt text), digits for
48
+ // the page index;
49
+ // - a field is never omitted. A field with nothing to say carries the empty
50
+ // string, so the matcher's arity is fixed and "this one had no alt text"
51
+ // cannot degrade into a no-match.
52
+ function makePlaceholderShape(className, fields) {
53
+ const open = `<div class="${className}" `;
54
+ const quoteRe = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, (c) => '\\' + c);
55
+ return {
56
+ // `probe` is the cheap "is there anything to do at all?" test the bake
57
+ // passes run before doing any work. Derived from the first field rather
58
+ // than spelled out again, for the same no-drift reason as `re`.
59
+ probe: new RegExp(quoteRe(fields[0][0]) + '='),
60
+ html(values) {
61
+ return open + fields.map((f, i) => `${f[0]}="${values[i]}"`).join(' ') + '></div>';
62
+ },
63
+ re: new RegExp(
64
+ quoteRe(open) + fields.map((f) => quoteRe(f[0]) + '="' + f[1] + '"').join(' ') + '><\\/div>',
65
+ 'g'),
66
+ };
67
+ }
68
+
69
+ const DRAWIO_PLACEHOLDER = makePlaceholderShape('drawio', [
70
+ ['data-drawio-src', '([^"]*)'],
71
+ ['data-drawio-page', '(\\d+)'],
72
+ // v3.4.0 batch2 final review R5 — the markdown author's alt text, base64 of
73
+ // the already-HTML-escaped string marked hands us, so it survives the trip
74
+ // to bakeDrawio() and becomes the baked box's accessible name.
75
+ ['data-drawio-alt', '([^"]*)'],
76
+ ]);
77
+
78
+ const GRAPHVIZ_PLACEHOLDER = makePlaceholderShape('graphviz', [
79
+ ['data-graphviz-src', '([^"]*)'],
80
+ ]);
81
+
28
82
  function firstExistingPath(candidates) {
29
83
  for (const candidate of candidates) {
30
84
  if (!candidate) {
@@ -321,6 +375,29 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
321
375
  const src = srcPath;
322
376
  let usesMermaid = false;
323
377
  let usesWaveDrom = false;
378
+ let usesDrawio = false;
379
+ // Set only when some drawio reference actually resolves to 2+ pages — the
380
+ // sheet bar (CSS + runtime) is gated on this, not on usesDrawio alone, so a
381
+ // single-page .drawio file never pays for multi-page UI.
382
+ let usesDrawioMultiPage = false;
383
+ // v3.4.0 batch2 Task 6: every `.drawio`/`.xml` source path this render
384
+ // actually resolved and read, so a caller (lib/editor/server.js) can stat
385
+ // them later to notice an external edit — resolveAssetPath()'s result was
386
+ // previously a pure local inside drawioPlaceholderFor(), never surviving
387
+ // past the base64 it gets embedded as (see the investigation note this
388
+ // task shipped with). A Map, not an array, so the same referenced file
389
+ // linked from two different images is only stat'd once.
390
+ //
391
+ // Fix round 1 (review F4): the VALUE is the file's stamp taken here, at
392
+ // read time, not later by the caller. bakeDrawio() launches headless
393
+ // Chromium and bakes every page after this function returns — seconds, not
394
+ // microseconds, on first use — so a stamp taken by the caller AFTER the
395
+ // bake records bytes that were never baked, and every later comparison
396
+ // then answers "up to date" about a revision nobody ever rendered. Taken
397
+ // BEFORE the readFileSync below for the same reason in miniature: a stamp
398
+ // that is older than the bytes we baked costs one redundant re-render,
399
+ // while one that is newer loses the change permanently.
400
+ const drawioRefs = new Map();
324
401
  let usesMath = false;
325
402
  let blocks = null;
326
403
  // ── Local image assets ───────────────────────────────────────────────────────
@@ -436,6 +513,68 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
436
513
  return uri ? uri + fragment : null;
437
514
  }
438
515
 
516
+ // v3.4.0 §4. drawio (.drawio / .xml) goes down the graphviz-style path — a
517
+ // placeholder carrying the source, baked to inline SVG by an async post-pass
518
+ // — instead of image inlining. Returns the placeholder markup, or null to
519
+ // mean "not a drawio reference, fall through to the normal image path"
520
+ // (missing file, non-drawio content, or an extension we don't touch at all).
521
+ // Must run BEFORE inlineImageSrc(): that function warnOnce()s "not a known
522
+ // image extension" for anything outside IMAGE_MIME_BY_EXT, and .drawio/.xml
523
+ // are not image extensions — by the time control reached inlineImageSrc it
524
+ // would already be too late.
525
+ function drawioPlaceholderFor(href, altText) {
526
+ if (!href || isExternalRef(href)) return null;
527
+ const { filePart, fragment } = splitAssetRef(href);
528
+ if (!filePart) return null;
529
+ const ext = path.extname(filePart).toLowerCase();
530
+ if (ext !== '.drawio' && ext !== '.xml') return null;
531
+
532
+ const abs = resolveAssetPath(filePart);
533
+ if (!abs) return null; // No such file: let the ordinary image path warn.
534
+
535
+ // Stamp BEFORE the read — see the drawioRefs comment above.
536
+ const stamp = drawio.drawioStampOf(abs);
537
+ let xml;
538
+ try { xml = fs.readFileSync(abs, 'utf8'); }
539
+ catch (e) { return null; }
540
+
541
+ if (!drawio.isDrawioXml(xml)) {
542
+ // Extension matched but the content didn't — worth a warning for
543
+ // .drawio (that extension promises mxfile/mxGraphModel content), but
544
+ // not for .xml (most .xml files are not diagrams, so silently falling
545
+ // through to the normal image/HTML path is the right default).
546
+ if (ext === '.drawio') {
547
+ warnOnce(href, `[WARN] .drawio file is not mxfile/mxGraphModel, left as-is: ${href}`);
548
+ }
549
+ return null;
550
+ }
551
+
552
+ usesDrawio = true;
553
+ drawioRefs.set(abs, stamp);
554
+ const names = drawio.pageNamesOf(xml);
555
+ if (names.length >= 2) usesDrawioMultiPage = true;
556
+ // splitAssetRef() keeps the leading '#' (it's re-appended verbatim to a
557
+ // data: URI elsewhere), but resolvePageIndex() expects the bare page
558
+ // name/number — strip it here rather than teach resolvePageIndex two
559
+ // input shapes.
560
+ const pageFragment = fragment ? fragment.slice(1) : '';
561
+ const pageIndex = drawio.resolvePageIndex(names, pageFragment);
562
+ const b64 = Buffer.from(xml, 'utf8').toString('base64');
563
+ // v3.4.0 batch2 final review R5 — carry the author's alt text through to
564
+ // bakeDrawio(), which turns it into the baked box's accessible name. It
565
+ // travels base64-encoded for the same reason the source does: the shared
566
+ // placeholder shape's contract is that every value is already safe inside
567
+ // a double-quoted attribute, and an alt text can contain anything.
568
+ //
569
+ // `altText` is marked's `token.text`, which is ALREADY HTML-escaped
570
+ // (MEASURED on marked 14: `![A "q" & <b>](x)` arrives as
571
+ // `A &quot;q&quot; &amp; &lt;b&gt;`) — the same string the ordinary
572
+ // `<img alt="${text}">` path below interpolates raw. So it must not be
573
+ // escaped again here or on the way out.
574
+ const altB64 = Buffer.from(String(altText || ''), 'utf8').toString('base64');
575
+ return '\n' + DRAWIO_PLACEHOLDER.html([b64, pageIndex, altB64]) + '\n';
576
+ }
577
+
439
578
  // A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
440
579
  function inlineSrcsetValue(value) {
441
580
  const parts = String(value).split(',');
@@ -588,6 +727,8 @@ ${itemsHtml}
588
727
  </ul>`;
589
728
  }
590
729
 
730
+ // 契約:一律回傳純文字,永遠不帶 HTML 實體。呼叫端(TOC 標籤、
731
+ // slugifyHeading、startSection)三者都假設這一點。
591
732
  function flattenTokenText(tokens) {
592
733
  if (!Array.isArray(tokens)) {
593
734
  return '';
@@ -598,12 +739,12 @@ ${itemsHtml}
598
739
  return flattenTokenText(item.tokens);
599
740
  }
600
741
  if (item.type === 'codespan') {
601
- return item.text || '';
742
+ return unescapeHtml(item.text || '');
602
743
  }
603
744
  if (item.tokens) {
604
745
  return flattenTokenText(item.tokens);
605
746
  }
606
- return item.text || '';
747
+ return unescapeHtml(item.text || '');
607
748
  })
608
749
  .join('');
609
750
  }
@@ -667,8 +808,21 @@ ${itemsHtml}
667
808
  return classes;
668
809
  }
669
810
 
811
+ // Real HTML tags always have a letter (optionally after a `/`) immediately
812
+ // after `<` — `<x>`, `</div>`, `<br/>`. This deliberately does NOT match
813
+ // `<[^>]*>` generically: once flattenTokenText() started handing back
814
+ // unescaped plain text (v3.4.0 §1), literal author text like "C < D > E"
815
+ // would otherwise look like a tag and get silently eaten here.
816
+ //
817
+ // HTML comments (`<!-- ... -->`) don't start with a letter either, so they
818
+ // need their own alternative — without it a heading like
819
+ // "## Section <!-- note --> Title" leaks "note" into the slug
820
+ // (v3.4.0 Task 1 fix round 1). Known gap, deliberately left open: this
821
+ // does NOT special-case `<!DOCTYPE ...>` or `<? ... ?>` (processing
822
+ // instructions) — same non-letter-after-`<` shape, but neither has shown
823
+ // up in a real heading/prose fixture. Add an alternative here if one does.
670
824
  function stripHtmlTags(value) {
671
- return String(value || '').replace(/<[^>]*>/g, '');
825
+ return String(value || '').replace(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^<>]*>/g, '');
672
826
  }
673
827
 
674
828
  function slugifyHeading(value) {
@@ -694,6 +848,19 @@ ${itemsHtml}
694
848
  .replace(/'/g, '&#39;');
695
849
  }
696
850
 
851
+ // escapeHtml() 的精確逆函式。marked 的 inline lexer 會把 text / codespan
852
+ // token 的 `text` 欄位跳脫過再交出來,而 heading token 自己的 `text` 是
853
+ // 原始的 —— flattenTokenText() 用這一支把兩邊拉回同一個狀態。
854
+ // &amp; 必須最後解:先解它會讓 `&amp;lt;` 變成 `<` 而不是 `&lt;`。
855
+ function unescapeHtml(value) {
856
+ return String(value || '')
857
+ .replace(/&lt;/g, '<')
858
+ .replace(/&gt;/g, '>')
859
+ .replace(/&quot;/g, '"')
860
+ .replace(/&#39;/g, "'")
861
+ .replace(/&amp;/g, '&');
862
+ }
863
+
697
864
  // marked's tokenizer already escapes alt / title (its default image renderer
698
865
  // interpolates both raw), so this must NOT escape them again. Only the src is
699
866
  // ours; everything else — including cleanUrl() on a src we leave alone — stays
@@ -704,6 +871,13 @@ ${itemsHtml}
704
871
  const href = isObj ? (token.href || '') : (token || '');
705
872
  const title = isObj ? (token.title || '') : (arguments[1] || '');
706
873
  const text = isObj ? (token.text || '') : (arguments[2] || '');
874
+
875
+ // v3.4.0 §4: drawio 走 graphviz 那條路(建置時烤成內嵌 SVG),不走圖片
876
+ // 內嵌。判別看的是檔案的根元素而不是副檔名 —— ![](foo.xml) 只有在內容
877
+ // 真的是 mxfile / mxGraphModel 時才是圖,否則原樣落到下面既有的路徑。
878
+ const drawioOut = drawioPlaceholderFor(href, text);
879
+ if (drawioOut) return drawioOut;
880
+
707
881
  const uri = inlineImageSrc(href);
708
882
  if (!uri) return baseImage.apply(this, arguments);
709
883
  // A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
@@ -774,7 +948,7 @@ ${itemsHtml}
774
948
  // base64 in a data attribute — safe for arbitrary dot syntax (quotes,
775
949
  // angle brackets, newlines) inside an HTML attribute.
776
950
  const b64 = Buffer.from(code, 'utf8').toString('base64');
777
- return `\n<div class="graphviz" data-graphviz-src="${b64}"></div>\n`;
951
+ return '\n' + GRAPHVIZ_PLACEHOLDER.html([b64]) + '\n';
778
952
  }
779
953
  // Default: syntax-highlighted code block
780
954
  const escaped = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
@@ -932,6 +1106,20 @@ ${itemsHtml}
932
1106
  // falls entirely inside one part, so this is byte-identical to baking the
933
1107
  // joined string.
934
1108
  parts = await Promise.all(parts.map(bakeGraphviz));
1109
+ // v3.4.0 §4: 與 graphviz 同一個理由 —— 逐 part 烤,不是 join 之後才烤。
1110
+ // patch 路徑會把 parts[i] 直接插進 DOM,而用戶端沒有 drawio 渲染器
1111
+ // (__md2docInitDiagrams 只處理 mermaid 與 WaveDrom),沒烤的 part 會
1112
+ // 變成一個空的 <div class="drawio">。與 bakeGraphviz 不同的是 drawio
1113
+ // 需要一顆 headless Chromium,所以逐 part 依序(而非並行)烤,讓所有
1114
+ // part 共用同一顆延遲啟動的 browser,一份文件最多開一次。
1115
+ const drawioBrowser = makeLazyBrowserRef();
1116
+ try {
1117
+ for (let i = 0; i < parts.length; i++) {
1118
+ parts[i] = await bakeDrawio(parts[i], drawioBrowser);
1119
+ }
1120
+ } finally {
1121
+ await drawioBrowser.closeIfOpen();
1122
+ }
935
1123
  bodyHtml = parts.join('\n');
936
1124
  } else {
937
1125
  bodyHtml = marked.parse(mdPre);
@@ -1349,7 +1537,19 @@ ${itemsHtml}
1349
1537
  it. Fixed chrome takes no layout space, so the page has to make its
1350
1538
  own: this is the only place that offset is written. The mobile rule at
1351
1539
  max-width:1080px above already reserves 60px for .sidebar-toggle; the
1352
- bar sits above it, so the two offsets ADD there.
1540
+ bar sits above it. That 60px is NOT re-reserved here: T11-2 hides
1541
+ .sidebar-toggle in edit mode (the rule further down this stylesheet), so
1542
+ in edit mode there is no button under the bar to clear, and adding the
1543
+ two offsets left a band of empty page nothing was ever painted into.
1544
+ Measured on the build that did add them, at 1080/1000/800 wide:
1545
+ .page-layout's computed padding-top was 104px against a 44px toolbar —
1546
+ 60px of dead space, and a 105px gap between the toolbar's bottom edge
1547
+ and the first block against 45px at 1200 wide, where the desktop rule
1548
+ applies. The @media block below therefore still has to carry an explicit
1549
+ value, identical to the desktop one: the READER stylesheet's own
1550
+ 'max-width: 1080px' rule sets padding-top: 60px for the drawer toggle it
1551
+ still shows, and deleting this override would hand the edit page that
1552
+ 60px straight back.
1353
1553
  .reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
1354
1554
  down by the same amount here so the outline does not slide under the
1355
1555
  bar, with its height reduced to match so it still ends at the viewport
@@ -1360,7 +1560,7 @@ ${itemsHtml}
1360
1560
  height: calc(100vh - 48px - var(--ed-toolbar-h));
1361
1561
  }
1362
1562
  @media (max-width: 1080px) {
1363
- .page-layout { padding-top: calc(60px + var(--ed-toolbar-h)); }
1563
+ .page-layout { padding-top: var(--ed-toolbar-h); }
1364
1564
  .reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
1365
1565
  }
1366
1566
  /* The 大綱 button's DESKTOP half, on an attribute of its OWN.
@@ -1382,6 +1582,197 @@ ${itemsHtml}
1382
1582
  @media (min-width: 1081px) {
1383
1583
  body[data-ed-outline-hidden] .reader-sidebar,
1384
1584
  body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
1585
+ }
1586
+ /* T11-2 step 3: this button sits at z-index 100 (base stylesheet, above)
1587
+ directly under .ed-toolbar's z-index 101. Measured at 1080x900 and
1588
+ 800x900: document.elementFromPoint() at the button's own rect centre
1589
+ returns the toolbar, not the button, at both widths — a real pointer
1590
+ click never lands on it there, only a synthetic .click() does. The
1591
+ toolbar's own left/right are both 0, spanning the full viewport width,
1592
+ so nothing about that occlusion is a function of width; the two
1593
+ measured points are a sample of a band the toolbar covers edge to
1594
+ edge, not two lucky ones. It is safe to remove in edit mode because
1595
+ lib/editor/client.js's toolbar carries its own outline button
1596
+ (runToolbarAction()'s 'outline' case, which calls toggleOutlineSidebar(),
1597
+ the same function this button calls) which opens the identical drawer,
1598
+ and Task 8 gave that toolbar a keyboard cursor (Alt+F10) — the outline
1599
+ button is not among the ids Task 8 measured as stranding the caret on
1600
+ BODY after activation. This rule is edit-mode-only (editModeLayoutCss);
1601
+ reader-mode output never includes it, so the mobile drawer toggle there
1602
+ is unaffected. */
1603
+ .sidebar-toggle { display: none; }
1604
+ /* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
1605
+ contentEl.hidden = true and mounts a plain textarea in its place, and
1606
+ setDocMode() writes docMode onto body as data-ed-mode. Measured on that
1607
+ build: with ten TOC links listed, clicking the last one moved neither
1608
+ the (hidden) reading position nor the textarea's own scrollTop
1609
+ (3924 before and after) — the click target was a normal anchor jump /
1610
+ scrollIntoView() reaching into a subtree rooted at .content, and
1611
+ .content's own display:none is what absorbed it; the same subtree
1612
+ relationship holds for a search result's jump target. Hiding the TOC
1613
+ nav and the search-results panel while data-ed-mode="source" removes
1614
+ the two controls whose only reachable target sits in that hidden
1615
+ subtree; the search input row (.reader-tools) is left as-is — driving
1616
+ it against the textarea's own line numbers instead is a separate
1617
+ feasibility question, not answered here. */
1618
+ body[data-ed-mode="source"] .toc,
1619
+ body[data-ed-mode="source"] .search-results { display: none !important; }
1620
+
1621
+ /* ── v3.4.0 batch3 Task 6: the wavedrom editing surface ───────────────
1622
+ Both of these are mounted on document.body by lib/editor/client.js, and
1623
+ that is not a styling choice: .content's children are what the client
1624
+ reads back as its own render and what the WYSIWYG path serialises into
1625
+ the user's markdown, so a panel or a button parked in there ends up in
1626
+ their file. Everything below is therefore positioned against the
1627
+ viewport rather than against .content.
1628
+
1629
+ z-index 998 sits UNDER .ed-conflict (999) on purpose — a save conflict
1630
+ has to stay reachable even while the wave editor is open — and over the
1631
+ toolbar's own band, because the editor is modal over the document. */
1632
+ .ed-wave-edit-btn {
1633
+ position: fixed; z-index: 12; padding: 3px 10px; font-size: 12px;
1634
+ border: 1px solid #bbb; border-radius: 4px; background: #ffffffee;
1635
+ color: #222; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.2);
1636
+ }
1637
+ .ed-wave-edit-btn:hover { background: #fff; border-color: #888; }
1638
+ .ed-wave-overlay {
1639
+ position: fixed; inset: 0; z-index: 998; background: rgba(0,0,0,.45);
1640
+ display: flex; align-items: center; justify-content: center;
1641
+ }
1642
+ .ed-wave-panel {
1643
+ background: #fff; border-radius: 6px; width: min(1200px, 94vw);
1644
+ height: min(760px, 90vh); display: flex; flex-direction: column;
1645
+ box-shadow: 0 6px 28px rgba(0,0,0,.35); overflow: hidden;
1646
+ }
1647
+ .ed-wave-head {
1648
+ display: flex; align-items: center; justify-content: space-between;
1649
+ padding: 8px 12px; border-bottom: 1px solid #e2e2e2; font-weight: bold;
1650
+ }
1651
+ .ed-wave-close {
1652
+ border: 1px solid #bbb; border-radius: 4px; background: #f6f6f6;
1653
+ padding: 3px 12px; cursor: pointer;
1654
+ }
1655
+ /* Which way out keeps the work and which throws it away. Escape is a modal's
1656
+ universal close key and here it discards a whole session, so the header
1657
+ says so next to the button that does not. Normal weight against the bar's
1658
+ bold heading: it is a caption, not a warning the user has to clear. */
1659
+ .ed-wave-escape-hint {
1660
+ margin-left: auto; margin-right: 12px; font-weight: normal;
1661
+ font-size: 11px; color: #7a4b00;
1662
+ }
1663
+ .ed-wave-toolbar {
1664
+ display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
1665
+ padding: 8px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
1666
+ }
1667
+ .ed-wave-brushes, .ed-wave-cycleops, .ed-wave-meta {
1668
+ display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
1669
+ }
1670
+ .ed-wave-brush {
1671
+ width: 28px; height: 28px; border: 1px solid #c4c4c4; border-radius: 4px;
1672
+ background: #fff; font-family: monospace; font-size: 14px; cursor: pointer;
1673
+ }
1674
+ .ed-wave-brush.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
1675
+ .ed-wave-cycleops button, .ed-wave-toolbar button {
1676
+ border: 1px solid #c4c4c4; border-radius: 4px; background: #fff;
1677
+ padding: 3px 8px; font-size: 12px; cursor: pointer;
1678
+ }
1679
+ .ed-wave-toolbar button:disabled { opacity: .45; cursor: default; }
1680
+ .ed-wave-metafield { display: flex; align-items: center; gap: 4px; font-size: 12px; }
1681
+ .ed-wave-metafield input { width: 110px; font-size: 12px; padding: 2px 4px; }
1682
+ /* The "this drawing is not showing you everything" notice. period / phase /
1683
+ config.hscale all change what the ENGINE paints and none of them is
1684
+ modelled by the hand drawing, so a document carrying one says so instead
1685
+ of drawing a confident wrong picture. Full row, so it cannot be mistaken
1686
+ for another toolbar control. */
1687
+ .ed-wave-unmodelled {
1688
+ flex: 1 1 100%; font-size: 12px; color: #7a4b00; background: #fff6e0;
1689
+ border: 1px solid #e8c987; border-radius: 4px; padding: 4px 8px;
1690
+ }
1691
+ /* The stronger of the two notices: not "this drawing is incomplete" but
1692
+ "this block has nothing this editor can edit" (a reg: / assign: block).
1693
+ Same shape as its neighbour so the two read as one family, one step
1694
+ louder because the information gap is bigger. */
1695
+ .ed-wave-nolanes { color: #7a1f00; background: #ffe9e0; border-color: #e8a987; }
1696
+ .ed-wave-body { display: flex; flex: 1 1 auto; min-height: 0; }
1697
+ /* Widths chosen so the DRAWING still gets room at the narrowest viewport this
1698
+ repo's browser tests use (800x600 -> panel 752): 752 - 200 - 160 = 392px,
1699
+ which is 8 cycles at .ed-wave-canvas's 48px pitch. The canvas is a scroll
1700
+ container, so a wider diagram scrolls rather than pushing the preview off —
1701
+ but a canvas column narrower than a couple of cycles would make the drag
1702
+ gesture unusable, and that was the first thing measured to go wrong here
1703
+ (a press landed on the preview column, 116px past the canvas's clip). */
1704
+ .ed-wave-lanes {
1705
+ flex: 0 0 200px; border-right: 1px solid #e2e2e2; overflow: auto;
1706
+ padding: 8px 0 8px 0;
1707
+ }
1708
+ /* The rows are absolutely positioned at index * laneHeight so they line up
1709
+ with the drawing beside them cell for cell. A group title is NOT a row —
1710
+ measured against the pinned engine in Task 2 — so it is drawn as a label in
1711
+ the rail spanning its lanes' rows instead, which is the shape the engine
1712
+ itself gives it (horizontal space in the name column, no vertical space). */
1713
+ .ed-wave-lane-rows { position: relative; }
1714
+ .ed-wave-lane-row {
1715
+ position: absolute; left: 0; right: 0; display: flex; align-items: center;
1716
+ gap: 3px; padding-right: 6px; box-sizing: border-box;
1717
+ }
1718
+ .ed-wave-lane-row.is-selected { background: #eaf2fe; }
1719
+ .ed-wave-lane-name { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 2px 4px; }
1720
+ .ed-wave-lane-row button {
1721
+ border: 1px solid #cfcfcf; border-radius: 3px; background: #fff;
1722
+ font-size: 11px; line-height: 1; padding: 3px 4px; cursor: pointer;
1723
+ }
1724
+ .ed-wave-lane-row button:disabled { opacity: .4; cursor: default; }
1725
+ .ed-wave-lane-add-tail { position: absolute; left: 6px; }
1726
+ .ed-wave-group, .ed-wave-group-input {
1727
+ position: absolute; width: 14px; padding: 0; font-size: 11px;
1728
+ writing-mode: vertical-rl; text-orientation: mixed; overflow: hidden;
1729
+ border: 1px solid #d8d8d8; border-radius: 3px; background: #f2f2f2;
1730
+ color: #555; cursor: pointer; box-sizing: border-box;
1731
+ }
1732
+ .ed-wave-group-input { width: 16px; height: 90px; cursor: text; }
1733
+ .ed-wave-canvas-wrap { flex: 1 1 auto; overflow: auto; padding: 8px; }
1734
+ .ed-wave-canvas { display: block; }
1735
+ .ed-wave-grid { stroke: #ececec; stroke-width: 1; }
1736
+ .ed-wave-line, .ed-wave-edge { stroke: #111; stroke-width: 2; fill: none; }
1737
+ .ed-wave-weak { stroke-dasharray: 4 3; }
1738
+ .ed-wave-x { fill: #d9d9d9; stroke: #111; stroke-width: 2; }
1739
+ .ed-wave-bus { fill: #f5e9c8; stroke: #111; stroke-width: 2; }
1740
+ .ed-wave-bus-3 { fill: #d8ead3; }
1741
+ .ed-wave-bus-4 { fill: #d6e4f7; }
1742
+ .ed-wave-bus-5 { fill: #f7d6e4; }
1743
+ .ed-wave-buslabel { font-size: 11px; fill: #222; }
1744
+ .ed-wave-selection { fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1; }
1745
+ /* The KEYBOARD's own cell, and deliberately not the same mark as the
1746
+ selection: the selection is the RANGE a cycle operation acts on, the
1747
+ cursor is the ONE cell the next keystroke paints, and after Shift+left /
1748
+ right both are on screen at once. pointer-events: none so the rect can
1749
+ never swallow the press the drag underneath it is made of. */
1750
+ .ed-wave-cursor {
1751
+ fill: none; stroke: #d93025; stroke-width: 2; pointer-events: none;
1752
+ }
1753
+ /* The drawing is in the Tab cycle, so it has to SAY when it holds the
1754
+ keyboard: a cell cursor alone leaves a user who just Tabbed onto it with
1755
+ nothing that distinguishes "the drawing has the keyboard" from "the
1756
+ drawing is where I left a cursor before I walked away". */
1757
+ .ed-wave-canvas:focus { outline: 2px solid #1a73e8; outline-offset: 2px; }
1758
+ /* The discontinuity marker. The engine draws one gap <use> per pipe
1759
+ character; drawing none left the two pictures disagreeing on a lane whose
1760
+ levels agreed exactly. */
1761
+ .ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; }
1762
+ .ed-wave-preview {
1763
+ flex: 0 1 340px; min-width: 160px; border-left: 1px solid #e2e2e2;
1764
+ overflow: auto; padding: 8px;
1765
+ }
1766
+ .ed-wave-preview-label { font-size: 11px; color: #666; margin-bottom: 6px; }
1767
+ .ed-wave-status {
1768
+ border-top: 1px solid #e2e2e2; padding: 6px 12px; font-size: 12px;
1769
+ color: #444; min-height: 18px;
1770
+ }
1771
+ .ed-wave-parse-error { padding: 16px; }
1772
+ .ed-wave-parse-message { color: #b00020; font-weight: bold; }
1773
+ .ed-wave-parse-excerpt {
1774
+ background: #f6f6f6; border: 1px solid #e2e2e2; padding: 6px;
1775
+ overflow-x: auto; font-family: monospace; font-size: 12px;
1385
1776
  }`;
1386
1777
 
1387
1778
  const html = `<!DOCTYPE html>
@@ -1530,6 +1921,24 @@ ${itemsHtml}
1530
1921
  z-index: 98;
1531
1922
  }
1532
1923
  body[data-sidebar-open] .sidebar-scrim { display: block; }
1924
+ /* The scroll LOCK for this same attribute lives inside the
1925
+ '@media (max-width: 1080px)' block below, next to
1926
+ 'body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }'
1927
+ — deliberately NOT here at base scope, unlike the rule immediately
1928
+ above. Reason (Ruling I1, review round 2): the mobile-drawer meaning of
1929
+ data-sidebar-open, including .sidebar-scrim's own display:block rule
1930
+ just above, already leaks past its breakpoint by design (see the
1931
+ ⚠ comment above '@media (min-width: 1081px) { body[data-ed-outline-hidden]
1932
+ … }' for why the desktop 大綱 toggle needed its OWN attribute instead of
1933
+ reusing this one — reusing it once already painted the whole desktop
1934
+ viewport grey and click-dead). Nothing here clears data-sidebar-open on
1935
+ resize (grep this file's two resize listeners — scroll anchor and
1936
+ lightbox fit — neither touches it), so a lock at base scope would ALSO
1937
+ survive: open the drawer narrow, widen the window, and the desktop page
1938
+ would go unscrollable on top of the pre-existing grey scrim. The grey
1939
+ scrim is that already-accepted gap; an unscrollable desktop page is not
1940
+ — it is new, and scoping the lock to the same breakpoint the drawer
1941
+ itself is scoped to is what keeps it from outliving the drawer. */
1533
1942
  .reader-tools,
1534
1943
  .search-results,
1535
1944
  .toc {
@@ -1951,7 +2360,29 @@ ${itemsHtml}
1951
2360
  hr { border: none; border-top: 1px solid #e1e4e8; margin: 24px 0; }
1952
2361
  .mermaid { text-align: center; margin: 20px 0; }
1953
2362
  .graphviz { text-align: center; margin: 20px 0; }
1954
- .content img {
2363
+ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pay for this —
2364
+ conditional injection, same pattern as usesWaveDrom / usesMath below.
2365
+ .drawio needs position: relative because the (conditionally injected,
2366
+ see below) sheet bar is its absolute child — same relationship as
2367
+ .lightbox-stage / .lightbox-canvas, see CLAUDE.md. */
2368
+ .drawio { text-align: center; margin: 20px 0; position: relative; }
2369
+ .drawio svg { max-width: 100%; height: auto; }
2370
+ .drawio-page[hidden] { display: none !important; }
2371
+ .drawio-failed { color: #b91c1c; }
2372
+ ` : ''}${usesDrawioMultiPage ? ` /* Sheet bar: only when a document actually has a multi-page drawio
2373
+ diagram — a single-page file must not pay for it either. */
2374
+ .drawio-sheetbar {
2375
+ position: absolute; left: 50%; transform: translateX(-50%); bottom: 4px;
2376
+ display: none; gap: 4px; padding: 4px 6px; border-radius: 6px;
2377
+ background: rgba(255,255,255,.92); box-shadow: 0 1px 4px rgba(0,0,0,.18);
2378
+ }
2379
+ .drawio:hover .drawio-sheetbar { display: flex; }
2380
+ .drawio-sheetbar button {
2381
+ border: 0; background: transparent; cursor: pointer;
2382
+ font: inherit; padding: 2px 8px; border-radius: 4px;
2383
+ }
2384
+ .drawio-sheetbar button[aria-pressed="true"] { background: #dbeafe; }
2385
+ ` : ''} .content img {
1955
2386
  max-width: 100%;
1956
2387
  height: auto;
1957
2388
  }
@@ -1968,6 +2399,7 @@ ${itemsHtml}
1968
2399
  .content img,
1969
2400
  .content .mermaid,
1970
2401
  .content .graphviz,
2402
+ .content .drawio,
1971
2403
  .content [id^="WaveDrom_Display_"],
1972
2404
  .content .wavedrom-diagram { cursor: zoom-in; }
1973
2405
  .content a img { cursor: pointer; }
@@ -2092,6 +2524,17 @@ ${itemsHtml}
2092
2524
  background: #ffffff;
2093
2525
  }
2094
2526
  body[data-lightbox-open] { overflow: hidden; }
2527
+ /* The 'html, body { overflow-x: clip }' rule above keeps documentElement's
2528
+ own overflow-x at clip, not visible, so overflow does not propagate from
2529
+ body to the viewport per the CSS spec's root-propagation rule; the
2530
+ scrolling element stays documentElement, and its overflow-y sits at the
2531
+ visible default. That is the gap the body rule above does not close --
2532
+ measured in test/lightbox.test.js and test/editor-journey.test.js: a
2533
+ wheel over .lightbox-bar, PageDown and End all reach documentElement
2534
+ while it is open. Locking documentElement's own overflow here closes
2535
+ it; JS mirrors the data-lightbox-open attribute onto the html element
2536
+ in openLightbox/closeLightbox below. */
2537
+ html[data-lightbox-open] { overflow: hidden; }
2095
2538
  @media print { .lightbox { display: none !important; } }
2096
2539
  @media (max-width: 1080px) {
2097
2540
  .sidebar-toggle { display: inline-flex; align-items: center; }
@@ -2122,6 +2565,21 @@ ${itemsHtml}
2122
2565
  flex: initial;
2123
2566
  }
2124
2567
  body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
2568
+ /* Scroll lock for the drawer, same two-rule shape the lightbox uses a
2569
+ few lines above (body[data-lightbox-open] / html[data-lightbox-open])
2570
+ and for the identical reason: 'html, body { overflow-x: clip }' keeps
2571
+ documentElement's own overflow-y at the visible default, so it stays
2572
+ the scrolling element regardless of what 'body' says — a body-only
2573
+ rule leaves the page scrollable behind the scrim. Was missing
2574
+ entirely before this task (a missing lock, not a defeated one). Scoped
2575
+ to THIS breakpoint on purpose — see the comment above
2576
+ '.sidebar-scrim { display: none; … }' near the top of this stylesheet
2577
+ for why base scope would leak an unscrollable desktop page past a
2578
+ resize, since nothing clears data-sidebar-open on one. JS mirrors
2579
+ data-sidebar-open onto documentElement in setSidebarOpen(), matching
2580
+ openLightbox/closeLightbox. */
2581
+ body[data-sidebar-open] { overflow: hidden; }
2582
+ html[data-sidebar-open] { overflow: hidden; }
2125
2583
  .sidebar-splitter { display: none; }
2126
2584
  .heading-anchor { opacity: 1; }
2127
2585
  }
@@ -2318,7 +2776,21 @@ ${itemsHtml}
2318
2776
  this element's containing block and left:100% lands it flush against
2319
2777
  the menu's right edge. Source order matters: equal specificity with
2320
2778
  .ed-handle-menu's own left:-4px, so this rule must stay AFTER it. */
2321
- .ed-handle-submenu { left: 100%; margin-left: 4px; }
2779
+ /* Viewport-clamp backstop: openConvertSubmenu() (lib/editor/client.js)
2780
+ shifts the panel's top up so its bottom clears the viewport when there
2781
+ is room to do so — that shift is the clamp. This rule covers what the
2782
+ shift cannot: a viewport shorter than the panel, where the viewport's
2783
+ own top edge still leaves the panel's bottom edge off-screen. What
2784
+ happens under max-height, in the order it happens: CONVERT_TARGETS's
2785
+ buttons (lib/editor/convert-md.js), each carrying .ed-handle-menu-btn's
2786
+ own height (26px), first shrink via flex as the cap tightens (measured
2787
+ on this branch: 26px at a cap around 342px, stepping down to 18px
2788
+ around a 250px cap) — no scrolling yet, the list still fits end to
2789
+ end, just shorter per row. overflow-y: auto only starts moving content
2790
+ once the rows have already reached that floor and the cap is still
2791
+ tighter than the list at 18px per row. (No backticks in this comment:
2792
+ it lives inside a JS template literal.) */
2793
+ .ed-handle-submenu { left: 100%; margin-left: 4px; max-height: calc(100vh - 8px); overflow-y: auto; }
2322
2794
  .ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
2323
2795
  .ed-handle-menu-btn[hidden] { display: none; }
2324
2796
  /* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
@@ -2529,7 +3001,23 @@ ${itemsHtml}
2529
3001
  font: inherit; font-size: 13px; line-height: 1;
2530
3002
  white-space: nowrap; cursor: pointer;
2531
3003
  }
2532
- .ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
3004
+ .ed-seltb-btn:hover:not([aria-disabled="true"]) { background: rgba(255, 255, 255, 0.18); }
3005
+ /* v3.3.0 (F2): the four states these buttons report about the selection.
3006
+ 'true' (the selection is entirely inside a mark of this type) is the
3007
+ filled treatment the fixed toolbar already uses. 'mixed' (the selection
3008
+ overlaps marks of this type without lying inside one) is deliberately NOT
3009
+ the filled one — pressing it removes marks rather than adding any — so it
3010
+ gets a dashed edge and a half-strength ground instead. 'false' keeps the
3011
+ rule above. INERT is aria-disabled, never the disabled DOM property; see
3012
+ paintSelToolbarMarks() in lib/editor/client.js for why. */
3013
+ .ed-seltb-btn[aria-pressed="true"] {
3014
+ background: #e6edf3; color: #101215; border-color: #e6edf3;
3015
+ }
3016
+ .ed-seltb-btn[aria-pressed="mixed"] {
3017
+ background: rgba(230, 237, 243, 0.3);
3018
+ border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
3019
+ }
3020
+ .ed-seltb-btn[aria-disabled="true"] { opacity: 0.38; cursor: default; }
2533
3021
  .ed-seltb-b { font-weight: bold; }
2534
3022
  .ed-seltb-i { font-style: italic; }
2535
3023
  .ed-seltb-s { text-decoration: line-through; }
@@ -2550,13 +3038,91 @@ ${itemsHtml}
2550
3038
  .ed-toolbar {
2551
3039
  position: fixed; top: 0; left: 0; right: 0; z-index: 101;
2552
3040
  box-sizing: border-box; height: var(--ed-toolbar-h);
2553
- display: flex; align-items: center; justify-content: center;
3041
+ display: flex; align-items: center; justify-content: flex-start;
2554
3042
  flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
2555
- gap: 6px; padding: 8px 12px;
3043
+ /* v3.4.0 §3: gap was 6px until the save button (id 'save', file group)
3044
+ landed at the FRONT of the row. Ruling T11-2 (below) requires the
3045
+ outline (☰) button's own centre point to hit-test successfully at
3046
+ 800/1080px WITHOUT any scrolling — it is the only way to open the
3047
+ sidebar drawer in edit mode, and that test deliberately does not
3048
+ scroll first. A leading addition shifts every button after it right
3049
+ by its own width; MEASURED at 800px wide before this change: 22
3050
+ buttons put outline's centre at x=795 (row content 975px, overflow
3051
+ 175px) — already only a 5px margin inside the 800px viewport. Adding
3052
+ save (23 buttons, row content 1026px, overflow 226px) pushed it to
3053
+ x=846, entirely off screen (elementFromPoint at that point returns
3054
+ null — off-screen, not occluded by another element; contrast the
3055
+ .ed-toolbar-status overlap a few lines down, which returns the
3056
+ covered BUTTON because that slot has pointer-events: none, an
3057
+ entirely different failure shape). This gap value was chosen
3058
+ empirically (a one-off puppeteer script, not a formula — flex gap
3059
+ plus intrinsic button/separator widths do not sum cleanly by hand):
3060
+ re-measured at 800px, outline's centre lands at x=765, a 35px margin
3061
+ — comfortably more headroom than the original 5px, not just barely
3062
+ restored. Also re-verified against the F12 "narrow viewport,
3063
+ scrolling allowed" sweep just below (420/640/820px): every button,
3064
+ save included, still reaches an empty unreachable list and an empty
3065
+ neverClear list at all three — this change does not trade the T11-2
3066
+ no-scroll guarantee for the F12 with-scroll one. */
3067
+ gap: 3px; padding: 8px 12px;
3068
+ /* F12, both halves measured on this build at 900px viewport height.
3069
+ justify-content was center. Centering an overflowing flex row splits
3070
+ the overflow across BOTH ends, and the leading end of a scroll
3071
+ container's content is not reachable by scrolling: driven at 820 /
3072
+ 640 / 420 px wide, sweeping scrollLeft from 0 to its maximum in 8px
3073
+ steps and reading every button's rect, the buttons that never once sat
3074
+ inside the viewport were [undo] / [undo, redo, headings] /
3075
+ [undo, redo, headings, quote, code, list]. On this build — leading-edge
3076
+ layout plus the padding-right below, driven together, not one at a time
3077
+ — the same sweep leaves that set empty at all three widths.
3078
+
3079
+ padding-right buys the buttons room to clear .ed-toolbar-status, whose
3080
+ fixed box occupies the viewport's right 124px (right: 12px plus
3081
+ min-width: 7rem at this font size). The claim it supports is narrow:
3082
+ SOME scroll position exists at which a given button is clear of the
3083
+ slot. It is NOT "a button can never sit under the slot" — driven at
3084
+ scrollLeft 0 after this change, [outline, preview] still overlap the
3085
+ slot at a 900px viewport and [check, bold, italic, strike] do at 420px.
3086
+ What makes that harmless is the slot's own pointer-events: none, in
3087
+ the rule further down — the load-bearing line, not this one. */
3088
+ padding-right: 124px;
2556
3089
  background: rgba(16, 18, 21, 0.92); color: #e6edf3;
3090
+ /* F12, second half: before this the bar scrolled but nothing on screen
3091
+ said so, so a button parked outside the viewport looked absent rather
3092
+ than off to one side. Two edge gradients, painted only on the side
3093
+ that still has content hidden behind it; the client runtime writes
3094
+ data-ed-tb-overflow from scrollLeft against the scroll maximum.
3095
+
3096
+ Deliberately a background layer on the bar itself rather than a new
3097
+ overlay node. An overlay would have to be taken out of flow to avoid
3098
+ becoming a flex item in this row, and an out-of-flow child of this bar
3099
+ anchors to the scrolled content box and drifts with it (the
3100
+ .ed-toolbar-status note below records that measurement) — while
3101
+ anchoring it to the viewport instead would add a floating layer to the
3102
+ set test/editor-journey.test.js enumerates and demands a scroll answer
3103
+ for. A background layer needs neither: background-attachment defaults
3104
+ to scroll, which pins the gradient to this element's padding box while
3105
+ its contents move underneath.
3106
+
3107
+ These gradients sit behind the buttons, because that is where the
3108
+ painting order puts a background layer: above the background colour,
3109
+ below the element's content. That is the reason they are dark rather
3110
+ than light — darkening the ground a translucent button sits on is what
3111
+ reads as content passing under an edge, where a light wash behind the
3112
+ same button would not. */
3113
+ --ed-tb-edge-l: rgba(0, 0, 0, 0);
3114
+ --ed-tb-edge-r: rgba(0, 0, 0, 0);
3115
+ background-image:
3116
+ linear-gradient(to right, var(--ed-tb-edge-l), rgba(0, 0, 0, 0)),
3117
+ linear-gradient(to left, var(--ed-tb-edge-r), rgba(0, 0, 0, 0));
3118
+ background-repeat: no-repeat;
3119
+ background-position: left center, right center;
3120
+ background-size: 26px 100%, 26px 100%;
2557
3121
  font-size: 13px;
2558
3122
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
2559
3123
  }
3124
+ .ed-toolbar[data-ed-tb-overflow~="left"] { --ed-tb-edge-l: rgba(0, 0, 0, 0.55); }
3125
+ .ed-toolbar[data-ed-tb-overflow~="right"] { --ed-tb-edge-r: rgba(0, 0, 0, 0.55); }
2560
3126
  .ed-toolbar-sep {
2561
3127
  flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
2562
3128
  background: rgba(255, 255, 255, 0.25);
@@ -2570,18 +3136,45 @@ ${itemsHtml}
2570
3136
  white-space: nowrap; cursor: pointer;
2571
3137
  }
2572
3138
  .ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
3139
+ /* F12: where the toolbar's keyboard cursor is. An outline rather than a
3140
+ background so it stays legible on a button that is already pressed-in
3141
+ (aria-pressed paints that one nearly white). Claim no more for it than a
3142
+ keyboard route: the buttons keep their tabindex="-1" and the caret stays
3143
+ in the document, so no focus move happens for assistive technology to
3144
+ follow, and what this cue does for a screen reader has not been measured
3145
+ here. What it does do is let the row be walked without a mouse. */
3146
+ .ed-toolbar-btn[data-ed-tb-cursor] {
3147
+ outline: 2px solid #6ea8fe; outline-offset: 1px;
3148
+ }
2573
3149
  .ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
2574
3150
  .ed-toolbar-btn[aria-pressed="true"] {
2575
3151
  background: #e6edf3; color: #101215; border-color: #e6edf3;
2576
3152
  }
3153
+ /* v3.3.0 (F2): the middle state, matching .ed-seltb-btn[aria-pressed="mixed"]
3154
+ above so the two bars answer the same question the same way. */
3155
+ .ed-toolbar-btn[aria-pressed="mixed"] {
3156
+ background: rgba(230, 237, 243, 0.3);
3157
+ border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
3158
+ }
2577
3159
  .ed-toolbar-b { font-weight: bold; }
2578
3160
  .ed-toolbar-i { font-style: italic; }
2579
3161
  .ed-toolbar-s { text-decoration: line-through; }
2580
- /* H1 fix: the brief's literal margin-left: auto (a flex-flow item) was
2581
- measured to break .ed-toolbar's justify-content: center — an auto
2582
- margin on any flex item absorbs ALL positive free space before
2583
- justify-content sees any, so the ~22-button group snapped flush left on
2584
- every viewport wide enough not to overflow. position: fixed removes
3162
+ /* H1 fix — and its reason has since moved, so both readings are kept here
3163
+ rather than one silently standing in for the other. The brief's literal
3164
+ margin-left: auto (a flex-flow item) was measured on v3.2.0 to break
3165
+ .ed-toolbar's justify-content: center — an auto margin on any flex item
3166
+ absorbs ALL positive free space before justify-content sees any, so the
3167
+ ~22-button group snapped flush left on every viewport wide enough not to
3168
+ overflow. F12 replaced that declaration with justify-content: flex-start
3169
+ (the .ed-toolbar rule above), so flush left IS the shipped layout now and
3170
+ that particular failure mode no longer exists. What the rule guards today
3171
+ is the slot's out-of-flow-ness, and the assertion beside it in
3172
+ test/editmode-render.test.js carries the re-driven numbers: against the
3173
+ slot as it ships the margin changes nothing at all, while putting the slot
3174
+ back in flow with it turns the slot into a scrolling flex item
3175
+ (scrollWidth 975 -> 1093 at 420px) and moves its rect off the strip
3176
+ .ed-toolbar's padding-right reserves for it ([1276, 1388] -> [1164, 1276]
3177
+ at 1400px). position: fixed removes
2585
3178
  the slot from the flex layout entirely (buttons measured byte-identical
2586
3179
  to a build with the slot absent, at both a 1400px and a 420px viewport),
2587
3180
  and — unlike position: absolute against .ed-toolbar's own
@@ -2593,14 +3186,17 @@ ${itemsHtml}
2593
3186
  H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
2594
3187
  v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
2595
3188
  so it no longer ships empty — but that content is inert TEXT, never a
2596
- control, which is exactly what keeps the rule below sufficient. At narrow viewports .ed-toolbar's own
2597
- overflow-x: auto lets the button row scroll to a rest position where
2598
- this fixed 7rem box's footprint physically covers three real buttons
2599
- (image / outline / preview, measured at 420px width, scrollLeft at its
2600
- max 221) — document.elementFromPoint() over any of them resolves to
2601
- this span, not the button underneath, and a real click on it never
2602
- reaches the button's listener. Harmless today only because the span
2603
- carries nothing to interact with. WHOEVER PUTS A CONTROL HERE (or
3189
+ control, which is exactly what keeps the rule below sufficient. The
3190
+ overlap it keeps harmless is real and still present after F12 moved this
3191
+ bar's scroll geometry: at scrollLeft 0, this box's footprint covers
3192
+ outline and preview at a 900px-wide viewport, and check / bold / italic /
3193
+ strike at 420px. Re-driven on this build, hit-testing the middle of each
3194
+ of those overlaps: with the rule as it ships,
3195
+ document.elementFromPoint() answers the BUTTON at every one of them; flip
3196
+ this span's pointer-events to auto and leave everything else alone, and
3197
+ every one of them answers this span instead. That reading is the same hit
3198
+ test a press resolves through, and the rule below is what stands between
3199
+ the two readings — not the span's emptiness. WHOEVER PUTS A CONTROL HERE (or
2604
3200
  wires up two-way sync, §3) MUST remove or scope this
2605
3201
  rule AND independently solve the overlap (e.g. reflow the slot in-flow
2606
3202
  at narrow widths, or shrink/hide it below some width) — simply
@@ -2613,13 +3209,19 @@ ${itemsHtml}
2613
3209
  pointer-events: none; }
2614
3210
  /* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
2615
3211
  anchored to a fixed bar rather than moved into a block, so it is
2616
- positioned in viewport coordinates by the client runtime. */
3212
+ positioned in viewport coordinates by the client runtime.
3213
+ max-height/overflow-y: same backstop role as .ed-handle-submenu above
3214
+ (Task 9 fix round 1, Ruling T9-5) — toggleToolbarHeadingMenu()
3215
+ (lib/editor/client.js) does the shift-to-fit; this covers a viewport
3216
+ shorter than the panel, where the shift alone cannot bring the whole
3217
+ list on-screen. */
2617
3218
  .ed-toolbar-menu {
2618
3219
  position: fixed; z-index: 102;
2619
3220
  display: flex; flex-direction: column; gap: 2px;
2620
3221
  padding: 4px; border-radius: 8px;
2621
3222
  background: rgba(16, 18, 21, 0.96); color: #e6edf3;
2622
3223
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
3224
+ max-height: calc(100vh - 8px); overflow-y: auto;
2623
3225
  }
2624
3226
  .ed-toolbar-menu[hidden] { display: none; }
2625
3227
  .ed-toolbar-menu-btn {
@@ -2628,6 +3230,18 @@ ${itemsHtml}
2628
3230
  font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
2629
3231
  }
2630
3232
  .ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
3233
+ /* backlog #7 (Task 9 review, M1): the panel's OWN keyboard cursor
3234
+ (lib/editor/client.js's toolbarMenuIndex / data-ed-tb-menu-cursor) had
3235
+ no matching rule at all — the fix made the six items operable by
3236
+ keyboard but left the cursor invisible, the exact backlog #10 class of
3237
+ defect ("can act, but cannot see where"). Same outline idiom as
3238
+ .ed-toolbar-btn[data-ed-tb-cursor] above, for the same reason (legible
3239
+ over :hover's own background, and over nothing at all when neither
3240
+ applies). */
3241
+ .ed-toolbar-menu-btn[data-ed-tb-menu-cursor] {
3242
+ outline: 2px solid #6ea8fe; outline-offset: -2px;
3243
+ background: rgba(255, 255, 255, 0.18);
3244
+ }
2631
3245
  /* 追加 4: the whole-document source escape hatch. Replaces the rendered
2632
3246
  content column for the duration of source mode; the document's own
2633
3247
  lines[] is untouched until the mode is left (see docsource.js). */
@@ -2651,8 +3265,45 @@ ${itemsHtml}
2651
3265
  }
2652
3266
  .ed-commit { color: #0a7a0a; }
2653
3267
  .ed-cancel { color: #b00020; }
3268
+ /* backlog #8: 'top: 0' used to put this banner directly OVER .ed-toolbar
3269
+ (same top-of-viewport band, and z-index 999 against the bar's 101 wins
3270
+ the paint) — MEASURED (task-10-toolbar-probe.js): all 23 toolbar
3271
+ buttons' own centre points hit-tested to the banner, not the button,
3272
+ for every .ed-conflict banner showBanner() raises (conflict /
3273
+ render-failed / save-failed / dropped-gesture / structural-refusal /
3274
+ swallow all share this one class — there is exactly one producer, see
3275
+ showBanner() in lib/editor/client.js). Floors at the SAME
3276
+ --ed-toolbar-h token placeTeMenuAboveCell()'s .ed-te-menu already
3277
+ clamps to (if top < topInset, top = r.bottom + …, both in
3278
+ lib/editor/client.js) — rather than inventing a new offset.
3279
+ Final-review I1 correction: an earlier draft of this comment also named
3280
+ positionSelToolbar()'s .ed-seltb here, as if it clamped to this same
3281
+ token. Retracted — MEASURED: positionSelToolbar() reads --ed-toolbar-h
3282
+ only to decide whether a selection scrolled fully out of view should
3283
+ hide the bar entirely (rect.bottom <= topInset); the vertical floor it
3284
+ actually clamps 'top' to is a bare margin = 4 local, independent
3285
+ of the toolbar's height. Swept 66 scroll positions: at 17 of them
3286
+ .ed-seltb's own top measures as low as 14.75px — inside this banner's
3287
+ band — with no --ed-toolbar-h involved (task10-seltb-check-probe.js).
3288
+ Not reproduced with a LIVE banner open at the same time (two attempts:
3289
+ a burst's bold toggle never hits /api/render, and Ctrl+S's
3290
+ switchAwayFrom() tears the bar down before a banner could show) — left
3291
+ as a geometry note in CHANGELOG.md's Known issues rather than a
3292
+ behaviour change.
3293
+ Deliberately NOT a z-index change: the toolbar comment above
3294
+ ("z-index sits ... below .ed-conflict (999): a save conflict must
3295
+ always be reachable") still holds — the banner stays the topmost
3296
+ thing, it just no longer shares the toolbar's own band to be on top
3297
+ OF. showBanner() additionally closes .ed-te-grip / .ed-tb-insert /
3298
+ .ed-te-menu / .ed-toolbar-menu — four dynamic fixed overlays that can
3299
+ each land in that band, though (per the correction above) only
3300
+ .ed-te-menu's own code floors there via --ed-toolbar-h; the others
3301
+ land nearby as a side effect of their own trigger's position, not a
3302
+ shared clamp — see showBanner()'s parallel comment in
3303
+ lib/editor/client.js. Closed regardless, so a banner can never land on
3304
+ top of one of those instead. */
2654
3305
  .ed-conflict {
2655
- position: fixed; top: 0; left: 0; right: 0; padding: 10px;
3306
+ position: fixed; top: var(--ed-toolbar-h); left: 0; right: 0; padding: 10px;
2656
3307
  background: #b00020; color: #fff; z-index: 999; text-align: center;
2657
3308
  display: flex; align-items: center; justify-content: center; gap: 12px;
2658
3309
  }
@@ -3246,8 +3897,10 @@ ${mermaidInitTag}`)
3246
3897
  function setSidebarOpen(open) {
3247
3898
  if (open) {
3248
3899
  document.body.setAttribute('data-sidebar-open', '');
3900
+ document.documentElement.setAttribute('data-sidebar-open', '');
3249
3901
  } else {
3250
3902
  document.body.removeAttribute('data-sidebar-open');
3903
+ document.documentElement.removeAttribute('data-sidebar-open');
3251
3904
  }
3252
3905
  if (sidebarToggle) sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
3253
3906
  }
@@ -3414,11 +4067,62 @@ ${mermaidInitTag}`)
3414
4067
 
3415
4068
  captureScrollAnchor();
3416
4069
 
4070
+ // ── drawio sheet bar ─────────────────────────────────────────────────────
4071
+ // v3.4.0 §4: switching pages is a pure hidden-attribute toggle. Every page was
4072
+ // rendered to inert SVG at build time (lib/drawio.js's bake pass), so there
4073
+ // is no engine, no diagrams.net script, and no network request here — this
4074
+ // works exactly the same with the network unplugged. Only injected when
4075
+ // usesDrawioMultiPage: a single-page .drawio file must not pay for this UI
4076
+ // at all (matching CSS gate is in the <style> block above).
4077
+ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusable hook instead of a
4078
+ // bare one-shot forEach, so the edit-mode client runtime can re-invoke it
4079
+ // — scoped to just one freshly swapped-in block — after a background
4080
+ // refresh re-bakes a \`.drawio\`/\`.xml\` source that changed on disk (see
4081
+ // lib/editor/client.js's stale-drawio handler). Still gated on
4082
+ // usesDrawioMultiPage exactly as before: a single-page document must never
4083
+ // pay for (or even mention) this UI at all — a guard test asserts the
4084
+ // whole feature's class name is absent byte-for-byte from that output, so
4085
+ // nothing below this line may ever run, or exist as source text, outside
4086
+ // this branch.
4087
+ window.__md2docInitDrawioSheetBar = function (rootEl) {
4088
+ var scope = rootEl || document;
4089
+ scope.querySelectorAll('.drawio[data-drawio-pages]').forEach(function (box) {
4090
+ if (box.querySelector('.drawio-sheetbar')) return; // already wired
4091
+ var names;
4092
+ try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
4093
+ catch (e) { return; }
4094
+ if (!Array.isArray(names)) return;
4095
+ if (names.length < 2) return;
4096
+ var pages = box.querySelectorAll('.drawio-page');
4097
+ var bar = document.createElement('div');
4098
+ bar.className = 'drawio-sheetbar';
4099
+ names.forEach(function (name, i) {
4100
+ var b = document.createElement('button');
4101
+ b.type = 'button';
4102
+ b.textContent = name || String(i + 1);
4103
+ b.setAttribute('aria-pressed', pages[i] && !pages[i].hidden ? 'true' : 'false');
4104
+ b.addEventListener('click', function (event) {
4105
+ // The bar lives inside .drawio, which is also a lightbox click
4106
+ // target below (LIGHTBOX_TARGETS) — without stopPropagation a page
4107
+ // switch would also bubble up and pop the lightbox open.
4108
+ event.stopPropagation();
4109
+ for (var j = 0; j < pages.length; j++) pages[j].hidden = j !== i;
4110
+ bar.querySelectorAll('button').forEach(function (x, j) {
4111
+ x.setAttribute('aria-pressed', j === i ? 'true' : 'false');
4112
+ });
4113
+ });
4114
+ bar.appendChild(b);
4115
+ });
4116
+ box.appendChild(bar);
4117
+ });
4118
+ };
4119
+ window.__md2docInitDrawioSheetBar(document);
4120
+ ` : ''}
3417
4121
  // ── Diagram / image lightbox ──────────────────────────────────────────────
3418
4122
  // Specs are read at 100% but their block diagrams and waveforms are drawn far
3419
4123
  // wider than the column, so the inline copy is unreadably small. Clicking one
3420
4124
  // pops it into a modal stage that zooms and scrolls.
3421
- var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
4125
+ var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
3422
4126
  var LIGHTBOX_MIN_ZOOM = 0.05;
3423
4127
  var LIGHTBOX_MAX_ZOOM = 8;
3424
4128
  var LIGHTBOX_STEP = 1.25;
@@ -4156,6 +4860,15 @@ ${mermaidInitTag}`)
4156
4860
 
4157
4861
  function lightboxSourceOf(node) {
4158
4862
  if (node.tagName === 'IMG') return node;
4863
+ // A multi-page .drawio bakes every page's <svg> into the DOM (hidden
4864
+ // ones included, so page switching needs no engine) — a plain
4865
+ // querySelector('svg') returns the FIRST one in document order, which is
4866
+ // wrong whenever page 1 isn't the one currently shown. Pick the visible
4867
+ // page's svg instead.
4868
+ if (node.classList && node.classList.contains('drawio')) {
4869
+ var visiblePage = node.querySelector('.drawio-page:not([hidden])');
4870
+ if (visiblePage) return visiblePage.querySelector('svg') || node;
4871
+ }
4159
4872
  return node.querySelector('svg') || node;
4160
4873
  }
4161
4874
 
@@ -4206,6 +4919,7 @@ ${mermaidInitTag}`)
4206
4919
  lightboxReturnScroll = window.scrollY;
4207
4920
  suppressAnchorCapture = true;
4208
4921
  document.body.setAttribute('data-lightbox-open', '');
4922
+ document.documentElement.setAttribute('data-lightbox-open', '');
4209
4923
  lightboxEl.hidden = false;
4210
4924
  lightboxStage.scrollTop = 0;
4211
4925
  lightboxStage.scrollLeft = 0;
@@ -4218,6 +4932,7 @@ ${mermaidInitTag}`)
4218
4932
  if (!lightboxIsOpen()) return;
4219
4933
  lightboxEl.hidden = true;
4220
4934
  document.body.removeAttribute('data-lightbox-open');
4935
+ document.documentElement.removeAttribute('data-lightbox-open');
4221
4936
  annoCancelDraft();
4222
4937
  annoSyncInline();
4223
4938
  annoSourceNode = null;
@@ -4289,12 +5004,34 @@ ${mermaidInitTag}`)
4289
5004
  </body>
4290
5005
  </html>`;
4291
5006
 
4292
- const bakedHtml = await bakeGraphviz(html);
5007
+ const gvHtml = await bakeGraphviz(html);
4293
5008
  // editMode's bodyHtml is already baked per-part above; re-baking here is a
4294
5009
  // short-circuit no-op (bakeGraphviz bails out when there's no
4295
5010
  // data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
4296
- const bakedBody = await bakeGraphviz(bodyHtml);
4297
- return { html: bakedHtml, bodyHtml: bakedBody, parts, blocks };
5011
+ const gvBody = await bakeGraphviz(bodyHtml);
5012
+ // v3.4.0 §4: same short-circuit story for drawio. editMode already baked
5013
+ // every part above, so both calls below are no-ops there (no browser is
5014
+ // opened) — this is only load-bearing for the non-editMode
5015
+ // `bodyHtml = marked.parse(mdPre)` path, which never went through the
5016
+ // per-part loop. `html` and `bodyHtml` share the same placeholders (html
5017
+ // embeds bodyHtml), so both bakes share one lazily-opened browser instead
5018
+ // of paying for two.
5019
+ const drawioBrowser = makeLazyBrowserRef();
5020
+ let bakedHtml, bakedBody;
5021
+ try {
5022
+ bakedHtml = await bakeDrawio(gvHtml, drawioBrowser);
5023
+ bakedBody = await bakeDrawio(gvBody, drawioBrowser);
5024
+ } finally {
5025
+ await drawioBrowser.closeIfOpen();
5026
+ }
5027
+ return {
5028
+ html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
5029
+ // v3.4.0 batch2 Task 6: an added field, not a shape change to the four
5030
+ // existing ones — every call site destructures the specific keys it
5031
+ // wants (checked across lib/editor/server.js and test/*.js), none reads
5032
+ // the object as a whole, so an addition here is backward compatible.
5033
+ drawioRefs: Array.from(drawioRefs, ([p, stamp]) => ({ path: p, stamp })),
5034
+ };
4298
5035
  }
4299
5036
  module.exports = { renderMarkdown };
4300
5037
 
@@ -4302,7 +5039,7 @@ module.exports = { renderMarkdown };
4302
5039
  // in-process WASM engine. Loads the WASM module only when at least one dot
4303
5040
  // block exists, so text-only docs pay nothing.
4304
5041
  async function bakeGraphviz(htmlStr) {
4305
- if (!/data-graphviz-src=/.test(htmlStr)) return htmlStr;
5042
+ if (!GRAPHVIZ_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
4306
5043
  let Graphviz;
4307
5044
  try {
4308
5045
  ({ Graphviz } = require('@hpcc-js/wasm-graphviz'));
@@ -4311,7 +5048,7 @@ async function bakeGraphviz(htmlStr) {
4311
5048
  return htmlStr;
4312
5049
  }
4313
5050
  const gv = await Graphviz.load();
4314
- return htmlStr.replace(/<div class="graphviz" data-graphviz-src="([^"]*)"><\/div>/g, (m, b64) => {
5051
+ return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
4315
5052
  const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
4316
5053
  try {
4317
5054
  const svg = gv.dot(dotSrc)
@@ -4336,6 +5073,111 @@ function launchBrowser(puppeteer) {
4336
5073
  });
4337
5074
  }
4338
5075
 
5076
+ // v3.4.0 §4. A document with no drawio reference must not pay for opening
5077
+ // Chromium. `get()` launches on its first call only (and every later caller
5078
+ // awaits the same promise, so a document with several drawio placeholders
5079
+ // still opens exactly one browser); `closeIfOpen()` closes it only if `get()`
5080
+ // was ever actually called.
5081
+ function makeLazyBrowserRef() {
5082
+ let browserPromise = null;
5083
+ return {
5084
+ get() {
5085
+ if (!browserPromise) {
5086
+ const puppeteer = require('puppeteer');
5087
+ browserPromise = launchBrowser(puppeteer);
5088
+ }
5089
+ return browserPromise;
5090
+ },
5091
+ async closeIfOpen() {
5092
+ if (!browserPromise) return;
5093
+ const browser = await browserPromise;
5094
+ await browser.close();
5095
+ },
5096
+ };
5097
+ }
5098
+
5099
+ // Render every deferred drawio placeholder to inline SVG using a headless
5100
+ // Chromium running the vendored drawio viewer (lib/drawio.js). Every page is
5101
+ // baked, not just the one selected by the markdown fragment (`#PageName`),
5102
+ // so switching pages client-side is a pure `hidden`-attribute toggle with no
5103
+ // engine involved — see lib/drawio.js's own comment on why a Node-side XML
5104
+ // parser can't substitute for the real layout engine.
5105
+ async function bakeDrawio(htmlStr, browserRef) {
5106
+ if (!DRAWIO_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
5107
+
5108
+ // replace()'s callback can't be async, so collect the jobs first and apply
5109
+ // them (each with its own possible browser-page await) afterwards.
5110
+ const jobs = [];
5111
+ htmlStr.replace(DRAWIO_PLACEHOLDER.re, (m, b64, pageIdx, altB64) => {
5112
+ jobs.push({
5113
+ m,
5114
+ b64,
5115
+ pageIdx: Number(pageIdx),
5116
+ alt: Buffer.from(altB64, 'base64').toString('utf8'),
5117
+ });
5118
+ return m;
5119
+ });
5120
+
5121
+ for (const job of jobs) {
5122
+ const xml = Buffer.from(job.b64, 'base64').toString('utf8');
5123
+ const names = drawio.pageNamesOf(xml);
5124
+ let svgs;
5125
+ try {
5126
+ // One document shares one browser: browserRef.get() only really opens
5127
+ // Chromium on its first call.
5128
+ svgs = await drawio.bakeDrawioSvg(xml, { browser: await browserRef.get() });
5129
+ } catch (e) {
5130
+ // page.evaluate() can reject with a plain string rather than an Error
5131
+ // (observed: puppeteer surfacing a serialization failure as "buffer
5132
+ // error") — e.message would silently read as "undefined" then.
5133
+ const reason = (e && e.message) ? e.message : String(e);
5134
+ console.error('[WARN] drawio render failed:', reason);
5135
+ // A silent blank is the most expensive failure this project has learned
5136
+ // from (v3.3.0) — leave a visible placeholder naming the error instead.
5137
+ const msg = ('drawio 圖無法渲染:' + reason)
5138
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5139
+ // The callback form, NOT a replacement string: `msg` is generated
5140
+ // content (a renderer error message), and String.replace() interprets
5141
+ // $$ / $& / $` / $' inside a replacement STRING even when the pattern
5142
+ // is a plain string. A callback's return value is never re-scanned.
5143
+ // See the same note on the success path below.
5144
+ htmlStr = htmlStr.replace(job.m,
5145
+ () => `<div class="drawio drawio-failed"><p>${msg}</p></div>`);
5146
+ continue;
5147
+ }
5148
+ const inner = svgs.map((svg, i) =>
5149
+ `<div class="drawio-page"${i === job.pageIdx ? '' : ' hidden'}>${svg}</div>`).join('');
5150
+ // Page names may contain any character (commas and quotes included), so
5151
+ // they travel as base64-encoded JSON rather than a delimited string — a
5152
+ // delimiter breaks on both "two names glued together" and "a name that
5153
+ // happens to contain the delimiter", and a literal control character
5154
+ // living in the document is invisible to boot.
5155
+ const attr = Buffer.from(JSON.stringify(names), 'utf8').toString('base64');
5156
+ // v3.4.0 batch2 final review R5 — the author already wrote a name in
5157
+ // `![Architecture overview](arch.drawio)`; give the baked box that name.
5158
+ // The role differs by page count on purpose: a multi-page box has the
5159
+ // sheet bar's <button>s appended INSIDE it at runtime, and role="img"
5160
+ // would take them off the accessibility tree entirely, so those boxes get
5161
+ // role="group" — also nameable, children still exposed. No alt text means
5162
+ // no attributes at all: an empty accessible name is worse than none.
5163
+ const named = job.alt
5164
+ ? ` role="${names.length >= 2 ? 'group' : 'img'}" aria-label="${job.alt}"`
5165
+ : '';
5166
+ // v3.4.0 batch2 final review B2 — the callback form is load-bearing.
5167
+ // `inner` is the baked SVG, i.e. arbitrary diagram text, and a
5168
+ // replacement STRING has $$ / $& / $` / $' interpreted in it even when
5169
+ // the pattern is a plain string: a `$$` label silently collapsed to a
5170
+ // single `$`, and a `$&` label (the byte sequence any label with `$`
5171
+ // before `&` produces once the SVG escapes the `&`) spliced this very
5172
+ // placeholder — the whole .drawio file's base64 — into the middle of a
5173
+ // <text> element. bakeGraphviz() above was always a callback; this is the
5174
+ // same rule. Keep it a callback.
5175
+ htmlStr = htmlStr.replace(job.m,
5176
+ () => `<div class="drawio" data-drawio-pages="${attr}"${named}>${inner}</div>`);
5177
+ }
5178
+ return htmlStr;
5179
+ }
5180
+
4339
5181
  // Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
4340
5182
  // the diagram-engine runtime scripts so the output HTML carries no diagram JS.
4341
5183
  async function bakeDiagrams(htmlStr, dstPath) {