trekoon 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/docs/commands.md +100 -0
- package/docs/quickstart.md +74 -1
- package/package.json +2 -1
- package/src/board/assets/app.js +589 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- 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 +64 -0
- package/src/board/assets/components/EpicsOverview.js +80 -0
- 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 +80 -0
- 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 +39 -0
- package/src/board/assets/main.js +11 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/runtime/delegation.js +309 -0
- package/src/board/assets/state/actions.js +454 -0
- package/src/board/assets/state/api.js +281 -0
- package/src/board/assets/state/store.js +472 -0
- 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 +1811 -0
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +308 -0
- package/src/board/server.ts +185 -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/help.ts +21 -0
- package/src/commands/init.ts +29 -0
- package/src/domain/mutation-service.ts +40 -0
- package/src/domain/tracker-domain.ts +11 -3
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { createBoardActions } from "./state/actions.js";
|
|
2
|
+
import { createApi } from "./state/api.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";
|
|
15
|
+
|
|
16
|
+
const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
|
|
17
|
+
const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function readSessionTokenFromStorage() {
|
|
37
|
+
try {
|
|
38
|
+
return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function persistSessionToken(token) {
|
|
45
|
+
try {
|
|
46
|
+
sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveRuntimeSession() {
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
const queryToken = (url.searchParams.get("token") || "").trim();
|
|
56
|
+
if (queryToken.length > 0) {
|
|
57
|
+
return { token: queryToken, shouldScrubAddressBar: persistSessionToken(queryToken) };
|
|
58
|
+
}
|
|
59
|
+
return { token: readSessionTokenFromStorage(), shouldScrubAddressBar: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrubTokenFromAddressBar() {
|
|
63
|
+
const url = new URL(window.location.href);
|
|
64
|
+
if (!url.searchParams.has("token") || typeof window.history?.replaceState !== "function") return;
|
|
65
|
+
url.searchParams.delete("token");
|
|
66
|
+
window.history.replaceState(window.history.state, document.title, `${url.pathname}${url.search}${url.hash}` || "/");
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
}
|
|
76
|
+
|
|
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);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readJsonScript(scriptId) {
|
|
91
|
+
const script = document.getElementById(scriptId);
|
|
92
|
+
if (!script) return null;
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(script.textContent || "null");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`Failed to parse ${scriptId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Board shell layout
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
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>
|
|
116
|
+
</div>
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const slot = (name) => appElement.querySelector(`[data-slot="${name}"]`);
|
|
120
|
+
return {
|
|
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"),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Boot
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export async function bootLegacyBoard(options = {}) {
|
|
138
|
+
const appElement = options.mountElement instanceof HTMLElement
|
|
139
|
+
? options.mountElement
|
|
140
|
+
: document.querySelector("#app");
|
|
141
|
+
|
|
142
|
+
if (!(appElement instanceof HTMLElement)) {
|
|
143
|
+
throw new Error("Board runtime could not find its mount element.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
applyTheme(readThemePreference());
|
|
148
|
+
const runtimeSession = resolveRuntimeSession();
|
|
149
|
+
if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
|
|
150
|
+
|
|
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
|
+
}
|
|
161
|
+
|
|
162
|
+
const snapshot = normalizeSnapshot(snapshotPayload);
|
|
163
|
+
|
|
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]")}
|
|
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>
|
|
175
|
+
</div>
|
|
176
|
+
</section>
|
|
177
|
+
`;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
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
|
+
}
|
|
213
|
+
|
|
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
|
+
}
|
|
222
|
+
|
|
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
|
+
}
|
|
237
|
+
|
|
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
|
+
}
|
|
247
|
+
|
|
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
|
+
}
|
|
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
|
+
}
|
|
267
|
+
|
|
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 });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
overlay.focus({ preventScroll: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function syncOverlayEnvironment() {
|
|
279
|
+
const hadOverlay = activeOverlay instanceof HTMLElement;
|
|
280
|
+
const nextOverlay = getActiveOverlayElement();
|
|
281
|
+
if (nextOverlay === activeOverlay) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (nextOverlay) {
|
|
286
|
+
activeOverlay = nextOverlay;
|
|
287
|
+
if (!hadOverlay) {
|
|
288
|
+
lockBackgroundScroll();
|
|
289
|
+
setBackgroundInert(true);
|
|
290
|
+
}
|
|
291
|
+
queueMicrotask(() => focusOverlay(nextOverlay));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
activeOverlay = null;
|
|
296
|
+
if (hadOverlay) {
|
|
297
|
+
unlockBackgroundScroll();
|
|
298
|
+
setBackgroundInert(false);
|
|
299
|
+
}
|
|
300
|
+
queueMicrotask(() => restoreOverlayFocus());
|
|
301
|
+
}
|
|
302
|
+
|
|
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 });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const first = focusableElements[0];
|
|
313
|
+
const last = focusableElements[focusableElements.length - 1];
|
|
314
|
+
const current = document.activeElement;
|
|
315
|
+
|
|
316
|
+
if (event.shiftKey) {
|
|
317
|
+
if (current === first || !activeOverlay.contains(current)) {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
last.focus({ preventScroll: true });
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (current === last || !activeOverlay.contains(current)) {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
first.focus({ preventScroll: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
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
|
+
}
|
|
336
|
+
|
|
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;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
topmost.open = false;
|
|
346
|
+
const summary = topmost.querySelector("summary");
|
|
347
|
+
if (summary instanceof HTMLElement) {
|
|
348
|
+
summary.focus({ preventScroll: true });
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function dismissSearch(boardState, activeElement) {
|
|
354
|
+
const searchInput = document.querySelector("#board-search-input");
|
|
355
|
+
if (!(searchInput instanceof HTMLInputElement)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
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;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (searchHasValue || pendingSearchHasValue) {
|
|
367
|
+
searchInput.value = "";
|
|
368
|
+
actions.clearSearch();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
searchInput.blur();
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
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
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!showTasks && pendingConfirm) {
|
|
397
|
+
pendingConfirm = null;
|
|
398
|
+
confirmDialog.update(null);
|
|
399
|
+
}
|
|
400
|
+
|
|
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,
|
|
420
|
+
});
|
|
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
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
subtaskModal.update(showTasks && selectedTask && selectedSubtask ? {
|
|
452
|
+
subtask: selectedSubtask,
|
|
453
|
+
isMutating: store.isMutating,
|
|
454
|
+
} : null);
|
|
455
|
+
|
|
456
|
+
syncOverlayEnvironment();
|
|
457
|
+
}
|
|
458
|
+
|
|
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
|
+
}
|
|
486
|
+
|
|
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
|
+
});
|
|
496
|
+
|
|
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
|
+
});
|
|
561
|
+
|
|
562
|
+
// URL hash sync
|
|
563
|
+
syncUrlHash(model, {
|
|
564
|
+
beforeRestore: actions.cancelPendingSearch,
|
|
565
|
+
onRestore: rerender,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// Initial render
|
|
569
|
+
applyTheme(model.store.theme);
|
|
570
|
+
rerender();
|
|
571
|
+
} catch (error) {
|
|
572
|
+
appElement.innerHTML = `
|
|
573
|
+
<section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
|
|
574
|
+
<div class="${panelClasses("w-full p-8 text-center")}">
|
|
575
|
+
<div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
|
|
576
|
+
${renderIcon("warning", "text-[22px]")}
|
|
577
|
+
</div>
|
|
578
|
+
<span class="${sectionLabelClasses()}">Board error</span>
|
|
579
|
+
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-[var(--board-text)]">Could not load the board snapshot</h1>
|
|
580
|
+
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
|
|
581
|
+
</div>
|
|
582
|
+
</section>
|
|
583
|
+
`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (window.__TREKOON_BOARD_BOOTSTRAP__ !== "main") {
|
|
588
|
+
void bootLegacyBoard();
|
|
589
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function renderClampedText(context) {
|
|
2
|
+
const {
|
|
3
|
+
buttonLabel = "Description",
|
|
4
|
+
className = "",
|
|
5
|
+
emptyText = "",
|
|
6
|
+
escapeHtml,
|
|
7
|
+
lineClamp = 2,
|
|
8
|
+
renderIcon,
|
|
9
|
+
text,
|
|
10
|
+
} = context;
|
|
11
|
+
|
|
12
|
+
const trimmed = text?.trim() ?? "";
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return emptyText ? `<p class="board-clamped-text__empty ${escapeHtml(className)}">${escapeHtml(emptyText)}</p>` : "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return `
|
|
18
|
+
<details class="board-clamped-text" data-clamped-text>
|
|
19
|
+
<summary class="board-clamped-text__summary">
|
|
20
|
+
<span class="board-clamped-text__preview board-clamped-text__preview--${lineClamp} ${escapeHtml(className)}">${escapeHtml(trimmed)}</span>
|
|
21
|
+
<span class="board-clamped-text__toggle" aria-label="Toggle ${escapeHtml(buttonLabel)}">
|
|
22
|
+
<span class="board-clamped-text__toggle-more">Show more ${renderIcon("expand_more", "text-[16px]")}</span>
|
|
23
|
+
<span class="board-clamped-text__toggle-less">Collapse ${renderIcon("expand_less", "text-[16px]")}</span>
|
|
24
|
+
</span>
|
|
25
|
+
</summary>
|
|
26
|
+
<div class="board-clamped-text__body ${escapeHtml(className)}">
|
|
27
|
+
${escapeHtml(trimmed)}
|
|
28
|
+
</div>
|
|
29
|
+
</details>
|
|
30
|
+
`;
|
|
31
|
+
}
|