trekoon 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -708
- package/docs/ai-agents.md +198 -0
- package/docs/commands.md +226 -0
- package/docs/machine-contracts.md +253 -0
- package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
- package/docs/quickstart.md +207 -0
- package/package.json +3 -1
- package/src/board/assets/app.js +1498 -0
- package/src/board/assets/components/AppShell.js +17 -0
- package/src/board/assets/components/BoardTopbar.js +78 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/EpicRow.js +62 -0
- package/src/board/assets/components/EpicsOverview.js +43 -0
- package/src/board/assets/components/WorkspaceHeader.js +70 -0
- package/src/board/assets/components/assetMap.js +65 -0
- package/src/board/assets/index.html +76 -0
- package/src/board/assets/main.js +27 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/state/actions.js +334 -0
- package/src/board/assets/state/api.js +126 -0
- package/src/board/assets/state/store.js +172 -0
- package/src/board/assets/styles/board.css +1127 -0
- package/src/board/assets/utils/dom.js +308 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +299 -0
- package/src/board/server.ts +184 -0
- package/src/board/snapshot.ts +277 -0
- package/src/board/types.ts +43 -0
- package/src/commands/board.ts +158 -0
- package/src/commands/epic.ts +104 -3
- package/src/commands/help.ts +52 -13
- package/src/commands/init.ts +29 -0
- package/src/commands/subtask.ts +78 -1
- package/src/commands/task.ts +113 -7
- package/src/domain/mutation-service.ts +116 -0
- package/src/domain/tracker-domain.ts +261 -5
- package/src/domain/types.ts +51 -0
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
import { createBoardActions } from "./state/actions.js";
|
|
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";
|
|
18
|
+
|
|
19
|
+
const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
|
|
20
|
+
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
|
+
}
|
|
222
|
+
|
|
223
|
+
function readSessionTokenFromStorage() {
|
|
224
|
+
try {
|
|
225
|
+
const storedToken = sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "";
|
|
226
|
+
return storedToken.trim();
|
|
227
|
+
} catch {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function persistSessionToken(token) {
|
|
233
|
+
try {
|
|
234
|
+
sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
|
|
235
|
+
return true;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolveRuntimeSession() {
|
|
242
|
+
const url = new URL(window.location.href);
|
|
243
|
+
const queryToken = (url.searchParams.get("token") || "").trim();
|
|
244
|
+
if (queryToken.length > 0) {
|
|
245
|
+
return {
|
|
246
|
+
token: queryToken,
|
|
247
|
+
shouldScrubAddressBar: persistSessionToken(queryToken),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
token: readSessionTokenFromStorage(),
|
|
253
|
+
shouldScrubAddressBar: false,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function scrubTokenFromAddressBar() {
|
|
258
|
+
const url = new URL(window.location.href);
|
|
259
|
+
if (!url.searchParams.has("token") || typeof window.history?.replaceState !== "function") {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
url.searchParams.delete("token");
|
|
264
|
+
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
|
265
|
+
window.history.replaceState(window.history.state, document.title, nextUrl || "/");
|
|
266
|
+
}
|
|
267
|
+
|
|
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
|
+
}
|
|
273
|
+
|
|
274
|
+
return "todo";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readJsonScript(scriptId) {
|
|
278
|
+
const script = document.getElementById(scriptId);
|
|
279
|
+
if (!script) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(script.textContent || "null");
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new Error(`Failed to parse ${scriptId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
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
|
+
}
|
|
297
|
+
|
|
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("&", "&")
|
|
325
|
+
.replaceAll("<", "<")
|
|
326
|
+
.replaceAll(">", ">")
|
|
327
|
+
.replaceAll('"', """);
|
|
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
|
+
: ""}
|
|
338
|
+
</div>
|
|
339
|
+
`;
|
|
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
|
+
|
|
473
|
+
return {
|
|
474
|
+
generatedAt: rawSnapshot?.generatedAt ?? null,
|
|
475
|
+
epics,
|
|
476
|
+
tasks,
|
|
477
|
+
subtasks,
|
|
478
|
+
dependencies,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
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
|
+
}
|
|
756
|
+
|
|
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
|
+
}
|
|
809
|
+
|
|
810
|
+
function renderSubtaskItems(subtasks) {
|
|
811
|
+
if (subtasks.length === 0) {
|
|
812
|
+
return renderEmptyState("No subtasks", "This task does not have subtasks in the current snapshot.");
|
|
813
|
+
}
|
|
814
|
+
|
|
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
|
+
}
|
|
927
|
+
|
|
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
|
+
}
|
|
1007
|
+
|
|
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
|
+
}
|
|
1026
|
+
|
|
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>
|
|
1064
|
+
</div>
|
|
1065
|
+
</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
|
+
});
|
|
1251
|
+
|
|
1252
|
+
document.querySelector("[data-nav='epics']")?.addEventListener("click", () => {
|
|
1253
|
+
actions.showEpics();
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
document.querySelectorAll("[data-nav-board]").forEach((button) => {
|
|
1257
|
+
button.addEventListener("click", () => {
|
|
1258
|
+
actions.showBoard();
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
|
|
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
|
+
});
|
|
1267
|
+
|
|
1268
|
+
document.querySelectorAll("[data-view]").forEach((button) => {
|
|
1269
|
+
button.addEventListener("click", () => {
|
|
1270
|
+
actions.setView(button.dataset.view);
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
1273
|
+
|
|
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
|
+
});
|
|
1280
|
+
|
|
1281
|
+
|
|
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")) {
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
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
|
+
});
|
|
1298
|
+
|
|
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")) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
actions.closeSubtask();
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
document.querySelector(".board-modal")?.addEventListener("click", (event) => {
|
|
1309
|
+
event.stopPropagation();
|
|
1310
|
+
});
|
|
1311
|
+
|
|
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) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const taskId = form.dataset.taskForm;
|
|
1323
|
+
actions.submitTaskForm(taskId, new FormData(form));
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
document.querySelectorAll("[data-subtask-form]").forEach((form) => {
|
|
1328
|
+
form.addEventListener("submit", (event) => {
|
|
1329
|
+
event.preventDefault();
|
|
1330
|
+
if (store.isMutating) {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const subtaskId = form.dataset.subtaskForm;
|
|
1334
|
+
actions.submitSubtaskForm(subtaskId, new FormData(form));
|
|
1335
|
+
});
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
document.querySelectorAll("[data-create-subtask-form]").forEach((form) => {
|
|
1339
|
+
form.addEventListener("submit", (event) => {
|
|
1340
|
+
event.preventDefault();
|
|
1341
|
+
if (store.isMutating) {
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const taskId = form.dataset.createSubtaskForm;
|
|
1346
|
+
actions.submitCreateSubtask(taskId, new FormData(form));
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
document.querySelectorAll("[data-delete-subtask]").forEach((button) => {
|
|
1351
|
+
button.addEventListener("click", () => {
|
|
1352
|
+
if (store.isMutating) {
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const subtaskId = button.dataset.deleteSubtask;
|
|
1357
|
+
if (!subtaskId) {
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
actions.deleteSubtask(subtaskId);
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
document.querySelectorAll("[data-dependency-form]").forEach((form) => {
|
|
1366
|
+
form.addEventListener("submit", (event) => {
|
|
1367
|
+
event.preventDefault();
|
|
1368
|
+
if (store.isMutating) {
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const sourceId = form.dataset.dependencyForm;
|
|
1372
|
+
actions.addDependency(sourceId, new FormData(form));
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
document.querySelectorAll("[data-remove-dependency-source]").forEach((button) => {
|
|
1377
|
+
button.addEventListener("click", () => {
|
|
1378
|
+
if (store.isMutating) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const sourceId = button.dataset.removeDependencySource;
|
|
1382
|
+
const dependsOnId = button.dataset.removeDependencyTarget;
|
|
1383
|
+
actions.removeDependency(sourceId, dependsOnId);
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
document.querySelectorAll("[data-draggable-task]").forEach((card) => {
|
|
1388
|
+
card.addEventListener("dragstart", (event) => {
|
|
1389
|
+
if (store.isMutating) {
|
|
1390
|
+
event.preventDefault();
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
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
|
+
|
|
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;
|
|
1410
|
+
}
|
|
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
|
+
|
|
1417
|
+
window.onkeydown = (event) => {
|
|
1418
|
+
actions.handleKeydown(event);
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
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.");
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
applyTheme(readThemePreference());
|
|
1430
|
+
const runtimeSession = resolveRuntimeSession();
|
|
1431
|
+
if (runtimeSession.shouldScrubAddressBar) {
|
|
1432
|
+
scrubTokenFromAddressBar();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const headers = new Headers();
|
|
1436
|
+
if (runtimeSession.token.length > 0) {
|
|
1437
|
+
headers.set("authorization", `Bearer ${runtimeSession.token}`);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
|
|
1441
|
+
if (runtimeSession.token.length > 0) {
|
|
1442
|
+
const response = await fetch("/api/snapshot", {
|
|
1443
|
+
headers,
|
|
1444
|
+
});
|
|
1445
|
+
const payload = await response.json();
|
|
1446
|
+
if (!payload?.ok) {
|
|
1447
|
+
const message = payload?.error?.message || "Board request failed";
|
|
1448
|
+
throw new Error(message);
|
|
1449
|
+
}
|
|
1450
|
+
snapshotPayload = payload?.data?.snapshot ?? {};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const snapshot = normalizeSnapshot(snapshotPayload);
|
|
1454
|
+
|
|
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;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
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
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const renderState = renderBoard(model);
|
|
1480
|
+
attachInteractions(model, api, rerender);
|
|
1481
|
+
|
|
1482
|
+
const activeRuntime = scrollAuthorityStack.transition(renderState.ownerStack);
|
|
1483
|
+
if (options.preserveFocus !== false) {
|
|
1484
|
+
restoreRuntimeState(appElement, activeRuntime);
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
api = createApi(model, { sessionToken: runtimeSession.token, rerender });
|
|
1489
|
+
applyTheme(model.store.theme);
|
|
1490
|
+
rerender({ preserveFocus: false });
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
renderError(error instanceof Error ? error.message : String(error));
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (window.__TREKOON_BOARD_BOOTSTRAP__ !== "main") {
|
|
1497
|
+
void bootLegacyBoard();
|
|
1498
|
+
}
|