@helping-ai-workflow/md2doc 3.8.0 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,6 +78,10 @@ also pass `--open`.
78
78
  | `--version`, `-v` | Print version. |
79
79
  | `--help`, `-h` | Print help. |
80
80
 
81
+ ### Reading view
82
+
83
+ - **Dark mode** — a ☀/☾ toggle (bottom-right on desktop, top bar on phone). Light by default; the choice is remembered in the browser. PDF and print stay light.
84
+
81
85
  ### Images
82
86
 
83
87
  Local images referenced from the markdown are resolved against the **markdown file's own
@@ -12823,7 +12823,8 @@
12823
12823
  // S4 Task 2b (user request, 2026-09-01): the ONLY feedback the source
12824
12824
  // block gets. `.ed-handle` is `opacity: 0` off-hover, so from the moment
12825
12825
  // the pointer leaves the block there is nothing under the cursor but the
12826
- // indicator line. The ruling is a `cursor: grabbing` class on
12826
+ // indicator line. The ruling is a drag-cursor class (`cursor: move`; v3.8.1
12827
+ // dropped `grabbing`, a browser bitmap on Windows) on
12827
12828
  // `document.documentElement` — OUTSIDE `contentEl`, so the "mutate
12828
12829
  // nothing in the content DOM during a drag" invariant this whole gesture
12829
12830
  // rests on still holds, and every cancellation path stays unconditional.
@@ -13578,7 +13579,7 @@
13578
13579
  if (tePointer.hit.kind === 'row') {
13579
13580
  // The column grip hides like the insert bubbles above (it isn't
13580
13581
  // meaningful mid row-drag); the ROW grip stays visible and switches
13581
- // to its "dragging" visual (grabbing cursor) — it IS the drag
13582
+ // to its "dragging" visual (move cursor) — it IS the drag
13582
13583
  // handle the user is holding, per the brief ("the active grip may
13583
13584
  // stay as the drag handle visual").
13584
13585
  colGrip.hidden = true;
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).
@@ -1346,7 +1347,7 @@ ${itemsHtml}
1346
1347
  // (on a later call) — the CSS cursor rule and the lightbox click
1347
1348
  // target selector both key off this class (in addition to the
1348
1349
  // WaveDrom_Display_ id prefix, which non-edit pages rely on and
1349
- // never rename), so a diagram stays clickable and zoom-in-affordant
1350
+ // never rename), so a diagram stays clickable and shows its click cursor
1350
1351
  // for its whole lifetime, independent of id reclaiming.
1351
1352
  var displayDiv = wavePending[j].previousElementSibling;
1352
1353
  if (displayDiv) {
@@ -1908,9 +1909,9 @@ ${itemsHtml}
1908
1909
  }
