trekoon 0.2.8 → 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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/board/assets/app.js +468 -1377
  3. package/src/board/assets/components/ClampedText.js +1 -1
  4. package/src/board/assets/components/Component.js +271 -0
  5. package/src/board/assets/components/ConfirmDialog.js +81 -0
  6. package/src/board/assets/components/EpicRow.js +23 -21
  7. package/src/board/assets/components/EpicsOverview.js +48 -11
  8. package/src/board/assets/components/Inspector.js +335 -0
  9. package/src/board/assets/components/Notice.js +80 -0
  10. package/src/board/assets/components/SubtaskModal.js +100 -0
  11. package/src/board/assets/components/TaskCard.js +82 -0
  12. package/src/board/assets/components/TaskModal.js +99 -0
  13. package/src/board/assets/components/TopBar.js +167 -0
  14. package/src/board/assets/components/Workspace.js +308 -0
  15. package/src/board/assets/components/assetMap.js +29 -14
  16. package/src/board/assets/components/helpers.js +244 -0
  17. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  18. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  19. package/src/board/assets/index.html +20 -57
  20. package/src/board/assets/main.js +2 -18
  21. package/src/board/assets/runtime/delegation.js +309 -0
  22. package/src/board/assets/state/actions.js +136 -16
  23. package/src/board/assets/state/api.js +201 -46
  24. package/src/board/assets/state/store.js +417 -117
  25. package/src/board/assets/state/url.js +184 -0
  26. package/src/board/assets/state/utils.js +222 -0
  27. package/src/board/assets/styles/board.css +811 -127
  28. package/src/board/assets/styles/fonts.css +22 -0
  29. package/src/board/routes.ts +15 -6
  30. package/src/board/server.ts +1 -0
  31. package/src/board/assets/components/AppShell.js +0 -17
  32. package/src/board/assets/components/BoardTopbar.js +0 -78
  33. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  34. package/src/board/assets/utils/dom.js +0 -308
@@ -1,229 +1,41 @@
1
1
  import { createBoardActions } from "./state/actions.js";
2
2
  import { createApi } from "./state/api.js";
3
- import { renderBoardTopbar } from "./components/BoardTopbar.js";
4
- import { renderClampedText } from "./components/ClampedText.js";
5
- import { renderEpicRow as renderEpicOverviewRow } from "./components/EpicRow.js";
6
- import { renderEpicsOverview } from "./components/EpicsOverview.js";
7
- import { renderWorkspaceHeader } from "./components/WorkspaceHeader.js";
8
- import { applyTheme, createStore, readThemePreference, VIEW_MODES, STATUS_ORDER } from "./state/store.js";
9
- import {
10
- createScrollAuthorityStack,
11
- resolveFocusSelector,
12
- resolveScrollAuthorityStack,
13
- restoreRuntimeState,
14
- SCROLL_AUTHORITY,
15
- syncOverlayScrollLock,
16
- syncScrollAuthority,
17
- } from "./utils/dom.js";
3
+ import { applyTheme, createStore, readThemePreference } from "./state/store.js";
4
+ import { normalizeSnapshot, normalizeStatus } from "./state/utils.js";
5
+ import { syncUrlHash } from "./state/url.js";
6
+ import { createDelegation } from "./runtime/delegation.js";
7
+ import { createTopBar } from "./components/TopBar.js";
8
+ import { createWorkspace } from "./components/Workspace.js";
9
+ import { createTaskModal } from "./components/TaskModal.js";
10
+ import { createSubtaskModal } from "./components/SubtaskModal.js";
11
+ import { createNotice } from "./components/Notice.js";
12
+ import { createConfirmDialog } from "./components/ConfirmDialog.js";
13
+ import { createEpicsOverview } from "./components/EpicsOverview.js";
14
+ import { panelClasses, renderIcon, sectionLabelClasses, escapeHtml } from "./components/helpers.js";
18
15
 
19
16
  const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
20
17
  const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
21
- const STATUS_LABELS = {
22
- todo: "Todo",
23
- blocked: "Blocked",
24
- in_progress: "In progress",
25
- done: "Done",
26
- };
27
-
28
- const STATUS_BADGE_STYLES = {
29
- todo: "border-white/10 bg-white/[0.05] text-[var(--board-text-muted)]",
30
- blocked: "border-amber-500/20 bg-amber-500/10 text-amber-300",
31
- in_progress: "border-sky-400/20 bg-sky-400/10 text-sky-300",
32
- done: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
33
- default: "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text-muted)]",
34
- };
35
-
36
- const ACTIVE_EPIC_STATUSES = new Set(["in_progress", "todo"]);
37
- const SIDEBAR_EPIC_STATUS_PRIORITY = {
38
- in_progress: 0,
39
- todo: 1,
40
- blocked: 2,
41
- done: 3,
42
- };
43
-
44
- function cx(...classNames) {
45
- return classNames.filter(Boolean).join(" ");
46
- }
47
-
48
- function renderIcon(name, className = "") {
49
- return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
50
- }
51
-
52
- function panelClasses(extra = "") {
53
- return cx(
54
- "rounded-[28px] border border-[var(--board-border)] bg-[var(--board-surface)] shadow-panel",
55
- extra,
56
- );
57
- }
58
-
59
- function secondaryPanelClasses(extra = "") {
60
- return cx(
61
- "rounded-[24px] border border-[var(--board-border)] bg-[var(--board-surface-2)]",
62
- extra,
63
- );
64
- }
65
-
66
- function isCompactViewport() {
67
- return typeof window !== "undefined" && window.matchMedia?.("(max-width: 900px)")?.matches;
68
- }
69
-
70
- function shouldUseTaskModal(boardState, store) {
71
- return Boolean(boardState?.selectedTask && (store?.view === "kanban" || isCompactViewport()));
72
- }
73
-
74
- function buttonClasses(options = {}) {
75
- const kind = options.kind ?? "secondary";
76
- const iconOnly = options.iconOnly ?? false;
77
-
78
- return cx(
79
- "inline-flex items-center justify-center gap-2 rounded-2xl border text-sm font-medium transition duration-200",
80
- "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)]",
81
- iconOnly ? "h-10 w-10 px-0" : "min-h-10 px-4 py-2.5",
82
- kind === "primary"
83
- ? "border-[var(--board-accent)] bg-[var(--board-accent)] text-white hover:bg-[var(--board-accent-strong)] hover:border-[var(--board-accent-strong)]"
84
- : "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text)] hover:bg-white/[0.08] hover:border-[var(--board-border-strong)]",
85
- );
86
- }
87
-
88
- function fieldClasses() {
89
- return cx(
90
- "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",
91
- "placeholder:text-[var(--board-text-soft)] focus:border-[var(--board-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--board-accent-soft)]",
92
- "disabled:cursor-not-allowed disabled:opacity-60",
93
- );
94
- }
95
-
96
- function sectionLabelClasses() {
97
- return "text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]";
98
- }
99
-
100
- function neutralChipClasses() {
101
- 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)]";
102
- }
103
-
104
- function statusBadgeClasses(status) {
105
- return cx(
106
- "inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
107
- STATUS_BADGE_STYLES[normalizeStatus(status)] ?? STATUS_BADGE_STYLES.default,
108
- );
109
- }
110
-
111
- function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus)) {
112
- return `<span class="${statusBadgeClasses(rawStatus)}">${escapeHtml(label)}</span>`;
113
- }
114
-
115
- let appElement = null;
116
- const scrollAuthorityStack = createScrollAuthorityStack();
117
- let searchReturnFocusState = null;
118
-
119
- function resolveTaskDetailOwner(boardState, store) {
120
- if (!boardState?.selectedTask) {
121
- return null;
122
- }
123
-
124
- return shouldUseTaskModal(boardState, store) ? SCROLL_AUTHORITY.taskModal : SCROLL_AUTHORITY.inspector;
125
- }
126
-
127
- function rememberReturnFocus(owner, element) {
128
- if (!owner || !(element instanceof HTMLElement)) {
129
- return;
130
- }
131
-
132
- scrollAuthorityStack.rememberReturnFocus(owner, resolveFocusSelector(element));
133
- }
134
-
135
- function closeTopmostDisclosure(boardState, activeElement = document.activeElement) {
136
- const disclosureRoot = boardState?.selectedSubtaskId
137
- ? document.querySelector(".board-modal")
138
- : boardState?.selectedTaskId
139
- ? document.querySelector(".board-drawer, .board-task-modal")
140
- : document;
141
-
142
- if (!disclosureRoot || typeof disclosureRoot.querySelectorAll !== "function") {
143
- return false;
144
- }
145
-
146
- const openDisclosures = Array.from(disclosureRoot.querySelectorAll("details[open]"))
147
- .filter((disclosure) => disclosure instanceof HTMLDetailsElement);
148
- if (openDisclosures.length === 0) {
149
- return false;
150
- }
151
-
152
- let candidate = null;
153
- if (activeElement instanceof HTMLElement) {
154
- candidate = activeElement.closest("details[open]");
155
- if (candidate && !openDisclosures.includes(candidate)) {
156
- candidate = null;
157
- }
158
- }
159
-
160
- if (!(candidate instanceof HTMLDetailsElement)) {
161
- candidate = openDisclosures
162
- .map((disclosure, index) => {
163
- let depth = 0;
164
- let parentDisclosure = disclosure.parentElement?.closest("details[open]") ?? null;
165
- while (parentDisclosure instanceof HTMLDetailsElement) {
166
- depth += 1;
167
- parentDisclosure = parentDisclosure.parentElement?.closest("details[open]") ?? null;
168
- }
169
-
170
- return { disclosure, index, depth };
171
- })
172
- .sort((left, right) => {
173
- if (left.depth !== right.depth) {
174
- return right.depth - left.depth;
175
- }
176
- return right.index - left.index;
177
- })[0]?.disclosure ?? null;
178
- }
179
-
180
- if (!(candidate instanceof HTMLDetailsElement)) {
181
- return false;
182
- }
183
-
184
- candidate.open = false;
185
- candidate.querySelector(":scope > summary")?.focus({ preventScroll: true });
186
- return true;
187
- }
188
-
189
- function focusSearch(activeElement = document.activeElement) {
190
- if (activeElement instanceof HTMLElement && activeElement.id !== "board-search-input") {
191
- searchReturnFocusState = resolveFocusSelector(activeElement);
192
- }
193
-
194
- document.querySelector("#board-search-input")?.focus({ preventScroll: true });
195
- }
196
-
197
- function dismissSearch(boardState, activeElement = document.activeElement) {
198
- if (activeElement?.id !== "board-search-input") {
199
- return false;
200
- }
201
-
202
- restoreRuntimeState(appElement, {
203
- owner: boardState?.screen === "tasks" ? SCROLL_AUTHORITY.workspace : SCROLL_AUTHORITY.page,
204
- scrollState: [],
205
- fallbackTaskId: boardState?.selectedTaskId ?? null,
206
- fallbackEpicId: boardState?.selectedEpicId ?? null,
207
- returnFocusState: searchReturnFocusState,
208
- fallbackFocusSelectors: [
209
- "[data-nav-board]:not([disabled])",
210
- "[data-nav='epics']",
211
- "#board-epic-select",
212
- "[data-open-epic]",
213
- ],
214
- });
215
- searchReturnFocusState = null;
216
- return true;
217
- }
218
-
219
- function focusTaskDetail() {
220
- document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
221
- }
18
+ const FOCUSABLE_SELECTOR = [
19
+ "a[href]",
20
+ "area[href]",
21
+ "button:not([disabled])",
22
+ "input:not([disabled]):not([type='hidden'])",
23
+ "select:not([disabled])",
24
+ "textarea:not([disabled])",
25
+ "iframe",
26
+ "object",
27
+ "embed",
28
+ "[contenteditable]",
29
+ "[tabindex]:not([tabindex='-1'])",
30
+ ].join(", ");
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Session token management
34
+ // ---------------------------------------------------------------------------
222
35
 
