@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,217 @@
1
+ // web/insights.js — Insights tab (M-Insights).
2
+ //
3
+ // window.OpenKanInsights = { mount(root), unmount() }
4
+ //
5
+ // Fetches /api/insights/velocity on first mount (or on hash change
6
+ // to re-fetch). Renders a three-card summary row (tasks done in
7
+ // window, busiest day, average lead time) above one stacked-bar
8
+ // chart via window.OpenKanCharts. Empty state shows the
9
+ // `web/brand/empty-tasks.svg` illustration + a real cause-and-action
10
+ // sentence.
11
+
12
+ (() => {
13
+ "use strict";
14
+
15
+ const { api } = window.OpenKanAPI;
16
+ const { renderStackedBar, isAllZero } = window.OpenKanCharts;
17
+
18
+ let mountedRoot = null;
19
+ let mountedData = null;
20
+
21
+ function el(tag, cls, props = {}) {
22
+ const e = document.createElement(tag);
23
+ if (cls) e.className = cls;
24
+ for (const [k, v] of Object.entries(props)) {
25
+ if (k === "text") e.textContent = v;
26
+ else if (k === "html") e.innerHTML = v;
27
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
28
+ else e.setAttribute(k, v);
29
+ }
30
+ return e;
31
+ }
32
+
33
+ function buildSummary(data) {
34
+ const days = data.days || [];
35
+ const cols = data.columns || {};
36
+ const done = cols.done || [];
37
+ const totalDone = done.reduce((a, b) => a + (b > 0 ? b : 0), 0);
38
+
39
+ // Busiest day: argmax of day total across all columns.
40
+ let bestIdx = -1;
41
+ let bestVal = 0;
42
+ for (let i = 0; i < days.length; i++) {
43
+ let dayTotal = 0;
44
+ for (const id of ["backlog", "todo", "doing", "review", "done"]) {
45
+ const arr = cols[id] || [];
46
+ const v = arr[i] || 0;
47
+ if (v > 0) dayTotal += v;
48
+ }
49
+ if (dayTotal > bestVal) { bestVal = dayTotal; bestIdx = i; }
50
+ }
51
+ let busiestDay = "—";
52
+ if (bestIdx >= 0) {
53
+ const parts = (days[bestIdx] || "").split("-").map(Number);
54
+ if (parts.length === 3 && parts.every(n => !isNaN(n))) {
55
+ const dt = new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0, 0);
56
+ try {
57
+ busiestDay = new Intl.DateTimeFormat(undefined, { month: "short", day: "2-digit" }).format(dt);
58
+ } catch {
59
+ busiestDay = days[bestIdx];
60
+ }
61
+ }
62
+ }
63
+
64
+ // Average lead time. We don't have per-task move-into-doing /
65
+ // move-into-done timestamps in the current response, so this card
66
+ // reports the count of move-into-done events in the window. The
67
+ // design spec notes the per-task lead-time derivation as a v2.
68
+ // For v1 we display a derived "moves per day" number as a stand-in
69
+ // and a clear copy line.
70
+ const window = Math.max(1, days.length);
71
+ const movesPerDay = (totalDone / window).toFixed(1);
72
+
73
+ return { totalDone, busiestDay, movesPerDay, bestVal, window };
74
+ }
75
+
76
+ function renderSummaryRow(parent, summary) {
77
+ const row = el("div", "insights-summary");
78
+ row.appendChild(buildCard(String(summary.totalDone), "Tasks done in window",
79
+ "Sum of move-into-done events over the period."));
80
+ row.appendChild(buildCard(summary.busiestDay, "Busiest day",
81
+ `Day with the most moves (${summary.bestVal || 0} total).`));
82
+ row.appendChild(buildCard(summary.movesPerDay, "Avg moves / day (done)",
83
+ "Tasks reaching Done per day, averaged over the window."));
84
+ parent.appendChild(row);
85
+ }
86
+
87
+ function buildCard(value, label, hint) {
88
+ const card = el("div", "insights-card");
89
+ const v = el("div", "insights-card-value", { text: value });
90
+ const l = el("div", "insights-card-label", { text: label });
91
+ const h = el("div", "insights-card-hint", { text: hint });
92
+ card.appendChild(v);
93
+ card.appendChild(l);
94
+ card.appendChild(h);
95
+ return card;
96
+ }
97
+
98
+ function renderEmptyState(parent) {
99
+ const wrap = el("div", "insights-empty");
100
+ const img = el("img", "insights-empty-img", {
101
+ src: "./brand/empty-tasks.svg",
102
+ alt: "",
103
+ width: 320,
104
+ height: 200,
105
+ });
106
+ const caption = el("p", "insights-empty-caption");
107
+ caption.appendChild(document.createTextNode(
108
+ "No changelog activity yet. Start moving cards to populate this chart."
109
+ ));
110
+ wrap.appendChild(img);
111
+ wrap.appendChild(caption);
112
+ parent.appendChild(wrap);
113
+ }
114
+
115
+ function buildDataTable(parent, data) {
116
+ const details = el("details", "insights-data");
117
+ const summary = el("summary", "insights-data-summary", { text: "Show data table" });
118
+ details.appendChild(summary);
119
+ const table = el("table", "insights-data-table");
120
+ const thead = el("thead");
121
+ const tr = el("tr");
122
+ tr.appendChild(el("th", "", { text: "Date" }));
123
+ for (const id of ["backlog", "todo", "doing", "review", "done"]) {
124
+ tr.appendChild(el("th", "", { text: id }));
125
+ }
126
+ tr.appendChild(el("th", "", { text: "total" }));
127
+ thead.appendChild(tr);
128
+ table.appendChild(thead);
129
+ const tbody = el("tbody");
130
+ const days = data.days || [];
131
+ for (let i = 0; i < days.length; i++) {
132
+ const tr2 = el("tr");
133
+ tr2.appendChild(el("td", "", { text: days[i] }));
134
+ let dayTotal = 0;
135
+ for (const id of ["backlog", "todo", "doing", "review", "done"]) {
136
+ const v = (data.columns?.[id] || [])[i] || 0;
137
+ tr2.appendChild(el("td", "", { text: String(v) }));
138
+ if (v > 0) dayTotal += v;
139
+ }
140
+ tr2.appendChild(el("td", "", { text: String(dayTotal) }));
141
+ tbody.appendChild(tr2);
142
+ }
143
+ table.appendChild(tbody);
144
+ details.appendChild(table);
145
+ parent.appendChild(details);
146
+ }
147
+
148
+ function buildLegend(parent) {
149
+ const ul = el("ul", "insights-legend");
150
+ for (const id of ["backlog", "todo", "doing", "review", "done"]) {
151
+ const li = el("li", "insights-legend-item");
152
+ const sw = el("span", `insights-legend-swatch swatch-${id}`);
153
+ sw.setAttribute("aria-hidden", "true");
154
+ li.appendChild(sw);
155
+ li.appendChild(document.createTextNode(id));
156
+ ul.appendChild(li);
157
+ }
158
+ parent.appendChild(ul);
159
+ }
160
+
161
+ function render(mount, data) {
162
+ while (mount.firstChild) mount.removeChild(mount.firstChild);
163
+ const card = el("section", "insights-card-wrap");
164
+ mount.appendChild(card);
165
+
166
+ const summary = buildSummary(data);
167
+ renderSummaryRow(card, summary);
168
+
169
+ const chartWrap = el("div", "insights-chart");
170
+ if (isAllZero(data)) {
171
+ renderEmptyState(chartWrap);
172
+ } else {
173
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
174
+ svg.classList.add("insights-chart-svg");
175
+ renderStackedBar(svg, data);
176
+ chartWrap.appendChild(svg);
177
+ buildLegend(chartWrap);
178
+ }
179
+ card.appendChild(chartWrap);
180
+ buildDataTable(card, data);
181
+ }
182
+
183
+ async function mount(root) {
184
+ if (!root) return;
185
+ mountedRoot = root;
186
+ while (root.firstChild) root.removeChild(root.firstChild);
187
+ const loading = el("p", "insights-loading", { text: "Loading insights…" });
188
+ root.appendChild(loading);
189
+ try {
190
+ const data = await api("GET", "/api/insights/velocity?days=30");
191
+ mountedData = data;
192
+ if (mountedRoot === root) render(root, data);
193
+ } catch (err) {
194
+ while (root.firstChild) root.removeChild(root.firstChild);
195
+ const errP = el("p", "insights-error");
196
+ errP.appendChild(document.createTextNode(
197
+ "Failed to load insights. Retry by switching tabs."
198
+ ));
199
+ const errHint = el("span", "insights-error-hint");
200
+ errHint.appendChild(document.createTextNode(
201
+ (err && err.message) ? ` (${err.message})` : ""
202
+ ));
203
+ errP.appendChild(errHint);
204
+ root.appendChild(errP);
205
+ }
206
+ }
207
+
208
+ function unmount() {
209
+ if (mountedRoot) {
210
+ while (mountedRoot.firstChild) mountedRoot.removeChild(mountedRoot.firstChild);
211
+ }
212
+ mountedRoot = null;
213
+ mountedData = null;
214
+ }
215
+
216
+ window.OpenKanInsights = { mount, unmount };
217
+ })();
@@ -0,0 +1,446 @@
1
+ // OpenKan — global keyboard shortcuts + help overlay (M13).
2
+ // window.OpenKanKeyboard = { on, off, execute, showHelp, hideHelp,
3
+ // isHelpOpen, getFocusedId, setFocusedId,
4
+ // focusCardById, scrollFocusedIntoView }
5
+ //
6
+ // The keyboard module is a thin event hub: it owns the global keydown
7
+ // listener, the focused-card state, and the help overlay. Consumers
8
+ // (app.js, task-view.js) register action handlers via on("focus.next", …)
9
+ // and the module invokes them when the matching key is pressed.
10
+ //
11
+ // The focused-card class is rendered by app.js's renderCard() — it calls
12
+ // getFocusedId() and adds .focused to the matching <article>. The keyboard
13
+ // module also exposes scrollFocusedIntoView() so callers can force a scroll
14
+ // after a navigation event without waiting for the next render pass.
15
+ //
16
+ // Shortcut table:
17
+ // j / ↓ focus.next — select next card in column
18
+ // k / ↑ focus.previous — select previous card
19
+ // h / ← focus.column-prev — select first card in prev column
20
+ // l / → focus.column-next — select first card in next column
21
+ // Enter focus.open — open the focused card's task view
22
+ // Space focus.toggle-select — toggle selection of the focused card
23
+ // 1..5 action.move-column — column index 0..4 (backlog → done)
24
+ // a action.archive
25
+ // e action.edit — open / focus the task view's edit mode
26
+ // d action.delete
27
+ // / search.focus
28
+ // ? or Shift+/ help.show
29
+ // Cmd/Ctrl+K palette.open
30
+ // Esc (priority: palette > help > others — see handler)
31
+ //
32
+ // Keys that could disrupt typing (e, d, a, ?, /) are ignored while focus is
33
+ // inside an <input>, <textarea>, or [contenteditable]. Cmd/Ctrl+K and Esc
34
+ // always work.
35
+
36
+ (() => {
37
+ "use strict";
38
+
39
+ /** @type {string|null} */
40
+ let focusedId = null;
41
+ /** @type {HTMLElement|null} The help overlay root, built lazily. */
42
+ let helpRoot = null;
43
+ /** Element that had focus before the help overlay opened; restored on close. */
44
+ let helpPrevFocus = null;
45
+
46
+ /** @type {Map<string, Set<Function>>} */
47
+ const handlers = new Map();
48
+
49
+ function on(event, handler) {
50
+ if (!event || typeof handler !== "function") return () => {};
51
+ let set = handlers.get(event);
52
+ if (!set) { set = new Set(); handlers.set(event, set); }
53
+ set.add(handler);
54
+ return () => off(event, handler);
55
+ }
56
+ function off(event, handler) {
57
+ const set = handlers.get(event);
58
+ if (!set) return;
59
+ set.delete(handler);
60
+ if (set.size === 0) handlers.delete(event);
61
+ }
62
+ function emit(event, ...args) {
63
+ const set = handlers.get(event);
64
+ if (!set) return;
65
+ for (const fn of set) {
66
+ try { fn(...args); } catch (e) {
67
+ console.error(`[keyboard] handler for ${event} threw:`, e);
68
+ }
69
+ }
70
+ }
71
+ /** Manual invocation of a named action — useful for tests / external triggers. */
72
+ function execute(event, ...args) {
73
+ emit(event, ...args);
74
+ }
75
+
76
+ function getFocusedId() { return focusedId; }
77
+ function setFocusedId(id) {
78
+ if (focusedId === id) return;
79
+ focusedId = id;
80
+ emit("focus.changed", id);
81
+ }
82
+
83
+ // ─── Helpers ──────────────────────────────────────────────────────────────
84
+ function isTypingTarget(target) {
85
+ if (!target || target.nodeType !== 1) return false;
86
+ if (target.matches && target.matches("input, textarea")) return true;
87
+ if (target.isContentEditable) return true;
88
+ // Select / contenteditable ancestors.
89
+ let n = target.parentElement;
90
+ while (n) {
91
+ if (n.isContentEditable) return true;
92
+ n = n.parentElement;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function taskViewOpen() {
98
+ const v = document.getElementById("task-view");
99
+ return !!v && !v.hidden;
100
+ }
101
+
102
+ function lightboxOpen() {
103
+ const lb = document.getElementById("image-lightbox");
104
+ return !!lb && !lb.hidden;
105
+ }
106
+
107
+ function modalOpen() {
108
+ for (const id of ["modal-backdrop", "settings-backdrop", "template-backdrop"]) {
109
+ const el = document.getElementById(id);
110
+ if (el && !el.hidden) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // ─── Help overlay (lazy-created) ──────────────────────────────────────────
116
+ function buildHelpOverlay() {
117
+ const root = document.createElement("div");
118
+ root.id = "help-overlay";
119
+ root.className = "help-overlay";
120
+ root.setAttribute("role", "dialog");
121
+ root.setAttribute("aria-modal", "true");
122
+ root.setAttribute("aria-labelledby", "help-title");
123
+ root.hidden = true;
124
+
125
+ const dialog = document.createElement("div");
126
+ dialog.className = "help-dialog";
127
+ dialog.tabIndex = -1;
128
+ dialog.innerHTML = `
129
+ <header class="help-header">
130
+ <h2 id="help-title">Keyboard shortcuts</h2>
131
+ <button type="button" class="btn-icon help-close" aria-label="Close">×</button>
132
+ </header>
133
+ <div class="help-body">
134
+ <section class="help-col">
135
+ <h3>Navigation</h3>
136
+ <dl>
137
+ <dt><kbd>j</kbd> / <kbd>↓</kbd></dt><dd>next card</dd>
138
+ <dt><kbd>k</kbd> / <kbd>↑</kbd></dt><dd>previous card</dd>
139
+ <dt><kbd>h</kbd> / <kbd>←</kbd></dt><dd>previous column</dd>
140
+ <dt><kbd>l</kbd> / <kbd>→</kbd></dt><dd>next column</dd>
141
+ <dt><kbd>Enter</kbd></dt><dd>open focused task</dd>
142
+ </dl>
143
+ </section>
144
+ <section class="help-col">
145
+ <h3>Selection</h3>
146
+ <dl>
147
+ <dt><kbd>Space</kbd></dt><dd>toggle selection of focused card</dd>
148
+ <dt><kbd>1</kbd>–<kbd>5</kbd></dt><dd>move selected cards to column (backlog → done)</dd>
149
+ <dt><kbd>a</kbd></dt><dd>archive selected</dd>
150
+ <dt><kbd>d</kbd></dt><dd>delete selected (confirm)</dd>
151
+ </dl>
152
+ </section>
153
+ <section class="help-col">
154
+ <h3>Other</h3>
155
+ <dl>
156
+ <dt><kbd>/</kbd></dt><dd>focus search</dd>
157
+ <dt><kbd>?</kbd></dt><dd>this help</dd>
158
+ <dt><kbd>⌘K</kbd> / <kbd>Ctrl K</kbd></dt><dd>command palette</dd>
159
+ <dt><kbd>Esc</kbd></dt><dd>close overlay / clear selection</dd>
160
+ </dl>
161
+ </section>
162
+ </div>
163
+ `;
164
+ root.append(dialog);
165
+
166
+ // Click on backdrop (not dialog body) closes.
167
+ root.addEventListener("click", (e) => {
168
+ if (e.target === root) hideHelp();
169
+ });
170
+ dialog.querySelector(".help-close").addEventListener("click", hideHelp);
171
+
172
+ document.body.appendChild(root);
173
+ return root;
174
+ }
175
+
176
+ function ensureHelp() {
177
+ if (!helpRoot) helpRoot = buildHelpOverlay();
178
+ return helpRoot;
179
+ }
180
+
181
+ function showHelp() {
182
+ const root = ensureHelp();
183
+ root.hidden = false;
184
+ // Move focus into the dialog so Esc / Tab work naturally.
185
+ setTimeout(() => {
186
+ const dlg = root.querySelector(".help-dialog");
187
+ try { dlg?.focus({ preventScroll: true }); } catch {}
188
+ // Focus the close button by default.
189
+ const btn = root.querySelector(".help-close");
190
+ try { btn?.focus({ preventScroll: true }); } catch {}
191
+ }, 0);
192
+ document.body.classList.add("help-open");
193
+ }
194
+
195
+ function hideHelp() {
196
+ if (!helpRoot || helpRoot.hidden) return false;
197
+ helpRoot.hidden = true;
198
+ document.body.classList.remove("help-open");
199
+ // Return focus to whatever had it before opening, if still attached.
200
+ if (helpPrevFocus && document.contains(helpPrevFocus)) {
201
+ try { helpPrevFocus.focus({ preventScroll: true }); } catch {}
202
+ }
203
+ helpPrevFocus = null;
204
+ return true;
205
+ }
206
+
207
+ function isHelpOpen() {
208
+ return !!(helpRoot && !helpRoot.hidden);
209
+ }
210
+
211
+ // ─── Command palette interlock ───────────────────────────────────────────
212
+ // The palette module lives in command-palette.js and registers handlers for
213
+ // "palette.open" / "palette.close" / "palette.isOpen". Esc closes it from
214
+ // there so we don't create a circular dependency — keyboard.js only knows
215
+ // it can ask via emit("palette.isOpen") → emit("palette.close").
216
+ function paletteIsOpen() {
217
+ let open = false;
218
+ emit("palette.isOpen", () => { open = true; });
219
+ return open;
220
+ }
221
+ function paletteClose() {
222
+ emit("palette.close");
223
+ }
224
+
225
+ // ─── Scroll focused card into view ───────────────────────────────────────
226
+ function scrollFocusedIntoView() {
227
+ if (!focusedId) return;
228
+ const board = document.getElementById("board");
229
+ if (!board) return;
230
+ const card = board.querySelector(`.card[data-id="${cssEscape(focusedId)}"]`);
231
+ if (!card) return;
232
+ try {
233
+ card.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
234
+ } catch {
235
+ try { card.scrollIntoView(); } catch {}
236
+ }
237
+ // Mark the card as the active element so Enter / Space don't reach the body.
238
+ try { card.focus({ preventScroll: true }); } catch {}
239
+ }
240
+
241
+ function focusCardById(id) {
242
+ if (!id) return;
243
+ const board = document.getElementById("board");
244
+ if (!board) return;
245
+ const card = board.querySelector(`.card[data-id="${cssEscape(id)}"]`);
246
+ if (!card) return;
247
+ setFocusedId(id);
248
+ scrollFocusedIntoView();
249
+ }
250
+
251
+ // CSS.escape isn't always available — roll our own for the bare chars that
252
+ // appear in task ids (UUIDs, [A-Z0-9-]). Falls back to a no-op escape.
253
+ function cssEscape(s) {
254
+ if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(s);
255
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
256
+ }
257
+
258
+ // ─── Global keydown handler ──────────────────────────────────────────────
259
+ // Capture phase: we see keys before the focused element does. Cmd/Ctrl+K
260
+ // always works; Esc always works. Other shortcuts yield when the user is
261
+ // typing in an input/textarea or [contenteditable].
262
+
263
+ function handler(e) {
264
+ // Allow our own input (palette, help) to work even when focused.
265
+ const key = e.key;
266
+
267
+ // Esc — applies the priority order described in the spec:
268
+ // palette > help > lightbox > task view > bulk select > modal
269
+ // The first two are owned by this module. Lightbox is owned by images.js
270
+ // and modal by app.js — we just fall through to them. The task view has
271
+ // no Esc handler of its own, so we close it from here. Bulk select is
272
+ // handled by app.js's bubble-phase listener; ditto fall through.
273
+ if (key === "Escape") {
274
+ if (paletteIsOpen()) {
275
+ e.preventDefault();
276
+ e.stopPropagation();
277
+ paletteClose();
278
+ return;
279
+ }
280
+ if (isHelpOpen()) {
281
+ e.preventDefault();
282
+ e.stopPropagation();
283
+ hideHelp();
284
+ return;
285
+ }
286
+ if (taskViewOpen()) {
287
+ e.preventDefault();
288
+ e.stopPropagation();
289
+ window.OpenKanTaskView?.close?.();
290
+ return;
291
+ }
292
+ // Other handlers (lightbox / modal / bulk select) own their own Esc.
293
+ return;
294
+ }
295
+
296
+ // From here on, ignore typing inside editable fields unless the key is
297
+ // Cmd/Ctrl+K (handled below; nothing else has a global override).
298
+ if (isTypingTarget(e.target)) return;
299
+
300
+ // If the palette or help overlay is open, hand off every shortcut to its
301
+ // own keydown handlers — we don't want j/k to move the board focus, or
302
+ // Space to bubble out and toggle board selection while the user is
303
+ // navigating the overlay. Esc still has priority (handled above).
304
+ if (paletteIsOpen() || isHelpOpen()) return;
305
+
306
+ const isMod = e.metaKey || e.ctrlKey;
307
+
308
+ // Cmd/Ctrl+K → palette
309
+ if (isMod && (key === "k" || key === "K")) {
310
+ e.preventDefault();
311
+ e.stopPropagation();
312
+ emit("palette.open");
313
+ return;
314
+ }
315
+
316
+ // ? or Shift+/
317
+ if ((key === "?" || (e.shiftKey && key === "/")) && !e.altKey) {
318
+ // Don't intercept when a modal is open — let the modal receive the key.
319
+ if (modalOpen()) return;
320
+ e.preventDefault();
321
+ showHelp();
322
+ return;
323
+ }
324
+
325
+ // /
326
+ if (key === "/" && !e.shiftKey && !isMod) {
327
+ if (modalOpen()) return;
328
+ e.preventDefault();
329
+ emit("search.focus");
330
+ return;
331
+ }
332
+
333
+ // If the task view is the active surface, narrow navigation to app.js
334
+ // handlers so app.js can decide whether to act (it currently doesn't
335
+ // expose edit-mode navigation, so most keys are no-ops here).
336
+ if (taskViewOpen()) {
337
+ // Space is still consumed by us — prevent the page from scrolling
338
+ // while the task view is open and the user presses Space.
339
+ if (key === " ") {
340
+ e.preventDefault();
341
+ emit("focus.toggle-select");
342
+ return;
343
+ }
344
+ // j/k/e forward even when the task view is up; consumers can ignore.
345
+ }
346
+
347
+ // j / ↓ — next card
348
+ if (key === "j" || key === "ArrowDown") {
349
+ if (modalOpen() && !taskViewOpen()) return;
350
+ e.preventDefault();
351
+ emit("focus.next");
352
+ return;
353
+ }
354
+ // k / ↑ — previous card
355
+ if (key === "k" || key === "ArrowUp") {
356
+ if (modalOpen() && !taskViewOpen()) return;
357
+ e.preventDefault();
358
+ emit("focus.previous");
359
+ return;
360
+ }
361
+ // h / ← — previous column
362
+ if (key === "h" || key === "ArrowLeft") {
363
+ if (modalOpen() && !taskViewOpen()) return;
364
+ e.preventDefault();
365
+ emit("focus.column-prev");
366
+ return;
367
+ }
368
+ // l / → — next column
369
+ if (key === "l" || key === "ArrowRight") {
370
+ if (modalOpen() && !taskViewOpen()) return;
371
+ e.preventDefault();
372
+ emit("focus.column-next");
373
+ return;
374
+ }
375
+
376
+ // Enter — open focused card.
377
+ if (key === "Enter" && !isMod) {
378
+ // Let Enter submit forms inside the task view's <input>/<textarea>; we
379
+ // shouldn't intercept when an editable child is the actual target.
380
+ if (e.target && e.target.matches && e.target.matches("input, textarea, button, a")) return;
381
+ e.preventDefault();
382
+ emit("focus.open");
383
+ return;
384
+ }
385
+
386
+ // Space — toggle selection of focused card.
387
+ if (key === " ") {
388
+ // Don't swallow Space in inputs.
389
+ if (e.target && e.target.matches && e.target.matches("input, textarea, button")) return;
390
+ e.preventDefault();
391
+ emit("focus.toggle-select");
392
+ return;
393
+ }
394
+
395
+ // 1-5 — move to column (0-indexed; backlog → done).
396
+ if (/^[1-5]$/.test(key) && !isMod && !e.shiftKey && !e.altKey) {
397
+ e.preventDefault();
398
+ emit("action.move-column", parseInt(key, 10) - 1);
399
+ return;
400
+ }
401
+
402
+ // a — archive
403
+ if (key === "a" && !isMod && !e.shiftKey && !e.altKey) {
404
+ e.preventDefault();
405
+ emit("action.archive");
406
+ return;
407
+ }
408
+
409
+ // d — delete
410
+ if (key === "d" && !isMod && !e.shiftKey && !e.altKey) {
411
+ e.preventDefault();
412
+ emit("action.delete");
413
+ return;
414
+ }
415
+
416
+ // e — edit (task view edit mode)
417
+ if (key === "e" && !isMod && !e.shiftKey && !e.altKey) {
418
+ // Spec: proxy — opens the focused task if not already; otherwise a no-op.
419
+ e.preventDefault();
420
+ emit("action.edit");
421
+ return;
422
+ }
423
+ }
424
+
425
+ // ─── Boot ────────────────────────────────────────────────────────────────
426
+ function init() {
427
+ if (window.__openkanKeyboardInit) return;
428
+ window.__openkanKeyboardInit = true;
429
+ window.addEventListener("keydown", handler, { capture: true });
430
+ document.body.classList.add("keyboard-enabled");
431
+ }
432
+
433
+ // Public API.
434
+ window.OpenKanKeyboard = {
435
+ on, off, execute,
436
+ showHelp, hideHelp, isHelpOpen,
437
+ getFocusedId, setFocusedId,
438
+ focusCardById, scrollFocusedIntoView,
439
+ };
440
+
441
+ if (document.readyState === "loading") {
442
+ document.addEventListener("DOMContentLoaded", init, { once: true });
443
+ } else {
444
+ init();
445
+ }
446
+ })();