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.
- package/package.json +1 -1
- package/src/board/assets/app.js +468 -1377
- package/src/board/assets/components/ClampedText.js +1 -1
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +23 -21
- package/src/board/assets/components/EpicsOverview.js +48 -11
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +80 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +308 -0
- package/src/board/assets/components/assetMap.js +29 -14
- package/src/board/assets/components/helpers.js +244 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +20 -57
- package/src/board/assets/main.js +2 -18
- package/src/board/assets/runtime/delegation.js +309 -0
- package/src/board/assets/state/actions.js +136 -16
- package/src/board/assets/state/api.js +201 -46
- package/src/board/assets/state/store.js +417 -117
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +811 -127
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/routes.ts +15 -6
- package/src/board/server.ts +1 -0
- package/src/board/assets/components/AppShell.js +0 -17
- package/src/board/assets/components/BoardTopbar.js +0 -78
- package/src/board/assets/components/WorkspaceHeader.js +0 -70
- package/src/board/assets/utils/dom.js +0 -308
package/src/board/assets/app.js
CHANGED
|
@@ -1,229 +1,41 @@
|
|
|
1
1
|
import { createBoardActions } from "./state/actions.js";
|
|
2
2
|
import { createApi } from "./state/api.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
: ""}
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
return;
|
|
295
|
+
activeOverlay = null;
|
|
296
|
+
if (hadOverlay) {
|
|
297
|
+
unlockBackgroundScroll();
|
|
298
|
+
setBackgroundInert(false);
|
|
1332
299
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
});
|
|
1336
|
-
});
|
|
300
|
+
queueMicrotask(() => restoreOverlayFocus());
|
|
301
|
+
}
|
|
1337
302
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
if (
|
|
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
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
});
|
|
312
|
+
const first = focusableElements[0];
|
|
313
|
+
const last = focusableElements[focusableElements.length - 1];
|
|
314
|
+
const current = document.activeElement;
|
|
1349
315
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
324
|
+
if (current === last || !activeOverlay.contains(current)) {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
first.focus({ preventScroll: true });
|
|
1359
327
|
}
|
|
328
|
+
}
|
|
1360
329
|
|
|
1361
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
345
|
+
topmost.open = false;
|
|
346
|
+
const summary = topmost.querySelector("summary");
|
|
347
|
+
if (summary instanceof HTMLElement) {
|
|
348
|
+
summary.focus({ preventScroll: true });
|
|
1380
349
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
actions.removeDependency(sourceId, dependsOnId);
|
|
1384
|
-
});
|
|
1385
|
-
});
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
1386
352
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
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
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
}
|
|
366
|
+
if (searchHasValue || pendingSearchHasValue) {
|
|
367
|
+
searchInput.value = "";
|
|
368
|
+
actions.clearSearch();
|
|
369
|
+
}
|
|
1421
370
|
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
396
|
+
if (!showTasks && pendingConfirm) {
|
|
397
|
+
pendingConfirm = null;
|
|
398
|
+
confirmDialog.update(null);
|
|
399
|
+
}
|
|
1439
400
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
451
|
+
subtaskModal.update(showTasks && selectedTask && selectedSubtask ? {
|
|
452
|
+
subtask: selectedSubtask,
|
|
453
|
+
isMutating: store.isMutating,
|
|
454
|
+
} : null);
|
|
1454
455
|
|
|
1455
|
-
|
|
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
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
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(
|
|
570
|
+
rerender();
|
|
1491
571
|
} catch (error) {
|
|
1492
|
-
|
|
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
|
|