@helping-ai-workflow/md2doc 3.7.0 → 3.9.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
@@ -21,6 +21,7 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const drawio = require('./drawio.js');
24
+ const { applyReaderTheme } = require('./theme/tokens');
24
25
 
25
26
  // How long to let client-side WaveDrom / Mermaid scripts render before we
26
27
  // snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
@@ -140,9 +141,14 @@ const waveDromSkinTag = inlineScriptTag(localWaveDromSkin, 'wavedrom');
140
141
  const waveDromTag = inlineScriptTag(localWaveDromJs, 'wavedrom');
141
142
  const mermaidScriptTag = inlineScriptTag(localMermaidJs, 'mermaid');
142
143
 
144
+ // v3.8.0 (design review item 5): mermaid in md2doc's link blue on a light
145
+ // tint, the body font, white edge labels. Shared by the reader init below and
146
+ // the edit-mode init hook so both modes draw the same diagram.
147
+ const MERMAID_CONFIG_JS = "theme: 'base', themeVariables: { fontFamily: '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Arial, \"Microsoft JhengHei\", sans-serif', primaryColor: '#eaf2fd', primaryBorderColor: '#0969da', primaryTextColor: '#1f2328', lineColor: '#57606a', secondaryColor: '#f6f8fa', tertiaryColor: '#ffffff', edgeLabelBackground: '#ffffff' }";
148
+
143
149
  const mermaidInitTag = `<script type="text/javascript" data-md2doc-diagram-engine="mermaid">
144
150
  if (typeof mermaid !== 'undefined') {
145
- mermaid.initialize({ startOnLoad: true, theme: 'default' });
151
+ mermaid.initialize({ startOnLoad: true, ${MERMAID_CONFIG_JS} });
146
152
  }
147
153
  </script>`;
148
154
 
@@ -370,9 +376,76 @@ function renderEditModeList(listToken, blocks, biRef, out) {
370
376
  return parts;
371
377
  }
372
378
 
