@helping-ai-workflow/md2doc 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -145,15 +145,61 @@ function assertBlockRangesFit(blocks, lines) {
145
145
  }
146
146
  }
147
147
 
148
- async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '', listenPort = 0 }) {
148
+ // F13: the never-opened grace deadline is deliberately its own constant,
149
+ // not idleTimeoutMs reused. idleTimeoutMs governs the gap since the last
150
+ // browser ping (client.js pings every 10s; the default 30,000ms tolerates
151
+ // a short run of missed pings once a real tab is confirmed present).
152
+ // Before the URL is opened there is no ping to measure against — the wait
153
+ // is instead for a human to alt-tab to a browser, or, over SSH, copy the
154
+ // printed URL across to a local machine and load it there, which routinely
155
+ // takes longer than idleTimeoutMs allows. Reusing idleTimeoutMs for that
156
+ // wait would close sessions on people still reading the terminal output.
157
+ // Five minutes is chosen as a margin generous for that human handoff while
158
+ // still finite; test/cli-edit.test.js drives it through a shortened
159
+ // override — an unopened session still exits on its own, and a session
160
+ // opened partway through the grace window keeps working afterward.
161
+ const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
162
+
163
+ // T21 item 3: closing this server has to mean "stop serving now", not "stop
164
+ // serving once every open socket happens to go away". `server.close()` on its
165
+ // own does the latter: it stops accepting, then waits for the connections it
166
+ // already has. A browser handed the edit URL opens a socket it may never send
167
+ // a request on (a speculative preconnect), and such a socket is not one Node
168
+ // retires by itself — driven against `md2doc --edit` on this box with a
169
+ // viewer stub that opened one TCP connection to the port and sent no bytes,
170
+ // SIGINT printed cli.js's line and the process was still alive 10 s later,
171
+ // 3 runs of 3 — while the same runs with a viewer stub that connected and
172
+ // sent a request, and with `--no-open`, exited in 7-10 ms. The same hang
173
+ // reaches the idle and never-opened deadlines below, which have no second
174
+ // keystroke to rescue them, so every exit this file owns goes through here.
175
+ //
176
+ // closeAllConnections() lands the sockets that close() would wait on. It is
177
+ // Node >= 18.2 and package.json's engines floor is >= 18, so the call is
178
+ // guarded rather than assumed; on an 18.0/18.1 runtime the behaviour is
179
+ // exactly what it was before this function existed.
180
+ function shutdownServer(server, reason) {
181
+ // F13: name the reason before closing, so cli.js's listener (the process's
182
+ // stdout writer) can print a line distinct from the other exits.
183
+ if (reason) server.emit('md2doc-auto-close', reason);
184
+ server.close();
185
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
186
+ }
187
+
188
+ async function createEditorServer({
189
+ files, idleTimeoutMs = 30000, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
190
+ clientJs = '', listenPort = 0,
191
+ }) {
149
192
  const absFiles = files.map((f) => path.resolve(f));
150
193
  let idleTimer = null;
194
+ let neverOpenedTimer = null;
151
195
  let started = false;
152
196
 
153
197
  function bumpIdle(server) {
154
198
  if (!started) return;
155
199
  if (idleTimer) clearTimeout(idleTimer);
156
- idleTimer = setTimeout(() => server.close(), idleTimeoutMs);
200
+ idleTimer = setTimeout(() => {
201
+ shutdownServer(server, 'idle');
202
+ }, idleTimeoutMs);
157
203
  if (idleTimer.unref) idleTimer.unref();
158
204
  }
159
205
 
@@ -442,6 +488,28 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
442
488
  server.listen(listenPort, '127.0.0.1');
443
489
  });
444
490
  const port = server.address().port;
