@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,456 @@
1
+ // OpenKan — Changelog tab (M10).
2
+ // window.OpenKanChangelog = { mount(rootEl), unmount() }
3
+ //
4
+ // Pulls events from /api/changelog and renders a vertical timeline, newest
5
+ // first. Filter by kind prefix, by date range. Infinite scroll loads 50 at
6
+ // a time. Clicking a taskId opens the task in the Tasks tab.
7
+
8
+ (() => {
9
+ "use strict";
10
+
11
+ const { api } = window.OpenKanAPI;
12
+
13
+ const PAGE_SIZE = 50;
14
+ const KIND_FILTERS = [
15
+ { id: "all", label: "all" },
16
+ { id: "task", label: "task.*" },
17
+ { id: "agent", label: "agent.*" },
18
+ { id: "settings", label: "settings.*" },
19
+ { id: "git", label: "git.*" },
20
+ ];
21
+
22
+ // State per-mount
23
+ let state = null;
24
+
25
+ function el(tag, cls, props = {}) {
26
+ const e = document.createElement(tag);
27
+ if (cls) e.className = cls;
28
+ for (const [k, v] of Object.entries(props)) {
29
+ if (k === "text") e.textContent = v;
30
+ else if (k === "html") e.innerHTML = v;
31
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
32
+ else e.setAttribute(k, v);
33
+ }
34
+ return e;
35
+ }
36
+
37
+ function relativeTime(iso) {
38
+ if (!iso) return "";
39
+ const t = new Date(iso).getTime();
40
+ if (isNaN(t)) return "";
41
+ const delta = Date.now() - t;
42
+ const sec = Math.floor(delta / 1000);
43
+ if (sec < 5) return "just now";
44
+ if (sec < 60) return `${sec}s ago`;
45
+ const min = Math.floor(sec / 60);
46
+ if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
47
+ const hr = Math.floor(min / 60);
48
+ if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
49
+ const day = Math.floor(hr / 24);
50
+ if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
51
+ return new Date(iso).toLocaleDateString();
52
+ }
53
+
54
+ function kindToBucket(kind) {
55
+ if (!kind) return "other";
56
+ const k = String(kind).toLowerCase();
57
+ if (k.startsWith("task")) return "task";
58
+ if (k.startsWith("agent")) return "agent";
59
+ if (k.startsWith("settings") || k === "kanban.organized") return "settings";
60
+ if (k.startsWith("git")) return "git";
61
+ return "other";
62
+ }
63
+
64
+ // The server only supports a single `kind=` filter, not prefix-based
65
+ // groups. For prefix filters (task.*, agent.*, ...) we fetch all events
66
+ // and filter client-side. For "all" we still ask the server for a
67
+ // paginated window.
68
+ function passesKindFilter(eventKind, filterKind) {
69
+ if (!filterKind || filterKind === "all") return true;
70
+ return kindToBucket(eventKind) === filterKind;
71
+ }
72
+
73
+ function initialsFor(name) {
74
+ if (!name) return "?";
75
+ const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
76
+ if (parts.length === 0) return "?";
77
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
78
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
79
+ }
80
+
81
+ // Deterministic color from a string. Same approach as in task-view.js.
82
+ function colorFor(seed) {
83
+ let h = 0;
84
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
85
+ const hue = Math.abs(h) % 360;
86
+ return `hsl(${hue} 60% 45%)`;
87
+ }
88
+
89
+ function buildSummary(events) {
90
+ const byKind = {};
91
+ for (const e of events) {
92
+ const bucket = kindToBucket(e.kind);
93
+ byKind[bucket] = (byKind[bucket] || 0) + 1;
94
+ }
95
+ const total = events.length;
96
+ const ordered = ["task", "agent", "settings", "git", "other"]
97
+ .map((k) => ({ k, n: byKind[k] || 0 }))
98
+ .filter((x) => x.n > 0)
99
+ .sort((a, b) => b.n - a.n);
100
+ const max = ordered.reduce((m, x) => Math.max(m, x.n), 0) || 1;
101
+
102
+ const wrap = el("div", "changelog-summary");
103
+ wrap.append(el("div", "changelog-summary-title", { text: `Last 30 days — ${total} event${total === 1 ? "" : "s"}` }));
104
+
105
+ const bars = el("div", "changelog-summary-bars");
106
+ if (total === 0) {
107
+ bars.append(el("div", "changelog-empty", { text: "No events recorded yet." }));
108
+ } else {
109
+ for (const { k, n } of ordered) {
110
+ const row = el("div", "changelog-summary-row");
111
+ row.append(
112
+ el("span", "bar-label", { text: k }),
113
+ (() => {
114
+ const track = el("div", "bar-track");
115
+ const fill = el("div", "bar-fill");
116
+ fill.style.width = `${(n / max) * 100}%`;
117
+ track.append(fill);
118
+ return track;
119
+ })(),
120
+ el("span", "bar-count", { text: String(n) }),
121
+ );
122
+ bars.append(row);
123
+ }
124
+ }
125
+ wrap.append(bars);
126
+ return wrap;
127
+ }
128
+
129
+ function buildViewHero() {
130
+ const hero = el("header", "view-hero");
131
+ const copy = el("div");
132
+ copy.append(
133
+ el("span", "view-eyebrow", { text: "Project activity" }),
134
+ el("h2", null, { text: "Changelog" }),
135
+ el("p", null, { text: "Trace task, agent, settings, and source-control events over time." }),
136
+ );
137
+ hero.append(copy);
138
+ return hero;
139
+ }
140
+
141
+ function buildFilterRow() {
142
+ const row = el("div", "changelog-filter-row");
143
+
144
+ row.append(el("span", "filter-bar-label", { text: "kind" }));
145
+ for (const k of KIND_FILTERS) {
146
+ const btn = el("button", null, { type: "button", text: k.label });
147
+ if (state.filter.kind === k.id) btn.classList.add("active");
148
+ btn.addEventListener("click", () => {
149
+ if (state.filter.kind === k.id) return;
150
+ state.filter.kind = k.id;
151
+ // Re-mount: clear data and reload.
152
+ state.events = [];
153
+ state.offset = 0;
154
+ state.hasMore = true;
155
+ rebuildTimeline();
156
+ loadMore({ reset: true });
157
+ });
158
+ row.append(btn);
159
+ }
160
+
161
+ // "Completed only" toggle — server-side filter (?completedOnly=true).
162
+ // When active, only `task.completed`-type events come back; if the
163
+ // server doesn't recognize the flag we fall back to client-side
164
+ // filtering in loadMore().
165
+ const completedBtn = el("button", "completed-toggle", {
166
+ type: "button",
167
+ "aria-pressed": state.filter.completedOnly ? "true" : "false",
168
+ });
169
+ completedBtn.append(
170
+ el("span", "dot"),
171
+ document.createTextNode("Completed only"),
172
+ );
173
+ completedBtn.title = "Show only task events with a completion status";
174
+ completedBtn.addEventListener("click", () => {
175
+ state.filter.completedOnly = !state.filter.completedOnly;
176
+ completedBtn.setAttribute("aria-pressed", state.filter.completedOnly ? "true" : "false");
177
+ resetAndReload();
178
+ });
179
+ row.append(completedBtn);
180
+
181
+ // Date range
182
+ const wrap = el("span", "date-range");
183
+ const from = el("input", null, { type: "date", "aria-label": "From date" });
184
+ from.value = state.filter.from || "";
185
+ from.addEventListener("change", () => {
186
+ state.filter.from = from.value || "";
187
+ resetAndReload();
188
+ });
189
+ const to = el("input", null, { type: "date", "aria-label": "To date" });
190
+ to.value = state.filter.to || "";
191
+ to.addEventListener("change", () => {
192
+ state.filter.to = to.value || "";
193
+ resetAndReload();
194
+ });
195
+ wrap.append(" from ", from, " to ", to);
196
+ row.append(wrap);
197
+
198
+ const clearBtn = el("button", null, { type: "button", text: "Clear dates" });
199
+ clearBtn.addEventListener("click", () => {
200
+ state.filter.from = "";
201
+ state.filter.to = "";
202
+ from.value = "";
203
+ to.value = "";
204
+ resetAndReload();
205
+ });
206
+ row.append(clearBtn);
207
+
208
+ return row;
209
+ }
210
+
211
+ // Module-scope helper: detach and forget the current "Loading…" placeholder
212
+ // so the next render starts clean. Both `loadMore()` and `resetAndReload()`
213
+ // call this before appending a new one, and `loadMore()` also calls it on
214
+ // success so the spinner disappears once we have events to show.
215
+ function removeLoading() {
216
+ if (state.loadingMsg && state.loadingMsg.parentNode) {
217
+ state.loadingMsg.parentNode.removeChild(state.loadingMsg);
218
+ }
219
+ state.loadingMsg = null;
220
+ }
221
+
222
+ function resetAndReload() {
223
+ state.events = [];
224
+ state.offset = 0;
225
+ state.hasMore = true;
226
+ // CRITICAL: reset the timeline's count so the next render starts at 0.
227
+ // Without this, `state.timeline.dataset.count` still holds the previous
228
+ // render's count (e.g. 50) and the render loop at the bottom of
229
+ // `loadMore()` does `state.events.slice(rendered)` with a `rendered` of
230
+ // 50 against a freshly-empty `state.events` — yielding zero entries.
231
+ if (state.timeline) state.timeline.dataset.count = "0";
232
+ // Drop any in-flight "Loading…" element (on either rootEl or timeline)
233
+ // before we add a fresh one, otherwise we accumulate stale placeholders.
234
+ removeLoading();
235
+ state.timeline.innerHTML = "";
236
+ state.loadingMsg = el("div", "changelog-empty", { text: "Loading…" });
237
+ state.timeline.append(state.loadingMsg);
238
+ loadMore({ reset: true });
239
+ }
240
+
241
+ async function loadMore({ reset = false } = {}) {
242
+ if (!state || !state.hasMore || state.loading) return;
243
+ state.loading = true;
244
+
245
+ try {
246
+ const params = new URLSearchParams();
247
+ params.set("limit", String(PAGE_SIZE));
248
+ params.set("offset", String(state.offset));
249
+ if (state.filter.from) params.set("since", `${state.filter.from}T00:00:00`);
250
+ if (state.filter.to) params.set("until", `${state.filter.to}T23:59:59`);
251
+ if (state.filter.completedOnly) params.set("completedOnly", "true");
252
+
253
+ const data = await api("GET", `/api/changelog?${params.toString()}`);
254
+ const events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : [];
255
+ const total = typeof data?.total === "number" ? data.total : events.length;
256
+
257
+ // Apply client-side kind filter when filtering by prefix group.
258
+ const filteredEvents = passesKindFilter(null, "all") || state.filter.kind === "all"
259
+ ? events
260
+ : events.filter((e) => passesKindFilter(e.kind, state.filter.kind));
261
+
262
+ if (reset) {
263
+ state.events = filteredEvents;
264
+ removeLoading();
265
+ state.timeline.innerHTML = "";
266
+ } else {
267
+ state.events.push(...filteredEvents);
268
+ // Drop the bottom-of-timeline "Loading more…" placeholder once we
269
+ // actually have new entries to show.
270
+ removeLoading();
271
+ }
272
+ state.offset += events.length;
273
+ state.hasMore = state.offset < total && events.length > 0;
274
+
275
+ // Append rendered entries
276
+ const rendered = Number(state.timeline.dataset.count || 0);
277
+ for (const e of state.events.slice(rendered)) {
278
+ state.timeline.append(renderEntry(e));
279
+ }
280
+ state.timeline.dataset.count = String(state.events.length);
281
+
282
+ // Toggle load-more button
283
+ if (state.loadMoreBtn) state.loadMoreBtn.remove();
284
+ if (state.hasMore) {
285
+ const btn = el("button", "changelog-load-more", { type: "button", text: "Load more" });
286
+ btn.addEventListener("click", () => loadMore());
287
+ state.loadMoreBtn = btn;
288
+ state.rootEl.append(btn);
289
+ }
290
+
291
+ // Empty state
292
+ if (state.events.length === 0 && !state.hasMore) {
293
+ if (!state.timeline.querySelector(".changelog-empty")) {
294
+ state.timeline.append(el("div", "changelog-empty", { text: "No events match the current filters." }));
295
+ }
296
+ }
297
+ } catch (err) {
298
+ // Server may not implement the endpoint yet (M10 still landing).
299
+ // Show a friendly empty state so the tab still works.
300
+ if (state.timeline) {
301
+ state.timeline.innerHTML = "";
302
+ state.timeline.append(el("div", "changelog-empty", {
303
+ text: `Changelog unavailable: ${err.message}`,
304
+ }));
305
+ }
306
+ state.hasMore = false;
307
+ } finally {
308
+ state.loading = false;
309
+ }
310
+ }
311
+
312
+ function renderEntry(e) {
313
+ const entry = el("article", "changelog-entry");
314
+
315
+ const head = el("div", "changelog-entry-head");
316
+ head.append(el("span", "changelog-time", {
317
+ text: relativeTime(e.ts),
318
+ title: e.ts,
319
+ }));
320
+ head.append(el("span", `changelog-kind-chip kind-${kindToBucket(e.kind)}`, {
321
+ text: e.kind || "?",
322
+ title: e.kind,
323
+ }));
324
+
325
+ if (e.author) {
326
+ const author = el("span", "changelog-author");
327
+ const av = el("span", "avatar-circle avatar-sm");
328
+ const seed = String(e.author);
329
+ av.style.setProperty("--avatar-bg", colorFor(seed));
330
+ av.textContent = initialsFor(seed);
331
+ author.append(av, e.author);
332
+ head.append(author);
333
+ }
334
+ entry.append(head);
335
+
336
+ entry.append(el("div", "changelog-summary-line", { text: e.summary || "" }));
337
+
338
+ if (e.payload && Object.keys(e.payload).length > 0) {
339
+ const toggle = el("button", "changelog-details-toggle", { type: "button", text: "▸ payload" });
340
+ const pre = el("pre", "changelog-payload", { text: JSON.stringify(e.payload, null, 2) });
341
+ pre.hidden = true;
342
+ toggle.addEventListener("click", () => {
343
+ pre.hidden = !pre.hidden;
344
+ toggle.textContent = pre.hidden ? "▸ payload" : "▾ payload";
345
+ });
346
+ entry.append(toggle, pre);
347
+ }
348
+
349
+ if (e.taskId) {
350
+ const open = el("button", "changelog-open-task", { type: "button", text: "open task →" });
351
+ open.addEventListener("click", () => {
352
+ // Switch to Tasks tab, then open the task view.
353
+ if (window.OpenKanTabs) window.OpenKanTabs.activate("tasks");
354
+ // Defer slightly so the task view is visible before we mount it.
355
+ setTimeout(() => window.OpenKanTaskView?.open(e.taskId), 30);
356
+ });
357
+ entry.append(open);
358
+ }
359
+ return entry;
360
+ }
361
+
362
+ async function loadSummary() {
363
+ if (!state) return;
364
+ try {
365
+ const data = await api("GET", "/api/changelog/summary?days=30");
366
+ const byKind = data?.byKind || {};
367
+ // Convert byKind map (real kind names) into our 5 buckets for the bars.
368
+ const buckets = {};
369
+ for (const [k, v] of Object.entries(byKind)) {
370
+ const b = kindToBucket(k);
371
+ buckets[b] = (buckets[b] || 0) + v;
372
+ }
373
+ const total = Object.values(byKind).reduce((a, b) => a + b, 0);
374
+ const fake = [];
375
+ const ordered = ["task", "agent", "settings", "git", "other"]
376
+ .map((k) => ({ k, n: buckets[k] || 0 }))
377
+ .filter((x) => x.n > 0)
378
+ .sort((a, b) => b.n - a.n);
379
+ const max = ordered.reduce((m, x) => Math.max(m, x.n), 0) || 1;
380
+ const wrap = el("div", "changelog-summary");
381
+ wrap.append(el("div", "changelog-summary-title", { text: `Last 30 days — ${total} event${total === 1 ? "" : "s"}` }));
382
+ if (total === 0) {
383
+ wrap.append(el("div", "changelog-empty", { text: "No events recorded yet." }));
384
+ } else {
385
+ const bars = el("div", "changelog-summary-bars");
386
+ for (const { k, n } of ordered) {
387
+ const row = el("div", "changelog-summary-row");
388
+ row.append(
389
+ el("span", "bar-label", { text: k }),
390
+ (() => {
391
+ const track = el("div", "bar-track");
392
+ const fill = el("div", "bar-fill");
393
+ fill.style.width = `${(n / max) * 100}%`;
394
+ track.append(fill);
395
+ return track;
396
+ })(),
397
+ el("span", "bar-count", { text: String(n) }),
398
+ );
399
+ bars.append(row);
400
+ }
401
+ wrap.append(bars);
402
+ }
403
+ state.summaryEl.replaceWith(wrap);
404
+ state.summaryEl = wrap;
405
+ } catch {
406
+ // Fallback: try to derive from the events we already loaded.
407
+ const wrap = buildSummary(state.events);
408
+ state.summaryEl.replaceWith(wrap);
409
+ state.summaryEl = wrap;
410
+ }
411
+ }
412
+
413
+ function rebuildTimeline() {
414
+ state.rootEl.innerHTML = "";
415
+ state.rootEl.append(buildViewHero());
416
+ state.summaryEl = el("div", "changelog-summary");
417
+ state.rootEl.append(state.summaryEl);
418
+ state.rootEl.append(buildFilterRow());
419
+ state.timeline = el("div", "changelog-timeline");
420
+ state.rootEl.append(state.timeline);
421
+ // Track the loading message in module-scope state so a successful
422
+ // `loadMore()` (or the next `resetAndReload()`) can find and remove it.
423
+ // Previously this element was orphaned at the bottom of rootEl because
424
+ // `loadMore()` only cleaned up the timeline-scoped loadingMsg.
425
+ if (!state.loadingMsg || !state.loadingMsg.parentNode) {
426
+ state.loadingMsg = el("div", "changelog-empty", { text: "Loading…" });
427
+ }
428
+ state.rootEl.append(state.loadingMsg);
429
+ }
430
+
431
+ function mount(rootEl) {
432
+ if (!rootEl) throw new Error("changelog-view: rootEl is required");
433
+ unmount();
434
+ state = {
435
+ rootEl,
436
+ events: [],
437
+ offset: 0,
438
+ hasMore: true,
439
+ loading: false,
440
+ filter: { kind: "all", from: "", to: "", completedOnly: false },
441
+ timeline: null,
442
+ summaryEl: null,
443
+ loadMoreBtn: null,
444
+ loadingMsg: null,
445
+ };
446
+ rebuildTimeline();
447
+ loadSummary();
448
+ loadMore({ reset: true });
449
+ }
450
+
451
+ function unmount() {
452
+ state = null;
453
+ }
454
+
455
+ window.OpenKanChangelog = { mount, unmount };
456
+ })();