@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.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- 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
|
+
})();
|