491
+
492
+ // F13: counts from listen (this point), not from process start — listen
493
+ // is the moment a client could first reach the server, and that is the
494
+ // deadline this timer is measuring against. Unref'd like idleTimer, and
495
+ // for the same reason: the listening socket is what should keep the
496
+ // process alive, not this timer. A timer that held a ref would keep the
497
+ // process up on its own even if the socket already went away, which is
498
+ // backwards for a clock whose only job is to give up and exit.
499
+ neverOpenedTimer = setTimeout(() => {
500
+ // The guard against closing a session that has already been opened —
501
+ // this timer is armed once, at listen time, and left to run; it is
502
+ // not cancelled by the GET /edit/:id handler. Whether the URL was
503
+ // opened by the time the deadline arrives is decided here, by reading
504
+ // `started` fresh at fire time. Proven load-bearing by mutation: with
505
+ // this line removed, an actively-pinged session still gets closed the
506
+ // moment this deadline passes (see test/cli-edit.test.js's "must
507
+ // survive the never-opened grace deadline" case).
508
+ if (started) return;
509
+ shutdownServer(server, 'never-opened');
510
+ }, neverOpenedGraceMs);
511
+ if (neverOpenedTimer.unref) neverOpenedTimer.unref();
512
+
445
513
  return {
446
514
  server,
447
515
  port,
@@ -449,7 +517,11 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
449
517
  const i = absFiles.indexOf(path.resolve(absPath));
450
518
  return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
451
519
  },
452
- close() { if (idleTimer) clearTimeout(idleTimer); server.close(); },
520
+ close() {
521
+ if (idleTimer) clearTimeout(idleTimer);
522
+ if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
523
+ shutdownServer(server, null);
524
+ },
453
525
  };
454
526
  }
455
527
 
