@polderlabs/openkan 0.4.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 (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,600 @@
1
+ // OpenKan — MDX viewer (M7/M8/M9).
2
+ // window.OpenKanMdxViewer = { mount(rootEl, opts) }
3
+ //
4
+ // Consumes server-rendered MDX HTML from /api/tasks/:id/mdx-rendered and wires:
5
+ // - clickable blocks (inline comment composer)
6
+ // - <Preview> placeholders → /api/preview POST → sandboxed iframe
7
+ // - <Ask>/<Choice>/<Input>/<Confirm> placeholders → live forms
8
+ // - postMessage listener for iframe respond() callbacks
9
+ //
10
+ // The viewer is mounted and torn down by window.OpenKanTaskView.
11
+
12
+ (() => {
13
+ "use strict";
14
+
15
+ const { api } = window.OpenKanAPI;
16
+
17
+ /**
18
+ * Internal per-mount state.
19
+ * @type {{
20
+ * taskId: string,
21
+ * rootEl: HTMLElement,
22
+ * pendingInputs: Array<{id:string,blockId?:string,type:string,question:string,options?:any,placeholder?:string}>,
23
+ * commentCounts: Map<string, number>,
24
+ * onCommentAdded: Function,
25
+ * onInputResponded: Function,
26
+ * messageListener: ((e: MessageEvent) => void)|null,
27
+ * activeComposer: HTMLElement|null,
28
+ * activeComposerBlock: HTMLElement|null,
29
+ * }|null}
30
+ */
31
+ let state = null;
32
+
33
+ // ─── Current user cache (loaded from /api/me) ───────────────────────────────
34
+ // Used by the comment composer so the POST body carries `author` and the
35
+ // server / other tabs can attribute the comment correctly. Loaded once on
36
+ // first mount, refreshed if missing.
37
+ let currentUserCache = null;
38
+ let currentUserPromise = null;
39
+ async function loadCurrentUser() {
40
+ if (currentUserCache) return currentUserCache;
41
+ if (currentUserPromise) return currentUserPromise;
42
+ currentUserPromise = (async () => {
43
+ try {
44
+ const data = await api("GET", "/api/me");
45
+ currentUserCache = data && typeof data === "object" ? data : { name: "user" };
46
+ } catch {
47
+ currentUserCache = { name: "user" };
48
+ }
49
+ // Expose for other modules (docs-view, task-view) so they can compare
50
+ // authors without re-fetching.
51
+ try { window.OpenKanCurrentUser = currentUserCache; } catch {}
52
+ return currentUserCache;
53
+ })();
54
+ return currentUserPromise;
55
+ }
56
+
57
+ // ─── Utilities ──────────────────────────────────────────────────────────────
58
+ function el(tag, cls, props = {}) {
59
+ const e = document.createElement(tag);
60
+ if (cls) e.className = cls;
61
+ for (const [k, v] of Object.entries(props)) {
62
+ if (k === "text") e.textContent = v;
63
+ else if (k === "html") e.innerHTML = v;
64
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
65
+ else e.setAttribute(k, v);
66
+ }
67
+ return e;
68
+ }
69
+
70
+ function escapeAttr(s) {
71
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
72
+ }
73
+
74
+ // Parse a single progress line. The canonical format is:
75
+ // > [12:34:56] tool: edit_file on src/auth.ts — added login rate-limit
76
+ // but we tolerate `> tool: …` (no leading timestamp) and treat the whole
77
+ // remainder as the text. Returns { time, text } or null if the line is empty.
78
+ function parseProgressLine(text) {
79
+ const trimmed = String(text || "").trim();
80
+ if (!trimmed) return null;
81
+ const m = trimmed.match(/^\[(?:\d{1,2}:)?\d{1,2}:\d{2}\]\s+(.*)$/);
82
+ if (m) return { time: trimmed.slice(1, trimmed.indexOf("]") + 1), text: m[1] };
83
+ // Fallback: try "HH:MM" without seconds
84
+ const m2 = trimmed.match(/^(\d{1,2}:\d{2})\s+(.*)$/);
85
+ if (m2) return { time: m2[1], text: m2[2] };
86
+ return { time: null, text: trimmed };
87
+ }
88
+
89
+ // Walk the rendered MDX looking for an h2 with the exact text "Agent
90
+ // progress" and turn the immediate-following blockquote siblings into a
91
+ // styled vertical timeline. If no section is found, this is a no-op.
92
+ function restyleAgentProgress(rootEl) {
93
+ if (!rootEl) return;
94
+ const headings = rootEl.querySelectorAll("h1, h2, h3, h4, h5, h6");
95
+ for (const h of headings) {
96
+ if ((h.textContent || "").trim().toLowerCase() !== "agent progress") continue;
97
+ // Collect following siblings until the next heading of any level.
98
+ const collected = [];
99
+ let node = h.nextElementSibling;
100
+ while (node) {
101
+ const tag = node.tagName?.toLowerCase() || "";
102
+ if (/^h[1-6]$/.test(tag)) break;
103
+ collected.push(node);
104
+ node = node.nextElementSibling;
105
+ }
106
+ if (collected.length === 0) return;
107
+
108
+ // Build the timeline container and move each blockquote in.
109
+ const tl = document.createElement("div");
110
+ tl.className = "agent-progress-timeline";
111
+ let entries = 0;
112
+ for (const elNode of collected) {
113
+ // Each blockquote may contain multiple <p> tags. We treat each <p> as
114
+ // a separate progress entry; multi-paragraph quotes are uncommon but
115
+ // handled.
116
+ const ps = elNode.tagName?.toLowerCase() === "blockquote"
117
+ ? elNode.querySelectorAll("p")
118
+ : [elNode];
119
+ for (const p of ps) {
120
+ const parsed = parseProgressLine(p.textContent || "");
121
+ if (!parsed) continue;
122
+ const entry = document.createElement("div");
123
+ entry.className = "agent-progress-entry";
124
+ if (parsed.time) {
125
+ const t = document.createElement("span");
126
+ t.className = "progress-time";
127
+ t.textContent = parsed.time;
128
+ entry.append(t);
129
+ }
130
+ const txt = document.createElement("span");
131
+ txt.className = "progress-text";
132
+ txt.textContent = parsed.text;
133
+ entry.append(txt);
134
+ tl.append(entry);
135
+ entries++;
136
+ }
137
+ elNode.remove();
138
+ }
139
+
140
+ // Insert the timeline after the heading.
141
+ h.parentNode?.insertBefore(tl, h.nextSibling);
142
+
143
+ // If there are more than 20 entries, collapse with a "show all" button.
144
+ if (entries > 20) {
145
+ const items = [...tl.querySelectorAll(".agent-progress-entry")];
146
+ items.slice(20).forEach((it) => { it.hidden = true; });
147
+ const more = document.createElement("button");
148
+ more.className = "agent-progress-more";
149
+ more.type = "button";
150
+ more.textContent = `Show all (${entries})`;
151
+ let expanded = false;
152
+ more.addEventListener("click", () => {
153
+ expanded = !expanded;
154
+ items.slice(20).forEach((it) => { it.hidden = !expanded; });
155
+ more.textContent = expanded ? "Show recent only" : `Show all (${entries})`;
156
+ });
157
+ tl.append(more);
158
+ }
159
+ return; // only the first "Agent progress" section is restyled.
160
+ }
161
+ }
162
+
163
+ // Add an anchor ¶ to every heading so readers can deep-link to a section.
164
+ // The id is derived from the heading text (lowercased, kebab-cased, de-
165
+ // duplicated). Clicking the anchor copies the URL hash to the clipboard.
166
+ function addHeadingAnchors(rootEl) {
167
+ if (!rootEl) return;
168
+ const headings = rootEl.querySelectorAll("h1, h2, h3, h4, h5, h6");
169
+ const seen = new Set();
170
+ for (const h of headings) {
171
+ const level = parseInt(h.tagName.slice(1), 10);
172
+ if (level > 4) continue; // keep the timeline clean; only h1-h4
173
+ const text = (h.textContent || "").trim();
174
+ if (!text) continue;
175
+ let slug = text.toLowerCase()
176
+ .replace(/[^a-z0-9]+/g, "-")
177
+ .replace(/^-+|-+$/g, "")
178
+ .slice(0, 64) || "section";
179
+ let id = slug;
180
+ let n = 2;
181
+ while (seen.has(id) || (rootEl.ownerDocument || document).getElementById(id)) {
182
+ id = `${slug}-${n++}`;
183
+ }
184
+ seen.add(id);
185
+ h.id = id;
186
+ h.classList.add("mdx-heading");
187
+ const a = document.createElement("a");
188
+ a.className = "mdx-heading-anchor";
189
+ a.href = `#${id}`;
190
+ a.setAttribute("aria-label", `Link to section: ${text}`);
191
+ a.textContent = "¶";
192
+ a.addEventListener("click", async (e) => {
193
+ // Copy the deep link to the clipboard but let the hash update too.
194
+ try {
195
+ const url = `${location.origin}${location.pathname}#${id}`;
196
+ await navigator.clipboard.writeText(url);
197
+ a.classList.add("copied");
198
+ a.setAttribute("aria-label", `Copied link to ${text}`);
199
+ setTimeout(() => a.classList.remove("copied"), 1200);
200
+ } catch { /* clipboard unavailable — hash still updates */ }
201
+ });
202
+ h.append(a);
203
+ }
204
+ }
205
+
206
+ // ─── Comment composer ───────────────────────────────────────────────────────
207
+ function closeComposer() {
208
+ if (state?.activeComposer) {
209
+ state.activeComposer.remove();
210
+ state.activeComposer = null;
211
+ state.activeComposerBlock = null;
212
+ }
213
+ }
214
+
215
+ function openComposer(blockEl) {
216
+ console.debug("[openkan] composer opened for block", blockEl.getAttribute("data-block-id"));
217
+ closeComposer();
218
+ const blockId = blockEl.getAttribute("data-block-id");
219
+ const line = blockEl.getAttribute("data-line") || "1";
220
+ if (!blockId) {
221
+ console.warn("[openkan] composer: block has no data-block-id, aborting");
222
+ return;
223
+ }
224
+
225
+ const composer = el("div", "mdx-block-comment-composer");
226
+ const ta = el("textarea", "composer-textarea", {
227
+ rows: "2",
228
+ placeholder: "Leave a comment on this block…",
229
+ });
230
+ composer.append(ta);
231
+ const actions = el("div", "composer-actions");
232
+ const cancel = el("button", "btn btn-icon-sm", { text: "Cancel", type: "button" });
233
+ cancel.addEventListener("click", closeComposer);
234
+ const save = el("button", "btn btn-primary btn-icon-sm", { text: "Save", type: "button" });
235
+ actions.append(cancel, save);
236
+ composer.append(actions);
237
+
238
+ save.addEventListener("click", async () => {
239
+ const text = ta.value.trim();
240
+ if (!text) return;
241
+ save.disabled = true;
242
+ // Resolve current user. Three fallbacks, in order:
243
+ // 1. window.OpenKanCurrentUser — set by mdx-viewer.loadCurrentUser()
244
+ // or by task-view.fetchMe().
245
+ // 2. A fresh /api/me call (covers the race where the user is loaded
246
+ // *during* the click).
247
+ // 3. The literal "user" — server never silently drops a comment
248
+ // because of a missing author field.
249
+ const me = window.OpenKanCurrentUser;
250
+ let author = (me && me.name) || "user";
251
+ console.debug("[openkan] comment POST starting", { taskId: state.taskId, blockId, line, text, author });
252
+ try {
253
+ const result = await api("POST", `/api/tasks/${state.taskId}/comments`, {
254
+ blockId,
255
+ line: Number(line) || 1,
256
+ text,
257
+ author,
258
+ });
259
+ console.debug("[openkan] comment POST response:", result);
260
+ closeComposer();
261
+ await state.onCommentAdded?.();
262
+ } catch (err) {
263
+ console.error("[openkan] comment POST error:", err);
264
+ alert(`Failed to save comment: ${err.message}`);
265
+ save.disabled = false;
266
+ }
267
+ });
268
+
269
+ // Also submit with Cmd/Ctrl+Enter for power users.
270
+ ta.addEventListener("keydown", (ev) => {
271
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") {
272
+ ev.preventDefault();
273
+ save.click();
274
+ } else if (ev.key === "Escape") {
275
+ ev.preventDefault();
276
+ closeComposer();
277
+ }
278
+ });
279
+
280
+ // Insert immediately after the block.
281
+ blockEl.insertAdjacentElement("afterend", composer);
282
+ state.activeComposer = composer;
283
+ state.activeComposerBlock = blockEl;
284
+ // Defer focus so the textarea is in the DOM and visible.
285
+ setTimeout(() => { try { ta.focus(); } catch {} }, 0);
286
+ }
287
+
288
+ // ─── Comment-count indicators ───────────────────────────────────────────────
289
+ function paintCommentIndicators() {
290
+ if (!state?.rootEl) return;
291
+ const blocks = state.rootEl.querySelectorAll(".mdx-block[data-block-id]");
292
+ blocks.forEach((b) => {
293
+ const id = b.getAttribute("data-block-id");
294
+ const count = state.commentCounts.get(id) || 0;
295
+ b.classList.toggle("has-comments", count > 0);
296
+ let badge = b.querySelector(":scope > .mdx-comment-count");
297
+ if (count > 0) {
298
+ if (!badge) {
299
+ badge = el("span", "mdx-comment-count", { text: String(count) });
300
+ badge.title = `${count} comment${count === 1 ? "" : "s"}`;
301
+ b.append(badge);
302
+ } else {
303
+ badge.textContent = String(count);
304
+ }
305
+ } else if (badge) {
306
+ badge.remove();
307
+ }
308
+ });
309
+ }
310
+
311
+ // ─── <Preview> placeholder → sandboxed iframe ──────────────────────────────
312
+ async function mountPreview(placeholder) {
313
+ const tsx = placeholder.getAttribute("data-mdx-tsx") || "";
314
+ const propsRaw = placeholder.getAttribute("data-mdx-props") || "{}";
315
+ let props = {};
316
+ try { props = JSON.parse(propsRaw); } catch { props = {}; }
317
+
318
+ placeholder.classList.add("mdx-preview");
319
+ placeholder.innerHTML = `<div class="mdx-preview-loading">Compiling preview…</div>`;
320
+
321
+ let result;
322
+ try {
323
+ result = await api("POST", "/api/preview", { tsx, props });
324
+ } catch (err) {
325
+ placeholder.innerHTML = `<div class="mdx-preview-error">Preview failed: ${escapeAttr(err.message)}</div>`;
326
+ return;
327
+ }
328
+ if (result?.error || !result?.sandboxHtml) {
329
+ placeholder.innerHTML = `<div class="mdx-preview-error">Preview failed: ${escapeAttr(result?.error || "no output")}</div>`;
330
+ return;
331
+ }
332
+ placeholder.innerHTML = "";
333
+ const iframe = el("iframe", "mdx-preview-iframe", {
334
+ sandbox: "allow-scripts",
335
+ referrerpolicy: "no-referrer",
336
+ title: "TSX preview",
337
+ });
338
+ // srcdoc comes from the server (trusted). The sandbox attribute already isolates it.
339
+ iframe.srcdoc = result.sandboxHtml;
340
+ placeholder.append(iframe);
341
+ }
342
+
343
+ // ─── <Ask>/<Choice>/<Input>/<Confirm> placeholder → live form ──────────────
344
+ function mountFormPlaceholder(placeholder) {
345
+ const blockId = placeholder.getAttribute("data-block-id");
346
+ const type = placeholder.getAttribute("data-mdx-component");
347
+ const question = placeholder.getAttribute("data-question") || "";
348
+ const placeholderText = placeholder.getAttribute("data-placeholder") || "";
349
+ let options = [];
350
+ try { options = JSON.parse(placeholder.getAttribute("data-options") || "[]"); } catch {}
351
+
352
+ // Find a pending input that matches this blockId (if any).
353
+ const match = state?.pendingInputs?.find(
354
+ (i) => i.status === "pending" && i.blockId && i.blockId === blockId,
355
+ );
356
+
357
+ placeholder.classList.add("mdx-component-form");
358
+ placeholder.innerHTML = "";
359
+
360
+ const head = el("header", "mdx-component-form-head");
361
+ head.append(el("span", "mdx-component-type", { text: type }));
362
+ if (match) head.append(el("span", "mdx-component-status", { text: "· awaiting response" }));
363
+ placeholder.append(head);
364
+
365
+ if (!match) {
366
+ placeholder.append(el("div", "mdx-component-question", { text: question || "(no question text)" }));
367
+ placeholder.append(el("div", "mdx-component-inactive", {
368
+ text: match ? "" : "This question is not currently active.",
369
+ }));
370
+ return;
371
+ }
372
+
373
+ const form = el("form", "mdx-component-form-body");
374
+ form.append(el("div", "mdx-component-question", { text: match.question || question }));
375
+
376
+ let control;
377
+ switch (match.type) {
378
+ case "choice": {
379
+ control = el("div", "banner-options");
380
+ for (const opt of (match.options || options)) {
381
+ const id = `${match.id}-${opt.id}`;
382
+ const radio = el("input", null, { type: "radio", name: "value", value: opt.id, id });
383
+ const lbl = el("label", null, { for: id });
384
+ lbl.append(radio, ` ${opt.label}`);
385
+ if (opt.description) lbl.append(el("div", "option-desc", { text: opt.description }));
386
+ control.append(lbl);
387
+ }
388
+ form.append(control);
389
+ break;
390
+ }
391
+ case "confirm": {
392
+ control = el("div", "banner-confirm-row");
393
+ const yesId = `${match.id}-yes`;
394
+ const noId = `${match.id}-no`;
395
+ const yl = el("label", null, { for: yesId });
396
+ yl.append(el("input", null, { type: "radio", name: "value", value: "yes", id: yesId }), " Yes");
397
+ const nl = el("label", null, { for: noId });
398
+ nl.append(el("input", null, { type: "radio", name: "value", value: "no", id: noId }), " No");
399
+ control.append(yl, nl);
400
+ form.append(control);
401
+ break;
402
+ }
403
+ case "input":
404
+ case "ask":
405
+ default: {
406
+ control = el("textarea", "banner-textarea", {
407
+ rows: "3",
408
+ placeholder: match.placeholder || placeholderText || "Type your response…",
409
+ name: "value",
410
+ });
411
+ form.append(control);
412
+ break;
413
+ }
414
+ }
415
+ const submit = el("button", "btn btn-primary", { text: "Send", type: "submit" });
416
+ form.append(submit);
417
+
418
+ form.addEventListener("submit", async (e) => {
419
+ e.preventDefault();
420
+ const fd = new FormData(form);
421
+ let body;
422
+ if (match.type === "choice") {
423
+ body = { inputId: match.id, optionId: String(fd.get("value") || "") };
424
+ if (!body.optionId) return alert("Please choose an option.");
425
+ } else if (match.type === "confirm") {
426
+ body = { inputId: match.id, value: String(fd.get("value") || "") };
427
+ if (!body.value) return alert("Please choose Yes or No.");
428
+ } else {
429
+ body = { inputId: match.id, value: String(fd.get("value") || "").trim() };
430
+ if (!body.value) return alert("Please type a response.");
431
+ }
432
+ submit.disabled = true;
433
+ try {
434
+ await state.onInputResponded?.(match.id, body);
435
+ } catch (err) {
436
+ alert(`Failed: ${err.message}`);
437
+ submit.disabled = false;
438
+ }
439
+ });
440
+
441
+ placeholder.append(form);
442
+ }
443
+
444
+ // ─── Click handler for comment composer ─────────────────────────────────────
445
+ function onBlockClick(ev) {
446
+ if (typeof console !== "undefined") {
447
+ const block = ev.target.closest(".mdx-block[data-block-id]");
448
+ console.debug("[openkan] mdx-block clicked:", block?.getAttribute("data-block-id") || "(none)");
449
+ }
450
+ const block = ev.target.closest(".mdx-block[data-block-id]");
451
+ if (!block) return;
452
+ // Don't open composer when clicking inside a form, iframe, button, input, or textarea.
453
+ if (ev.target.closest("form, button, input, textarea, select, iframe, a")) return;
454
+ // Don't open composer for component placeholders (they handle their own interaction).
455
+ if (ev.target.closest("[data-mdx-component]")) return;
456
+ ev.preventDefault();
457
+ openComposer(block);
458
+ }
459
+
460
+ // Some MDX renders dynamically swap the inner HTML (e.g. comment-count
461
+ // badges are inserted via paintCommentIndicators). Bind a single delegated
462
+ // click handler at the document level that catches the rare case where the
463
+ // per-root listener missed the target due to a bubble-phase stop. Cheap
464
+ // because it just walks closest() and exits.
465
+ function documentBlockClick(ev) {
466
+ const root = state?.rootEl;
467
+ if (!root) return;
468
+ // Only act when the click is inside the current MDX root.
469
+ if (!root.contains(ev.target)) return;
470
+ onBlockClick(ev);
471
+ }
472
+
473
+ // ─── postMessage listener for preview iframes ───────────────────────────────
474
+ function onMessage(ev) {
475
+ const data = ev.data;
476
+ if (!data || data.type !== "openkan:respond" || data.version !== 1) return;
477
+ // The iframe cannot tell us which input it relates to; surface to task view
478
+ // which can log/ignore. Preview components are visual — respond() is rare.
479
+ try { state?.onInputResponded?.(null, { value: data.value }); } catch {}
480
+ }
481
+
482
+ function teardown() {
483
+ if (state) {
484
+ try { window.removeEventListener("message", state.messageListener); } catch {}
485
+ try { state.rootEl?.removeEventListener("click", onBlockClick); } catch {}
486
+ try { document.removeEventListener("click", documentBlockClick, true); } catch {}
487
+ state = null;
488
+ }
489
+ }
490
+ async function mount(rootEl, opts = {}) {
491
+ if (!rootEl) throw new Error("mdx-viewer: rootEl is required");
492
+ teardown();
493
+
494
+ const taskId = opts.taskId;
495
+ if (!taskId) throw new Error("mdx-viewer: opts.taskId is required");
496
+
497
+ state = {
498
+ taskId,
499
+ rootEl,
500
+ pendingInputs: opts.pendingInputs || [],
501
+ commentCounts: new Map(),
502
+ onCommentAdded: opts.onCommentAdded || (() => {}),
503
+ onInputResponded: opts.onInputResponded || (() => {}),
504
+ messageListener: onMessage,
505
+ activeComposer: null,
506
+ activeComposerBlock: null,
507
+ };
508
+
509
+ window.addEventListener("message", state.messageListener);
510
+ // Eagerly fetch the current user so the composer can attach author to
511
+ // the POST body without waiting on user interaction.
512
+ loadCurrentUser();
513
+
514
+ // 1. Resolve the rendered HTML/blocks. Prefer what the caller already has
515
+ // (the /api/tasks/:id endpoint embeds renderedHtml/renderedBlocks, so
516
+ // a second fetch is wasted work). Fall back to the legacy endpoint
517
+ // when the embedded payload is missing — e.g. an older server.
518
+ // The decision to skip the fetch hinges on the rendered payload, NOT
519
+ // on the comments list (which is always present on /api/tasks/:id
520
+ // but absent from /api/tasks/:id/mdx-rendered).
521
+ let html = null;
522
+ let blocks = null;
523
+ let comments = [];
524
+ const hasEmbeddedRendered = typeof opts.html === "string" || Array.isArray(opts.blocks);
525
+ if (hasEmbeddedRendered) {
526
+ html = typeof opts.html === "string" ? opts.html : "";
527
+ blocks = Array.isArray(opts.blocks) ? opts.blocks : [];
528
+ comments = Array.isArray(opts.comments) ? opts.comments : [];
529
+ } else {
530
+ try {
531
+ const rendered = await api("GET", `/api/tasks/${taskId}/mdx-rendered`);
532
+ html = rendered?.html ?? "";
533
+ blocks = rendered?.blocks ?? [];
534
+ comments = rendered?.comments ?? [];
535
+ } catch (err) {
536
+ rootEl.innerHTML = `<div class="mdx-error">Failed to render MDX: ${escapeAttr(err.message)}</div>`;
537
+ return;
538
+ }
539
+ }
540
+
541
+ // 2. Build comment-count map.
542
+ for (const c of comments) {
543
+ if (c?.blockId) {
544
+ state.commentCounts.set(c.blockId, (state.commentCounts.get(c.blockId) || 0) + 1);
545
+ }
546
+ }
547
+ // Also merge in any comments passed separately (the task view fetched the same set).
548
+
549
+ // 3. Inject HTML. The server has sanitized it.
550
+ rootEl.innerHTML = html;
551
+
552
+ // Focus mgmt — make the MDX root programmatically focusable so screen
553
+ // readers can announce the freshly-mounted content without forcing the
554
+ // user to Tab through every browser chrome element first. We don't
555
+ // steal focus here (callers can request it via opts.focusRoot); just
556
+ // make sure tabindex is set so the element IS reachable on demand.
557
+ if (opts.focusRoot) {
558
+ rootEl.tabIndex = -1;
559
+ try { rootEl.focus({ preventScroll: false }); } catch {}
560
+ } else {
561
+ // Always expose tabindex so the parent can decide to focus.
562
+ if (!rootEl.hasAttribute("tabindex")) rootEl.setAttribute("tabindex", "-1");
563
+ }
564
+
565
+ // 4. Wire click → composer for each .mdx-block.
566
+ rootEl.addEventListener("click", onBlockClick);
567
+ // Defense in depth: also listen at the document level (capture phase) so
568
+ // we catch clicks even if a downstream handler called stopPropagation()
569
+ // before bubbling reached rootEl. documentBlockClick is a no-op when the
570
+ // click is outside the current root, so it doesn't interfere with other
571
+ // viewers.
572
+ document.addEventListener("click", documentBlockClick, true);
573
+
574
+ // 5. Paint comment-count indicators.
575
+ paintCommentIndicators();
576
+
577
+ // 6. Process <Preview> placeholders — these become iframes.
578
+ const previewEls = rootEl.querySelectorAll('[data-mdx-component="Preview"]');
579
+ previewEls.forEach((ph) => { mountPreview(ph); });
580
+
581
+ // 7. Process <Ask>/<Choice>/<Input>/<Confirm> placeholders.
582
+ for (const type of ["Ask", "Choice", "Input", "Confirm"]) {
583
+ rootEl.querySelectorAll(`[data-mdx-component="${type}"]`).forEach((ph) => {
584
+ mountFormPlaceholder(ph);
585
+ });
586
+ }
587
+
588
+ // 8. Restyle the "## Agent progress" section as a vertical timeline.
589
+ // The MDX file stores progress lines as blockquotes under an h2 with that
590
+ // exact text (per M11 spec). We re-render those blockquotes as a
591
+ // `.agent-progress-timeline` so it reads cleanly instead of as raw quotes.
592
+ restyleAgentProgress(rootEl);
593
+
594
+ // 9. Add anchor-link icons to every h1-h4 so readers can deep-link to
595
+ // a section and copy the URL.
596
+ addHeadingAnchors(rootEl);
597
+ }
598
+
599
+ window.OpenKanMdxViewer = { mount, teardown };
600
+ })();