1909
1910
  .ed-wave-lane-row.is-selected { background: #eaf2fe; }
1910
1911
  .ed-wave-lane-handle {
1911
- 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;
1912
1913
  }
1913
- .ed-wave-lane-handle:active { cursor: grabbing; }
1914
+ .ed-wave-lane-handle:active { cursor: move; }
1914
1915
  /* min-width, not just flex-grow: min-width is what a flex item will not
1915
1916
  shrink past, and shrinking past it is the vertical-text failure above.
1916
1917
  MEASURED after this change (real page, 800x600, the journey suite's own
@@ -2321,7 +2322,7 @@ const html = `<!DOCTYPE html>
2321
2322
  .sidebar-splitter {
2322
2323
  flex: 0 0 32px;
2323
2324
  align-self: stretch;
2324
- cursor: col-resize;
2325
+ cursor: ew-resize;
2325
2326
  display: flex;
2326
2327
  justify-content: flex-start;
2327
2328
  touch-action: none;
@@ -2957,7 +2958,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2957
2958
  .content .graphviz,
2958
2959
  .content .drawio,
2959
2960
  .content [id^="WaveDrom_Display_"],
2960
- .content .wavedrom-diagram { cursor: zoom-in; }
2961
+ .content .wavedrom-diagram { cursor: pointer; }
2961
2962
  .content a img { cursor: pointer; }
2962
2963
 
2963
2964
  .lightbox {
@@ -3062,10 +3063,10 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3062
3063
  position: relative;
3063
3064
  flex: 1 1 auto;
3064
3065
  overflow: auto;
3065
- cursor: grab;
3066
+ cursor: move;
3066
3067
  overscroll-behavior: contain;
3067
3068
  }
3068
- .lightbox-stage[data-panning] { cursor: grabbing; }
3069
+ .lightbox-stage[data-panning] { cursor: move; }
3069
3070
  .lightbox-canvas { margin: 0 auto; position: relative; }
3070
3071
  /* Sized in px by the runtime; the child follows, so the scroll extent grows
3071
3072
  with the zoom. A CSS transform would scale the pixels and leave the scroll
@@ -3540,7 +3541,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3540
3541
  having: '.ed-handle' (the button the gesture starts on), '.ed-handle-menu-btn'
3541
3542
  and every table grip carry their own 'cursor: pointer', and a plain
3542
3543
  inherited cursor loses to all of them. */
3543
- html.ed-block-dragging, html.ed-block-dragging * { cursor: grabbing !important; }
3544
+ html.ed-block-dragging, html.ed-block-dragging * { cursor: move !important; }
3544
3545
  /* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
3545
3546
  proximity zone (user-acceptance feedback: unusably small, no visible
3546
3547
  affordance) with two real, adequately-sized (>=18x24px) click/drag
@@ -3567,7 +3568,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3567
3568
  place-content: center, so the dot cluster stays compact/centered
3568
3569
  regardless of the button's own (larger, hit-target-sized) box — no
3569
3570
  images, no background gradients. '.ed-te-grip-dragging' is EITHER
3570
- 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
3571
3572
  column drag is in flight (Task 8: columns became draggable too, same
3572
3573
  as rows) — see cancelTeDrag()/the pointermove listener in the client
3573
3574
  runtime. */
@@ -3589,17 +3590,17 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3589
3590
  the position was a hard-coded literal 20. */
3590
3591
  :root { --ed-te-grip-row-w: 20px; }
3591
3592
  .ed-te-grip-row {
3592
- 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;
3593
3594
  grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
3594
3595
  gap: 3px 4px;
3595
3596
  }
3596
- .ed-te-grip-row.ed-te-grip-dragging { cursor: grabbing; }
3597
+ .ed-te-grip-row.ed-te-grip-dragging { cursor: move; }
3597
3598
  .ed-te-grip-col {
3598
- width: 28px; height: 24px; cursor: grab; z-index: 7;
3599
+ width: 28px; height: 24px; cursor: move; z-index: 7;
3599
3600
  grid-template-columns: repeat(3, 3px); grid-template-rows: repeat(2, 3px);
3600
3601
  gap: 4px 3px;
3601
3602
  }
3602
- .ed-te-grip-col.ed-te-grip-dragging { cursor: grabbing; }
3603
+ .ed-te-grip-col.ed-te-grip-dragging { cursor: move; }
3603
3604
  /* Task 4: floating selection toolbar (bold/italic/strikethrough/underline/
3604
3605
  code/link), shown over a
3605
3606
  non-collapsed selection inside an active WYSIWYG session. position:
@@ -4001,9 +4002,10 @@ ${mermaidInitTag}`)
4001
4002
  // wholesale after a commit (see lib/editor/client.js), which detaches every
4002
4003
  // node this was captured from. window.__md2docRebindReader() below
4003
4004
  // re-queries and reassigns this same binding so every closure in this IIFE
4004
- // that reads headingNodes (detectActiveHeading, the zoom/resize scroll
4005
- // anchor's binary search, …) sees the live nodes without needing its own
4006
- // 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.)
4007
4009
  let headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
4008
4010
  const tocLinks = new Map(
4009
4011
  Array.from(document.querySelectorAll('.toc a[href^="#"]')).map((link) => [link.getAttribute('href').slice(1), link])
@@ -4032,7 +4034,9 @@ ${mermaidInitTag}`)
4032
4034
  // the active section itself. The rows are still written into the breadcrumb
4033
4035
  // element, but CSS hides it (the TOC highlights the path); the useful output
4034
4036
  // today is the last link's title, which names the mobile bar.
4037
+ let breadcrumbId = null;
4035
4038
  function renderBreadcrumb(sectionId) {
4039
+ breadcrumbId = sectionId;
4036
4040
  if (!breadcrumbEl) return;
4037
4041
  if (!sectionIndexById.has(sectionId)) {
4038
4042
  breadcrumbEl.innerHTML = '';
@@ -4078,11 +4082,24 @@ ${mermaidInitTag}`)
4078
4082
  if (!scroller) return;
4079
4083
  const scrollerRect = scroller.getBoundingClientRect();
4080
4084
  const linkRect = link.getBoundingClientRect();
4081
- if (linkRect.top < scrollerRect.top) {
4082
- scroller.scrollTop += linkRect.top - scrollerRect.top;
4083
- } else if (linkRect.bottom > scrollerRect.bottom) {
4084
- 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);
4085
4099
  }
4100
+ Array.from(document.querySelectorAll('.toc-list details[open]')).forEach((node) => {
4101
+ if (!keep.has(node)) node.open = false;
4102
+ });
4086
4103
  }
4087
4104
 
4088
4105
  let observerFrozen = false;
@@ -4103,52 +4120,92 @@ ${mermaidInitTag}`)
4103
4120
  next.classList.add('is-active');
4104
4121
  if (!freezeSidebar) {
4105
4122
  expandTocAncestors(next);
4123
+ collapseOtherTocDetails(next);
4106
4124
  ensureTocLinkVisible(next);
4107
4125
  }
4108
4126
  }
4109
4127
  }
4110
4128
 
4111
- // Hoisted out of a bare if block (and into a rebindable function) so that
4112
- // window.__md2docRebindReader() below can disconnect the stale observer
4113
- // and create a fresh one over the post-edit heading nodes — an observer
4114
- // keeps observing the exact node references passed to observe(), so after
4115
- // .content's innerHTML is replaced wholesale, the old observer is watching
4116
- // detached nodes that will never intersect anything again.
4117
- let headingObserver = null;
4118
- function bindHeadingObserver() {
4119
- if (headingObserver) {
4120
- headingObserver.disconnect();
4121
- headingObserver = null;
4122
- }
4123
- if (typeof IntersectionObserver === 'undefined' || !headingNodes.length) {
4124
- 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;
4125
4147
  }
4126
- headingObserver = new IntersectionObserver((entries) => {
4127
- if (observerFrozen) {
4128
- return;
4129
- }
4130
- const visible = entries
4131
- .filter((entry) => entry.isIntersecting)
4132
- .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
4133
- if (visible[0]) {
4134
- 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;
4135
4157
  }
4136
- }, { rootMargin: '0px 0px -65% 0px', threshold: [0, 1] });
4137
- 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();
4138
4195
  }
4139
- bindHeadingObserver();
4140
4196
 
4141
4197
  // Paint the breadcrumb immediately so the header is populated before the
4142
- // first IntersectionObserver callback fires.
4198
+ // first reading-line pass runs, then run that pass once on load.
4143
4199
  if (sections[0]) {
4144
4200
  renderBreadcrumb(sections[0].id);
4145
4201
  }
4202
+ bindHeadingObserver();
4146
4203
 
4147
4204
  // Edit mode only: re-init hook, sibling to window.__md2docInitDiagrams
4148
4205
  // (defined in diagramInitHookScript above for edit-mode pages), called
4149
4206
  // from lib/editor/client.js's rerenderAll() right after it swaps
4150
4207
  // .content's innerHTML on a commit. Re-queries the heading nodes that
4151
- // just got replaced and rebinds the IntersectionObserver onto them, so
4208
+ // just got replaced and runs one reading-line pass over them, so
4152
4209
  // TOC highlighting / breadcrumb tracking / the zoom-resize scroll anchor
4153
4210
  // (all of which read the headingNodes binding above) keep working
4154
4211
  // against live nodes instead of silently going dead after the first edit.
@@ -4380,22 +4437,8 @@ ${mermaidInitTag}`)
4380
4437
  }
4381
4438
  }
4382
4439
 
4383
- function detectActiveHeading() {
4384
- const threshold = 120;
4385
- let candidate = null;
4386
- for (const node of headingNodes) {
4387
- const rect = node.getBoundingClientRect();
4388
- if (rect.top <= threshold) {
4389
- candidate = node;
4390
- } else {
4391
- break;
4392
- }
4393
- }
4394
- return candidate || headingNodes[0] || null;
4395
- }
4396
-
4397
4440
  function resyncTocToScroll() {
4398
- const node = detectActiveHeading();
4441
+ const node = readingLineHeading();
4399
4442
  if (!node) return;
4400
4443
  if (readerState.activeSectionId === node.id) {
4401
4444
  const link = tocLinks.get(node.id);
@@ -4403,9 +4446,11 @@ ${mermaidInitTag}`)
4403
4446
  expandTocAncestors(link);
4404
4447
  ensureTocLinkVisible(link);
4405
4448
  }
4449
+ syncReadingTitle(node);
4406
4450
  return;
4407
4451
  }
4408
4452
  syncActiveHeading(node.id);
4453
+ syncReadingTitle(node);
4409
4454
  }
4410
4455
 
4411
4456
  function ensureActiveResultVisible() {
@@ -4623,11 +4668,19 @@ ${mermaidInitTag}`)
4623
4668
  var anchorTop = 0;
4624
4669
  var anchorFrame = 0;
4625
4670
  var suppressAnchorCapture = false;
4671
+ // Column width the anchor was last captured/held at; -1 until the column
4672
+ // ResizeObserver below is installed. See captureScrollAnchor's first guard.
4673
+ var lastAnchorColW = -1;
4626
4674
  var lastLayoutWidth = document.documentElement.clientWidth;
4627
4675
  var lastPixelRatio = window.devicePixelRatio;
4628
4676
 
4629
4677
  function captureScrollAnchor() {
4630
4678
  if (suppressAnchorCapture || !contentEl) return;
4679
+ // A column reflow the ResizeObserver has not delivered yet: during a
4680
+ // splitter drag the next pointermove lands before holdScrollAnchor's
4681
+ // trailing capture frame, so capturing now would pin a post-reflow node
4682
+ // and the observer's restore would then find nothing to correct.
4683
+ if (lastAnchorColW >= 0 && contentEl.clientWidth !== lastAnchorColW) return;
4631
4684
  var rect = contentEl.getBoundingClientRect();
4632
4685
  var hit = document.elementFromPoint(rect.left + rect.width / 2, ANCHOR_LINE);
4633
4686
  var node = (hit && hit.closest) ? hit.closest('main.content > *') : null;
@@ -4679,14 +4732,7 @@ ${mermaidInitTag}`)
4679
4732
  });
4680
4733
  }, { passive: true });
4681
4734
 
4682
- window.addEventListener('resize', function () {
4683
- var width = document.documentElement.clientWidth;
4684
- var ratio = window.devicePixelRatio;
4685
- // A height-only change (a mobile browser hiding its toolbar, a devtools
4686
- // dock) reflows nothing, so correcting the scroll would only jerk the page.
4687
- if (width === lastLayoutWidth && ratio === lastPixelRatio) return;
4688
- lastLayoutWidth = width;
4689
- lastPixelRatio = ratio;
4735
+ function holdScrollAnchor() {
4690
4736
  // Hold the anchor across the whole reflow: the scrollTo below fires scroll
4691
4737
  // events that would otherwise re-capture a mid-reflow position.
4692
4738
  suppressAnchorCapture = true;
@@ -4698,8 +4744,37 @@ ${mermaidInitTag}`)
4698
4744
  captureScrollAnchor();
4699
4745
  });
4700
4746
  });
4747
+ }
4748
+
4749
+ window.addEventListener('resize', function () {
4750
+ var width = document.documentElement.clientWidth;
4751
+ var ratio = window.devicePixelRatio;
4752
+ // A height-only change (a mobile browser hiding its toolbar, a devtools
4753
+ // dock) reflows nothing, so correcting the scroll would only jerk the page.
4754
+ if (width === lastLayoutWidth && ratio === lastPixelRatio) return;
4755
+ lastLayoutWidth = width;
4756
+ lastPixelRatio = ratio;
4757
+ holdScrollAnchor();
4701
4758
  });
4702
4759
 
4760
+ // The column also narrows and widens with no window resize at all: a
4761
+ // sidebar-splitter drag, its double-click reset and the TOC collapse toggle
4762
+ // only move --md2doc-sidebar-w / data-toc-collapsed, so the 'resize' listener
4763
+ // above never hears about them and the reflow slid the page by thousands of
4764
+ // px. Watch the column itself. Width only: its height changes whenever an
4765
+ // image or diagram finishes loading, which reflows nothing above the anchor.
4766
+ // The first callback (fired by observe()) sees the starting width and does
4767
+ // nothing, so a load that lands on a #hash is never pulled back.
4768
+ if (contentEl && typeof ResizeObserver === 'function') {
4769
+ lastAnchorColW = contentEl.clientWidth;
4770
+ new ResizeObserver(function () {
4771
+ var w = contentEl.clientWidth;
4772
+ if (w === lastAnchorColW) return;
4773
+ lastAnchorColW = w;
4774
+ holdScrollAnchor();
4775
+ }).observe(contentEl);
4776
+ }
4777
+
4703
4778
  captureScrollAnchor();
4704
4779
 
4705
4780
  // ── drawio sheet bar ─────────────────────────────────────────────────────
@@ -5873,6 +5948,9 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
5873
5948
  } finally {
5874
5949
  await drawioBrowser.closeIfOpen();
5875
5950
  }
5951
+ // v3.9.0 dark mode: a post-pass over the finished reader HTML (lib/theme/).
5952
+ // Edit mode is light-only; noTheme is an internal switch for the light-lock test.
5953
+ if (!opts.editMode && !opts.noTheme) bakedHtml = applyReaderTheme(bakedHtml);
5876
5954
  return {
5877
5955
  html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
5878
5956
  // v3.4.0 batch2 Task 6: an added field, not a shape change to the four
@@ -0,0 +1,268 @@
1
+ // md2doc reader theme toggle (v3.9.0). Inlined into reader HTML by
2
+ // lib/theme/tokens.js#applyReaderTheme — plain browser JS, not part of the
3
+ // lib/md2doc.js template literal. Must never contain a closing script tag.
4
+ // Spec: docs/superpowers/specs/2026-10-02-dark-mode-design.md
5
+ (function () {
6
+ 'use strict';
7
+ var KEY = 'md2doc-theme';
8
+ var root = document.documentElement;
9
+ var btn = document.getElementById('md2doc-theme-toggle');
10
+ var dataEl = document.getElementById('md2doc-theme-data');
11
+ if (!btn || !dataEl) return;
12
+ btn.hidden = false;
13
+ var data = JSON.parse(dataEl.textContent);
14
+ var listeners = [];
15
+ function onTheme(fn) { listeners.push(fn); }
16
+ function notify(dark) { for (var i = 0; i < listeners.length; i++) listeners[i](dark); }
17
+ function isDark() { return root.getAttribute('data-md2doc-theme') === 'dark'; }
18
+ function save(dark) {
19
+ try {
20
+ if (dark) localStorage.setItem(KEY, 'dark'); else localStorage.removeItem(KEY);
21
+ } catch (e) { /* storage blocked: the choice lasts for this page only */ }
22
+ }
23
+ function paint() {
24
+ var dark = isDark();
25
+ var label = dark ? 'Dark theme (click for light)' : 'Light theme (click for dark)';
26
+ btn.textContent = dark ? '☾' : '☀';
27
+ btn.setAttribute('aria-label', label);
28
+ btn.title = label;
29
+ }
30
+ btn.addEventListener('click', function () {
31
+ var dark = !isDark();
32
+ if (dark) root.setAttribute('data-md2doc-theme', 'dark'); else root.removeAttribute('data-md2doc-theme');
33
+ save(dark);
34
+ paint();
35
+ notify(dark);
36
+ });
37
+
38
+ // Phone (<= 1080px): the one button moves into the 44px top bar. A document
39
+ // with no headings has no bar, so the button keeps floating there.
40
+ var bar = document.getElementById('mobile-bar');
41
+ var phone = window.matchMedia('(max-width: 1080px)');
42
+ function place() {
43
+ if (bar && phone.matches) { if (btn.parentNode !== bar) bar.appendChild(btn); }
44
+ else if (btn.parentNode !== document.body) document.body.appendChild(btn);
45
+ }
46
+ if (phone.addEventListener) phone.addEventListener('change', place); else if (phone.addListener) phone.addListener(place);
47
+ place();
48
+
49
+ // Print is always light. The dark tokens are @media screen only; the
50
+ // listeners put diagrams back to their light form while printing.
51
+ var printedDark = false;
52
+ window.addEventListener('beforeprint', function () { printedDark = isDark(); if (printedDark) notify(false); });
53
+ window.addEventListener('afterprint', function () { if (printedDark) notify(true); printedDark = false; });
54
+
55
+ paint();
56
+
57
+ // ── mermaid: light and dark each drawn once, then swapped as markup ──
58
+ // The runtime takes over md2doc's own startOnLoad render (mermaid reads
59
+ // startOnLoad from its config at window load), draws light first so print
60
+ // and toggle-back always have it, and draws dark the first time it is needed.
61
+ var mermaids = [].slice.call(document.querySelectorAll('.content .mermaid'));
62
+ var hasMermaid = typeof window.mermaid !== 'undefined' && mermaids.length > 0;
63
+ var rendered = { light: null, dark: null };
64
+ var chain = Promise.resolve();
65
+ var inFlight = 0;
66
+ // Which render the hosts hold. The observer below must not recolour a light
67
+ // render that is shown on purpose (print while dark swaps light back in).
68
+ var mermaidShown = 'light';
69
+ function mermaidConfig(theme) {
70
+ return { startOnLoad: false, theme: 'base', themeVariables: theme === 'dark' ? data.mermaidDark : data.mermaidLight };
71
+ }
72
+ function swapMermaid(theme) {
73
+ mermaidShown = theme;
74
+ mermaids.forEach(function (el, i) { el.innerHTML = rendered[theme][i]; });
75
+ }
76
+ function cached(theme) {
77
+ var r = rendered[theme];
78
+ if (!r) return false;
79
+ for (var i = 0; i < mermaids.length; i++) if (typeof r[i] !== 'string') return false;
80
+ return true;
81
+ }
82
+ // Light: mermaid.run over the source-text hosts, which is what shows mermaid's
83
+ // own error box for a broken diagram. run() rejects after one, so the cache is
84
+ // stored either way; otherwise every toggle would redraw everything.
85
+ function drawLight() {
86
+ window.mermaid.initialize(mermaidConfig('light'));
87
+ mermaidShown = 'light';
88
+ mermaids.forEach(function (el) { el.removeAttribute('data-processed'); el.textContent = el.getAttribute('data-md2doc-src'); });
89
+ function store() { rendered.light = mermaids.map(function (el) { return el.innerHTML; }); }
90
+ return window.mermaid.run({ nodes: mermaids }).then(store, function (e) { store(); throw e; });
91
+ }
92
+ // Dark: no host is touched until its own svg is ready, so the page never
93
+ // collapses to source text (WebKit has no scroll anchoring and would lose the
94
+ // reading position). A broken diagram keeps its current markup.
95
+ function drawDark() {
96
+ window.mermaid.initialize(mermaidConfig('dark'));
97
+ mermaidShown = 'dark';
98
+ rendered.dark = [];
99
+ var p = Promise.resolve();
100
+ mermaids.forEach(function (el, i) {
101
+ p = p.then(function () {
102
+ return window.mermaid.render('md2doc-mermaid-dark-' + i, el.getAttribute('data-md2doc-src')).then(function (res) {
103
+ el.innerHTML = res.svg;
104
+ if (res.bindFunctions) res.bindFunctions(el);
105
+ var s = el.querySelector('svg');
106
+ if (s) { s.setAttribute('data-md2doc-recoloured', ''); s.setAttribute('data-md2doc-live', ''); }
107
+ }, function () {
108
+ var stray = document.getElementById('dmd2doc-mermaid-dark-' + i);
109
+ if (stray && stray.parentNode) stray.parentNode.removeChild(stray);
110
+ // The host keeps its light render (mermaid's error box); it was drawn light, so it needs the full recolour.
111
+ var kept = el.querySelector('svg');
112
+ if (kept && !kept.hasAttribute('data-md2doc-live')) recolourSvg(kept, isDark());
113
+ }).then(function () { rendered.dark[i] = el.innerHTML; });
114
+ });
115
+ });
116
+ return p.then(function () { window.mermaid.initialize(mermaidConfig('light')); });
117
+ }
118
+ function drawMermaid(theme) { return theme === 'dark' ? drawDark() : drawLight(); }
119
+ function showMermaid(dark) {
120
+ if (!hasMermaid) return;
121
+ var theme = dark ? 'dark' : 'light';
122
+ // Synchronous when nothing is drawing and the render is cached: print needs that.
123
+ // Known limit: if beforeprint fires while a dark draw is in flight, the light swap
124
+ // is queued behind it, so that print snapshot can catch the raw mermaid source.
125
+ if (!inFlight && cached(theme)) { swapMermaid(theme); return; }
126
+ inFlight++;
127
+ chain = chain.then(function () { return cached(theme) ? swapMermaid(theme) : drawMermaid(theme); })
128
+ .catch(function () { /* a failed render leaves mermaid's own error box */ })
129
+ .then(function () { inFlight--; });
130
+ }
131
+ if (hasMermaid) {
132
+ mermaids.forEach(function (el) { el.setAttribute('data-md2doc-src', el.textContent); });
133
+ window.mermaid.initialize(mermaidConfig('light'));
134
+ window.addEventListener('load', function () {
135
+ showMermaid(false);
136
+ if (isDark()) showMermaid(true);
137
+ });
138
+ onTheme(showMermaid);
139
+ }
140
+
141
+ // ── graphviz / WaveDrom / baked SVG: recolour by computed colour ──
142
+ // Rules: spec "Diagram recolour rules (D10)". Reading COMPUTED fill/stroke
143
+ // covers both graphviz presentation attributes and WaveDrom's own <style>.
144
+ var INK = '#c4c7cc';
145
+ var TEXT = '#e3e3e3';
146
+ // .mermaid svg is always included. A live dark render (data-md2doc-live, set by drawDark)
147
+ // keeps mermaid's own arrowheads, markers and edges: only its labels and node shapes
148
+ // carrying an author style are recoloured. A baked or light-drawn mermaid svg gets the full rules.
149
+ var RECOLOUR = '.content .graphviz svg, .content [id^="WaveDrom_Display_"] svg, .content .wavedrom-diagram svg, .content .mermaid svg';
150
+ var XHTML = 'http://www.w3.org/1999/xhtml';
151
+ // Saturation caps are the spec D10 values: 0.7 for text, 0.8 for lines, 0.55 for fills.
152
+ var probe = document.createElement('canvas').getContext('2d');
153
+ function rgba(c) {
154
+ if (!c || c === 'none' || c.indexOf('url(') === 0) return null;
155
+ var m = /rgba?\(([^)]+)\)/.exec(c);
156
+ if (!m) {
157
+ probe.fillStyle = '#000'; probe.fillStyle = c; c = probe.fillStyle;
158
+ if (c.charAt(0) === '#') return [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16), 1];
159
+ m = /rgba?\(([^)]+)\)/.exec(c);
160
+ if (!m) return null;
161
+ }
162
+ var p = m[1].split(',').map(parseFloat);
163
+ return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1];
164
+ }
165
+ function lum(c) {
166
+ function f(v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
167
+ return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
168
+ }
169
+ function hsl(c) {
170
+ var r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
171
+ var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2, h = 0, s = 0;
172
+ if (d) {
173
+ s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
174
+ h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
175
+ h *= 60;
176
+ }
177
+ return [h, s, l];
178
+ }
179
+ function tone(c, l, smax) {
180
+ var x = hsl(c);
181
+ return 'hsl(' + Math.round(x[0]) + ', ' + Math.round(Math.min(x[1], smax) * 100) + '%, ' + Math.round(l * 100) + '%)';
182
+ }
183
+ function lineColour(c) { return hsl(c)[1] > 0.25 ? tone(c, 0.68, 0.8) : INK; }
184
+ function recolourSvg(svg, dark) {
185
+ var els = svg.querySelectorAll('polygon, ellipse, path, polyline, rect, line, circle, text, tspan, foreignObject *');
186
+ var live = svg.hasAttribute('data-md2doc-live');
187
+ for (var i = 0; i < els.length; i++) {
188
+ var el = els[i];
189
+ if (el.hasAttribute('data-md2doc-style')) {
190
+ var orig = el.getAttribute('data-md2doc-style');
191
+ if (orig) el.setAttribute('style', orig); else el.removeAttribute('style');
192
+ } else {
193
+ if (live && el.namespaceURI !== XHTML) {
194
+ var own = el.getAttribute('style') || '';
195
+ // Only what the author styled: node and subgraph shapes, linkStyle edges, text. Never "any inline style":
196
+ // mermaid writes its own fill and stroke inline (pie legend rects), and markers keep their theme colours.
197
+ if (el.closest('marker') || !/(^|;)\s*(fill|stroke)\s*:/.test(own) ||
198
+ !(el.closest('g.node, g.cluster') || el.matches('.flowchart-link') || /^(text|tspan)$/i.test(el.tagName))) continue;
199
+ }
200
+ el.setAttribute('data-md2doc-style', el.getAttribute('style') || '');
201
+ }
202
+ if (!dark) continue;
203
+ var cs = getComputedStyle(el);
204
+ var ov = '';
205
+ if (el.namespaceURI === XHTML) {
206
+ // Mermaid flowchart labels: HTML inside foreignObject, coloured by CSS.
207
+ var fg = rgba(cs.color), bgc = rgba(cs.backgroundColor);
208
+ if (fg && fg[3] > 0 && lum(fg) < 0.35) ov += 'color:' + (hsl(fg)[1] > 0.25 ? tone(fg, 0.78, 0.7) : TEXT) + ' !important;';
209
+ if (bgc && bgc[3] > 0) {
210
+ var BL = lum(bgc);
211
+ if (BL > 0.85) ov += 'background-color:transparent !important;';
212
+ else if (BL > 0.3) ov += 'background-color:' + tone(bgc, 0.26, 0.55) + ' !important;';
213
+ }
214
+ if (ov) {
215
+ var hb = el.getAttribute('data-md2doc-style');
216
+ el.setAttribute('style', (hb ? hb.replace(/;?\s*$/, ';') : '') + ov);
217
+ }
218
+ continue;
219
+ }
220
+ var fill = rgba(cs.fill), stroke = rgba(cs.stroke);
221
+ // Overrides are written once, as attribute text, never through el.style:
222
+ // setProperty leaves style="" on the element after the restore removes
223
+ // the attribute (Chromium re-serialises it), so light would not be
224
+ // byte-exact.
225
+ if (/^(text|tspan)$/i.test(el.tagName)) {
226
+ if (fill && fill[3] > 0 && lum(fill) < 0.35) ov += 'fill:' + (hsl(fill)[1] > 0.25 ? tone(fill, 0.78, 0.7) : TEXT) + ' !important;';
227
+ } else {
228
+ if (fill && fill[3] > 0) {
229
+ var L = lum(fill);
230
+ if (L > 0.85) ov += 'fill:transparent !important;';
231
+ else if (L > 0.3) ov += 'fill:' + tone(fill, 0.26, 0.55) + ' !important;';
232
+ else if (el.closest('g.edge, marker')) ov += 'fill:' + lineColour(fill) + ' !important;';
233
+ }
234
+ if (stroke && stroke[3] > 0 && lum(stroke) < 0.35) ov += 'stroke:' + lineColour(stroke) + ' !important;';
235
+ }
236
+ if (ov) {
237
+ var base = el.getAttribute('data-md2doc-style');
238
+ el.setAttribute('style', (base ? base.replace(/;?\s*$/, ';') : '') + ov);
239
+ }
240
+ }
241
+ if (dark) svg.setAttribute('data-md2doc-recoloured', ''); else svg.removeAttribute('data-md2doc-recoloured');
242
+ }
243
+ function recolourAll(dark) {
244
+ var svgs = document.querySelectorAll(RECOLOUR);
245
+ for (var i = 0; i < svgs.length; i++) recolourSvg(svgs[i], dark);
246
+ }
247
+ onTheme(recolourAll);
248
+ // WaveDrom draws again at DOMContentLoaded, load, +250ms and +1000ms; watch
249
+ // for inserted svgs instead of copying that schedule (same reasoning as
250
+ // applyDiagramScale's observer in lib/md2doc.js).
251
+ var content = document.querySelector('.content');
252
+ if (content && typeof MutationObserver === 'function') {
253
+ new MutationObserver(function (records) {
254
+ if (!isDark()) return;
255
+ for (var r = 0; r < records.length; r++) {
256
+ var added = records[r].addedNodes;
257
+ for (var a = 0; a < added.length; a++) {
258
+ var node = added[a];
259
+ if (!node || node.nodeType !== 1) continue;
260
+ if (mermaidShown === 'light' && node.closest && node.closest('.mermaid')) continue;
261
+ var svgs = node.matches && node.matches(RECOLOUR) ? [node] : (node.querySelectorAll ? node.querySelectorAll(RECOLOUR) : []);
262
+ for (var k = 0; k < svgs.length; k++) recolourSvg(svgs[k], true);
263
+ }
264
+ }
265
+ }).observe(content, { childList: true, subtree: true });
266
+ }
267
+ if (isDark()) recolourAll(true);
268
+ })();
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ // v3.9.0 dark mode: the reader's colour table and the CSS that applies it.
5
+ // Spec: docs/superpowers/specs/2026-10-02-dark-mode-design.md
6
+ //
7
+ // applyReaderTheme() (added in Task 2) hands the reader <style> block to
8
+ // applyThemeTokens(). Every colour literal that has a role below becomes
9
+ // var(--md-<name>), and :root defines each var as that SAME literal, so light
10
+ // rendering is exactly what it was. Dark redefines the vars inside
11
+ // @media screen only, which is what keeps print light.
12
+
13
+ const THEME_TOKENS = [
14
+ { name: 'bg', light: ['#ffffff', '#fff'], dark: '#1b1b1d' },
15
+ { name: 'panel', light: ['#f3f5f7'], dark: '#232325' },
16
+ { name: 'surface', light: ['#f6f8fa'], dark: '#2a2a2d' },
17
+ { name: 'surface-2', light: ['#fafbfc'], dark: '#2a2a2d' },
18
+ { name: 'fg', light: ['#24292e'], dark: '#e3e3e3' },
19
+ { name: 'strong', light: ['#1f2328'], dark: '#f5f6f7' },
20
+ { name: 'muted', light: ['#57606a'], dark: '#a3a6ab' },
21
+ { name: 'muted-2', light: ['#59636e'], dark: '#a3a6ab' },
22
+ { name: 'subtle', light: ['#6a737d'], dark: '#a3a6ab' },
23
+ { name: 'rule', light: ['#e1e4e8'], dark: '#38393c' },
24
+ { name: 'rule-2', light: ['#d8dee4'], dark: '#38393c' },
25
+ { name: 'rule-3', light: ['#dfe2e5'], dark: '#38393c' },
26
+ { name: 'rule-strong', light: ['#c9ced4'], dark: '#505257' },
27
+ { name: 'border', light: ['#d0d7de'], dark: '#46484c' },
28
+ { name: 'hover', light: ['#e3e8ee'], dark: '#303134' },
29
+ { name: 'hover-2', light: ['#e8edf2'], dark: '#303134' },
30
+ { name: 'hover-3', light: ['#eef2f6'], dark: '#303134' },
31
+ { name: 'accent', light: ['#0969da'], dark: '#6ea8f5' },
32
+ { name: 'active-fg', light: ['#0550ae'], dark: '#a5c9f8' },
33
+ { name: 'active-fg-2', light: ['#0b57d0'], dark: '#a5c9f8' },
34
+ { name: 'active-bg', light: ['#dbe6f3'], dark: '#24364f' },
35
+ { name: 'hit-bg', light: ['#fff5c2'], dark: '#4d4417' },
36
+ { name: 'mark-bg', light: ['#fde68a'], dark: '#6e5e14' },
37
+ { name: 'code', light: ['#b93a0c'], dark: '#ff9f70' },
38
+ { name: 'accent-soft', light: ['#93c5fd'], dark: '#6ea8f5' },
39
+ { name: 'error', light: ['#b91c1c'], dark: '#ff8a8a' },
40
+ ];
41
+
42
+ // Literals in reader rules that deliberately stay literal. Measured on real
43
+ // output (mac-tx-core design doc, 2026-10-02): '#000' is the TOC edge-fade
44
+ // mask (alpha only, never painted); '#dbeafe' is the draw.io sheet-bar
45
+ // pressed button, which sits inside the white draw.io plate in dark.
46
+ const KEEP_LITERALS = ['#000', '#dbeafe'];
47
+
48
+ // Content links: today they use the browser default, which stays in light.
49
+ const LINK_DARK = '#79b0f6';
50
+
51
+ // Rules whose literals stay literal: the editor's own UI (edit mode is light
52
+ // only) and the lightbox, which is a dark overlay already.
53
+ const SKIP_SELECTOR = /(^|[\s,>+~(])(html\.ed-|\.ed-|\.lightbox)/;
54
+
55
+ const MERMAID_LIGHT_VARS = {
56
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Arial, "Microsoft JhengHei", sans-serif',
57
+ primaryColor: '#eaf2fd', primaryBorderColor: '#0969da', primaryTextColor: '#1f2328', lineColor: '#57606a',
58
+ secondaryColor: '#f6f8fa', tertiaryColor: '#ffffff', edgeLabelBackground: '#ffffff',
59
+ };
60
+ const MERMAID_DARK_VARS = Object.assign({}, MERMAID_LIGHT_VARS, {
61
+ primaryColor: '#24364f', primaryBorderColor: '#6ea8f5', primaryTextColor: '#f5f6f7', lineColor: '#a3a6ab',
62
+ secondaryColor: '#2a2a2d', tertiaryColor: '#1b1b1d', edgeLabelBackground: '#1b1b1d',
63
+ });
64
+
65
+ const LITERAL_TO_TOKEN = new Map();
66
+ for (const t of THEME_TOKENS) for (const l of t.light) LITERAL_TO_TOKEN.set(l, t.name);
67
+
68
+ // Line-based on purpose: the reader CSS is one declaration per line or one
69
+ // rule per line, and a line-based pass leaves comment prose (which names
70
+ // colours) alone. `selector` is the text before the last '{' seen.
71
+ function applyThemeTokens(css) {
72
+ let selector = '';
73
+ return css.split('\n').map((line) => {
74
+ const brace = line.indexOf('{');
75
+ if (brace !== -1) selector = line.slice(0, brace);
76
+ if (/^\s*(\/\*|\*)/.test(line)) return line;
77
+ if (SKIP_SELECTOR.test(selector)) return line;
78
+ return line.replace(/#[0-9a-fA-F]{3,6}\b/g, (m) => {
79
+ const name = LITERAL_TO_TOKEN.get(m.toLowerCase());
80
+ return name ? 'var(--md-' + name + ')' : m;
81
+ });
82
+ }).join('\n');
83
+ }
84
+
85
+ function themeCss() {
86
+ const D = ':root[data-md2doc-theme="dark"]';
87
+ const light = THEME_TOKENS.map((t) => '--md-' + t.name + ': ' + t.light[0] + ';').join(' ');
88
+ const dark = THEME_TOKENS.map((t) => '--md-' + t.name + ': ' + t.dark + ';').join(' ');
89
+ return [
90
+ ' /* v3.9.0 dark mode (lib/theme/tokens.js). Light values are the literals they replace. */',
91
+ ' :root { ' + light + ' }',
92
+ ' @media screen {',
93
+ ' ' + D + ' { ' + dark + ' color-scheme: dark; }',
94
+ ' ' + D + ' .content a:not(.heading-anchor), ' + D + ' .content a:not(.heading-anchor):visited { color: ' + LINK_DARK + '; }',
95
+ ' ' + D + ' .content img { background: #ffffff; border-radius: 4px; box-shadow: 0 0 0 10px #ffffff; }',
96
+ ' ' + D + ' .content .drawio { background: #ffffff; border-radius: 6px; box-shadow: 0 0 0 10px #ffffff; }',
97
+ ' ' + D + ' .lightbox-canvas > svg[data-md2doc-recoloured] { background: var(--md-bg); }',
98
+ ' }',
99
+ ' .md2doc-theme-toggle { position: fixed; right: 20px; bottom: 20px; z-index: 60; width: 40px; height: 40px;',
100
+ ' border-radius: 50%; display: grid; place-items: center; padding: 0; font-size: 18px; line-height: 1;',
101
+ ' cursor: pointer; border: 1px solid var(--md-border); background: var(--md-bg); color: var(--md-strong);',
102
+ ' box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); }',
103
+ ' .md2doc-theme-toggle[hidden] { display: none; }',
104
+ ' .md2doc-theme-toggle:hover { background: var(--md-hover); }',
105
+ ' .md2doc-theme-toggle:focus-visible { outline: 2px solid var(--md-accent); outline-offset: 2px; }',
106
+ ' .mobile-bar .md2doc-theme-toggle { position: static; margin-left: auto; flex: 0 0 auto; width: 36px; height: 36px;',
107
+ ' box-shadow: none; font-size: 17px; }',
108
+ ' @media print { .md2doc-theme-toggle { display: none !important; } }',
109
+ ].join('\n');
110
+ }
111
+
112
+
113
+ // Runs before any stylesheet, so a saved dark choice never flashes light.
114
+ const EARLY_SCRIPT = "<script>try{if(localStorage.getItem('md2doc-theme')==='dark')document.documentElement.setAttribute('data-md2doc-theme','dark');}catch(e){}</script>";
115
+ const TOGGLE_HTML = '<button type="button" class="md2doc-theme-toggle" hidden id="md2doc-theme-toggle" aria-label="Light theme (click for dark)" title="Light theme (click for dark)">☀</button>';
116
+ const RUNTIME_JS = fs.readFileSync(path.join(__dirname, 'runtime.js'), 'utf8');
117
+
118
+ // The reader <style> is the first one after </title>; KaTeX's own <style>
119
+ // comes after it, so "last </style> before </head>" is the wrong anchor here.
120
+ function applyReaderTheme(html) {
121
+ const titleEnd = html.indexOf('</title>');
122
+ const start = titleEnd < 0 ? -1 : html.indexOf('<style>', titleEnd);
123
+ const end = start < 0 ? -1 : html.indexOf('</style>', start);
124
+ if (end < 0) throw new Error('applyReaderTheme: reader <style> block not found');
125
+ let out = html.slice(0, start) + applyThemeTokens(html.slice(start, end)) + '\n' + themeCss() + '\n' + html.slice(end);
126
+ const head = out.indexOf('<head>\n');
127
+ if (head < 0) throw new Error('applyReaderTheme: <head> not found');
128
+ out = out.slice(0, head + 7) + EARLY_SCRIPT + '\n' + out.slice(head + 7);
129
+ // lastIndexOf: inlined diagram libraries carry '</body>' inside strings.
130
+ const bodyEnd = out.lastIndexOf('</body>');
131
+ if (bodyEnd < 0) throw new Error('applyReaderTheme: </body> not found');
132
+ const data = JSON.stringify({ mermaidLight: MERMAID_LIGHT_VARS, mermaidDark: MERMAID_DARK_VARS });
133
+ return out.slice(0, bodyEnd) + TOGGLE_HTML + '\n<script id="md2doc-theme-data" type="application/json">' + data +
134
+ '</script>\n<script>\n' + RUNTIME_JS + '\n</script>\n' + out.slice(bodyEnd);
135
+ }
136
+
137
+ module.exports = {
138
+ THEME_TOKENS, KEEP_LITERALS, LINK_DARK, MERMAID_LIGHT_VARS, MERMAID_DARK_VARS,
139
+ applyThemeTokens, themeCss, applyReaderTheme,
140
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.8.0",
3
+ "version": "3.9.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "scripts": {
47
47
  "preinstall": "node scripts/preinstall.js",
48
- "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/cell-range.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js && node test/reader-design.test.js",
48
+ "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/cell-range.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js && node test/reader-design.test.js && node test/theme.test.js",
49
49
  "test:browser": "node test/reader-click.test.js"
50
50
  },
51
51
  "repository": {