trekoon 0.2.7 → 0.2.9

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 (45) hide show
  1. package/README.md +60 -0
  2. package/docs/commands.md +100 -0
  3. package/docs/quickstart.md +74 -1
  4. package/package.json +2 -1
  5. package/src/board/assets/app.js +589 -0
  6. package/src/board/assets/components/ClampedText.js +31 -0
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +64 -0
  10. package/src/board/assets/components/EpicsOverview.js +80 -0
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +80 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +308 -0
  18. package/src/board/assets/components/assetMap.js +80 -0
  19. package/src/board/assets/components/helpers.js +244 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +39 -0
  23. package/src/board/assets/main.js +11 -0
  24. package/src/board/assets/manifest.json +12 -0
  25. package/src/board/assets/runtime/delegation.js +309 -0
  26. package/src/board/assets/state/actions.js +454 -0
  27. package/src/board/assets/state/api.js +281 -0
  28. package/src/board/assets/state/store.js +472 -0
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +1811 -0
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/install.ts +196 -0
  34. package/src/board/open-browser.ts +131 -0
  35. package/src/board/routes.ts +308 -0
  36. package/src/board/server.ts +185 -0
  37. package/src/board/snapshot.ts +277 -0
  38. package/src/board/types.ts +43 -0
  39. package/src/commands/board.ts +158 -0
  40. package/src/commands/help.ts +21 -0
  41. package/src/commands/init.ts +29 -0
  42. package/src/domain/mutation-service.ts +40 -0
  43. package/src/domain/tracker-domain.ts +11 -3
  44. package/src/runtime/cli-shell.ts +5 -0
  45. package/src/storage/path.ts +36 -0
