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