223
36
  function readSessionTokenFromStorage() {
224
37
  try {
225
- const storedToken = sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "";
226
- return storedToken.trim();
38
+ return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
227
39
  } catch {
228
40
  return "";
229
41
  }
@@ -242,44 +54,42 @@ function resolveRuntimeSession() {
242
54
  const url = new URL(window.location.href);
243
55
  const queryToken = (url.searchParams.get("token") || "").trim();
244
56
  if (queryToken.length > 0) {
245
- return {
246
- token: queryToken,
247
- shouldScrubAddressBar: persistSessionToken(queryToken),
248
- };
57
+ return { token: queryToken, shouldScrubAddressBar: persistSessionToken(queryToken) };
249
58
  }
250
-
251
- return {
252
- token: readSessionTokenFromStorage(),
253
- shouldScrubAddressBar: false,
254
- };
59
+ return { token: readSessionTokenFromStorage(), shouldScrubAddressBar: false };
255
60
  }
256
61
 
257
62
  function scrubTokenFromAddressBar() {
258
63
  const url = new URL(window.location.href);
259
- if (!url.searchParams.has("token") || typeof window.history?.replaceState !== "function") {
260
- return;
261
- }
262
-
64
+ if (!url.searchParams.has("token") || typeof window.history?.replaceState !== "function") return;
263
65
  url.searchParams.delete("token");
264
- const nextUrl = `${url.pathname}${url.search}${url.hash}`;
265
- window.history.replaceState(window.history.state, document.title, nextUrl || "/");
66
+ window.history.replaceState(window.history.state, document.title, `${url.pathname}${url.search}${url.hash}` || "/");
266
67
  }
267
68
 
268
- function normalizeStatus(rawStatus) {
269
- if (rawStatus === "in-progress") return "in_progress";
270
- if (rawStatus === "todo" || rawStatus === "blocked" || rawStatus === "in_progress" || rawStatus === "done") {
271
- return rawStatus;
272
- }
69
+ function prefersReducedMotion() {
70
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
71
+ }
72
+
73
+ function getScrollBehavior() {
74
+ return prefersReducedMotion() ? "auto" : "smooth";
75
+ }
273
76
 
274
- return "todo";
77
+ function isFocusableElement(element) {
78
+ if (!(element instanceof HTMLElement)) return false;
79
+ if (element.hidden) return false;
80
+ if (element.getAttribute("aria-hidden") === "true") return false;
81
+ if (element.closest("[hidden], [inert]")) return false;
82
+ return element.getClientRects().length > 0;
83
+ }
84
+
85
+ function getFocusableElements(container) {
86
+ if (!(container instanceof HTMLElement)) return [];
87
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isFocusableElement);
275
88
  }
276
89
 
277
90
  function readJsonScript(scriptId) {
278
91
  const script = document.getElementById(scriptId);
279
- if (!script) {
280
- return null;
281
- }
282
-
92
+ if (!script) return null;
283
93
  try {
284
94
  return JSON.parse(script.textContent || "null");
285
95
  } catch (error) {
@@ -287,1209 +97,490 @@ function readJsonScript(scriptId) {
287
97
  }
288
98
  }
289
99
 
290
- function normalizeArray(value) {
291
- return Array.isArray(value) ? value : [];
292
- }
293
-
294
- function getId(record) {
295
- return typeof record?.id === "string" && record.id.length > 0 ? record.id : crypto.randomUUID();
296
- }
100
+ // ---------------------------------------------------------------------------
101
+ // Board shell layout
102
+ // ---------------------------------------------------------------------------
297
103
 
298
- function deriveCounts(tasks) {
299
- return STATUS_ORDER.reduce((counts, status) => {
300
- counts[status] = tasks.filter((task) => task.status === status).length;
301
- return counts;
302
- }, {});
303
- }
304
-
305
- function formatDate(timestamp) {
306
- if (!timestamp) return "Unknown";
307
- return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(timestamp);
308
- }
309
-
310
- function readStatusLabel(rawStatus) {
311
- if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
312
- return "Unknown";
313
- }
314
-
315
- if (rawStatus === "todo" || rawStatus === "blocked" || rawStatus === "in_progress" || rawStatus === "done" || rawStatus === "in-progress") {
316
- return STATUS_LABELS[normalizeStatus(rawStatus)] ?? rawStatus;
317
- }
318
-
319
- return rawStatus.replaceAll("_", " ").replaceAll("-", " ");
320
- }
321
-
322
- function escapeHtml(value) {
323
- return String(value)
324
- .replaceAll("&", "&amp;")
325
- .replaceAll("<", "&lt;")
326
- .replaceAll(">", "&gt;")
327
- .replaceAll('"', "&quot;");
328
- }
329
-
330
- function renderEmptyState(title, description, shortcut) {
331
- return `
332
- <div class="rounded-[24px] border border-dashed border-[var(--board-border-strong)] bg-[var(--board-accent-soft)]/40 px-5 py-6 text-center">
333
- <strong class="block text-base font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
334
- <p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(description)}</p>
335
- ${shortcut
336
- ? `<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>`
337
- : ""}
104
+ function createBoardShell(appElement) {
105
+ appElement.innerHTML = `
106
+ <div class="board-layout mx-auto flex w-full max-w-[1600px] flex-col gap-4 px-4 py-4 sm:px-6 sm:py-6 xl:px-8" id="board-shell">
107
+ <div data-slot="notice"></div>
108
+ <div data-slot="topbar"></div>
109
+ <div data-slot="epics-overview"></div>
110
+ <div data-slot="tasks-root" class="board-root board-root--tasks min-h-0 w-full" style="display:none">
111
+ <div data-slot="workspace"></div>
112
+ </div>
113
+ <div data-slot="task-modal"></div>
114
+ <div data-slot="subtask-modal"></div>
115
+ <div data-slot="confirm-dialog"></div>
338
116
  </div>
339
117
  `;
340
- }
341
-
342
- function readNodeLabel(kind, title) {
343
- if (kind === "task") {
344
- return `Task: ${title}`;
345
- }
346
-
347
- if (kind === "subtask") {
348
- return `Subtask: ${title}`;
349
- }
350
-
351
- return title;
352
- }
353
-
354
- function normalizeSnapshot(rawSnapshot) {
355
- const rawEpics = normalizeArray(rawSnapshot?.epics);
356
- const rawTasks = normalizeArray(rawSnapshot?.tasks);
357
- const rawSubtasks = normalizeArray(rawSnapshot?.subtasks);
358
- const rawDependencies = normalizeArray(rawSnapshot?.dependencies);
359
- const taskIndex = new Map();
360
- const subtaskIndex = new Map();
361
-
362
- const tasks = rawTasks.map((task) => {
363
- const normalizedTask = {
364
- id: getId(task),
365
- kind: "task",
366
- epicId: task.epicId ?? task.epic?.id ?? null,
367
- title: String(task.title ?? "Untitled task"),
368
- description: String(task.description ?? ""),
369
- status: normalizeStatus(task.status),
370
- createdAt: Number(task.createdAt ?? Date.now()),
371
- updatedAt: Number(task.updatedAt ?? task.createdAt ?? Date.now()),
372
- blockedBy: [],
373
- blocks: [],
374
- dependencyIds: [],
375
- dependentIds: [],
376
- subtasks: [],
377
- searchText: "",
378
- };
379
-
380
- taskIndex.set(normalizedTask.id, normalizedTask);
381
- return normalizedTask;
382
- });
383
-
384
- const subtasks = rawSubtasks.map((subtask) => {
385
- const normalizedSubtask = {
386
- id: getId(subtask),
387
- kind: "subtask",
388
- taskId: subtask.taskId ?? subtask.task?.id ?? null,
389
- title: String(subtask.title ?? "Untitled subtask"),
390
- description: String(subtask.description ?? ""),
391
- status: normalizeStatus(subtask.status),
392
- createdAt: Number(subtask.createdAt ?? Date.now()),
393
- updatedAt: Number(subtask.updatedAt ?? subtask.createdAt ?? Date.now()),
394
- blockedBy: [],
395
- blocks: [],
396
- dependencyIds: [],
397
- dependentIds: [],
398
- searchText: "",
399
- };
400
-
401
- subtaskIndex.set(normalizedSubtask.id, normalizedSubtask);
402
- return normalizedSubtask;
403
- });
404
-
405
- for (const subtask of subtasks) {
406
- const parentTask = taskIndex.get(subtask.taskId);
407
- if (parentTask) {
408
- parentTask.subtasks.push(subtask);
409
- }
410
- }
411
-
412
- const dependencies = rawDependencies.map((dependency) => ({
413
- id: getId(dependency),
414
- sourceId: String(dependency.sourceId ?? ""),
415
- sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
416
- dependsOnId: String(dependency.dependsOnId ?? ""),
417
- dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
418
- }));
419
-
420
- const lookupNode = (kind, id) => {
421
- if (kind === "subtask") {
422
- return subtaskIndex.get(id) ?? null;
423
- }
424
-
425
- return taskIndex.get(id) ?? null;
426
- };
427
-
428
- for (const dependency of dependencies) {
429
- const source = lookupNode(dependency.sourceKind, dependency.sourceId);
430
- const target = lookupNode(dependency.dependsOnKind, dependency.dependsOnId);
431
- if (source) {
432
- source.blockedBy.push(dependency.dependsOnId);
433
- source.dependencyIds.push(dependency.id);
434
- }
435
- if (target) {
436
- target.blocks.push(dependency.sourceId);
437
- target.dependentIds.push(dependency.id);
438
- }
439
- }
440
-
441
- const epics = rawEpics.map((epic) => {
442
- const epicId = getId(epic);
443
- const epicTasks = tasks.filter((task) => task.epicId === epicId);
444
- const normalizedEpic = {
445
- id: epicId,
446
- title: String(epic.title ?? "Untitled epic"),
447
- description: String(epic.description ?? ""),
448
- status: String(epic.status ?? "todo"),
449
- createdAt: Number(epic.createdAt ?? Date.now()),
450
- updatedAt: Number(epic.updatedAt ?? epic.createdAt ?? Date.now()),
451
- taskIds: epicTasks.map((task) => task.id),
452
- counts: deriveCounts(epicTasks),
453
- searchText: "",
454
- };
455
-
456
- normalizedEpic.searchText = [normalizedEpic.title, normalizedEpic.description, ...epicTasks.map((task) => task.title)].join(" ").toLowerCase();
457
- return normalizedEpic;
458
- });
459
-
460
- for (const subtask of subtasks) {
461
- subtask.searchText = [subtask.title, subtask.description, subtask.status].join(" ").toLowerCase();
462
- }
463
-
464
- for (const task of tasks) {
465
- task.searchText = [
466
- task.title,
467
- task.description,
468
- task.status,
469
- ...task.subtasks.map((subtask) => `${subtask.title} ${subtask.description} ${subtask.status}`),
470
- ].join(" ").toLowerCase();
471
- }
472
118
 
119
+ const slot = (name) => appElement.querySelector(`[data-slot="${name}"]`);
473
120
  return {
474
- generatedAt: rawSnapshot?.generatedAt ?? null,
475
- epics,
476
- tasks,
477
- subtasks,
478
- dependencies,
121
+ notice: slot("notice"),
122
+ topbar: slot("topbar"),
123
+ epicsOverview: slot("epics-overview"),
124
+ tasksRoot: slot("tasks-root"),
125
+ workspace: slot("workspace"),
126
+ taskModal: slot("task-modal"),
127
+ subtaskModal: slot("subtask-modal"),
128
+ confirmDialog: slot("confirm-dialog"),
129
+ shell: appElement.querySelector("#board-shell"),
479
130
  };
480
131
  }
481
132
 
482
- function renderStatusSelect(name, selectedStatus, disabled = false) {
483
- return `
484
- <select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
485
- ${STATUS_ORDER.map((status) => `
486
- <option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
487
- `).join("")}
488
- </select>
489
- `;
490
- }
491
-
492
- function renderNotice(notice) {
493
- if (!notice) {
494
- return "";
495
- }
496
-
497
- return `
498
- <section class="${panelClasses("mb-4 flex items-start gap-3 p-4 sm:p-5")}" aria-live="polite">
499
- <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${notice.type === "error" ? "bg-red-500/10 text-red-300 ring-1 ring-red-500/20" : "bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/20"}">
500
- ${renderIcon(notice.type === "error" ? "warning" : "check_circle", "text-[20px]")}
501
- </div>
502
- <div class="min-w-0">
503
- <p class="${sectionLabelClasses()}">${notice.type === "error" ? "Action blocked" : "Saved"}</p>
504
- <p class="mt-1 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(notice.message)}</p>
505
- </div>
506
- </section>
507
- `;
508
- }
509
-
510
- function renderDescriptionPreview(description, className = "mt-1 text-sm leading-6 text-[var(--board-text-muted)]") {
511
- if (!description || description.trim().length === 0) {
512
- return "";
513
- }
514
-
515
- return `<p class="${escapeHtml(className)}">${escapeHtml(description)}</p>`;
516
- }
517
-
518
- function renderDescriptionBody(description, className = "text-sm leading-7 text-[var(--board-text-muted)]") {
519
- if (!description || description.trim().length === 0) {
520
- return `<p class="${escapeHtml(className)}">No description provided.</p>`;
521
- }
522
-
523
- return `<div class="${escapeHtml(className)}">${escapeHtml(description).replaceAll("\n", "<br />")}</div>`;
524
- }
525
-
526
- function shouldCollapseDescription(description) {
527
- if (!description) {
528
- return false;
529
- }
530
-
531
- const trimmed = description.trim();
532
- return trimmed.length > 260 || trimmed.split("\n").length > 5;
533
- }
534
-
535
- function renderDescriptionSection(title, description, options = {}) {
536
- const {
537
- open = false,
538
- compact = false,
539
- emptyText = "Add context so collaborators know what done looks like.",
540
- } = options;
541
-
542
- if (!description || description.trim().length === 0) {
543
- return `
544
- <section class="${secondaryPanelClasses("board-detail-card p-4")}">
545
- <div class="board-section__header flex items-center justify-between gap-3">
546
- <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
547
- <span class="${neutralChipClasses()}">Empty</span>
548
- </div>
549
- <p class="mt-3 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(emptyText)}</p>
550
- </section>
551
- `;
552
- }
553
-
554
- if (!shouldCollapseDescription(description)) {
555
- return `
556
- <section class="${secondaryPanelClasses("board-detail-card p-4")}">
557
- <div class="board-section__header flex items-center justify-between gap-3">
558
- <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
559
- <span class="${neutralChipClasses()}">${escapeHtml(`${description.trim().length} chars`)}</span>
560
- </div>
561
- <div class="mt-3 ${compact ? "board-detail-copy board-detail-copy--compact" : "board-detail-copy"}">
562
- ${renderDescriptionBody(description)}
563
- </div>
564
- </section>
565
- `;
566
- }
567
-
568
- return `
569
- <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${open ? "open" : ""}>
570
- <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
571
- <span>${escapeHtml(title)}</span>
572
- <span class="${neutralChipClasses()}">Long</span>
573
- </summary>
574
- <div class="mt-3 board-detail-copy ${compact ? "board-detail-copy--compact" : ""}">
575
- ${renderDescriptionBody(description)}
576
- </div>
577
- </details>
578
- `;
579
- }
580
-
581
- function renderEpicCountSummary(epic) {
582
- const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
583
- const counts = epic.counts || { todo: 0, blocked: 0, in_progress: 0, done: 0 };
584
-
585
- return `
586
- <span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
587
- <span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
588
- <span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
589
- `;
590
- }
591
-
592
- function compareEpicsForSidebar(leftEpic, rightEpic) {
593
- const leftStatus = normalizeStatus(leftEpic.status);
594
- const rightStatus = normalizeStatus(rightEpic.status);
595
- const statusDelta = (SIDEBAR_EPIC_STATUS_PRIORITY[leftStatus] ?? Number.MAX_SAFE_INTEGER)
596
- - (SIDEBAR_EPIC_STATUS_PRIORITY[rightStatus] ?? Number.MAX_SAFE_INTEGER);
597
-
598
- if (statusDelta !== 0) {
599
- return statusDelta;
600
- }
601
-
602
- const updatedDelta = Number(rightEpic.updatedAt ?? 0) - Number(leftEpic.updatedAt ?? 0);
603
- if (updatedDelta !== 0) {
604
- return updatedDelta;
605
- }
606
-
607
- return leftEpic.title.localeCompare(rightEpic.title);
608
- }
609
-
610
- function getSidebarEpics(epics, search) {
611
- const query = search.trim().toLowerCase();
612
- const visibleEpics = query.length === 0
613
- ? epics.filter((epic) => ACTIVE_EPIC_STATUSES.has(normalizeStatus(epic.status)))
614
- : epics.filter((epic) => epic.searchText.includes(query));
615
-
616
- return [...visibleEpics].sort(compareEpicsForSidebar);
617
- }
618
-
619
- function renderEpicSidebarItem(epic, selected) {
620
- const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
621
- return `
622
- <button
623
- type="button"
624
- class="board-sidebar-item ${cx(
625
- "w-full rounded-2xl border px-3.5 py-3 text-left transition duration-200 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-surface)]",
626
- selected
627
- ? "border-[var(--board-border-strong)] bg-[var(--board-accent-soft)] text-[var(--board-text)] shadow-focus"
628
- : "border-[var(--board-border)] bg-white/[0.03] text-[var(--board-text-muted)] hover:border-[var(--board-border-strong)] hover:bg-white/[0.06]",
629
- )}"
630
- aria-current="${selected}"
631
- data-open-epic="${escapeHtml(epic.id)}"
632
- >
633
- <div class="flex items-start gap-3">
634
- <div class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${selected ? "bg-[var(--board-accent)] text-white" : "bg-[var(--board-surface-3)] text-[var(--board-accent)]"}">
635
- ${renderIcon("folder", "text-[18px]")}
636
- </div>
637
- <div class="min-w-0">
638
- <strong class="block text-sm font-semibold leading-snug text-[var(--board-text)]">${escapeHtml(epic.title)}</strong>
639
- <div class="mt-2 flex flex-wrap items-center gap-2">
640
- ${renderStatusBadge(epic.status)}
641
- <span class="text-xs text-[var(--board-text-soft)]">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
642
- </div>
643
- </div>
644
- </div>
645
- </button>
646
- `;
647
- }
648
-
649
- function renderTaskMeta(task, includeStatus = false) {
650
- return `
651
- ${includeStatus ? renderStatusBadge(task.status) : ""}
652
- <span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
653
- ${task.blockedBy.length > 0 ? `<span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>` : ""}
654
- `;
655
- }
656
-
657
- function hasLongTaskTitle(title) {
658
- if (!title) {
659
- return false;
660
- }
661
-
662
- const trimmed = title.trim();
663
- return trimmed.length > 72 || trimmed.split("\n").length > 2;
664
- }
665
-
666
- function renderTaskCard(task, selected, isMutating = false) {
667
- const longTitle = hasLongTaskTitle(task.title);
668
-
669
- return `
670
- <article
671
- class="board-task-card ${cx(
672
- "rounded-[22px] border p-3.5 transition duration-200 lg:p-4",
673
- "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-surface)]",
674
- selected
675
- ? "border-[var(--board-border-strong)] bg-[var(--board-accent-soft)] shadow-focus"
676
- : "border-[var(--board-border)] bg-[var(--board-surface-2)] shadow-[0_10px_30px_rgba(0,0,0,0.18)] hover:-translate-y-0.5 hover:border-[var(--board-border-strong)] hover:shadow-lift",
677
- )}"
678
- tabindex="0"
679
- draggable="${isMutating ? "false" : "true"}"
680
- data-task-id="${escapeHtml(task.id)}"
681
- data-draggable-task="true"
682
- role="button"
683
- aria-pressed="${selected}"
684
- >
685
- <div class="board-task-card__header flex items-start justify-between gap-3">
686
- <div class="flex min-w-0 flex-wrap items-center gap-2">
687
- ${renderStatusBadge(task.status)}
688
- <span class="board-task-card__eyebrow text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]">${escapeHtml(formatDate(task.updatedAt))}</span>
689
- </div>
690
- ${longTitle ? `<span class="board-task-card__cue ${neutralChipClasses()}">Open for full title</span>` : ""}
691
- </div>
692
- <div class="board-task-card__body mt-3 grid gap-3">
693
- <strong class="board-task-card__title block text-sm font-semibold leading-5 text-[var(--board-text)] sm:text-[0.95rem]">${escapeHtml(task.title)}</strong>
694
- ${task.description?.trim() ? `<p class="board-task-card__description text-sm leading-5 text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(task.description.trim())}</p>` : ""}
695
- </div>
696
- <div class="board-task-card__footer mt-3 flex flex-wrap items-center gap-2.5">${renderTaskMeta(task)}</div>
697
- </article>
698
- `;
699
- }
700
-
701
- function renderListRow(task, selected) {
702
- const longTitle = hasLongTaskTitle(task.title);
703
-
704
- return `
705
- <article
706
- class="board-list-row ${cx(
707
- "grid gap-3 rounded-[22px] border px-4 py-3 transition duration-200 lg:grid-cols-[minmax(0,2fr)_150px_minmax(0,210px)_110px] lg:items-start",
708
- "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-surface)]",
709
- selected
710
- ? "border-[var(--board-border-strong)] bg-[var(--board-accent-soft)] shadow-focus"
711
- : "border-[var(--board-border)] bg-white/[0.02] hover:border-[var(--board-border-strong)] hover:bg-white/[0.04]",
712
- )}"
713
- data-task-id="${escapeHtml(task.id)}"
714
- tabindex="0"
715
- role="button"
716
- aria-pressed="${selected}"
717
- >
718
- <div class="board-list-row__summary min-w-0">
719
- <div class="board-list-row__summary-head flex min-w-0 flex-wrap items-start justify-between gap-2">
720
- <strong class="board-list-row__title block min-w-0 text-sm font-semibold text-[var(--board-text)] sm:text-[0.98rem]">${escapeHtml(task.title)}</strong>
721
- ${longTitle ? `<span class="board-list-row__cue ${neutralChipClasses()}">Open</span>` : ""}
722
- </div>
723
- ${task.description?.trim() ? `<p class="board-list-row__description mt-2 text-sm leading-5 text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(task.description.trim())}</p>` : ""}
724
- </div>
725
- <div class="board-list-row__status">${renderStatusBadge(task.status)}</div>
726
- <div class="board-list-row__meta flex min-w-0 flex-wrap gap-2">${renderTaskMeta(task)}</div>
727
- <span class="board-list-row__updated text-sm text-[var(--board-text-muted)]">${escapeHtml(formatDate(task.updatedAt))}</span>
728
- </article>
729
- `;
730
- }
731
-
732
- function renderDependencyOptions(task, snapshot) {
733
- const existing = new Set(task.blockedBy);
734
- return [
735
- ...snapshot.tasks.map((candidate) => ({ id: candidate.id, kind: "task", title: candidate.title })),
736
- ...snapshot.subtasks.map((candidate) => ({ id: candidate.id, kind: "subtask", title: candidate.title })),
737
- ]
738
- .filter((candidate) => candidate.id !== task.id)
739
- .filter((candidate) => !existing.has(candidate.id))
740
- .map((candidate) => `
741
- <option value="${escapeHtml(candidate.id)}">${escapeHtml(readNodeLabel(candidate.kind, candidate.title))}</option>
742
- `)
743
- .join("");
744
- }
745
-
746
- function lookupNode(snapshot, id) {
747
- return snapshot.tasks.find((task) => task.id === id)
748
- ?? snapshot.subtasks.find((subtask) => subtask.id === id)
749
- ?? null;
750
- }
751
-
752
- function renderDependencyItems(task, snapshot, isMutating = false, dependencyIds = task.blockedBy) {
753
- if (dependencyIds.length === 0) {
754
- return renderEmptyState("No dependencies", "Add blockers here to keep task transitions honest.");
755
- }
133
+ // ---------------------------------------------------------------------------
134
+ // Boot
135
+ // ---------------------------------------------------------------------------
756
136
 
757
- return dependencyIds.map((dependencyId) => {
758
- const dependency = lookupNode(snapshot, dependencyId);
759
- return `
760
- <article class="board-related-item grid gap-3 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
761
- <div class="min-w-0">
762
- <strong class="block text-sm font-semibold text-[var(--board-text)]">${escapeHtml(readNodeLabel(dependency?.kind ?? "task", dependency?.title ?? dependencyId))}</strong>
763
- ${renderDescriptionPreview(dependency?.description ?? "", "board-related-item__description mt-2 text-sm leading-6 text-[var(--board-text-muted)]")}
764
- </div>
765
- <div class="flex flex-wrap items-center gap-2">
766
- ${renderStatusBadge(dependency?.status ?? "todo", readStatusLabel(dependency?.status ?? "Unknown"))}
767
- <button type="button" class="${buttonClasses()}" data-remove-dependency-source="${escapeHtml(task.id)}" data-remove-dependency-target="${escapeHtml(dependencyId)}" ${isMutating ? "disabled" : ""}>Remove</button>
768
- </div>
769
- </article>
770
- `;
771
- }).join("");
772
- }
773
-
774
- function renderDependencySection(task, snapshot, isMutating = false) {
775
- const visibleDependencies = task.blockedBy.slice(0, 3);
776
- const hiddenDependencies = task.blockedBy.slice(3);
777
- return `
778
- <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${task.blockedBy.length <= 2 ? "open" : ""}>
779
- <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
780
- <span>Dependencies</span>
781
- <span class="${neutralChipClasses()}">${task.blockedBy.length}</span>
782
- </summary>
783
- <form class="mt-4 grid gap-4" data-dependency-form="${escapeHtml(task.id)}">
784
- <label class="grid gap-2">
785
- <span class="${sectionLabelClasses()}">Add dependency</span>
786
- <select class="${fieldClasses()}" name="dependsOnId" required ${isMutating ? "disabled" : ""}>
787
- <option value="">Select a task or subtask</option>
788
- ${renderDependencyOptions(task, snapshot)}
789
- </select>
790
- </label>
791
- <div class="flex justify-end">
792
- <button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Add dependency</button>
793
- </div>
794
- </form>
795
- <div class="board-inline-list mt-4 space-y-3">
796
- ${renderDependencyItems(task, snapshot, isMutating, visibleDependencies)}
797
- </div>
798
- ${hiddenDependencies.length > 0 ? `
799
- <details class="board-disclosure board-detail-nested mt-4 ${secondaryPanelClasses("p-3")}">
800
- <summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Show ${hiddenDependencies.length} more ${hiddenDependencies.length === 1 ? "dependency" : "dependencies"}</summary>
801
- <div class="board-inline-list mt-3 space-y-3">
802
- ${renderDependencyItems(task, snapshot, isMutating, hiddenDependencies)}
803
- </div>
804
- </details>
805
- ` : ""}
806
- </details>
807
- `;
808
- }
137
+ export async function bootLegacyBoard(options = {}) {
138
+ const appElement = options.mountElement instanceof HTMLElement
139
+ ? options.mountElement
140
+ : document.querySelector("#app");
809
141
 
810
- function renderSubtaskItems(subtasks) {
811
- if (subtasks.length === 0) {
812
- return renderEmptyState("No subtasks", "This task does not have subtasks in the current snapshot.");
142
+ if (!(appElement instanceof HTMLElement)) {
143
+ throw new Error("Board runtime could not find its mount element.");
813
144
  }
814
145
 
815
- return `
816
- <div class="space-y-3">
817
- ${subtasks.map((subtask) => `
818
- <article class="board-related-item grid gap-3 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] px-4 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
819
- <div class="min-w-0">
820
- <strong class="block text-sm font-semibold text-[var(--board-text)]">${escapeHtml(subtask.title)}</strong>
821
- ${renderDescriptionPreview(subtask.description, "board-related-item__description mt-2 text-sm leading-6 text-[var(--board-text-muted)]")}
822
- </div>
823
- <div class="flex flex-wrap items-center gap-2">
824
- ${renderStatusBadge(subtask.status)}
825
- <button type="button" class="${buttonClasses()}" data-open-subtask="${escapeHtml(subtask.id)}">Open</button>
826
- <button type="button" class="${buttonClasses()}" data-delete-subtask="${escapeHtml(subtask.id)}">Remove</button>
827
- </div>
828
- </article>
829
- `).join("")}
830
- </div>
831
- `;
832
- }
833
-
834
- function renderSubtaskSection(task, isMutating = false) {
835
- const visibleSubtasks = task.subtasks.slice(0, 4);
836
- const hiddenSubtasks = task.subtasks.slice(4);
837
- const shouldOpen = task.subtasks.length <= 3;
838
- return `
839
- <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${shouldOpen ? "open" : ""}>
840
- <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
841
- <span>Subtasks</span>
842
- <span class="${neutralChipClasses()}">${task.subtasks.length}</span>
843
- </summary>
844
- <div class="mt-4 space-y-4">
845
- <details class="board-disclosure board-detail-nested ${secondaryPanelClasses("p-3")}" ${task.subtasks.length === 0 ? "open" : ""}>
846
- <summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Add subtask</summary>
847
- <div class="mt-3">
848
- ${renderCreateSubtaskForm(task, isMutating)}
849
- </div>
850
- </details>
851
- ${renderSubtaskItems(visibleSubtasks)}
852
- ${hiddenSubtasks.length > 0 ? `
853
- <details class="board-disclosure board-detail-nested ${secondaryPanelClasses("p-3")}">
854
- <summary class="cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">Show ${hiddenSubtasks.length} more subtask${hiddenSubtasks.length === 1 ? "" : "s"}</summary>
855
- <div class="mt-3">
856
- ${renderSubtaskItems(hiddenSubtasks)}
857
- </div>
858
- </details>
859
- ` : ""}
860
- </div>
861
- </details>
862
- `;
863
- }
864
-
865
- function renderCreateSubtaskForm(task, isMutating = false) {
866
- return `
867
- <form class="grid gap-4 rounded-3xl border border-[var(--board-border)] bg-white/[0.03] p-4" data-create-subtask-form="${escapeHtml(task.id)}">
868
- <div>
869
- <span class="${sectionLabelClasses()}">Add subtask</span>
870
- <p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">Create a new subtask directly from the task detail panel.</p>
871
- </div>
872
- <label class="grid gap-2">
873
- <span class="${sectionLabelClasses()}">Title</span>
874
- <input class="${fieldClasses()}" name="title" placeholder="Write tests" required ${isMutating ? "disabled" : ""} />
875
- </label>
876
- <label class="grid gap-2">
877
- <span class="${sectionLabelClasses()}">Description</span>
878
- <textarea class="${fieldClasses()} min-h-[96px]" name="description" rows="3" placeholder="Optional context for this subtask" ${isMutating ? "disabled" : ""}></textarea>
879
- </label>
880
- <label class="grid gap-2">
881
- <span class="${sectionLabelClasses()}">Status</span>
882
- ${renderStatusSelect("status", "todo", isMutating)}
883
- </label>
884
- <div class="flex justify-end">
885
- <button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Add subtask</button>
886
- </div>
887
- </form>
888
- `;
889
- }
890
-
891
- function renderSubtaskModal(subtask, isMutating = false) {
892
- return `
893
- <div class="board-modal-backdrop fixed inset-0 z-40 grid place-items-center bg-slate-950/70 p-4 backdrop-blur-md" data-close-subtask>
894
- <section class="board-modal board-modal--sheet ${panelClasses("grid max-h-[calc(100dvh-2rem)] w-full grid-rows-[auto_1fr] overflow-hidden p-5 sm:p-6")}" role="dialog" aria-modal="true" aria-labelledby="board-subtask-modal-title">
895
- <header class="board-modal__header board-detail-surface__header border-b border-[var(--board-border)] pb-5">
896
- <div>
897
- <span class="${sectionLabelClasses()}">Subtask editor</span>
898
- <h3 id="board-subtask-modal-title" class="mt-2 text-xl font-semibold tracking-tight text-[var(--board-text)]">${escapeHtml(subtask.title)}</h3>
899
- <p class="mt-2 text-sm text-[var(--board-text-muted)]">Focused editing surface with its own scroll and sticky close action.</p>
900
- </div>
901
- <button type="button" class="${buttonClasses()} mt-4 sm:mt-0" data-close-subtask>Close</button>
902
- </header>
903
- <div class="board-modal__body board-detail-surface__body min-h-0 pt-5" data-scroll-surface="subtask-modal">
904
- <form class="grid gap-4" data-subtask-form="${escapeHtml(subtask.id)}">
905
- <label class="grid gap-2">
906
- <span class="${sectionLabelClasses()}">Title</span>
907
- <input class="${fieldClasses()}" name="title" value="${escapeHtml(subtask.title)}" required ${isMutating ? "disabled" : ""} />
908
- </label>
909
- <label class="grid gap-2">
910
- <span class="${sectionLabelClasses()}">Description</span>
911
- <textarea class="${fieldClasses()} min-h-[144px]" name="description" rows="5" ${isMutating ? "disabled" : ""}>${escapeHtml(subtask.description)}</textarea>
912
- </label>
913
- <label class="grid gap-2">
914
- <span class="${sectionLabelClasses()}">Status</span>
915
- ${renderStatusSelect("status", subtask.status, isMutating)}
916
- </label>
917
- <div class="board-modal__actions mt-2 flex flex-wrap justify-end gap-3">
918
- <button type="button" class="${buttonClasses()}" data-close-subtask>Cancel</button>
919
- <button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Save subtask</button>
920
- </div>
921
- </form>
922
- </div>
923
- </section>
924
- </div>
925
- `;
926
- }
146
+ try {
147
+ applyTheme(readThemePreference());
148
+ const runtimeSession = resolveRuntimeSession();
149
+ if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
927
150
 
928
- function renderTaskSurface(task, epics, snapshot, isMutating = false, options = {}) {
929
- const epic = epics.find((candidate) => candidate.id === task.epicId) ?? null;
930
- const {
931
- titleId = "",
932
- closeLabel = "Close",
933
- containerClassName = "board-detail-surface",
934
- detailEyebrow = "Task detail",
935
- scrollSurface = "inspector",
936
- } = options;
937
-
938
- return `
939
- <div class="${containerClassName} grid h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden">
940
- <header class="board-detail-surface__header board-drawer__header border-b border-[var(--board-border)] pb-5">
941
- <div class="board-detail-surface__hero flex flex-col gap-4">
942
- <div class="board-detail-surface__title-row flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
943
- <div class="min-w-0">
944
- <span class="${sectionLabelClasses()}">${escapeHtml(detailEyebrow)}</span>
945
- <h3 ${titleId ? `id="${escapeHtml(titleId)}"` : ""} class="mt-2 text-2xl font-semibold tracking-tight text-[var(--board-text)]">${escapeHtml(task.title)}</h3>
946
- <p class="board-detail-surface__context mt-2 text-sm text-[var(--board-text-muted)]">One dominant task surface with sticky context, close, and constrained internal scrolling.</p>
947
- </div>
948
- <button type="button" class="${buttonClasses()} shrink-0" data-close-task>${escapeHtml(closeLabel)}</button>
949
- </div>
950
- <div class="board-detail-surface__meta flex flex-wrap gap-2">
951
- <span class="${neutralChipClasses()}">Epic ${escapeHtml(epic?.title ?? "Unknown")}</span>
952
- ${renderStatusBadge(task.status)}
953
- <span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
954
- <span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>
955
- </div>
956
- </div>
957
- </header>
958
- <div class="board-detail-surface__body board-drawer__body min-h-0 overscroll-contain pt-5 pr-1" data-scroll-surface="${escapeHtml(scrollSurface)}">
959
- <div class="board-detail-surface__stack space-y-4">
960
- <section class="${secondaryPanelClasses("board-detail-card p-4")}">
961
- <div class="board-detail-summary-grid grid gap-3 md:grid-cols-2 xl:grid-cols-3">
962
- <div>
963
- <p class="${sectionLabelClasses()}">Updated</p>
964
- <p class="mt-2 text-sm font-medium text-[var(--board-text)]">${escapeHtml(formatDate(task.updatedAt))}</p>
965
- </div>
966
- <div>
967
- <p class="${sectionLabelClasses()}">Dependencies</p>
968
- <p class="mt-2 text-sm font-medium text-[var(--board-text)]">${task.blockedBy.length} blocking item${task.blockedBy.length === 1 ? "" : "s"}</p>
969
- </div>
970
- <div>
971
- <p class="${sectionLabelClasses()}">Outgoing</p>
972
- <p class="mt-2 text-sm font-medium text-[var(--board-text)]">${task.blocks.length} dependent item${task.blocks.length === 1 ? "" : "s"}</p>
973
- </div>
974
- </div>
975
- </section>
976
- ${renderDescriptionSection("Description", task.description, { open: false, compact: true })}
977
- <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" open>
978
- <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
979
- <span>Edit task</span>
980
- ${renderStatusBadge(task.status)}
981
- </summary>
982
- <form class="mt-4 grid gap-4" data-task-form="${escapeHtml(task.id)}">
983
- <label class="grid gap-2">
984
- <span class="${sectionLabelClasses()}">Title</span>
985
- <input class="${fieldClasses()}" name="title" value="${escapeHtml(task.title)}" required ${isMutating ? "disabled" : ""} />
986
- </label>
987
- <label class="grid gap-2">
988
- <span class="${sectionLabelClasses()}">Description</span>
989
- <textarea class="${fieldClasses()} min-h-[180px]" name="description" rows="7" ${isMutating ? "disabled" : ""}>${escapeHtml(task.description)}</textarea>
990
- </label>
991
- <label class="grid gap-2">
992
- <span class="${sectionLabelClasses()}">Status</span>
993
- ${renderStatusSelect("status", task.status, isMutating)}
994
- </label>
995
- <div class="board-detail-surface__actions flex flex-wrap justify-end gap-3">
996
- <button type="submit" class="${buttonClasses({ kind: "primary" })}" ${isMutating ? "disabled" : ""}>Save task</button>
997
- </div>
998
- </form>
999
- </details>
1000
- ${renderDependencySection(task, snapshot, isMutating)}
1001
- ${renderSubtaskSection(task, isMutating)}
1002
- </div>
1003
- </div>
1004
- </div>
1005
- `;
1006
- }
151
+ // Fetch snapshot
152
+ let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
153
+ if (runtimeSession.token.length > 0) {
154
+ const headers = new Headers();
155
+ headers.set("authorization", `Bearer ${runtimeSession.token}`);
156
+ const response = await fetch("/api/snapshot", { headers });
157
+ const payload = await response.json();
158
+ if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
159
+ snapshotPayload = payload?.data?.snapshot ?? {};
160
+ }
1007
161
 
1008
- function renderTaskModal(task, epics, snapshot, isMutating = false) {
1009
- const compactViewport = isCompactViewport();
1010
- return `
1011
- <div class="board-task-modal-backdrop fixed inset-0 z-30 grid place-items-center bg-slate-950/70 p-4 backdrop-blur-md" data-close-task>
1012
- <section class="board-task-modal ${panelClasses("grid max-h-[calc(100dvh-2rem)] w-full grid-rows-[1fr] overflow-hidden p-5 sm:p-6")}" role="dialog" aria-modal="true" aria-labelledby="board-task-modal-title">
1013
- <div class="h-full min-h-0 overflow-hidden">
1014
- ${renderTaskSurface(task, epics, snapshot, isMutating, {
1015
- titleId: "board-task-modal-title",
1016
- closeLabel: compactViewport ? "Back to board" : "Close",
1017
- containerClassName: "board-detail-surface board-detail-surface--modal",
1018
- detailEyebrow: compactViewport ? "Task focus mode" : "Task detail",
1019
- scrollSurface: "task-modal",
1020
- })}
1021
- </div>
1022
- </section>
1023
- </div>
1024
- `;
1025
- }
162
+ const snapshot = normalizeSnapshot(snapshotPayload);
1026
163
 
1027
- function renderBoard(model) {
1028
- const { store, getBoardState } = model;
1029
- const boardState = getBoardState();
1030
- const visibleEpics = boardState.visibleEpics;
1031
- const sidebarEpics = getSidebarEpics(store.snapshot.epics, store.search);
1032
- const visibleTasks = boardState.visibleTasks;
1033
- const selectedEpic = boardState.selectedEpic;
1034
- const selectedTask = boardState.selectedTask;
1035
- const selectedSubtask = boardState.selectedSubtask;
1036
- const screen = boardState.screen;
1037
- const compactViewport = isCompactViewport();
1038
- const useTaskModal = shouldUseTaskModal(boardState, store);
1039
- const currentNav = selectedTask ? "detail" : screen === "tasks" ? "board" : "epics";
1040
- const primarySurfaceLabel = currentNav === "detail"
1041
- ? "Detail"
1042
- : screen === "tasks"
1043
- ? "Board"
1044
- : "Epics";
1045
- const ownerStack = resolveScrollAuthorityStack(boardState, { useTaskModal });
1046
-
1047
- const columnsMarkup = STATUS_ORDER.map((status) => {
1048
- const columnTasks = visibleTasks.filter((task) => task.status === status);
1049
- const columnTitle = readStatusLabel(status);
1050
- const content = columnTasks.length === 0
1051
- ? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
1052
- : columnTasks
1053
- .map((task) => renderTaskCard(task, selectedTask?.id === task.id, store.isMutating))
1054
- .join("");
1055
-
1056
- return `
1057
- <section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
1058
- <header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
1059
- <div class="min-w-0">
1060
- <p class="${sectionLabelClasses()}">${escapeHtml(columnTitle)}</p>
1061
- <div class="mt-2 flex flex-wrap items-center gap-2">
1062
- ${renderStatusBadge(status)}
1063
- <span class="${neutralChipClasses()}">${columnTasks.length} item${columnTasks.length === 1 ? "" : "s"}</span>
164
+ // Empty board
165
+ if (snapshot.epics.length === 0 && snapshot.tasks.length === 0) {
166
+ appElement.innerHTML = `
167
+ <section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
168
+ <div class="${panelClasses("w-full p-8 text-center")}">
169
+ <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--board-accent-soft)] text-[var(--board-accent)] ring-1 ring-[var(--board-border-strong)]">
170
+ ${renderIcon("inventory_2", "text-[22px]")}
1064
171
  </div>
172
+ <span class="${sectionLabelClasses()}">Board ready</span>
173
+ <h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">No work has been published yet</h1>
174
+ <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">Once the board snapshot is installed into <code class="rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 text-[var(--board-text)]">.trekoon/board</code>, epics and tasks will appear here.</p>
1065
175
  </div>
1066
- ${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
1067
- </header>
1068
- <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
1069
- </section>
1070
- `;
1071
- }).join("");
1072
-
1073
- const listRows = visibleTasks.length === 0
1074
- ? renderEmptyState("No matching tasks", "Nothing in this slice matches the active search and epic filters.", "/")
1075
- : visibleTasks.map((task) => renderListRow(task, selectedTask?.id === task.id)).join("");
1076
- const workspaceLayoutClass = selectedTask && !useTaskModal
1077
- ? "board-root--tasks board-root--detail board-root--detail-open"
1078
- : "board-root--tasks";
1079
-
1080
- const topbarMarkup = renderBoardTopbar({
1081
- buttonClasses,
1082
- currentNav,
1083
- escapeHtml,
1084
- neutralChipClasses,
1085
- renderIcon,
1086
- screen,
1087
- search: store.search,
1088
- searchScope: boardState.searchScope,
1089
- selectedEpic,
1090
- theme: store.theme,
1091
- });
1092
-
1093
- const epicsOverviewMarkup = renderEpicsOverview({
1094
- panelClasses,
1095
- renderEmptyState,
1096
- renderEpicRow: (epic) => renderEpicOverviewRow({
1097
- epic,
1098
- escapeHtml,
1099
- formatDate,
1100
- neutralChipClasses,
1101
- renderClampedText,
1102
- renderIcon,
1103
- renderStatusBadge,
1104
- selected: boardState.selectedEpicId === epic.id,
1105
- }),
1106
- sectionLabelClasses,
1107
- store,
1108
- visibleEpics,
1109
- });
1110
-
1111
- const tasksWorkspaceMarkup = selectedEpic ? `
1112
- <div class="board-root ${workspaceLayoutClass} ${selectedTask && !useTaskModal ? "has-detail" : ""} min-h-0 w-full grid gap-4 xl:gap-5" data-scroll-surface="workspace">
1113
- <aside class="board-sidebar ${panelClasses("hidden min-h-0 overflow-hidden p-4 xl:grid xl:grid-rows-[auto_1fr]")}" aria-label="Epic switcher">
1114
- <header class="board-sidebar__header border-b border-[var(--board-border)] pb-4">
1115
- <span class="${sectionLabelClasses()}">Epics</span>
1116
- <h2 class="mt-2 text-lg font-semibold tracking-tight text-[var(--board-text)]">Switch epic</h2>
1117
- <p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">Showing active epics first: in progress, then todo.</p>
1118
- </header>
1119
- <div class="board-sidebar__list mt-4 grid min-h-0 content-start gap-2.5 overflow-auto pr-1 overscroll-contain">
1120
- ${sidebarEpics.length === 0
1121
- ? renderEmptyState("No active epics", "Todo and in-progress epics will appear here for quick switching.")
1122
- : sidebarEpics.map((epic) => renderEpicSidebarItem(epic, boardState.selectedEpicId === epic.id)).join("")}
1123
- </div>
1124
- </aside>
1125
-
1126
- <section class="board-workspace ${panelClasses("grid min-h-0 min-w-0 grid-rows-[auto_1fr] overflow-hidden p-5 sm:p-6")}" aria-label="Workspace">
1127
- ${renderWorkspaceHeader({
1128
- escapeHtml,
1129
- fieldClasses,
1130
- isCompactViewport: compactViewport,
1131
- neutralChipClasses,
1132
- primarySurfaceLabel,
1133
- renderEpicCountSummary,
1134
- renderIcon,
1135
- renderStatusBadge,
1136
- sectionLabelClasses,
1137
- searchScope: boardState.searchScope,
1138
- selectedEpic,
1139
- snapshotEpics: store.snapshot.epics,
1140
- store: {
1141
- isMutating: store.isMutating,
1142
- selectedEpicId: boardState.selectedEpicId,
1143
- view: store.view,
1144
- viewModes: VIEW_MODES.map((view) => ({
1145
- active: store.view === view,
1146
- classes: cx(
1147
- "rounded-2xl px-4 py-2 text-sm font-medium transition 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-surface)]",
1148
- store.view === view ? "bg-[var(--board-accent-soft)] text-[var(--board-text)] shadow-[inset_0_0_0_1px_var(--board-border-strong)]" : "text-[var(--board-text-muted)] hover:text-[var(--board-text)]",
1149
- ),
1150
- icon: view === "kanban" ? "view_kanban" : "list",
1151
- id: view,
1152
- label: view === "kanban" ? "Kanban" : "Rows",
1153
- })),
1154
- },
1155
- visibleTasks,
1156
- })}
1157
-
1158
- <div class="board-content mt-6 min-h-0 min-w-0 overflow-hidden">
1159
- ${store.view === "kanban"
1160
- ? `<div class="board-kanban board-kanban--dense min-h-0 min-w-0 overflow-y-auto pr-1">${columnsMarkup}</div>`
1161
- : `
1162
- <div class="board-list board-list--dense grid min-h-0 gap-4 grid-rows-[auto_1fr]">
1163
- <div class="board-list__header hidden gap-3 px-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--board-text-soft)] lg:grid lg:grid-cols-[minmax(0,2fr)_150px_minmax(0,210px)_110px]">
1164
- <span>Task</span>
1165
- <span>Status</span>
1166
- <span>Workflow</span>
1167
- <span>Updated</span>
1168
- </div>
1169
- <div class="board-list__rows min-h-0 space-y-3 overflow-auto pr-1 overscroll-contain">${listRows}</div>
1170
- </div>`}
1171
- </div>
1172
- </section>
1173
-
1174
- ${selectedTask && !useTaskModal ? `
1175
- <aside class="board-panel board-drawer board-detail-surface-frame is-open ${panelClasses("fixed inset-4 z-30 grid max-h-[calc(100dvh-2rem)] min-h-0 overflow-hidden p-5 xl:static xl:inset-auto xl:max-h-none xl:p-5")}" aria-label="Task inspector">
1176
- ${renderTaskSurface(selectedTask, store.snapshot.epics, store.snapshot, store.isMutating, {
1177
- closeLabel: "Close inspector",
1178
- containerClassName: "board-detail-surface board-detail-surface--inspector",
1179
- detailEyebrow: "Task inspector",
1180
- scrollSurface: "inspector",
1181
- })}
1182
- </aside>
1183
- ` : ""}
1184
- </div>
1185
- ${useTaskModal ? renderTaskModal(selectedTask, store.snapshot.epics, store.snapshot, store.isMutating) : ""}
1186
- ` : epicsOverviewMarkup;
1187
-
1188
- appElement.innerHTML = `
1189
- ${renderNotice(store.notice)}
1190
- <div class="board-layout ${screen === "tasks" ? "board-layout--workspace" : "board-layout--overview"} mx-auto flex w-full max-w-[1600px] flex-col gap-4 px-4 py-4 sm:px-6 sm:py-6 xl:px-8 ${screen === "tasks" ? "min-h-0" : "min-h-screen"}" data-scroll-surface="page">
1191
- ${topbarMarkup}
1192
- ${screen === "tasks" ? tasksWorkspaceMarkup : epicsOverviewMarkup}
1193
- ${selectedSubtask ? renderSubtaskModal(selectedSubtask, store.isMutating) : ""}
1194
- </div>
1195
- `;
1196
-
1197
- syncScrollAuthority(appElement.querySelector(".board-layout"), ownerStack);
1198
- syncOverlayScrollLock(Boolean(useTaskModal || selectedSubtask));
1199
- return { ownerStack };
1200
- }
1201
-
1202
- function renderError(message) {
1203
- syncOverlayScrollLock(false);
1204
- appElement.innerHTML = `
1205
- <section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
1206
- <div class="${panelClasses("w-full p-8 text-center")}">
1207
- <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
1208
- ${renderIcon("warning", "text-[22px]")}
1209
- </div>
1210
- <span class="${sectionLabelClasses()}">Board error</span>
1211
- <h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">Could not load the board snapshot</h1>
1212
- <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">${escapeHtml(message)}</p>
1213
- </div>
1214
- </section>
1215
- `;
1216
- }
1217
-
1218
- function attachInteractions(model, api, rerender) {
1219
- const { store, getBoardState } = model;
1220
- const actions = createBoardActions({
1221
- model,
1222
- api,
1223
- rerender,
1224
- normalizeSnapshot,
1225
- normalizeStatus,
1226
- applyTheme,
1227
- closeTopmostDisclosure,
1228
- dismissSearch,
1229
- focusSearch,
1230
- focusTaskDetail,
1231
- searchFocusKeys: SEARCH_FOCUS_KEYS,
1232
- });
1233
-
1234
- document.querySelector("[data-action='toggle-theme']")?.addEventListener("click", () => {
1235
- actions.toggleTheme();
1236
- });
1237
-
1238
- document.querySelector("#board-search-input")?.addEventListener("input", (event) => {
1239
- actions.updateSearch(event.target.value);
1240
- });
1241
-
1242
- document.querySelectorAll("[data-open-epic]").forEach((button) => {
1243
- button.addEventListener("click", () => {
1244
- actions.openEpic(button.dataset.openEpic || null);
1245
- });
1246
- });
1247
-
1248
- document.querySelector("#board-epic-select")?.addEventListener("change", (event) => {
1249
- actions.selectEpic(event.target.value || null);
1250
- });
176
+ </section>
177
+ `;
178
+ return;
179
+ }
1251
180
 
1252
- document.querySelector("[data-nav='epics']")?.addEventListener("click", () => {
1253
- actions.showEpics();
1254
- });
181
+ // Create store and API
182
+ const model = createStore(snapshot, { normalizeSnapshot });
183
+ const slots = createBoardShell(appElement);
184
+
185
+ // Mount components
186
+ const topBar = createTopBar().mount(slots.topbar);
187
+ const workspace = createWorkspace().mount(slots.workspace);
188
+ const taskModal = createTaskModal().mount(slots.taskModal);
189
+ const subtaskModal = createSubtaskModal().mount(slots.subtaskModal);
190
+ const notice = createNotice().mount(slots.notice);
191
+ const confirmDialog = createConfirmDialog().mount(slots.confirmDialog);
192
+ const epicsOverview = createEpicsOverview().mount(slots.epicsOverview);
193
+
194
+ // Pending confirm state for destructive actions
195
+ let pendingConfirm = null;
196
+ let activeOverlay = null;
197
+ let overlayOpener = null;
198
+ let restoreFocusPending = false;
199
+ let previousBodyOverflow = "";
200
+ let previousBodyPaddingRight = "";
201
+ let scrollLockDepth = 0;
202
+
203
+ const backgroundSlots = [
204
+ slots.notice,
205
+ slots.topbar,
206
+ slots.epicsOverview,
207
+ slots.tasksRoot,
208
+ ];
209
+
210
+ function setOverlayOpener(candidate) {
211
+ overlayOpener = candidate instanceof HTMLElement ? candidate : document.activeElement instanceof HTMLElement ? document.activeElement : null;
212
+ }
1255
213
 
1256
- document.querySelectorAll("[data-nav-board]").forEach((button) => {
1257
- button.addEventListener("click", () => {
1258
- actions.showBoard();
1259
- });
1260
- });
214
+ function restoreOverlayFocus() {
215
+ if (!restoreFocusPending) return;
216
+ restoreFocusPending = false;
217
+ if (overlayOpener instanceof HTMLElement && overlayOpener.isConnected && !overlayOpener.closest("[inert]")) {
218
+ overlayOpener.focus({ preventScroll: true });
219
+ }
220
+ overlayOpener = null;
221
+ }
1261
222
 
1262
- document.querySelectorAll("[data-nav-detail]").forEach((button) => {
1263
- button.addEventListener("click", () => {
1264
- document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
1265
- });
1266
- });
223
+ function lockBackgroundScroll() {
224
+ if (scrollLockDepth > 0) {
225
+ scrollLockDepth += 1;
226
+ return;
227
+ }
228
+ previousBodyOverflow = document.body.style.overflow;
229
+ previousBodyPaddingRight = document.body.style.paddingRight;
230
+ const scrollbarWidth = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
231
+ document.documentElement.classList.add("board-scroll-locked");
232
+ document.body.classList.add("board-scroll-locked");
233
+ document.body.style.overflow = "hidden";
234
+ if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
235
+ scrollLockDepth = 1;
236
+ }
1267
237
 
1268
- document.querySelectorAll("[data-view]").forEach((button) => {
1269
- button.addEventListener("click", () => {
1270
- actions.setView(button.dataset.view);
1271
- });
1272
- });
238
+ function unlockBackgroundScroll() {
239
+ if (scrollLockDepth === 0) return;
240
+ scrollLockDepth -= 1;
241
+ if (scrollLockDepth > 0) return;
242
+ document.documentElement.classList.remove("board-scroll-locked");
243
+ document.body.classList.remove("board-scroll-locked");
244
+ document.body.style.overflow = previousBodyOverflow;
245
+ document.body.style.paddingRight = previousBodyPaddingRight;
246
+ }
1273
247
 
1274
- document.querySelectorAll("[data-task-id]").forEach((node) => {
1275
- node.addEventListener("click", () => {
1276
- rememberReturnFocus(SCROLL_AUTHORITY.workspace, node);
1277
- actions.selectTask(node.dataset.taskId);
1278
- });
1279
- });
248
+ function setBackgroundInert(isInert) {
249
+ for (const slot of backgroundSlots) {
250
+ if (!(slot instanceof HTMLElement)) continue;
251
+ if (isInert) {
252
+ slot.inert = true;
253
+ slot.setAttribute("aria-hidden", "true");
254
+ } else {
255
+ slot.inert = false;
256
+ slot.removeAttribute("aria-hidden");
257
+ }
258
+ }
259
+ }
1280
260
 
261
+ function getActiveOverlayElement() {
262
+ return slots.confirmDialog.querySelector("[data-overlay-root]")
263
+ || slots.subtaskModal.querySelector("[data-overlay-root]")
264
+ || slots.taskModal.querySelector("[data-overlay-root]")
265
+ || null;
266
+ }
1281
267
 
1282
- document.querySelectorAll("[data-close-task]").forEach((button) => {
1283
- button.addEventListener("click", (event) => {
1284
- if (event.currentTarget !== event.target && event.currentTarget?.classList?.contains("board-task-modal-backdrop")) {
268
+ function focusOverlay(overlay) {
269
+ if (!(overlay instanceof HTMLElement)) return;
270
+ const autofocusTarget = overlay.querySelector("[data-overlay-initial-focus]");
271
+ if (autofocusTarget instanceof HTMLElement && isFocusableElement(autofocusTarget)) {
272
+ autofocusTarget.focus({ preventScroll: true });
1285
273
  return;
1286
274
  }
1287
- actions.closeTask();
1288
- });
1289
- });
1290
-
1291
- document.querySelectorAll("[data-open-subtask]").forEach((button) => {
1292
- button.addEventListener("click", () => {
1293
- const boardState = getBoardState();
1294
- rememberReturnFocus(resolveTaskDetailOwner(boardState, store), button);
1295
- actions.openSubtask(button.dataset.openSubtask || null);
1296
- });
1297
- });
275
+ overlay.focus({ preventScroll: true });
276
+ }
1298
277
 
1299
- document.querySelectorAll("[data-close-subtask]").forEach((node) => {
1300
- node.addEventListener("click", (event) => {
1301
- if (event.currentTarget !== event.target && event.currentTarget?.classList?.contains("board-modal-backdrop")) {
278
+ function syncOverlayEnvironment() {
279
+ const hadOverlay = activeOverlay instanceof HTMLElement;
280
+ const nextOverlay = getActiveOverlayElement();
281
+ if (nextOverlay === activeOverlay) {
1302
282
  return;
1303
283
  }
1304
- actions.closeSubtask();
1305
- });
1306
- });
1307
-
1308
- document.querySelector(".board-modal")?.addEventListener("click", (event) => {
1309
- event.stopPropagation();
1310
- });
1311
284
 
1312
- document.querySelector(".board-task-modal")?.addEventListener("click", (event) => {
1313
- event.stopPropagation();
1314
- });
1315
-
1316
- document.querySelectorAll("[data-task-form]").forEach((form) => {
1317
- form.addEventListener("submit", (event) => {
1318
- event.preventDefault();
1319
- if (store.isMutating) {
285
+ if (nextOverlay) {
286
+ activeOverlay = nextOverlay;
287
+ if (!hadOverlay) {
288
+ lockBackgroundScroll();
289
+ setBackgroundInert(true);
290
+ }
291
+ queueMicrotask(() => focusOverlay(nextOverlay));
1320
292
  return;
1321
293
  }
1322
- const taskId = form.dataset.taskForm;
1323
- actions.submitTaskForm(taskId, new FormData(form));
1324
- });
1325
- });
1326
294
 
1327
- document.querySelectorAll("[data-subtask-form]").forEach((form) => {
1328
- form.addEventListener("submit", (event) => {
1329
- event.preventDefault();
1330
- if (store.isMutating) {
1331
- return;
295
+ activeOverlay = null;
296
+ if (hadOverlay) {
297
+ unlockBackgroundScroll();
298
+ setBackgroundInert(false);
1332
299
  }
1333
- const subtaskId = form.dataset.subtaskForm;
1334
- actions.submitSubtaskForm(subtaskId, new FormData(form));
1335
- });
1336
- });
300
+ queueMicrotask(() => restoreOverlayFocus());
301
+ }
1337
302
 
1338
- document.querySelectorAll("[data-create-subtask-form]").forEach((form) => {
1339
- form.addEventListener("submit", (event) => {
1340
- event.preventDefault();
1341
- if (store.isMutating) {
303
+ function trapOverlayFocus(event) {
304
+ if (event.key !== "Tab" || !(activeOverlay instanceof HTMLElement)) return;
305
+ const focusableElements = getFocusableElements(activeOverlay);
306
+ if (focusableElements.length === 0) {
307
+ event.preventDefault();
308
+ activeOverlay.focus({ preventScroll: true });
1342
309
  return;
1343
310
  }
1344
311
 
1345
- const taskId = form.dataset.createSubtaskForm;
1346
- actions.submitCreateSubtask(taskId, new FormData(form));
1347
- });
1348
- });
312
+ const first = focusableElements[0];
313
+ const last = focusableElements[focusableElements.length - 1];
314
+ const current = document.activeElement;
1349
315
 
1350
- document.querySelectorAll("[data-delete-subtask]").forEach((button) => {
1351
- button.addEventListener("click", () => {
1352
- if (store.isMutating) {
316
+ if (event.shiftKey) {
317
+ if (current === first || !activeOverlay.contains(current)) {
318
+ event.preventDefault();
319
+ last.focus({ preventScroll: true });
320
+ }
1353
321
  return;
1354
322
  }
1355
323
 
1356
- const subtaskId = button.dataset.deleteSubtask;
1357
- if (!subtaskId) {
1358
- return;
324
+ if (current === last || !activeOverlay.contains(current)) {
325
+ event.preventDefault();
326
+ first.focus({ preventScroll: true });
1359
327
  }
328
+ }
1360
329
 
1361
- actions.deleteSubtask(subtaskId);
1362
- });
1363
- });
330
+ function containOverlayFocus(event) {
331
+ if (!(activeOverlay instanceof HTMLElement)) return;
332
+ if (activeOverlay.contains(event.target)) return;
333
+ const [firstFocusable] = getFocusableElements(activeOverlay);
334
+ (firstFocusable || activeOverlay).focus({ preventScroll: true });
335
+ }
1364
336
 
1365
- document.querySelectorAll("[data-dependency-form]").forEach((form) => {
1366
- form.addEventListener("submit", (event) => {
1367
- event.preventDefault();
1368
- if (store.isMutating) {
1369
- return;
337
+ function closeTopmostDisclosure() {
338
+ const openDetails = Array.from(document.querySelectorAll("details[open]"))
339
+ .filter((element) => !element.closest("[data-overlay-root]"));
340
+ const topmost = openDetails.at(-1);
341
+ if (!(topmost instanceof HTMLDetailsElement)) {
342
+ return false;
1370
343
  }
1371
- const sourceId = form.dataset.dependencyForm;
1372
- actions.addDependency(sourceId, new FormData(form));
1373
- });
1374
- });
1375
344
 
1376
- document.querySelectorAll("[data-remove-dependency-source]").forEach((button) => {
1377
- button.addEventListener("click", () => {
1378
- if (store.isMutating) {
1379
- return;
345
+ topmost.open = false;
346
+ const summary = topmost.querySelector("summary");
347
+ if (summary instanceof HTMLElement) {
348
+ summary.focus({ preventScroll: true });
1380
349
  }
1381
- const sourceId = button.dataset.removeDependencySource;
1382
- const dependsOnId = button.dataset.removeDependencyTarget;
1383
- actions.removeDependency(sourceId, dependsOnId);
1384
- });
1385
- });
350
+ return true;
351
+ }
1386
352
 
1387
- document.querySelectorAll("[data-draggable-task]").forEach((card) => {
1388
- card.addEventListener("dragstart", (event) => {
1389
- if (store.isMutating) {
1390
- event.preventDefault();
1391
- return;
353
+ function dismissSearch(boardState, activeElement) {
354
+ const searchInput = document.querySelector("#board-search-input");
355
+ if (!(searchInput instanceof HTMLInputElement)) {
356
+ return false;
1392
357
  }
1393
- const taskId = card.dataset.taskId;
1394
- if (!taskId) {
1395
- return;
1396
- }
1397
- event.dataTransfer?.setData("text/task-id", taskId);
1398
- event.dataTransfer?.setData("text/plain", taskId);
1399
- });
1400
- });
1401
358
 
1402
- document.querySelectorAll("[data-drop-status]").forEach((column) => {
1403
- column.addEventListener("dragover", (event) => {
1404
- event.preventDefault();
1405
- });
1406
- column.addEventListener("drop", (event) => {
1407
- event.preventDefault();
1408
- if (store.isMutating) {
1409
- return;
359
+ const searchHasValue = boardState.search.trim().length > 0;
360
+ const pendingSearchHasValue = searchInput.value.trim().length > 0;
361
+ const searchIsFocused = activeElement === searchInput;
362
+ if (!searchHasValue && !pendingSearchHasValue && !searchIsFocused) {
363
+ return false;
1410
364
  }
1411
- const taskId = event.dataTransfer?.getData("text/task-id") || event.dataTransfer?.getData("text/plain");
1412
- const nextStatus = column.dataset.dropStatus;
1413
- actions.dropTaskStatus(taskId, nextStatus);
1414
- });
1415
- });
1416
365
 
1417
- window.onkeydown = (event) => {
1418
- actions.handleKeydown(event);
1419
- };
1420
- }
366
+ if (searchHasValue || pendingSearchHasValue) {
367
+ searchInput.value = "";
368
+ actions.clearSearch();
369
+ }
1421
370
 
1422
- export async function bootLegacyBoard(options = {}) {
1423
- try {
1424
- appElement = options.mountElement instanceof HTMLElement ? options.mountElement : document.querySelector("#app");
1425
- if (!(appElement instanceof HTMLElement)) {
1426
- throw new Error("Board runtime could not find its mount element.");
371
+ searchInput.blur();
372
+ return true;
1427
373
  }
1428
374
 
1429
- applyTheme(readThemePreference());
1430
- const runtimeSession = resolveRuntimeSession();
1431
- if (runtimeSession.shouldScrubAddressBar) {
1432
- scrubTokenFromAddressBar();
1433
- }
375
+ document.addEventListener("keydown", trapOverlayFocus, true);
376
+ document.addEventListener("focusin", containOverlayFocus, true);
377
+
378
+ // Render cycle
379
+ function rerender() {
380
+ const store = model.store;
381
+ const boardState = model.getBoardState();
382
+ const screen = boardState.screen;
383
+ const selectedTask = boardState.selectedTask;
384
+ const selectedSubtask = boardState.selectedSubtask;
385
+ const currentNav = selectedTask ? "detail" : screen === "tasks" ? "board" : "epics";
386
+
387
+ // Layout toggles
388
+ const showTasks = screen === "tasks" && boardState.selectedEpic;
389
+ slots.epicsOverview.style.display = showTasks ? "none" : "";
390
+ slots.tasksRoot.style.display = showTasks ? "" : "none";
391
+
392
+ if (showTasks) {
393
+ slots.tasksRoot.className = "board-root board-root--tasks min-h-0 w-full";
394
+ }
1434
395
 
1435
- const headers = new Headers();
1436
- if (runtimeSession.token.length > 0) {
1437
- headers.set("authorization", `Bearer ${runtimeSession.token}`);
1438
- }
396
+ if (!showTasks && pendingConfirm) {
397
+ pendingConfirm = null;
398
+ confirmDialog.update(null);
399
+ }
1439
400
 
1440
- let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
1441
- if (runtimeSession.token.length > 0) {
1442
- const response = await fetch("/api/snapshot", {
1443
- headers,
401
+ if (!showTasks || !selectedTask) {
402
+ taskModal.update(null);
403
+ }
404
+
405
+ if (!showTasks || !selectedTask || !selectedSubtask) {
406
+ subtaskModal.update(null);
407
+ }
408
+
409
+ slots.shell.className = `board-layout ${screen === "tasks" ? "board-layout--workspace" : "board-layout--overview"} mx-auto flex w-full max-w-[1600px] flex-col gap-4 px-4 py-4 sm:px-6 sm:py-6 xl:px-8 ${screen === "tasks" ? "min-h-0" : "min-h-screen"}`;
410
+
411
+ // Update components
412
+ topBar.update({
413
+ currentNav,
414
+ screen,
415
+ search: store.search,
416
+ searchScope: boardState.searchScope,
417
+ selectedEpic: boardState.selectedEpic,
418
+ theme: store.theme,
419
+ isMutating: store.isMutating,
1444
420
  });
1445
- const payload = await response.json();
1446
- if (!payload?.ok) {
1447
- const message = payload?.error?.message || "Board request failed";
1448
- throw new Error(message);
421
+
422
+ notice.update({
423
+ notice: store.notice,
424
+ onDismiss() { store.notice = null; rerender(); },
425
+ });
426
+
427
+ if (showTasks) {
428
+ workspace.update({
429
+ selectedEpic: boardState.selectedEpic,
430
+ selectedTask,
431
+ searchScope: boardState.searchScope,
432
+ snapshotEpics: store.snapshot.epics,
433
+ store,
434
+ visibleTasks: boardState.visibleTasks,
435
+ });
436
+
437
+ taskModal.update(selectedTask ? {
438
+ task: selectedTask,
439
+ epics: store.snapshot.epics,
440
+ snapshot: store.snapshot,
441
+ isMutating: store.isMutating,
442
+ } : null);
443
+ } else {
444
+ epicsOverview.update({
445
+ visibleEpics: boardState.visibleEpics,
446
+ selectedEpicId: boardState.selectedEpicId,
447
+ store,
448
+ });
1449
449
  }
1450
- snapshotPayload = payload?.data?.snapshot ?? {};
1451
- }
1452
450
 
1453
- const snapshot = normalizeSnapshot(snapshotPayload);
451
+ subtaskModal.update(showTasks && selectedTask && selectedSubtask ? {
452
+ subtask: selectedSubtask,
453
+ isMutating: store.isMutating,
454
+ } : null);
1454
455
 
1455
- if (snapshot.epics.length === 0 && snapshot.tasks.length === 0) {
1456
- syncOverlayScrollLock(false);
1457
- appElement.innerHTML = `
1458
- <section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
1459
- <div class="${panelClasses("w-full p-8 text-center")}">
1460
- <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--board-accent-soft)] text-[var(--board-accent)] ring-1 ring-[var(--board-border-strong)]">
1461
- ${renderIcon("inventory_2", "text-[22px]")}
1462
- </div>
1463
- <span class="${sectionLabelClasses()}">Board ready</span>
1464
- <h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">No work has been published yet</h1>
1465
- <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">Once the board snapshot is installed into <code class="rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 text-[var(--board-text)]">.trekoon/board</code>, epics and tasks will appear here.</p>
1466
- </div>
1467
- </section>
1468
- `;
1469
- return;
456
+ syncOverlayEnvironment();
1470
457
  }
1471
458
 
1472
- const model = createStore(snapshot, { normalizeSnapshot });
1473
- let api = null;
1474
- const rerender = (options = {}) => {
1475
- if (appElement.childElementCount > 0) {
1476
- scrollAuthorityStack.capture(appElement, model.store);
1477
- }
459
+ const api = createApi(model, { sessionToken: runtimeSession.token, rerender });
460
+
461
+ // Actions for delegation
462
+ const actions = createBoardActions({
463
+ model,
464
+ api,
465
+ rerender,
466
+ normalizeSnapshot,
467
+ normalizeStatus,
468
+ applyTheme,
469
+ closeTopmostDisclosure,
470
+ dismissSearch,
471
+ hasOpenOverlay: () => activeOverlay instanceof HTMLElement,
472
+ closeActiveOverlay: () => {
473
+ if (pendingConfirm) {
474
+ restoreFocusPending = true;
475
+ pendingConfirm = null;
476
+ confirmDialog.update(null);
477
+ syncOverlayEnvironment();
478
+ return;
479
+ }
480
+
481
+ if (model.getBoardState().selectedSubtaskId) {
482
+ restoreFocusPending = true;
483
+ actions.closeSubtask();
484
+ return;
485
+ }
1478
486
 
1479
- const renderState = renderBoard(model);
1480
- attachInteractions(model, api, rerender);
487
+ if (model.getBoardState().selectedTaskId) {
488
+ restoreFocusPending = true;
489
+ actions.closeTask();
490
+ }
491
+ },
492
+ focusSearch: () => document.querySelector("#board-search-input")?.focus({ preventScroll: true }),
493
+ focusTaskDetail: () => document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: getScrollBehavior() }),
494
+ searchFocusKeys: SEARCH_FOCUS_KEYS,
495
+ });
1481
496
 
1482
- const activeRuntime = scrollAuthorityStack.transition(renderState.ownerStack);
1483
- if (options.preserveFocus !== false) {
1484
- restoreRuntimeState(appElement, activeRuntime);
1485
- }
1486
- };
497
+ // Event delegation
498
+ createDelegation(appElement, {
499
+ isMutating: () => model.store.isMutating,
500
+ deleteSubtask: (id, opener) => {
501
+ setOverlayOpener(opener);
502
+ pendingConfirm = { action: () => actions.deleteSubtask(id), title: "Remove subtask", message: "This subtask will be permanently removed. Are you sure?" };
503
+ confirmDialog.update({ open: true, title: pendingConfirm.title, message: pendingConfirm.message, confirmLabel: "Remove", cancelLabel: "Cancel", tone: "destructive" });
504
+ syncOverlayEnvironment();
505
+ },
506
+ removeDependency: (src, dep, opener) => {
507
+ setOverlayOpener(opener);
508
+ pendingConfirm = { action: () => actions.removeDependency(src, dep), title: "Remove dependency", message: "This dependency link will be removed. Are you sure?" };
509
+ confirmDialog.update({ open: true, title: pendingConfirm.title, message: pendingConfirm.message, confirmLabel: "Remove", cancelLabel: "Cancel", tone: "destructive" });
510
+ syncOverlayEnvironment();
511
+ },
512
+ openSubtask: (id, opener) => {
513
+ setOverlayOpener(opener);
514
+ actions.openSubtask(id);
515
+ },
516
+ closeSubtask: () => {
517
+ restoreFocusPending = true;
518
+ actions.closeSubtask();
519
+ },
520
+ closeTask: () => {
521
+ restoreFocusPending = true;
522
+ actions.closeTask();
523
+ },
524
+ showEpics: () => actions.showEpics(),
525
+ showBoard: () => actions.showBoard(),
526
+ scrollToDetail: () => document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: getScrollBehavior() }),
527
+ setView: (view) => actions.setView(view),
528
+ toggleTheme: () => actions.toggleTheme(),
529
+ toggleNotesPanel: () => actions.toggleNotesPanel(),
530
+ confirmDelete: () => {
531
+ if (pendingConfirm) {
532
+ restoreFocusPending = true;
533
+ pendingConfirm.action();
534
+ pendingConfirm = null;
535
+ confirmDialog.update(null);
536
+ syncOverlayEnvironment();
537
+ }
538
+ },
539
+ cancelDelete: () => {
540
+ restoreFocusPending = true;
541
+ pendingConfirm = null;
542
+ confirmDialog.update(null);
543
+ syncOverlayEnvironment();
544
+ },
545
+ openEpic: (id) => actions.openEpic(id),
546
+ selectEpic: (id) => actions.selectEpic(id),
547
+ selectTask: (id, opener) => {
548
+ setOverlayOpener(opener);
549
+ actions.selectTask(id);
550
+ },
551
+ updateSearch: (value) => actions.updateSearch(value),
552
+ submitTaskForm: (id, data) => actions.submitTaskForm(id, data),
553
+ submitSubtaskForm: (id, data) => actions.submitSubtaskForm(id, data),
554
+ submitCreateSubtask: (id, data) => actions.submitCreateSubtask(id, data),
555
+ addDependency: (src, data) => actions.addDependency(src, data),
556
+ dropTaskStatus: (id, status) => actions.dropTaskStatus(id, status),
557
+ changeEpicStatus: (epicId, status) => actions.changeEpicStatus(epicId, status),
558
+ bulkSetStatus: (epicId, status) => actions.bulkSetStatus(epicId, status),
559
+ handleKeydown: (event) => actions.handleKeydown(event),
560
+ });
1487
561
 
1488
- api = createApi(model, { sessionToken: runtimeSession.token, rerender });
562
+ // URL hash sync
563
+ syncUrlHash(model, {
564
+ beforeRestore: actions.cancelPendingSearch,
565
+ onRestore: rerender,
566
+ });
567
+
568
+ // Initial render
1489
569
  applyTheme(model.store.theme);
1490
- rerender({ preserveFocus: false });
570
+ rerender();
1491
571
  } catch (error) {
1492
- renderError(error instanceof Error ? error.message : String(error));
572
+ appElement.innerHTML = `
573
+ <section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
574
+ <div class="${panelClasses("w-full p-8 text-center")}">
575
+ <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
576
+ ${renderIcon("warning", "text-[22px]")}
577
+ </div>
578
+ <span class="${sectionLabelClasses()}">Board error</span>
579
+ <h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">Could not load the board snapshot</h1>
580
+ <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
581
+ </div>
582
+ </section>
583
+ `;
1493
584
  }
1494
585
  }
1495
586