@@ -0,0 +1,244 @@
1
+ import { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
2
+
3
+ export { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER };
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Status labels & styles
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const STATUS_LABELS = {
10
+ todo: "Todo",
11
+ blocked: "Blocked",
12
+ in_progress: "In progress",
13
+ done: "Done",
14
+ };
15
+
16
+ const STATUS_BADGE_STYLES = {
17
+ todo: "border-white/10 bg-white/[0.05] text-[var(--board-text-muted)]",
18
+ blocked: "border-amber-500/20 bg-amber-500/10 text-amber-300",
19
+ in_progress: "border-sky-400/20 bg-sky-400/10 text-sky-300",
20
+ done: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
21
+ default: "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text-muted)]",
22
+ };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Class-name helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export function cx(...classNames) {
29
+ return classNames.filter(Boolean).join(" ");
30
+ }
31
+
32
+ export function panelClasses(extra = "") {
33
+ return cx(
34
+ "rounded-[28px] border border-[var(--board-border)] bg-[var(--board-surface)] shadow-panel",
35
+ extra,
36
+ );
37
+ }
38
+
39
+ export function secondaryPanelClasses(extra = "") {
40
+ return cx(
41
+ "rounded-[24px] border border-[var(--board-border)] bg-[var(--board-surface-2)]",
42
+ extra,
43
+ );
44
+ }
45
+
46
+ export function sectionLabelClasses() {
47
+ return "text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]";
48
+ }
49
+
50
+ export function neutralChipClasses() {
51
+ return "inline-flex items-center gap-1 rounded-full border border-[var(--board-border)] bg-white/[0.04] px-2.5 py-1 text-[11px] font-medium text-[var(--board-text-muted)]";
52
+ }
53
+
54
+ export function buttonClasses(options = {}) {
55
+ const kind = options.kind ?? "secondary";
56
+ const iconOnly = options.iconOnly ?? false;
57
+
58
+ return cx(
59
+ "inline-flex items-center justify-center gap-2 rounded-2xl border text-sm font-medium transition duration-200",
60
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--board-border-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--board-bg)]",
61
+ iconOnly ? "h-10 w-10 px-0" : "min-h-10 px-4 py-2.5",
62
+ kind === "primary"
63
+ ? "border-[var(--board-accent)] bg-[var(--board-accent)] text-white hover:bg-[var(--board-accent-strong)] hover:border-[var(--board-accent-strong)]"
64
+ : "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text)] hover:bg-white/[0.08] hover:border-[var(--board-border-strong)]",
65
+ );
66
+ }
67
+
68
+ export function fieldClasses() {
69
+ return cx(
70
+ "w-full rounded-2xl border border-[var(--board-border)] bg-[var(--board-surface-2)] px-3.5 py-3 text-sm text-[var(--board-text)] shadow-sm transition",
71
+ "placeholder:text-[var(--board-text-soft)] focus:border-[var(--board-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--board-accent-soft)]",
72
+ "disabled:cursor-not-allowed disabled:opacity-60",
73
+ );
74
+ }
75
+
76
+ function statusBadgeClasses(status) {
77
+ return cx(
78
+ "inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
79
+ STATUS_BADGE_STYLES[normalizeStatus(status)] ?? STATUS_BADGE_STYLES.default,
80
+ );
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Render helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function renderIcon(name, className = "") {
88
+ return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
89
+ }
90
+
91
+ export function readStatusLabel(rawStatus) {
92
+ if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
93
+ return "Unknown";
94
+ }
95
+
96
+ const normalized = normalizeStatus(rawStatus);
97
+ if (STATUS_LABELS[normalized]) {
98
+ return STATUS_LABELS[normalized];
99
+ }
100
+
101
+ return rawStatus.replaceAll("_", " ").replaceAll("-", " ");
102
+ }
103
+
104
+ export function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus)) {
105
+ return `<span class="${statusBadgeClasses(rawStatus)}">${escapeHtml(label)}</span>`;
106
+ }
107
+
108
+ export function renderStatusSelect(name, selectedStatus, disabled = false) {
109
+ return `
110
+ <select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
111
+ ${STATUS_ORDER.map((status) => `
112
+ <option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
113
+ `).join("")}
114
+ </select>
115
+ `;
116
+ }
117
+
118
+ export function renderEmptyState(title, description, shortcut) {
119
+ return `
120
+ <div class="rounded-[24px] border border-dashed border-[var(--board-border-strong)] bg-[var(--board-accent-soft)]/40 px-5 py-6 text-center">
121
+ <strong class="block text-base font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
122
+ <p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(description)}</p>
123
+ ${shortcut
124
+ ? `<p class="mt-3 text-xs text-[var(--board-text-soft)]">Try <span class="inline-flex items-center rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 font-medium text-[var(--board-text-muted)]">${escapeHtml(shortcut)}</span></p>`
125
+ : ""}
126
+ </div>
127
+ `;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Description rendering helpers
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export function renderDescriptionPreview(description, className = "mt-1 text-sm leading-6 text-[var(--board-text-muted)]") {
135
+ if (!description || description.trim().length === 0) return "";
136
+ return `<p class="${escapeHtml(className)}">${escapeHtml(description)}</p>`;
137
+ }
138
+
139
+ export function renderDescriptionBody(description, className = "text-sm leading-7 text-[var(--board-text-muted)]") {
140
+ if (!description || description.trim().length === 0) {
141
+ return `<p class="${escapeHtml(className)}">No description provided.</p>`;
142
+ }
143
+ return `<div class="${escapeHtml(className)}" style="white-space:pre-wrap;word-break:break-word">${escapeHtml(description)}</div>`;
144
+ }
145
+
146
+ export function shouldCollapseDescription(description) {
147
+ if (!description) return false;
148
+ const trimmed = description.trim();
149
+ return trimmed.length > 260 || trimmed.split("\n").length > 5;
150
+ }
151
+
152
+ export function renderDescriptionSection(title, description, options = {}) {
153
+ const {
154
+ open = false,
155
+ compact = false,
156
+ emptyText = "Add context so collaborators know what done looks like.",
157
+ } = options;
158
+
159
+ if (!description || description.trim().length === 0) {
160
+ return `
161
+ <section class="${secondaryPanelClasses("board-detail-card p-4")}">
162
+ <div class="board-section__header flex items-center justify-between gap-3">
163
+ <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
164
+ <span class="${neutralChipClasses()}">Empty</span>
165
+ </div>
166
+ <p class="mt-3 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(emptyText)}</p>
167
+ </section>
168
+ `;
169
+ }
170
+
171
+ if (!shouldCollapseDescription(description)) {
172
+ return `
173
+ <section class="${secondaryPanelClasses("board-detail-card p-4")}">
174
+ <div class="board-section__header flex items-center justify-between gap-3">
175
+ <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
176
+ <span class="${neutralChipClasses()}">${escapeHtml(`${description.trim().length} chars`)}</span>
177
+ </div>
178
+ <div class="mt-3 ${compact ? "board-detail-copy board-detail-copy--compact" : "board-detail-copy"}">
179
+ ${renderDescriptionBody(description)}
180
+ </div>
181
+ </section>
182
+ `;
183
+ }
184
+
185
+ return `
186
+ <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${open ? "open" : ""}>
187
+ <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
188
+ <span>${escapeHtml(title)}</span>
189
+ <span class="${neutralChipClasses()}">Long</span>
190
+ </summary>
191
+ <div class="mt-3 board-detail-copy ${compact ? "board-detail-copy--compact" : ""}">
192
+ ${renderDescriptionBody(description)}
193
+ </div>
194
+ </details>
195
+ `;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Misc shared helpers
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export function readNodeLabel(kind, title) {
203
+ if (kind === "task") return `Task: ${title}`;
204
+ if (kind === "subtask") return `Subtask: ${title}`;
205
+ return title;
206
+ }
207
+
208
+ export function renderEpicCountSummary(epic) {
209
+ const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
210
+ const counts = epic.counts || { todo: 0, blocked: 0, in_progress: 0, done: 0 };
211
+ return `
212
+ <span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
213
+ <span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
214
+ <span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
215
+ `;
216
+ }
217
+
218
+ export function renderTaskMeta(task, includeStatus = false) {
219
+ return `
220
+ ${includeStatus ? renderStatusBadge(task.status) : ""}
221
+ <span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
222
+ ${task.blockedBy.length > 0 ? `<span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>` : ""}
223
+ `;
224
+ }
225
+
226
+ export function hasLongTaskTitle(title) {
227
+ if (!title) return false;
228
+ const trimmed = title.trim();
229
+ return trimmed.length > 72 || trimmed.split("\n").length > 2;
230
+ }
231
+
232
+ export function isCompactViewport() {
233
+ return typeof window !== "undefined" && window.matchMedia?.("(max-width: 900px)")?.matches;
234
+ }
235
+
236
+ export function shouldUseTaskModal(boardState, store) {
237
+ return Boolean(boardState?.selectedTask);
238
+ }
239
+
240
+ export function lookupNode(snapshot, id) {
241
+ return snapshot.tasks.find((task) => task.id === id)
242
+ ?? snapshot.subtasks.find((subtask) => subtask.id === id)
243
+ ?? null;
244
+ }
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="theme-color" content="#0b0d12" data-board-theme-color="active" />
8
+ <meta name="theme-color" content="#0b0d12" media="(prefers-color-scheme: dark)" />
9
+ <meta name="theme-color" content="#f4f6fb" media="(prefers-color-scheme: light)" />
10
+ <meta
11
+ name="description"
12
+ content="Trekoon board — local-first workspace for browsing epics, tasks, and flow states."
13
+ />
14
+ <title>Trekoon Board</title>
15
+ <link rel="stylesheet" href="./styles/fonts.css" />
16
+ <link rel="stylesheet" href="./styles/board.css" />
17
+ </head>
18
+ <body>
19
+ <a href="#board-runtime-root" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-[var(--board-accent)] focus:px-4 focus:py-2 focus:text-white">
20
+ Skip to content
21
+ </a>
22
+
23
+ <main id="app">
24
+ <div class="board-shell-v2">
25
+ <section class="board-shell-v2__frame">
26
+ <div class="board-shell-v2__runtime-shell">
27
+ <div
28
+ id="board-runtime-root"
29
+ class="board-shell-v2__runtime"
30
+ data-board-runtime-root
31
+ ></div>
32
+ </div>
33
+ </section>
34
+ </div>
35
+ </main>
36
+
37
+ <script type="module" src="./main.js"></script>
38
+ </body>
39
+ </html>
@@ -0,0 +1,11 @@
1
+ window.__TREKOON_BOARD_BOOTSTRAP__ = "main";
2
+
3
+ const runtimeRoot = document.querySelector("[data-board-runtime-root]");
4
+
5
+ if (!(runtimeRoot instanceof HTMLElement)) {
6
+ throw new Error("Board shell could not find the runtime mount root.");
7
+ }
8
+
9
+ const { bootLegacyBoard } = await import("./app.js");
10
+
11
+ await bootLegacyBoard({ mountElement: runtimeRoot });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "trekoon-board-assets",
3
+ "contractVersion": "1.0.0",
4
+ "entryFile": "index.html",
5
+ "files": [
6
+ "app.js",
7
+ "components/AppShell.js",
8
+ "index.html",
9
+ "main.js",
10
+ "styles/board.css"
11
+ ]
12
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Event delegation system for the board runtime.
3
+ *
4
+ * Attaches a single listener per event type on the root element.
5
+ * Uses event.target.closest() to match data-attributes, so dynamically
6
+ * rendered content is handled automatically without rebinding.
7
+ *
8
+ * @param {HTMLElement} rootElement Mount root for delegated listeners.
9
+ * @param {object} actions Callback map the delegation dispatches into.
10
+ * @returns {() => void} Teardown function that removes every listener.
11
+ */
12
+ export function createDelegation(rootElement, actions) {
13
+ // ---------------------------------------------------------------------------
14
+ // Click delegation
15
+ // ---------------------------------------------------------------------------
16
+ function handleClick(event) {
17
+ const { target } = event;
18
+
19
+ // -- Destructive / mutation buttons (most specific first) -----------------
20
+
21
+ const deleteSubtaskEl = target.closest("[data-delete-subtask]");
22
+ if (deleteSubtaskEl) {
23
+ if (actions.isMutating()) return;
24
+ const subtaskId = deleteSubtaskEl.dataset.deleteSubtask;
25
+ if (subtaskId) actions.deleteSubtask(subtaskId, deleteSubtaskEl);
26
+ return;
27
+ }
28
+
29
+ const removeDependencyEl = target.closest("[data-remove-dependency-source]");
30
+ if (removeDependencyEl) {
31
+ if (actions.isMutating()) return;
32
+ const sourceId = removeDependencyEl.dataset.removeDependencySource;
33
+ const dependsOnId = removeDependencyEl.dataset.removeDependencyTarget;
34
+ actions.removeDependency(sourceId, dependsOnId, removeDependencyEl);
35
+ return;
36
+ }
37
+
38
+ // -- Disclosure openers ---------------------------------------------------
39
+
40
+ const openSubtaskEl = target.closest("[data-open-subtask]");
41
+ if (openSubtaskEl) {
42
+ actions.openSubtask(openSubtaskEl.dataset.openSubtask || null, openSubtaskEl);
43
+ return;
44
+ }
45
+
46
+ // -- Backdrop-style close handlers ----------------------------------------
47
+ // Only close when the click lands directly on the backdrop element itself,
48
+ // not on any child content rendered inside the overlay.
49
+
50
+ const closeSubtaskEl = target.closest("[data-close-subtask]");
51
+ if (closeSubtaskEl) {
52
+ if (
53
+ closeSubtaskEl.classList.contains("board-modal-backdrop") &&
54
+ target !== closeSubtaskEl
55
+ ) {
56
+ return;
57
+ }
58
+ actions.closeSubtask();
59
+ return;
60
+ }
61
+
62
+ const closeTaskEl = target.closest("[data-close-task]");
63
+ if (closeTaskEl) {
64
+ if (
65
+ closeTaskEl.classList.contains("board-task-modal-backdrop") &&
66
+ target !== closeTaskEl
67
+ ) {
68
+ return;
69
+ }
70
+ actions.closeTask();
71
+ return;
72
+ }
73
+
74
+ const closeConfirmEl = target.closest("[data-close-confirm]");
75
+ if (closeConfirmEl) {
76
+ if (
77
+ closeConfirmEl.classList.contains("board-confirm-backdrop") &&
78
+ target !== closeConfirmEl
79
+ ) {
80
+ return;
81
+ }
82
+ actions.cancelDelete();
83
+ return;
84
+ }
85
+
86
+ // -- Navigation -----------------------------------------------------------
87
+
88
+ const navEl = target.closest("[data-nav]");
89
+ if (navEl) {
90
+ if (navEl.dataset.nav === "epics") actions.showEpics();
91
+ return;
92
+ }
93
+
94
+ const navBoardEl = target.closest("[data-nav-board]");
95
+ if (navBoardEl) {
96
+ actions.showBoard();
97
+ return;
98
+ }
99
+
100
+ const navDetailEl = target.closest("[data-nav-detail]");
101
+ if (navDetailEl) {
102
+ actions.scrollToDetail();
103
+ return;
104
+ }
105
+
106
+ // -- Notes panel toggle ---------------------------------------------------
107
+
108
+ const toggleNotesEl = target.closest("[data-toggle-notes]");
109
+ if (toggleNotesEl) {
110
+ actions.toggleNotesPanel();
111
+ return;
112
+ }
113
+
114
+ // -- View switching -------------------------------------------------------
115
+
116
+ const viewEl = target.closest("[data-view]");
117
+ if (viewEl) {
118
+ actions.setView(viewEl.dataset.view);
119
+ return;
120
+ }
121
+
122
+ // -- Generic actions (toggle-theme, confirm-delete, cancel-delete) --------
123
+
124
+ const actionEl = target.closest("[data-action]");
125
+ if (actionEl) {
126
+ const action = actionEl.dataset.action;
127
+ if (action === "toggle-theme") actions.toggleTheme();
128
+ if (action === "confirm-delete") actions.confirmDelete();
129
+ if (action === "cancel-delete") actions.cancelDelete();
130
+ return;
131
+ }
132
+
133
+ // -- Epic selection -------------------------------------------------------
134
+
135
+ const openEpicEl = target.closest("[data-open-epic]");
136
+ if (openEpicEl) {
137
+ actions.openEpic(openEpicEl.dataset.openEpic || null);
138
+ return;
139
+ }
140
+
141
+ // -- Task selection (broadest — checked last) -----------------------------
142
+
143
+ const taskEl = target.closest("[data-task-id]");
144
+ if (taskEl) {
145
+ actions.selectTask(taskEl.dataset.taskId, taskEl);
146
+ return;
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Input delegation (#board-search-input)
152
+ // ---------------------------------------------------------------------------
153
+ function handleInput(event) {
154
+ if (event.target.id === "board-search-input") {
155
+ actions.updateSearch(event.target.value);
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Change delegation (#board-epic-select)
161
+ // ---------------------------------------------------------------------------
162
+ function handleChange(event) {
163
+ if (event.target.id === "board-epic-select") {
164
+ actions.selectEpic(event.target.value || null);
165
+ return;
166
+ }
167
+
168
+ const epicStatusForm = event.target.closest("[data-epic-status-form]");
169
+ if (epicStatusForm) {
170
+ if (actions.isMutating()) return;
171
+ actions.changeEpicStatus(epicStatusForm.dataset.epicStatusForm, event.target.value);
172
+ return;
173
+ }
174
+
175
+ const bulkStatusForm = event.target.closest("[data-bulk-status-form]");
176
+ if (bulkStatusForm) {
177
+ if (actions.isMutating()) return;
178
+ const newStatus = event.target.value;
179
+ if (newStatus) {
180
+ actions.bulkSetStatus(bulkStatusForm.dataset.bulkStatusForm, newStatus);
181
+ event.target.value = "";
182
+ }
183
+ return;
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Submit delegation (data-task-form, data-subtask-form, etc.)
189
+ // ---------------------------------------------------------------------------
190
+ function handleSubmit(event) {
191
+ const form = event.target;
192
+
193
+ const taskForm = form.closest("[data-task-form]");
194
+ if (taskForm) {
195
+ event.preventDefault();
196
+ if (actions.isMutating()) return;
197
+ actions.submitTaskForm(taskForm.dataset.taskForm, new FormData(taskForm));
198
+ return;
199
+ }
200
+
201
+ const subtaskForm = form.closest("[data-subtask-form]");
202
+ if (subtaskForm) {
203
+ event.preventDefault();
204
+ if (actions.isMutating()) return;
205
+ actions.submitSubtaskForm(
206
+ subtaskForm.dataset.subtaskForm,
207
+ new FormData(subtaskForm),
208
+ );
209
+ return;
210
+ }
211
+
212
+ const createSubtaskForm = form.closest("[data-create-subtask-form]");
213
+ if (createSubtaskForm) {
214
+ event.preventDefault();
215
+ if (actions.isMutating()) return;
216
+ actions.submitCreateSubtask(
217
+ createSubtaskForm.dataset.createSubtaskForm,
218
+ new FormData(createSubtaskForm),
219
+ );
220
+ return;
221
+ }
222
+
223
+ const dependencyForm = form.closest("[data-dependency-form]");
224
+ if (dependencyForm) {
225
+ event.preventDefault();
226
+ if (actions.isMutating()) return;
227
+ actions.addDependency(
228
+ dependencyForm.dataset.dependencyForm,
229
+ new FormData(dependencyForm),
230
+ );
231
+ return;
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Keyboard shortcuts (window-level)
237
+ // ---------------------------------------------------------------------------
238
+ function handleKeydown(event) {
239
+ if (event.defaultPrevented) {
240
+ return;
241
+ }
242
+ actions.handleKeydown(event);
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Drag-and-drop delegation
247
+ // ---------------------------------------------------------------------------
248
+ function handleDragstart(event) {
249
+ const draggable = event.target.closest("[data-draggable-task]");
250
+ if (!draggable) return;
251
+
252
+ if (actions.isMutating()) {
253
+ event.preventDefault();
254
+ return;
255
+ }
256
+
257
+ const taskId = draggable.dataset.taskId;
258
+ if (!taskId) return;
259
+
260
+ event.dataTransfer?.setData("text/task-id", taskId);
261
+ event.dataTransfer?.setData("text/plain", taskId);
262
+ }
263
+
264
+ function handleDragover(event) {
265
+ if (event.target.closest("[data-drop-status]")) {
266
+ event.preventDefault();
267
+ }
268
+ }
269
+
270
+ function handleDrop(event) {
271
+ const column = event.target.closest("[data-drop-status]");
272
+ if (!column) return;
273
+
274
+ event.preventDefault();
275
+ if (actions.isMutating()) return;
276
+
277
+ const taskId =
278
+ event.dataTransfer?.getData("text/task-id") ||
279
+ event.dataTransfer?.getData("text/plain");
280
+ const nextStatus = column.dataset.dropStatus;
281
+ actions.dropTaskStatus(taskId, nextStatus);
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Attach
286
+ // ---------------------------------------------------------------------------
287
+ rootElement.addEventListener("click", handleClick);
288
+ rootElement.addEventListener("input", handleInput);
289
+ rootElement.addEventListener("change", handleChange);
290
+ rootElement.addEventListener("submit", handleSubmit);
291
+ rootElement.addEventListener("dragstart", handleDragstart);
292
+ rootElement.addEventListener("dragover", handleDragover);
293
+ rootElement.addEventListener("drop", handleDrop);
294
+ window.addEventListener("keydown", handleKeydown);
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Teardown
298
+ // ---------------------------------------------------------------------------
299
+ return function teardown() {
300
+ rootElement.removeEventListener("click", handleClick);
301
+ rootElement.removeEventListener("input", handleInput);
302
+ rootElement.removeEventListener("change", handleChange);
303
+ rootElement.removeEventListener("submit", handleSubmit);
304
+ rootElement.removeEventListener("dragstart", handleDragstart);
305
+ rootElement.removeEventListener("dragover", handleDragover);
306
+ rootElement.removeEventListener("drop", handleDrop);
307
+ window.removeEventListener("keydown", handleKeydown);
308
+ };
309
+ }