@suzumiyaaoba/mdxr 0.1.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.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +2902 -0
  5. package/dist/components.d.mts +791 -0
  6. package/dist/components.mjs +2 -0
  7. package/dist/config-SI9IyFiC.mjs +184 -0
  8. package/dist/doc-context-CEqzMYKv.d.mts +568 -0
  9. package/dist/index.d.mts +20 -0
  10. package/dist/index.mjs +4 -0
  11. package/dist/ui-GKFD0mx5.mjs +15986 -0
  12. package/package.json +131 -0
  13. package/skill/SKILL.md +49 -0
  14. package/skill/references/components/charts.md +172 -0
  15. package/skill/references/components/document.md +98 -0
  16. package/skill/references/components/forms.md +35 -0
  17. package/skill/references/components/investigation.md +198 -0
  18. package/skill/references/components/layout.md +68 -0
  19. package/skill/references/components/output.md +158 -0
  20. package/skill/references/components/planning.md +155 -0
  21. package/skill/references/components/reports.md +252 -0
  22. package/skill/references/components/shadcn.md +19 -0
  23. package/skill/references/components.md +186 -0
  24. package/skill/references/extending.md +48 -0
  25. package/src/ask-sheet.ts +40 -0
  26. package/src/assets/css.ts +392 -0
  27. package/src/assets/scripts.ts +78 -0
  28. package/src/catalog.ts +287 -0
  29. package/src/cli.ts +178 -0
  30. package/src/client/doc-events.ts +1108 -0
  31. package/src/client/entry.ts +27 -0
  32. package/src/client-js.ts +47 -0
  33. package/src/component-map.ts +47 -0
  34. package/src/components/ui/accordion.tsx +77 -0
  35. package/src/components/ui/alert-dialog.tsx +185 -0
  36. package/src/components/ui/alert.tsx +76 -0
  37. package/src/components/ui/aspect-ratio.tsx +22 -0
  38. package/src/components/ui/attachment.tsx +208 -0
  39. package/src/components/ui/avatar.tsx +106 -0
  40. package/src/components/ui/badge.tsx +52 -0
  41. package/src/components/ui/breadcrumb.tsx +121 -0
  42. package/src/components/ui/bubble.tsx +128 -0
  43. package/src/components/ui/button-group.tsx +88 -0
  44. package/src/components/ui/button.tsx +58 -0
  45. package/src/components/ui/calendar.tsx +226 -0
  46. package/src/components/ui/card.tsx +102 -0
  47. package/src/components/ui/carousel.tsx +246 -0
  48. package/src/components/ui/chart.tsx +379 -0
  49. package/src/components/ui/checkbox.tsx +27 -0
  50. package/src/components/ui/collapsible.tsx +19 -0
  51. package/src/components/ui/combobox.tsx +298 -0
  52. package/src/components/ui/command.tsx +193 -0
  53. package/src/components/ui/context-menu.tsx +271 -0
  54. package/src/components/ui/dialog.tsx +159 -0
  55. package/src/components/ui/direction.tsx +4 -0
  56. package/src/components/ui/drawer.tsx +227 -0
  57. package/src/components/ui/dropdown-menu.tsx +269 -0
  58. package/src/components/ui/empty.tsx +104 -0
  59. package/src/components/ui/field.tsx +237 -0
  60. package/src/components/ui/fieldset.tsx +32 -0
  61. package/src/components/ui/frame.tsx +87 -0
  62. package/src/components/ui/hover-card.tsx +50 -0
  63. package/src/components/ui/input-group.tsx +159 -0
  64. package/src/components/ui/input-otp.tsx +83 -0
  65. package/src/components/ui/input.tsx +19 -0
  66. package/src/components/ui/item.tsx +202 -0
  67. package/src/components/ui/kbd.tsx +26 -0
  68. package/src/components/ui/label.tsx +19 -0
  69. package/src/components/ui/marker.tsx +71 -0
  70. package/src/components/ui/menubar.tsx +284 -0
  71. package/src/components/ui/message-scroller.tsx +128 -0
  72. package/src/components/ui/message.tsx +91 -0
  73. package/src/components/ui/meter.tsx +80 -0
  74. package/src/components/ui/native-select.tsx +64 -0
  75. package/src/components/ui/navigation-menu.tsx +170 -0
  76. package/src/components/ui/pagination.tsx +133 -0
  77. package/src/components/ui/popover.tsx +87 -0
  78. package/src/components/ui/progress.tsx +82 -0
  79. package/src/components/ui/questionnaire.tsx +328 -0
  80. package/src/components/ui/radio-group.tsx +35 -0
  81. package/src/components/ui/resizable.tsx +49 -0
  82. package/src/components/ui/scroll-area.tsx +50 -0
  83. package/src/components/ui/select.tsx +201 -0
  84. package/src/components/ui/separator.tsx +22 -0
  85. package/src/components/ui/sheet.tsx +135 -0
  86. package/src/components/ui/sidebar.tsx +730 -0
  87. package/src/components/ui/skeleton.tsx +13 -0
  88. package/src/components/ui/slider.tsx +51 -0
  89. package/src/components/ui/spinner.tsx +16 -0
  90. package/src/components/ui/switch.tsx +31 -0
  91. package/src/components/ui/table.tsx +113 -0
  92. package/src/components/ui/tabs.tsx +82 -0
  93. package/src/components/ui/textarea.tsx +17 -0
  94. package/src/components/ui/toast.tsx +229 -0
  95. package/src/components/ui/toggle-group.tsx +87 -0
  96. package/src/components/ui/toggle.tsx +43 -0
  97. package/src/components/ui/tooltip.tsx +65 -0
  98. package/src/components.ts +78 -0
  99. package/src/config.ts +56 -0
  100. package/src/define.ts +122 -0
  101. package/src/doc-context.ts +20 -0
  102. package/src/editor.ts +94 -0
  103. package/src/format-error.ts +78 -0
  104. package/src/guards.ts +84 -0
  105. package/src/hooks/use-mobile.ts +21 -0
  106. package/src/html.ts +91 -0
  107. package/src/hydrate/export-index.ts +232 -0
  108. package/src/hydrate/import-scan.ts +169 -0
  109. package/src/hydrate/plugins.ts +118 -0
  110. package/src/hydrate/runtime-module.ts +145 -0
  111. package/src/hydrate-runtime.ts +67 -0
  112. package/src/hydrate.ts +147 -0
  113. package/src/index.ts +16 -0
  114. package/src/init.ts +57 -0
  115. package/src/langs.ts +148 -0
  116. package/src/lines.ts +53 -0
  117. package/src/load-user-module.ts +191 -0
  118. package/src/mdx.ts +248 -0
  119. package/src/paths.ts +36 -0
  120. package/src/rehype/shiki.ts +533 -0
  121. package/src/remark/alerts.ts +53 -0
  122. package/src/remark/ast.ts +96 -0
  123. package/src/remark/callouts.ts +25 -0
  124. package/src/remark/code-file.ts +85 -0
  125. package/src/remark/code-meta.ts +21 -0
  126. package/src/remark/directives.ts +170 -0
  127. package/src/remark/file-paths.ts +64 -0
  128. package/src/remark/headings.ts +131 -0
  129. package/src/remark/no-js.ts +40 -0
  130. package/src/render.ts +337 -0
  131. package/src/serve.ts +322 -0
  132. package/src/styles/globals.css +134 -0
  133. package/src/styles/shadcn.css +641 -0
  134. package/src/tailwind.ts +119 -0
  135. package/src/ui/approvals.tsx +76 -0
  136. package/src/ui/ask-question.tsx +386 -0
  137. package/src/ui/ask.tsx +206 -0
  138. package/src/ui/attrs.ts +51 -0
  139. package/src/ui/audit.tsx +139 -0
  140. package/src/ui/bar-chart.tsx +334 -0
  141. package/src/ui/benchmarks.tsx +143 -0
  142. package/src/ui/bits.tsx +537 -0
  143. package/src/ui/board.tsx +173 -0
  144. package/src/ui/bridge.tsx +207 -0
  145. package/src/ui/bumps.tsx +178 -0
  146. package/src/ui/callout.tsx +106 -0
  147. package/src/ui/changes.tsx +89 -0
  148. package/src/ui/chart-bits.tsx +52 -0
  149. package/src/ui/chart.ts +577 -0
  150. package/src/ui/checks.tsx +203 -0
  151. package/src/ui/child-index.tsx +44 -0
  152. package/src/ui/children.ts +43 -0
  153. package/src/ui/chips.tsx +49 -0
  154. package/src/ui/cmd.tsx +27 -0
  155. package/src/ui/columns.tsx +31 -0
  156. package/src/ui/comments.tsx +544 -0
  157. package/src/ui/compare.tsx +67 -0
  158. package/src/ui/decision.tsx +79 -0
  159. package/src/ui/deps.tsx +78 -0
  160. package/src/ui/details.tsx +43 -0
  161. package/src/ui/diff-parse.ts +307 -0
  162. package/src/ui/diff.tsx +458 -0
  163. package/src/ui/diffstat.tsx +58 -0
  164. package/src/ui/due.tsx +65 -0
  165. package/src/ui/effort.tsx +29 -0
  166. package/src/ui/endpoints.tsx +118 -0
  167. package/src/ui/envvars.tsx +96 -0
  168. package/src/ui/figure.tsx +37 -0
  169. package/src/ui/file-icon.ts +1020 -0
  170. package/src/ui/file-link.ts +32 -0
  171. package/src/ui/file-ref.tsx +39 -0
  172. package/src/ui/files.tsx +112 -0
  173. package/src/ui/findings.tsx +85 -0
  174. package/src/ui/flow.tsx +73 -0
  175. package/src/ui/funnel.tsx +121 -0
  176. package/src/ui/gantt.tsx +443 -0
  177. package/src/ui/gauges.tsx +135 -0
  178. package/src/ui/glossary.tsx +29 -0
  179. package/src/ui/graph-layout.ts +149 -0
  180. package/src/ui/graph-specs.tsx +102 -0
  181. package/src/ui/graph.tsx +278 -0
  182. package/src/ui/grid.tsx +119 -0
  183. package/src/ui/hypothesis.tsx +94 -0
  184. package/src/ui/icon.tsx +80 -0
  185. package/src/ui/incident.tsx +130 -0
  186. package/src/ui/index.ts +342 -0
  187. package/src/ui/ins-del.tsx +41 -0
  188. package/src/ui/json.tsx +190 -0
  189. package/src/ui/layout.ts +9 -0
  190. package/src/ui/line-chart.tsx +251 -0
  191. package/src/ui/matrix.tsx +208 -0
  192. package/src/ui/meta.tsx +73 -0
  193. package/src/ui/option.tsx +64 -0
  194. package/src/ui/owner.tsx +40 -0
  195. package/src/ui/packages.tsx +104 -0
  196. package/src/ui/pathway.tsx +96 -0
  197. package/src/ui/phase.tsx +41 -0
  198. package/src/ui/pie-chart.tsx +170 -0
  199. package/src/ui/plan.tsx +77 -0
  200. package/src/ui/pre.tsx +175 -0
  201. package/src/ui/priority.tsx +51 -0
  202. package/src/ui/props.tsx +77 -0
  203. package/src/ui/quadrant.tsx +172 -0
  204. package/src/ui/radar.tsx +200 -0
  205. package/src/ui/ref.tsx +160 -0
  206. package/src/ui/release.tsx +178 -0
  207. package/src/ui/req.tsx +42 -0
  208. package/src/ui/review.tsx +133 -0
  209. package/src/ui/risk.tsx +67 -0
  210. package/src/ui/sankey.tsx +287 -0
  211. package/src/ui/scatter.tsx +237 -0
  212. package/src/ui/schema.tsx +113 -0
  213. package/src/ui/score.tsx +105 -0
  214. package/src/ui/search.tsx +120 -0
  215. package/src/ui/series.tsx +38 -0
  216. package/src/ui/severity.tsx +69 -0
  217. package/src/ui/shadcn.tsx +216 -0
  218. package/src/ui/spark.tsx +86 -0
  219. package/src/ui/stack.tsx +57 -0
  220. package/src/ui/stats.tsx +63 -0
  221. package/src/ui/status-badge.tsx +66 -0
  222. package/src/ui/statuspage.tsx +238 -0
  223. package/src/ui/steps.tsx +74 -0
  224. package/src/ui/summary.tsx +41 -0
  225. package/src/ui/symbol-ref.tsx +73 -0
  226. package/src/ui/terminal.tsx +117 -0
  227. package/src/ui/tests.tsx +218 -0
  228. package/src/ui/timeline.tsx +63 -0
  229. package/src/ui/toc.tsx +56 -0
  230. package/src/ui/tones.ts +187 -0
  231. package/src/ui/trace.tsx +69 -0
  232. package/src/ui/tree.tsx +281 -0
  233. package/src/ui/treemap.tsx +128 -0
  234. package/src/ui/venn.tsx +258 -0
  235. package/src/ui/verdict.tsx +78 -0
  236. package/src/ui/waterfall.tsx +142 -0
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * Client-side document interactions — plain DOM code, no React. Bundled
3
+ * into every rendered document via `entry.ts` (esbuild → CLIENT_JS) and
4
+ * imported live by the Storybook preview, which binds `handleDocEvent`
5
+ * itself. Keep this module free of top-level side effects; entry.ts owns
6
+ * listener registration.
7
+ */
8
+
9
+ import { formatAnswerSheet } from "../ask-sheet.js";
10
+ import type { SheetEntry } from "../ask-sheet.js";
11
+
12
+ /** `el.closest(sel)` narrowed to HTMLElement — misses and non-HTML hits (SVG) are null. */
13
+ const closestEl = (el: Element, sel: string): HTMLElement | null => {
14
+ const hit = el.closest(sel);
15
+ return hit instanceof HTMLElement ? hit : null;
16
+ };
17
+
18
+ // Flashes a feedback state on a button: swaps to the .mdxr-copy-done icon /
19
+ // label and tints it via .copied (success) or shakes it red via
20
+ // .copy-failed. A repeat click restarts the pop (reflow) and the timer.
21
+ const flash = (
22
+ b: HTMLElement,
23
+ cls: "copied" | "copy-failed",
24
+ label?: string
25
+ ): void => {
26
+ const ex = b as HTMLElement & {
27
+ mdxrLabel?: null | string;
28
+ mdxrTimer?: ReturnType<typeof setTimeout>;
29
+ };
30
+ clearTimeout(ex.mdxrTimer);
31
+ if (label !== undefined) {
32
+ ex.mdxrLabel ??= b.getAttribute("aria-label");
33
+ b.setAttribute("aria-label", label);
34
+ }
35
+ b.classList.remove("copied", "copy-failed");
36
+ void b.offsetWidth;
37
+ b.classList.add(cls);
38
+ ex.mdxrTimer = setTimeout(() => {
39
+ b.classList.remove(cls);
40
+ if (ex.mdxrLabel !== undefined) {
41
+ if (ex.mdxrLabel === null) {
42
+ b.removeAttribute("aria-label");
43
+ } else {
44
+ b.setAttribute("aria-label", ex.mdxrLabel);
45
+ }
46
+ ex.mdxrLabel = undefined;
47
+ }
48
+ }, 1600);
49
+ };
50
+
51
+ const writeClipboard = (
52
+ text: string,
53
+ done: () => void,
54
+ fail: () => void
55
+ ): void => {
56
+ const legacy = (): boolean => {
57
+ const ta = document.createElement("textarea");
58
+ ta.value = text;
59
+ ta.setAttribute("readonly", "");
60
+ ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
61
+ document.body.append(ta);
62
+ ta.select();
63
+ let ok = false;
64
+ try {
65
+ // oxlint-disable-next-line typescript/no-deprecated -- only fallback outside secure contexts
66
+ ok = document.execCommand("copy");
67
+ } catch {
68
+ ok = false;
69
+ }
70
+ ta.remove();
71
+ return ok;
72
+ };
73
+ // navigator.clipboard is absent outside secure contexts (e.g. file://).
74
+ const clip = navigator.clipboard as Clipboard | undefined;
75
+ if (clip === undefined) {
76
+ if (legacy()) {
77
+ done();
78
+ } else {
79
+ fail();
80
+ }
81
+ return;
82
+ }
83
+ void (async () => {
84
+ try {
85
+ await clip.writeText(text);
86
+ done();
87
+ } catch {
88
+ if (legacy()) {
89
+ done();
90
+ } else {
91
+ fail();
92
+ }
93
+ }
94
+ })();
95
+ };
96
+
97
+ // Writes `text` to the clipboard and flashes `btn` accordingly; labels are
98
+ // optional so icon-only buttons can omit them.
99
+ const copyWithFeedback = (
100
+ btn: HTMLElement,
101
+ text: string,
102
+ doneLabel?: string,
103
+ failLabel?: string
104
+ ): void => {
105
+ writeClipboard(
106
+ text,
107
+ () => {
108
+ flash(btn, "copied", doneLabel);
109
+ },
110
+ () => {
111
+ flash(btn, "copy-failed", failLabel);
112
+ }
113
+ );
114
+ };
115
+
116
+ // The text a reader actually selected for a choice/select answer: the
117
+ // choice card's visible label minus its description, or the <option> text.
118
+ // Falls back to the control's value when no text can be read.
119
+ const choiceText = (f: Element): string => {
120
+ if (f instanceof HTMLOptionElement) {
121
+ // Same whitespace collapse as SSR's choiceLabel — otherwise the live
122
+ // sheet rewrites an SSR-seeded "a b" label as "a b" on first sync.
123
+ const t = (f.textContent ?? "").replaceAll(/\s+/gu, " ").trim();
124
+ return t === "" ? f.value : t;
125
+ }
126
+ const fallback = f instanceof HTMLInputElement ? f.value : "";
127
+ const body = f.closest("label")?.querySelector(".mdxr-choice-text");
128
+ if (!(body instanceof HTMLElement)) {
129
+ return fallback;
130
+ }
131
+ let t = "";
132
+ for (const n of body.childNodes) {
133
+ if (n instanceof HTMLElement && n.classList.contains("mdxr-choice-desc")) {
134
+ continue;
135
+ }
136
+ t += n.textContent ?? "";
137
+ }
138
+ t = t.replaceAll(/\s+/gu, " ").trim();
139
+ return t === "" ? fallback : t;
140
+ };
141
+
142
+ const multiAnswer = (q: HTMLElement): string => {
143
+ const vals: string[] = [];
144
+ for (const f of q.querySelectorAll("input:checked")) {
145
+ const v = choiceText(f);
146
+ if (v !== "") {
147
+ vals.push(v);
148
+ }
149
+ }
150
+ return vals.join(", ");
151
+ };
152
+
153
+ const choiceAnswer = (q: HTMLElement): string => {
154
+ const f = q.querySelector("input:checked");
155
+ return f === null ? "" : choiceText(f);
156
+ };
157
+
158
+ const selectAnswer = (q: HTMLElement): string => {
159
+ const s = q.querySelector("select");
160
+ const [opt] =
161
+ s instanceof HTMLSelectElement && s.value !== "" ? s.selectedOptions : [];
162
+ return opt === undefined ? "" : choiceText(opt);
163
+ };
164
+
165
+ const fieldAnswer = (q: HTMLElement): string => {
166
+ const f = q.querySelector("input, textarea");
167
+ if (f instanceof HTMLTextAreaElement) {
168
+ return f.value.trim();
169
+ }
170
+ if (!(f instanceof HTMLInputElement)) {
171
+ return "";
172
+ }
173
+ if (f.type === "checkbox") {
174
+ // Toggle questions are checkboxes — always answered, yes or no.
175
+ return f.checked ? "yes" : "no";
176
+ }
177
+ return f.value.trim();
178
+ };
179
+
180
+ const ANSWER_READERS: Record<string, (q: HTMLElement) => string> = {
181
+ choice: choiceAnswer,
182
+ multi: multiAnswer,
183
+ select: selectAnswer,
184
+ };
185
+
186
+ // One question's answer: checked choices read as their visible text,
187
+ // selects as the chosen option's text, toggles as yes/no, free-form
188
+ // fields as their raw value. "" means unanswered.
189
+ const answerOf = (q: HTMLElement): string =>
190
+ ANSWER_READERS[q.dataset.qType ?? ""]?.(q) ?? fieldAnswer(q);
191
+
192
+ // Serializes an [data-ask] block into the Markdown answer sheet shown in
193
+ // [data-ask-output]: `# title` then one `- **label**: answer` line per
194
+ // question, in DOM order. Unanswered fields stay blank; toggles are
195
+ // always answered (yes/no). Multi-line answers indent under their item.
196
+ // The line format is shared with SSR via ask-sheet.ts.
197
+ const askMarkdown = (box: HTMLElement): string => {
198
+ const entries: SheetEntry[] = [];
199
+ for (const q of box.querySelectorAll("[data-mdxr-q]")) {
200
+ if (!(q instanceof HTMLElement)) {
201
+ continue;
202
+ }
203
+ entries.push({ answer: answerOf(q), label: q.dataset.qLabel ?? "" });
204
+ }
205
+ return formatAnswerSheet(box.dataset.askTitle, entries);
206
+ };
207
+
208
+ // Re-renders the answer sheet into the block's [data-ask-output] pane.
209
+ const syncAsk = (box: HTMLElement): void => {
210
+ const out = box.querySelector("[data-ask-output]");
211
+ if (out !== null) {
212
+ out.textContent = askMarkdown(box);
213
+ }
214
+ };
215
+
216
+ /* ---- Board: drag & drop, move buttons, markdown copy ----------------------
217
+ * Cards are `draggable` and carry their props in `data-card-*` attributes,
218
+ * so the current arrangement serializes straight back into `<Board>` markup
219
+ * (`data-board-copy` copies it — paste it over the source block to persist
220
+ * a reorganization). `[data-board-move]` buttons cover keyboards and touch
221
+ * (HTML5 DnD is absent there). All of it is plain DOM work — no hydration.
222
+ */
223
+
224
+ // The card mid-drag, or null. Non-null also gates dragover/drop handling so
225
+ // unrelated drags (files, text selections) keep their native behavior.
226
+ let draggedCard: HTMLElement | null = null;
227
+
228
+ const DROP_HINTS = ["mdxr-drop-before", "mdxr-drop-end", "mdxr-drop-lane"];
229
+
230
+ // A board's own lanes — a Board nested in a card's children belongs to the
231
+ // inner board, so `closest` on each candidate does the assignment.
232
+ const lanesOf = (board: HTMLElement): HTMLElement[] =>
233
+ [...board.querySelectorAll("[data-board-lane]")].filter(
234
+ (l): l is HTMLElement =>
235
+ l instanceof HTMLElement && l.closest("[data-board]") === board
236
+ );
237
+
238
+ // The lane's cards: direct children of its `[data-board-cards]` box.
239
+ const cardsOf = (lane: HTMLElement): HTMLElement[] => {
240
+ const box = lane.querySelector(":scope > [data-board-cards]");
241
+ if (box === null) {
242
+ return [];
243
+ }
244
+ return [...box.children].filter(
245
+ (c): c is HTMLElement =>
246
+ c instanceof HTMLElement && Object.hasOwn(c.dataset, "boardCard")
247
+ );
248
+ };
249
+
250
+ const clearDropHints = (board: HTMLElement): void => {
251
+ for (const n of board.querySelectorAll(`.${DROP_HINTS.join(", .")}`)) {
252
+ n.classList.remove(...DROP_HINTS);
253
+ }
254
+ };
255
+
256
+ // After any move the lane counts and the move buttons' `disabled` state
257
+ // follow the DOM (first-lane cards can't go left, last-lane can't go right).
258
+ const syncBoard = (board: HTMLElement): void => {
259
+ const lanes = lanesOf(board);
260
+ for (const [i, lane] of lanes.entries()) {
261
+ const cards = cardsOf(lane);
262
+ const badge = lane.querySelector(":scope > header .mdxr-lane-count");
263
+ if (badge !== null) {
264
+ badge.textContent = String(cards.length);
265
+ }
266
+ for (const card of cards) {
267
+ const prev = card.querySelector('[data-board-move="-1"]');
268
+ const next = card.querySelector('[data-board-move="1"]');
269
+ if (prev instanceof HTMLButtonElement) {
270
+ prev.disabled = i === 0;
271
+ }
272
+ if (next instanceof HTMLButtonElement) {
273
+ next.disabled = i === lanes.length - 1;
274
+ }
275
+ }
276
+ }
277
+ };
278
+
279
+ /** Initial counts + move-button state for every board under `root`. */
280
+ export const syncBoards = (root: ParentNode): void => {
281
+ for (const b of root.querySelectorAll("[data-board]")) {
282
+ if (b instanceof HTMLElement) {
283
+ syncBoard(b);
284
+ }
285
+ }
286
+ };
287
+
288
+ const moveCard = (card: HTMLElement, dir: number): void => {
289
+ const board = closestEl(card, "[data-board]");
290
+ const lane = closestEl(card, "[data-board-lane]");
291
+ if (board === null || lane === null) {
292
+ return;
293
+ }
294
+ const lanes = lanesOf(board);
295
+ const target = lanes[lanes.indexOf(lane) + dir];
296
+ target?.querySelector(":scope > [data-board-cards]")?.append(card);
297
+ if (target !== undefined) {
298
+ syncBoard(board);
299
+ }
300
+ };
301
+
302
+ // The insertion point for a pointer at clientY: the first card whose
303
+ // vertical midpoint sits below it (the dragged card aside), i.e. "drop
304
+ // before this card"; null means append at the lane's end.
305
+ const dropBefore = (lane: HTMLElement, clientY: number): HTMLElement | null => {
306
+ for (const c of cardsOf(lane)) {
307
+ if (c === draggedCard) {
308
+ continue;
309
+ }
310
+ const r = c.getBoundingClientRect();
311
+ if (clientY < r.top + r.height / 2) {
312
+ return c;
313
+ }
314
+ }
315
+ return null;
316
+ };
317
+
318
+ // Attribute in the emitted markup, always `name="v"`: JSX decodes entities
319
+ // in attribute strings, so `&`/`"`/`<`/control chars escape as entities —
320
+ // the emitted markup stays expression-free (mdxr forbids JS expressions).
321
+ const jsxAttr = (name: string, value: string): string =>
322
+ ` ${name}="${value
323
+ .replaceAll("&", "&amp;")
324
+ .replaceAll('"', "&quot;")
325
+ .replaceAll("<", "&lt;")
326
+ .replaceAll("\n", "&#10;")
327
+ .replaceAll("\r", "&#13;")
328
+ .replaceAll("\t", "&#9;")}"`;
329
+
330
+ // Card children serialize as markdown text inside <BoardCard>…</BoardCard>:
331
+ // entities cover `&`/`<`/braces (JSX markers), backslash escapes cover
332
+ // markdown's inline specials and line-leading block markers, so the text
333
+ // re-parses as it reads. Formatting the children had is already lost —
334
+ // data-card-text is the flattened visible text.
335
+ const mdText = (s: string): string =>
336
+ s
337
+ .replaceAll("\\", "\\\\")
338
+ .replaceAll("&", "&amp;")
339
+ .replaceAll("<", "&lt;")
340
+ .replaceAll("{", "&#123;")
341
+ .replaceAll("}", "&#125;")
342
+ .replaceAll(/(?<ch>[`*_[\]])/gu, "\\$<ch>")
343
+ .replaceAll(
344
+ /(?<bol>^|\n)(?<ws>[ \t]*)(?<mark>[-+>#])/gu,
345
+ "$<bol>$<ws>\\$<mark>"
346
+ )
347
+ .replaceAll(
348
+ /(?<bol>^|\n)(?<ws>[ \t]*)(?<num>\d+)(?<dot>[.)])(?=\s|$)/gu,
349
+ "$<bol>$<ws>$<num>\\$<dot>"
350
+ );
351
+
352
+ // `[attr name, dataset key]` in emitted order: title first (required),
353
+ // then the chips in their rendered order.
354
+ const CARD_ATTRS = [
355
+ ["status", "cardStatus"],
356
+ ["priority", "cardPriority"],
357
+ ["effort", "cardEffort"],
358
+ ["owner", "cardOwner"],
359
+ ["due", "cardDue"],
360
+ ] as const;
361
+
362
+ // Serializes a board's current DOM into `<Board>`/`<Lane>`/`<BoardCard>`
363
+ // markup — the source form, so pasting it back persists the arrangement.
364
+ const boardMarkdown = (board: HTMLElement): string => {
365
+ const attr = (name: string, value: string | undefined): string =>
366
+ value === undefined || value === "" ? "" : jsxAttr(name, value);
367
+ const lines = [`<Board${attr("title", board.dataset.boardTitle)}>`];
368
+ for (const lane of lanesOf(board)) {
369
+ lines.push(
370
+ ` <Lane${attr("title", lane.dataset.laneTitle)}${attr("status", lane.dataset.laneStatus)}>`
371
+ );
372
+ for (const card of cardsOf(lane)) {
373
+ const d = card.dataset;
374
+ const attrs = CARD_ATTRS.map(([name, key]) => attr(name, d[key])).join(
375
+ ""
376
+ );
377
+ const open = ` <BoardCard${attr("title", d.cardTitle)}${attrs}`;
378
+ lines.push(
379
+ d.cardText === undefined || d.cardText === ""
380
+ ? `${open} />`
381
+ : `${open}>${mdText(d.cardText)}</BoardCard>`
382
+ );
383
+ }
384
+ lines.push(" </Lane>");
385
+ }
386
+ lines.push("</Board>");
387
+ return lines.join("\n");
388
+ };
389
+
390
+ // Downloads the answer sheet as `<slug>.md` via a temporary blob link.
391
+ const saveAsk = (box: HTMLElement, btn: HTMLElement): void => {
392
+ syncAsk(box);
393
+ const slug = (box.dataset.askTitle ?? "answers")
394
+ .toLowerCase()
395
+ .replaceAll(/[^\p{L}\p{N}]+/gu, "-")
396
+ .replaceAll(/^-+|-+$/gu, "");
397
+ const url = URL.createObjectURL(
398
+ new Blob([askMarkdown(box)], { type: "text/markdown;charset=utf-8" })
399
+ );
400
+ const a = document.createElement("a");
401
+ a.href = url;
402
+ a.download = `${slug === "" ? "answers" : slug}.md`;
403
+ document.body.append(a);
404
+ a.click();
405
+ a.remove();
406
+ setTimeout(() => {
407
+ URL.revokeObjectURL(url);
408
+ }, 1000);
409
+ flash(btn, "copied");
410
+ };
411
+
412
+ // localStorage can throw on file:// or in hardened contexts — reads fall
413
+ // back to "auto", writes are best-effort (the toggle still applies for
414
+ // this view).
415
+ const storedTheme = (): string => {
416
+ try {
417
+ return localStorage.getItem("mdxr-theme") ?? "auto";
418
+ } catch {
419
+ return "auto";
420
+ }
421
+ };
422
+
423
+ const persistTheme = (mode: string): void => {
424
+ try {
425
+ if (mode === "auto") {
426
+ localStorage.removeItem("mdxr-theme");
427
+ } else {
428
+ localStorage.setItem("mdxr-theme", mode);
429
+ }
430
+ } catch {
431
+ // Best-effort — see above.
432
+ }
433
+ };
434
+
435
+ const NEXT_MODE: Record<string, string> = {
436
+ auto: "light",
437
+ dark: "auto",
438
+ light: "dark",
439
+ };
440
+
441
+ // Cycles auto → light → dark, persists the choice, and repaints every
442
+ // toggle's icon/label to match.
443
+ const cycleTheme = (): void => {
444
+ const stored = storedTheme();
445
+ const mode = stored === "light" || stored === "dark" ? stored : "auto";
446
+ const next = NEXT_MODE[mode] ?? "auto";
447
+ persistTheme(next);
448
+ const dark =
449
+ next === "dark" ||
450
+ (next === "auto" && matchMedia("(prefers-color-scheme: dark)").matches);
451
+ document.documentElement.classList.toggle("dark", dark);
452
+ const title = `Theme: ${next}`;
453
+ for (const b of document.querySelectorAll("[data-mdxr-theme]")) {
454
+ if (!(b instanceof HTMLElement)) {
455
+ continue;
456
+ }
457
+ b.dataset.mode = next;
458
+ b.setAttribute("title", title);
459
+ b.setAttribute("aria-label", `Switch theme (current: ${next})`);
460
+ }
461
+ };
462
+
463
+ // The lane + home board when `el` sits over a valid drop target for the
464
+ // active drag — lanes of the dragged card's own board only, not other
465
+ // boards and not the gaps between lanes.
466
+ const dragLane = (
467
+ el: Element
468
+ ): { home: HTMLElement; lane: HTMLElement } | null => {
469
+ if (draggedCard === null) {
470
+ return null;
471
+ }
472
+ const home = closestEl(draggedCard, "[data-board]");
473
+ const lane = closestEl(el, "[data-board-lane]");
474
+ if (home === null || lane === null || lane.closest("[data-board]") !== home) {
475
+ return null;
476
+ }
477
+ return { home, lane };
478
+ };
479
+
480
+ const dragStart = (e: Event, el: Element): boolean => {
481
+ const card = closestEl(el, "[data-board-card]");
482
+ if (card === null || card.closest("[data-board]") === null) {
483
+ return false;
484
+ }
485
+ draggedCard = card;
486
+ if (e instanceof DragEvent && e.dataTransfer !== null) {
487
+ e.dataTransfer.effectAllowed = "move";
488
+ // Firefox won't start a drag without setData.
489
+ e.dataTransfer.setData("text/plain", card.dataset.cardTitle ?? "");
490
+ }
491
+ // Defer so the drag image keeps the card's normal look. Re-check the
492
+ // active drag: a same-frame drop/dragend would otherwise leave a stale
493
+ // mdxr-drag class behind after dragFinish already ran.
494
+ requestAnimationFrame(() => {
495
+ if (draggedCard === card) {
496
+ card.classList.add("mdxr-drag");
497
+ }
498
+ });
499
+ return true;
500
+ };
501
+
502
+ // Ends the active drag — drop/dragend both land here; `home` clears the
503
+ // insertion-point hints when known.
504
+ const dragFinish = (home: HTMLElement | null): void => {
505
+ if (draggedCard === null) {
506
+ return;
507
+ }
508
+ draggedCard.classList.remove("mdxr-drag");
509
+ if (home !== null) {
510
+ clearDropHints(home);
511
+ }
512
+ draggedCard = null;
513
+ };
514
+
515
+ const dragOver = (e: Event, el: Element): boolean => {
516
+ if (draggedCard === null) {
517
+ return false;
518
+ }
519
+ const target = dragLane(el);
520
+ const home = closestEl(draggedCard, "[data-board]");
521
+ if (target === null) {
522
+ // Off the lanes: clear stale hints, keep the no-drop cursor.
523
+ if (home !== null) {
524
+ clearDropHints(home);
525
+ }
526
+ return true;
527
+ }
528
+ e.preventDefault();
529
+ const box = target.lane.querySelector(":scope > [data-board-cards]");
530
+ if (box === null) {
531
+ return true;
532
+ }
533
+ if (e instanceof DragEvent && e.dataTransfer !== null) {
534
+ e.dataTransfer.dropEffect = "move";
535
+ }
536
+ const before = dropBefore(
537
+ target.lane,
538
+ e instanceof DragEvent ? e.clientY : 0
539
+ );
540
+ clearDropHints(target.home);
541
+ target.lane.classList.add("mdxr-drop-lane");
542
+ if (before === null) {
543
+ box.classList.add("mdxr-drop-end");
544
+ } else {
545
+ before.classList.add("mdxr-drop-before");
546
+ }
547
+ return true;
548
+ };
549
+
550
+ const drop = (e: Event, el: Element): boolean => {
551
+ if (draggedCard === null) {
552
+ return false;
553
+ }
554
+ const target = dragLane(el);
555
+ if (target === null) {
556
+ // Keep the card's text out of fields/links outside the board.
557
+ e.preventDefault();
558
+ dragFinish(closestEl(draggedCard, "[data-board]"));
559
+ return true;
560
+ }
561
+ e.preventDefault();
562
+ const box = target.lane.querySelector(":scope > [data-board-cards]");
563
+ const before = dropBefore(
564
+ target.lane,
565
+ e instanceof DragEvent ? e.clientY : 0
566
+ );
567
+ if (box !== null) {
568
+ if (before === null) {
569
+ box.append(draggedCard);
570
+ } else {
571
+ before.before(draggedCard);
572
+ }
573
+ }
574
+ dragFinish(target.home);
575
+ syncBoard(target.home);
576
+ return true;
577
+ };
578
+
579
+ /* ---- Comments: line anchors, reply form, markdown copy -------------------
580
+ * <Comments> markup carries the hooks: [data-mdxr-comment] cards inside
581
+ * [data-comment-strip] threads, a [data-comment-add] "+" on every code/diff
582
+ * row, [data-thread-tools] holding each thread's Reply affordance, and
583
+ * [data-comments-copy] on the block's tools row. The strip/card/form markup
584
+ * ships as inert <template data-comment-tpl> fragments inside the
585
+ * [data-comments] root — handlers clone them so client-built markup stays
586
+ * single-sourced in SSR. [data-comments-copy] serializes the block's DOM
587
+ * back into <Comments> markup — paste it over the source block to persist
588
+ * a review.
589
+ */
590
+
591
+ interface CommentAnchor {
592
+ file?: string;
593
+ lines?: string;
594
+ side?: string;
595
+ }
596
+
597
+ // Mirrors owner.tsx's initials — the client bundle can't reach the React
598
+ // helper without dragging the component tree into the IIFE.
599
+ const commentInitials = (name: string): string => {
600
+ const clean = name.replace(/^@+/u, "").trim();
601
+ const parts = clean.split(/[\s._-]+/u).filter(Boolean);
602
+ const chars =
603
+ parts.length > 0
604
+ ? parts.slice(0, 2).map((w) => w.charAt(0))
605
+ : [clean.charAt(0)];
606
+ return chars.join("").toUpperCase();
607
+ };
608
+
609
+ // dataset writes skip empty/undefined values — `d.k = undefined` would set
610
+ // the literal "undefined" attribute.
611
+ const dataName = (key: string): string =>
612
+ `data-${key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`)}`;
613
+
614
+ const setData = (
615
+ el: HTMLElement,
616
+ key: string,
617
+ value: string | undefined
618
+ ): void => {
619
+ if (value === undefined || value === "") {
620
+ el.removeAttribute(dataName(key));
621
+ } else {
622
+ el.setAttribute(dataName(key), value);
623
+ }
624
+ };
625
+
626
+ // Clone one <template data-comment-tpl> fragment parked inside `root`
627
+ // (the [data-comments] block). Returns the fragment's single root element.
628
+ // Parsed templates keep their kids in .content; hydrated ones (React
629
+ // rebuilds them as real element children) keep them as direct children —
630
+ // read both.
631
+ const cloneTpl = (root: HTMLElement, kind: string): HTMLElement | null => {
632
+ const tpl = root.querySelector(`template[data-comment-tpl="${kind}"]`);
633
+ if (!(tpl instanceof HTMLTemplateElement)) {
634
+ return null;
635
+ }
636
+ const node = (
637
+ tpl.content.firstElementChild ?? tpl.firstElementChild
638
+ )?.cloneNode(true);
639
+ return node instanceof HTMLElement ? node : null;
640
+ };
641
+
642
+ // The strip's own anchor (replies inherit it): data-strip-* the SSR set
643
+ // from the thread's last comment.
644
+ const anchorOfStrip = (strip: HTMLElement): CommentAnchor => ({
645
+ file: strip.dataset.stripFile,
646
+ lines: strip.dataset.stripLines,
647
+ side: strip.dataset.stripSide,
648
+ });
649
+
650
+ // Append a cloned comment form to `strip`, hiding its Reply tools until the
651
+ // form closes. A strip already hosting a form just refocuses it.
652
+ const openCommentForm = (
653
+ root: HTMLElement,
654
+ strip: HTMLElement,
655
+ anchor: CommentAnchor
656
+ ): void => {
657
+ const live = strip.querySelector(":scope > [data-comment-form]");
658
+ if (live instanceof HTMLElement) {
659
+ live.querySelector("textarea")?.focus();
660
+ return;
661
+ }
662
+ const form = cloneTpl(root, "form");
663
+ if (form === null) {
664
+ return;
665
+ }
666
+ setData(form, "formFile", anchor.file);
667
+ setData(form, "formLines", anchor.lines);
668
+ setData(form, "formSide", anchor.side);
669
+ const tools = strip.querySelector(":scope > [data-thread-tools]");
670
+ if (tools instanceof HTMLElement) {
671
+ tools.hidden = true;
672
+ }
673
+ strip.append(form);
674
+ form.querySelector("textarea")?.focus();
675
+ };
676
+
677
+ // Remove the form and bring back the strip's Reply tools. A strip with no
678
+ // cards left is one "+" opened and the user abandoned — drop it entirely
679
+ // and hand focus back to the row's "+".
680
+ const closeCommentForm = (form: HTMLElement): void => {
681
+ const strip = form.parentElement;
682
+ form.remove();
683
+ if (
684
+ !(strip instanceof HTMLElement) ||
685
+ !Object.hasOwn(strip.dataset, "commentStrip")
686
+ ) {
687
+ return;
688
+ }
689
+ if (strip.querySelector("[data-mdxr-comment]") === null) {
690
+ const add =
691
+ strip.previousElementSibling?.querySelector("[data-comment-add]");
692
+ strip.remove();
693
+ if (add instanceof HTMLElement) {
694
+ add.focus();
695
+ }
696
+ return;
697
+ }
698
+ const tools = strip.querySelector(":scope > [data-thread-tools]");
699
+ if (!(tools instanceof HTMLElement)) {
700
+ return;
701
+ }
702
+ tools.hidden = false;
703
+ const reply = tools.querySelector("[data-comment-reply]");
704
+ if (reply instanceof HTMLElement) {
705
+ reply.focus();
706
+ }
707
+ };
708
+
709
+ // Fill one [data-cc-*] slot with text — unused slots leave the clone so the
710
+ // built card matches SSR markup (no empty spans/avatar).
711
+ const fillSlot = (
712
+ card: HTMLElement,
713
+ slot: string,
714
+ value: string | undefined
715
+ ): void => {
716
+ const el = card.querySelector(slot);
717
+ if (value === undefined || value === "") {
718
+ el?.remove();
719
+ return;
720
+ }
721
+ if (el !== null) {
722
+ el.textContent = value;
723
+ }
724
+ };
725
+
726
+ // Build a [data-mdxr-comment] card off the `card` template: header slots
727
+ // filled from the anchor + author, body split into <p> paragraphs, and the
728
+ // data-comment-* attributes the markdown serializer reads.
729
+ const buildCommentCard = (
730
+ root: HTMLElement,
731
+ anchor: CommentAnchor,
732
+ author: string,
733
+ text: string
734
+ ): HTMLElement | null => {
735
+ const card = cloneTpl(root, "card");
736
+ if (card === null) {
737
+ return null;
738
+ }
739
+ setData(card, "commentAuthor", author);
740
+ setData(card, "commentFile", anchor.file);
741
+ setData(card, "commentLines", anchor.lines);
742
+ setData(card, "commentSide", anchor.side === "old" ? "old" : undefined);
743
+ setData(card, "commentText", text);
744
+ fillSlot(card, "[data-cc-name]", author.replace(/^@+/u, ""));
745
+ fillSlot(
746
+ card,
747
+ "[data-cc-lines]",
748
+ anchor.lines === undefined ? undefined : `:${anchor.lines}`
749
+ );
750
+ if (anchor.side !== "old") {
751
+ card.querySelector("[data-cc-old]")?.remove();
752
+ }
753
+ if (author === "") {
754
+ card.querySelector("[data-cc-avatar]")?.remove();
755
+ } else {
756
+ card.querySelector("[data-cc-avatar-anon]")?.remove();
757
+ fillSlot(card, "[data-cc-avatar]", commentInitials(author));
758
+ }
759
+ const body = card.querySelector("[data-cc-body]");
760
+ for (const para of text.split(/\n{2,}/u)) {
761
+ const p = document.createElement("p");
762
+ p.textContent = para;
763
+ body?.append(p);
764
+ }
765
+ return card;
766
+ };
767
+
768
+ const submitCommentForm = (form: HTMLElement): void => {
769
+ const strip = closestEl(form, "[data-comment-strip]");
770
+ const root = closestEl(form, "[data-comments]");
771
+ const input = form.querySelector("[data-comment-input]");
772
+ const nameField = form.querySelector("[data-comment-author]");
773
+ if (
774
+ strip === null ||
775
+ root === null ||
776
+ !(input instanceof HTMLTextAreaElement)
777
+ ) {
778
+ return;
779
+ }
780
+ const text = input.value.trim();
781
+ if (text === "") {
782
+ input.focus();
783
+ return;
784
+ }
785
+ const card = buildCommentCard(
786
+ root,
787
+ {
788
+ file: form.dataset.formFile,
789
+ lines: form.dataset.formLines,
790
+ side: form.dataset.formSide,
791
+ },
792
+ nameField instanceof HTMLInputElement ? nameField.value.trim() : "",
793
+ text
794
+ );
795
+ if (card === null) {
796
+ return;
797
+ }
798
+ // New cards land ahead of the tools row — last in the thread.
799
+ const tools = strip.querySelector(":scope > [data-thread-tools]");
800
+ if (tools === null) {
801
+ strip.append(card);
802
+ } else {
803
+ tools.before(card);
804
+ }
805
+ closeCommentForm(form);
806
+ };
807
+
808
+ // The thread strip under a line row: the one already there (its anchor
809
+ // stands), else a fresh clone inserted after the row carrying the "+"
810
+ // button's anchor.
811
+ const stripAfterRow = (
812
+ row: HTMLElement,
813
+ root: HTMLElement,
814
+ anchor: CommentAnchor
815
+ ): HTMLElement | null => {
816
+ const next = row.nextElementSibling;
817
+ if (
818
+ next instanceof HTMLElement &&
819
+ Object.hasOwn(next.dataset, "commentStrip")
820
+ ) {
821
+ return next;
822
+ }
823
+ const strip = cloneTpl(root, "strip");
824
+ if (strip === null) {
825
+ return null;
826
+ }
827
+ setData(strip, "stripLines", anchor.lines);
828
+ setData(strip, "stripSide", anchor.side);
829
+ setData(strip, "stripFile", anchor.file);
830
+ row.after(strip);
831
+ return strip;
832
+ };
833
+
834
+ // "+" on a code/diff row: open the form in the row's strip, anchored to the
835
+ // button's line (and side/file for diffs). An existing strip under the row
836
+ // absorbs the new comment — GitHub keeps one thread per line.
837
+ const openLineComment = (btn: HTMLElement): void => {
838
+ const row = closestEl(btn, "[data-comment-row]");
839
+ const root = closestEl(btn, "[data-comments]");
840
+ if (row === null || root === null) {
841
+ return;
842
+ }
843
+ const anchor: CommentAnchor = {
844
+ file: btn.dataset.file,
845
+ lines: btn.dataset.commentAdd,
846
+ side: btn.dataset.side,
847
+ };
848
+ const strip = stripAfterRow(row, root, anchor);
849
+ if (strip !== null) {
850
+ openCommentForm(root, strip, anchor);
851
+ }
852
+ };
853
+
854
+ // `<Comment>` attrs in emitted order — [attribute name, dataset key].
855
+ const COMMENT_ATTRS = [
856
+ ["author", "commentAuthor"],
857
+ ["file", "commentFile"],
858
+ ["href", "commentHref"],
859
+ ["lines", "commentLines"],
860
+ ["severity", "commentSeverity"],
861
+ ["side", "commentSide"],
862
+ ["title", "commentTitle"],
863
+ ] as const;
864
+
865
+ // A fence that survives backtick runs inside the code — one tick longer
866
+ // than the longest run (three minimum).
867
+ const fenceFor = (code: string): string => {
868
+ let max = 0;
869
+ for (const m of code.matchAll(/`+/gu)) {
870
+ max = Math.max(max, m[0].length);
871
+ }
872
+ return "`".repeat(Math.max(3, max + 1));
873
+ };
874
+
875
+ // Serialize a [data-comments] block back into <Comments> markup — the
876
+ // source form, so pasting it over the source block persists the review.
877
+ // The fence round-trips verbatim (lang + raw meta + text on the root's
878
+ // data-comments-*); every card re-emits its data-comment-* attributes, DOM
879
+ // order preserving thread order.
880
+ const commentsMarkdown = (root: HTMLElement): string => {
881
+ const out = ["<Comments>", ""];
882
+ const code = (root.dataset.commentsCode ?? "").replace(/\n$/u, "");
883
+ const lang = root.dataset.commentsLang ?? "";
884
+ const meta = root.dataset.commentsMeta ?? "";
885
+ if (code !== "") {
886
+ const fence = fenceFor(code);
887
+ out.push(
888
+ `${fence}${lang}${meta === "" ? "" : ` ${meta}`}`,
889
+ code,
890
+ fence,
891
+ ""
892
+ );
893
+ }
894
+ for (const c of root.querySelectorAll("[data-mdxr-comment]")) {
895
+ // Hydrated <template> kids are real children — the card skeleton inside
896
+ // the `card` template must not serialize as a phantom comment.
897
+ if (!(c instanceof HTMLElement) || c.closest("template") !== null) {
898
+ continue;
899
+ }
900
+ const d = c.dataset;
901
+ const attrs = COMMENT_ATTRS.map(([name, key]) => {
902
+ const v = d[key];
903
+ return v === undefined || v === "" ? "" : jsxAttr(name, v);
904
+ }).join("");
905
+ const text = d.commentText ?? "";
906
+ out.push(
907
+ text === ""
908
+ ? `<Comment${attrs} />`
909
+ : `<Comment${attrs}>${mdText(text)}</Comment>`
910
+ );
911
+ }
912
+ out.push("", "</Comments>");
913
+ return out.join("\n");
914
+ };
915
+
916
+ // Comments clicks: "+" line buttons, thread Reply, form Cancel/Comment, the
917
+ // block's Copy markdown.
918
+ const handleCommentsClick = (el: Element): boolean => {
919
+ const addBtn = closestEl(el, "[data-comment-add]");
920
+ if (addBtn !== null) {
921
+ openLineComment(addBtn);
922
+ return true;
923
+ }
924
+ const replyBtn = closestEl(el, "[data-comment-reply]");
925
+ if (replyBtn !== null) {
926
+ const strip = closestEl(replyBtn, "[data-comment-strip]");
927
+ const root = closestEl(replyBtn, "[data-comments]");
928
+ if (strip !== null && root !== null) {
929
+ openCommentForm(root, strip, anchorOfStrip(strip));
930
+ }
931
+ return true;
932
+ }
933
+ const cancelBtn = closestEl(el, "[data-comment-cancel]");
934
+ if (cancelBtn !== null) {
935
+ const form = closestEl(cancelBtn, "[data-comment-form]");
936
+ if (form !== null) {
937
+ closeCommentForm(form);
938
+ }
939
+ return true;
940
+ }
941
+ const submitBtn = closestEl(el, "[data-comment-submit]");
942
+ if (submitBtn !== null) {
943
+ const form = closestEl(submitBtn, "[data-comment-form]");
944
+ if (form !== null) {
945
+ submitCommentForm(form);
946
+ }
947
+ return true;
948
+ }
949
+ const copyBtn = closestEl(el, "[data-comments-copy]");
950
+ if (copyBtn === null) {
951
+ return false;
952
+ }
953
+ const root = closestEl(copyBtn, "[data-comments]");
954
+ if (root !== null) {
955
+ copyWithFeedback(copyBtn, commentsMarkdown(root));
956
+ }
957
+ return true;
958
+ };
959
+
960
+ // Comment clicks plus the form's keydown: Cmd/Ctrl+Enter submits, Esc
961
+ // cancels — GitHub's comment-box shortcuts.
962
+ const handleComments = (e: Event, el: Element): boolean => {
963
+ if (e.type === "click") {
964
+ return handleCommentsClick(el);
965
+ }
966
+ if (e.type !== "keydown" || !(e instanceof KeyboardEvent)) {
967
+ return false;
968
+ }
969
+ const form = closestEl(el, "[data-comment-form]");
970
+ if (form === null) {
971
+ return false;
972
+ }
973
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
974
+ submitCommentForm(form);
975
+ return true;
976
+ }
977
+ if (e.key === "Escape") {
978
+ closeCommentForm(form);
979
+ return true;
980
+ }
981
+ return false;
982
+ };
983
+
984
+ // Board clicks: per-card lane move buttons and the markdown copy.
985
+ const handleBoardClick = (el: Element): boolean => {
986
+ const moveBtn = el.closest("[data-board-move]");
987
+ if (moveBtn instanceof HTMLButtonElement) {
988
+ const card = closestEl(moveBtn, "[data-board-card]");
989
+ if (card !== null && !moveBtn.disabled) {
990
+ moveCard(card, moveBtn.dataset.boardMove === "-1" ? -1 : 1);
991
+ }
992
+ return true;
993
+ }
994
+ const boardCopyBtn = closestEl(el, "[data-board-copy]");
995
+ if (boardCopyBtn === null) {
996
+ return false;
997
+ }
998
+ const board = closestEl(boardCopyBtn, "[data-board]");
999
+ if (board !== null) {
1000
+ copyWithFeedback(boardCopyBtn, boardMarkdown(board));
1001
+ }
1002
+ return true;
1003
+ };
1004
+
1005
+ // Card drag lifecycle + board clicks: dragstart arms `draggedCard`;
1006
+ // dragover marks the insertion point and allows the drop; drop moves the
1007
+ // card; dragend covers aborted drags (the source always gets it, drop or
1008
+ // not); clicks hit the move buttons or the markdown copy. Returns true
1009
+ // when the event was consumed.
1010
+ const handleBoard = (e: Event, el: Element): boolean => {
1011
+ if (e.type === "click") {
1012
+ return handleBoardClick(el);
1013
+ }
1014
+ if (e.type === "dragstart") {
1015
+ return dragStart(e, el);
1016
+ }
1017
+ if (e.type === "dragover") {
1018
+ return dragOver(e, el);
1019
+ }
1020
+ if (e.type === "drop") {
1021
+ return drop(e, el);
1022
+ }
1023
+ if (e.type !== "dragend" || draggedCard === null) {
1024
+ return false;
1025
+ }
1026
+ dragFinish(closestEl(draggedCard, "[data-board]"));
1027
+ return true;
1028
+ };
1029
+
1030
+ // Click handling once board/comments had their pass: generic copy, ask
1031
+ // copy/save, theme cycle.
1032
+ const handleDocClick = (el: Element): void => {
1033
+ const copyBtn = closestEl(el, "[data-copy]");
1034
+ if (copyBtn !== null) {
1035
+ copyWithFeedback(
1036
+ copyBtn,
1037
+ copyBtn.dataset.copy ?? "",
1038
+ "Copied",
1039
+ "Copy failed"
1040
+ );
1041
+ return;
1042
+ }
1043
+ const askBtn = closestEl(el, "[data-ask-copy]");
1044
+ if (askBtn !== null) {
1045
+ const box = closestEl(askBtn, "[data-ask]");
1046
+ if (box === null) {
1047
+ return;
1048
+ }
1049
+ // Re-collect on click too: autofill/programmatic edits fire no events.
1050
+ syncAsk(box);
1051
+ copyWithFeedback(askBtn, askMarkdown(box));
1052
+ return;
1053
+ }
1054
+ const askSaveBtn = closestEl(el, "[data-ask-save]");
1055
+ if (askSaveBtn !== null) {
1056
+ const box = closestEl(askSaveBtn, "[data-ask]");
1057
+ if (box !== null) {
1058
+ saveAsk(box, askSaveBtn);
1059
+ }
1060
+ return;
1061
+ }
1062
+ const themeBtn = closestEl(el, "[data-mdxr-theme]");
1063
+ if (themeBtn !== null) {
1064
+ cycleTheme();
1065
+ }
1066
+ };
1067
+
1068
+ /**
1069
+ * Delegated handler for the document's interactive bits. On `input`/`change`
1070
+ * inside an `[data-ask]` block it rewrites the block's `[data-ask-output]`
1071
+ * Markdown answer sheet (`- **label**: answer` lines under `# title`, built
1072
+ * from `data-q-label`/`data-q-type` on each `[data-mdxr-q]` wrapper). On
1073
+ * `click`: `[data-copy]` copies a fixed string; `[data-ask-copy]` copies the
1074
+ * sheet; `[data-ask-save]` downloads it as a `.md` file; `[data-board-move]`
1075
+ * shifts a card a lane over; `[data-board-copy]` copies the board's current
1076
+ * `<Board>` markup; `[data-comment-*]` covers add/reply/cancel/submit and
1077
+ * `[data-comments-copy]` the block's `<Comments>` markup;
1078
+ * `[data-mdxr-theme]` cycles the document theme auto → light → dark
1079
+ * (persisted to localStorage, so THEME_JS can restore it before first
1080
+ * paint). The `drag*` types drive the board's card drag & drop, `keydown`
1081
+ * the comment form's submit/cancel shortcuts. `entry.ts` registers this on
1082
+ * click/input/change/keydown/dragstart/dragover/drop/dragend; the Storybook
1083
+ * preview binds it directly.
1084
+ */
1085
+ export const handleDocEvent = (e: Event): void => {
1086
+ const el = e.target;
1087
+ if (!(el instanceof Element)) {
1088
+ return;
1089
+ }
1090
+ if (handleBoard(e, el)) {
1091
+ return;
1092
+ }
1093
+ if (handleComments(e, el)) {
1094
+ return;
1095
+ }
1096
+ // Field edits keep the Markdown pane live; the init pass in entry.ts
1097
+ // seeds it by bubbling a synthetic `input` event off each [data-ask].
1098
+ if (e.type === "input" || e.type === "change") {
1099
+ const box = closestEl(el, "[data-ask]");
1100
+ if (box !== null) {
1101
+ syncAsk(box);
1102
+ }
1103
+ return;
1104
+ }
1105
+ if (e.type === "click") {
1106
+ handleDocClick(el);
1107
+ }
1108
+ };