@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 +4 -0
- package/lib/editor/client.js +3 -2
- package/lib/md2doc.js +149 -71
- package/lib/theme/runtime.js +268 -0
- package/lib/theme/tokens.js +140 -0
- package/package.json +2 -2
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
|
package/lib/editor/client.js
CHANGED
|
@@ -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:
|
|
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 (
|
|
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
|
|
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:
|
|
1912
|
+
flex: 0 0 auto; cursor: move; color: #aaa; user-select: none; font-size: 13px;
|
|
1912
1913
|
}
|
|
1913
|
-
.ed-wave-lane-handle:active { cursor:
|
|
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:
|
|
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:
|
|
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:
|
|
3066
|
+
cursor: move;
|
|
3066
3067
|
overscroll-behavior: contain;
|
|
3067
3068
|
}
|
|
3068
|
-
.lightbox-stage[data-panning] { cursor:
|
|
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:
|
|
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 (
|
|
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:
|
|
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:
|
|
3597
|
+
.ed-te-grip-row.ed-te-grip-dragging { cursor: move; }
|
|
3597
3598
|
.ed-te-grip-col {
|
|
3598
|
-
width: 28px; height: 24px; cursor:
|
|
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:
|
|
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 (
|
|
4005
|
-
//
|
|
4006
|
-
//
|
|
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
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
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
|
-
//
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
//
|
|
4116
|
-
//
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
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
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
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
|
-
}
|
|
4137
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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": {
|