@@ -29,11 +29,11 @@ const BUTTON_DEFS = [
29
29
  { id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
30
30
 
31
31
  // inline
32
- { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: false },
33
- { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: false },
34
- { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: false },
35
- { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: false },
36
- { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: false },
32
+ { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: true },
33
+ { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: true },
34
+ { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: true },
35
+ { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: true },
36
+ { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: true },
37
37
 
38
38
  // indent
39
39
  { id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
@@ -65,8 +65,36 @@ const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert',
65
65
  // state right after rerenderAll, before any block gets focus/selection).
66
66
  const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
67
67
 
68
- // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection, mode}
69
- // -> { [id]: {active: bool, disabled: bool, label?: string} }
68
+ // The mark element each inline button toggles. This module cannot ask the
69
+ // DOM which marks a selection sits in, so the client measures that and hands
70
+ // the answer in as `ctx.marks` — keyed by TAG, not by button id, because
71
+ // .ed-seltb carries a U button with no counterpart in this roster and both
72
+ // toolbars are painted from one measurement. The mapping from button to tag
73
+ // has to live somewhere, and here is where the button ids are.
74
+ const MARK_TAG_BY_ID = Object.freeze({
75
+ bold: 'STRONG',
76
+ italic: 'EM',
77
+ strike: 'DEL',
78
+ 'inline-code': 'CODE',
79
+ link: 'A',
80
+ });
81
+
82
+ // The inline-format ids, read off the map above rather than retyped. The
83
+ // `hasSelection` rule below used to carry a hand-written copy of this list;
84
+ // the marks rule under it would have been a second.
85
+ const MARK_IDS = Object.freeze(Object.keys(MARK_TAG_BY_ID));
86
+
87
+ // The per-tag values `ctx.marks` may carry, and what each one means for the
88
+ // button that toggles that tag. The client's selectionMarkStates() is what
89
+ // produces them; the names are shared vocabulary, not a re-derivation.
90
+ // 'whole' the selection lies inside one mark of that tag
91
+ // 'partial' the selection overlaps marks of that tag without lying in one
92
+ // 'none' no mark of that tag is involved
93
+ // 'inert' pressing the button would do nothing at all
94
+
95
+ // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection,
96
+ // mode, marks}
97
+ // -> { [id]: {active: bool, mixed: bool, disabled: bool, label?: string} }
70
98
  function deriveState(ctx) {
71
99
  const c = ctx || {};
72
100
  const blockType = c.blockType;
@@ -79,7 +107,7 @@ function deriveState(ctx) {
79
107
 
80
108
  const state = {};
81
109
  for (const b of BUTTONS) {
82
- state[b.id] = { active: false, disabled: false };
110
+ state[b.id] = { active: false, mixed: false, disabled: false };
83
111
  }
84
112
 
85
113
  // v3.2.1: the mode button names the NEXT state, never the current one.
@@ -152,24 +180,57 @@ function deriveState(ctx) {
152
180
  // refuse for reasons this ctx does not carry (a structural list gate, a
153
181
  // block that owns no source line).
154
182
  if (!c.hasSelection) {
155
- for (const id of ['bold', 'italic', 'strike', 'inline-code', 'link']) {
183
+ for (const id of MARK_IDS) {
156
184
  state[id].disabled = true;
157
185
  }
158
186
  }
159
187
 
188
+ // v3.3.0 (F2): what the selection is already marked as. Before this the ctx
189
+ // carried one boolean about the selection and nothing about its identity.
190
+ // MEASURED on the build before this change, reading the fixed toolbar's B
191
+ // over a selection wholly inside a <strong>, one straddling its edge, and one
192
+ // in plain text: aria-pressed came back null on all three — the buttons were
193
+ // `toggle: false`, so the client's aria-pressed write skipped them.
194
+ //
195
+ // `active` and `mixed` are separate booleans rather than one tri-state
196
+ // because `active` is what the toggle-roster test sweeps for and what the
197
+ // rest of this function already speaks. The client turns the pair into
198
+ // aria-pressed true / mixed / false.
199
+ //
200
+ // 'inert' disables. It is NOT the same question as `hasSelection`: that one
201
+ // asks whether the action would refuse the selection outright, this one asks
202
+ // whether the branch the action would actually take is a no-op. A press on
203
+ // an inert button leaves the document exactly as it was.
204
+ const marks = c.marks;
205
+ if (marks) {
206
+ for (const id of MARK_IDS) {
207
+ const m = marks[MARK_TAG_BY_ID[id]];
208
+ state[id].active = m === 'whole';
209
+ state[id].mixed = m === 'partial';
210
+ if (m === 'inert') state[id].disabled = true;
211
+ }
212
+ }
213
+
160
214
  // mode 'source' is the full-document raw-source escape hatch: everything
161
215
  // is disabled except the mode button that got you there (id 'preview',
162
- // which in v3.2.1 is the edit <-> source toggle). This overrides every
163
- // disabled flag computed above, and is the ONLY way back out — do not
164
- // narrow it.
216
+ // which in v3.2.1 is the edit <-> source toggle) and, since T11-2,
217
+ // 'outline' (☰). The mode button is still the ONLY way back to 'edit' —
218
+ // that has not changed. 'outline' stays reachable because it is what
219
+ // opens the sidebar drawer (toggleOutlineSidebar() in
220
+ // lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
221
+ // to reach the drawer another way is hidden in edit mode entirely (see the
222
+ // T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
223
+ // mode with no way to open that drawer at all. This override still runs
224
+ // last and still overrides every disabled flag computed above for every
225
+ // other button; do not narrow it for anything but these two ids.
165
226
  if (mode === 'source') {
166
227
  for (const b of BUTTONS) {
167
- state[b.id].disabled = b.id !== 'preview';
228
+ state[b.id].disabled = b.id !== 'preview' && b.id !== 'outline';
168
229
  }
169
230
  }
170
231
 
171
232
  return state;
172
233
  }
173
234
 
174
- return { BUTTONS, GROUPS, deriveState };
235
+ return { BUTTONS, GROUPS, MARK_TAG_BY_ID, deriveState };
175
236
  });
package/lib/md2doc.js CHANGED
@@ -1349,7 +1349,19 @@ ${itemsHtml}
1349
1349
  it. Fixed chrome takes no layout space, so the page has to make its
1350
1350
  own: this is the only place that offset is written. The mobile rule at
1351
1351
  max-width:1080px above already reserves 60px for .sidebar-toggle; the
1352
- bar sits above it, so the two offsets ADD there.
1352
+ bar sits above it. That 60px is NOT re-reserved here: T11-2 hides
1353
+ .sidebar-toggle in edit mode (the rule further down this stylesheet), so
1354
+ in edit mode there is no button under the bar to clear, and adding the
1355
+ two offsets left a band of empty page nothing was ever painted into.
1356
+ Measured on the build that did add them, at 1080/1000/800 wide:
1357
+ .page-layout's computed padding-top was 104px against a 44px toolbar —
1358
+ 60px of dead space, and a 105px gap between the toolbar's bottom edge
1359
+ and the first block against 45px at 1200 wide, where the desktop rule
1360
+ applies. The @media block below therefore still has to carry an explicit
1361
+ value, identical to the desktop one: the READER stylesheet's own
1362
+ 'max-width: 1080px' rule sets padding-top: 60px for the drawer toggle it
1363
+ still shows, and deleting this override would hand the edit page that
1364
+ 60px straight back.
1353
1365
  .reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
1354
1366
  down by the same amount here so the outline does not slide under the
1355
1367
  bar, with its height reduced to match so it still ends at the viewport
@@ -1360,7 +1372,7 @@ ${itemsHtml}
1360
1372
  height: calc(100vh - 48px - var(--ed-toolbar-h));
1361
1373
  }
1362
1374
  @media (max-width: 1080px) {
1363
- .page-layout { padding-top: calc(60px + var(--ed-toolbar-h)); }
1375
+ .page-layout { padding-top: var(--ed-toolbar-h); }
1364
1376
  .reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
1365
1377
  }
1366
1378
  /* The 大綱 button's DESKTOP half, on an attribute of its OWN.
@@ -1382,7 +1394,41 @@ ${itemsHtml}
1382
1394
  @media (min-width: 1081px) {
1383
1395
  body[data-ed-outline-hidden] .reader-sidebar,
1384
1396
  body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
1385
- }`;
1397
+ }
1398
+ /* T11-2 step 3: this button sits at z-index 100 (base stylesheet, above)
1399
+ directly under .ed-toolbar's z-index 101. Measured at 1080x900 and
1400
+ 800x900: document.elementFromPoint() at the button's own rect centre
1401
+ returns the toolbar, not the button, at both widths — a real pointer
1402
+ click never lands on it there, only a synthetic .click() does. The
1403
+ toolbar's own left/right are both 0, spanning the full viewport width,
1404
+ so nothing about that occlusion is a function of width; the two
1405
+ measured points are a sample of a band the toolbar covers edge to
1406
+ edge, not two lucky ones. It is safe to remove in edit mode because
1407
+ lib/editor/client.js's toolbar carries its own outline button
1408
+ (runToolbarAction()'s 'outline' case, which calls toggleOutlineSidebar(),
1409
+ the same function this button calls) which opens the identical drawer,
1410
+ and Task 8 gave that toolbar a keyboard cursor (Alt+F10) — the outline
1411
+ button is not among the ids Task 8 measured as stranding the caret on
1412
+ BODY after activation. This rule is edit-mode-only (editModeLayoutCss);
1413
+ reader-mode output never includes it, so the mobile drawer toggle there
1414
+ is unaffected. */
1415
+ .sidebar-toggle { display: none; }
1416
+ /* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
1417
+ contentEl.hidden = true and mounts a plain textarea in its place, and
1418
+ setDocMode() writes docMode onto body as data-ed-mode. Measured on that
1419
+ build: with ten TOC links listed, clicking the last one moved neither
1420
+ the (hidden) reading position nor the textarea's own scrollTop
1421
+ (3924 before and after) — the click target was a normal anchor jump /
1422
+ scrollIntoView() reaching into a subtree rooted at .content, and
1423
+ .content's own display:none is what absorbed it; the same subtree
1424
+ relationship holds for a search result's jump target. Hiding the TOC
1425
+ nav and the search-results panel while data-ed-mode="source" removes
1426
+ the two controls whose only reachable target sits in that hidden
1427
+ subtree; the search input row (.reader-tools) is left as-is — driving
1428
+ it against the textarea's own line numbers instead is a separate
1429
+ feasibility question, not answered here. */
1430
+ body[data-ed-mode="source"] .toc,
1431
+ body[data-ed-mode="source"] .search-results { display: none !important; }`;
1386
1432
 
1387
1433
  const html = `<!DOCTYPE html>
1388
1434
  <html lang="en">
@@ -2092,6 +2138,17 @@ ${itemsHtml}
2092
2138
  background: #ffffff;
2093
2139
  }
2094
2140
  body[data-lightbox-open] { overflow: hidden; }
2141
+ /* The 'html, body { overflow-x: clip }' rule above keeps documentElement's
2142
+ own overflow-x at clip, not visible, so overflow does not propagate from
2143
+ body to the viewport per the CSS spec's root-propagation rule; the
2144
+ scrolling element stays documentElement, and its overflow-y sits at the
2145
+ visible default. That is the gap the body rule above does not close --
2146
+ measured in test/lightbox.test.js and test/editor-journey.test.js: a
2147
+ wheel over .lightbox-bar, PageDown and End all reach documentElement
2148
+ while it is open. Locking documentElement's own overflow here closes
2149
+ it; JS mirrors the data-lightbox-open attribute onto the html element
2150
+ in openLightbox/closeLightbox below. */
2151
+ html[data-lightbox-open] { overflow: hidden; }
2095
2152
  @media print { .lightbox { display: none !important; } }
2096
2153
  @media (max-width: 1080px) {
2097
2154
  .sidebar-toggle { display: inline-flex; align-items: center; }
@@ -2318,7 +2375,21 @@ ${itemsHtml}
2318
2375
  this element's containing block and left:100% lands it flush against
2319
2376
  the menu's right edge. Source order matters: equal specificity with
2320
2377
  .ed-handle-menu's own left:-4px, so this rule must stay AFTER it. */
2321
- .ed-handle-submenu { left: 100%; margin-left: 4px; }
2378
+ /* Viewport-clamp backstop: openConvertSubmenu() (lib/editor/client.js)
2379
+ shifts the panel's top up so its bottom clears the viewport when there
2380
+ is room to do so — that shift is the clamp. This rule covers what the
2381
+ shift cannot: a viewport shorter than the panel, where the viewport's
2382
+ own top edge still leaves the panel's bottom edge off-screen. What
2383
+ happens under max-height, in the order it happens: CONVERT_TARGETS's
2384
+ buttons (lib/editor/convert-md.js), each carrying .ed-handle-menu-btn's
2385
+ own height (26px), first shrink via flex as the cap tightens (measured
2386
+ on this branch: 26px at a cap around 342px, stepping down to 18px
2387
+ around a 250px cap) — no scrolling yet, the list still fits end to
2388
+ end, just shorter per row. overflow-y: auto only starts moving content
2389
+ once the rows have already reached that floor and the cap is still
2390
+ tighter than the list at 18px per row. (No backticks in this comment:
2391
+ it lives inside a JS template literal.) */
2392
+ .ed-handle-submenu { left: 100%; margin-left: 4px; max-height: calc(100vh - 8px); overflow-y: auto; }
2322
2393
  .ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
2323
2394
  .ed-handle-menu-btn[hidden] { display: none; }
2324
2395
  /* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
@@ -2529,7 +2600,23 @@ ${itemsHtml}
2529
2600
  font: inherit; font-size: 13px; line-height: 1;
2530
2601
  white-space: nowrap; cursor: pointer;
2531
2602
  }
2532
- .ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
2603
+ .ed-seltb-btn:hover:not([aria-disabled="true"]) { background: rgba(255, 255, 255, 0.18); }
2604
+ /* v3.3.0 (F2): the four states these buttons report about the selection.
2605
+ 'true' (the selection is entirely inside a mark of this type) is the
2606
+ filled treatment the fixed toolbar already uses. 'mixed' (the selection
2607
+ overlaps marks of this type without lying inside one) is deliberately NOT
2608
+ the filled one — pressing it removes marks rather than adding any — so it
2609
+ gets a dashed edge and a half-strength ground instead. 'false' keeps the
2610
+ rule above. INERT is aria-disabled, never the disabled DOM property; see
2611
+ paintSelToolbarMarks() in lib/editor/client.js for why. */
2612
+ .ed-seltb-btn[aria-pressed="true"] {
2613
+ background: #e6edf3; color: #101215; border-color: #e6edf3;
2614
+ }
2615
+ .ed-seltb-btn[aria-pressed="mixed"] {
2616
+ background: rgba(230, 237, 243, 0.3);
2617
+ border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
2618
+ }
2619
+ .ed-seltb-btn[aria-disabled="true"] { opacity: 0.38; cursor: default; }
2533
2620
  .ed-seltb-b { font-weight: bold; }
2534
2621
  .ed-seltb-i { font-style: italic; }
2535
2622
  .ed-seltb-s { text-decoration: line-through; }
@@ -2550,13 +2637,67 @@ ${itemsHtml}
2550
2637
  .ed-toolbar {
2551
2638
  position: fixed; top: 0; left: 0; right: 0; z-index: 101;
2552
2639
  box-sizing: border-box; height: var(--ed-toolbar-h);
2553
- display: flex; align-items: center; justify-content: center;
2640
+ display: flex; align-items: center; justify-content: flex-start;
2554
2641
  flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
2555
2642
  gap: 6px; padding: 8px 12px;
2643
+ /* F12, both halves measured on this build at 900px viewport height.
2644
+ justify-content was center. Centering an overflowing flex row splits
2645
+ the overflow across BOTH ends, and the leading end of a scroll
2646
+ container's content is not reachable by scrolling: driven at 820 /
2647
+ 640 / 420 px wide, sweeping scrollLeft from 0 to its maximum in 8px
2648
+ steps and reading every button's rect, the buttons that never once sat
2649
+ inside the viewport were [undo] / [undo, redo, headings] /
2650
+ [undo, redo, headings, quote, code, list]. On this build — leading-edge
2651
+ layout plus the padding-right below, driven together, not one at a time
2652
+ — the same sweep leaves that set empty at all three widths.
2653
+
2654
+ padding-right buys the buttons room to clear .ed-toolbar-status, whose
2655
+ fixed box occupies the viewport's right 124px (right: 12px plus
2656
+ min-width: 7rem at this font size). The claim it supports is narrow:
2657
+ SOME scroll position exists at which a given button is clear of the
2658
+ slot. It is NOT "a button can never sit under the slot" — driven at
2659
+ scrollLeft 0 after this change, [outline, preview] still overlap the
2660
+ slot at a 900px viewport and [check, bold, italic, strike] do at 420px.
2661
+ What makes that harmless is the slot's own pointer-events: none, in
2662
+ the rule further down — the load-bearing line, not this one. */
2663
+ padding-right: 124px;
2556
2664
  background: rgba(16, 18, 21, 0.92); color: #e6edf3;
2665
+ /* F12, second half: before this the bar scrolled but nothing on screen
2666
+ said so, so a button parked outside the viewport looked absent rather
2667
+ than off to one side. Two edge gradients, painted only on the side
2668
+ that still has content hidden behind it; the client runtime writes
2669
+ data-ed-tb-overflow from scrollLeft against the scroll maximum.
2670
+
2671
+ Deliberately a background layer on the bar itself rather than a new
2672
+ overlay node. An overlay would have to be taken out of flow to avoid
2673
+ becoming a flex item in this row, and an out-of-flow child of this bar
2674
+ anchors to the scrolled content box and drifts with it (the
2675
+ .ed-toolbar-status note below records that measurement) — while
2676
+ anchoring it to the viewport instead would add a floating layer to the
2677
+ set test/editor-journey.test.js enumerates and demands a scroll answer
2678
+ for. A background layer needs neither: background-attachment defaults
2679
+ to scroll, which pins the gradient to this element's padding box while
2680
+ its contents move underneath.
2681
+
2682
+ These gradients sit behind the buttons, because that is where the
2683
+ painting order puts a background layer: above the background colour,
2684
+ below the element's content. That is the reason they are dark rather
2685
+ than light — darkening the ground a translucent button sits on is what
2686
+ reads as content passing under an edge, where a light wash behind the
2687
+ same button would not. */
2688
+ --ed-tb-edge-l: rgba(0, 0, 0, 0);
2689
+ --ed-tb-edge-r: rgba(0, 0, 0, 0);
2690
+ background-image:
2691
+ linear-gradient(to right, var(--ed-tb-edge-l), rgba(0, 0, 0, 0)),
2692
+ linear-gradient(to left, var(--ed-tb-edge-r), rgba(0, 0, 0, 0));
2693
+ background-repeat: no-repeat;
2694
+ background-position: left center, right center;
2695
+ background-size: 26px 100%, 26px 100%;
2557
2696
  font-size: 13px;
2558
2697
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
2559
2698
  }
2699
+ .ed-toolbar[data-ed-tb-overflow~="left"] { --ed-tb-edge-l: rgba(0, 0, 0, 0.55); }
2700
+ .ed-toolbar[data-ed-tb-overflow~="right"] { --ed-tb-edge-r: rgba(0, 0, 0, 0.55); }
2560
2701
  .ed-toolbar-sep {
2561
2702
  flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
2562
2703
  background: rgba(255, 255, 255, 0.25);
@@ -2570,18 +2711,45 @@ ${itemsHtml}
2570
2711
  white-space: nowrap; cursor: pointer;
2571
2712
  }
2572
2713
  .ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
2714
+ /* F12: where the toolbar's keyboard cursor is. An outline rather than a
2715
+ background so it stays legible on a button that is already pressed-in
2716
+ (aria-pressed paints that one nearly white). Claim no more for it than a
2717
+ keyboard route: the buttons keep their tabindex="-1" and the caret stays
2718
+ in the document, so no focus move happens for assistive technology to
2719
+ follow, and what this cue does for a screen reader has not been measured
2720
+ here. What it does do is let the row be walked without a mouse. */
2721
+ .ed-toolbar-btn[data-ed-tb-cursor] {
2722
+ outline: 2px solid #6ea8fe; outline-offset: 1px;
2723
+ }
2573
2724
  .ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
2574
2725
  .ed-toolbar-btn[aria-pressed="true"] {
2575
2726
  background: #e6edf3; color: #101215; border-color: #e6edf3;
2576
2727
  }
2728
+ /* v3.3.0 (F2): the middle state, matching .ed-seltb-btn[aria-pressed="mixed"]
2729
+ above so the two bars answer the same question the same way. */
2730
+ .ed-toolbar-btn[aria-pressed="mixed"] {
2731
+ background: rgba(230, 237, 243, 0.3);
2732
+ border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
2733
+ }
2577
2734
  .ed-toolbar-b { font-weight: bold; }
2578
2735
  .ed-toolbar-i { font-style: italic; }
2579
2736
  .ed-toolbar-s { text-decoration: line-through; }
2580
- /* H1 fix: the brief's literal margin-left: auto (a flex-flow item) was
2581
- measured to break .ed-toolbar's justify-content: center — an auto
2582
- margin on any flex item absorbs ALL positive free space before
2583
- justify-content sees any, so the ~22-button group snapped flush left on
2584
- every viewport wide enough not to overflow. position: fixed removes
2737
+ /* H1 fix — and its reason has since moved, so both readings are kept here
2738
+ rather than one silently standing in for the other. The brief's literal
2739
+ margin-left: auto (a flex-flow item) was measured on v3.2.0 to break
2740
+ .ed-toolbar's justify-content: center — an auto margin on any flex item
2741
+ absorbs ALL positive free space before justify-content sees any, so the
2742
+ ~22-button group snapped flush left on every viewport wide enough not to
2743
+ overflow. F12 replaced that declaration with justify-content: flex-start
2744
+ (the .ed-toolbar rule above), so flush left IS the shipped layout now and
2745
+ that particular failure mode no longer exists. What the rule guards today
2746
+ is the slot's out-of-flow-ness, and the assertion beside it in
2747
+ test/editmode-render.test.js carries the re-driven numbers: against the
2748
+ slot as it ships the margin changes nothing at all, while putting the slot
2749
+ back in flow with it turns the slot into a scrolling flex item
2750
+ (scrollWidth 975 -> 1093 at 420px) and moves its rect off the strip
2751
+ .ed-toolbar's padding-right reserves for it ([1276, 1388] -> [1164, 1276]
2752
+ at 1400px). position: fixed removes
2585
2753
  the slot from the flex layout entirely (buttons measured byte-identical
2586
2754
  to a build with the slot absent, at both a 1400px and a 420px viewport),
2587
2755
  and — unlike position: absolute against .ed-toolbar's own
@@ -2593,14 +2761,17 @@ ${itemsHtml}
2593
2761
  H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
2594
2762
  v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
2595
2763
  so it no longer ships empty — but that content is inert TEXT, never a
2596
- control, which is exactly what keeps the rule below sufficient. At narrow viewports .ed-toolbar's own
2597
- overflow-x: auto lets the button row scroll to a rest position where
2598
- this fixed 7rem box's footprint physically covers three real buttons
2599
- (image / outline / preview, measured at 420px width, scrollLeft at its
2600
- max 221) — document.elementFromPoint() over any of them resolves to
2601
- this span, not the button underneath, and a real click on it never
2602
- reaches the button's listener. Harmless today only because the span
2603
- carries nothing to interact with. WHOEVER PUTS A CONTROL HERE (or
2764
+ control, which is exactly what keeps the rule below sufficient. The
2765
+ overlap it keeps harmless is real and still present after F12 moved this
2766
+ bar's scroll geometry: at scrollLeft 0, this box's footprint covers
2767
+ outline and preview at a 900px-wide viewport, and check / bold / italic /
2768
+ strike at 420px. Re-driven on this build, hit-testing the middle of each
2769
+ of those overlaps: with the rule as it ships,
2770
+ document.elementFromPoint() answers the BUTTON at every one of them; flip
2771
+ this span's pointer-events to auto and leave everything else alone, and
2772
+ every one of them answers this span instead. That reading is the same hit
2773
+ test a press resolves through, and the rule below is what stands between
2774
+ the two readings — not the span's emptiness. WHOEVER PUTS A CONTROL HERE (or
2604
2775
  wires up two-way sync, §3) MUST remove or scope this
2605
2776
  rule AND independently solve the overlap (e.g. reflow the slot in-flow
2606
2777
  at narrow widths, or shrink/hide it below some width) — simply
@@ -2613,13 +2784,19 @@ ${itemsHtml}
2613
2784
  pointer-events: none; }
2614
2785
  /* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
2615
2786
  anchored to a fixed bar rather than moved into a block, so it is
2616
- positioned in viewport coordinates by the client runtime. */
2787
+ positioned in viewport coordinates by the client runtime.
2788
+ max-height/overflow-y: same backstop role as .ed-handle-submenu above
2789
+ (Task 9 fix round 1, Ruling T9-5) — toggleToolbarHeadingMenu()
2790
+ (lib/editor/client.js) does the shift-to-fit; this covers a viewport
2791
+ shorter than the panel, where the shift alone cannot bring the whole
2792
+ list on-screen. */
2617
2793
  .ed-toolbar-menu {
2618
2794
  position: fixed; z-index: 102;
2619
2795
  display: flex; flex-direction: column; gap: 2px;
2620
2796
  padding: 4px; border-radius: 8px;
2621
2797
  background: rgba(16, 18, 21, 0.96); color: #e6edf3;
2622
2798
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
2799
+ max-height: calc(100vh - 8px); overflow-y: auto;
2623
2800
  }
2624
2801
  .ed-toolbar-menu[hidden] { display: none; }
2625
2802
  .ed-toolbar-menu-btn {
@@ -4206,6 +4383,7 @@ ${mermaidInitTag}`)
4206
4383
  lightboxReturnScroll = window.scrollY;
4207
4384
  suppressAnchorCapture = true;
4208
4385
  document.body.setAttribute('data-lightbox-open', '');
4386
+ document.documentElement.setAttribute('data-lightbox-open', '');
4209
4387
  lightboxEl.hidden = false;
4210
4388
  lightboxStage.scrollTop = 0;
4211
4389
  lightboxStage.scrollLeft = 0;
@@ -4218,6 +4396,7 @@ ${mermaidInitTag}`)
4218
4396
  if (!lightboxIsOpen()) return;
4219
4397
  lightboxEl.hidden = true;
4220
4398
  document.body.removeAttribute('data-lightbox-open');
4399
+ document.documentElement.removeAttribute('data-lightbox-open');
4221
4400
  annoCancelDraft();
4222
4401
  annoSyncInline();
4223
4402
  annoSourceNode = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",