379
+ // <html lang> for a rendered document (v3.8.0, design review item 2).
380
+ // Tagged zh-Hant when Han characters are at least 10% of (CJK characters +
381
+ // Latin words), counted outside code; when kana is at least 20% of
382
+ // (Han + kana) it is ja, and when Hangul is at least 20% of (Han + Hangul)
383
+ // it is ko (both still need CJK to be 10% of the prose). Measured 2026-10-01: English docs
384
+ // (README, CLAUDE.md) <= 0.013, mixed zh/en specs in mac_top/docs >= 0.237,
385
+ // so 0.10 leaves a wide margin on both sides. Traditional vs Simplified is
386
+ // not detected: every Chinese document this tool renders today is zh-Hant.
387
+ const LANG_TAG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
388
+ function detectDocLang(mdText) {
389
+ const prose = String(mdText)
390
+ // Fences are anchored to line starts (up to 3 spaces of indent) and the
391
+ // closing fence must be at least as long as the opening one, so an inline
392
+ // mention of the marker, or a shorter marker line inside a longer fence,
393
+ // cannot swallow the prose around it. Backticks are written as x60 escapes.
394
+ .replace(/^ {0,3}(\x60{3,})[^\n\x60]*\n[\s\S]*?^ {0,3}\1\x60*[ \t\r]*$/gm, '')
395
+ .replace(/^ {0,3}(~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1~*[ \t\r]*$/gm, '')
396
+ .replace(/\x60[^\x60\n]*\x60/g, '');
397
+ const han = (prose.match(/[\u3400-\u9fff]/g) || []).length;
398
+ const kana = (prose.match(/[\u3040-\u30ff]/g) || []).length;
399
+ const hangul = (prose.match(/[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]/g) || []).length;
400
+ const words = (prose.match(/[A-Za-z]+/g) || []).length;
401
+ // Kana or Hangul only count as Japanese or Korean when CJK text is the bulk
402
+ // of the document at all (same 10% floor as zh-Hant); one stray の in an
403
+ // English or Chinese document must not flip its language.
404
+ const cjk = han + kana + hangul;
405
+ if (cjk + words === 0) return 'en';
406
+ if (cjk / (cjk + words) >= 0.10) {
407
+ if (kana / (han + kana) >= 0.2) return 'ja';
408
+ if (hangul / (han + hangul) >= 0.2) return 'ko';
409
+ }
410
+ return (han + kana) / (han + kana + words) >= 0.10 ? 'zh-Hant' : 'en';
411
+ }
412
+
413
+ // v3.8.0 (design review item 12): the paragraph immediately after the
414
+ // document's FIRST </h1>, when EVERY line of it is "<strong>Key:</strong>
415
+ // value", is the spec's metadata block — rendered as a two-column <dl>.
416
+ // Allow-list rule: one non-matching line and the paragraph is left alone.
417
+ function renderDocMetaBlock(bodyHtml) {
418
+ const at = bodyHtml.indexOf('</h1>');
419
+ if (at < 0) return bodyHtml;
420
+ const head = bodyHtml.slice(0, at + 5);
421
+ const tail = bodyHtml.slice(at + 5);
422
+ const m = tail.match(/^(\s*)<p>([\s\S]*?)<\/p>/);
423
+ if (!m) return bodyHtml;
424
+ const lines = m[2].split('\n').filter((l) => l.trim());
425
+ const rows = lines.map((l) => l.match(/^\s*<strong>([^<]+):<\/strong>\s*([\s\S]*)$/));
426
+ if (rows.length < 2 || rows.some((r) => !r)) return bodyHtml;
427
+ const items = rows.map((r) => '<dt>' + r[1] + '</dt><dd>' + r[2] + '</dd>').join('');
428
+ return head + m[1] + '<dl class="doc-meta">' + items + '</dl>' + tail.slice(m[0].length);
429
+ }
430
+
431
+ // v3.8.0 sidebar icons. Path data from Lucide (ISC licence, see
432
+ // THIRD-PARTY-NOTICES.md), inlined so the single-file output works offline.
433
+ const ICON_SVG_OPEN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">';
434
+ const READER_ICONS = {
435
+ unfold: ICON_SVG_OPEN + '<path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3 3-3-3"/><path d="m15 5-3-3-3 3"/></svg>',
436
+ fold: ICON_SVG_OPEN + '<path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3-3-3 3"/><path d="m15 5-3 3-3-3"/></svg>',
437
+ panel: ICON_SVG_OPEN + '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>',
438
+ up: ICON_SVG_OPEN + '<path d="m18 15-6-6-6 6"/></svg>',
439
+ down: ICON_SVG_OPEN + '<path d="m6 9 6 6 6-6"/></svg>',
440
+ x: ICON_SVG_OPEN + '<path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
441
+ };
442
+
373
443
  async function renderMarkdown(mdText, srcPath, opts = {}) {
374
444
  const md = mdText;
375
445
  const src = srcPath;
446
+ const docLang = (typeof opts.lang === 'string' && LANG_TAG_RE.test(opts.lang))
447
+ ? opts.lang
448
+ : detectDocLang(md);
376
449
  let usesMermaid = false;
377
450
  let usesWaveDrom = false;
378
451
  let usesDrawio = false;
@@ -699,6 +772,16 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
699
772
  return root;
700
773
  }
701
774
 
775
+ // v3.8.0 (design review item 7+8): when the document's only H1 is its
776
+ // title and it has sections, list those sections at the TOC's top level —
777
+ // the title row indented every entry one level and carried no information.
778
+ // Several H1s are chapters and are kept as they are.
779
+ function flattenSingleRoot(tree) {
780
+ return tree.length === 1 && tree[0].depth === 1 && tree[0].children.length
781
+ ? tree[0].children
782
+ : tree;
783
+ }
784
+
702
785
  function renderTocNodes(nodes, level = 1) {
703
786
  if (!nodes.length) {
704
787
  return '';
@@ -959,7 +1042,14 @@ ${itemsHtml}
959
1042
  const depth = Number(token.depth || 1);
960
1043
  const headingText = stripHtmlTags(flattenTokenText(token.tokens) || token.text || '');
961
1044
  const headingId = slugifyHeading(headingText);
962
- const headingHtml = this.parser.parseInline(token.tokens);
1045
+ const inlineHtml = this.parser.parseInline(token.tokens);
1046
+ // v3.8.0 (design review item 4): a leading section number ("4.1 ",
1047
+ // "12. ") gets its own span so CSS can widen the gap after it. The
1048
+ // space stays in the markup, so textContent is unchanged. Read mode
1049
+ // only — the editor turns heading DOM back into Markdown.
1050
+ const headingHtml = opts.editMode
1051
+ ? inlineHtml
1052
+ : inlineHtml.replace(/^(\d+(?:\.\d+)*\.?)(\s)/, '<span class="sec">$1</span>$2');
963
1053
 
964
1054
  tocItems.push({
965
1055
  depth,
@@ -1126,6 +1216,7 @@ ${itemsHtml}
1126
1216
  }
1127
1217
  // Both the ```math fence and the $/$$ extension emit class="katex" — a single
1128
1218
  // post-parse scan is the source of truth for conditional CSS injection.
1219
+ if (!opts.editMode) bodyHtml = renderDocMetaBlock(bodyHtml);
1129
1220
  usesMath = /class="katex/.test(bodyHtml);
1130
1221
  serializedSections = JSON.stringify(
1131
1222
  sections.map((section) => ({
@@ -1141,30 +1232,31 @@ ${itemsHtml}
1141
1232
  <section class="reader-tools">
1142
1233
  <label class="reader-search-label" for="doc-search-input">Search</label>
1143
1234
  <div class="reader-search-row">
1144
- <input type="search" id="doc-search-input" placeholder="Enter keyword and press Enter">
1145
- <button id="doc-search-submit" type="button">Search</button>
1146
- <button id="doc-search-clear" type="button">Clear</button>
1235
+ <input type="search" id="doc-search-input" placeholder="Search this document">
1236
+ <button id="doc-search-submit" type="button" tabindex="-1">Search</button>
1237
+ <button id="doc-search-clear" type="button" aria-label="Clear search" title="Clear search">${READER_ICONS.x}</button>
1147
1238
  </div>
1148
1239
  </section>
1149
1240
  <section class="search-results" id="search-results" hidden>
1150
1241
  <div class="search-results-header">
1151
1242
  <span class="search-results-title">Results</span>
1152
1243
  <span id="search-result-count" class="reader-status">0</span>
1153
- <button id="search-prev" type="button" disabled aria-label="Previous match">◀</button>
1154
- <button id="search-next" type="button" disabled aria-label="Next match">▶</button>
1244
+ <button id="search-prev" type="button" disabled aria-label="Previous match" title="Previous match">${READER_ICONS.up}</button>
1245
+ <button id="search-next" type="button" disabled aria-label="Next match" title="Next match">${READER_ICONS.down}</button>
1155
1246
  </div>
1156
1247
  <div id="search-results-list"></div>
1157
1248
  </section>
1158
1249
  <nav class="toc" aria-label="Table of contents" data-reader-toc>
1159
1250
  <div class="toc-header">
1251
+ <span class="toc-title">Contents</span>
1160
1252
  <div class="toc-header-actions">
1161
- <button id="toc-expand-all" type="button" aria-label="Expand all">⊞</button>
1162
- <button id="toc-collapse-all" type="button" aria-label="Collapse all">⊟</button>
1163
- <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">◀</button>
1253
+ <button id="toc-expand-all" type="button" aria-label="Expand all" title="Expand all">${READER_ICONS.unfold}</button>
1254
+ <button id="toc-collapse-all" type="button" aria-label="Collapse all" title="Collapse all">${READER_ICONS.fold}</button>
1255
+ <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">${READER_ICONS.panel}</button>
1164
1256
  </div>
1165
1257
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1166
1258
  </div>
1167
- ${renderTocNodes(tocTree)}
1259
+ ${renderTocNodes(flattenSingleRoot(tocTree))}
1168
1260
  </nav>
1169
1261
  </aside>
1170
1262
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
@@ -1214,7 +1306,7 @@ ${itemsHtml}
1214
1306
  window.__md2docInitDiagrams = function (rootEl) {
1215
1307
  rootEl = rootEl || document;
1216
1308
  if (typeof mermaid !== 'undefined') {
1217
- mermaid.initialize({ startOnLoad: false, theme: 'default' });
1309
+ mermaid.initialize({ startOnLoad: false, ${MERMAID_CONFIG_JS} });
1218
1310
  var mermaidNodes = rootEl.querySelectorAll ? rootEl.querySelectorAll('.mermaid') : [];
1219
1311
  var pending = [];
1220
1312
  for (var i = 0; i < mermaidNodes.length; i++) {
@@ -1255,7 +1347,7 @@ ${itemsHtml}
1255
1347
  // (on a later call) — the CSS cursor rule and the lightbox click
1256
1348
  // target selector both key off this class (in addition to the
1257
1349
  // WaveDrom_Display_ id prefix, which non-edit pages rely on and
1258
- // never rename), so a diagram stays clickable and zoom-in-affordant
1350
+ // never rename), so a diagram stays clickable and shows its click cursor
1259
1351
  // for its whole lifetime, independent of id reclaiming.
1260
1352
  var displayDiv = wavePending[j].previousElementSibling;
1261
1353
  if (displayDiv) {
@@ -1609,6 +1701,10 @@ ${itemsHtml}
1609
1701
  reader-mode output never includes it, so the mobile drawer toggle there
1610
1702
  is unaffected. */
1611
1703
  .sidebar-toggle { display: none; }
1704
+ .mobile-bar { display: none; }
1705
+ /* The reader bar is gone in edit mode, so its 52px anchor offset goes too
1706
+ (it moved scrollIntoView landings under the edit toolbar). */
1707
+ .content [id] { scroll-margin-top: 0; }
1612
1708
  /* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
1613
1709
  contentEl.hidden = true and mounts a plain textarea in its place, and
1614
1710
  setDocMode() writes docMode onto body as data-ed-mode. Measured on that
@@ -1813,9 +1909,9 @@ ${itemsHtml}
1813
1909
  }
1814
1910
  .ed-wave-lane-row.is-selected { background: #eaf2fe; }
1815
1911
  .ed-wave-lane-handle {
1816
- flex: 0 0 auto; cursor: grab; color: #aaa; user-select: none; font-size: 13px;
1912
+ flex: 0 0 auto; cursor: move; color: #aaa; user-select: none; font-size: 13px;
1817
1913
  }
1818
- .ed-wave-lane-handle:active { cursor: grabbing; }
1914
+ .ed-wave-lane-handle:active { cursor: move; }
1819
1915
  /* min-width, not just flex-grow: min-width is what a flex item will not
1820
1916
  shrink past, and shrinking past it is the vertical-text failure above.
1821
1917
  MEASURED after this change (real page, 800x600, the journey suite's own
@@ -2137,15 +2233,24 @@ ${itemsHtml}
2137
2233
  overflow-x: auto; font-family: monospace; font-size: 12px;
2138
2234
  }`;
2139
2235
 
2140
- const html = `<!DOCTYPE html>
2141
- <html lang="en">
2236
+ // The phone bar only exists when there is a TOC for its button to open: a
2237
+ // document with no headings has no sidebar, and an empty bar would carry a
2238
+ // button that opens an empty drawer.
2239
+ const mobileBarHtml = tocHtml
2240
+ ? '<header class="mobile-bar" id="mobile-bar">\n' +
2241
+ '<button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>\n' +
2242
+ '<span class="mobile-bar-title" id="mobile-bar-title"></span>\n' +
2243
+ '</header>\n'
2244
+ : '';
2245
+ const html = `<!DOCTYPE html>
2246
+ <html lang="${docLang}">
2142
2247
  <head>
2143
2248
  <meta charset="UTF-8">
2144
2249
  <meta name="viewport" content="width=device-width, initial-scale=1">
2145
2250
  <title>${title}</title>
2146
2251
  <style>
2147
2252
  body {
2148
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
2253
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", sans-serif;
2149
2254
  margin: 0;
2150
2255
  padding: 0;
2151
2256
  line-height: 1.65;
@@ -2174,7 +2279,9 @@ ${itemsHtml}
2174
2279
  min-width: 0;
2175
2280
  height: calc(100vh - 48px);
2176
2281
  overflow: hidden;
2177
- padding-right: 8px;
2282
+ padding: 12px 8px 12px 12px;
2283
+ background: #f3f5f7;
2284
+ border-radius: 10px;
2178
2285
  box-sizing: border-box;
2179
2286
  display: flex;
2180
2287
  flex-direction: column;
@@ -2184,7 +2291,7 @@ ${itemsHtml}
2184
2291
  flex-basis: 36px;
2185
2292
  width: 36px;
2186
2293
  min-width: 36px;
2187
- padding-right: 0;
2294
+ padding: 12px 4px;
2188
2295
  }
2189
2296
  body[data-toc-collapsed] .reader-tools,
2190
2297
  body[data-toc-collapsed] .search-results,
@@ -2215,7 +2322,7 @@ ${itemsHtml}
2215
2322
  .sidebar-splitter {
2216
2323
  flex: 0 0 32px;
2217
2324
  align-self: stretch;
2218
- cursor: col-resize;
2325
+ cursor: ew-resize;
2219
2326
  display: flex;
2220
2327
  justify-content: flex-start;
2221
2328
  touch-action: none;
@@ -2240,26 +2347,13 @@ ${itemsHtml}
2240
2347
  display: none;
2241
2348
  }
2242
2349
  #toc-collapse-toggle {
2243
- margin-left: 0;
2244
- padding: 2px 8px;
2245
- font: inherit;
2246
- font-size: 0.9em;
2247
- line-height: 1;
2248
- border: 1px solid #d0d7de;
2249
- border-radius: 6px;
2250
- background: #ffffff;
2251
- color: #57606a;
2252
- cursor: pointer;
2253
2350
  transition: transform 0.15s ease;
2254
2351
  }
2255
- #toc-collapse-toggle:hover {
2256
- background: #eef2f6;
2257
- color: #24292e;
2258
- }
2259
2352
  @media (max-width: 1080px) {
2260
2353
  #toc-collapse-toggle { display: none; }
2261
2354
  }
2262
2355
  .reader-tools { flex: 0 0 auto; }
2356
+ .mobile-bar { display: none; }
2263
2357
  .sidebar-toggle {
2264
2358
  display: none;
2265
2359
  position: fixed;
@@ -2304,65 +2398,91 @@ ${itemsHtml}
2304
2398
  .reader-tools,
2305
2399
  .search-results,
2306
2400
  .toc {
2307
- border: 1px solid #d0d7de;
2308
- border-radius: 10px;
2309
- background: #f8fafc;
2310
- padding: 12px 14px;
2401
+ border: none;
2402
+ background: transparent;
2403
+ padding: 2px 0;
2311
2404
  }
2312
- .reader-search-label {
2313
- display: block;
2314
- font-size: 0.78rem;
2315
- font-weight: 700;
2316
- letter-spacing: 0.08em;
2317
- text-transform: uppercase;
2318
- color: #57606a;
2319
- margin-bottom: 6px;
2405
+ .reader-search-label,
2406
+ #doc-search-submit {
2407
+ position: absolute;
2408
+ width: 1px;
2409
+ height: 1px;
2410
+ padding: 0;
2411
+ margin: -1px;
2412
+ overflow: hidden;
2413
+ clip: rect(0, 0, 0, 0);
2414
+ white-space: nowrap;
2415
+ border: 0;
2320
2416
  }
2321
2417
  .reader-search-row {
2418
+ position: relative;
2322
2419
  display: flex;
2323
- gap: 6px;
2324
- margin-bottom: 8px;
2420
+ margin-bottom: 4px;
2325
2421
  }
2326
2422
  .reader-search-row input[type="search"] {
2327
2423
  flex: 1 1 auto;
2328
2424
  min-width: 0;
2329
- padding: 6px 8px;
2425
+ padding: 7px 30px 7px 10px;
2330
2426
  border: 1px solid #d0d7de;
2331
2427
  border-radius: 6px;
2428
+ background: #ffffff;
2332
2429
  font: inherit;
2333
2430
  }
2334
- .reader-search-row button {
2335
- padding: 4px 10px;
2336
- font: inherit;
2337
- font-size: 0.85em;
2338
- border: 1px solid #d0d7de;
2339
- border-radius: 6px;
2340
- background: #ffffff;
2431
+ .reader-search-row input[type="search"]::-webkit-search-cancel-button {
2432
+ -webkit-appearance: none;
2433
+ appearance: none;
2434
+ display: none;
2435
+ }
2436
+ #doc-search-clear {
2437
+ position: absolute;
2438
+ right: 4px;
2439
+ top: 50%;
2440
+ transform: translateY(-50%);
2441
+ display: inline-grid;
2442
+ place-items: center;
2443
+ padding: 4px;
2444
+ line-height: 0;
2445
+ border: none;
2446
+ border-radius: 4px;
2447
+ background: transparent;
2448
+ color: #59636e;
2341
2449
  cursor: pointer;
2342
2450
  }
2343
- .reader-search-row button:hover {
2344
- background: #eef2f6;
2451
+ #doc-search-clear:hover {
2452
+ background: #e3e8ee;
2453
+ color: #1f2328;
2454
+ }
2455
+ #doc-search-input:placeholder-shown ~ #doc-search-clear {
2456
+ display: none;
2345
2457
  }
2346
2458
  .search-results-header,
2347
2459
  .toc-header {
2348
2460
  display: flex;
2349
- flex-direction: column;
2350
- align-items: stretch;
2351
- gap: 4px;
2461
+ flex-direction: row;
2462
+ align-items: center;
2463
+ gap: 6px;
2352
2464
  margin-bottom: 8px;
2353
2465
  }
2466
+ .toc-header {
2467
+ justify-content: space-between;
2468
+ border-bottom: 1px solid #d8dee4;
2469
+ padding-bottom: 6px;
2470
+ }
2471
+ body[data-toc-collapsed] .toc-header {
2472
+ border-bottom: none;
2473
+ }
2354
2474
  .toc-header-actions {
2355
2475
  flex: 0 0 auto;
2356
2476
  display: flex;
2357
- gap: 6px;
2477
+ gap: 2px;
2358
2478
  justify-content: flex-end;
2359
2479
  }
2360
- /* Sticky breadcrumb: stacked ancestor chain of the current scroll position.
2361
- Each row is single-line + ellipsis; full text on hover via title=. */
2480
+ /* Breadcrumb: kept in the DOM (the runtime and editor-reader-rebind read it)
2481
+ but hidden since v3.8.0 — the TOC highlights the whole active path instead. */
2362
2482
  .toc-breadcrumb {
2363
2483
  flex: 1 1 auto;
2364
2484
  min-width: 0;
2365
- display: flex;
2485
+ display: none;
2366
2486
  flex-direction: column;
2367
2487
  gap: 1px;
2368
2488
  }
@@ -2387,13 +2507,14 @@ ${itemsHtml}
2387
2507
  }
2388
2508
  .search-results-title,
2389
2509
  .toc-title {
2390
- font-size: 0.78rem;
2391
- font-weight: 700;
2392
- letter-spacing: 0.08em;
2393
- text-transform: uppercase;
2394
- color: #57606a;
2510
+ font-size: 0.86rem;
2511
+ font-weight: 600;
2512
+ color: #1f2328;
2395
2513
  flex: 0 0 auto;
2396
2514
  }
2515
+ body[data-toc-collapsed] .toc-title {
2516
+ display: none;
2517
+ }
2397
2518
  .reader-status {
2398
2519
  flex: 1 1 auto;
2399
2520
  font-size: 0.82em;
@@ -2401,20 +2522,21 @@ ${itemsHtml}
2401
2522
  }
2402
2523
  .search-results-header button,
2403
2524
  .toc-header button {
2404
- padding: 2px 8px;
2525
+ display: inline-grid;
2526
+ place-items: center;
2527
+ padding: 4px;
2405
2528
  font: inherit;
2406
- font-size: 0.9em;
2407
- line-height: 1;
2408
- border: 1px solid #d0d7de;
2529
+ line-height: 0;
2530
+ border: none;
2409
2531
  border-radius: 6px;
2410
- background: #ffffff;
2411
- color: #57606a;
2532
+ background: transparent;
2533
+ color: #59636e;
2412
2534
  cursor: pointer;
2413
2535
  }
2414
2536
  .search-results-header button:hover,
2415
2537
  .toc-header button:hover {
2416
- background: #eef2f6;
2417
- color: #24292e;
2538
+ background: #e3e8ee;
2539
+ color: #1f2328;
2418
2540
  }
2419
2541
  .search-results-header button:disabled {
2420
2542
  opacity: 0.45;
@@ -2460,8 +2582,8 @@ ${itemsHtml}
2460
2582
  background: #eef2f6;
2461
2583
  }
2462
2584
  .search-result-item.is-active {
2463
- background: #dbeafe;
2464
- box-shadow: inset 0 0 0 1px #93c5fd;
2585
+ background: #dbe6f3;
2586
+ box-shadow: none;
2465
2587
  }
2466
2588
  .search-result-title {
2467
2589
  font-weight: 600;
@@ -2510,6 +2632,8 @@ ${itemsHtml}
2510
2632
  the shift+wheel handler; appears only when a title actually overflows. */
2511
2633
  overflow-x: auto;
2512
2634
  scrollbar-width: thin;
2635
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
2636
+ mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
2513
2637
  }
2514
2638
  .toc ul {
2515
2639
  list-style: none;
@@ -2534,13 +2658,15 @@ ${itemsHtml}
2534
2658
  min-width: 100%;
2535
2659
  color: #57606a;
2536
2660
  text-decoration: none;
2537
- padding: 2px 0;
2661
+ padding: 2px 8px;
2538
2662
  line-height: 1.4;
2539
2663
  white-space: nowrap;
2540
2664
  box-sizing: border-box;
2665
+ border-radius: 4px;
2541
2666
  }
2542
2667
  .toc summary > a {
2543
- flex: 0 0 auto;
2668
+ flex: 1 0 auto;
2669
+ min-width: 0;
2544
2670
  }
2545
2671
  .toc summary {
2546
2672
  min-width: 0;
@@ -2550,17 +2676,41 @@ ${itemsHtml}
2550
2676
  }
2551
2677
  .toc a:hover {
2552
2678
  color: #0969da;
2679
+ background: #e8edf2;
2553
2680
  }
2554
2681
  .toc a.is-active {
2555
- color: #0b57d0;
2556
- font-weight: 700;
2682
+ color: #0550ae;
2683
+ font-weight: 600;
2684
+ background: #dbe6f3;
2557
2685
  }
2558
2686
  .toc a.is-match {
2559
- color: #355070;
2560
- background: #eaf2ff;
2687
+ color: inherit;
2688
+ background: #fff5c2;
2689
+ }
2690
+ .toc li:has(a.is-active) > details > summary > a:not(.is-active) {
2691
+ color: #1f2328;
2692
+ font-weight: 600;
2693
+ }
2694
+ .reader-sidebar :is(a, button, input, summary):focus-visible,
2695
+ .mobile-bar button:focus-visible,
2696
+ .content a:focus-visible {
2697
+ outline: 2px solid #0969da;
2698
+ outline-offset: 2px;
2561
2699
  border-radius: 4px;
2562
- padding-left: 4px;
2563
- padding-right: 4px;
2700
+ }
2701
+ .toc :is(a, summary):focus-visible {
2702
+ outline-offset: -2px;
2703
+ }
2704
+ /* The list's right-edge fade is a mask, and a mask clips the ring of a
2705
+ focused row along with the text. Lift it while a row holds focus. */
2706
+ .toc > .toc-list:has(:focus-visible) {
2707
+ -webkit-mask-image: none;
2708
+ mask-image: none;
2709
+ }
2710
+ .toc-header button:focus-visible,
2711
+ .search-results-header button:focus-visible,
2712
+ .reader-sidebar .search-result-item:focus-visible {
2713
+ outline-offset: -2px;
2564
2714
  }
2565
2715
  .toc details {
2566
2716
  margin: 0;
@@ -2582,6 +2732,8 @@ ${itemsHtml}
2582
2732
  font-size: 0.78em;
2583
2733
  line-height: 1.8;
2584
2734
  flex: 0 0 auto;
2735
+ width: 12px;
2736
+ text-align: center;
2585
2737
  transform: translateY(1px);
2586
2738
  }
2587
2739
  .toc details[open] > summary::before {
@@ -2593,15 +2745,15 @@ ${itemsHtml}
2593
2745
  border-left: 1px solid #d8dee4;
2594
2746
  }
2595
2747
  .toc-item:not(.toc-parent) > a {
2596
- padding-left: 18px;
2748
+ padding-left: 26px;
2597
2749
  }
2598
2750
  .toc-list-level-1 > .toc-item > a,
2599
2751
  .toc-list-level-1 > .toc-item > details > summary > a {
2600
- font-weight: 600;
2752
+ font-size: 0.95em;
2601
2753
  }
2602
2754
  .toc-list-level-2 > .toc-item > a,
2603
2755
  .toc-list-level-2 > .toc-item > details > summary > a {
2604
- font-size: 0.95em;
2756
+ font-size: 0.9em;
2605
2757
  }
2606
2758
  .toc-list-level-3 > .toc-item > a,
2607
2759
  .toc-list-level-3 > .toc-item > details > summary > a,
@@ -2633,6 +2785,19 @@ ${itemsHtml}
2633
2785
  }
2634
2786
  .content > * { max-width: 100%; }
2635
2787
  .content iframe, .content video, .content canvas { max-width: 100%; height: auto; }
2788
+ .doc-meta {
2789
+ display: grid;
2790
+ grid-template-columns: max-content 1fr;
2791
+ gap: 4px 20px;
2792
+ background: #f6f8fa;
2793
+ border-radius: 6px;
2794
+ padding: 12px 16px;
2795
+ margin: 16px 0 28px;
2796
+ font-size: 0.92em;
2797
+ line-height: 1.55;
2798
+ }
2799
+ .doc-meta dt { color: #57606a; }
2800
+ .doc-meta dd { margin: 0; }
2636
2801
  .heading-with-anchor {
2637
2802
  position: relative;
2638
2803
  }
@@ -2653,14 +2818,15 @@ ${itemsHtml}
2653
2818
  color: #0969da;
2654
2819
  }
2655
2820
  h1 { font-size: 2em; border-bottom: 2px solid #e1e4e8; padding-bottom: 10px; margin-top: 1.5em; }
2656
- h2 { font-size: 1.5em; border-bottom: 1px solid #e1e4e8; padding-bottom: 6px; margin-top: 1.4em; }
2657
- h3 { font-size: 1.2em; margin-top: 1.3em; }
2658
- h4 { font-size: 1.05em; margin-top: 1.2em; }
2821
+ h2 { font-size: 1.5em; border-bottom: 1px solid #e1e4e8; padding-bottom: 6px; margin-top: 2.2em; margin-bottom: 0.7em; }
2822
+ h3 { font-size: 1.3em; margin-top: 2em; margin-bottom: 0.5em; }
2823
+ h4 { font-size: 1.1em; margin-top: 1.8em; margin-bottom: 0.4em; }
2824
+ .sec { margin-right: 0.4em; font-variant-numeric: tabular-nums; }
2659
2825
  code {
2660
2826
  background: #f6f8fa;
2661
2827
  padding: 2px 5px;
2662
2828
  border-radius: 3px;
2663
- font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
2829
+ font-family: "Cascadia Mono", Consolas, "SFMono-Regular", "Liberation Mono", Menlo, "Microsoft JhengHei", monospace;
2664
2830
  font-size: 0.875em;
2665
2831
  }
2666
2832
  pre {
@@ -2671,6 +2837,13 @@ ${itemsHtml}
2671
2837
  line-height: 1.45;
2672
2838
  }
2673
2839
  pre code { background: none; padding: 0; font-size: 0.875em; }
2840
+ .content :not(pre) > code {
2841
+ background: none;
2842
+ padding: 0;
2843
+ border-radius: 0;
2844
+ color: #b93a0c;
2845
+ font-size: 0.92em;
2846
+ }
2674
2847
  table {
2675
2848
  display: block;
2676
2849
  border-collapse: collapse;
@@ -2680,9 +2853,8 @@ ${itemsHtml}
2680
2853
  font-size: 0.9em;
2681
2854
  overflow-x: auto;
2682
2855
  }
2683
- th, td { border: 1px solid #dfe2e5; padding: 7px 14px; text-align: left; }
2684
- th { background: #f6f8fa; font-weight: 600; }
2685
- tr:nth-child(even) { background: #fafbfc; }
2856
+ th, td { border: none; border-bottom: 1px solid #e1e4e8; padding: 7px 14px; text-align: left; vertical-align: top; }
2857
+ th { font-weight: 600; border-bottom: 2px solid #c9ced4; }
2686
2858
  .content table th,
2687
2859
  .content table td {
2688
2860
  overflow-wrap: normal;
@@ -2695,7 +2867,7 @@ ${itemsHtml}
2695
2867
  .content table th code {
2696
2868
  white-space: nowrap;
2697
2869
  }
2698
- .content table { table-layout: auto; }
2870
+ .content table { table-layout: auto; font-variant-numeric: tabular-nums; }
2699
2871
  .content table col.col-narrow { width: 1%; }
2700
2872
  .content table col.col-prose { width: auto; }
2701
2873
  .content table th.cell-narrow,
@@ -2708,10 +2880,7 @@ ${itemsHtml}
2708
2880
  background: #ffffff;
2709
2881
  }
2710
2882
  .content table thead th:first-child {
2711
- background: #f6f8fa;
2712
- }
2713
- .content table tbody tr:nth-child(even) td:first-child {
2714
- background: #fafbfc;
2883
+ background: #ffffff;
2715
2884
  }
2716
2885
  blockquote {
2717
2886
  border-left: 4px solid #dfe2e5;
@@ -2789,7 +2958,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2789
2958
  .content .graphviz,
2790
2959
  .content .drawio,
2791
2960
  .content [id^="WaveDrom_Display_"],
2792
- .content .wavedrom-diagram { cursor: zoom-in; }
2961
+ .content .wavedrom-diagram { cursor: pointer; }
2793
2962
  .content a img { cursor: pointer; }
2794
2963
 
2795
2964
  .lightbox {
@@ -2894,10 +3063,10 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2894
3063
  position: relative;
2895
3064
  flex: 1 1 auto;
2896
3065
  overflow: auto;
2897
- cursor: grab;
3066
+ cursor: move;
2898
3067
  overscroll-behavior: contain;
2899
3068
  }
2900
- .lightbox-stage[data-panning] { cursor: grabbing; }
3069
+ .lightbox-stage[data-panning] { cursor: move; }
2901
3070
  .lightbox-canvas { margin: 0 auto; position: relative; }
2902
3071
  /* Sized in px by the runtime; the child follows, so the scroll extent grows
2903
3072
  with the zoom. A CSS transform would scale the pixels and leave the scroll
@@ -2925,21 +3094,59 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2925
3094
  html[data-lightbox-open] { overflow: hidden; }
2926
3095
  @media print { .lightbox { display: none !important; } }
2927
3096
  @media (max-width: 1080px) {
3097
+ .content table td.cell-prose,
3098
+ .content table th.cell-prose { min-width: 15em; }
2928
3099
  .sidebar-toggle { display: inline-flex; align-items: center; }
3100
+ .mobile-bar {
3101
+ display: flex;
3102
+ align-items: center;
3103
+ gap: 10px;
3104
+ position: fixed;
3105
+ top: 0;
3106
+ left: 0;
3107
+ right: 0;
3108
+ height: 44px;
3109
+ padding: 0 12px;
3110
+ box-sizing: border-box;
3111
+ background: #ffffff;
3112
+ border-bottom: 1px solid #d8dee4;
3113
+ z-index: 100;
3114
+ }
3115
+ .mobile-bar .sidebar-toggle {
3116
+ position: static;
3117
+ box-shadow: none;
3118
+ }
3119
+ .mobile-bar-title {
3120
+ min-width: 0;
3121
+ flex: 1 1 auto;
3122
+ font-size: 14px;
3123
+ font-weight: 600;
3124
+ color: #24292e;
3125
+ white-space: nowrap;
3126
+ overflow: hidden;
3127
+ text-overflow: ellipsis;
3128
+ }
3129
+ .content [id] { scroll-margin-top: 52px; }
3130
+ /* The 60px clears the fixed bar, so it only applies when the bar exists
3131
+ (a document with no headings has none). :where() keeps the selector at
3132
+ the specificity of a bare class, so the edit-mode override still wins. */
3133
+ .page-layout:where(.mobile-bar ~ *) {
3134
+ padding-top: 60px;
3135
+ }
2929
3136
  .page-layout {
2930
3137
  display: block;
2931
3138
  max-width: 100%;
2932
- padding-top: 60px;
2933
3139
  }
2934
3140
  .reader-sidebar {
2935
3141
  position: fixed;
2936
- top: 0;
3142
+ top: 44px;
2937
3143
  left: 0;
2938
3144
  bottom: 0;
2939
3145
  width: 85%;
2940
3146
  max-width: 360px;
2941
- height: 100vh;
2942
- background: #ffffff;
3147
+ height: calc(100vh - 44px);
3148
+ background: #f3f5f7;
3149
+ border-radius: 0;
2943
3150
  z-index: 99;
2944
3151
  transform: translateX(-100%);
2945
3152
  transition: transform 0.2s ease;
@@ -2949,10 +3156,9 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2949
3156
  display: flex;
2950
3157
  flex-direction: column;
2951
3158
  gap: 12px;
2952
- box-shadow: 2px 0 12px rgba(0,0,0,0.15);
2953
3159
  flex: initial;
2954
3160
  }
2955
- body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
3161
+ body[data-sidebar-open] .reader-sidebar { transform: translateX(0); box-shadow: 2px 0 12px rgba(0,0,0,0.15); }
2956
3162
  /* Scroll lock for the drawer, same two-rule shape the lightbox uses a
2957
3163
  few lines above (body[data-lightbox-open] / html[data-lightbox-open])
2958
3164
  and for the identical reason: 'html, body { overflow-x: clip }' keeps
@@ -2969,7 +3175,6 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2969
3175
  body[data-sidebar-open] { overflow: hidden; }
2970
3176
  html[data-sidebar-open] { overflow: hidden; }
2971
3177
  .sidebar-splitter { display: none; }
2972
- .heading-anchor { opacity: 1; }
2973
3178
  }
2974
3179
  @media print {
2975
3180
  body { font-size: 11pt; }
@@ -2994,10 +3199,19 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2994
3199
  padding: 0 10px;
2995
3200
  }
2996
3201
  .reader-sidebar,
3202
+ .mobile-bar,
2997
3203
  .sidebar-toggle,
2998
3204
  .sidebar-splitter,
2999
3205
  .sidebar-scrim { display: none !important; }
3000
3206
  .content { max-width: 100%; }
3207
+ /* A4 lays out at about 680px, inside the max-width: 1080px block above,
3208
+ so its 15em prose floor applied to print; the table scrolls
3209
+ horizontally on screen and a PDF clips what overflows. Drop the floor.
3210
+ The sticky first column goes static so its bottom border prints. */
3211
+ .content table td.cell-prose,
3212
+ .content table th.cell-prose { min-width: 0; }
3213
+ .content table tbody td:first-child,
3214
+ .content table thead th:first-child { position: static; }
3001
3215
  .heading-anchor { display: none; }
3002
3216
  pre { font-size: 9pt; }
3003
3217
  a[href]:after { content: none; }
@@ -3015,18 +3229,17 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3015
3229
  .ed-te-hl).
3016
3230
 
3017
3231
  The descendant rules and their !important are not decoration, they are
3018
- the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
3019
- tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
3232
+ the whole problem: <pre> (#f6f8fa) and the sticky first column (#ffffff)
3020
3233
  carry OPAQUE backgrounds, and the sticky column additionally paints at
3021
- z-index: 1 over anything drawn beneath it. Two of those beat a plain
3022
- descendant selector on specificity alone — counted:
3023
- .content table tbody tr:nth-child(even) td:first-child is (0,3,4)
3024
- against this rule's (0,3,2), and .content table tbody td:first-child is
3025
- (0,2,3) against the same (0,3,2) — so without !important the tint is
3026
- invisible on exactly the blocks a user is most likely to select. .ed-te-hl
3027
- needed the identical treatment for the identical reason. This ADDS rules
3028
- rather than reordering the sticky/zebra/header set, which CLAUDE.md says
3029
- wins by source order and must not be moved.
3234
+ z-index: 1 over anything drawn beneath it. Until v3.8.0 the grey <th>
3235
+ (#f6f8fa) and the zebra stripe tr:nth-child(even) (#fafbfc, whose sticky
3236
+ override .content table tbody tr:nth-child(even) td:first-child was
3237
+ (0,3,4) against this rule's (0,3,2)) did too; v3.8.0 removed both, and
3238
+ the th / tr:nth-child(even) selectors below stay so a later restyle
3239
+ cannot quietly reopen the hole. Without !important the tint is invisible
3240
+ on exactly the blocks a user is most likely to select. .ed-te-hl needed
3241
+ the identical treatment for the identical reason. This ADDS rules rather
3242
+ than reordering the sticky-column rules, which win by source order.
3030
3243
  (No backticks in this comment: it lives inside a JS template literal.)
3031
3244
 
3032
3245
  :focus outline is suppressed because the roving tabindex below focuses a
@@ -3328,7 +3541,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3328
3541
  having: '.ed-handle' (the button the gesture starts on), '.ed-handle-menu-btn'
3329
3542
  and every table grip carry their own 'cursor: pointer', and a plain
3330
3543
  inherited cursor loses to all of them. */
3331
- html.ed-block-dragging, html.ed-block-dragging * { cursor: grabbing !important; }
3544
+ html.ed-block-dragging, html.ed-block-dragging * { cursor: move !important; }
3332
3545
  /* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
3333
3546
  proximity zone (user-acceptance feedback: unusably small, no visible
3334
3547
  affordance) with two real, adequately-sized (>=18x24px) click/drag
@@ -3355,7 +3568,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3355
3568
  place-content: center, so the dot cluster stays compact/centered
3356
3569
  regardless of the button's own (larger, hit-target-sized) box — no
3357
3570
  images, no background gradients. '.ed-te-grip-dragging' is EITHER
3358
- grip's own "active drag handle" visual (grabbing cursor) while a row OR
3571
+ grip's own "active drag handle" visual (move cursor) while a row OR
3359
3572
  column drag is in flight (Task 8: columns became draggable too, same
3360
3573
  as rows) — see cancelTeDrag()/the pointermove listener in the client
3361
3574
  runtime. */
@@ -3377,17 +3590,17 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3377
3590
  the position was a hard-coded literal 20. */
3378
3591
  :root { --ed-te-grip-row-w: 20px; }
3379
3592
  .ed-te-grip-row {
3380
- width: var(--ed-te-grip-row-w); height: 28px; cursor: grab; z-index: 7;
3593
+ width: var(--ed-te-grip-row-w); height: 28px; cursor: move; z-index: 7;
3381
3594
  grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
3382
3595
  gap: 3px 4px;
3383
3596
  }
3384
- .ed-te-grip-row.ed-te-grip-dragging { cursor: grabbing; }
3597
+ .ed-te-grip-row.ed-te-grip-dragging { cursor: move; }
3385
3598
  .ed-te-grip-col {
3386
- width: 28px; height: 24px; cursor: grab; z-index: 7;
3599
+ width: 28px; height: 24px; cursor: move; z-index: 7;
3387
3600
  grid-template-columns: repeat(3, 3px); grid-template-rows: repeat(2, 3px);
3388
3601
  gap: 4px 3px;
3389
3602
  }
3390
- .ed-te-grip-col.ed-te-grip-dragging { cursor: grabbing; }
3603
+ .ed-te-grip-col.ed-te-grip-dragging { cursor: move; }
3391
3604
  /* Task 4: floating selection toolbar (bold/italic/strikethrough/underline/
3392
3605
  code/link), shown over a
3393
3606
  non-collapsed selection inside an active WYSIWYG session. position:
@@ -3723,8 +3936,7 @@ ${opts.editMode ? editModeLayoutCss : ''}
3723
3936
  ${usesMath ? buildKatexStyleTag() : ''}
3724
3937
  </head>
3725
3938
  <body>
3726
- <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
3727
- <div class="sidebar-scrim" id="sidebar-scrim"></div>
3939
+ ${mobileBarHtml}<div class="sidebar-scrim" id="sidebar-scrim"></div>
3728
3940
  <div class="page-layout">
3729
3941
  ${tocHtml}
3730
3942
  <main class="content">
@@ -3790,9 +4002,10 @@ ${mermaidInitTag}`)
3790
4002
  // wholesale after a commit (see lib/editor/client.js), which detaches every
3791
4003
  // node this was captured from. window.__md2docRebindReader() below
3792
4004
  // re-queries and reassigns this same binding so every closure in this IIFE
3793
- // that reads headingNodes (detectActiveHeading, the zoom/resize scroll
3794
- // anchor's binary search, …) sees the live nodes without needing its own
3795
- // re-init call — they all close over this one variable, not a copy of it.
4005
+ // that reads headingNodes (readingLineHeading, …) sees the live nodes without
4006
+ // needing its own re-init call — they all close over this one variable, not a
4007
+ // copy of it. (The zoom/resize anchoring, captureScrollAnchor, does not read
4008
+ // it: it walks contentEl.children.)
3796
4009
  let headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
3797
4010
  const tocLinks = new Map(
3798
4011
  Array.from(document.querySelectorAll('.toc a[href^="#"]')).map((link) => [link.getAttribute('href').slice(1), link])
@@ -3812,12 +4025,18 @@ ${mermaidInitTag}`)
3812
4025
  }
3813
4026
 
3814
4027
  const breadcrumbEl = document.querySelector('[data-toc-breadcrumb]');
4028
+ const mobileBarTitle = document.getElementById('mobile-bar-title');
4029
+ if (mobileBarTitle && sections.length) mobileBarTitle.textContent = sections[0].title;
3815
4030
  const sectionIndexById = new Map(sections.map((section, i) => [section.id, i]));
3816
4031
 
3817
4032
  // Sticky breadcrumb: the ancestor chain (shallow→deep) of the active section.
3818
4033
  // For each decreasing depth, take the nearest preceding section, then append
3819
- // the active section itself. Rendered as stacked, indented, clickable rows.
4034
+ // the active section itself. The rows are still written into the breadcrumb
4035
+ // element, but CSS hides it (the TOC highlights the path); the useful output
4036
+ // today is the last link's title, which names the mobile bar.
4037
+ let breadcrumbId = null;
3820
4038
  function renderBreadcrumb(sectionId) {
4039
+ breadcrumbId = sectionId;
3821
4040
  if (!breadcrumbEl) return;
3822
4041
  if (!sectionIndexById.has(sectionId)) {
3823
4042
  breadcrumbEl.innerHTML = '';
@@ -3837,6 +4056,7 @@ ${mermaidInitTag}`)
3837
4056
  return;
3838
4057
  }
3839
4058
  const minDepth = chain[0].depth;
4059
+ if (mobileBarTitle) mobileBarTitle.textContent = chain[chain.length - 1].title;
3840
4060
  breadcrumbEl.innerHTML = chain
3841
4061
  .map((section, i) => {
3842
4062
  const indent = (section.depth - minDepth) * 10;
@@ -3862,11 +4082,24 @@ ${mermaidInitTag}`)
3862
4082
  if (!scroller) return;
3863
4083
  const scrollerRect = scroller.getBoundingClientRect();
3864
4084
  const linkRect = link.getBoundingClientRect();
3865
- if (linkRect.top < scrollerRect.top) {
3866
- scroller.scrollTop += linkRect.top - scrollerRect.top;
3867
- } else if (linkRect.bottom > scrollerRect.bottom) {
3868
- scroller.scrollTop += linkRect.bottom - scrollerRect.bottom;
4085
+ // Keep the active row at about 40% of the TOC box instead of nudging it
4086
+ // back to an edge: the highlight stays mid-box, never at 0% or 95%.
4087
+ const target = scroller.scrollTop + (linkRect.top - scrollerRect.top) - scroller.clientHeight * 0.4;
4088
+ if (Math.abs(target - scroller.scrollTop) <= 2) return;
4089
+ const out = linkRect.bottom < scrollerRect.top || linkRect.top > scrollerRect.bottom;
4090
+ const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
4091
+ scroller.scrollTo({ top: target, behavior: out || calm ? 'auto' : 'smooth' });
4092
+ }
4093
+
4094
+ // Accordion: only the active row's ancestor path stays open.
4095
+ function collapseOtherTocDetails(link) {
4096
+ const keep = new Set();
4097
+ for (let node = link && link.closest('details'); node; node = node.parentElement && node.parentElement.closest('details')) {
4098
+ keep.add(node);
3869
4099
  }
4100
+ Array.from(document.querySelectorAll('.toc-list details[open]')).forEach((node) => {
4101
+ if (!keep.has(node)) node.open = false;
4102
+ });
3870
4103
  }
3871
4104
 
3872
4105
  let observerFrozen = false;
@@ -3887,52 +4120,92 @@ ${mermaidInitTag}`)
3887
4120
  next.classList.add('is-active');
3888
4121
  if (!freezeSidebar) {
3889
4122
  expandTocAncestors(next);
4123
+ collapseOtherTocDetails(next);
3890
4124
  ensureTocLinkVisible(next);
3891
4125
  }
3892
4126
  }
3893
4127
  }
3894
4128
 
3895
- // Hoisted out of a bare if block (and into a rebindable function) so that
3896
- // window.__md2docRebindReader() below can disconnect the stale observer
3897
- // and create a fresh one over the post-edit heading nodes — an observer
3898
- // keeps observing the exact node references passed to observe(), so after
3899
- // .content's innerHTML is replaced wholesale, the old observer is watching
3900
- // detached nodes that will never intersect anything again.
3901
- let headingObserver = null;
3902
- function bindHeadingObserver() {
3903
- if (headingObserver) {
3904
- headingObserver.disconnect();
3905
- headingObserver = null;
3906
- }
3907
- if (typeof IntersectionObserver === 'undefined' || !headingNodes.length) {
3908
- return;
4129
+ // The reading line: the last heading that has a TOC row and sits at or
4130
+ // above 35% of the viewport height (the first such heading when none has
4131
+ // reached the line yet). Headings without a TOC row (a lone H1 is flattened
4132
+ // away) are skipped, so the highlight never lands on a row that is not there.
4133
+ // A TOC click lands the clicked heading at the top of the page; when its
4134
+ // section is shorter than the gap down to the line, the next heading is
4135
+ // already above the line and would steal the highlight from the row that was
4136
+ // just clicked. The clicked heading stays the answer for as long as it is
4137
+ // still between the page top and the line, and the first scroll that moves
4138
+ // it out of that band hands tracking back to the line.
4139
+ let pinnedHeadingId = null;
4140
+ function readingLineHeading() {
4141
+ const line = window.innerHeight * 0.35;
4142
+ if (pinnedHeadingId) {
4143
+ const pinned = document.getElementById(pinnedHeadingId);
4144
+ const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
4145
+ if (pinned && tocLinks.has(pinned.id) && top >= -40 && top <= line) return pinned;
4146
+ pinnedHeadingId = null;
3909
4147
  }
3910
- headingObserver = new IntersectionObserver((entries) => {
3911
- if (observerFrozen) {
3912
- return;
3913
- }
3914
- const visible = entries
3915
- .filter((entry) => entry.isIntersecting)
3916
- .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
3917
- if (visible[0]) {
3918
- syncActiveHeading(visible[0].target.id);
4148
+ let first = null;
4149
+ let candidate = null;
4150
+ for (const node of headingNodes) {
4151
+ if (!tocLinks.has(node.id)) continue;
4152
+ if (!first) first = node;
4153
+ if (node.getBoundingClientRect().top <= line) {
4154
+ candidate = node;
4155
+ } else {
4156
+ break;
3919
4157
  }
3920
- }, { rootMargin: '0px 0px -65% 0px', threshold: [0, 1] });
3921
- headingNodes.forEach((node) => headingObserver.observe(node));
4158
+ }
4159
+ return candidate || first;
4160
+ }
4161
+
4162
+ // One scroll listener, installed once, throttled to one pass per animation
4163
+ // frame. bindHeadingObserver keeps its name because
4164
+ // window.__md2docRebindReader calls it after an edit-mode re-render; it only
4165
+ // runs a pass now, since headingNodes is read live on every pass.
4166
+ // A lone H1 has no TOC row, so the highlight skips it; the bar still names
4167
+ // the document while that H1 is on screen, as it always has, and names the
4168
+ // active section once the H1 has scrolled off.
4169
+ function syncReadingTitle(node) {
4170
+ const first = sections[0] ? document.getElementById(sections[0].id) : null;
4171
+ const onScreen = first && first !== node && !tocLinks.has(first.id) && first.getBoundingClientRect().top >= 0;
4172
+ const titleId = onScreen ? first.id : node.id;
4173
+ if (titleId !== breadcrumbId) renderBreadcrumb(titleId);
4174
+ }
4175
+ let readingLinePending = false;
4176
+ function readingLinePass() {
4177
+ readingLinePending = false;
4178
+ if (observerFrozen) return;
4179
+ const node = readingLineHeading();
4180
+ if (!node) return;
4181
+ syncActiveHeading(node.id);
4182
+ syncReadingTitle(node);
4183
+ }
4184
+ window.addEventListener('scroll', () => {
4185
+ if (readingLinePending) return;
4186
+ readingLinePending = true;
4187
+ requestAnimationFrame(readingLinePass);
4188
+ }, { passive: true });
4189
+ document.addEventListener('click', (event) => {
4190
+ const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
4191
+ if (link) pinnedHeadingId = link.getAttribute('href').slice(1);
4192
+ });
4193
+ function bindHeadingObserver() {
4194
+ readingLinePass();
3922
4195
  }
3923
- bindHeadingObserver();
3924
4196
 
3925
4197
  // Paint the breadcrumb immediately so the header is populated before the
3926
- // first IntersectionObserver callback fires.
4198
+ // first reading-line pass runs, then run that pass once on load.
3927
4199
  if (sections[0]) {
3928
4200
  renderBreadcrumb(sections[0].id);
3929
4201
  }
4202
+ bindHeadingObserver();
3930
4203
 
3931
4204
  // Edit mode only: re-init hook, sibling to window.__md2docInitDiagrams
3932
4205
  // (defined in diagramInitHookScript above for edit-mode pages), called
3933
4206
  // from lib/editor/client.js's rerenderAll() right after it swaps
3934
4207
  // .content's innerHTML on a commit. Re-queries the heading nodes that
3935
- // just got replaced and rebinds the IntersectionObserver onto them, so
4208
+ // just got replaced and runs one reading-line pass over them, so
3936
4209
  // TOC highlighting / breadcrumb tracking / the zoom-resize scroll anchor
3937
4210
  // (all of which read the headingNodes binding above) keep working
3938
4211
  // against live nodes instead of silently going dead after the first edit.
@@ -4164,22 +4437,8 @@ ${mermaidInitTag}`)
4164
4437
  }
4165
4438
  }
4166
4439
 
4167
- function detectActiveHeading() {
4168
- const threshold = 120;
4169
- let candidate = null;
4170
- for (const node of headingNodes) {
4171
- const rect = node.getBoundingClientRect();
4172
- if (rect.top <= threshold) {
4173
- candidate = node;
4174
- } else {
4175
- break;
4176
- }
4177
- }
4178
- return candidate || headingNodes[0] || null;
4179
- }
4180
-
4181
4440
  function resyncTocToScroll() {
4182
- const node = detectActiveHeading();
4441
+ const node = readingLineHeading();
4183
4442
  if (!node) return;
4184
4443
  if (readerState.activeSectionId === node.id) {
4185
4444
  const link = tocLinks.get(node.id);
@@ -4187,9 +4446,11 @@ ${mermaidInitTag}`)
4187
4446
  expandTocAncestors(link);
4188
4447
  ensureTocLinkVisible(link);
4189
4448
  }
4449
+ syncReadingTitle(node);
4190
4450
  return;
4191
4451
  }
4192
4452
  syncActiveHeading(node.id);
4453
+ syncReadingTitle(node);
4193
4454
  }
4194
4455
 
4195
4456
  function ensureActiveResultVisible() {
@@ -4416,18 +4677,30 @@ ${mermaidInitTag}`)
4416
4677
  var hit = document.elementFromPoint(rect.left + rect.width / 2, ANCHOR_LINE);
4417
4678
  var node = (hit && hit.closest) ? hit.closest('main.content > *') : null;
4418
4679
  if (!node) {
4419
- // Between blocks, or over a gap: fall back to the last heading above the
4420
- // anchor line. Heading offsets are monotonic in document order, so this
4421
- // is a binary search rather than a scan of every heading each frame.
4680
+ // Over a gap (a heading's top margin, the space between blocks): pin the
4681
+ // first block that starts below the anchor line, which is what the reader
4682
+ // sees next to it. Falling back to the last heading ABOVE the line would
4683
+ // pin something up to a whole section away. Block tops are monotonic in
4684
+ // document order, so this is a binary search over the column's children.
4685
+ var kids = contentEl.children;
4422
4686
  var lo = 0;
4423
- var hi = headingNodes.length - 1;
4687
+ var hi = kids.length - 1;
4424
4688
  while (lo <= hi) {
4425
4689
  var mid = (lo + hi) >> 1;
4426
- if (headingNodes[mid].getBoundingClientRect().top <= ANCHOR_LINE) {
4427
- node = headingNodes[mid];
4428
- lo = mid + 1;
4429
- } else {
4690
+ // A child with no layout boxes (every WaveDrom fence leaves a script
4691
+ // element) reports an all-zero rect, which would break the
4692
+ // monotonic order the search depends on. Compare the nearest
4693
+ // following child that is rendered; none left before hi means the
4694
+ // answer is in the lower half.
4695
+ var probe = mid;
4696
+ while (probe <= hi && !kids[probe].getClientRects().length) probe++;
4697
+ if (probe > hi) {
4430
4698
  hi = mid - 1;
4699
+ } else if (kids[probe].getBoundingClientRect().top > ANCHOR_LINE) {
4700
+ node = kids[probe];
4701
+ hi = mid - 1;
4702
+ } else {
4703
+ lo = probe + 1;
4431
4704
  }
4432
4705
  }
4433
4706
  }
@@ -5645,6 +5918,9 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
5645
5918
  } finally {
5646
5919
  await drawioBrowser.closeIfOpen();
5647
5920
  }
5921
+ // v3.9.0 dark mode: a post-pass over the finished reader HTML (lib/theme/).
5922
+ // Edit mode is light-only; noTheme is an internal switch for the light-lock test.
5923
+ if (!opts.editMode && !opts.noTheme) bakedHtml = applyReaderTheme(bakedHtml);
5648
5924
  return {
5649
5925
  html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
5650
5926
  // v3.4.0 batch2 Task 6: an added field, not a shape change to the four
@@ -5656,6 +5932,42 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
5656
5932
  }
5657
5933
  module.exports = { renderMarkdown };
5658
5934
 
5935
+ // Index of the first "{" in dot source that opens the graph body: not inside a
5936
+ // double-quoted string, a // or /* */ comment, a line starting with # (dot
5937
+ // treats it as preprocessor output), or an HTML-like <...> id. -1 if none.
5938
+ function firstDotBrace(src) {
5939
+ let i = 0;
5940
+ const n = src.length;
5941
+ while (i < n) {
5942
+ const c = src[i];
5943
+ if (c === '"') {
5944
+ i++;
5945
+ while (i < n && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
5946
+ i++;
5947
+ } else if (c === '/' && src[i + 1] === '/') {
5948
+ while (i < n && src[i] !== '\n') i++;
5949
+ } else if (c === '/' && src[i + 1] === '*') {
5950
+ const end = src.indexOf('*/', i + 2);
5951
+ i = end < 0 ? n : end + 2;
5952
+ } else if (c === '#' && (i === 0 || src[i - 1] === '\n')) {
5953
+ while (i < n && src[i] !== '\n') i++;
5954
+ } else if (c === '<') {
5955
+ let depth = 0;
5956
+ while (i < n) {
5957
+ if (src[i] === '<') depth++;
5958
+ else if (src[i] === '>' && --depth === 0) break;
5959
+ i++;
5960
+ }
5961
+ i++;
5962
+ } else if (c === '{') {
5963
+ return i;
5964
+ } else {
5965
+ i++;
5966
+ }
5967
+ }
5968
+ return -1;
5969
+ }
5970
+
5659
5971
  // Render every deferred dot/graphviz placeholder to inline SVG using the
5660
5972
  // in-process WASM engine. Loads the WASM module only when at least one dot
5661
5973
  // block exists, so text-only docs pay nothing.
@@ -5672,11 +5984,35 @@ async function bakeGraphviz(htmlStr) {
5672
5984
  return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
5673
5985
  const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
5674
5986
  try {
5675
- const svg = gv.dot(dotSrc)
5987
+ // Graphviz measures label widths with the font it lays out in, and with
5988
+ // no fontname that is Times, whose metrics are narrower than the sans
5989
+ // the SVG is drawn in -- so labels overflowed their boxes. Declare
5990
+ // Helvetica BEFORE layout, right after the first brace; a fontname the
5991
+ // author writes later in the source still wins. Exactly "Helvetica":
5992
+ // a "Helvetica,sans-Serif" family list falls back to Times widths.
5993
+ const at = firstDotBrace(dotSrc);
5994
+ const laidOut = at < 0 ? dotSrc : dotSrc.slice(0, at + 1) +
5995
+ ' graph [fontname="Helvetica"]; node [fontname="Helvetica"]; edge [fontname="Helvetica"]; ' + dotSrc.slice(at + 1);
5996
+ let laid;
5997
+ try {
5998
+ laid = gv.dot(laidOut);
5999
+ } catch (injectErr) {
6000
+ // Injection must never turn a valid graph into an error: lay the
6001
+ // original source out as the author wrote it.
6002
+ if (laidOut === dotSrc) throw injectErr;
6003
+ laid = gv.dot(dotSrc);
6004
+ }
6005
+ const svg = laid
5676
6006
  .replace(/<\?xml[^>]*\?>/g, '')
5677
6007
  .replace(/<!DOCTYPE[\s\S]*?>/g, '')
5678
6008
  // drop graphviz's "Generated by graphviz" banner comment so the div and <svg> are adjacent
5679
6009
  .replace(/<!--[\s\S]*?-->/g, '')
6010
+ // v3.8.0: graphviz falls back to Times when no fontname is set. The
6011
+ // injection above makes Helvetica the layout font, so this rewrite
6012
+ // now only touches an author's explicit Times-Roman, or a figure
6013
+ // whose injection fell back to the original source; other explicit
6014
+ // fontnames are untouched.
6015
+ .replace(/font-family="Times,serif"/g, 'font-family="Helvetica,sans-Serif"')
5680
6016
  .trim();
5681
6017
  return `<div class="graphviz">${svg}</div>`;
5682
6018
  } catch (e) {
@@ -5838,6 +6174,7 @@ async function bakeDiagrams(htmlStr, dstPath) {
5838
6174
  if (require.main === module) {
5839
6175
  const [,, src, dst] = process.argv;
5840
6176
  const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
6177
+ const LANG_ARG = (process.argv.slice(4).find((a) => a.startsWith('--lang=')) || '').slice('--lang='.length) || undefined;
5841
6178
  if (!src || !dst) {
5842
6179
  console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
5843
6180
  process.exit(1);
@@ -5847,7 +6184,7 @@ if (require.main === module) {
5847
6184
 
5848
6185
  (async () => {
5849
6186
  const mdText = fs.readFileSync(src, 'utf8');
5850
- const { html } = await renderMarkdown(mdText, path.resolve(src), {});
6187
+ const { html } = await renderMarkdown(mdText, path.resolve(src), { lang: LANG_ARG });
5851
6188
  let finalHtml = html;
5852
6189
 
5853
6190
  if (ext === '.html') {