@helping-ai-workflow/md2doc 3.2.0 → 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.
@@ -11,7 +11,8 @@
11
11
  })(typeof self !== 'undefined' ? self : this, function () {
12
12
 
13
13
  // Pure state machine + lines[]<->string conversion for the "追加 4 全文原始碼
14
- // 逃生口" (whole-document source escape hatch): 編輯 ⇄ 原始碼 ⇄ 預覽.
14
+ // 逃生口" (whole-document source escape hatch): 編輯 ⇄ 原始碼.
15
+ // v3.2.1: 預覽(preview)已移除,見下方 MODES 上的說明。
15
16
  //
16
17
  // EOL representation: `eol` is always a single literal separator string --
17
18
  // '\n', '\r\n' or '\r' -- the same convention server.js already establishes
@@ -31,12 +32,21 @@
31
32
  // 2. `\r\n` is listed before the two single-character alternatives, so a
32
33
  // CRLF pair is always consumed as ONE separator, never split into a
33
34
  // spurious blank line between a lone \r and a lone \n.
34
- const MODES = ['edit', 'source', 'preview'];
35
+ const MODES = ['edit', 'source'];
35
36
 
36
- // Three-state cycle: edit -> source -> preview -> edit. An unrecognized
37
- // mode (defensive: a caller passing something stale) falls back to the
38
- // first state rather than throwing, matching how the rest of this module
39
- // prefers a safe default over a thrown exception for malformed input.
37
+ // v3.2.1: 兩態循環 edit <-> source。preview 移除 —— 實測它相對 edit 只做
38
+ // 兩件事(applyPreviewEditability(false) 與兩行隱藏 ⠿/+ 的 CSS),卻擋在
39
+ // source 回 edit 的路上,而且 22 顆工具列按鈕中 13 顆在它底下仍然寫得進磁碟。
40
+ //
41
+ // 為什麼是移除而不是加閘門:一個「列舉可及面」推導出來的唯讀閘門必然漏掉
42
+ // 一整族 —— 切換前就已經開著的 ⠿ 選單在切換後仍是 140x118、display:flex,
43
+ // 其 16 個項目每一個都寫得進磁碟。閘門要約 8 個守衛點跨 2 個檔案才擋得住,
44
+ // 而 preview 本身沒有任何 edit 給不了的能力。
45
+ //
46
+ // An unrecognized mode (defensive: a caller passing something stale) falls
47
+ // back to the first state rather than throwing, matching how the rest of
48
+ // this module prefers a safe default over a thrown exception for malformed
49
+ // input.
40
50
  function next(mode) {
41
51
  const idx = MODES.indexOf(mode);
42
52
  return MODES[(idx + 1) % MODES.length];
@@ -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 },
@@ -48,7 +48,13 @@ const BUTTON_DEFS = [
48
48
 
49
49
  // view
50
50
  { id: 'outline', group: 'view', label: '☰', icon: '☰', title: '大綱', toggle: false },
51
- { id: 'preview', group: 'view', label: '👁', icon: '👁', title: '預覽', toggle: true },
51
+ // v3.2.1: 這顆是 edit <-> source 的兩態切換(preview 模式已移除)。id 保留
52
+ // 為 'preview' 是刻意的 —— 每一支選擇器、journey 案例與 runtime 測試都指名
53
+ // 它,改名只換得一個更好聽的字串。label / title 不是烘死值:deriveState()
54
+ // 每次都依當前 mode 覆寫成【下一個】狀態的名字,這裡的值只是 edit 模式的
55
+ // 起始值。toggle: false —— 兩態下 aria-pressed 講不出真話(按鈕講的是去處,
56
+ // 不是目前所在),真話由會變的 label / title 承擔。
57
+ { id: 'preview', group: 'view', label: 'M↓', icon: 'M↓', title: '切換到原始碼模式', toggle: false },
52
58
  ];
53
59
 
54
60
  const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
@@ -59,8 +65,36 @@ const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert',
59
65
  // state right after rerenderAll, before any block gets focus/selection).
60
66
  const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
61
67
 
62
- // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection, mode}
63
- // -> { [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} }
64
98
  function deriveState(ctx) {
65
99
  const c = ctx || {};
66
100
  const blockType = c.blockType;
@@ -73,14 +107,21 @@ function deriveState(ctx) {
73
107
 
74
108
  const state = {};
75
109
  for (const b of BUTTONS) {
76
- state[b.id] = { active: false, disabled: false };
110
+ state[b.id] = { active: false, mixed: false, disabled: false };
77
111
  }
78
112
 
79
- // preview is the tri-state edit/source/preview toggle: "pressed" whenever
80
- // the current mode is not plain editing. It stays enabled in source mode
81
- // via the mode==='source' override below (do not gate this on inList/
82
- // hasBlock — it is the escape hatch OUT of those states).
83
- state.preview.active = mode !== 'edit';
113
+ // v3.2.1: the mode button names the NEXT state, never the current one.
114
+ // Under the old tri-state cycle it was permanently labelled 「預覽」 and
115
+ // reported aria-pressed=true in BOTH non-edit modes, so pressing it twice
116
+ // landed in source with no grips and an interface that said nothing about
117
+ // where you were — the reported "stuck" defect. It carries no `active`
118
+ // now: with two states the honest signal is the label, and the current
119
+ // mode is spelled out in .ed-toolbar-status by the client.
120
+ // It stays enabled in source mode via the mode==='source' override below
121
+ // (do not gate this on inList/hasBlock — it is the escape hatch OUT of
122
+ // those states).
123
+ state.preview.label = mode === 'source' ? '✎' : 'M↓';
124
+ state.preview.title = mode === 'source' ? '切換到編輯模式' : '切換到原始碼模式';
84
125
 
85
126
  // headings: dropdown whose label tracks the current heading depth.
86
127
  state.headings.label = blockType === 'heading' ? 'H' + headingDepth : 'H';
@@ -139,22 +180,57 @@ function deriveState(ctx) {
139
180
  // refuse for reasons this ctx does not carry (a structural list gate, a
140
181
  // block that owns no source line).
141
182
  if (!c.hasSelection) {
142
- for (const id of ['bold', 'italic', 'strike', 'inline-code', 'link']) {
183
+ for (const id of MARK_IDS) {
143
184
  state[id].disabled = true;
144
185
  }
145
186
  }
146
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
+
147
214
  // mode 'source' is the full-document raw-source escape hatch: everything
148
- // is disabled except the toggle that got you there (preview). This
149
- // overrides every disabled flag computed above.
215
+ // is disabled except the mode button that got you there (id 'preview',
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.
150
226
  if (mode === 'source') {
151
227
  for (const b of BUTTONS) {
152
- state[b.id].disabled = b.id !== 'preview';
228
+ state[b.id].disabled = b.id !== 'preview' && b.id !== 'outline';
153
229
  }
154
230
  }
155
231
 
156
232
  return state;
157
233
  }
158
234
 
159
- return { BUTTONS, GROUPS, deriveState };
235
+ return { BUTTONS, GROUPS, MARK_TAG_BY_ID, deriveState };
160
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
@@ -2590,16 +2758,21 @@ ${itemsHtml}
2590
2758
  row's overflow-x: auto scrolls (measured identical rect at scrollLeft
2591
2759
  0 / mid / max, where the absolute alternative moved by the exact scroll
2592
2760
  delta). References no gutter-alignment custom property.
2593
- H2 fix: pointer-events: none below is LOAD-BEARING while this slot
2594
- ships empty, not decorative. At narrow viewports .ed-toolbar's own
2595
- overflow-x: auto lets the button row scroll to a rest position where
2596
- this fixed 7rem box's footprint physically covers three real buttons
2597
- (image / outline / preview, measured at 420px width, scrollLeft at its
2598
- max 221) — document.elementFromPoint() over any of them resolves to
2599
- this span, not the button underneath, and a real click on it never
2600
- reaches the button's listener. Harmless today only because the span
2601
- carries no content to interact with. WHOEVER WIRES UP TWO-WAY SYNC
2602
- (§3) AND PUTS REAL CONTENT OR CONTROLS HERE MUST remove or scope this
2761
+ H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
2762
+ v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
2763
+ so it no longer ships empty — but that content is inert TEXT, never a
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
2775
+ wires up two-way sync, §3) MUST remove or scope this
2603
2776
  rule AND independently solve the overlap (e.g. reflow the slot in-flow
2604
2777
  at narrow widths, or shrink/hide it below some width) — simply
2605
2778
  deleting this line without doing that reopens the swallowed-click
@@ -2611,13 +2784,19 @@ ${itemsHtml}
2611
2784
  pointer-events: none; }
2612
2785
  /* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
2613
2786
  anchored to a fixed bar rather than moved into a block, so it is
2614
- 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. */
2615
2793
  .ed-toolbar-menu {
2616
2794
  position: fixed; z-index: 102;
2617
2795
  display: flex; flex-direction: column; gap: 2px;
2618
2796
  padding: 4px; border-radius: 8px;
2619
2797
  background: rgba(16, 18, 21, 0.96); color: #e6edf3;
2620
2798
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
2799
+ max-height: calc(100vh - 8px); overflow-y: auto;
2621
2800
  }
2622
2801
  .ed-toolbar-menu[hidden] { display: none; }
2623
2802
  .ed-toolbar-menu-btn {
@@ -2636,8 +2815,6 @@ ${itemsHtml}
2636
2815
  border: 1px solid #d0d7de; border-radius: 8px; resize: vertical;
2637
2816
  white-space: pre; overflow-wrap: normal;
2638
2817
  }
2639
- body[data-ed-mode="preview"] .ed-handle,
2640
- body[data-ed-mode="preview"] .ed-insert { display: none !important; }
2641
2818
  .ed-editing { position: relative; }
2642
2819
  .ed-raw {
2643
2820
  display: block; width: 100%; font-family: monospace; font-size: 13px;
@@ -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;