@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,381 @@
1
+ // OpenKan — command palette (M13).
2
+ // window.OpenKanCommandPalette = {
3
+ // open(), close(), isOpen(),
4
+ // registerAction(action), // returns unregister
5
+ // registerTaskProvider(fn) // returns unregister
6
+ // }
7
+ //
8
+ // Cmd/Ctrl+K opens a centered modal with a fuzzy-search input. The fuzzy
9
+ // match is a simple case-insensitive substring score (spec'd below) — not a
10
+ // true edit-distance fuzzy match.
11
+ //
12
+ // Result sources:
13
+ // 1. Actions: registered via registerAction({ id, label, hint, run, keys? }).
14
+ // Shown when the query is empty OR starts with ">".
15
+ // 2. Tasks: provided on demand by app.js via registerTaskProvider(fn). Shown
16
+ // when the query doesn't match "@" or ">" prefixes and the query matches
17
+ // a task's title/description/tags.
18
+ //
19
+ // Keyboard:
20
+ // ↑ / ↓ (or Ctrl+P / Ctrl+N) move highlight
21
+ // Enter run the highlighted result
22
+ // Esc close (handled by keyboard.js Esc priority)
23
+
24
+ (() => {
25
+ "use strict";
26
+
27
+ /** @type {Array<{id:string,label:string,hint?:string,keys?:string,run:Function,score?:number}>} */
28
+ const actions = [];
29
+ /** @type {Set<(query:string)=>Array<{id:string,title:string,subtitle?:string,run:Function}>>} */
30
+ const taskProviders = new Set();
31
+
32
+ /** @type {HTMLDivElement|null} */
33
+ let root = null;
34
+ /** @type {HTMLInputElement|null} */
35
+ let input = null;
36
+ /** @type {HTMLUListElement|null} */
37
+ let list = null;
38
+ /** @type {HTMLDivElement|null} */
39
+ let emptyEl = null;
40
+ /** @type {number} index of the highlighted result, or -1 */
41
+ let highlight = -1;
42
+ /** @type {Array<{label:string,hint?:string,run:Function}>} current rendered results */
43
+ let current = [];
44
+ /** @type {Element|null} previously focused element (restored on close) */
45
+ let prevFocus = null;
46
+
47
+ function el(tag, cls, props = {}) {
48
+ const e = document.createElement(tag);
49
+ if (cls) e.className = cls;
50
+ for (const [k, v] of Object.entries(props)) {
51
+ if (k === "text") e.textContent = v;
52
+ else if (k === "html") e.innerHTML = v;
53
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
54
+ else e.setAttribute(k, v);
55
+ }
56
+ return e;
57
+ }
58
+
59
+ // ─── Fuzzy match — case-insensitive substring scoring ────────────────────
60
+ // score = startsWith ? 100 : includes ? 50 : 0
61
+ // Order by score desc, then alphabetical asc for ties.
62
+ function fuzzyScore(haystack, needle) {
63
+ if (!needle) return 1; // empty query matches everything at base score
64
+ if (!haystack) return 0;
65
+ const h = String(haystack).toLowerCase();
66
+ const n = String(needle).toLowerCase();
67
+ if (!n) return 1;
68
+ if (h.startsWith(n)) return 100;
69
+ if (h.includes(n)) return 50;
70
+ return 0;
71
+ }
72
+
73
+ // Build score records for actions, sorted by (score desc, label asc).
74
+ function rankActions(query) {
75
+ const q = (query || "").replace(/^>\s*/, "");
76
+ const out = [];
77
+ for (const a of actions) {
78
+ const score = Math.max(
79
+ fuzzyScore(a.label, q),
80
+ fuzzyScore(a.id, q),
81
+ fuzzyScore(a.hint || "", q),
82
+ );
83
+ if (score <= 0 && q) continue;
84
+ out.push({ ...a, score: q ? score : 1 });
85
+ }
86
+ out.sort((a, b) => (b.score - a.score) || a.label.localeCompare(b.label));
87
+ return out;
88
+ }
89
+
90
+ function rankTasks(query) {
91
+ const q = (query || "").replace(/^@\s*/, "");
92
+ if (!q) return [];
93
+ /** @type {Array<{id:string,title:string,subtitle?:string,run:Function,score:number}>} */
94
+ const out = [];
95
+ for (const provider of taskProviders) {
96
+ let list = [];
97
+ try { list = provider(q) || []; } catch { list = []; }
98
+ for (const t of list) {
99
+ if (!t) continue;
100
+ const titleScore = fuzzyScore(t.title || "", q);
101
+ const descScore = fuzzyScore(t.subtitle || "", q);
102
+ const score = Math.max(titleScore, descScore);
103
+ if (score <= 0) continue;
104
+ out.push({ ...t, score });
105
+ }
106
+ }
107
+ out.sort((a, b) => (b.score - a.score) || (a.title || "").localeCompare(b.title || ""));
108
+ return out;
109
+ }
110
+
111
+ function isOpen() { return !!root && !root.hidden; }
112
+
113
+ function open() {
114
+ if (!root) build();
115
+ if (!root.hidden) return;
116
+ prevFocus = document.activeElement instanceof Element ? document.activeElement : null;
117
+ root.hidden = false;
118
+ input.value = "";
119
+ document.body.classList.add("palette-open");
120
+ setTimeout(() => {
121
+ try { input.focus({ preventScroll: true }); } catch { input.focus(); }
122
+ render("");
123
+ }, 0);
124
+ }
125
+
126
+ function close() {
127
+ if (!root || root.hidden) return;
128
+ root.hidden = true;
129
+ document.body.classList.remove("palette-open");
130
+ if (prevFocus && document.contains(prevFocus)) {
131
+ try { prevFocus.focus({ preventScroll: true }); } catch {}
132
+ }
133
+ prevFocus = null;
134
+ }
135
+
136
+ function render(query) {
137
+ if (!list) return;
138
+ list.innerHTML = "";
139
+ const trimmed = (query || "").trim();
140
+ // Mode selection:
141
+ // - empty / ">" prefix → actions only
142
+ // - "@" prefix → tasks only
143
+ // - any other query → actions then tasks (ranked independently,
144
+ // then concatenated — matches the spec's "any
145
+ // query that matches a task title" while still
146
+ // letting users discover actions by name).
147
+ const isActionMode = trimmed === "" || trimmed.startsWith(">");
148
+ const isTaskMode = !isActionMode && trimmed.startsWith("@");
149
+ const ranked = isActionMode
150
+ ? rankActions(query).map((r) => ({ ...r, kind: "action" }))
151
+ : isTaskMode
152
+ ? rankTasks(query).map((r) => ({ ...r, kind: "task" }))
153
+ : [
154
+ ...rankActions(query).map((r) => ({ ...r, kind: "action" })),
155
+ ...rankTasks(query).map((r) => ({ ...r, kind: "task" })),
156
+ ];
157
+ current = ranked.map((r) => ({
158
+ label: r.label || r.title,
159
+ hint: r.hint || r.subtitle,
160
+ run: r.run,
161
+ kind: r.kind,
162
+ id: r.id || r.label || r.title,
163
+ }));
164
+ highlight = current.length > 0 ? 0 : -1;
165
+
166
+ if (current.length === 0) {
167
+ emptyEl.textContent = trimmed
168
+ ? "No matching actions or tasks"
169
+ : "Type to search actions and tasks";
170
+ emptyEl.hidden = false;
171
+ list.hidden = true;
172
+ return;
173
+ }
174
+ emptyEl.hidden = true;
175
+ list.hidden = false;
176
+ const MAX_RENDER = 50;
177
+ for (let i = 0; i < Math.min(current.length, MAX_RENDER); i++) {
178
+ const r = current[i];
179
+ const li = el("li", "palette-item", {
180
+ role: "option",
181
+ "data-idx": String(i),
182
+ "aria-selected": i === highlight ? "true" : "false",
183
+ id: `palette-item-${i}`,
184
+ });
185
+ li.append(el("span", "palette-item-label", { text: r.label }));
186
+ if (r.hint) li.append(el("span", "palette-item-hint", { text: r.hint }));
187
+ if (r.kind === "task") li.append(el("span", "palette-item-kind", { text: "Task" }));
188
+ li.addEventListener("click", () => runAt(i));
189
+ li.addEventListener("mousemove", () => setHighlight(i));
190
+ if (i === highlight) li.classList.add("highlighted");
191
+ list.append(li);
192
+ }
193
+ if (current.length > MAX_RENDER) {
194
+ list.append(el("li", "palette-overflow", {
195
+ text: `${current.length - MAX_RENDER} more — refine your query`,
196
+ }));
197
+ }
198
+ setActiveDescendant();
199
+ }
200
+
201
+ function setHighlight(i) {
202
+ if (!list) return;
203
+ if (i < 0 || i >= current.length) return;
204
+ if (highlight === i) return;
205
+ const prev = list.querySelector(`[data-idx="${highlight}"]`);
206
+ if (prev) {
207
+ prev.classList.remove("highlighted");
208
+ prev.setAttribute("aria-selected", "false");
209
+ }
210
+ highlight = i;
211
+ const next = list.querySelector(`[data-idx="${i}"]`);
212
+ if (next) {
213
+ next.classList.add("highlighted");
214
+ next.setAttribute("aria-selected", "true");
215
+ // Keep highlighted item in view.
216
+ try { next.scrollIntoView({ block: "nearest" }); } catch {}
217
+ }
218
+ setActiveDescendant();
219
+ }
220
+
221
+ function setActiveDescendant() {
222
+ if (!input || !list) return;
223
+ if (highlight < 0) {
224
+ input.setAttribute("aria-activedescendant", "");
225
+ } else {
226
+ input.setAttribute("aria-activedescendant", `palette-item-${highlight}`);
227
+ }
228
+ }
229
+
230
+ function runAt(i) {
231
+ const r = current[i];
232
+ if (!r) return;
233
+ close();
234
+ try { r.run(); } catch (e) {
235
+ console.error("[palette] action threw:", e);
236
+ }
237
+ }
238
+
239
+ // ─── Input + keyboard wiring ─────────────────────────────────────────────
240
+ function onKeyDown(e) {
241
+ if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n") || (e.ctrlKey && e.key === "N")) {
242
+ e.preventDefault();
243
+ setHighlight(highlight + 1 >= current.length ? 0 : highlight + 1);
244
+ return;
245
+ }
246
+ if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p") || (e.ctrlKey && e.key === "P")) {
247
+ e.preventDefault();
248
+ setHighlight(highlight - 1 < 0 ? current.length - 1 : highlight - 1);
249
+ return;
250
+ }
251
+ if (e.key === "Enter") {
252
+ e.preventDefault();
253
+ if (highlight >= 0) runAt(highlight);
254
+ return;
255
+ }
256
+ if (e.key === "Home") {
257
+ e.preventDefault();
258
+ setHighlight(0);
259
+ return;
260
+ }
261
+ if (e.key === "End") {
262
+ e.preventDefault();
263
+ setHighlight(current.length - 1);
264
+ return;
265
+ }
266
+ }
267
+
268
+ function onInput() {
269
+ render(input.value);
270
+ }
271
+
272
+ // ─── Build + boot ────────────────────────────────────────────────────────
273
+ function build() {
274
+ root = el("div", "command-palette", {
275
+ id: "command-palette",
276
+ role: "dialog",
277
+ "aria-modal": "true",
278
+ "aria-label": "Command palette",
279
+ hidden: true,
280
+ });
281
+ root.tabIndex = -1;
282
+
283
+ const dialog = el("div", "command-palette-dialog");
284
+ const searchWrap = el("div", "command-palette-search");
285
+ input = el("input", null, {
286
+ type: "text",
287
+ autocomplete: "off",
288
+ spellcheck: "false",
289
+ placeholder: "Type a command or search…",
290
+ "aria-label": "Command palette search",
291
+ "aria-controls": "command-palette-list",
292
+ "aria-autocomplete": "list",
293
+ });
294
+ input.setAttribute("role", "combobox");
295
+ input.id = "command-palette-input";
296
+ searchWrap.append(input);
297
+ dialog.append(searchWrap);
298
+
299
+ list = el("ul", "command-palette-list", {
300
+ id: "command-palette-list",
301
+ role: "listbox",
302
+ "aria-labelledby": "command-palette-input",
303
+ });
304
+ dialog.append(list);
305
+
306
+ emptyEl = el("div", "command-palette-empty", {
307
+ text: "Type to search actions and tasks",
308
+ });
309
+ dialog.append(emptyEl);
310
+
311
+ const footer = el("div", "command-palette-footer");
312
+ footer.append(
313
+ el("span", null, { text: "↑/↓ navigate" }),
314
+ el("span", "dot", { "aria-hidden": "true", text: "·" }),
315
+ el("span", null, { text: "↵ select" }),
316
+ el("span", "dot", { "aria-hidden": "true", text: "·" }),
317
+ el("span", null, { text: "esc close" }),
318
+ );
319
+ dialog.append(footer);
320
+
321
+ root.append(dialog);
322
+
323
+ // Backdrop closes when clicking outside the dialog.
324
+ root.addEventListener("click", (e) => {
325
+ if (e.target === root) close();
326
+ });
327
+
328
+ input.addEventListener("input", onInput);
329
+ input.addEventListener("keydown", onKeyDown);
330
+
331
+ document.body.appendChild(root);
332
+ }
333
+
334
+ function init() {
335
+ if (!root) build();
336
+ // Wire to the keyboard module — palette.open / palette.close / palette.isOpen.
337
+ if (window.OpenKanKeyboard) {
338
+ window.OpenKanKeyboard.on("palette.open", () => open());
339
+ window.OpenKanKeyboard.on("palette.isOpen", (cb) => {
340
+ if (typeof cb === "function") cb(isOpen());
341
+ });
342
+ // The keyboard module asks isOpen via emit("palette.isOpen", cb).
343
+ // For Esc handling, it calls emit("palette.close") when palette is open.
344
+ window.OpenKanKeyboard.on("palette.close", () => close());
345
+ }
346
+ }
347
+
348
+ // ─── Public API ──────────────────────────────────────────────────────────
349
+ function registerAction(action) {
350
+ if (!action || !action.id || typeof action.run !== "function") return () => {};
351
+ const item = {
352
+ id: String(action.id),
353
+ label: String(action.label || action.id),
354
+ hint: action.hint ? String(action.hint) : undefined,
355
+ keys: action.keys ? String(action.keys) : undefined,
356
+ run: action.run,
357
+ };
358
+ actions.push(item);
359
+ return () => {
360
+ const idx = actions.indexOf(item);
361
+ if (idx >= 0) actions.splice(idx, 1);
362
+ };
363
+ }
364
+
365
+ function registerTaskProvider(fn) {
366
+ if (typeof fn !== "function") return () => {};
367
+ taskProviders.add(fn);
368
+ return () => taskProviders.delete(fn);
369
+ }
370
+
371
+ window.OpenKanCommandPalette = {
372
+ open, close, isOpen,
373
+ registerAction, registerTaskProvider,
374
+ };
375
+
376
+ if (document.readyState === "loading") {
377
+ document.addEventListener("DOMContentLoaded", init, { once: true });
378
+ } else {
379
+ init();
380
+ }
381
+ })();
@@ -0,0 +1,317 @@
1
+ // OpenKan — Contributors tab (M10).
2
+ // window.OpenKanContributors = { mount(rootEl), unmount() }
3
+ //
4
+ // Pulls from /api/contributors, /api/tasks-index, /api/changelog.
5
+ // Each row is expandable; inside the row we list attributed tasks + last
6
+ // 10 events by that author.
7
+
8
+ (() => {
9
+ "use strict";
10
+
11
+ const { api } = window.OpenKanAPI;
12
+
13
+ // State per-mount
14
+ let state = null;
15
+
16
+ function el(tag, cls, props = {}) {
17
+ const e = document.createElement(tag);
18
+ if (cls) e.className = cls;
19
+ for (const [k, v] of Object.entries(props)) {
20
+ if (k === "text") e.textContent = v;
21
+ else if (k === "html") e.innerHTML = v;
22
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
23
+ else e.setAttribute(k, v);
24
+ }
25
+ return e;
26
+ }
27
+
28
+ function initialsFor(name) {
29
+ if (!name) return "?";
30
+ const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
31
+ if (parts.length === 0) return "?";
32
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
33
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
34
+ }
35
+
36
+ function colorFor(seed) {
37
+ let h = 0;
38
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
39
+ const hue = Math.abs(h) % 360;
40
+ return `hsl(${hue} 60% 45%)`;
41
+ }
42
+
43
+ function relativeTime(iso) {
44
+ if (!iso) return "";
45
+ const t = new Date(iso).getTime();
46
+ if (isNaN(t)) return "";
47
+ const delta = Date.now() - t;
48
+ const sec = Math.floor(delta / 1000);
49
+ if (sec < 60) return `${sec}s ago`;
50
+ const min = Math.floor(sec / 60);
51
+ if (min < 60) return `${min}m ago`;
52
+ const hr = Math.floor(min / 60);
53
+ if (hr < 24) return `${hr}h ago`;
54
+ const day = Math.floor(hr / 24);
55
+ if (day < 30) return `${day}d ago`;
56
+ return new Date(iso).toLocaleDateString();
57
+ }
58
+
59
+ function buildHeader() {
60
+ const header = el("div", "contributors-header");
61
+
62
+ header.append(el("span", "filter-bar-label", { text: "Sort" }));
63
+ const sort = el("select", null, { "aria-label": "Sort contributors" });
64
+ for (const opt of [
65
+ { v: "commits", t: "most commits" },
66
+ { v: "recent", t: "most recent" },
67
+ { v: "name", t: "by name" },
68
+ ]) {
69
+ const o = el("option", null, { value: opt.v, text: opt.t });
70
+ if (state.sort === opt.v) o.selected = true;
71
+ sort.append(o);
72
+ }
73
+ sort.addEventListener("change", () => {
74
+ state.sort = sort.value;
75
+ renderList();
76
+ });
77
+ header.append(sort);
78
+
79
+ const me = state.contributors.find((c) => c.isCurrentUser || c.currentUser);
80
+ if (me) {
81
+ header.append(el("span", "current-user-badge", {
82
+ text: `@me is ${me.name || me.email || "current user"}`,
83
+ }));
84
+ }
85
+
86
+ return header;
87
+ }
88
+
89
+ function buildViewHero() {
90
+ const hero = el("header", "view-hero");
91
+ const copy = el("div");
92
+ copy.append(
93
+ el("span", "view-eyebrow", { text: "Project collaboration" }),
94
+ el("h2", null, { text: "Contributors" }),
95
+ el("p", null, { text: "See ownership, recent activity, and the tasks each contributor touches." }),
96
+ );
97
+ hero.append(copy);
98
+ return hero;
99
+ }
100
+
101
+ function sortContributors(list) {
102
+ const sorted = list.slice();
103
+ if (state.sort === "commits") {
104
+ sorted.sort((a, b) => (b.commits || 0) - (a.commits || 0));
105
+ } else if (state.sort === "recent") {
106
+ sorted.sort((a, b) => String(b.lastSeen || "").localeCompare(String(a.lastSeen || "")));
107
+ } else {
108
+ sorted.sort((a, b) => String(a.name || a.email || "").localeCompare(String(b.name || b.email || "")));
109
+ }
110
+ return sorted;
111
+ }
112
+
113
+ function renderList() {
114
+ if (!state) return;
115
+ state.listEl.innerHTML = "";
116
+ const sorted = sortContributors(state.contributors);
117
+ if (sorted.length === 0) {
118
+ state.listEl.append(el("div", "contributors-empty", { text: "No contributors yet (no git commits found)." }));
119
+ return;
120
+ }
121
+ for (const c of sorted) {
122
+ state.listEl.append(renderRow(c));
123
+ }
124
+ }
125
+
126
+ function renderRow(c) {
127
+ const row = el("article", "contributor-row" + (c.isCurrentUser || c.currentUser ? " is-me" : ""));
128
+ row.dataset.email = c.email || "";
129
+
130
+ // Summary line
131
+ const summary = el("div", "contributor-summary");
132
+ const av = el("div", "avatar-circle avatar-lg");
133
+ av.style.setProperty("--avatar-bg", colorFor(c.email || c.name || ""));
134
+ av.textContent = initialsFor(c.name || c.email);
135
+ summary.append(av);
136
+
137
+ const meta = el("div", "contributor-meta");
138
+ const nameRow = el("div", "contributor-name");
139
+ nameRow.append(c.name || c.email || "(unknown)");
140
+ if (c.isCurrentUser || c.currentUser) {
141
+ nameRow.append(el("span", "tag-chip", {
142
+ text: "@me",
143
+ title: "current git user",
144
+ }));
145
+ }
146
+ meta.append(nameRow);
147
+ if (c.email && c.name) meta.append(el("div", "contributor-email", { text: c.email }));
148
+
149
+ const stats = el("div", "contributor-stats");
150
+ stats.append(
151
+ el("span", null, { text: `${c.commits ?? 0} commit${c.commits === 1 ? "" : "s"}` }),
152
+ el("span", null, { text: `last seen ${relativeTime(c.lastSeen)}` }),
153
+ el("span", null, { text: `${c.activeTasks ?? 0} task${c.activeTasks === 1 ? "" : "s"} active` }),
154
+ );
155
+ meta.append(stats);
156
+
157
+ summary.append(meta);
158
+
159
+ const expand = el("button", "contributor-expand-btn", { type: "button", text: "show tasks ▾" });
160
+ summary.append(expand);
161
+
162
+ row.append(summary);
163
+
164
+ // Detail (expanded on click)
165
+ const detail = el("div", "contributor-detail");
166
+ row.append(detail);
167
+
168
+ expand.addEventListener("click", async () => {
169
+ const isOpen = row.classList.toggle("expanded");
170
+ expand.textContent = isOpen ? "hide ▴" : "show tasks ▾";
171
+ if (isOpen && !detail.dataset.loaded) {
172
+ detail.dataset.loaded = "1";
173
+ await populateDetail(detail, c);
174
+ }
175
+ });
176
+
177
+ return row;
178
+ }
179
+
180
+ async function populateDetail(detail, c) {
181
+ detail.innerHTML = '<div class="contributors-empty">Loading…</div>';
182
+
183
+ // Tasks attributed to this contributor
184
+ let tasks = [];
185
+ try {
186
+ const idx = await api("GET", "/api/tasks-index");
187
+ const list = Array.isArray(idx?.tasks) ? idx.tasks : Array.isArray(idx) ? idx : [];
188
+ tasks = list.filter((t) => Array.isArray(t.contributors) && t.contributors.some((cc) => sameContributor(cc, c)));
189
+ } catch {
190
+ tasks = [];
191
+ }
192
+
193
+ const tasksWrap = el("div");
194
+ tasksWrap.append(el("h4", null, { text: `Tasks (${tasks.length})` }));
195
+ if (tasks.length === 0) {
196
+ tasksWrap.append(el("div", "contributors-empty", { text: "No attributed tasks." }));
197
+ } else {
198
+ const list = el("div", "contributor-tasks");
199
+ for (const t of tasks) {
200
+ const item = el("div", "contributor-task", { "data-id": t.id });
201
+ const dot = el("span", `task-status-dot state-${t.state ?? t.status ?? "idle"}`);
202
+ item.append(dot);
203
+ item.append(el("span", "task-title-text", { text: t.title || "(untitled)" }));
204
+ item.append(el("span", "task-column-text", { text: t.column || "" }));
205
+ item.addEventListener("click", () => {
206
+ if (window.OpenKanTabs) window.OpenKanTabs.activate("tasks");
207
+ setTimeout(() => window.OpenKanTaskView?.open(t.id), 30);
208
+ });
209
+ list.append(item);
210
+ }
211
+ tasksWrap.append(list);
212
+ }
213
+ detail.append(tasksWrap);
214
+
215
+ // Mini-timeline of last 10 events
216
+ let events = [];
217
+ try {
218
+ const author = c.email || c.name;
219
+ const params = new URLSearchParams();
220
+ params.set("author", author);
221
+ params.set("limit", "10");
222
+ const data = await api("GET", `/api/changelog?${params.toString()}`);
223
+ events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : [];
224
+ } catch {
225
+ events = [];
226
+ }
227
+
228
+ const tlWrap = el("div");
229
+ tlWrap.append(el("h4", null, { text: `Recent activity (${events.length})` }));
230
+ if (events.length === 0) {
231
+ tlWrap.append(el("div", "contributors-empty", { text: "No recent events." }));
232
+ } else {
233
+ const tl = el("div", "contributor-timeline");
234
+ for (const e of events) {
235
+ const row = el("div", "contributor-timeline-entry");
236
+ row.append(
237
+ el("span", "entry-time", { text: relativeTime(e.ts), title: e.ts }),
238
+ el("span", "entry-summary", { text: `${e.kind} — ${e.summary || ""}` }),
239
+ );
240
+ tl.append(row);
241
+ }
242
+ tlWrap.append(tl);
243
+ }
244
+ detail.append(tlWrap);
245
+
246
+ detail.innerHTML = "";
247
+ detail.append(tasksWrap, tlWrap);
248
+ }
249
+
250
+ function sameContributor(cc, c) {
251
+ if (!cc || !c) return false;
252
+ if (c.email && cc.email && String(cc.email).toLowerCase() === String(c.email).toLowerCase()) return true;
253
+ if (c.name && cc.name && cc.name === c.name) return true;
254
+ return false;
255
+ }
256
+
257
+ async function mount(rootEl) {
258
+ if (!rootEl) throw new Error("contributors-view: rootEl is required");
259
+ unmount();
260
+ state = {
261
+ rootEl,
262
+ contributors: [],
263
+ sort: "commits",
264
+ listEl: null,
265
+ };
266
+
267
+ rootEl.innerHTML = '<div class="contributors-empty">Loading…</div>';
268
+
269
+ let contributors = [];
270
+ let currentUser = null;
271
+ try {
272
+ const data = await api("GET", "/api/contributors");
273
+ contributors = Array.isArray(data?.contributors) ? data.contributors : Array.isArray(data) ? data : [];
274
+ currentUser = data?.currentUser || null;
275
+ } catch (err) {
276
+ rootEl.innerHTML = "";
277
+ rootEl.append(el("div", "contributors-empty", {
278
+ text: `Contributors unavailable: ${err.message}`,
279
+ }));
280
+ return;
281
+ }
282
+
283
+ if (currentUser) {
284
+ const email = (currentUser.email || "").toLowerCase();
285
+ for (const c of contributors) {
286
+ if (email && (c.email || "").toLowerCase() === email) {
287
+ c.isCurrentUser = true;
288
+ }
289
+ }
290
+ // If we have a current user but no match, surface a synthetic row.
291
+ if (!contributors.some((c) => c.isCurrentUser)) {
292
+ contributors.unshift({
293
+ name: currentUser.name || currentUser.email || "you",
294
+ email: currentUser.email || "",
295
+ commits: 0,
296
+ lastSeen: null,
297
+ activeTasks: 0,
298
+ isCurrentUser: true,
299
+ });
300
+ }
301
+ }
302
+
303
+ state.contributors = contributors;
304
+ rootEl.innerHTML = "";
305
+ rootEl.append(buildViewHero());
306
+ rootEl.append(buildHeader());
307
+ state.listEl = el("div", "contributors-list");
308
+ rootEl.append(state.listEl);
309
+ renderList();
310
+ }
311
+
312
+ function unmount() {
313
+ state = null;
314
+ }
315
+
316
+ window.OpenKanContributors = { mount, unmount };
317
+ })();