@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,4251 @@
1
+ // OpenKan — board view (M0–M1 + M7 status indicator + M10/M11 dashboard +
2
+ // M13 keyboard nav, command palette, ARIA, cross-tab integration).
3
+ // 5 columns, SSE live updates with polling fallback, HTML5 drag-and-drop,
4
+ // "New Task" modal, per-task action menu, dashboard tab router, sort/filter,
5
+ // archive toggle, saved filters via localStorage, contributors filter row,
6
+ // drag-and-drop v1.1 (ghost card, drop indicator, multi-select, shake).
7
+
8
+ (() => {
9
+ "use strict";
10
+
11
+ const { api, on, onStatus } = window.OpenKanAPI;
12
+
13
+ const COLUMNS = [
14
+ { id: "backlog", title: "Backlog" },
15
+ { id: "todo", title: "To Do" },
16
+ { id: "doing", title: "In Progress" },
17
+ { id: "review", title: "Review" },
18
+ { id: "done", title: "Done" },
19
+ ];
20
+
21
+ // Columns in their natural progression — used by "Move to next column".
22
+ const COLUMN_ORDER = COLUMNS.map((c) => c.id);
23
+
24
+ // Categories that the server uses. Also the set of tags that double as a
25
+ // category badge on cards.
26
+ const CATEGORIES = new Set([
27
+ "frontend", "backend", "infra", "docs",
28
+ "test", "design", "data", "security", "task",
29
+ ]);
30
+
31
+ // Priority is explicit text, not a decorative glyph, so it remains
32
+ // scannable in every theme and does not compete with task titles.
33
+ const PRIORITY_META = {
34
+ urgent: { code: "P0", label: "Urgent", className: "priority-urgent", rank: 0 },
35
+ high: { code: "P1", label: "High", className: "priority-high", rank: 1 },
36
+ normal: { code: "P2", label: "Normal", className: "priority-normal", rank: 2 },
37
+ low: { code: "P3", label: "Low", className: "priority-low", rank: 3 },
38
+ };
39
+
40
+ const EFFORT_RANK = { xl: 0, l: 1, m: 2, s: 3, xs: 4 };
41
+
42
+ // Sort options shown in the popover. Keep `value` aligned with the legacy
43
+ // <select id="sort-select"> values so the URL hash stays compatible.
44
+ const SORT_OPTIONS = [
45
+ { value: "newest", label: "Newest first", desc: "Sort by createdAt, newest first." },
46
+ { value: "oldest", label: "Oldest first", desc: "Sort by createdAt, oldest first." },
47
+ { value: "priority", label: "Priority (urgent→low)", desc: "Urgent first, then High, Normal, Low." },
48
+ { value: "effort", label: "Effort (xl→xs)", desc: "Largest effort first, smallest last." },
49
+ { value: "activity", label: "Last activity", desc: "Most recently touched task first." },
50
+ ];
51
+ const SORT_VALUES = SORT_OPTIONS.map((o) => o.value);
52
+ const SORT_LABEL = Object.fromEntries(SORT_OPTIONS.map((o) => [o.value, o.label]));
53
+
54
+ /** @type {Map<string, any>} */
55
+ const tasks = new Map();
56
+ /** Map of contributor email → contributor record (from /api/contributors). */
57
+ const contributors = new Map();
58
+ /** Current user (best-effort from /api/contributors). */
59
+ let currentUser = null;
60
+
61
+ // ---------- Filter / view state ----------
62
+ // category: "all" or a category id (must also be present in t.tags).
63
+ // tags: array of tag names (all must be present in t.tags, AND-order).
64
+ // contributor: "all" | "@me" | "<email>"
65
+ // archive: "active" | "archived" | "both"
66
+ // sort: "newest" | "oldest" | "priority" | "effort" | "activity"
67
+ // search: free-text query (titles / descriptions / tags / MDX content).
68
+ // Persisted in window.location.hash as separate params.
69
+ const filter = {
70
+ category: "all",
71
+ tags: [],
72
+ contributor: "all",
73
+ archive: "active",
74
+ sort: "newest",
75
+ search: "",
76
+ };
77
+
78
+ // ---------- Bulk-action state ----------
79
+ /** Set of currently selected task ids (Ctrl/Cmd-click on cards). */
80
+ const selectedIds = new Set();
81
+ /** Set of task ids that match the active search query. */
82
+ let searchMatchIds = null; // null = no search active
83
+ let searchDebounce = null;
84
+ let searchSeq = 0;
85
+
86
+ // ---------- localStorage keys ----------
87
+ const SAVED_FILTERS_KEY = "openkan:saved-filters";
88
+ const SAVED_FILTERS_MAX = 5;
89
+
90
+ function readHashFilter() {
91
+ const raw = (window.location.hash || "").replace(/^#/, "");
92
+ if (!raw) return null;
93
+ const params = new URLSearchParams(raw);
94
+ return {
95
+ tab: params.get("tab") || "tasks",
96
+ doc: params.get("doc") || "",
97
+ category: params.get("category") || "all",
98
+ tags: (params.get("tags") || "")
99
+ .split(",")
100
+ .map((t) => t.trim().toLowerCase())
101
+ .filter(Boolean),
102
+ contributor: params.get("contributor") || "all",
103
+ archive: ["active", "archived", "both"].includes(params.get("archive"))
104
+ ? params.get("archive")
105
+ : "active",
106
+ sort: SORT_VALUES.includes(params.get("sort"))
107
+ ? params.get("sort")
108
+ : "newest",
109
+ search: params.get("q") || "",
110
+ };
111
+ }
112
+
113
+ function writeHashFilter() {
114
+ const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
115
+ // Preserve `tab` so the tab router can stay in sync.
116
+ if (!params.get("tab")) params.set("tab", "tasks");
117
+ if (filter.category && filter.category !== "all") params.set("category", filter.category);
118
+ else params.delete("category");
119
+ if (filter.tags.length > 0) params.set("tags", filter.tags.join(","));
120
+ else params.delete("tags");
121
+ if (filter.contributor && filter.contributor !== "all") params.set("contributor", filter.contributor);
122
+ else params.delete("contributor");
123
+ if (filter.archive && filter.archive !== "active") params.set("archive", filter.archive);
124
+ else params.delete("archive");
125
+ if (filter.sort && filter.sort !== "newest") params.set("sort", filter.sort);
126
+ else params.delete("sort");
127
+ if (filter.search) params.set("q", filter.search);
128
+ else params.delete("q");
129
+ const next = `#${params.toString()}`;
130
+ if (window.location.hash !== next) {
131
+ const url = window.location.pathname + window.location.search + next;
132
+ window.history.replaceState(null, "", url);
133
+ }
134
+ }
135
+
136
+ function applyFilterToButtons() {
137
+ const bar = document.getElementById("filter-bar");
138
+ if (bar) bar.classList.toggle(
139
+ "empty",
140
+ filter.category === "all" && filter.tags.length === 0 && filter.contributor === "all" && !filter.search,
141
+ );
142
+ // Reflect the archive filter on the body element so CSS can gate the
143
+ // .card-archived display:none rule on `data-archive="active"` — this
144
+ // re-shows archived cards when the user picks Archived or Both.
145
+ try { document.body.setAttribute("data-archive", filter.archive || "active"); } catch {}
146
+ const clear = document.getElementById("filter-clear");
147
+ if (clear) clear.disabled = filter.category === "all" && filter.tags.length === 0 && filter.contributor === "all" && !filter.search;
148
+ for (const btn of document.querySelectorAll("#filter-categories button[data-category]")) {
149
+ const v = btn.getAttribute("data-category");
150
+ const active = filter.category === v;
151
+ btn.classList.toggle("active", active);
152
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
153
+ }
154
+ for (const btn of document.querySelectorAll("#filter-tags button[data-tag]")) {
155
+ const v = btn.getAttribute("data-tag");
156
+ const active = filter.tags.includes(v);
157
+ btn.classList.toggle("active", active);
158
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
159
+ }
160
+ for (const btn of document.querySelectorAll("#filter-contributors button[data-contributor]")) {
161
+ const v = btn.getAttribute("data-contributor");
162
+ const active = filter.contributor === v;
163
+ btn.classList.toggle("active", active);
164
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
165
+ }
166
+ for (const btn of document.querySelectorAll(".archive-toggle button[data-archive]")) {
167
+ const v = btn.getAttribute("data-archive");
168
+ const active = filter.archive === v;
169
+ btn.classList.toggle("active", active);
170
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
171
+ }
172
+ const sortSel = document.getElementById("sort-select");
173
+ if (sortSel) sortSel.value = filter.sort;
174
+ const sortTrigger = document.getElementById("sort-trigger");
175
+ if (sortTrigger) {
176
+ sortTrigger.textContent = SORT_LABEL[filter.sort] || SORT_LABEL.newest;
177
+ sortTrigger.setAttribute("aria-label", `Sort: ${SORT_LABEL[filter.sort] || SORT_LABEL.newest}`);
178
+ }
179
+ // Refresh popover state (highlight the active option).
180
+ const popover = document.getElementById("sort-popover");
181
+ if (popover) {
182
+ for (const opt of popover.querySelectorAll(".sort-option")) {
183
+ const v = opt.getAttribute("data-value");
184
+ const isActive = v === filter.sort;
185
+ opt.classList.toggle("active", isActive);
186
+ opt.setAttribute("aria-selected", isActive ? "true" : "false");
187
+ }
188
+ }
189
+ // Sync the search input without firing its input event.
190
+ if (searchInput && document.activeElement !== searchInput && searchInput.value !== filter.search) {
191
+ searchInput.value = filter.search;
192
+ }
193
+ const activeFilterCount =
194
+ (filter.category !== "all" ? 1 : 0) +
195
+ filter.tags.length +
196
+ (filter.contributor !== "all" ? 1 : 0);
197
+ const count = document.getElementById("filter-count");
198
+ if (count) {
199
+ count.textContent = String(activeFilterCount);
200
+ count.hidden = activeFilterCount === 0;
201
+ }
202
+ }
203
+
204
+ function taskMatchesFilter(t) {
205
+ if (filter.category !== "all") {
206
+ const tags = t.tags || [];
207
+ if (t.category && t.category === filter.category) {
208
+ // ok
209
+ } else if (!tags.includes(filter.category)) {
210
+ return false;
211
+ }
212
+ }
213
+ if (filter.tags.length > 0) {
214
+ const tags = new Set((t.tags || []).map((x) => String(x).toLowerCase()));
215
+ for (const want of filter.tags) {
216
+ if (!tags.has(want)) return false;
217
+ }
218
+ }
219
+ if (filter.contributor !== "all") {
220
+ const list = Array.isArray(t.contributors) ? t.contributors : [];
221
+ if (filter.contributor === "@me") {
222
+ if (!currentUser || !currentUser.email) return false;
223
+ const email = currentUser.email.toLowerCase();
224
+ if (!list.some((c) => (c.email || "").toLowerCase() === email)) return false;
225
+ } else {
226
+ const email = filter.contributor.toLowerCase();
227
+ if (!list.some((c) => (c.email || "").toLowerCase() === email)) return false;
228
+ }
229
+ }
230
+ if (filter.archive === "active" && t.archived) return false;
231
+ if (filter.archive === "archived" && !t.archived) return false;
232
+ // Search: rely on the server-returned `searchMatchIds` set when available,
233
+ // so the count and the per-card visibility agree. Falls back to a local
234
+ // substring check so the board still updates immediately while the
235
+ // debounced server call is in flight (and keeps working if /api/search
236
+ // returns 404 mid-development).
237
+ if (searchMatchIds) {
238
+ if (!searchMatchIds.has(t.id)) return false;
239
+ } else if (filter.search) {
240
+ const q = filter.search.toLowerCase();
241
+ if (!taskTextMatchesQuery(t, q)) return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ // Local substring check used as a fast fallback while the debounced server
247
+ // call is in flight. Mirrors the server-side rule (kanban/search.ts).
248
+ function taskTextMatchesQuery(t, q) {
249
+ if (!q) return true;
250
+ if ((t.title || "").toLowerCase().includes(q)) return true;
251
+ if ((t.description || "").toLowerCase().includes(q)) return true;
252
+ const tags = Array.isArray(t.tags) ? t.tags : [];
253
+ if (tags.some((tag) => String(tag).toLowerCase().includes(q))) return true;
254
+ return false;
255
+ }
256
+
257
+ function sortTasks(list) {
258
+ const cmp = {
259
+ newest: (a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")),
260
+ oldest: (a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")),
261
+ priority: (a, b) => {
262
+ const ra = PRIORITY_META[a.priority]?.rank ?? 99;
263
+ const rb = PRIORITY_META[b.priority]?.rank ?? 99;
264
+ if (ra !== rb) return ra - rb;
265
+ return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
266
+ },
267
+ effort: (a, b) => {
268
+ const ra = EFFORT_RANK[a.effort] ?? 99;
269
+ const rb = EFFORT_RANK[b.effort] ?? 99;
270
+ if (ra !== rb) return ra - rb;
271
+ return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
272
+ },
273
+ activity: (a, b) => String(b.lastActivity || b.updatedAt || "").localeCompare(String(a.lastActivity || a.updatedAt || "")),
274
+ }[filter.sort] || ((a, b) => (a.order ?? 0) - (b.order ?? 0));
275
+ return list.slice().sort(cmp);
276
+ }
277
+
278
+ // ---------- Saved filters (localStorage) ----------
279
+ function readSavedFilters() {
280
+ try {
281
+ const raw = localStorage.getItem(SAVED_FILTERS_KEY);
282
+ if (!raw) return [];
283
+ const parsed = JSON.parse(raw);
284
+ return Array.isArray(parsed) ? parsed : [];
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+ function writeSavedFilters(list) {
290
+ try {
291
+ localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(list.slice(0, SAVED_FILTERS_MAX)));
292
+ } catch {}
293
+ }
294
+ function saveCurrentFilter(name) {
295
+ const trimmed = String(name || "").trim();
296
+ if (!trimmed) return false;
297
+ const list = readSavedFilters();
298
+ const snapshot = {
299
+ name: trimmed,
300
+ ts: new Date().toISOString(),
301
+ category: filter.category,
302
+ tags: filter.tags.slice(),
303
+ contributor: filter.contributor,
304
+ archive: filter.archive,
305
+ sort: filter.sort,
306
+ };
307
+ // Replace if same name exists.
308
+ const idx = list.findIndex((s) => s.name === trimmed);
309
+ if (idx >= 0) list[idx] = snapshot;
310
+ else list.unshift(snapshot);
311
+ writeSavedFilters(list);
312
+ renderSavedFilters();
313
+ return true;
314
+ }
315
+ function deleteSavedFilter(name) {
316
+ const list = readSavedFilters().filter((s) => s.name !== name);
317
+ writeSavedFilters(list);
318
+ renderSavedFilters();
319
+ }
320
+ function applySavedFilter(s) {
321
+ filter.category = s.category || "all";
322
+ filter.tags = Array.isArray(s.tags) ? s.tags.slice() : [];
323
+ filter.contributor = s.contributor || "all";
324
+ filter.archive = s.archive || "active";
325
+ filter.sort = s.sort || "newest";
326
+ writeHashFilter();
327
+ applyFilterToButtons();
328
+ renderBoard();
329
+ }
330
+ function renderSavedFilters() {
331
+ const bar = document.getElementById("saved-filters-bar");
332
+ const list = document.getElementById("saved-filters-list");
333
+ if (!bar || !list) return;
334
+ const items = readSavedFilters();
335
+ list.innerHTML = "";
336
+ if (items.length === 0) {
337
+ bar.hidden = true;
338
+ return;
339
+ }
340
+ bar.hidden = false;
341
+ for (const s of items) {
342
+ const chip = el("span", "saved-filter-chip", { title: `Saved ${s.ts || ""}` });
343
+ chip.append(el("span", null, { text: s.name }));
344
+ const rm = el("button", "chip-remove", { type: "button", text: "×", "aria-label": `Delete filter ${s.name}` });
345
+ rm.addEventListener("click", (e) => {
346
+ e.stopPropagation();
347
+ deleteSavedFilter(s.name);
348
+ });
349
+ chip.append(rm);
350
+ chip.addEventListener("click", () => applySavedFilter(s));
351
+ list.append(chip);
352
+ }
353
+ }
354
+
355
+ // ---------- Sort popover ----------
356
+ // Replaces the plain <select> with a styled popover while keeping the
357
+ // legacy <select id="sort-select"> in the DOM (visually hidden) so the
358
+ // value is still readable from tests, screen-reader-only navigation, and
359
+ // the URL hash serializer. Both code paths funnel through setSort().
360
+
361
+ function setSort(value) {
362
+ const v = SORT_VALUES.includes(value) ? value : "newest";
363
+ if (filter.sort === v) {
364
+ // Still re-apply so the trigger label / popover state stay in sync.
365
+ applyFilterToButtons();
366
+ return;
367
+ }
368
+ filter.sort = v;
369
+ const sortSel = document.getElementById("sort-select");
370
+ if (sortSel) sortSel.value = v;
371
+ writeHashFilter();
372
+ applyFilterToButtons();
373
+ renderBoard();
374
+ }
375
+
376
+ let sortPopoverOpen = false;
377
+ let sortPopoverCleanup = null;
378
+
379
+ function closeSortPopover() {
380
+ const popover = document.getElementById("sort-popover");
381
+ const trigger = document.getElementById("sort-trigger");
382
+ if (popover) popover.hidden = true;
383
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
384
+ sortPopoverOpen = false;
385
+ if (sortPopoverCleanup) { sortPopoverCleanup(); sortPopoverCleanup = null; }
386
+ }
387
+
388
+ function openSortPopover() {
389
+ const popover = document.getElementById("sort-popover");
390
+ const trigger = document.getElementById("sort-trigger");
391
+ if (!popover || !trigger) return;
392
+ popover.hidden = false;
393
+ trigger.setAttribute("aria-expanded", "true");
394
+ sortPopoverOpen = true;
395
+ focusSortOption(filter.sort);
396
+
397
+ // Defer attaching doc-level listeners so the click that opened the
398
+ // popover doesn't immediately close it.
399
+ setTimeout(() => {
400
+ const onDocClick = (e) => {
401
+ const wrap = document.getElementById("sort-select-wrap");
402
+ if (wrap && wrap.contains(e.target)) return;
403
+ closeSortPopover();
404
+ };
405
+ const onKey = (e) => {
406
+ if (e.key === "Escape") {
407
+ e.preventDefault();
408
+ closeSortPopover();
409
+ document.getElementById("sort-trigger")?.focus();
410
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
411
+ e.preventDefault();
412
+ moveSortOption(e.key === "ArrowDown" ? 1 : -1);
413
+ } else if (e.key === "Enter" || e.key === " ") {
414
+ // Only intercept Enter when focus is in the popover.
415
+ if (popover.contains(document.activeElement)) {
416
+ e.preventDefault();
417
+ commitFocusedOption();
418
+ }
419
+ } else if (e.key === "Tab") {
420
+ closeSortPopover();
421
+ }
422
+ };
423
+ document.addEventListener("mousedown", onDocClick, true);
424
+ document.addEventListener("keydown", onKey, true);
425
+ sortPopoverCleanup = () => {
426
+ document.removeEventListener("mousedown", onDocClick, true);
427
+ document.removeEventListener("keydown", onKey, true);
428
+ };
429
+ }, 0);
430
+ }
431
+
432
+ function focusSortOption(value) {
433
+ const popover = document.getElementById("sort-popover");
434
+ if (!popover) return;
435
+ const options = [...popover.querySelectorAll(".sort-option")];
436
+ if (options.length === 0) return;
437
+ let idx = options.findIndex((o) => o.getAttribute("data-value") === value);
438
+ if (idx < 0) idx = 0;
439
+ for (const o of options) o.classList.remove("focused");
440
+ options[idx].classList.add("focused");
441
+ try { options[idx].focus(); } catch {}
442
+ }
443
+
444
+ function moveSortOption(delta) {
445
+ const popover = document.getElementById("sort-popover");
446
+ if (!popover) return;
447
+ const options = [...popover.querySelectorAll(".sort-option")];
448
+ if (options.length === 0) return;
449
+ const active = document.activeElement;
450
+ let idx = options.indexOf(active);
451
+ if (idx < 0) idx = options.findIndex((o) => o.classList.contains("focused"));
452
+ if (idx < 0) idx = 0;
453
+ const next = (idx + delta + options.length) % options.length;
454
+ for (const o of options) o.classList.remove("focused");
455
+ options[next].classList.add("focused");
456
+ try { options[next].focus(); } catch {}
457
+ }
458
+
459
+ function commitFocusedOption() {
460
+ const popover = document.getElementById("sort-popover");
461
+ if (!popover) return;
462
+ const active = document.activeElement;
463
+ const target = (active && active.classList && active.classList.contains("sort-option"))
464
+ ? active
465
+ : popover.querySelector(".sort-option.focused") || popover.querySelector(".sort-option");
466
+ if (!target) return;
467
+ const value = target.getAttribute("data-value");
468
+ if (value) {
469
+ setSort(value);
470
+ closeSortPopover();
471
+ document.getElementById("sort-trigger")?.focus();
472
+ }
473
+ }
474
+
475
+ function buildSortPopover() {
476
+ const wrap = document.getElementById("sort-select-wrap");
477
+ const sortSel = document.getElementById("sort-select");
478
+ if (!wrap || !sortSel) return;
479
+
480
+ // Hide the original <select> visually. Keep it in the DOM so setSort()
481
+ // can keep its .value in sync and so any test/screen-reader code that
482
+ // targets the legacy control still works.
483
+ sortSel.classList.add("visually-hidden");
484
+ sortSel.setAttribute("aria-hidden", "true");
485
+ sortSel.tabIndex = -1;
486
+
487
+ // Replace (or insert) the trigger button.
488
+ let trigger = document.getElementById("sort-trigger");
489
+ if (!trigger) {
490
+ trigger = el("button", "sort-trigger", {
491
+ type: "button",
492
+ id: "sort-trigger",
493
+ "aria-haspopup": "listbox",
494
+ "aria-expanded": "false",
495
+ "aria-label": "Sort tasks",
496
+ });
497
+ sortSel.insertAdjacentElement("beforebegin", trigger);
498
+ }
499
+ trigger.textContent = SORT_LABEL[filter.sort] || SORT_LABEL.newest;
500
+ trigger.setAttribute("aria-label", `Sort: ${SORT_LABEL[filter.sort] || SORT_LABEL.newest}`);
501
+
502
+ trigger.addEventListener("click", (e) => {
503
+ e.preventDefault();
504
+ e.stopPropagation();
505
+ if (sortPopoverOpen) closeSortPopover();
506
+ else openSortPopover();
507
+ });
508
+ trigger.addEventListener("keydown", (e) => {
509
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
510
+ e.preventDefault();
511
+ if (!sortPopoverOpen) openSortPopover();
512
+ }
513
+ });
514
+
515
+ // Replace (or insert) the popover.
516
+ let popover = document.getElementById("sort-popover");
517
+ if (!popover) {
518
+ popover = el("div", "sort-popover", {
519
+ id: "sort-popover",
520
+ role: "listbox",
521
+ "aria-label": "Sort tasks",
522
+ hidden: true,
523
+ });
524
+ wrap.append(popover);
525
+ } else {
526
+ popover.innerHTML = "";
527
+ popover.setAttribute("role", "listbox");
528
+ }
529
+ for (const opt of SORT_OPTIONS) {
530
+ const btn = el("button", "sort-option", {
531
+ type: "button",
532
+ role: "option",
533
+ "data-value": opt.value,
534
+ "aria-selected": opt.value === filter.sort ? "true" : "false",
535
+ tabindex: "-1",
536
+ });
537
+ btn.append(el("span", "label", { text: opt.label }));
538
+ btn.append(el("span", "desc", { text: opt.desc }));
539
+ if (opt.value === filter.sort) {
540
+ btn.append(el("span", "check", { "aria-hidden": "true", text: "✓" }));
541
+ btn.classList.add("active");
542
+ }
543
+ btn.addEventListener("click", (e) => {
544
+ e.preventDefault();
545
+ e.stopPropagation();
546
+ setSort(opt.value);
547
+ closeSortPopover();
548
+ trigger.focus();
549
+ });
550
+ btn.addEventListener("keydown", (e) => {
551
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
552
+ e.preventDefault();
553
+ moveSortOption(e.key === "ArrowDown" ? 1 : -1);
554
+ } else if (e.key === "Enter" || e.key === " ") {
555
+ e.preventDefault();
556
+ commitFocusedOption();
557
+ } else if (e.key === "Escape") {
558
+ e.preventDefault();
559
+ closeSortPopover();
560
+ trigger.focus();
561
+ } else if (e.key === "Home") {
562
+ e.preventDefault();
563
+ focusSortOption(SORT_OPTIONS[0].value);
564
+ } else if (e.key === "End") {
565
+ e.preventDefault();
566
+ focusSortOption(SORT_OPTIONS[SORT_OPTIONS.length - 1].value);
567
+ }
568
+ });
569
+ popover.append(btn);
570
+ }
571
+ }
572
+
573
+ function attachSortPopover() {
574
+ buildSortPopover();
575
+ }
576
+
577
+ function attachFilterBar() {
578
+ const clear = document.getElementById("filter-clear");
579
+ const cats = document.getElementById("filter-categories");
580
+ const tags = document.getElementById("filter-tags");
581
+ const contribs = document.getElementById("filter-contributors");
582
+ const sortSel = document.getElementById("sort-select");
583
+ const saveBtn = document.getElementById("save-filter-btn");
584
+ if (!cats || !tags) return;
585
+
586
+ cats.addEventListener("click", (e) => {
587
+ const btn = e.target.closest("button[data-category]");
588
+ if (!btn) return;
589
+ filter.category = btn.getAttribute("data-category") || "all";
590
+ writeHashFilter();
591
+ applyFilterToButtons();
592
+ renderBoard();
593
+ });
594
+ tags.addEventListener("click", (e) => {
595
+ const btn = e.target.closest("button[data-tag]");
596
+ if (!btn) return;
597
+ const v = btn.getAttribute("data-tag") || "";
598
+ if (!v) return;
599
+ const idx = filter.tags.indexOf(v);
600
+ if (idx === -1) filter.tags.push(v);
601
+ else filter.tags.splice(idx, 1);
602
+ writeHashFilter();
603
+ applyFilterToButtons();
604
+ renderBoard();
605
+ });
606
+ if (contribs) {
607
+ contribs.addEventListener("click", (e) => {
608
+ const btn = e.target.closest("button[data-contributor]");
609
+ if (!btn) return;
610
+ filter.contributor = btn.getAttribute("data-contributor") || "all";
611
+ writeHashFilter();
612
+ applyFilterToButtons();
613
+ renderBoard();
614
+ });
615
+ }
616
+ if (sortSel) {
617
+ // The legacy <select> is kept around for keyboard / form-submit
618
+ // compatibility; the visible popover drives the actual UX. We still
619
+ // honor changes (e.g. tests, screen-reader-only navigation).
620
+ sortSel.addEventListener("change", () => {
621
+ setSort(sortSel.value || "newest");
622
+ });
623
+ }
624
+ attachSortPopover();
625
+ if (saveBtn) {
626
+ saveBtn.addEventListener("click", async () => {
627
+ // `window.prompt` is blocked in headless Chrome and some embedded
628
+ // contexts, so route through the custom inline modal in app.js.
629
+ const name = await promptForName("Save filter as:", "");
630
+ if (!name) return;
631
+ if (saveCurrentFilter(name)) {
632
+ window.OpenKanSettings?.showToast?.(`Saved filter "${name}"`);
633
+ }
634
+ });
635
+ }
636
+ if (clear) {
637
+ clear.addEventListener("click", () => {
638
+ filter.category = "all";
639
+ filter.tags = [];
640
+ filter.contributor = "all";
641
+ filter.search = "";
642
+ searchMatchIds = null;
643
+ writeHashFilter();
644
+ applyFilterToButtons();
645
+ renderBoard();
646
+ });
647
+ }
648
+ // External trigger: tag chip in task view writes the hash; we re-read.
649
+ window.addEventListener("hashchange", () => {
650
+ const next = readHashFilter();
651
+ if (!next) return;
652
+ const searchChanged = next.search !== filter.search;
653
+ filter.category = next.category;
654
+ filter.tags = next.tags;
655
+ filter.contributor = next.contributor;
656
+ filter.archive = next.archive;
657
+ filter.sort = next.sort;
658
+ filter.search = next.search || "";
659
+ if (searchChanged) {
660
+ // Hash navigated to a new search — re-run the debounced fetch.
661
+ const input = document.getElementById("search-input");
662
+ if (input) input.value = filter.search;
663
+ searchMatchIds = null;
664
+ if (filter.search) {
665
+ const meta = document.getElementById("search-meta");
666
+ runSearch(filter.search, meta);
667
+ }
668
+ }
669
+ applyFilterToButtons();
670
+ renderBoard();
671
+ activateTab(next.tab, { fromHash: true });
672
+ });
673
+
674
+ // Archive segmented control
675
+ document.querySelectorAll(".archive-toggle button[data-archive]").forEach((btn) => {
676
+ btn.addEventListener("click", () => {
677
+ filter.archive = btn.getAttribute("data-archive") || "active";
678
+ writeHashFilter();
679
+ applyFilterToButtons();
680
+ renderBoard();
681
+ });
682
+ });
683
+ }
684
+
685
+ const $ = (id) => document.getElementById(id);
686
+ const board = $("board");
687
+ const statusPill = $("status-pill");
688
+ const statusText = $("status-text");
689
+ const modal = $("modal-backdrop");
690
+ if (modal) document.body.appendChild(modal);
691
+ const form = $("new-task-form");
692
+ const menu = $("action-menu");
693
+ const searchInput = $("search-input");
694
+
695
+ // Module-scope wrappers used by both the click-button menu and the
696
+ // right-click context menu. `call` swallows the error to keep the menu
697
+ // from getting stuck open if a request fails.
698
+ const call = (m, p, b) => api(m, p, b).catch((e) => alert(`${m} failed: ${e.message}`));
699
+
700
+ // ---------- Connection state ----------
701
+ function setConnected(v) {
702
+ statusPill.classList.toggle("pill-connected", v);
703
+ statusPill.classList.toggle("pill-disconnected", !v);
704
+ statusText.textContent = v ? "Connected" : "Disconnected";
705
+ }
706
+ onStatus(setConnected);
707
+
708
+ // ---------- Render ----------
709
+ function el(tag, cls, props = {}) {
710
+ const e = document.createElement(tag);
711
+ if (cls) e.className = cls;
712
+ for (const [k, v] of Object.entries(props)) {
713
+ if (k === "text") e.textContent = v;
714
+ else if (k === "html") e.innerHTML = v;
715
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
716
+ else e.setAttribute(k, v);
717
+ }
718
+ return e;
719
+ }
720
+
721
+ function effectiveState(t) {
722
+ return t.state ?? t.status ?? "idle";
723
+ }
724
+
725
+ function makeTagChip(tag, opts = {}) {
726
+ const lower = String(tag).toLowerCase();
727
+ const isCategory = CATEGORIES.has(lower);
728
+ const cls = `tag-chip t-${lower}` + (isCategory ? " category" : "") + (opts.className ? ` ${opts.className}` : "");
729
+ const chip = el("span", cls, { text: opts.label ?? lower, title: opts.title ?? lower });
730
+ if (isCategory) chip.classList.add(`c-${lower}`);
731
+ return chip;
732
+ }
733
+
734
+ function makeCardPriority(t) {
735
+ const meta = PRIORITY_META[t.priority];
736
+ if (!meta) return null;
737
+ return el("span", `card-priority ${meta.className}`, {
738
+ // Preserve title space on narrow board viewports. The full priority
739
+ // stays available through the accessible label and native tooltip.
740
+ text: meta.code,
741
+ title: `Priority: ${meta.label}`,
742
+ "aria-label": `Priority: ${meta.label}`,
743
+ });
744
+ }
745
+
746
+ function makeCardTags(t) {
747
+ const raw = Array.isArray(t.tags) ? t.tags : [];
748
+ if (raw.length === 0) return null;
749
+ const MAX = 3;
750
+ const visible = raw.slice(0, MAX);
751
+ const overflow = raw.length - visible.length;
752
+ const row = el("div", "card-tags");
753
+ for (const tag of visible) row.append(makeTagChip(tag));
754
+ if (overflow > 0) {
755
+ row.append(el("span", "tag-chip overflow", {
756
+ text: `+${overflow}`,
757
+ title: raw.slice(MAX).join(", "),
758
+ }));
759
+ }
760
+ return row;
761
+ }
762
+
763
+ // Deterministic avatar color from a string. Same approach used elsewhere.
764
+ function avatarColorFor(seed) {
765
+ let h = 0;
766
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
767
+ const hue = Math.abs(h) % 360;
768
+ return `hsl(${hue} 60% 45%)`;
769
+ }
770
+
771
+ function initialsFor(name) {
772
+ if (!name) return "?";
773
+ const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
774
+ if (parts.length === 0) return "?";
775
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
776
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
777
+ }
778
+
779
+ // Resolve the assignees list on a task. The board index payload uses
780
+ // `assignees: string[]`; the task detail payload uses
781
+ // `contributors: Array<{name, email}>`. Normalize to a list of
782
+ // { label, seed } where label is the human name and seed is the email
783
+ // (used for the deterministic color hash and the tooltip).
784
+ function getAssignees(t) {
785
+ if (Array.isArray(t?.assignees) && t.assignees.length > 0) {
786
+ return t.assignees
787
+ .map((a) => {
788
+ if (a && typeof a === "object") {
789
+ const seed = String(a.email || a.name || "");
790
+ return seed ? { label: a.name || a.email || "?", seed } : null;
791
+ }
792
+ const s = String(a || "").trim();
793
+ return s ? { label: s, seed: s } : null;
794
+ })
795
+ .filter(Boolean);
796
+ }
797
+ if (Array.isArray(t?.contributors)) {
798
+ return t.contributors
799
+ .map((c) => {
800
+ const seed = String(c?.email || c?.name || "");
801
+ return seed ? { label: c.name || c.email || "?", seed } : null;
802
+ })
803
+ .filter(Boolean);
804
+ }
805
+ return [];
806
+ }
807
+
808
+ // Render an assignees avatar strip on the card. Up to 2 avatars, then
809
+ // "+N" overflow. The whole strip is a tooltip host with the full list.
810
+ function makeAssigneesStrip(t) {
811
+ const list = getAssignees(t);
812
+ if (list.length === 0) return null;
813
+ const MAX = 2;
814
+ const visible = list.slice(0, MAX);
815
+ const overflow = list.length - visible.length;
816
+ const tooltip = list.map((a) => a.label).join("\n");
817
+ const wrap = el("div", "assignees-stack assignee-tooltip", { "data-tooltip": tooltip, tabindex: "0" });
818
+ for (const a of visible) {
819
+ const av = el("span", "avatar-circle avatar-sm");
820
+ av.style.setProperty("--avatar-bg", avatarColorFor(a.seed));
821
+ av.textContent = initialsFor(a.label);
822
+ wrap.append(av);
823
+ }
824
+ if (overflow > 0) {
825
+ wrap.append(el("span", "assignee-overflow", {
826
+ text: `+${overflow}`,
827
+ }));
828
+ }
829
+ return wrap;
830
+ }
831
+
832
+ function renderCard(t) {
833
+ const state = effectiveState(t);
834
+ const columnTitle = (COLUMNS.find((c) => c.id === t.column) || {}).title || (t.column || "column");
835
+ const focusedId = window.OpenKanKeyboard?.getFocusedId?.();
836
+ const isFocused = focusedId === t.id;
837
+ const card = el("article", "card" + (t.archived ? " card-archived" : "") + (isFocused ? " focused" : ""), {
838
+ draggable: "true",
839
+ "data-id": t.id,
840
+ role: "button",
841
+ tabindex: isFocused ? "0" : "-1",
842
+ "aria-label": `${t.title || "(untitled)"}, ${columnTitle}, status ${state}${t.archived ? ", archived" : ""}`,
843
+ "aria-pressed": selectedIds.has(t.id) ? "true" : "false",
844
+ });
845
+ // A tiny deterministic stagger gives neighboring cards their own tactile
846
+ // hover cadence without making repeated renders visually random.
847
+ const motionSeed = [...String(t.id)].reduce((sum, char) => sum + char.charCodeAt(0), 0);
848
+ card.style.setProperty("--card-lift-delay", `${motionSeed % 4 * 14}ms`);
849
+ card.style.setProperty("--card-hover-tilt", `${((motionSeed % 3) - 1) * 0.18}deg`);
850
+ const header = el("div", "card-header");
851
+ const titleEl = el("div", "card-title", { text: t.title || "(untitled)" });
852
+ header.append(titleEl);
853
+ const pri = makeCardPriority(t);
854
+ if (pri) header.append(pri);
855
+ card.append(header);
856
+ // Subtask count badge — shows on the right of the title when this task
857
+ // has at least one child. Backend supplies `subtaskCount` on the index
858
+ // payload; falls back to the `subtasks` array length if not.
859
+ const subtaskCount = (() => {
860
+ if (typeof t.subtaskCount === "number") return t.subtaskCount;
861
+ if (Array.isArray(t.subtasks)) return t.subtasks.length;
862
+ return 0;
863
+ })();
864
+ if (subtaskCount > 0) {
865
+ const subBadge = el("span", "card-subtask-badge", {
866
+ text: `${subtaskCount} subtask${subtaskCount === 1 ? "" : "s"}`,
867
+ title: `${subtaskCount} subtask${subtaskCount === 1 ? "" : "s"}`,
868
+ });
869
+ // Right-aligned with the title via inline-block wrapper. We just
870
+ // append it to the title element so the existing flex layout on
871
+ // `.card-title` picks it up; CSS handles the alignment.
872
+ subBadge.dataset.parentLink = "true";
873
+ subBadge.addEventListener("click", (e) => {
874
+ e.stopPropagation();
875
+ window.OpenKanTaskView?.open(t.id);
876
+ });
877
+ titleEl.append(subBadge);
878
+ }
879
+ card.append(el("div", "card-desc", { text: t.description || "" }));
880
+ const tagsRow = makeCardTags(t);
881
+ if (tagsRow) card.append(tagsRow);
882
+ // Source link chip (M2 — frontend). When the task was imported from a
883
+ // markdown file we surface the path:line so the user can click straight
884
+ // through to the source. If the file no longer exists on disk we show
885
+ // a "deleted" state instead of a dead link.
886
+ if (t.source && t.source.path) {
887
+ const path = String(t.source.path);
888
+ const line = t.source.line ?? "?";
889
+ const chip = el("a", "card-source", {
890
+ href: `/${path}`,
891
+ target: "_blank",
892
+ rel: "noopener",
893
+ title: `Imported from ${path}:${line}`,
894
+ });
895
+ chip.append(
896
+ el("span", "card-source-label", { text: "Source", "aria-hidden": "true" }),
897
+ el("span", "card-source-text", { text: `${path}:${line}` }),
898
+ );
899
+ // Stop the click from bubbling up to the card body — we don't want
900
+ // the chip to also open the task view.
901
+ chip.addEventListener("click", (e) => e.stopPropagation());
902
+ // Lazy "deleted" detection: HEAD-style check on hover. If the fetch
903
+ // resolves 404 we flip the chip into the deleted state so the user
904
+ // sees the source is gone without us having to scan the filesystem on
905
+ // every render.
906
+ chip.addEventListener("mouseenter", () => {
907
+ if (chip.dataset.checked || chip.dataset.deleted) return;
908
+ chip.dataset.checked = "1";
909
+ fetch(chip.href, { method: "HEAD", cache: "no-store" })
910
+ .then((res) => {
911
+ if (!res.ok) {
912
+ chip.dataset.deleted = "1";
913
+ chip.removeAttribute("href");
914
+ chip.classList.add("card-source-deleted");
915
+ chip.title = `Source file no longer exists: ${path}`;
916
+ const txt = chip.querySelector(".card-source-text");
917
+ if (txt) txt.textContent = `${path} (deleted)`;
918
+ }
919
+ })
920
+ .catch(() => { /* network error — leave the link intact */ });
921
+ }, { once: true });
922
+ card.append(chip);
923
+ }
924
+ // Stale badge (M3 — frontend). Surfaced in the top-right when the
925
+ // server detected the source file has changed since import. Clicking
926
+ // it opens the task view so the user can re-derive tags.
927
+ if (t.stale === true) {
928
+ const stale = el("button", "card-stale-badge", {
929
+ type: "button",
930
+ text: "stale",
931
+ title: "Source has changed since this task was imported. Click to open.",
932
+ "aria-label": "Source is stale — click to open task",
933
+ });
934
+ stale.addEventListener("click", (e) => {
935
+ e.stopPropagation();
936
+ window.OpenKanTaskView?.open(t.id);
937
+ });
938
+ card.append(stale);
939
+ }
940
+ const meta = el("div", "card-meta");
941
+ const left = el("div");
942
+ left.style.cssText = "display:flex;align-items:center;gap:6px;";
943
+ left.append(
944
+ el("span", `status-dot ${state}`, { "aria-hidden": "true" }),
945
+ el("span", "card-state-label", { text: String(state).replaceAll("-", " "), title: `Status: ${state}` }),
946
+ );
947
+ if (t.agent)
948
+ left.append(el("span", "card-agent", { text: t.agent, title: `agent: ${t.agent}` }));
949
+ const avs = makeAssigneesStrip(t);
950
+ if (avs) left.append(avs);
951
+ const right = el("div");
952
+ right.style.cssText = "display:flex;gap:6px;align-items:center;";
953
+ if (t.artifact) {
954
+ const a = el("a", null, {
955
+ href: `/artifacts/tasks/${t.id}`,
956
+ title: "View artifact",
957
+ text: "Open",
958
+ });
959
+ a.className = "card-artifact-link";
960
+ a.addEventListener("click", (e) => e.stopPropagation());
961
+ right.append(a);
962
+ }
963
+ const more = el("button", "btn-icon", { text: "⋯", title: "Actions", "aria-label": "Task actions" });
964
+ more.addEventListener("click", (e) => {
965
+ e.stopPropagation();
966
+ openMenu(t, more);
967
+ });
968
+ right.append(more);
969
+ meta.append(left, right);
970
+ card.append(meta);
971
+
972
+ // Drag — v1.1: also handle multi-card drag (selected set) and ghost preview.
973
+ card.addEventListener("dragstart", (e) => {
974
+ // Compose the dragged id list: this card + any selected ones.
975
+ const dragged = new Set([t.id, ...selectedIds]);
976
+ dragState.draggedIds = [...dragged];
977
+ // Board columns move cards; chat copies a task reference. Advertise both
978
+ // outcomes so browsers accept the chat's copy drop effect.
979
+ e.dataTransfer.effectAllowed = "copyMove";
980
+ const chatTask = { type: "openkan-task", id: t.id, title: t.title, column: t.column };
981
+ try {
982
+ e.dataTransfer.setData("text/plain", dragState.draggedIds.join(","));
983
+ e.dataTransfer.setData("application/x-openkan-task", JSON.stringify(chatTask));
984
+ e.dataTransfer.setData("text/x-openkan-task", JSON.stringify(chatTask));
985
+ e.dataTransfer.setData("application/json", JSON.stringify(chatTask));
986
+ } catch {}
987
+ // A same-document fallback for engines that hide custom MIME data until
988
+ // drop. It is cleared by the normal drag teardown path.
989
+ window.OpenKanActiveTaskDrag = chatTask;
990
+ card.classList.add("dragging");
991
+ // Show ghost preview — custom positioned div.
992
+ dragState.ghost = buildGhost(t, dragState.draggedIds);
993
+ document.body.append(dragState.ghost);
994
+ // Hide native drag image so only our ghost shows.
995
+ try {
996
+ const blank = document.createElement("canvas");
997
+ blank.width = blank.height = 1;
998
+ e.dataTransfer.setDragImage(blank, 0, 0);
999
+ } catch {}
1000
+ });
1001
+ card.addEventListener("dragend", () => {
1002
+ card.classList.remove("dragging");
1003
+ teardownDragVisuals();
1004
+ });
1005
+ // Ctrl/Meta-click toggles selection for multi-drag + bulk actions.
1006
+ card.addEventListener("click", (e) => {
1007
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey === false) {
1008
+ // Only intercept if the user is on the card body, not a button/link.
1009
+ const target = e.target;
1010
+ const tag = (target.tagName || "").toLowerCase();
1011
+ if (tag === "a" || tag === "button") return;
1012
+ e.preventDefault();
1013
+ e.stopPropagation();
1014
+ toggleCardSelection(card, t.id);
1015
+ return;
1016
+ }
1017
+ // Plain click opens the detail view.
1018
+ const tag = (e.target.tagName || "").toLowerCase();
1019
+ if (tag === "a" || tag === "button") return;
1020
+ // Update the focused card so subsequent j/k/arrow keys start from here.
1021
+ window.OpenKanKeyboard?.setFocusedId?.(t.id);
1022
+ window.OpenKanTaskView?.open(t.id);
1023
+ });
1024
+ // Right-click on cards is handled by the global page-wide contextmenu
1025
+ // listener (see `attachGlobalContextMenu` near the boot section). We
1026
+ // deliberately don't attach a per-card contextmenu handler — let the
1027
+ // global delegate handle it via `e.target.closest(".card")` so the
1028
+ // behavior is consistent with column-body / filter-bar / topbar etc.
1029
+ // Apply the current selected state — renderBoard() rebuilds the DOM
1030
+ // after every state change, so we need to re-add the class on rebuild.
1031
+ if (selectedIds.has(t.id)) card.classList.add("selected");
1032
+
1033
+ return card;
1034
+ }
1035
+
1036
+ // Selected cards for multi-card drag + bulk action bar. Stored as a Set of
1037
+ // task ids. Renamed from `selectedCards` so it matches the public spec.
1038
+ function toggleCardSelection(cardEl, id) {
1039
+ if (selectedIds.has(id)) {
1040
+ selectedIds.delete(id);
1041
+ cardEl.classList.remove("selected");
1042
+ cardEl.setAttribute("aria-pressed", "false");
1043
+ } else {
1044
+ selectedIds.add(id);
1045
+ cardEl.classList.add("selected");
1046
+ cardEl.setAttribute("aria-pressed", "true");
1047
+ }
1048
+ updateBulkBar();
1049
+ }
1050
+ function clearCardSelection() {
1051
+ for (const c of document.querySelectorAll(".card.selected")) c.classList.remove("selected");
1052
+ selectedIds.clear();
1053
+ updateBulkBar();
1054
+ }
1055
+ document.addEventListener("click", (e) => {
1056
+ if (e.target.closest(".card")) return;
1057
+ if (e.target.closest("#bulk-bar")) return;
1058
+ clearCardSelection();
1059
+ });
1060
+
1061
+ function renderBoard() {
1062
+ if (!board) return;
1063
+ board.innerHTML = "";
1064
+ // `filterActive` controls whether taskMatchesFilter() runs at all. It
1065
+ // must include the archive toggle — otherwise with no other filter
1066
+ // active we'd render archived cards alongside active ones, which is the
1067
+ // "archived items don't get hidden" bug.
1068
+ const filterActive =
1069
+ filter.category !== "all" ||
1070
+ filter.tags.length > 0 ||
1071
+ filter.contributor !== "all" ||
1072
+ filter.archive !== "active" ||
1073
+ filter.search !== "";
1074
+
1075
+ // Group by column, apply filter, sort.
1076
+ const byColumn = new Map();
1077
+ for (const col of COLUMNS) byColumn.set(col.id, []);
1078
+ for (const t of tasks.values()) {
1079
+ const list = byColumn.get(t.column);
1080
+ if (list) list.push(t);
1081
+ }
1082
+ for (const [colId, list] of byColumn) {
1083
+ const filtered = filterActive ? list.filter(taskMatchesFilter) : list;
1084
+ byColumn.set(colId, sortTasks(filtered));
1085
+ }
1086
+
1087
+ const visibleTasks = [...byColumn.values()].flat();
1088
+ const setOverview = (id, value) => {
1089
+ const target = document.getElementById(id);
1090
+ if (target) target.textContent = String(value);
1091
+ };
1092
+ setOverview("overview-total", visibleTasks.length);
1093
+ setOverview("overview-doing", visibleTasks.filter((task) => task.column === "doing").length);
1094
+ setOverview("overview-review", visibleTasks.filter((task) => task.column === "review").length);
1095
+ setOverview("overview-done", visibleTasks.filter((task) => task.column === "done").length);
1096
+
1097
+ for (const col of COLUMNS) {
1098
+ const colTasks = byColumn.get(col.id) || [];
1099
+ const body = el("div", "column-body", { "data-column": col.id });
1100
+
1101
+ if (colTasks.length === 0) {
1102
+ const empty = el("div", "column-empty is-dropzone", {
1103
+ text: filterActive
1104
+ ? "No tasks match the filter"
1105
+ : "Drop a task here or + Add task",
1106
+ });
1107
+ body.append(empty);
1108
+ } else {
1109
+ for (const t of colTasks) body.append(renderCard(t));
1110
+ }
1111
+
1112
+ const column = el("section", "column", { "data-column": col.id });
1113
+ const header = el("div", "column-header");
1114
+ const totalInCol = (byColumn.get(col.id) || []).length + (
1115
+ // include archived-only when filter is "active"
1116
+ filter.archive !== "archived"
1117
+ ? [...tasks.values()].filter((t) => t.column === col.id && t.archived).length
1118
+ : 0
1119
+ );
1120
+ const matched = colTasks.length;
1121
+ const countLabel = filterActive && totalInCol !== matched
1122
+ ? `${matched} / ${totalInCol}`
1123
+ : String(matched);
1124
+ const titleSpan = el("span", "column-title", { text: col.title });
1125
+ const countSpan = el("span", "column-count", { text: countLabel });
1126
+ const addBtn = el("button", "column-add-btn", {
1127
+ type: "button",
1128
+ title: `Add a task to ${col.title}`,
1129
+ "aria-label": `Add a task to ${col.title}`,
1130
+ text: "+",
1131
+ });
1132
+ addBtn.addEventListener("click", (e) => {
1133
+ e.stopPropagation();
1134
+ openNewTaskModalInColumn(col.id);
1135
+ });
1136
+ header.append(titleSpan, countSpan, addBtn);
1137
+ column.append(header, body);
1138
+ attachDnD(column);
1139
+ board.append(column);
1140
+ }
1141
+ }
1142
+
1143
+ // ---------- Action menu ----------
1144
+ function openMenu(task, anchor) {
1145
+ const r = anchor.getBoundingClientRect();
1146
+ menu.innerHTML = "";
1147
+ menu.style.top = `${r.bottom + 4}px`;
1148
+ menu.style.left = `${Math.max(8, r.right - 200)}px`;
1149
+ menu.hidden = false;
1150
+
1151
+ const run = (label, fn, danger = false) => {
1152
+ const b = el("button", danger ? "danger" : null, { text: label });
1153
+ b.addEventListener("click", () => {
1154
+ if (typeof console !== "undefined") console.debug("[openkan] menu click:", label);
1155
+ menu.hidden = true;
1156
+ Promise.resolve().then(() => {
1157
+ try { fn(); } catch (err) {
1158
+ if (typeof console !== "undefined") console.error("[openkan] action threw:", err);
1159
+ toast(`Action failed: ${err.message || err}`, true);
1160
+ }
1161
+ });
1162
+ });
1163
+ menu.append(b);
1164
+ };
1165
+ // `call` is the module-scope wrapper defined near the top of the IIFE.
1166
+
1167
+ run("Discuss in chat", () => window.OpenKanChatSidebar?.mentionTask?.(task));
1168
+
1169
+ // "Move to next column" — hidden if archived or on the last column.
1170
+ const idx = COLUMN_ORDER.indexOf(task.column);
1171
+ if (!task.archived && idx >= 0 && idx < COLUMN_ORDER.length - 1) {
1172
+ const next = COLUMN_ORDER[idx + 1];
1173
+ run(`Move to ${labelForColumn(next)} →`, () => {
1174
+ call("PATCH", `/api/tasks/${task.id}`, { column: next })
1175
+ .then(() => toast(`Moved to ${labelForColumn(next)}`))
1176
+ .catch((err) => toast(`Move failed: ${err.message}`, true));
1177
+ });
1178
+ }
1179
+
1180
+ const state = effectiveState(task);
1181
+ if (state !== "running") {
1182
+ run("Start", () => call("POST", `/api/tasks/${task.id}/start`)
1183
+ .then(() => toast(`Started "${task.title}"`))
1184
+ .catch((err) => toast(`Start failed: ${err.message}`, true)));
1185
+ } else {
1186
+ run("Abort", () => call("POST", `/api/tasks/${task.id}/abort`)
1187
+ .then(() => toast(`Aborted "${task.title}"`))
1188
+ .catch((err) => toast(`Abort failed: ${err.message}`, true)));
1189
+ }
1190
+
1191
+ run("Edit", () => openEditModal(task.id));
1192
+
1193
+ run("View Detail", () => {
1194
+ window.OpenKanTaskView?.open(task.id);
1195
+ });
1196
+
1197
+ if (task.artifact) {
1198
+ const a = el("a", null, { href: `/artifacts/tasks/${task.id}`, text: "View Artifact ↗", target: "_blank", rel: "noopener" });
1199
+ menu.append(a);
1200
+ }
1201
+
1202
+ if (task.archived) {
1203
+ run("Restore", () => call("POST", `/api/tasks/${task.id}/restore`)
1204
+ .then(() => toast(`Restored "${task.title}"`))
1205
+ .catch((err) => toast(`Restore failed: ${err.message}`, true)));
1206
+ } else {
1207
+ run("Archive", () => archiveWithUndo(task));
1208
+ }
1209
+
1210
+ run("Delete", () => deleteWithUndo(task), true);
1211
+
1212
+ const off = (e) => {
1213
+ if (!menu.contains(e.target)) {
1214
+ menu.hidden = true;
1215
+ document.removeEventListener("click", off);
1216
+ }
1217
+ };
1218
+ setTimeout(() => document.addEventListener("click", off), 0);
1219
+ }
1220
+
1221
+ // ─── Right-click context menu (PAGE-WIDE, location-aware) ──────────────
1222
+ //
1223
+ // A single global `contextmenu` listener at capture phase decides which menu
1224
+ // to show based on `e.target.closest(...)`. Re-uses the `renderMenu` /
1225
+ // `hideMenu` infrastructure from the card-specific menu below.
1226
+ function openGlobalContextMenu(e) {
1227
+ if (typeof console !== "undefined") console.debug("[openkan] contextmenu at", e.target);
1228
+ // Inputs keep the native context menu (paste / spell-check etc.) so the
1229
+ // user can still get the browser spell-checker on text fields.
1230
+ const tEl = e.target;
1231
+ if (!tEl) return;
1232
+ const tTag = (tEl.tagName || "").toLowerCase();
1233
+ if (tTag === "input" || tTag === "textarea" || tEl.isContentEditable) {
1234
+ if (typeof console !== "undefined") console.debug("[openkan] contextmenu: in editable input — keeping native menu");
1235
+ return;
1236
+ }
1237
+ // Anchors / buttons keep their native menu (copy link, etc.). Walk up to
1238
+ // decide. We DON'T bail just because the click target is a <span> or
1239
+ // <article role="button"> — only true <a>/<button> elements get the
1240
+ // native menu.
1241
+ let walker = tEl;
1242
+ while (walker && walker !== document.body) {
1243
+ const tag = (walker.tagName || "").toLowerCase();
1244
+ if (tag === "a" || tag === "button") {
1245
+ if (typeof console !== "undefined") console.debug("[openkan] contextmenu: inside anchor/button — keeping native menu", tag);
1246
+ return;
1247
+ }
1248
+ walker = walker.parentElement;
1249
+ }
1250
+
1251
+ // Always suppress the native menu once we've decided to render ours.
1252
+ e.preventDefault();
1253
+ e.stopPropagation();
1254
+
1255
+ const items = [];
1256
+
1257
+ // 1. Card → per-task menu
1258
+ const cardEl = tEl.closest(".card");
1259
+ if (cardEl) {
1260
+ const taskId = cardEl.dataset.id;
1261
+ const task = tasks.get(taskId);
1262
+ if (task) {
1263
+ e.preventDefault();
1264
+ e.stopPropagation();
1265
+ openContextMenu(e, task);
1266
+ return;
1267
+ }
1268
+ }
1269
+
1270
+ // 2. Saved-filter chip
1271
+ const savedChip = tEl.closest(".saved-filter-chip");
1272
+ if (savedChip && savedChip.dataset.savedName) {
1273
+ const name = savedChip.dataset.savedName;
1274
+ items.push({
1275
+ label: `Apply "${name}"`,
1276
+ action: () => applySavedFilterByName(name),
1277
+ });
1278
+ items.push({
1279
+ label: `Delete saved filter "${name}"`,
1280
+ danger: true,
1281
+ action: () => deleteSavedFilterByName(name),
1282
+ });
1283
+ }
1284
+
1285
+ // 3. Tag chip in the filter bar (data-tag attribute)
1286
+ const filterChip = tEl.closest(".filter-bar button[data-tag]");
1287
+ if (filterChip && filterChip.dataset.tag) {
1288
+ const tg = filterChip.dataset.tag;
1289
+ items.push({
1290
+ label: `Filter by #${tg} only`,
1291
+ action: () => toggleOnlyTagFilter(tg),
1292
+ });
1293
+ items.push({
1294
+ label: `Add #${tg} to active filters`,
1295
+ action: () => addTagToActiveFilters(tg),
1296
+ });
1297
+ }
1298
+
1299
+ // 4. Category chip in the filter bar
1300
+ const categoryChip = tEl.closest(".filter-bar button[data-category]");
1301
+ if (categoryChip && categoryChip.dataset.category && categoryChip.dataset.category !== "all") {
1302
+ const cat = categoryChip.dataset.category;
1303
+ items.push({
1304
+ label: `Filter by category "${cat}" only`,
1305
+ action: () => setOnlyCategoryFilter(cat),
1306
+ });
1307
+ }
1308
+
1309
+ // 5. Column body / column header → column menu
1310
+ const columnEl = tEl.closest(".column");
1311
+ if (columnEl && columnEl.dataset.column) {
1312
+ const colId = columnEl.dataset.column;
1313
+ const col = COLUMNS.find((c) => c.id === colId);
1314
+ items.push({
1315
+ label: col?.title
1316
+ ? `Create task in "${col.title}"`
1317
+ : `Create task`,
1318
+ action: () => openNewTaskModalInColumn(colId),
1319
+ });
1320
+ items.push({
1321
+ label: `New task from template`,
1322
+ action: () => openNewTaskModalInColumn(colId, true),
1323
+ });
1324
+ if (selectedIds.size > 0) {
1325
+ items.push({
1326
+ label: `Move ${selectedIds.size} selected task${selectedIds.size === 1 ? "" : "s"} here`,
1327
+ action: () => bulkMoveSelectedTo(colId),
1328
+ });
1329
+ items.push({
1330
+ label: `Move ${selectedIds.size} selected task${selectedIds.size === 1 ? "" : "s"} to other project…`,
1331
+ action: () => openCrossProjectMoveDialog([...selectedIds]),
1332
+ });
1333
+ items.push({ kind: "divider" });
1334
+ }
1335
+ // Archive-all in this column
1336
+ items.push({
1337
+ label: `Archive archived tasks in this column`,
1338
+ action: () => archiveAllInColumn(colId),
1339
+ });
1340
+ items.push({
1341
+ label: `Re-derive tags for tasks in this column`,
1342
+ action: () => bulkRederiveInColumn(colId),
1343
+ });
1344
+ items.push({ kind: "divider" });
1345
+ items.push({
1346
+ label: `Copy column ID "${colId}"`,
1347
+ action: () => copyToClipboard(colId, "Column ID"),
1348
+ });
1349
+ }
1350
+
1351
+ // 6. Dashboard tab in the topbar
1352
+ const tabEl = tEl.closest(".topbar-tabs .tab");
1353
+ if (tabEl) {
1354
+ const tabName = tabEl.dataset.tab;
1355
+ if (tabName === "tasks") {
1356
+ items.push({
1357
+ label: "Clear all filters",
1358
+ action: () => clearAllFilters(),
1359
+ });
1360
+ items.push({
1361
+ label: "Show archived",
1362
+ action: () => setArchiveFilter("archived"),
1363
+ });
1364
+ items.push({
1365
+ label: "Show all (active + archived)",
1366
+ action: () => setArchiveFilter("both"),
1367
+ });
1368
+ } else if (tabName === "changelog") {
1369
+ items.push({
1370
+ label: "Refresh changelog",
1371
+ action: () => window.OpenKanChangelog?.mount?.(document.getElementById("changelog-root")),
1372
+ });
1373
+ } else if (tabName === "contributors") {
1374
+ items.push({
1375
+ label: "Refresh contributors",
1376
+ action: () => window.OpenKanContributors?.mount?.(document.getElementById("contributors-root")),
1377
+ });
1378
+ }
1379
+ }
1380
+
1381
+ // 7. Default page menu if nothing matched
1382
+ if (items.length === 0) {
1383
+ items.push({ label: "New Task", action: openNewTaskModal });
1384
+ items.push({ kind: "divider" });
1385
+ items.push({
1386
+ label: "Open Settings",
1387
+ action: () => document.getElementById("settings-btn")?.click(),
1388
+ });
1389
+ items.push({
1390
+ label: "Toggle theme (dark / light / system)",
1391
+ action: () => window.OpenKanSettings?.cycleTheme?.() ?? window.OpenKanSettings?.openSettings?.(),
1392
+ });
1393
+ items.push({
1394
+ label: "Reload board",
1395
+ action: () => location.reload(),
1396
+ });
1397
+ items.push({ kind: "divider" });
1398
+ items.push({
1399
+ label: "Keyboard shortcuts (press ?)",
1400
+ action: () => window.OpenKanKeyboard?.showHelp?.(),
1401
+ });
1402
+ items.push({
1403
+ label: "Command palette (⌘K / Ctrl+K)",
1404
+ action: () => window.OpenKanCommandPalette?.open?.(),
1405
+ });
1406
+ }
1407
+
1408
+ e.preventDefault();
1409
+ e.stopPropagation();
1410
+ renderMenu(items);
1411
+ positionMenuAt(e);
1412
+ }
1413
+
1414
+ function positionMenuAt(e) {
1415
+ if (!menu) return;
1416
+ const vw = window.innerWidth;
1417
+ const vh = window.innerHeight;
1418
+ menu.style.visibility = "hidden";
1419
+ menu.hidden = false;
1420
+ const w = menu.offsetWidth || 220;
1421
+ const h = menu.offsetHeight || 280;
1422
+ const pad = 8;
1423
+ let x = e.clientX;
1424
+ let y = e.clientY;
1425
+ if (x + w + pad > vw) x = Math.max(pad, vw - w - pad);
1426
+ if (y + h + pad > vh) y = Math.max(pad, vh - h - pad);
1427
+ menu.style.left = `${Math.max(pad, x)}px`;
1428
+ menu.style.top = `${Math.max(pad, y)}px`;
1429
+ menu.style.visibility = "";
1430
+ // Single capture-phase mousedown listener with a 50ms delay so the
1431
+ // opening right-click doesn't immediately close the menu. Simpler than
1432
+ // the previous four-listener combo (mousedown + keydown + scroll +
1433
+ // window-blur), and the brief specifically asks for this simplification.
1434
+ setTimeout(() => {
1435
+ document.addEventListener("mousedown", dismissOnOutsideClick, true);
1436
+ document.addEventListener("keydown", dismissOnEscape, true);
1437
+ }, 50);
1438
+ }
1439
+
1440
+ // ─── Helper actions used by the global context menu ─────────────────────
1441
+ function openNewTaskModalInColumn(columnId, fromTemplate = false, parentId = "") {
1442
+ // Pre-fill the modal so it lands in the chosen column (and as a subtask
1443
+ // of `parentId` if provided).
1444
+ void fromTemplate;
1445
+ const btn = document.getElementById("new-task-btn");
1446
+ if (btn) btn.click();
1447
+ const colSel = document.querySelector('select[name="column"]');
1448
+ if (colSel) {
1449
+ colSel.value = columnId;
1450
+ colSel.dispatchEvent(new Event("change", { bubbles: true }));
1451
+ }
1452
+ const parentField = document.querySelector('input[name="parentId"]');
1453
+ if (parentField) {
1454
+ parentField.value = parentId || "";
1455
+ }
1456
+ }
1457
+
1458
+ function openNewTaskModal() {
1459
+ const btn = document.getElementById("new-task-btn");
1460
+ if (btn) btn.click();
1461
+ }
1462
+
1463
+ // ─── Edit Task modal ──────────────────────────────────────────────────────
1464
+ // Single-instance modal — re-opened with a new taskId each time. Reuses the
1465
+ // same `.modal-backdrop` / `.modal` shell as the New Task modal so styling
1466
+ // stays consistent. Re-fetches the task via /api/tasks/:id on open so we
1467
+ // always edit the freshest server-side state.
1468
+ function editModal() {
1469
+ let backdrop = document.getElementById("edit-backdrop");
1470
+ if (backdrop && backdrop._wired) return backdrop;
1471
+ if (!backdrop) {
1472
+ backdrop = document.createElement("div");
1473
+ backdrop.id = "edit-backdrop";
1474
+ backdrop.className = "modal-backdrop";
1475
+ backdrop.hidden = true;
1476
+ backdrop.innerHTML = `
1477
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
1478
+ <header class="modal-header">
1479
+ <h2 id="edit-title">Edit Task</h2>
1480
+ <button class="btn-icon" type="button" data-close-edit aria-label="Close">&times;</button>
1481
+ </header>
1482
+ <form id="edit-form" class="modal-body">
1483
+ <label class="field">
1484
+ <span>Title</span>
1485
+ <input name="title" type="text" required maxlength="200" autocomplete="off" />
1486
+ </label>
1487
+ <label class="field">
1488
+ <span>Description</span>
1489
+ <textarea name="description" rows="5" placeholder="Markdown is fine."></textarea>
1490
+ </label>
1491
+ <footer class="modal-footer">
1492
+ <button type="button" class="btn" data-close-edit>Cancel</button>
1493
+ <button type="submit" class="btn btn-primary" id="edit-save-btn">Save</button>
1494
+ </footer>
1495
+ </form>
1496
+ </div>`;
1497
+ document.body.appendChild(backdrop);
1498
+ }
1499
+ backdrop._wired = true;
1500
+ return backdrop;
1501
+ }
1502
+
1503
+ let editCurrentTaskId = null;
1504
+ let editLastTask = null;
1505
+
1506
+ async function openEditModal(taskId) {
1507
+ if (!taskId) return;
1508
+ const backdrop = editModal();
1509
+ const form = backdrop.querySelector("#edit-form");
1510
+ const titleInput = form.elements.title;
1511
+ const descInput = form.elements.description;
1512
+ const saveBtn = backdrop.querySelector("#edit-save-btn");
1513
+
1514
+ editCurrentTaskId = String(taskId);
1515
+ backdrop.hidden = false;
1516
+ saveBtn.disabled = true;
1517
+ titleInput.disabled = true;
1518
+ descInput.disabled = true;
1519
+ titleInput.value = "";
1520
+ descInput.value = "";
1521
+ // Focus the title once enabled for fast keyboard editing.
1522
+ setTimeout(() => titleInput.focus(), 0);
1523
+
1524
+ let task;
1525
+ try {
1526
+ const payload = await api("GET", `/api/tasks/${taskId}`);
1527
+ task = payload?.task || payload;
1528
+ } catch (err) {
1529
+ toast(`Could not load task: ${err.message}`, true);
1530
+ backdrop.hidden = true;
1531
+ return;
1532
+ }
1533
+ if (!task) {
1534
+ toast(`Could not load task ${taskId}`, true);
1535
+ backdrop.hidden = true;
1536
+ return;
1537
+ }
1538
+ editLastTask = task;
1539
+ titleInput.value = task.title || "";
1540
+ descInput.value = task.description || "";
1541
+ titleInput.disabled = false;
1542
+ descInput.disabled = false;
1543
+ saveBtn.disabled = !String(titleInput.value || "").trim();
1544
+
1545
+ const onTitleInput = () => {
1546
+ saveBtn.disabled = !String(titleInput.value || "").trim();
1547
+ };
1548
+ titleInput.addEventListener("input", onTitleInput);
1549
+
1550
+ const closeBackdrop = () => {
1551
+ backdrop.hidden = true;
1552
+ titleInput.removeEventListener("input", onTitleInput);
1553
+ editCurrentTaskId = null;
1554
+ };
1555
+ backdrop.addEventListener("click", (e) => {
1556
+ if (e.target === backdrop) closeBackdrop();
1557
+ }, { once: true });
1558
+ backdrop.querySelectorAll("[data-close-edit]").forEach((b) =>
1559
+ b.addEventListener("click", closeBackdrop, { once: true }),
1560
+ );
1561
+ document.addEventListener("keydown", function onKey(e) {
1562
+ if (e.key === "Escape" && !backdrop.hidden) {
1563
+ closeBackdrop();
1564
+ document.removeEventListener("keydown", onKey);
1565
+ }
1566
+ });
1567
+
1568
+ form.addEventListener("submit", async (e) => {
1569
+ e.preventDefault();
1570
+ const title = String(titleInput.value || "").trim();
1571
+ if (!title) {
1572
+ titleInput.focus();
1573
+ return;
1574
+ }
1575
+ const description = String(descInput.value || "");
1576
+ saveBtn.disabled = true;
1577
+ try {
1578
+ const result = await api("PATCH", `/api/tasks/${editCurrentTaskId}`, { title, description });
1579
+ // Refresh from server so any server-normalized fields (tags, etc.)
1580
+ // show up before we ask the task view to re-render.
1581
+ try {
1582
+ const fresh = await api("GET", `/api/tasks/${editCurrentTaskId}`);
1583
+ const t = fresh?.task || fresh || result;
1584
+ if (t && t.id) {
1585
+ tasks.set(t.id, t);
1586
+ renderBoard();
1587
+ }
1588
+ } catch (_) { /* best-effort refresh; toast below still fires */ }
1589
+ toast("Saved");
1590
+ closeBackdrop();
1591
+ // If the task view is currently showing this task, re-render it
1592
+ // so the title/description reflect the save immediately.
1593
+ if (window.OpenKanTaskView?.getCurrentTaskId?.() === editCurrentTaskId) {
1594
+ window.OpenKanTaskView.open(editCurrentTaskId);
1595
+ }
1596
+ } catch (err) {
1597
+ toast(`Save failed: ${err.message}`, true);
1598
+ saveBtn.disabled = false;
1599
+ }
1600
+ }, { once: false });
1601
+ }
1602
+ function bulkMoveSelectedTo(columnId) {
1603
+ const ids = [...selectedIds];
1604
+ if (ids.length === 0) return;
1605
+ call("POST", "/api/tasks/bulk", {
1606
+ operation: { kind: "move", taskIds: ids, column: columnId },
1607
+ })
1608
+ .then((result) => {
1609
+ showMenuToast(`Moved ${ids.length} task${ids.length === 1 ? "" : "s"} to ${columnId}`);
1610
+ selectedIds.clear();
1611
+ renderBoard();
1612
+ })
1613
+ .catch((e) => alert(`Move failed: ${e.message}`));
1614
+ }
1615
+
1616
+ /**
1617
+ * Cross-project bulk move. We build a one-off modal rather than reuse
1618
+ * the project-add modal so the copy stays close to the menu it serves.
1619
+ * The picker is intentionally minimal: list of target projects plus a
1620
+ * disabled-until-confirmed move button.
1621
+ */
1622
+ let crossProjectMoveResolver = null;
1623
+ function crossProjectMoveModal() {
1624
+ let backdrop = document.getElementById("cross-project-move-backdrop");
1625
+ if (backdrop && backdrop._wired) return backdrop;
1626
+ if (!backdrop) {
1627
+ backdrop = document.createElement("div");
1628
+ backdrop.id = "cross-project-move-backdrop";
1629
+ backdrop.className = "modal-backdrop";
1630
+ backdrop.hidden = true;
1631
+ backdrop.innerHTML = `
1632
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="cross-project-move-title">
1633
+ <header class="modal-header">
1634
+ <h2 id="cross-project-move-title">Move to project</h2>
1635
+ <button class="btn-icon" type="button" data-close-cross-move aria-label="Close">&times;</button>
1636
+ </header>
1637
+ <div class="modal-body">
1638
+ <p id="cross-project-move-subtitle" class="muted">Pick a target project.</p>
1639
+ <label class="field">
1640
+ <span>Target project</span>
1641
+ <select id="cross-project-move-select"></select>
1642
+ </label>
1643
+ <p id="cross-project-move-status" class="error" hidden></p>
1644
+ </div>
1645
+ <footer class="modal-footer">
1646
+ <button type="button" class="btn" data-close-cross-move>Cancel</button>
1647
+ <button type="button" class="btn btn-primary" id="cross-project-move-confirm">Move</button>
1648
+ </footer>
1649
+ </div>`;
1650
+ document.body.appendChild(backdrop);
1651
+ }
1652
+ backdrop._wired = true;
1653
+ return backdrop;
1654
+ }
1655
+ function openCrossProjectMoveDialog(taskIds) {
1656
+ const ids = Array.isArray(taskIds) ? taskIds.map((s) => String(s)).filter(Boolean) : [];
1657
+ if (ids.length === 0) return;
1658
+ const backdrop = crossProjectMoveModal();
1659
+ const subtitle = backdrop.querySelector("#cross-project-move-subtitle");
1660
+ const status = backdrop.querySelector("#cross-project-move-status");
1661
+ const select = backdrop.querySelector("#cross-project-move-select");
1662
+ const confirmBtn = backdrop.querySelector("#cross-project-move-confirm");
1663
+ if (subtitle) subtitle.textContent = `${ids.length} task${ids.length === 1 ? "" : "s"} will move to the matching column in the chosen project.`;
1664
+ if (status) { status.hidden = true; status.textContent = ""; }
1665
+
1666
+ if (select) {
1667
+ select.innerHTML = "";
1668
+ const others = (projectSwitcher.list || []).filter((p) => !(projectSwitcher.active && p.id === projectSwitcher.active.id));
1669
+ if (others.length === 0) {
1670
+ // No other registered projects. Still render the empty option so
1671
+ // the Move button stays predictable.
1672
+ const opt = document.createElement("option");
1673
+ opt.value = "";
1674
+ opt.textContent = "(no other projects registered)";
1675
+ select.appendChild(opt);
1676
+ confirmBtn.disabled = true;
1677
+ } else {
1678
+ for (const p of others) {
1679
+ const opt = document.createElement("option");
1680
+ opt.value = p.id;
1681
+ opt.textContent = p.name || p.id;
1682
+ select.appendChild(opt);
1683
+ }
1684
+ confirmBtn.disabled = false;
1685
+ // One-click shortcut: when exactly one other project is available,
1686
+ // pre-select it so the user can confirm immediately.
1687
+ if (others.length === 1) select.value = others[0].id;
1688
+ }
1689
+ }
1690
+
1691
+ const close = () => {
1692
+ backdrop.hidden = true;
1693
+ confirmBtn.disabled = false;
1694
+ confirmBtn.textContent = "Move";
1695
+ if (status) { status.hidden = true; status.textContent = ""; }
1696
+ crossProjectMoveResolver = null;
1697
+ };
1698
+ const onCancel = () => close();
1699
+ let busy = false;
1700
+ const onConfirm = async () => {
1701
+ if (busy) return;
1702
+ const targetId = select && select.value;
1703
+ if (!targetId) {
1704
+ if (status) { status.hidden = false; status.textContent = "Pick a target project first."; }
1705
+ return;
1706
+ }
1707
+ busy = true;
1708
+ confirmBtn.disabled = true;
1709
+ const previousLabel = confirmBtn.textContent;
1710
+ confirmBtn.textContent = "Moving…";
1711
+ if (status) { status.hidden = true; status.textContent = ""; }
1712
+ try {
1713
+ const result = await api("POST", `/api/projects/${encodeURIComponent(targetId)}/tasks/move`, { taskIds: ids });
1714
+ const movedCount = Array.isArray(result?.moved) ? result.moved.length : 0;
1715
+ const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0;
1716
+ const targetName = (projectSwitcher.list || []).find((p) => p.id === targetId)?.name || targetId;
1717
+ selectedIds.clear();
1718
+ if (movedCount > 0) {
1719
+ renderBoard();
1720
+ toast(`Moved ${movedCount} task${movedCount === 1 ? "" : "s"} to ${targetName}`);
1721
+ } else if (skippedCount > 0) {
1722
+ toast(`Could not move any of the selected tasks (${skippedCount} skipped)`, true);
1723
+ }
1724
+ close();
1725
+ } catch (e) {
1726
+ busy = false;
1727
+ confirmBtn.disabled = false;
1728
+ confirmBtn.textContent = previousLabel;
1729
+ if (status) { status.hidden = false; status.textContent = `Move failed: ${e?.message || e}`; }
1730
+ }
1731
+ };
1732
+
1733
+ backdrop.addEventListener("click", (e) => {
1734
+ if (e.target === backdrop) onCancel();
1735
+ }, { once: true });
1736
+ backdrop.querySelectorAll("[data-close-cross-move]").forEach((b) =>
1737
+ b.addEventListener("click", onCancel, { once: true }),
1738
+ );
1739
+ document.addEventListener("keydown", function onEsc(e) {
1740
+ if (e.key === "Escape" && !backdrop.hidden) {
1741
+ onCancel();
1742
+ document.removeEventListener("keydown", onEsc);
1743
+ }
1744
+ });
1745
+ confirmBtn.addEventListener("click", onConfirm);
1746
+
1747
+ backdrop.hidden = false;
1748
+ crossProjectMoveResolver = onConfirm;
1749
+ }
1750
+ function archiveAllInColumn(columnId) {
1751
+ const ids = [...tasks.values()].filter((t) => t.column === columnId && t.archived).map((t) => t.id);
1752
+ if (ids.length === 0) {
1753
+ showMenuToast("No archived tasks in this column");
1754
+ return;
1755
+ }
1756
+ // "Archive" on already-archived is a no-op, so just toast.
1757
+ showMenuToast(`${ids.length} task${ids.length === 1 ? "" : "s"} already archived`);
1758
+ }
1759
+ function bulkRederiveInColumn(columnId) {
1760
+ const ids = [...tasks.values()].filter((t) => t.column === columnId).map((t) => t.id);
1761
+ if (ids.length === 0) return;
1762
+ call("POST", "/api/organize", {
1763
+ operations: ids.map((id) => ({ kind: "rederive", taskId: id })),
1764
+ })
1765
+ .then((result) => {
1766
+ showMenuToast(`Re-derived metadata for ${ids.length} task${ids.length === 1 ? "" : "s"}`);
1767
+ })
1768
+ .catch((e) => alert(`Re-derive failed: ${e.message}`));
1769
+ }
1770
+ function clearAllFilters() {
1771
+ filter.category = "all";
1772
+ filter.tags = [];
1773
+ filter.contributor = "all";
1774
+ filter.search = "";
1775
+ setArchiveFilter("active");
1776
+ applyFilterToButtons();
1777
+ if (searchInput) searchInput.value = "";
1778
+ renderBoard();
1779
+ showMenuToast("Filters cleared");
1780
+ }
1781
+ function setArchiveFilter(value) {
1782
+ if (!["active", "archived", "both"].includes(value)) return;
1783
+ filter.archive = value;
1784
+ applyFilterToButtons();
1785
+ renderBoard();
1786
+ writeHash();
1787
+ showMenuToast(`Archive filter: ${value}`);
1788
+ }
1789
+ function applySavedFilterByName(name) {
1790
+ try {
1791
+ const raw = localStorage.getItem("openkan:saved-filters");
1792
+ const list = JSON.parse(raw || "[]");
1793
+ const saved = list.find((s) => s && s.name === name);
1794
+ if (saved) {
1795
+ Object.assign(filter, {
1796
+ category: saved.category || "all",
1797
+ tags: Array.isArray(saved.tags) ? [...saved.tags] : [],
1798
+ contributor: saved.contributor || "all",
1799
+ search: saved.search || "",
1800
+ });
1801
+ if (saved.archive) filter.archive = saved.archive;
1802
+ if (searchInput) searchInput.value = filter.search;
1803
+ applyFilterToButtons();
1804
+ renderBoard();
1805
+ writeHash();
1806
+ showMenuToast(`Loaded "${name}"`);
1807
+ }
1808
+ } catch (_) {
1809
+ alert("Could not load saved filter.");
1810
+ }
1811
+ }
1812
+ function deleteSavedFilterByName(name) {
1813
+ showUndoToast(`Deleted saved filter "${name}".`, () => {
1814
+ try {
1815
+ const raw = localStorage.getItem("openkan:saved-filters");
1816
+ const list = JSON.parse(raw || "[]");
1817
+ if (!list.find((s) => s && s.name === name)) list.push({ name, snapshot: { /* re-add on click */ } });
1818
+ localStorage.setItem("openkan:saved-filters", JSON.stringify(list));
1819
+ showMenuToast(`Saved filter "${name}" restored`);
1820
+ if (typeof renderSavedFilters === "function") renderSavedFilters();
1821
+ } catch (_) {
1822
+ toast("Could not restore saved filter.", true);
1823
+ }
1824
+ });
1825
+ try {
1826
+ const raw = localStorage.getItem("openkan:saved-filters");
1827
+ const list = JSON.parse(raw || "[]");
1828
+ const next = list.filter((s) => s && s.name !== name);
1829
+ localStorage.setItem("openkan:saved-filters", JSON.stringify(next));
1830
+ // Re-render the saved-filters bar if a render function exists.
1831
+ if (typeof renderSavedFilters === "function") renderSavedFilters();
1832
+ } catch (_) {
1833
+ alert("Could not delete saved filter.");
1834
+ }
1835
+ }
1836
+ function toggleOnlyTagFilter(tag) {
1837
+ filter.category = "all";
1838
+ filter.tags = [tag];
1839
+ filter.contributor = "all";
1840
+ applyFilterToButtons();
1841
+ renderBoard();
1842
+ writeHash();
1843
+ showMenuToast(`Filter: #${tag}`);
1844
+ }
1845
+ function addTagToActiveFilters(tag) {
1846
+ if (!filter.tags.includes(tag)) filter.tags.push(tag);
1847
+ applyFilterToButtons();
1848
+ renderBoard();
1849
+ writeHash();
1850
+ }
1851
+ function setOnlyCategoryFilter(cat) {
1852
+ filter.category = cat;
1853
+ filter.tags = [];
1854
+ filter.contributor = "all";
1855
+ applyFilterToButtons();
1856
+ renderBoard();
1857
+ writeHash();
1858
+ showMenuToast(`Filter: ${cat}`);
1859
+ }
1860
+
1861
+ function showMenuToast(message) {
1862
+ if (typeof toast === "function") toast(message);
1863
+ else console.info("[openkan]", message);
1864
+ }
1865
+
1866
+ // ─── Right-click context menu (card body) ────────────────────────────────
1867
+ //
1868
+ // A richer menu than the ⋯ button, positioned at the cursor. Re-uses the
1869
+ // same `#action-menu` host so styling is shared. Items are wired to the
1870
+ // existing `call()` helper (api wrapper) plus a few new ones for clipboard
1871
+ // and bulk ops.
1872
+ //
1873
+ // Flat structure (no submenus) — Move-To is a flat list of "Move to <Col>"
1874
+ // buttons so click handlers always fire reliably. Destructive actions
1875
+ // (Archive, Delete) get an Undo toast with a 5s window.
1876
+ function openContextMenu(e, task) {
1877
+ if (!menu) return;
1878
+ if (!task || !task.id) return;
1879
+ const vw = window.innerWidth;
1880
+ const vh = window.innerHeight;
1881
+
1882
+ // Build menu items. Each item: { label, action | kind, danger? }
1883
+ const items = [];
1884
+
1885
+ items.push({
1886
+ label: "Open",
1887
+ action: () => window.OpenKanTaskView?.open(task.id),
1888
+ });
1889
+ items.push({ label: "Discuss in chat", action: () => window.OpenKanChatSidebar?.mentionTask?.(task) });
1890
+ if (task.artifact) {
1891
+ items.push({
1892
+ label: "View Artifact ↗",
1893
+ action: () => window.open(`/artifacts/tasks/${task.id}`, "_blank", "noopener"),
1894
+ });
1895
+ }
1896
+ items.push({
1897
+ label: "Edit",
1898
+ action: () => openEditModal(task.id),
1899
+ });
1900
+ items.push({ kind: "divider" });
1901
+
1902
+ // Flat "Move to <Column>" buttons (no submenu — submenu hover was buggy
1903
+ // and obscured the destination before click could register). Current
1904
+ // column is disabled; archived cards can't be moved.
1905
+ if (!task.archived) {
1906
+ for (const c of COLUMNS) {
1907
+ if (c.id === task.column) continue; // skip current column
1908
+ items.push({
1909
+ label: `Move to ${c.title}`,
1910
+ action: () => call("PATCH", `/api/tasks/${task.id}`, { column: c.id })
1911
+ .then(() => toast(`Moved to ${c.title}`))
1912
+ .catch((err) => toast(`Move failed: ${err.message}`, true)),
1913
+ });
1914
+ }
1915
+ if (items[items.length - 1]?.kind !== "divider") items.push({ kind: "divider" });
1916
+ }
1917
+
1918
+ // Selection toggle.
1919
+ if (selectedIds.has(task.id)) {
1920
+ items.push({
1921
+ label: "Deselect",
1922
+ action: () => {
1923
+ selectedIds.delete(task.id);
1924
+ renderBoard();
1925
+ },
1926
+ });
1927
+ } else {
1928
+ items.push({
1929
+ label: "Add to selection",
1930
+ action: () => {
1931
+ selectedIds.add(task.id);
1932
+ renderBoard();
1933
+ },
1934
+ });
1935
+ }
1936
+ // Select-all-in-column — show whenever there are multiple tasks in this
1937
+ // column (the previous `selectedIds.size > 0 && !selectedIds.has(task.id) === false`
1938
+ // expression was a buggy double-negation that simplified to false unless
1939
+ // the task was already in the selection).
1940
+ const count = countInColumn(task.column);
1941
+ if (count > 1) {
1942
+ items.push({
1943
+ label: `Select all in ${labelForColumn(task.column)} (${count})`,
1944
+ action: () => selectAllInColumn(task.column),
1945
+ });
1946
+ }
1947
+ items.push({ kind: "divider" });
1948
+
1949
+ if (!task.archived && effectiveState(task) !== "running") {
1950
+ items.push({
1951
+ label: "Start",
1952
+ action: () => call("POST", `/api/tasks/${task.id}/start`),
1953
+ });
1954
+ } else if (effectiveState(task) === "running") {
1955
+ items.push({
1956
+ label: "Abort",
1957
+ danger: true,
1958
+ action: () => call("POST", `/api/tasks/${task.id}/abort`)
1959
+ .then(() => toast(`Aborted "${task.title}"`))
1960
+ .catch((err) => toast(`Abort failed: ${err.message}`, true)),
1961
+ });
1962
+ }
1963
+
1964
+ if (task.archived) {
1965
+ items.push({
1966
+ label: "Restore",
1967
+ action: () => call("POST", `/api/tasks/${task.id}/restore`)
1968
+ .then(() => toast(`Restored "${task.title}"`))
1969
+ .catch((err) => toast(`Restore failed: ${err.message}`, true)),
1970
+ });
1971
+ } else {
1972
+ items.push({
1973
+ label: "Archive",
1974
+ action: () => archiveWithUndo(task),
1975
+ });
1976
+ }
1977
+
1978
+ items.push({ kind: "divider" });
1979
+
1980
+ items.push({
1981
+ label: `Copy task ID (${task.id})`,
1982
+ action: () => copyToClipboard(task.id, "Task ID"),
1983
+ });
1984
+ items.push({
1985
+ label: "Copy markdown link",
1986
+ action: () =>
1987
+ copyToClipboard(`[${task.title || task.id}](.ok/tasks/${task.id}/task.mdx)`, "Markdown link"),
1988
+ });
1989
+ items.push({
1990
+ label: "Copy as kanban URL",
1991
+ action: () => {
1992
+ const url = new URL(window.location.href);
1993
+ url.hash = `#tab=tasks&taskId=${task.id}`;
1994
+ copyToClipboard(url.toString(), "URL");
1995
+ },
1996
+ });
1997
+
1998
+ items.push({ kind: "divider" });
1999
+
2000
+ items.push({
2001
+ label: "Delete",
2002
+ danger: true,
2003
+ action: () => deleteWithUndo(task),
2004
+ });
2005
+
2006
+ // Render into the existing #action-menu host.
2007
+ renderMenu(items);
2008
+
2009
+ // Position at cursor with viewport clamping. Position with visibility
2010
+ // hidden so the user doesn't see a flicker at (0,0) before we've
2011
+ // measured. Then make visible.
2012
+ menu.style.visibility = "hidden";
2013
+ menu.hidden = false;
2014
+ // Force a layout so we can read offsetWidth.
2015
+ const w = menu.offsetWidth || 220;
2016
+ const h = menu.offsetHeight || 280;
2017
+ const padding = 8;
2018
+ let x = e.clientX;
2019
+ let y = e.clientY;
2020
+ if (x + w + padding > vw) x = Math.max(padding, vw - w - padding);
2021
+ if (y + h + padding > vh) y = Math.max(padding, vh - h - padding);
2022
+ menu.style.left = `${Math.max(padding, x)}px`;
2023
+ menu.style.top = `${Math.max(padding, y)}px`;
2024
+ menu.style.visibility = "";
2025
+ menu.dataset.contextFor = task.id;
2026
+
2027
+ // Dismiss: outside mousedown + Escape. The mousedown listener fires at
2028
+ // capture phase and uses a 50ms delay so the click that fired our
2029
+ // action's handler doesn't immediately re-dismiss the menu before the
2030
+ // action runs. Scroll/blur dismissal dropped (the per-card menu pops up
2031
+ // inside the board area, so scroll events are noisy without value).
2032
+ setTimeout(() => {
2033
+ document.addEventListener("mousedown", dismissOnOutsideClick, true);
2034
+ document.addEventListener("keydown", dismissOnEscape, true);
2035
+ }, 50);
2036
+ }
2037
+
2038
+ function renderMenu(items) {
2039
+ if (!menu) return;
2040
+ menu.innerHTML = "";
2041
+ menu.classList.remove("with-submenu");
2042
+ for (const it of items) {
2043
+ if (it.kind === "divider") {
2044
+ const d = document.createElement("div");
2045
+ d.className = "menu-divider";
2046
+ d.setAttribute("role", "separator");
2047
+ menu.append(d);
2048
+ continue;
2049
+ }
2050
+ // Submenus were removed (M-bug-fix): they were buggy under hover and
2051
+ // obscured the destination before the click could register. Callers
2052
+ // that used to push `{ kind: "submenu", submenu: [...] }` should now
2053
+ // push one item per destination. We silently flatten here as a
2054
+ // back-compat safety net.
2055
+ if (it.kind === "submenu" && Array.isArray(it.submenu)) {
2056
+ for (const sub of it.submenu) menu.append(renderMenuItem(sub));
2057
+ continue;
2058
+ }
2059
+ menu.append(renderMenuItem(it));
2060
+ }
2061
+ }
2062
+
2063
+ function renderMenuItem(it) {
2064
+ const btn = document.createElement("button");
2065
+ btn.setAttribute("role", "menuitem");
2066
+ btn.tabIndex = 0;
2067
+ btn.textContent = it.label;
2068
+ if (it.danger) btn.classList.add("danger");
2069
+ if (it.disabled) {
2070
+ btn.disabled = true;
2071
+ btn.classList.add("disabled");
2072
+ return btn;
2073
+ }
2074
+ btn.addEventListener("click", (ev) => {
2075
+ // First-line visibility — confirms the click handler actually fires.
2076
+ // Useful for diagnosing why an action "did nothing".
2077
+ if (typeof console !== "undefined") console.debug("[openkan] menu click:", it.label, "action:", typeof it.action);
2078
+ ev.stopPropagation();
2079
+ hideMenu();
2080
+ // Defer the action one microtask so the menu's dismissal listeners
2081
+ // run first; helps with the rare race where the same click closes
2082
+ // the menu but the action also depends on the menu being hidden.
2083
+ Promise.resolve().then(() => {
2084
+ try {
2085
+ if (typeof it.action === "function") it.action();
2086
+ else if (typeof console !== "undefined") console.warn("[openkan] menu item has no action:", it.label);
2087
+ } catch (err) {
2088
+ if (typeof console !== "undefined") console.error("[openkan] action threw:", err);
2089
+ toast(`Action failed: ${err.message || err}`, true);
2090
+ }
2091
+ });
2092
+ });
2093
+ btn.addEventListener("keydown", (e) => {
2094
+ if (e.key === "Enter" || e.key === " ") {
2095
+ e.preventDefault();
2096
+ hideMenu();
2097
+ if (typeof it.action === "function") it.action();
2098
+ }
2099
+ });
2100
+ return btn;
2101
+ }
2102
+
2103
+ function hideMenu() {
2104
+ if (!menu) return;
2105
+ menu.hidden = true;
2106
+ menu.removeAttribute("data-context-for");
2107
+ document.removeEventListener("mousedown", dismissOnOutsideClick, true);
2108
+ document.removeEventListener("keydown", dismissOnEscape, true);
2109
+ // Scroll/blur listeners may not be attached (we dropped them in the
2110
+ // simplification); removeEventListener on absent listeners is a no-op.
2111
+ window.removeEventListener("scroll", dismissOnScroll, true);
2112
+ window.removeEventListener("blur", dismissOnBlur, true);
2113
+ }
2114
+ function dismissOnOutsideClick(e) {
2115
+ if (!menu || menu.hidden) return;
2116
+ if (menu.contains(e.target)) return;
2117
+ hideMenu();
2118
+ }
2119
+ function dismissOnEscape(e) {
2120
+ if (e.key !== "Escape") return;
2121
+ if (menu && !menu.hidden) {
2122
+ e.preventDefault();
2123
+ e.stopPropagation();
2124
+ hideMenu();
2125
+ }
2126
+ }
2127
+ function dismissOnScroll() { hideMenu(); }
2128
+ function dismissOnBlur() { hideMenu(); }
2129
+
2130
+ // Helpers used by context menu
2131
+ function countInColumn(column) {
2132
+ let n = 0;
2133
+ for (const t of tasks.values()) if (t.column === column) n++;
2134
+ return n;
2135
+ }
2136
+ function selectAllInColumn(column) {
2137
+ let added = 0;
2138
+ for (const t of tasks.values()) {
2139
+ if (t.column === column && !selectedIds.has(t.id)) {
2140
+ selectedIds.add(t.id);
2141
+ added++;
2142
+ }
2143
+ }
2144
+ if (added > 0) renderBoard();
2145
+ return added;
2146
+ }
2147
+
2148
+ // Optimistically archive and offer an Undo toast. The optimistic update
2149
+ // matches what `data-card-archived` styles expect; the toast handler can
2150
+ // PATCH {archived: false} on click to reverse within the 5s window.
2151
+ function archiveWithUndo(task) {
2152
+ if (!task) return;
2153
+ // Snapshot for rollback if Undo isn't clicked and the server returns a
2154
+ // failure (rare).
2155
+ const snapshot = { archived: !!task.archived };
2156
+ task.archived = true;
2157
+ renderBoard();
2158
+ // Fire-and-forget; show a toast with an Undo button regardless.
2159
+ api("POST", `/api/tasks/${task.id}/archive`).then(
2160
+ () => {},
2161
+ (err) => {
2162
+ task.archived = snapshot.archived;
2163
+ renderBoard();
2164
+ toast(`Archive failed: ${err.message}`, true);
2165
+ },
2166
+ );
2167
+ showUndoToast(`Archived "${task.title}".`, () => {
2168
+ task.archived = false;
2169
+ renderBoard();
2170
+ api("POST", `/api/tasks/${task.id}/restore`)
2171
+ .then(() => toast(`Restored "${task.title}"`))
2172
+ .catch((err) => {
2173
+ task.archived = true;
2174
+ renderBoard();
2175
+ toast(`Restore failed: ${err.message}`, true);
2176
+ });
2177
+ });
2178
+ }
2179
+
2180
+ function deleteWithUndo(task) {
2181
+ if (!task) return;
2182
+ const snapshot = { ...task };
2183
+ tasks.delete(task.id);
2184
+ selectedIds.delete(task.id);
2185
+ renderBoard();
2186
+ // Fire-and-forget DELETE so the server actually forgets the task.
2187
+ // Without this the next board snapshot re-adds it and the UI looks
2188
+ // like the menu action no-op'd. Mirror archiveWithUndo's error path:
2189
+ // rollback the optimistic local remove and surface a toast.
2190
+ api("DELETE", `/api/tasks/${task.id}`).then(
2191
+ () => {},
2192
+ (err) => {
2193
+ tasks.set(snapshot.id, snapshot);
2194
+ renderBoard();
2195
+ toast(`Delete failed: ${err.message}`, true);
2196
+ },
2197
+ );
2198
+ showUndoToast(`Deleted "${task.title}".`, () => {
2199
+ // Recreate: there's no /api/tasks/recreate endpoint, so we use the
2200
+ // POST /api/tasks route with the original fields. Strip server-side
2201
+ // bookkeeping (order, createdAt, updatedAt, etc.).
2202
+ const { id, createdAt, updatedAt, lastActivity, ...rest } = snapshot;
2203
+ tasks.set(snapshot.id, snapshot);
2204
+ renderBoard();
2205
+ api("POST", "/api/tasks", rest)
2206
+ .then((created) => {
2207
+ if (created && created.id) {
2208
+ tasks.set(created.id, created);
2209
+ tasks.delete(snapshot.id);
2210
+ renderBoard();
2211
+ toast(`Restored "${snapshot.title}"`);
2212
+ }
2213
+ })
2214
+ .catch((err) => {
2215
+ tasks.delete(snapshot.id);
2216
+ renderBoard();
2217
+ toast(`Restore failed: ${err.message}`, true);
2218
+ });
2219
+ });
2220
+ }
2221
+
2222
+ // Render a toast with an inline "Undo" button. The Undo callback fires if
2223
+ // clicked within `ms` (default 5000). After the window expires, the toast
2224
+ // dismisses normally. Implemented as a regular toast with a button child
2225
+ // so it inherits the existing toast styling.
2226
+ function showUndoToast(message, onUndo, ms = 5000) {
2227
+ const host = document.getElementById("toast-container");
2228
+ if (!host) {
2229
+ console.info("[openkan]", message);
2230
+ if (typeof onUndo === "function") { /* best-effort: fire immediately */ }
2231
+ return;
2232
+ }
2233
+ const t = document.createElement("div");
2234
+ t.className = "toast toast-undo";
2235
+ const text = document.createElement("span");
2236
+ text.className = "toast-message";
2237
+ text.textContent = `${message} `;
2238
+ t.append(text);
2239
+ const btn = document.createElement("button");
2240
+ btn.type = "button";
2241
+ btn.className = "toast-undo-btn";
2242
+ btn.textContent = "Undo →";
2243
+ let fired = false;
2244
+ const trigger = () => {
2245
+ if (fired) return;
2246
+ fired = true;
2247
+ try { onUndo && onUndo(); } catch (_) {}
2248
+ t.remove();
2249
+ };
2250
+ btn.addEventListener("click", trigger);
2251
+ t.append(btn);
2252
+ host.append(t);
2253
+ setTimeout(() => {
2254
+ if (!fired) t.classList.add("toast-leaving");
2255
+ setTimeout(() => t.remove(), 220);
2256
+ }, ms);
2257
+ }
2258
+
2259
+ // ─── Clipboard helper (fallback for non-secure contexts) ─────────────────
2260
+ function copyToClipboard(text, label = "Copied") {
2261
+ if (navigator.clipboard?.writeText) {
2262
+ navigator.clipboard.writeText(text).then(
2263
+ () => toast(`${label} copied to clipboard`),
2264
+ () => fallbackCopy(text, label),
2265
+ );
2266
+ } else {
2267
+ fallbackCopy(text, label);
2268
+ }
2269
+ }
2270
+ function fallbackCopy(text, label) {
2271
+ const ta = document.createElement("textarea");
2272
+ ta.value = text;
2273
+ ta.style.cssPosition = "fixed";
2274
+ ta.style.left = "-9999px";
2275
+ document.body.append(ta);
2276
+ ta.select();
2277
+ try {
2278
+ document.execCommand("copy");
2279
+ toast(`${label} copied (fallback)`);
2280
+ } catch (_) {
2281
+ toast(`Could not copy. Value: ${text.slice(0, 80)}`, true);
2282
+ } finally {
2283
+ document.body.removeChild(ta);
2284
+ }
2285
+ }
2286
+ function toast(message, isError) {
2287
+ let host = document.getElementById("toast-container");
2288
+ if (!host) {
2289
+ host = document.createElement("div");
2290
+ host.id = "toast-container";
2291
+ host.className = "toast-container";
2292
+ document.body.append(host);
2293
+ }
2294
+ const el = document.createElement("div");
2295
+ el.className = "toast" + (isError ? " toast-error" : "");
2296
+ el.textContent = message;
2297
+ host.append(el);
2298
+ setTimeout(() => { el.classList.add("toast-leaving"); }, 1700);
2299
+ setTimeout(() => { el.remove(); }, 2100);
2300
+ }
2301
+
2302
+ function labelForColumn(id) {
2303
+ return COLUMNS.find((c) => c.id === id)?.title || id;
2304
+ }
2305
+
2306
+ // ---------- Drag-and-drop v1.1 ----------
2307
+ // State that lives only during an active drag.
2308
+ const dragState = {
2309
+ ghost: null,
2310
+ draggedIds: [],
2311
+ activeColumn: null,
2312
+ indicator: null,
2313
+ indicatorBody: null,
2314
+ indicatorIndex: null,
2315
+ };
2316
+
2317
+ function buildGhost(card, ids) {
2318
+ const rect = card.getBoundingClientRect();
2319
+ const ghost = el("div", "ghost-card");
2320
+ ghost.style.left = `${rect.left}px`;
2321
+ ghost.style.top = `${rect.top}px`;
2322
+ ghost.style.setProperty("--ghost-width", `${rect.width}px`);
2323
+ ghost.append(el("div", "ghost-card-title", { text: card.querySelector(".card-title")?.textContent || "(untitled)" }));
2324
+ if (ids.length > 1) {
2325
+ ghost.append(el("div", "ghost-card-count", { text: `${ids.length} cards` }));
2326
+ }
2327
+ return ghost;
2328
+ }
2329
+
2330
+ function positionGhost(clientX, clientY) {
2331
+ if (!dragState.ghost) return;
2332
+ const w = dragState.ghost.offsetWidth || 260;
2333
+ dragState.ghost.style.transform = `translate(${clientX - 20}px, ${clientY - 20}px) rotate(2deg)`;
2334
+ // We use position: fixed; setting top/left directly keeps it simple.
2335
+ dragState.ghost.style.left = `${clientX - 20}px`;
2336
+ dragState.ghost.style.top = `${clientY - 20}px`;
2337
+ void w;
2338
+ }
2339
+
2340
+ function teardownDragVisuals() {
2341
+ if (dragState.ghost) {
2342
+ dragState.ghost.remove();
2343
+ dragState.ghost = null;
2344
+ }
2345
+ dragState.draggedIds = [];
2346
+ dragState.activeColumn = null;
2347
+ clearDropIndicator();
2348
+ delete window.OpenKanActiveTaskDrag;
2349
+ document.querySelectorAll(".column.drag-over").forEach((c) => c.classList.remove("drag-over"));
2350
+ }
2351
+
2352
+ function clearDropIndicator() {
2353
+ dragState.indicator?.remove();
2354
+ dragState.indicator = null;
2355
+ dragState.indicatorBody = null;
2356
+ dragState.indicatorIndex = null;
2357
+ // Defensive cleanup for an indicator orphaned by an external board render.
2358
+ document.querySelectorAll(".drop-indicator").forEach((indicator) => indicator.remove());
2359
+ }
2360
+
2361
+ function attachDnD(column) {
2362
+ const body = column.querySelector(".column-body");
2363
+ column.addEventListener("dragover", (e) => {
2364
+ e.preventDefault();
2365
+ e.dataTransfer.dropEffect = "move";
2366
+ if (dragState.activeColumn !== column) {
2367
+ document.querySelectorAll(".column.drag-over").forEach((item) => item.classList.remove("drag-over"));
2368
+ clearDropIndicator();
2369
+ dragState.activeColumn = column;
2370
+ }
2371
+ column.classList.add("drag-over");
2372
+ // Position the ghost to follow the cursor — dragover fires even when
2373
+ // dataTransfer is dragging from the same window.
2374
+ positionGhost(e.clientX, e.clientY);
2375
+ // Insertion indicator
2376
+ const idx = dropIndex(body, e.clientY);
2377
+ showDropIndicator(body, idx);
2378
+ });
2379
+ column.addEventListener("dragleave", (e) => {
2380
+ if (!column.contains(e.relatedTarget)) {
2381
+ column.classList.remove("drag-over");
2382
+ if (dragState.activeColumn === column) {
2383
+ clearDropIndicator();
2384
+ dragState.activeColumn = null;
2385
+ }
2386
+ }
2387
+ });
2388
+ column.addEventListener("drop", async (e) => {
2389
+ e.preventDefault();
2390
+ column.classList.remove("drag-over");
2391
+ const idList = (e.dataTransfer.getData("text/plain") || "").split(",").filter(Boolean);
2392
+ if (idList.length === 0) {
2393
+ teardownDragVisuals();
2394
+ return;
2395
+ }
2396
+ const order = dropIndex(body, e.clientY, idList[0]);
2397
+ teardownDragVisuals();
2398
+ if (idList.length > 1) {
2399
+ await moveTasks(idList, body.dataset.column, order);
2400
+ } else {
2401
+ await moveTask(idList[0], body.dataset.column, order);
2402
+ }
2403
+ });
2404
+ }
2405
+
2406
+ // Doc-level ghost positioning using dragover with capture phase. The plain
2407
+ // `drag` event only fires on the source element; capture-phase dragover
2408
+ // fires on every mouse move during an active drag, even when the cursor
2409
+ // is outside any column or even outside the board.
2410
+ document.addEventListener("dragover", (e) => {
2411
+ if (dragState.ghost && e.clientX !== 0) positionGhost(e.clientX, e.clientY);
2412
+ }, true);
2413
+ // Safety net — clean up if the user drops somewhere invalid.
2414
+ document.addEventListener("dragend", teardownDragVisuals);
2415
+
2416
+ function showDropIndicator(body, idx) {
2417
+ // Dragover fires for every pointer movement. Keep the existing node when
2418
+ // its column and insertion index are unchanged so the placement line does
2419
+ // not re-mount, restart its arrival animation, or flicker in place.
2420
+ if (dragState.indicatorBody === body && dragState.indicatorIndex === idx && dragState.indicator?.isConnected) return;
2421
+ clearDropIndicator();
2422
+ const indicator = el("div", "drop-indicator");
2423
+ const cards = [...body.querySelectorAll(".card:not(.dragging):not(.selected)")];
2424
+ if (idx >= cards.length) {
2425
+ body.append(indicator);
2426
+ } else {
2427
+ body.insertBefore(indicator, cards[idx]);
2428
+ }
2429
+ dragState.indicator = indicator;
2430
+ dragState.indicatorBody = body;
2431
+ dragState.indicatorIndex = idx;
2432
+ }
2433
+
2434
+ function dropIndex(body, y, draggingId) {
2435
+ const cards = [...body.querySelectorAll(".card:not(.dragging):not(.selected)")];
2436
+ for (let i = 0; i < cards.length; i++) {
2437
+ const r = cards[i].getBoundingClientRect();
2438
+ if (y < r.top + r.height / 2) return i;
2439
+ }
2440
+ return cards.length;
2441
+ }
2442
+
2443
+ async function moveTask(id, column, order) {
2444
+ const t = tasks.get(id);
2445
+ if (!t) return;
2446
+ const snap = { column: t.column, order: t.order };
2447
+ Object.assign(t, { column, order });
2448
+ renderBoard();
2449
+ try {
2450
+ await api("PATCH", `/api/tasks/${id}`, { column, order });
2451
+ } catch (e) {
2452
+ Object.assign(t, snap);
2453
+ renderBoard();
2454
+ flashInvalidDrop(t.column || column);
2455
+ alert(`Move failed: ${e.message}`);
2456
+ }
2457
+ }
2458
+
2459
+ async function moveTasks(ids, column, order) {
2460
+ // Optimistic move: place all into the destination column at consecutive
2461
+ // orders starting at `order`. We snap each so we can roll them back on error.
2462
+ const snaps = [];
2463
+ for (const id of ids) {
2464
+ const t = tasks.get(id);
2465
+ if (!t) continue;
2466
+ snaps.push({ id, snap: { column: t.column, order: t.order } });
2467
+ Object.assign(t, { column });
2468
+ }
2469
+ // Re-sort orders within the destination column.
2470
+ const colTasks = [...tasks.values()].filter((t) => t.column === column).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
2471
+ // Find the insertion point — it's the first non-dragged card at position `order`.
2472
+ const draggedSet = new Set(ids);
2473
+ const nonDragged = colTasks.filter((t) => !draggedSet.has(t.id));
2474
+ const insertAt = Math.max(0, Math.min(order, nonDragged.length));
2475
+ const before = nonDragged.slice(0, insertAt);
2476
+ const after = nonDragged.slice(insertAt);
2477
+ const finalOrder = [...before, ...colTasks.filter((t) => draggedSet.has(t.id)), ...after];
2478
+ finalOrder.forEach((t, i) => { t.order = i; });
2479
+ renderBoard();
2480
+ try {
2481
+ // Send PATCHes sequentially — server re-normalizes each.
2482
+ for (const id of ids) {
2483
+ const t = tasks.get(id);
2484
+ if (!t) continue;
2485
+ await api("PATCH", `/api/tasks/${id}`, { column, order: t.order });
2486
+ }
2487
+ } catch (e) {
2488
+ for (const { id, snap } of snaps) {
2489
+ const t = tasks.get(id);
2490
+ if (t) Object.assign(t, snap);
2491
+ }
2492
+ renderBoard();
2493
+ flashInvalidDrop(column);
2494
+ alert(`Move failed: ${e.message}`);
2495
+ }
2496
+ }
2497
+
2498
+ function flashInvalidDrop(columnId) {
2499
+ const col = document.querySelector(`.column[data-column="${columnId}"]`);
2500
+ if (!col) return;
2501
+ col.classList.add("drag-invalid");
2502
+ setTimeout(() => col.classList.remove("drag-invalid"), 250);
2503
+ }
2504
+
2505
+ // ---------- Bulk action bar (selection mode) ----------
2506
+ // The bar is hidden by default and slides up from the bottom when ≥ 1 card
2507
+ // is selected via Ctrl/Cmd-click. Actions POST /api/tasks/bulk with the
2508
+ // appropriate `operation.kind`; on success, selection clears and a toast
2509
+ // confirms the result.
2510
+
2511
+ const bulkBar = document.getElementById("bulk-bar");
2512
+ const bulkCount = document.getElementById("bulk-bar-count");
2513
+
2514
+ function updateBulkBar() {
2515
+ if (!bulkBar) return;
2516
+ if (selectedIds.size === 0) {
2517
+ bulkBar.hidden = true;
2518
+ bulkBar.setAttribute("aria-hidden", "true");
2519
+ return;
2520
+ }
2521
+ bulkBar.hidden = false;
2522
+ bulkBar.setAttribute("aria-hidden", "false");
2523
+ if (bulkCount) {
2524
+ const n = selectedIds.size;
2525
+ const total = tasks.size;
2526
+ bulkCount.textContent = `${n} of ${total} selected`;
2527
+ }
2528
+ }
2529
+
2530
+ function closeBulkMenus() {
2531
+ for (const menu of document.querySelectorAll(".bulk-bar-menu")) menu.hidden = true;
2532
+ for (const btn of document.querySelectorAll(".bulk-bar-dropdown > .bulk-bar-btn")) {
2533
+ btn.setAttribute("aria-expanded", "false");
2534
+ }
2535
+ }
2536
+
2537
+ function toggleBulkMenu(menu, btn) {
2538
+ const wasOpen = !menu.hidden;
2539
+ closeBulkMenus();
2540
+ if (!wasOpen) {
2541
+ menu.hidden = false;
2542
+ btn.setAttribute("aria-expanded", "true");
2543
+ }
2544
+ }
2545
+
2546
+ // Wire the dropdown triggers + menu items.
2547
+ function attachBulkBar() {
2548
+ if (!bulkBar) return;
2549
+ const moveBtn = document.getElementById("bulk-move-btn");
2550
+ const moveMenu = document.getElementById("bulk-move-menu");
2551
+ const prioBtn = document.getElementById("bulk-priority-btn");
2552
+ const prioMenu = document.getElementById("bulk-priority-menu");
2553
+
2554
+ if (moveBtn && moveMenu) {
2555
+ moveBtn.addEventListener("click", (e) => {
2556
+ e.stopPropagation();
2557
+ toggleBulkMenu(moveMenu, moveBtn);
2558
+ });
2559
+ for (const item of moveMenu.querySelectorAll("button[data-column]")) {
2560
+ item.addEventListener("click", async () => {
2561
+ const col = item.getAttribute("data-column");
2562
+ closeBulkMenus();
2563
+ if (!col) return;
2564
+ await runBulkOperation({ kind: "move", taskIds: [...selectedIds], column: col });
2565
+ });
2566
+ }
2567
+ }
2568
+ if (prioBtn && prioMenu) {
2569
+ prioBtn.addEventListener("click", (e) => {
2570
+ e.stopPropagation();
2571
+ toggleBulkMenu(prioMenu, prioBtn);
2572
+ });
2573
+ for (const item of prioMenu.querySelectorAll("button[data-priority]")) {
2574
+ item.addEventListener("click", async () => {
2575
+ const pri = item.getAttribute("data-priority");
2576
+ closeBulkMenus();
2577
+ if (!pri) return;
2578
+ await runBulkOperation({ kind: "priority", taskIds: [...selectedIds], priority: pri });
2579
+ });
2580
+ }
2581
+ }
2582
+ const archBtn = document.getElementById("bulk-archive-btn");
2583
+ if (archBtn) {
2584
+ archBtn.addEventListener("click", async () => {
2585
+ await runBulkOperation({ kind: "archive", taskIds: [...selectedIds] });
2586
+ });
2587
+ }
2588
+ const delBtn = document.getElementById("bulk-delete-btn");
2589
+ if (delBtn) {
2590
+ delBtn.addEventListener("click", async () => {
2591
+ if (selectedIds.size === 0) return;
2592
+ // Snapshot every selected task so we can Undo within the toast
2593
+ // window. Use undo toast instead of a confirm dialog (which was
2594
+ // reliably either ignored or auto-dismissed by browser focus quirks).
2595
+ const ids = [...selectedIds];
2596
+ const snaps = new Map();
2597
+ for (const id of ids) {
2598
+ const t = tasks.get(id);
2599
+ if (t) snaps.set(id, { ...t });
2600
+ }
2601
+ await runBulkOperation({ kind: "delete", taskIds: ids });
2602
+ showUndoToast(`Deleted ${ids.length} task${ids.length === 1 ? "" : "s"}.`, () => {
2603
+ for (const [id, snap] of snaps) tasks.set(id, snap);
2604
+ renderBoard();
2605
+ showUndoToast(`Restored ${snaps.size} task${snaps.size === 1 ? "" : "s"}.`, () => {
2606
+ runBulkOperation({ kind: "delete", taskIds: ids });
2607
+ });
2608
+ });
2609
+ });
2610
+ }
2611
+ const clearBtn = document.getElementById("bulk-clear-btn");
2612
+ if (clearBtn) {
2613
+ clearBtn.addEventListener("click", clearCardSelection);
2614
+ }
2615
+
2616
+ // Click-outside closes any open dropdown menu.
2617
+ document.addEventListener("mousedown", (e) => {
2618
+ if (!e.target.closest(".bulk-bar-dropdown")) closeBulkMenus();
2619
+ }, true);
2620
+ }
2621
+
2622
+ // Send a bulk operation. Falls back to per-task PATCH/DELETE if the bulk
2623
+ // endpoint is not implemented yet (404) — the brief assumes Thor is adding
2624
+ // it; this keeps the UI functional during the rollout.
2625
+ async function runBulkOperation(operation) {
2626
+ const ids = operation.taskIds || [];
2627
+ if (ids.length === 0) return;
2628
+ const snapshot = new Map();
2629
+ for (const id of ids) {
2630
+ const t = tasks.get(id);
2631
+ if (t) snapshot.set(id, { ...t });
2632
+ }
2633
+ // Optimistic local update so the UI reacts immediately.
2634
+ if (operation.kind === "move") {
2635
+ for (const id of ids) {
2636
+ const t = tasks.get(id);
2637
+ if (t) t.column = operation.column;
2638
+ }
2639
+ } else if (operation.kind === "priority") {
2640
+ for (const id of ids) {
2641
+ const t = tasks.get(id);
2642
+ if (t) t.priority = operation.priority;
2643
+ }
2644
+ } else if (operation.kind === "archive") {
2645
+ for (const id of ids) {
2646
+ const t = tasks.get(id);
2647
+ if (t) t.archived = true;
2648
+ }
2649
+ } else if (operation.kind === "delete") {
2650
+ for (const id of ids) tasks.delete(id);
2651
+ }
2652
+ renderBoard();
2653
+ clearCardSelection();
2654
+
2655
+ const summary = describeBulkOp(operation);
2656
+ let usedFallback = false;
2657
+ try {
2658
+ await api("POST", "/api/tasks/bulk", { operation });
2659
+ } catch (err) {
2660
+ // Fallback path — server endpoint not implemented yet. Walk the ids.
2661
+ usedFallback = true;
2662
+ try {
2663
+ if (operation.kind === "move" || operation.kind === "priority") {
2664
+ for (const id of ids) {
2665
+ const body = operation.kind === "move"
2666
+ ? { column: operation.column }
2667
+ : { priority: operation.priority };
2668
+ await api("PATCH", `/api/tasks/${id}`, body);
2669
+ }
2670
+ } else if (operation.kind === "archive") {
2671
+ for (const id of ids) {
2672
+ await api("POST", `/api/tasks/${id}/archive`);
2673
+ }
2674
+ } else if (operation.kind === "delete") {
2675
+ for (const id of ids) {
2676
+ await api("DELETE", `/api/tasks/${id}`);
2677
+ }
2678
+ }
2679
+ } catch (fallbackErr) {
2680
+ // Roll back optimistic changes and re-render.
2681
+ for (const [id, snap] of snapshot) tasks.set(id, snap);
2682
+ for (const id of ids) if (!snapshot.has(id)) tasks.delete(id);
2683
+ renderBoard();
2684
+ showToast(`${summary} failed: ${fallbackErr.message}`, "error");
2685
+ return;
2686
+ }
2687
+ }
2688
+ // Always clear selection after success.
2689
+ clearCardSelection();
2690
+ showToast(`${summary}`, usedFallback ? "success" : "success");
2691
+ }
2692
+
2693
+ function describeBulkOp(op) {
2694
+ const n = (op.taskIds || []).length;
2695
+ const noun = n === 1 ? "task" : "tasks";
2696
+ switch (op.kind) {
2697
+ case "move": {
2698
+ const col = (COLUMNS.find((c) => c.id === op.column) || {}).title || op.column;
2699
+ return `Moved ${n} ${noun} to ${col}`;
2700
+ }
2701
+ case "priority": {
2702
+ const label = (PRIORITY_META[op.priority] || {}).label || op.priority;
2703
+ return `Set priority to ${label} on ${n} ${noun}`;
2704
+ }
2705
+ case "archive": return `Archived ${n} ${noun}`;
2706
+ case "delete": return `Deleted ${n} ${noun}`;
2707
+ default: return `Bulk operation on ${n} ${noun}`;
2708
+ }
2709
+ }
2710
+
2711
+ function showToast(message, kind = "success") {
2712
+ if (window.OpenKanSettings?.showToast) {
2713
+ window.OpenKanSettings.showToast(message, kind);
2714
+ return;
2715
+ }
2716
+ const container = document.getElementById("toast-container");
2717
+ if (!container) return;
2718
+ const toast = el("div", `toast toast-${kind}`, { text: message });
2719
+ container.append(toast);
2720
+ // 4s auto-dismiss (UX spec). Use the .toast-leaving class so the fade +
2721
+ // slide animation matches the entry animation defined in style.css.
2722
+ setTimeout(() => {
2723
+ toast.classList.add("toast-leaving");
2724
+ setTimeout(() => toast.remove(), 220);
2725
+ }, 4000);
2726
+ }
2727
+
2728
+ // ---------- Search (debounced live filter) ----------
2729
+ function attachSearch() {
2730
+ const input = document.getElementById("search-input");
2731
+ const meta = document.getElementById("search-meta");
2732
+ if (!input) return;
2733
+
2734
+ // Initial value from filter state (restored from URL hash on boot).
2735
+ input.value = filter.search || "";
2736
+
2737
+ input.addEventListener("input", () => {
2738
+ const value = input.value;
2739
+ filter.search = value;
2740
+ writeHashFilter();
2741
+ if (searchDebounce) clearTimeout(searchDebounce);
2742
+ if (!value) {
2743
+ // Empty query — show everything immediately and skip the server call.
2744
+ searchMatchIds = null;
2745
+ renderBoard();
2746
+ updateSearchMeta(meta, null, null);
2747
+ return;
2748
+ }
2749
+ // Optimistic local filter so the board updates instantly while the
2750
+ // debounced server call is in flight.
2751
+ searchMatchIds = null;
2752
+ renderBoard();
2753
+ searchDebounce = setTimeout(() => runSearch(value, meta), 200);
2754
+ });
2755
+
2756
+ // ESC clears the search input when it has focus.
2757
+ input.addEventListener("keydown", (e) => {
2758
+ if (e.key === "Escape" && input.value) {
2759
+ e.preventDefault();
2760
+ e.stopPropagation();
2761
+ input.value = "";
2762
+ filter.search = "";
2763
+ writeHashFilter();
2764
+ searchMatchIds = null;
2765
+ renderBoard();
2766
+ updateSearchMeta(meta, null, null);
2767
+ }
2768
+ });
2769
+ }
2770
+
2771
+ async function runSearch(q, metaEl) {
2772
+ const mySeq = ++searchSeq;
2773
+ const params = new URLSearchParams();
2774
+ params.set("q", q);
2775
+ if (filter.category && filter.category !== "all") params.set("category", filter.category);
2776
+ if (filter.tags.length > 0) params.set("tags", filter.tags.join(","));
2777
+ if (filter.contributor && filter.contributor !== "all") params.set("contributor", filter.contributor);
2778
+ if (filter.priority && filter.priority !== "all") params.set("priority", filter.priority);
2779
+ if (filter.column && filter.column !== "all") params.set("column", filter.column);
2780
+ if (filter.archive === "archived" || filter.archive === "both") params.set("archived", "true");
2781
+
2782
+ updateSearchMeta(metaEl, "searching", null);
2783
+
2784
+ let payload;
2785
+ try {
2786
+ payload = await api("GET", `/api/search?${params.toString()}`);
2787
+ } catch (err) {
2788
+ // Endpoint not implemented yet — fall back to local-only filtering.
2789
+ if (mySeq !== searchSeq) return;
2790
+ const matched = [...tasks.values()].filter((t) => taskTextMatchesQuery(t, q.toLowerCase())).length;
2791
+ searchMatchIds = null;
2792
+ renderBoard();
2793
+ updateSearchMeta(metaEl, "fallback", matched);
2794
+ return;
2795
+ }
2796
+ if (mySeq !== searchSeq) return; // a newer query has superseded this one
2797
+ const list = Array.isArray(payload?.results) ? payload.results : Array.isArray(payload) ? payload : [];
2798
+ const matchedIds = new Set(list.map((t) => t.id));
2799
+ searchMatchIds = matchedIds;
2800
+ const total = typeof payload?.total === "number" ? payload.total : matchedIds.size;
2801
+ renderBoard();
2802
+ updateSearchMeta(metaEl, "ok", total);
2803
+ }
2804
+
2805
+ function updateSearchMeta(metaEl, state, total) {
2806
+ if (!metaEl) return;
2807
+ if (state == null) {
2808
+ metaEl.textContent = "";
2809
+ metaEl.removeAttribute("data-state");
2810
+ return;
2811
+ }
2812
+ if (state === "searching") {
2813
+ metaEl.textContent = "searching…";
2814
+ metaEl.setAttribute("data-state", "searching");
2815
+ return;
2816
+ }
2817
+ if (state === "fallback") {
2818
+ metaEl.textContent = total != null ? `~${total} match (local)` : "";
2819
+ metaEl.setAttribute("data-state", "fallback");
2820
+ return;
2821
+ }
2822
+ if (state === "ok") {
2823
+ metaEl.textContent = total != null ? `${total} match${total === 1 ? "" : "es"}` : "";
2824
+ metaEl.setAttribute("data-state", "ok");
2825
+ return;
2826
+ }
2827
+ }
2828
+
2829
+ // ---------- SSE wiring (delegates to OpenKanAPI) ----------
2830
+ function applySnapshot(snap) {
2831
+ if (!snap) return;
2832
+ const list = Array.isArray(snap) ? snap : snap?.tasks || [];
2833
+ tasks.clear();
2834
+ for (const t of list) tasks.set(t.id, t);
2835
+ renderBoard();
2836
+ // Re-fetch if a search query is active so the match count stays accurate.
2837
+ if (filter.search) {
2838
+ const meta = document.getElementById("search-meta");
2839
+ runSearch(filter.search, meta);
2840
+ }
2841
+ }
2842
+
2843
+ on("board.snapshot", (snap) => { applySnapshot(snap); setConnected(true); });
2844
+ on("task.created", (payload) => {
2845
+ const t = payload?.task ?? payload;
2846
+ if (t && t.id) { tasks.set(t.id, t); renderBoard(); }
2847
+ });
2848
+ on("task.updated", (payload) => {
2849
+ const t = payload?.task ?? payload;
2850
+ if (t && t.id) { tasks.set(t.id, t); renderBoard(); }
2851
+ });
2852
+ on("task.deleted", (payload) => {
2853
+ const id = payload?.id;
2854
+ if (id && tasks.delete(id)) renderBoard();
2855
+ });
2856
+ // Filter change from another tab (or an SSE broadcast) — re-read hash and
2857
+ // re-render. Updates the search run too so the meta count stays accurate.
2858
+ on("filter.changed", () => {
2859
+ const next = readHashFilter();
2860
+ if (!next) return;
2861
+ const searchChanged = next.search !== filter.search;
2862
+ filter.category = next.category;
2863
+ filter.tags = next.tags;
2864
+ filter.contributor = next.contributor;
2865
+ filter.archive = next.archive;
2866
+ filter.sort = next.sort;
2867
+ filter.search = next.search || "";
2868
+ if (searchChanged) {
2869
+ const input = document.getElementById("search-input");
2870
+ if (input) input.value = filter.search;
2871
+ searchMatchIds = null;
2872
+ if (filter.search) {
2873
+ const meta = document.getElementById("search-meta");
2874
+ runSearch(filter.search, meta);
2875
+ }
2876
+ }
2877
+ applyFilterToButtons();
2878
+ renderBoard();
2879
+ });
2880
+ // Theme change from another tab — re-render so any color tokens (or
2881
+ // programmatically rendered colors) re-evaluate, even though the CSS rules
2882
+ // are already data-theme reactive.
2883
+ on("theme.changed", () => { renderBoard(); });
2884
+ // Forward-compatible handlers for events Thor is adding alongside the
2885
+ // bulk / image / template work. Each one triggers a board re-fetch so the
2886
+ // UI stays consistent even if the server changes the payload shape later.
2887
+ const REFRESH_EVENTS = [
2888
+ "task.bulk",
2889
+ "bulk.updated",
2890
+ "task.image-added",
2891
+ "task.image-deleted",
2892
+ "task.images-cleared",
2893
+ "task.archived",
2894
+ "task.restored",
2895
+ "task.changed",
2896
+ "session.ended",
2897
+ "tasks.reordered",
2898
+ ];
2899
+ for (const evt of REFRESH_EVENTS) {
2900
+ on(evt, async () => {
2901
+ try {
2902
+ const snap = await api("GET", "/api/board");
2903
+ applySnapshot(snap);
2904
+ } catch {}
2905
+ });
2906
+ }
2907
+
2908
+ // ---------- M13 — keyboard navigation, palette wiring, cross-tab ----------
2909
+ // The keyboard module publishes named events; we attach handlers that
2910
+ // update the focused card, the selection, or trigger bulk operations.
2911
+ function columnBodies() {
2912
+ return Array.from(document.querySelectorAll(".board .column .column-body"));
2913
+ }
2914
+ function cardsInBody(body) {
2915
+ return Array.from(body.querySelectorAll(".card[data-id]"));
2916
+ }
2917
+ function focusedCardEl() {
2918
+ const id = window.OpenKanKeyboard?.getFocusedId?.();
2919
+ if (!id) return null;
2920
+ const board = document.getElementById("board");
2921
+ if (!board) return null;
2922
+ let card;
2923
+ try { card = board.querySelector(`.card[data-id="${CSS.escape(id)}"]`); }
2924
+ catch { card = board.querySelector(`.card[data-id="${id}"]`); }
2925
+ return card || null;
2926
+ }
2927
+ function findCardPosition() {
2928
+ const fc = focusedCardEl();
2929
+ if (!fc) return null;
2930
+ const body = fc.closest(".column-body");
2931
+ const bodies = columnBodies();
2932
+ const bodyIdx = bodies.indexOf(body);
2933
+ if (bodyIdx < 0) return null;
2934
+ const cards = cardsInBody(body);
2935
+ return { bodyIdx, cardIdx: cards.indexOf(fc), bodies };
2936
+ }
2937
+ function nextColumnFirstCard(fromIdx, bodies) {
2938
+ if (!bodies) bodies = columnBodies();
2939
+ for (let i = 1; i <= bodies.length; i++) {
2940
+ const idx = (fromIdx + i) % bodies.length;
2941
+ const cards = cardsInBody(bodies[idx]);
2942
+ if (cards.length > 0) return cards[0];
2943
+ }
2944
+ return null;
2945
+ }
2946
+ function prevColumnLastCard(fromIdx, bodies) {
2947
+ if (!bodies) bodies = columnBodies();
2948
+ for (let i = 1; i <= bodies.length; i++) {
2949
+ const idx = (fromIdx - i + bodies.length) % bodies.length;
2950
+ const cards = cardsInBody(bodies[idx]);
2951
+ if (cards.length > 0) return cards[cards.length - 1];
2952
+ }
2953
+ return null;
2954
+ }
2955
+
2956
+ function moveFocus(direction) {
2957
+ const bodies = columnBodies();
2958
+ if (bodies.length === 0) return;
2959
+ const pos = findCardPosition();
2960
+ let target = null;
2961
+ if (!pos) {
2962
+ // No focus yet — start at the first card of the first column.
2963
+ target = cardsInBody(bodies[0])[0] || null;
2964
+ } else if (direction === "next") {
2965
+ const cards = cardsInBody(bodies[pos.bodyIdx]);
2966
+ if (pos.cardIdx + 1 < cards.length) target = cards[pos.cardIdx + 1];
2967
+ else target = nextColumnFirstCard(pos.bodyIdx, bodies);
2968
+ } else if (direction === "previous") {
2969
+ const cards = cardsInBody(bodies[pos.bodyIdx]);
2970
+ if (pos.cardIdx > 0) target = cards[pos.cardIdx - 1];
2971
+ else target = prevColumnLastCard(pos.bodyIdx, bodies);
2972
+ } else if (direction === "column-next") {
2973
+ target = nextColumnFirstCard(pos ? pos.bodyIdx : 0, bodies);
2974
+ } else if (direction === "column-prev") {
2975
+ target = prevColumnLastCard(pos ? pos.bodyIdx : 0, bodies);
2976
+ }
2977
+ if (!target) return;
2978
+ window.OpenKanKeyboard.setFocusedId(target.dataset.id);
2979
+ renderBoard();
2980
+ window.OpenKanKeyboard.scrollFocusedIntoView();
2981
+ }
2982
+
2983
+ function focusedIdsForBulk() {
2984
+ if (selectedIds.size > 0) return [...selectedIds];
2985
+ const id = window.OpenKanKeyboard?.getFocusedId?.();
2986
+ return id ? [id] : [];
2987
+ }
2988
+
2989
+ function openFocused() {
2990
+ const id = window.OpenKanKeyboard?.getFocusedId?.();
2991
+ if (!id) return;
2992
+ window.OpenKanTaskView?.open?.(id);
2993
+ }
2994
+
2995
+ function toggleFocusSelection() {
2996
+ const id = window.OpenKanKeyboard?.getFocusedId?.();
2997
+ if (!id) return;
2998
+ let card = focusedCardEl();
2999
+ if (!card) {
3000
+ // Card isn't in the DOM (filtered out, archived, etc.) — still flip
3001
+ // the selected set so the bulk bar shows up.
3002
+ if (selectedIds.has(id)) selectedIds.delete(id);
3003
+ else selectedIds.add(id);
3004
+ updateBulkBar();
3005
+ return;
3006
+ }
3007
+ toggleCardSelection(card, id);
3008
+ }
3009
+
3010
+ function moveSelectedTo(columnIdx) {
3011
+ const col = COLUMNS[columnIdx];
3012
+ if (!col) return;
3013
+ const ids = focusedIdsForBulk();
3014
+ if (ids.length === 0) return;
3015
+ runBulkOperation({ kind: "move", taskIds: ids, column: col.id });
3016
+ }
3017
+
3018
+ function archiveSelected() {
3019
+ const ids = focusedIdsForBulk();
3020
+ if (ids.length === 0) return;
3021
+ runBulkOperation({ kind: "archive", taskIds: ids });
3022
+ }
3023
+
3024
+ function deleteSelected() {
3025
+ const ids = focusedIdsForBulk();
3026
+ if (ids.length === 0) return;
3027
+ // The 'd' keyboard shortcut — drop the confirm dialog so the action
3028
+ // feels instantaneous. The destructive op still goes through the bulk
3029
+ // delete path which already triggers a toast on success.
3030
+ const snaps = new Map();
3031
+ for (const id of ids) {
3032
+ const t = tasks.get(id);
3033
+ if (t) snaps.set(id, { ...t });
3034
+ }
3035
+ runBulkOperation({ kind: "delete", taskIds: ids });
3036
+ showUndoToast(`Deleted ${ids.length} task${ids.length === 1 ? "" : "s"}.`, () => {
3037
+ for (const [id, snap] of snaps) tasks.set(id, snap);
3038
+ renderBoard();
3039
+ showUndoToast(`Restored ${snaps.size} task${snaps.size === 1 ? "" : "s"}.`, () => {
3040
+ runBulkOperation({ kind: "delete", taskIds: ids });
3041
+ });
3042
+ });
3043
+ }
3044
+
3045
+ function focusSearch() {
3046
+ const input = document.getElementById("search-input");
3047
+ if (input) {
3048
+ input.focus();
3049
+ try { input.select(); } catch {}
3050
+ }
3051
+ }
3052
+
3053
+ // Theme cycle — dark → light → system → dark.
3054
+ function cycleTheme() {
3055
+ const order = ["dark", "light", "system"];
3056
+ const cur = (window.OpenKanSettings?.currentTheme?.() || "dark").toLowerCase();
3057
+ const next = order[(order.indexOf(cur) + 1) % order.length] || "dark";
3058
+ if (window.OpenKanSettings?.applyTheme) {
3059
+ window.OpenKanSettings.applyTheme(next);
3060
+ // Broadcast to siblings so they pick up the change without an SSE round-trip.
3061
+ try { window.OpenKanCrossTab?.publish?.("theme.changed", { theme: next }); } catch {}
3062
+ showToast(`Theme: ${next}`);
3063
+ }
3064
+ }
3065
+
3066
+ function attachKeyboard() {
3067
+ const K = window.OpenKanKeyboard;
3068
+ if (!K) return;
3069
+ K.on("focus.next", () => moveFocus("next"));
3070
+ K.on("focus.previous", () => moveFocus("previous"));
3071
+ K.on("focus.column-next", () => moveFocus("column-next"));
3072
+ K.on("focus.column-prev", () => moveFocus("column-prev"));
3073
+ K.on("focus.open", () => openFocused());
3074
+ K.on("focus.toggle-select", () => toggleFocusSelection());
3075
+ K.on("action.move-column", (idx) => moveSelectedTo(idx));
3076
+ K.on("action.archive", () => archiveSelected());
3077
+ K.on("action.delete", () => deleteSelected());
3078
+ K.on("action.edit", () => {
3079
+ // 'e' on a focused card: open the Edit Task modal directly. If no
3080
+ // card is focused but a task view is open, edit the currently open
3081
+ // task. If neither, fall back to opening the focused card first.
3082
+ const focusedId = window.OpenKanKeyboard?.getFocusedId?.();
3083
+ const openTaskId = window.OpenKanTaskView?.getCurrentTaskId?.();
3084
+ if (focusedId) {
3085
+ openEditModal(focusedId);
3086
+ } else if (openTaskId) {
3087
+ openEditModal(openTaskId);
3088
+ } else {
3089
+ openFocused();
3090
+ }
3091
+ });
3092
+ K.on("search.focus", () => focusSearch());
3093
+ }
3094
+
3095
+ // ─── Command palette registration ────────────────────────────────────────
3096
+ // App owns the action set; the palette just renders and invokes whatever's
3097
+ // registered. The task provider exposes cards that match the search query.
3098
+ function registerPaletteActions() {
3099
+ const palette = window.OpenKanCommandPalette;
3100
+ if (!palette) return;
3101
+ const unregisters = [];
3102
+ unregisters.push(palette.registerAction({
3103
+ id: "new-task",
3104
+ label: "New task",
3105
+ hint: "Create a task",
3106
+ run: () => document.getElementById("new-task-btn")?.click(),
3107
+ }));
3108
+ unregisters.push(palette.registerAction({
3109
+ id: "open-settings",
3110
+ label: "Open Settings",
3111
+ hint: "Project, theme, archive",
3112
+ run: () => document.getElementById("settings-btn")?.click(),
3113
+ }));
3114
+ unregisters.push(palette.registerAction({
3115
+ id: "open-tasks",
3116
+ label: "Open Tasks tab",
3117
+ hint: "Kanban board",
3118
+ run: () => window.OpenKanTabs?.activate?.("tasks"),
3119
+ }));
3120
+ unregisters.push(palette.registerAction({
3121
+ id: "open-changelog",
3122
+ label: "Open Changelog tab",
3123
+ hint: "Recent activity",
3124
+ run: () => window.OpenKanTabs?.activate?.("changelog"),
3125
+ }));
3126
+ unregisters.push(palette.registerAction({
3127
+ id: "open-contributors",
3128
+ label: "Open Contributors tab",
3129
+ hint: "Team overview",
3130
+ run: () => window.OpenKanTabs?.activate?.("contributors"),
3131
+ }));
3132
+ unregisters.push(palette.registerAction({
3133
+ id: "toggle-theme",
3134
+ label: "Toggle theme",
3135
+ hint: "Dark → light → system",
3136
+ run: () => cycleTheme(),
3137
+ }));
3138
+ unregisters.push(palette.registerAction({
3139
+ id: "clear-filters",
3140
+ label: "Clear all filters",
3141
+ hint: "Reset board view",
3142
+ run: () => {
3143
+ filter.category = "all";
3144
+ filter.tags = [];
3145
+ filter.contributor = "all";
3146
+ filter.search = "";
3147
+ searchMatchIds = null;
3148
+ writeHashFilter();
3149
+ applyFilterToButtons();
3150
+ renderBoard();
3151
+ try {
3152
+ window.OpenKanCrossTab?.publish?.("filter.changed", { hash: window.location.hash });
3153
+ } catch {}
3154
+ },
3155
+ }));
3156
+ unregisters.push(palette.registerAction({
3157
+ id: "save-filter",
3158
+ label: "Save current filter",
3159
+ hint: "Name and store the active filter",
3160
+ run: async () => {
3161
+ // Same path as the filter-bar Save button — go through the custom
3162
+ // inline modal so it works in headless / embedded contexts.
3163
+ const name = await promptForName("Save filter as:", "");
3164
+ if (name && saveCurrentFilter(name)) {
3165
+ showToast(`Saved filter "${name}"`);
3166
+ }
3167
+ },
3168
+ }));
3169
+ unregisters.push(palette.registerAction({
3170
+ id: "reload",
3171
+ label: "Reload page",
3172
+ hint: "Hard reload",
3173
+ run: () => window.location.reload(),
3174
+ }));
3175
+ unregisters.push(palette.registerAction({
3176
+ id: "help-shortcuts",
3177
+ label: "Show keyboard shortcuts",
3178
+ hint: "Open the ? help overlay",
3179
+ run: () => window.OpenKanKeyboard?.showHelp?.(),
3180
+ }));
3181
+
3182
+ // Task provider — palette searches every task this tab has loaded.
3183
+ unregisters.push(palette.registerTaskProvider((q) => {
3184
+ const out = [];
3185
+ for (const t of tasks.values()) {
3186
+ if (!t || !t.id) continue;
3187
+ out.push({
3188
+ id: t.id,
3189
+ title: t.title || "(untitled)",
3190
+ subtitle: (COLUMNS.find((c) => c.id === t.column) || {}).title || t.column,
3191
+ run: () => window.OpenKanTaskView?.open?.(t.id),
3192
+ });
3193
+ }
3194
+ return out;
3195
+ }));
3196
+ return () => { for (const u of unregisters) try { u(); } catch {} };
3197
+ }
3198
+
3199
+ // ─── Cross-tab subscribe (mirror SSE for sibling-tab updates) ───────────
3200
+ // OpenKanAPI already forwards each cross-tab event to the local event bus,
3201
+ // so the handlers above (board.snapshot, filter.changed, theme.changed,
3202
+ // task.*/comment.*/input.*) all run from cross-tab events too. There is
3203
+ // nothing left to subscribe here besides re-publishing the hash as it
3204
+ // changes so siblings can sync filters.
3205
+ let lastPublishedHash = null;
3206
+ function attachCrossTab() {
3207
+ if (!window.OpenKanCrossTab) return;
3208
+ // Publish hash changes so other tabs can sync their filter view.
3209
+ const publish = () => {
3210
+ const hash = window.location.hash || "";
3211
+ if (hash === lastPublishedHash) return;
3212
+ lastPublishedHash = hash;
3213
+ try { window.OpenKanCrossTab.publish("filter.changed", { hash }); } catch {}
3214
+ };
3215
+ window.addEventListener("hashchange", publish);
3216
+ publish();
3217
+ }
3218
+
3219
+ // Expose the action helpers so external callers (tests, future modules)
3220
+ // can drive the keyboard nav from JS the same way the keyboard module does.
3221
+ window.OpenKanBoardActions = {
3222
+ moveFocus,
3223
+ openFocused,
3224
+ toggleFocusSelection,
3225
+ moveSelectedTo,
3226
+ archiveSelected,
3227
+ deleteSelected,
3228
+ focusSearch,
3229
+ cycleTheme,
3230
+ openEditModal,
3231
+ };
3232
+
3233
+ // Edit Task modal — exposed so task-view.js can open it from the
3234
+ // footer ("Edit" button) without each module wiring its own API.
3235
+ window.OpenKanEditTask = {
3236
+ open: openEditModal,
3237
+ isOpen() {
3238
+ const b = document.getElementById("edit-backdrop");
3239
+ return !!(b && !b.hidden);
3240
+ },
3241
+ };
3242
+
3243
+
3244
+ // ---------- Modal ----------
3245
+
3246
+ // Custom inline name prompt. `window.prompt()` is blocked in headless
3247
+ // Chrome and in some embedded contexts, so the 💾 Save filter button and
3248
+ // its command-palette sibling both go through this instead. Built on top
3249
+ // of the same `.modal-backdrop` / `.modal` shell as the rest of the
3250
+ // dashboard so it inherits theme variables and backdrop-click behaviour.
3251
+ // Resolves with the trimmed value on OK / Enter, or null on Cancel /
3252
+ // Escape / backdrop click / empty input.
3253
+ function promptForName(title, defaultValue = "") {
3254
+ return new Promise((resolve) => {
3255
+ const safeTitle = String(title ?? "Enter a name");
3256
+ const bd = document.createElement("div");
3257
+ bd.className = "modal-backdrop prompt-modal";
3258
+ bd.style.zIndex = "120";
3259
+ const modal = document.createElement("div");
3260
+ modal.className = "modal";
3261
+ modal.style.maxWidth = "420px";
3262
+ modal.innerHTML = `
3263
+ <header class="modal-header">
3264
+ <h2></h2>
3265
+ <button class="btn-icon" type="button" data-cancel aria-label="Close">&times;</button>
3266
+ </header>
3267
+ <div class="modal-body">
3268
+ <label class="field">
3269
+ <span>Name</span>
3270
+ <input type="text" autocomplete="off" />
3271
+ </label>
3272
+ </div>
3273
+ <footer class="modal-footer">
3274
+ <button class="btn" type="button" data-cancel>Cancel</button>
3275
+ <button class="btn btn-primary" type="button" data-ok>OK</button>
3276
+ </footer>
3277
+ `;
3278
+ bd.appendChild(modal);
3279
+ document.body.appendChild(bd);
3280
+ bd.hidden = false;
3281
+ const heading = modal.querySelector("h2");
3282
+ const input = modal.querySelector("input");
3283
+ if (heading) heading.textContent = safeTitle;
3284
+ if (input) input.value = String(defaultValue ?? "");
3285
+ if (input) {
3286
+ input.focus();
3287
+ input.select();
3288
+ }
3289
+ function cleanup(result) {
3290
+ bd.remove();
3291
+ resolve(result);
3292
+ }
3293
+ modal.querySelector("[data-ok]").addEventListener("click", () => {
3294
+ cleanup(input?.value?.trim() || null);
3295
+ });
3296
+ modal.querySelectorAll("[data-cancel]").forEach((b) =>
3297
+ b.addEventListener("click", () => cleanup(null)),
3298
+ );
3299
+ bd.addEventListener("mousedown", (ev) => {
3300
+ if (ev.target === bd) cleanup(null);
3301
+ });
3302
+ input?.addEventListener("keydown", (ev) => {
3303
+ if (ev.key === "Enter") cleanup(input.value.trim() || null);
3304
+ else if (ev.key === "Escape") cleanup(null);
3305
+ });
3306
+ });
3307
+ }
3308
+
3309
+ let creatingTask = false;
3310
+ let taskModalReturnFocus = null;
3311
+ function taskDraftKey() { return `openkan:task-draft:${projectSwitcher.active?.root || location.pathname}`; }
3312
+ function saveTaskDraft() {
3313
+ if (!form) return;
3314
+ try { sessionStorage.setItem(taskDraftKey(), JSON.stringify(Object.fromEntries(new FormData(form)))); } catch {}
3315
+ }
3316
+ function taskCreateError(message = "") {
3317
+ const error = $("task-create-error");
3318
+ if (error) { error.textContent = message; error.hidden = !message; }
3319
+ }
3320
+ function openModal() {
3321
+ if (!modal) return;
3322
+ taskModalReturnFocus = document.activeElement;
3323
+ if (window.OpenKanTaskView?.getCurrentTaskId?.()) {
3324
+ window.OpenKanTaskView.close();
3325
+ }
3326
+ activateTab("tasks");
3327
+ modal.hidden = false;
3328
+ if (form) {
3329
+ form.reset(); taskCreateError();
3330
+ form.elements.title?.removeAttribute("aria-invalid");
3331
+ try {
3332
+ const settings = JSON.parse(localStorage.getItem("openkan:settings") || "{}");
3333
+ const def = settings?.project?.defaultColumn;
3334
+ if (def) form.elements.column.value = def;
3335
+ const draft = JSON.parse(sessionStorage.getItem(taskDraftKey()) || "null");
3336
+ if (draft) for (const [key, value] of Object.entries(draft)) {
3337
+ const field = form.elements.namedItem(key);
3338
+ if (field && field.type !== "checkbox") field.value = String(value);
3339
+ }
3340
+ if (draft) form.elements.openAfterCreate.checked = draft.openAfterCreate === "on";
3341
+ } catch {}
3342
+ const context = $("task-create-project");
3343
+ if (context) context.textContent = `Project: ${projectSwitcher.active?.name || "current project"}. Drafts are saved in this browser tab.`;
3344
+ form.elements.title?.focus();
3345
+ }
3346
+ }
3347
+ function closeModal() {
3348
+ if (!modal || modal.hidden || creatingTask) return;
3349
+ saveTaskDraft();
3350
+ modal.hidden = true;
3351
+ taskModalReturnFocus?.focus?.();
3352
+ }
3353
+ window.OpenKanCreateTask = { openFromChat(message) {
3354
+ openModal();
3355
+ if (form.elements.title.value || form.elements.description.value) {
3356
+ showToast("Your existing task draft is open. Your chat text is unchanged.", "info"); return;
3357
+ }
3358
+ form.elements.title.value = String(message).split("\n")[0].slice(0, 200);
3359
+ form.elements.description.value = String(message);
3360
+ saveTaskDraft();
3361
+ } };
3362
+ if ($("new-task-btn")) $("new-task-btn").addEventListener("click", openModal);
3363
+ if (modal) {
3364
+ modal.addEventListener("click", (e) => {
3365
+ if (e.target === modal) closeModal();
3366
+ });
3367
+ document.querySelectorAll("[data-close-modal]").forEach((b) =>
3368
+ b.addEventListener("click", closeModal),
3369
+ );
3370
+ }
3371
+ document.addEventListener("keydown", (e) => {
3372
+ if (e.key === "Escape") {
3373
+ // Clear selection first — never silently lost work to a stray Esc.
3374
+ if (selectedIds.size > 0 && !e.target?.closest?.("input, textarea")) {
3375
+ clearCardSelection();
3376
+ return;
3377
+ }
3378
+ closeModal();
3379
+ closeBulkMenus();
3380
+ if (menu) menu.hidden = true;
3381
+ }
3382
+ });
3383
+ if (form) {
3384
+ form.addEventListener("input", () => { saveTaskDraft(); taskCreateError(); form.elements.title?.removeAttribute("aria-invalid"); });
3385
+ form.addEventListener("change", saveTaskDraft);
3386
+ $("task-discard-draft")?.addEventListener("click", () => {
3387
+ if (creatingTask) return;
3388
+ form.reset(); saveTaskDraft(); taskCreateError(); form.elements.title?.focus();
3389
+ });
3390
+ modal.addEventListener("keydown", (e) => {
3391
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
3392
+ if (e.key !== "Tab") return;
3393
+ const focusable = [...modal.querySelectorAll('button, input:not([type="hidden"]), textarea, select, summary, [tabindex="0"]')].filter(node => !node.disabled && node.getClientRects().length);
3394
+ const first = focusable[0], last = focusable.at(-1);
3395
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
3396
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
3397
+ });
3398
+
3399
+ form.addEventListener("submit", async (e) => {
3400
+ e.preventDefault();
3401
+ if (creatingTask) return;
3402
+ const fd = new FormData(form);
3403
+ const title = String(fd.get("title") || "").trim();
3404
+ if (!title || title.length > 200) {
3405
+ taskCreateError(!title ? "Give this task a title before creating it." : "Keep the title under 200 characters.");
3406
+ form.elements.title?.setAttribute("aria-invalid", "true");
3407
+ form.elements.title?.focus(); return;
3408
+ }
3409
+ const body = {
3410
+ title, description: String(fd.get("description") || ""),
3411
+ column: String(fd.get("column") || "todo"), priority: String(fd.get("priority") || "normal"),
3412
+ agent: String(fd.get("agent") || ""), model: String(fd.get("model") || ""),
3413
+ };
3414
+ const parentId = String(fd.get("parentId") || "").trim();
3415
+ if (parentId) body.parentId = parentId;
3416
+ const openAfterCreate = fd.get("openAfterCreate") === "on";
3417
+ const submit = $("task-create-submit");
3418
+ saveTaskDraft(); taskCreateError(); creatingTask = true;
3419
+ form.setAttribute("aria-busy", "true");
3420
+ for (const control of form.querySelectorAll("input, textarea, select, button")) control.disabled = true;
3421
+ if (submit) submit.textContent = "Creating…";
3422
+ try {
3423
+ const res = await api("POST", "/api/tasks", body);
3424
+ if (!res?.id) throw new Error("The server did not confirm task creation");
3425
+ tasks.set(res.id, res); renderBoard();
3426
+ form.reset(); creatingTask = false; closeModal();
3427
+ try { sessionStorage.removeItem(taskDraftKey()); } catch {}
3428
+ showToast("Task created", "success");
3429
+ if (openAfterCreate) window.OpenKanTaskView?.open?.(res.id);
3430
+ else if (parentId && window.OpenKanTaskView?.getCurrentTaskId?.() === parentId) window.OpenKanTaskView.open(parentId);
3431
+ } catch (err) {
3432
+ taskCreateError(`Could not create task: ${err.message}. Your draft is kept; try again.`);
3433
+ } finally {
3434
+ creatingTask = false; form.removeAttribute("aria-busy");
3435
+ for (const control of form.querySelectorAll("input, textarea, select, button")) control.disabled = false;
3436
+ if (submit) submit.textContent = "Create task";
3437
+ }
3438
+ });
3439
+ }
3440
+
3441
+ // ---------- Tab router ----------
3442
+ function activateTab(name, opts = {}) {
3443
+ if (name === "claude" || name === "bizar") name = "agents";
3444
+ const valid = ["home", "tasks", "changelog", "contributors", "docs", "goals", "agents", "insights"];
3445
+ if (!valid.includes(name)) name = "tasks";
3446
+ for (const btn of document.querySelectorAll(".tab")) {
3447
+ const isActive = btn.dataset.tab === name;
3448
+ btn.classList.toggle("active", isActive);
3449
+ btn.setAttribute("aria-selected", isActive ? "true" : "false");
3450
+ }
3451
+ const homeButton = document.getElementById("home-page-btn");
3452
+ homeButton?.classList.toggle("active", name === "home");
3453
+ homeButton?.setAttribute("aria-current", name === "home" ? "page" : "false");
3454
+ document.body.classList.toggle("workspace-home", name === "home");
3455
+ for (const pane of document.querySelectorAll(".tab-pane")) {
3456
+ const isActive = pane.dataset.tab === name;
3457
+ pane.hidden = !isActive;
3458
+ }
3459
+ // Update URL hash, preserving other params.
3460
+ if (!opts.fromHash) {
3461
+ const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
3462
+ params.set("tab", name);
3463
+ const next = `#${params.toString()}`;
3464
+ if (window.location.hash !== next) {
3465
+ const url = window.location.pathname + window.location.search + next;
3466
+ window.history.replaceState(null, "", url);
3467
+ }
3468
+ }
3469
+ // Lazy-mount views.
3470
+ if (name === "home") {
3471
+ const root = document.getElementById("home-root");
3472
+ if (root && window.OpenKanHome) window.OpenKanHome.mount(root);
3473
+ } else if (name === "changelog") {
3474
+ const root = document.getElementById("changelog-root");
3475
+ if (root && window.OpenKanChangelog) window.OpenKanChangelog.mount(root);
3476
+ } else if (name === "contributors") {
3477
+ const root = document.getElementById("contributors-root");
3478
+ if (root && window.OpenKanContributors) window.OpenKanContributors.mount(root);
3479
+ } else if (name === "docs") {
3480
+ const root = document.getElementById("docs-root");
3481
+ if (root && window.OpenKanDocs) {
3482
+ // Pass through the requested file from the hash (if any), so a
3483
+ // `#tab=docs&doc=README.mdx` URL restores the last-viewed file.
3484
+ const initial = readHashFilter();
3485
+ window.OpenKanDocs.mount(root, { initialDoc: initial?.doc || "" });
3486
+ }
3487
+ } else if (name === "goals") {
3488
+ const root = document.getElementById("goals-root");
3489
+ if (root && window.OpenKanGoals) window.OpenKanGoals.mount(root);
3490
+ } else if (name === "agents") {
3491
+ const root = document.getElementById("claude-pane-root");
3492
+ if (root && window.OpenKanClaude) window.OpenKanClaude.mount(root);
3493
+ } else if (name === "insights") {
3494
+ const root = document.getElementById("insights-root");
3495
+ if (root && window.OpenKanInsights) window.OpenKanInsights.mount(root);
3496
+ } else if (name === "tasks") {
3497
+ // Unmount non-active views to free any subscriptions.
3498
+ window.OpenKanChangelog?.unmount?.();
3499
+ window.OpenKanContributors?.unmount?.();
3500
+ window.OpenKanDocs?.unmount?.();
3501
+ window.OpenKanGoals?.unmount?.();
3502
+ window.OpenKanClaude?.unmount?.();
3503
+ window.OpenKanInsights?.unmount?.();
3504
+ }
3505
+ }
3506
+
3507
+ function attachWorkspaceMode() {
3508
+ const key = "openkan:workspace-mode";
3509
+ const setMode = (mode) => {
3510
+ const chatMode = mode === "chat";
3511
+ document.body.classList.toggle("workspace-mode-chat", chatMode);
3512
+ document.body.classList.toggle("workspace-mode-tasks", !chatMode);
3513
+ document.querySelectorAll("[data-workspace-mode]").forEach((button) => button.classList.toggle("active", button.dataset.workspaceMode === mode));
3514
+ try { localStorage.setItem(key, mode); } catch {}
3515
+ if (chatMode) window.OpenKanChatSidebar?.open?.();
3516
+ };
3517
+ let initial = "tasks"; try { initial = localStorage.getItem(key) || "tasks"; } catch {}
3518
+ setMode(initial);
3519
+ document.querySelectorAll("[data-workspace-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.workspaceMode)));
3520
+ }
3521
+
3522
+ function attachTabRouter() {
3523
+ const closeTaskAndActivate = (name) => {
3524
+ if (window.OpenKanTaskView?.getCurrentTaskId?.()) window.OpenKanTaskView.close();
3525
+ activateTab(name);
3526
+ };
3527
+ document.querySelectorAll(".tab").forEach((btn) => btn.addEventListener("click", () => closeTaskAndActivate(btn.dataset.tab || "tasks")));
3528
+ document.getElementById("home-page-btn")?.addEventListener("click", () => closeTaskAndActivate("home"));
3529
+
3530
+ const moreButton = document.getElementById("workspace-more-btn");
3531
+ const moreMenu = document.getElementById("workspace-more-menu");
3532
+ const closeMore = () => { if (!moreMenu || !moreButton) return; moreMenu.hidden = true; moreButton.setAttribute("aria-expanded", "false"); };
3533
+ moreButton?.addEventListener("click", (event) => {
3534
+ event.stopPropagation();
3535
+ if (!moreMenu) return;
3536
+ moreMenu.hidden = !moreMenu.hidden;
3537
+ moreButton.setAttribute("aria-expanded", moreMenu.hidden ? "false" : "true");
3538
+ });
3539
+ moreMenu?.querySelectorAll("[data-workspace-page]").forEach((button) => button.addEventListener("click", () => { closeMore(); closeTaskAndActivate(button.dataset.workspacePage || "tasks"); }));
3540
+ moreMenu?.querySelectorAll("[data-workspace-mode]").forEach((button) => button.addEventListener("click", () => closeMore()));
3541
+ document.addEventListener("click", (event) => { if (!event.target.closest(".workspace-more")) closeMore(); });
3542
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMore(); });
3543
+ }
3544
+
3545
+ function attachFilterDisclosure() {
3546
+ const toggle = document.getElementById("filter-toggle-btn");
3547
+ const advanced = document.getElementById("filter-advanced");
3548
+ if (!toggle || !advanced) return;
3549
+ const setOpen = (open) => {
3550
+ advanced.hidden = !open;
3551
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
3552
+ toggle.classList.toggle("active", open);
3553
+ try { localStorage.setItem("openkan:filters-open", open ? "1" : "0"); } catch {}
3554
+ };
3555
+ let initial = false;
3556
+ try { initial = localStorage.getItem("openkan:filters-open") === "1"; } catch {}
3557
+ setOpen(initial);
3558
+ toggle.addEventListener("click", () => setOpen(advanced.hidden));
3559
+ }
3560
+
3561
+ // ---------- Contributors sidebar (filter row + helpers) ----------
3562
+ async function loadContributors() {
3563
+ try {
3564
+ const data = await api("GET", "/api/contributors");
3565
+ const list = Array.isArray(data?.contributors) ? data.contributors : Array.isArray(data) ? data : [];
3566
+ currentUser = data?.currentUser || null;
3567
+ if (currentUser && currentUser.email) {
3568
+ // Fallback marker in case server didn't mark list entries.
3569
+ }
3570
+ contributors.clear();
3571
+ for (const c of list) {
3572
+ if (c?.email) contributors.set(c.email.toLowerCase(), c);
3573
+ }
3574
+ // Decorate the contributors filter row with a button per contributor.
3575
+ renderContributorFilterRow(list);
3576
+ } catch (err) {
3577
+ // Soft-fail: the contributors filter row just stays at all | @me.
3578
+ console.warn("[app.js] /api/contributors fetch failed:", err);
3579
+ }
3580
+ }
3581
+
3582
+ function renderContributorFilterRow(list) {
3583
+ const row = document.getElementById("filter-contributors");
3584
+ if (!row) return;
3585
+ // Remove any prior contributor entries (keep the fixed all/@me buttons).
3586
+ row.querySelectorAll("button[data-contributor]:not([data-contributor='all']):not([data-contributor='@me'])")
3587
+ .forEach((b) => b.remove());
3588
+ // Decorate the @me button with an avatar showing the current user's
3589
+ // initials and a deterministic color — matches the contributor chips.
3590
+ const meBtn = row.querySelector('button[data-contributor="@me"]');
3591
+ if (meBtn) {
3592
+ meBtn.innerHTML = "";
3593
+ const meName = currentUser?.name || currentUser?.email || "me";
3594
+ const meSeed = currentUser?.email || meName;
3595
+ const meAv = el("span", "contrib-avatar");
3596
+ meAv.style.background = avatarColorFor(meSeed);
3597
+ meAv.textContent = initialsFor(meName);
3598
+ meBtn.append(meAv, el("span", "contrib-name", { text: "@me" }));
3599
+ }
3600
+ for (const c of list || []) {
3601
+ if (!c?.email) continue;
3602
+ const btn = el("button", null, {
3603
+ type: "button",
3604
+ "data-contributor": c.email,
3605
+ title: c.email,
3606
+ });
3607
+ const av = el("span", "contrib-avatar");
3608
+ av.style.background = avatarColorFor(c.email);
3609
+ av.textContent = initialsFor(c.name || c.email);
3610
+ btn.append(av, el("span", "contrib-name", { text: c.name || c.email }));
3611
+ if (filter.contributor === c.email) btn.classList.add("active");
3612
+ row.append(btn);
3613
+ }
3614
+ }
3615
+
3616
+ const topbar = document.querySelector(".topbar");
3617
+ if (topbar && typeof ResizeObserver === "function") {
3618
+ new ResizeObserver(() => {
3619
+ document.documentElement.style.setProperty("--topbar-height", `${Math.ceil(topbar.getBoundingClientRect().height)}px`);
3620
+ }).observe(topbar);
3621
+ }
3622
+
3623
+ // ---------- Project switcher + brand chip -----------------------------
3624
+ // Manages the topbar dropdown that lists every registered project plus the
3625
+ // "+ Add project…" affordance. Uses the multi-project registry at
3626
+ // /api/projects, which Thor wires up server-side; until that endpoint
3627
+ // exists the dropdown degrades gracefully (just shows an empty list with
3628
+ // the Add option).
3629
+ const projectSwitcher = {
3630
+ /** Cached project list — null until first fetch. */
3631
+ list: null,
3632
+ /** The currently active project entry. */
3633
+ active: null,
3634
+ /** Last error message, surfaced in the popover. */
3635
+ error: null,
3636
+ };
3637
+
3638
+ async function fetchProjects() {
3639
+ try {
3640
+ const data = await api("GET", "/api/projects");
3641
+ const list = Array.isArray(data?.projects) ? data.projects : Array.isArray(data) ? data : [];
3642
+ projectSwitcher.list = list;
3643
+ projectSwitcher.active = list.find((p) => p.active) || list[0] || null;
3644
+ projectSwitcher.error = null;
3645
+ return list;
3646
+ } catch (err) {
3647
+ projectSwitcher.error = err?.message || String(err);
3648
+ projectSwitcher.list = [];
3649
+ projectSwitcher.active = null;
3650
+ return [];
3651
+ }
3652
+ }
3653
+
3654
+ function truncateName(name, max = 14) {
3655
+ const s = String(name || "");
3656
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
3657
+ }
3658
+
3659
+ function renderProjectChip() {
3660
+ const label = document.getElementById("project-switcher-label");
3661
+ const brand = document.getElementById("brand-project-name");
3662
+ const dot = document.querySelector(".project-switcher-dot");
3663
+ const bdot = document.querySelector(".brand-project-dot");
3664
+ const name = projectSwitcher.active?.name || "no project";
3665
+ if (label) label.textContent = truncateName(name);
3666
+ if (brand) brand.textContent = name;
3667
+ const chip = document.getElementById("brand-project-chip");
3668
+ if (chip) chip.setAttribute("aria-label", `Current project: ${name}. Switch project`);
3669
+ if (dot) dot.classList.toggle("is-empty", !projectSwitcher.active);
3670
+ if (bdot) bdot.classList.toggle("is-empty", !projectSwitcher.active);
3671
+ }
3672
+
3673
+ // IDs of every button that can OPEN the popover. The dismiss listener
3674
+ // ignores mousedowns on these so the trigger's own click handler can
3675
+ // decide whether to toggle. Without this, clicking the brand chip while
3676
+ // the popover is open would: (1) mousedown capture phase → close the
3677
+ // popover because the brand chip isn't the recorded anchor; (2) click
3678
+ // on the chip → handler sees pop.hidden=true → reopens it. The net
3679
+ // result is the toggle looks broken from the user's perspective.
3680
+ const PROJECT_TRIGGER_IDS = ["project-switcher-btn", "brand-project-chip"];
3681
+
3682
+ function closeProjectPopover() {
3683
+ const pop = document.getElementById("project-switcher-popover");
3684
+ // Clear aria-expanded on BOTH trigger buttons — either one could have
3685
+ // opened the popover, and either one should now report "collapsed".
3686
+ const btn = document.getElementById("project-switcher-btn");
3687
+ const chip = document.getElementById("brand-project-chip");
3688
+ if (pop) pop.hidden = true;
3689
+ if (btn) btn.setAttribute("aria-expanded", "false");
3690
+ if (chip) chip.setAttribute("aria-expanded", "false");
3691
+ detachProjectPopoverDismiss();
3692
+ }
3693
+
3694
+ // Module-scope so we can remove the listener on close. Using a 50ms defer
3695
+ // avoids the click-that-opened-the-popover from also being the click that
3696
+ // immediately dismisses it (the user expects the popover to open first).
3697
+ let projectPopoverDismiss = null;
3698
+
3699
+ function attachProjectPopoverDismiss(popover) {
3700
+ detachProjectPopoverDismiss();
3701
+ const triggerEls = PROJECT_TRIGGER_IDS
3702
+ .map((id) => document.getElementById(id))
3703
+ .filter(Boolean);
3704
+ const onDocDown = (ev) => {
3705
+ if (!popover || popover.hidden) return;
3706
+ if (popover.contains(ev.target)) return;
3707
+ for (const el of triggerEls) {
3708
+ if (el.contains(ev.target)) return;
3709
+ }
3710
+ closeProjectPopover();
3711
+ };
3712
+ const onKey = (ev) => {
3713
+ if (ev.key === "Escape" && popover && !popover.hidden) {
3714
+ ev.preventDefault();
3715
+ ev.stopPropagation();
3716
+ closeProjectPopover();
3717
+ }
3718
+ };
3719
+ document.addEventListener("mousedown", onDocDown, true);
3720
+ document.addEventListener("keydown", onKey, true);
3721
+ projectPopoverDismiss = () => {
3722
+ document.removeEventListener("mousedown", onDocDown, true);
3723
+ document.removeEventListener("keydown", onKey, true);
3724
+ };
3725
+ }
3726
+ function detachProjectPopoverDismiss() {
3727
+ if (projectPopoverDismiss) {
3728
+ try { projectPopoverDismiss(); } catch {}
3729
+ projectPopoverDismiss = null;
3730
+ }
3731
+ }
3732
+
3733
+ // Position the popover directly below the anchor that opened it. The
3734
+ // popover element must already be in the DOM (renderProjectPopover()
3735
+ // populates it) so getBoundingClientRect() returns real dimensions.
3736
+ function positionPopover(anchorBtn) {
3737
+ const pop = document.getElementById("project-switcher-popover");
3738
+ if (!pop || !anchorBtn) return;
3739
+ const rect = anchorBtn.getBoundingClientRect();
3740
+ const popRect = pop.getBoundingClientRect();
3741
+ // Default: align the popover's right edge to the anchor's right edge.
3742
+ // That's the right look for the topbar-right chip.
3743
+ let top = rect.bottom + 6;
3744
+ let left = rect.right - popRect.width;
3745
+ // The brand chip lives on the LEFT side of the topbar — align the
3746
+ // popover's left edge to the anchor's left edge for a more natural
3747
+ // visual flow.
3748
+ if (anchorBtn.id === "brand-project-chip") {
3749
+ left = rect.left;
3750
+ }
3751
+ // Keep the popover on-screen with a small margin.
3752
+ const PAD = 8;
3753
+ const maxLeft = window.innerWidth - popRect.width - PAD;
3754
+ if (left > maxLeft) left = maxLeft;
3755
+ if (left < PAD) left = PAD;
3756
+ // If the popover would fall off the bottom, flip it above the anchor.
3757
+ if (top + popRect.height > window.innerHeight - PAD) {
3758
+ top = rect.top - popRect.height - 6;
3759
+ }
3760
+ if (top < PAD) top = PAD;
3761
+ pop.style.top = `${top}px`;
3762
+ pop.style.left = `${left}px`;
3763
+ }
3764
+
3765
+ function openProjectPopover(anchorBtn) {
3766
+ const pop = document.getElementById("project-switcher-popover");
3767
+ const btn = document.getElementById("project-switcher-btn");
3768
+ const chip = document.getElementById("brand-project-chip");
3769
+ if (!pop) return;
3770
+ renderProjectPopover();
3771
+ pop.hidden = false;
3772
+ // Mark BOTH trigger buttons as expanded so screen readers and CSS
3773
+ // stay in sync regardless of which one opened the popover.
3774
+ if (btn) btn.setAttribute("aria-expanded", "true");
3775
+ if (chip) chip.setAttribute("aria-expanded", "true");
3776
+ // Position the popover below the anchor that opened it.
3777
+ positionPopover(anchorBtn);
3778
+ // Defer dismiss-listener attachment so the opening click doesn't
3779
+ // immediately close the popover. 50ms is short enough to feel
3780
+ // instant but long enough to outlast the opening click event.
3781
+ setTimeout(() => attachProjectPopoverDismiss(pop), 50);
3782
+ void fetchProjects().then(() => { if (!pop.hidden) { renderProjectPopover(); positionPopover(anchorBtn); } });
3783
+ }
3784
+
3785
+ // Single toggle entry point used by both trigger buttons. Decouples
3786
+ // the click handler from the mousedown-capture dismiss listener so the
3787
+ // two can never race (close-then-reopen) on the same click.
3788
+ function toggleProjectPopover(anchorBtn) {
3789
+ const pop = document.getElementById("project-switcher-popover");
3790
+ if (!pop) return;
3791
+ if (!pop.hidden) {
3792
+ closeProjectPopover();
3793
+ return;
3794
+ }
3795
+ openProjectPopover(anchorBtn);
3796
+ }
3797
+
3798
+ function renderProjectPopover() {
3799
+ const pop = document.getElementById("project-switcher-popover");
3800
+ if (!pop) return;
3801
+ pop.innerHTML = "";
3802
+ // Header row — a small "Projects" label so the popover reads as a
3803
+ // section header and not just a loose list of buttons. Styled in CSS
3804
+ // as a muted, uppercase, letter-spaced label.
3805
+ pop.append(el("div", "project-switcher-header", { text: "Projects · Recent activity" }));
3806
+ const list = projectSwitcher.list || [];
3807
+ if (projectSwitcher.error) {
3808
+ pop.append(el("div", "project-switcher-error", {
3809
+ text: `Projects unavailable: ${projectSwitcher.error}`,
3810
+ }));
3811
+ }
3812
+ if (list.length === 0 && !projectSwitcher.error) {
3813
+ pop.append(el("div", "project-switcher-empty", {
3814
+ text: "No projects registered yet.",
3815
+ }));
3816
+ }
3817
+ for (const p of list) {
3818
+ const row = el("button", "project-switcher-item", {
3819
+ type: "button",
3820
+ role: "menuitem",
3821
+ title: p.root || p.name,
3822
+ });
3823
+ if (p.active) row.classList.add("active");
3824
+ row.append(el("span", `project-dot${p.active ? " filled" : ""}`, { "aria-hidden": "true" }));
3825
+ const identity = el("span", "project-switcher-identity");
3826
+ identity.append(el("span", "project-switcher-name", { text: p.name || "(unnamed)" }));
3827
+ identity.append(el("span", "project-switcher-path", { text: p.root || "" }));
3828
+ row.append(identity);
3829
+ if (p.active) {
3830
+ row.append(el("span", "project-switcher-badge", { text: "active" }));
3831
+ }
3832
+ // Per-row ✕ — stops propagation so clicking it doesn't switch.
3833
+ const x = el("span", "project-switcher-remove", {
3834
+ text: "✕",
3835
+ title: "Remove this project",
3836
+ role: "button",
3837
+ "aria-label": `Remove project ${p.name}`,
3838
+ });
3839
+ x.addEventListener("click", async (ev) => {
3840
+ ev.stopPropagation();
3841
+ if (!confirm(`Remove project "${p.name}"?`)) return;
3842
+ // Close immediately so the popover disappears even before the DELETE
3843
+ // request lands. If the delete fails, the toast surfaces the error
3844
+ // and the user can re-open the popover to retry.
3845
+ closeProjectPopover();
3846
+ try {
3847
+ await api("DELETE", `/api/projects/${encodeURIComponent(p.id)}`);
3848
+ await fetchProjects();
3849
+ renderProjectChip();
3850
+ } catch (err) {
3851
+ showToast(`Failed to remove project: ${err.message}`, "error");
3852
+ }
3853
+ });
3854
+ row.addEventListener("click", async () => {
3855
+ // Close immediately so the popover disappears the instant the user
3856
+ // picks a project — don't wait for the PATCH to land.
3857
+ closeProjectPopover();
3858
+ try {
3859
+ await api("PATCH", `/api/projects/${encodeURIComponent(p.id)}/active`);
3860
+ } catch (err) {
3861
+ showToast(`Failed to switch project: ${err.message}`, "error");
3862
+ return;
3863
+ }
3864
+ await fetchProjects();
3865
+ renderProjectChip();
3866
+ // Hard reload so every module re-fetches against the new project.
3867
+ window.location.reload();
3868
+ });
3869
+ row.addEventListener("contextmenu", (ev) => {
3870
+ ev.preventDefault();
3871
+ openProjectContextMenu(ev, p);
3872
+ });
3873
+ row.append(x);
3874
+ pop.append(row);
3875
+ }
3876
+ if (list.length > 0) {
3877
+ pop.append(el("div", "project-switcher-divider"));
3878
+ }
3879
+ const addRow = el("button", "project-switcher-add", {
3880
+ type: "button",
3881
+ role: "menuitem",
3882
+ text: "+ Add project…",
3883
+ });
3884
+ addRow.addEventListener("click", () => {
3885
+ closeProjectPopover();
3886
+ openAddProjectModal();
3887
+ });
3888
+ pop.append(addRow);
3889
+ }
3890
+
3891
+ function openProjectContextMenu(ev, project) {
3892
+ // Minimal v1 context menu: "Open in new tab" + "Reveal in file manager".
3893
+ const menu = document.createElement("div");
3894
+ menu.className = "project-context-menu";
3895
+ menu.style.position = "fixed";
3896
+ menu.style.left = `${Math.min(ev.clientX, window.innerWidth - 200)}px`;
3897
+ menu.style.top = `${Math.min(ev.clientY, window.innerHeight - 100)}px`;
3898
+ menu.setAttribute("role", "menu");
3899
+
3900
+ const open = document.createElement("button");
3901
+ open.type = "button";
3902
+ open.textContent = "Open in new tab";
3903
+ open.addEventListener("click", () => {
3904
+ window.open("/", "_blank", "noopener");
3905
+ document.body.removeChild(menu);
3906
+ });
3907
+ menu.append(open);
3908
+
3909
+ const reveal = document.createElement("button");
3910
+ reveal.type = "button";
3911
+ reveal.textContent = "Reveal in file manager";
3912
+ reveal.addEventListener("click", async () => {
3913
+ try {
3914
+ await api("POST", "/api/reveal", { path: project.root });
3915
+ } catch (err) {
3916
+ // Server may not implement /api/reveal — fall back to clipboard so
3917
+ // the user can paste it into their file manager.
3918
+ try {
3919
+ await navigator.clipboard.writeText(String(project.root || ""));
3920
+ showToast(`Copied path: ${project.root}`);
3921
+ } catch {
3922
+ alert(`Project root: ${project.root}`);
3923
+ }
3924
+ } finally {
3925
+ if (menu.parentNode) menu.parentNode.removeChild(menu);
3926
+ }
3927
+ });
3928
+ menu.append(reveal);
3929
+
3930
+ const close = (e) => {
3931
+ if (menu.contains(e.target)) return;
3932
+ if (menu.parentNode) menu.parentNode.removeChild(menu);
3933
+ document.removeEventListener("click", close, true);
3934
+ };
3935
+ document.addEventListener("click", close, true);
3936
+ document.body.append(menu);
3937
+ }
3938
+
3939
+ function getProjectModal() {
3940
+ return document.getElementById("project-backdrop");
3941
+ }
3942
+ function openAddProjectModal() {
3943
+ const bd = getProjectModal();
3944
+ if (!bd) return;
3945
+ bd.hidden = false;
3946
+ setTimeout(() => {
3947
+ const nameInput = bd.querySelector('input[name="name"]');
3948
+ if (nameInput) nameInput.focus();
3949
+ }, 0);
3950
+ }
3951
+ function closeAddProjectModal() {
3952
+ const bd = getProjectModal();
3953
+ if (bd) {
3954
+ bd.hidden = true;
3955
+ const form = bd.querySelector("form");
3956
+ if (form) form.reset();
3957
+ }
3958
+ }
3959
+
3960
+ function attachProjectSwitcher() {
3961
+ const btn = document.getElementById("project-switcher-btn");
3962
+ const pop = document.getElementById("project-switcher-popover");
3963
+ const chip = document.getElementById("brand-project-chip");
3964
+ if (pop) {
3965
+ // Move the popover to <body> so `position: fixed` is relative to
3966
+ // the viewport, not the topbar. The topbar uses `backdrop-filter`
3967
+ // which creates a containing block for fixed-positioned descendants
3968
+ // in some browsers; relocating to <body> sidesteps that entirely
3969
+ // and also lets the popover escape any future overflow:hidden on
3970
+ // the topbar or its ancestors.
3971
+ if (pop.parentElement !== document.body) {
3972
+ document.body.appendChild(pop);
3973
+ }
3974
+ }
3975
+ if (chip) {
3976
+ // Brand chip starts collapsed. We add aria-haspopup/aria-expanded
3977
+ // here (rather than in the HTML) so the chip advertises itself as
3978
+ // a menu trigger to assistive tech.
3979
+ chip.setAttribute("aria-haspopup", "menu");
3980
+ chip.setAttribute("aria-expanded", "false");
3981
+ }
3982
+ if (btn && pop) {
3983
+ btn.addEventListener("click", (e) => {
3984
+ e.preventDefault();
3985
+ e.stopPropagation();
3986
+ toggleProjectPopover(btn);
3987
+ });
3988
+ // Dismiss listeners are attached inside openProjectPopover() with a
3989
+ // 50ms delay so the opening click doesn't immediately close it. The
3990
+ // close handler detaches them, so we don't need module-scope ones here.
3991
+ }
3992
+ if (chip) {
3993
+ chip.addEventListener("click", (e) => {
3994
+ e.preventDefault();
3995
+ e.stopPropagation();
3996
+ toggleProjectPopover(chip);
3997
+ });
3998
+ }
3999
+ // Add-project modal wiring.
4000
+ const bd = getProjectModal();
4001
+ if (bd) {
4002
+ bd.addEventListener("click", (e) => {
4003
+ if (e.target === bd) closeAddProjectModal();
4004
+ });
4005
+ bd.querySelectorAll("[data-close-project]").forEach((b) =>
4006
+ b.addEventListener("click", closeAddProjectModal),
4007
+ );
4008
+ const form = document.getElementById("project-form");
4009
+ if (form) {
4010
+ form.addEventListener("submit", async (ev) => {
4011
+ ev.preventDefault();
4012
+ const fd = new FormData(form);
4013
+ const body = {
4014
+ name: String(fd.get("name") || "").trim(),
4015
+ root: String(fd.get("root") || "").trim(),
4016
+ };
4017
+ if (!body.name || !body.root) return alert("Name and path are required.");
4018
+ try {
4019
+ await api("POST", "/api/projects", body);
4020
+ } catch (err) {
4021
+ alert(`Failed to add project: ${err.message}`);
4022
+ return;
4023
+ }
4024
+ closeAddProjectModal();
4025
+ // Re-fetch and refresh the chip + dropdown.
4026
+ await fetchProjects();
4027
+ renderProjectChip();
4028
+ // Reload so the new project becomes active on the server side and
4029
+ // the rest of the UI can re-fetch against it.
4030
+ window.location.reload();
4031
+ });
4032
+ }
4033
+
4034
+ // Browse-button wiring — opens the path picker in folder mode and
4035
+ // writes the picked path into the root input. The path picker is
4036
+ // loaded by its own script tag in index.html and exposed via
4037
+ // window.OpenKanPathPicker. We treat its absence as a no-op so the
4038
+ // page still works if the script fails to load.
4039
+ const browseBtn = document.getElementById("project-browse-btn");
4040
+ if (browseBtn) {
4041
+ browseBtn.addEventListener("click", () => {
4042
+ const picker = window.OpenKanPathPicker;
4043
+ if (!picker || typeof picker.open !== "function") {
4044
+ console.warn(
4045
+ "[project-modal] Browse clicked but OpenKanPathPicker is not loaded",
4046
+ );
4047
+ return;
4048
+ }
4049
+ const rootInput =
4050
+ bd.querySelector('input[name="root"]') ||
4051
+ document.querySelector('input[name="root"]');
4052
+ const initial =
4053
+ rootInput && typeof rootInput.value === "string"
4054
+ ? rootInput.value.trim()
4055
+ : "";
4056
+ picker.open({
4057
+ title: "Choose a project folder",
4058
+ mode: "folder",
4059
+ initialPath: initial || undefined,
4060
+ onPick: (path) => {
4061
+ if (rootInput) rootInput.value = path;
4062
+ },
4063
+ onCancel: () => {
4064
+ /* user dismissed — nothing to do */
4065
+ },
4066
+ });
4067
+ });
4068
+ }
4069
+ }
4070
+ // Initial fetch.
4071
+ fetchProjects().then(() => renderProjectChip());
4072
+ }
4073
+
4074
+ // ---------- Boot ----------
4075
+ (async () => {
4076
+ // Restore filter from URL hash before first render.
4077
+ const initial = readHashFilter();
4078
+ if (initial) {
4079
+ filter.category = initial.category;
4080
+ filter.tags = initial.tags;
4081
+ filter.contributor = initial.contributor;
4082
+ filter.archive = initial.archive;
4083
+ filter.sort = initial.sort;
4084
+ filter.search = initial.search || "";
4085
+ }
4086
+ attachFilterBar();
4087
+ attachSearch();
4088
+ attachBulkBar();
4089
+ applyFilterToButtons();
4090
+ renderSavedFilters();
4091
+ attachTabRouter();
4092
+ attachWorkspaceMode();
4093
+ attachFilterDisclosure();
4094
+ attachProjectSwitcher();
4095
+ // Page-wide right-click context menu (capture-phase delegation). The
4096
+ // handler decides which menu to show based on `e.target.closest()`:
4097
+ // card → per-task menu, column → column ops, chip → filter ops, etc.
4098
+ document.addEventListener("contextmenu", openGlobalContextMenu, true);
4099
+ // Normal-bubble fallback. If a downstream capture-phase handler calls
4100
+ // stopPropagation() (or some browser extension swallows the capture
4101
+ // event), this listener still catches the contextmenu so the user
4102
+ // always gets a response.
4103
+ document.addEventListener("contextmenu", openGlobalContextMenu);
4104
+ // M13 wiring — keyboard nav + command palette actions + cross-tab sync.
4105
+ // Order matters: keyboard module is initialized when keyboard.js loads
4106
+ // (it's a self-contained IIFE on a DOMContentLoaded hook), but our
4107
+ // handlers must be registered before any key fires. The palette registers
4108
+ // its actions during init() too, and exposes itself at first palette.open
4109
+ // call — so we attach handlers eagerly.
4110
+ attachKeyboard();
4111
+ registerPaletteActions();
4112
+ attachCrossTab();
4113
+ // The ⌘K topbar button is a click-only entry point to the palette.
4114
+ // The keyboard module is the canonical emitter for "palette.open".
4115
+ const paletteBtn = document.getElementById("palette-btn");
4116
+ if (paletteBtn) {
4117
+ paletteBtn.addEventListener("click", (e) => {
4118
+ e.preventDefault();
4119
+ window.OpenKanKeyboard?.execute?.("palette.open");
4120
+ });
4121
+ }
4122
+ // Activate the tab from the hash, defaulting to "tasks".
4123
+ const hash = readHashFilter();
4124
+ activateTab(hash?.tab || "tasks", { fromHash: true });
4125
+
4126
+ // Mount the chat sidebar (right-rail chat orchestrator). chat-sidebar.js
4127
+ // wires its own topbar toggle button; this call ensures the DOM shell
4128
+ // exists so the toggle can flip its open/closed state.
4129
+ try { window.OpenKanChatSidebar?.mount?.(document.body); } catch { /* ignore */ }
4130
+
4131
+ try {
4132
+ applySnapshot(await api("GET", "/api/board"));
4133
+ setConnected(true);
4134
+ } catch {
4135
+ setConnected(false);
4136
+ }
4137
+
4138
+ // Best-effort fetch of contributors (used by filter row + assignees).
4139
+ loadContributors();
4140
+
4141
+ // Run the restored search query once the board has loaded so the meta
4142
+ // count and the per-card visibility agree.
4143
+ if (filter.search) {
4144
+ const meta = document.getElementById("search-meta");
4145
+ runSearch(filter.search, meta);
4146
+ }
4147
+ updateBulkBar();
4148
+
4149
+ // If the user lands on a task URL we don't auto-open it (no notion of
4150
+ // deep-link to a task in v1.1). Hook left here for future M12 work.
4151
+ })();
4152
+
4153
+ // Expose a tiny API so the task view (a separate file) can set a filter
4154
+ // and have the board re-render.
4155
+ window.OpenKanBoard = {
4156
+ setTagFilter(tag) {
4157
+ const t = String(tag || "").toLowerCase().trim();
4158
+ if (!t) return;
4159
+ if (!filter.tags.includes(t)) filter.tags.push(t);
4160
+ writeHashFilter();
4161
+ applyFilterToButtons();
4162
+ renderBoard();
4163
+ },
4164
+ setCategoryFilter(category) {
4165
+ filter.category = String(category || "all");
4166
+ writeHashFilter();
4167
+ applyFilterToButtons();
4168
+ renderBoard();
4169
+ },
4170
+ setContributorFilter(contributor) {
4171
+ filter.contributor = String(contributor || "all");
4172
+ writeHashFilter();
4173
+ applyFilterToButtons();
4174
+ renderBoard();
4175
+ },
4176
+ setSearch(query) {
4177
+ const q = String(query || "");
4178
+ filter.search = q;
4179
+ const input = document.getElementById("search-input");
4180
+ if (input) input.value = q;
4181
+ searchMatchIds = null;
4182
+ writeHashFilter();
4183
+ applyFilterToButtons();
4184
+ renderBoard();
4185
+ if (q) {
4186
+ const meta = document.getElementById("search-meta");
4187
+ runSearch(q, meta);
4188
+ } else {
4189
+ const meta = document.getElementById("search-meta");
4190
+ updateSearchMeta(meta, null, null);
4191
+ }
4192
+ },
4193
+ setFilter(filterObj) {
4194
+ if (!filterObj) return;
4195
+ if (typeof filterObj.category === "string") filter.category = filterObj.category;
4196
+ if (Array.isArray(filterObj.tags)) filter.tags = filterObj.tags.slice();
4197
+ if (typeof filterObj.contributor === "string") filter.contributor = filterObj.contributor;
4198
+ if (typeof filterObj.archive === "string") filter.archive = filterObj.archive;
4199
+ if (typeof filterObj.sort === "string") filter.sort = filterObj.sort;
4200
+ writeHashFilter();
4201
+ applyFilterToButtons();
4202
+ renderBoard();
4203
+ },
4204
+ getFilter() {
4205
+ return {
4206
+ category: filter.category,
4207
+ tags: filter.tags.slice(),
4208
+ contributor: filter.contributor,
4209
+ archive: filter.archive,
4210
+ sort: filter.sort,
4211
+ search: filter.search,
4212
+ };
4213
+ },
4214
+ getSelectedIds() {
4215
+ return [...selectedIds];
4216
+ },
4217
+ clearSelection() {
4218
+ clearCardSelection();
4219
+ },
4220
+ activateTab(name) {
4221
+ activateTab(name);
4222
+ },
4223
+ };
4224
+
4225
+ // Expose the tab router for cross-file coordination (changelog-view,
4226
+ // contributors-view use it to jump to the Tasks tab).
4227
+ window.OpenKanTabs = { activate: activateTab };
4228
+
4229
+ // Expose the right-click menu infrastructure so the task view (and any
4230
+ // other module) can show context menus that share the same host, styling,
4231
+ // and dismiss behavior. Callers push an items[] in the same shape as
4232
+ // renderMenu's argument.
4233
+ window.OpenKanMenu = {
4234
+ showAt(items, ev) {
4235
+ if (!ev) return;
4236
+ renderMenu(items);
4237
+ positionMenuAt(ev);
4238
+ },
4239
+ show(items, anchorEl) {
4240
+ renderMenu(items);
4241
+ // Synthesize a clientX/Y from the anchor's bounding rect so the menu
4242
+ // appears just below it. Useful for keyboard "open menu" handlers.
4243
+ if (anchorEl) {
4244
+ const r = anchorEl.getBoundingClientRect();
4245
+ const fake = { clientX: r.left, clientY: r.bottom };
4246
+ positionMenuAt(fake);
4247
+ }
4248
+ },
4249
+ hide: hideMenu,
4250
+ };
4251
+ })();