trekoon 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/board/assets/app.js +468 -1377
  3. package/src/board/assets/components/ClampedText.js +1 -1
  4. package/src/board/assets/components/Component.js +271 -0
  5. package/src/board/assets/components/ConfirmDialog.js +81 -0
  6. package/src/board/assets/components/EpicRow.js +23 -21
  7. package/src/board/assets/components/EpicsOverview.js +48 -11
  8. package/src/board/assets/components/Inspector.js +335 -0
  9. package/src/board/assets/components/Notice.js +80 -0
  10. package/src/board/assets/components/SubtaskModal.js +100 -0
  11. package/src/board/assets/components/TaskCard.js +82 -0
  12. package/src/board/assets/components/TaskModal.js +99 -0
  13. package/src/board/assets/components/TopBar.js +167 -0
  14. package/src/board/assets/components/Workspace.js +308 -0
  15. package/src/board/assets/components/assetMap.js +29 -14
  16. package/src/board/assets/components/helpers.js +244 -0
  17. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  18. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  19. package/src/board/assets/index.html +20 -57
  20. package/src/board/assets/main.js +2 -18
  21. package/src/board/assets/runtime/delegation.js +309 -0
  22. package/src/board/assets/state/actions.js +136 -16
  23. package/src/board/assets/state/api.js +201 -46
  24. package/src/board/assets/state/store.js +417 -117
  25. package/src/board/assets/state/url.js +184 -0
  26. package/src/board/assets/state/utils.js +222 -0
  27. package/src/board/assets/styles/board.css +811 -127
  28. package/src/board/assets/styles/fonts.css +22 -0
  29. package/src/board/routes.ts +15 -6
  30. package/src/board/server.ts +1 -0
  31. package/src/board/assets/components/AppShell.js +0 -17
  32. package/src/board/assets/components/BoardTopbar.js +0 -78
  33. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  34. package/src/board/assets/utils/dom.js +0 -308
@@ -0,0 +1,22 @@
1
+ /* Inter — variable, Latin subset, weights 400–700 */
2
+ @font-face {
3
+ font-family: "Inter";
4
+ font-style: normal;
5
+ font-weight: 400 700;
6
+ font-display: swap;
7
+ src: url("../fonts/inter-latin.woff2") format("woff2");
8
+ unicode-range: U+0020-007E, U+00A0, U+00A9, U+00AB, U+00AD, U+00AE, U+00B7,
9
+ U+00BB, U+00C0-00C1, U+00C9, U+00CD, U+00D1, U+00D3, U+00D7, U+00DA,
10
+ U+00DC, U+00E0-00E1, U+00E9, U+00ED, U+00F1, U+00F3, U+00F7, U+00FA,
11
+ U+00FC, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2039, U+203A,
12
+ U+20AC, U+2122, U+2212;
13
+ }
14
+
15
+ /* Material Symbols Rounded — subset of 16 glyphs used in board UI */
16
+ @font-face {
17
+ font-family: "Material Symbols Rounded";
18
+ font-style: normal;
19
+ font-weight: 400;
20
+ font-display: block;
21
+ src: url("../fonts/material-symbols-rounded.woff2") format("woff2");
22
+ }
@@ -53,7 +53,7 @@ function isSqliteBusyMessage(message: string): boolean {
53
53
  return normalized.includes("database is locked") || normalized.includes("database schema is locked");
54
54
  }
55
55
 
56
- function toBoardRouteError(error: unknown): BoardRouteError {
56
+ function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteError {
57
57
  if (error instanceof DomainError) {
58
58
  const status =
59
59
  error.code === "not_found"
@@ -76,7 +76,7 @@ function toBoardRouteError(error: unknown): BoardRouteError {
76
76
  return {
77
77
  status: 503,
78
78
  code: "database_busy",
79
- message: "Trekoon database is busy",
79
+ message: `${requestLabel} failed because the Trekoon database is busy`,
80
80
  details: {
81
81
  databaseMessage: message,
82
82
  },
@@ -86,15 +86,15 @@ function toBoardRouteError(error: unknown): BoardRouteError {
86
86
  return {
87
87
  status: 500,
88
88
  code: "internal_error",
89
- message: "Unexpected board API failure",
89
+ message: `${requestLabel} failed unexpectedly`,
90
90
  details: {
91
91
  cause: message,
92
92
  },
93
93
  };
94
94
  }
95
95
 
96
- function describeBoardError(mutations: MutationService, error: unknown): BoardRouteError {
97
- const routeError = toBoardRouteError(error);
96
+ function describeBoardError(mutations: MutationService, error: unknown, requestLabel: string): BoardRouteError {
97
+ const routeError = toBoardRouteError(error, requestLabel);
98
98
  const readableMessage = mutations.describeError(error);
99
99
  if (readableMessage === undefined) {
100
100
  return routeError;
@@ -178,6 +178,7 @@ function readRequiredString(body: Record<string, unknown>, field: string): strin
178
178
  export function createBoardApiHandler(context: BoardRouteContext): (request: Request) => Promise<Response> {
179
179
  return async (request: Request): Promise<Response> => {
180
180
  const url = new URL(request.url);
181
+ const requestLabel = `${request.method} ${url.pathname}`;
181
182
  const requestToken = extractToken(request, url);
182
183
  if (requestToken !== context.token) {
183
184
  return jsonResponse(401, {
@@ -202,6 +203,14 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
202
203
  });
203
204
  }
204
205
 
206
+ const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
207
+ if (epicCascadeMatch) {
208
+ const body = await parseJsonBody(request);
209
+ const status = readRequiredString(body, "status");
210
+ const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
211
+ return buildMutationResponse(domain, { plan });
212
+ }
213
+
205
214
  const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
206
215
  if (epicMatch) {
207
216
  const body = await parseJsonBody(request);
@@ -285,7 +294,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
285
294
  },
286
295
  });
287
296
  } catch (error: unknown) {
288
- const routeError = describeBoardError(mutations, error);
297
+ const routeError = describeBoardError(mutations, error, requestLabel);
289
298
  return jsonResponse(routeError.status, {
290
299
  ok: false,
291
300
  error: {
@@ -14,6 +14,7 @@ const CONTENT_TYPES: Record<string, string> = {
14
14
  ".json": "application/json; charset=utf-8",
15
15
  ".svg": "image/svg+xml",
16
16
  ".txt": "text/plain; charset=utf-8",
17
+ ".woff2": "font/woff2",
17
18
  };
18
19
 
19
20
  const BOARD_SERVER_STATE_FILENAME = "board-server.json";
@@ -1,17 +0,0 @@
1
- export function createBoardShellComponent() {
2
- return {
3
- template: `
4
- <div class="board-shell-v2">
5
- <section class="board-shell-v2__frame">
6
- <div class="board-shell-v2__runtime-shell">
7
- <div
8
- id="board-runtime-root"
9
- class="board-shell-v2__runtime"
10
- data-board-runtime-root
11
- ></div>
12
- </div>
13
- </section>
14
- </div>
15
- `,
16
- };
17
- }
@@ -1,78 +0,0 @@
1
- export function renderBoardTopbar(context) {
2
- const {
3
- buttonClasses,
4
- currentNav,
5
- escapeHtml,
6
- neutralChipClasses,
7
- renderIcon,
8
- screen,
9
- search,
10
- searchScope,
11
- selectedEpic,
12
- theme,
13
- } = context;
14
-
15
- const navItems = [
16
- { id: "epics", label: "Epics", icon: "layers", action: 'data-nav="epics"' },
17
- { id: "board", label: "Board", icon: "view_kanban", action: 'data-nav-board="true"', disabled: !selectedEpic },
18
- ];
19
-
20
- const navMarkup = navItems.map((item) => {
21
- const isActive = currentNav === item.id;
22
- const classes = [
23
- "board-shell-topbar__nav-item",
24
- isActive ? "is-active" : "",
25
- ].filter(Boolean).join(" ");
26
-
27
- return `
28
- <button type="button" class="${classes}" ${item.action} ${item.disabled ? "disabled" : ""} ${isActive ? 'aria-current="page"' : ""}>
29
- ${renderIcon(item.icon, "text-[16px]")} <span>${escapeHtml(item.label)}</span>
30
- </button>
31
- `;
32
- }).join("");
33
-
34
- const epicContext = selectedEpic
35
- ? escapeHtml(selectedEpic.title)
36
- : escapeHtml(searchScope?.summary ?? "No epic selected");
37
-
38
- return `
39
- <header class="board-shell-topbar ${screen === "tasks" ? "board-shell-topbar--workspace" : ""}">
40
- <div class="board-shell-topbar__identity">
41
- <div class="board-shell-topbar__brand-mark" aria-hidden="true">
42
- ${renderIcon("rocket_launch", "text-[18px]")}
43
- </div>
44
- <div class="min-w-0">
45
- <div class="board-shell-topbar__title-row">
46
- <h1>Trekoon</h1>
47
- <span class="${neutralChipClasses()}">Local repo</span>
48
- </div>
49
- <p class="board-shell-topbar__context">${epicContext}</p>
50
- </div>
51
- </div>
52
-
53
- <nav class="board-shell-topbar__nav" aria-label="Board sections">
54
- ${navMarkup}
55
- </nav>
56
-
57
- <div class="board-shell-topbar__tools">
58
- <label class="board-shell-topbar__search" aria-label="Search tasks and epics">
59
- ${renderIcon("search", "text-[16px] text-[var(--board-text-soft)]")}
60
- <input id="board-search-input" type="search" placeholder="Search epics, tasks, subtasks" value="${escapeHtml(search)}" />
61
- <span class="board-shell-topbar__search-kbd">/</span>
62
- </label>
63
- <div class="board-shell-topbar__actions">
64
- <button type="button" class="${buttonClasses({ iconOnly: true })}" data-action="toggle-theme" aria-label="Toggle ${theme === "dark" ? "light" : "dark"} theme">
65
- ${renderIcon(theme === "dark" ? "light_mode" : "dark_mode", "text-[18px]")}
66
- </button>
67
- <details class="board-shell-topbar__meta">
68
- <summary>${renderIcon("info", "text-[16px]")}</summary>
69
- <div>
70
- <p>Repo-backed board state and view preferences stay local to this workspace.</p>
71
- <p class="mt-2 text-sm text-[var(--board-text-muted)]">Current scope: ${escapeHtml(searchScope?.summary ?? "Epic overview")}</p>
72
- </div>
73
- </details>
74
- </div>
75
- </div>
76
- </header>
77
- `;
78
- }
@@ -1,70 +0,0 @@
1
- export function renderWorkspaceHeader(context) {
2
- const {
3
- escapeHtml,
4
- fieldClasses,
5
- isCompactViewport,
6
- neutralChipClasses,
7
- primarySurfaceLabel,
8
- renderEpicCountSummary,
9
- renderIcon,
10
- renderStatusBadge,
11
- sectionLabelClasses,
12
- searchScope,
13
- selectedEpic,
14
- snapshotEpics,
15
- store,
16
- visibleTasks,
17
- } = context;
18
-
19
- const description = selectedEpic.description?.trim() || "No epic description yet.";
20
-
21
- return `
22
- <header class="board-section-head board-section-head--workspace board-workspace-header">
23
- <div class="board-workspace-header__intro">
24
- <div class="board-workspace-header__title-block">
25
- <span class="${sectionLabelClasses()}">${escapeHtml(searchScope?.summary ?? "Selected epic")}</span>
26
- <div class="board-workspace-header__title-row">
27
- <h2>${escapeHtml(selectedEpic.title)}</h2>
28
- ${renderStatusBadge(selectedEpic.status)}
29
- ${isCompactViewport ? `<span class="${neutralChipClasses()}">Primary surface · ${escapeHtml(primarySurfaceLabel)}</span>` : ""}
30
- </div>
31
- <div class="mt-3 flex flex-wrap gap-2">
32
- <span class="${neutralChipClasses()}">${escapeHtml(searchScope?.detail ?? "")}</span>
33
- <span class="${neutralChipClasses()}">${visibleTasks.length} visible task${visibleTasks.length === 1 ? "" : "s"}</span>
34
- </div>
35
- </div>
36
- <details class="board-workspace-header__details">
37
- <summary>
38
- ${renderIcon("subject", "text-[18px]")}
39
- <span>${searchScope?.kind === "epic_search" ? "Epic scope" : "Epic notes"}</span>
40
- </summary>
41
- <p>${escapeHtml(description)}</p>
42
- </details>
43
- </div>
44
-
45
- <div class="board-workspace__toolbar board-workspace-header__toolbar">
46
- <label class="board-select grid gap-2 xl:min-w-[240px]" aria-label="Choose epic">
47
- <span class="${sectionLabelClasses()}">Epic</span>
48
- <select class="${fieldClasses()}" id="board-epic-select">
49
- ${snapshotEpics.map((epic) => `
50
- <option value="${escapeHtml(epic.id)}" ${store.selectedEpicId === epic.id ? "selected" : ""}>
51
- ${escapeHtml(epic.title)}
52
- </option>
53
- `).join("")}
54
- </select>
55
- </label>
56
- <div class="board-workspace-header__controls">
57
- <div class="board-tabs inline-flex rounded-2xl border border-[var(--board-border)] bg-white/[0.03] p-1" role="tablist" aria-label="Board views">
58
- ${store.viewModes.map((view) => `<button class="${view.classes}" type="button" role="tab" aria-selected="${view.active}" data-view="${view.id}">${renderIcon(view.icon, "text-[18px]")} ${view.label}</button>`).join("")}
59
- </div>
60
- <div class="board-legend board-workspace-header__legend">
61
- ${renderEpicCountSummary(selectedEpic)}
62
- <span class="${neutralChipClasses()}">${escapeHtml(searchScope?.summary ?? "Current scope")}</span>
63
- ${store.view === "kanban" ? `<span class="${neutralChipClasses()}">Drag to move</span>` : ""}
64
- ${store.isMutating ? `<span class="${neutralChipClasses()}">Saving…</span>` : ""}
65
- </div>
66
- </div>
67
- </div>
68
- </header>
69
- `;
70
- }
@@ -1,308 +0,0 @@
1
- export const SCROLL_AUTHORITY = Object.freeze({
2
- page: "page",
3
- workspace: "workspace",
4
- inspector: "inspector",
5
- taskModal: "task-modal",
6
- subtaskModal: "subtask-modal",
7
- });
8
-
9
- const SCROLL_OWNER_CONFIG = {
10
- [SCROLL_AUTHORITY.page]: {
11
- containerSelector: ".board-layout",
12
- scrollSelectors: [".board-layout"],
13
- defaultFocusSelectors: ["#board-search-input", "[data-nav-board]", "[data-open-epic]"],
14
- },
15
- [SCROLL_AUTHORITY.workspace]: {
16
- containerSelector: "[data-scroll-surface='workspace']",
17
- scrollSelectors: ["[data-scroll-surface='workspace']"],
18
- defaultFocusSelectors: [
19
- "[data-task-id][aria-pressed='true']",
20
- "[data-task-id]",
21
- "#board-search-input",
22
- "[data-open-epic][aria-current='true']",
23
- "[data-open-epic]",
24
- ],
25
- },
26
- [SCROLL_AUTHORITY.inspector]: {
27
- containerSelector: "[data-scroll-surface='inspector']",
28
- scrollSelectors: ["[data-scroll-surface='inspector']"],
29
- defaultFocusSelectors: ["[data-task-form] [name='title']", "[data-close-task]"],
30
- },
31
- [SCROLL_AUTHORITY.taskModal]: {
32
- containerSelector: "[data-scroll-surface='task-modal']",
33
- scrollSelectors: ["[data-scroll-surface='task-modal']"],
34
- defaultFocusSelectors: [".board-task-modal [data-task-form] [name='title']", ".board-task-modal [data-close-task]"],
35
- },
36
- [SCROLL_AUTHORITY.subtaskModal]: {
37
- containerSelector: "[data-scroll-surface='subtask-modal']",
38
- scrollSelectors: ["[data-scroll-surface='subtask-modal']"],
39
- defaultFocusSelectors: ["[data-subtask-form] [name='title']", "[data-close-subtask]"],
40
- },
41
- };
42
-
43
- function escapeSelector(value) {
44
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
45
- return CSS.escape(value);
46
- }
47
-
48
- return String(value).replaceAll(/(["\\])/g, "\\$1");
49
- }
50
-
51
- function buildAttributeSelector(attribute, value) {
52
- return `[${attribute}="${escapeSelector(value)}"]`;
53
- }
54
-
55
- function captureFieldSelection(element) {
56
- if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
57
- return null;
58
- }
59
-
60
- return {
61
- selectionStart: typeof element.selectionStart === "number" ? element.selectionStart : null,
62
- selectionEnd: typeof element.selectionEnd === "number" ? element.selectionEnd : null,
63
- };
64
- }
65
-
66
- export function resolveFocusSelector(element) {
67
- if (!(element instanceof HTMLElement)) {
68
- return null;
69
- }
70
-
71
- if (element.id) {
72
- return { selector: `#${escapeSelector(element.id)}`, selection: captureFieldSelection(element) };
73
- }
74
-
75
- const formField = element.closest("[data-task-form], [data-subtask-form], [data-create-subtask-form], [data-dependency-form]");
76
- if (formField instanceof HTMLElement && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name) {
77
- const attributeName = Array.from(formField.attributes)
78
- .find((attribute) => attribute.name.startsWith("data-") && attribute.name.endsWith("-form"))?.name;
79
- const attributeValue = attributeName ? formField.getAttribute(attributeName) : null;
80
- if (attributeName && attributeValue) {
81
- return {
82
- selector: `${buildAttributeSelector(attributeName, attributeValue)} [name="${escapeSelector(element.name)}"]`,
83
- selection: captureFieldSelection(element),
84
- };
85
- }
86
- }
87
-
88
- const attributeNames = [
89
- "data-open-epic",
90
- "data-task-id",
91
- "data-open-subtask",
92
- "data-close-task",
93
- "data-close-subtask",
94
- "data-nav",
95
- "data-nav-board",
96
- "data-view",
97
- "data-action",
98
- "data-delete-subtask",
99
- "data-remove-dependency-source",
100
- ];
101
-
102
- for (const attributeName of attributeNames) {
103
- const owner = element.closest(`[${attributeName}]`);
104
- if (!(owner instanceof HTMLElement)) {
105
- continue;
106
- }
107
-
108
- const attributeValue = owner.getAttribute(attributeName);
109
- if (attributeValue === null || attributeValue === "") {
110
- return { selector: `[${attributeName}]`, selection: null };
111
- }
112
-
113
- return { selector: buildAttributeSelector(attributeName, attributeValue), selection: null };
114
- }
115
-
116
- return null;
117
- }
118
-
119
- function captureScrollState(root, owner) {
120
- const scrollSelectors = SCROLL_OWNER_CONFIG[owner]?.scrollSelectors ?? [];
121
- const scrollState = [];
122
-
123
- for (const selector of scrollSelectors) {
124
- const element = root.querySelector(selector);
125
- if (element instanceof HTMLElement) {
126
- scrollState.push({ selector, top: element.scrollTop, left: element.scrollLeft });
127
- }
128
- }
129
-
130
- return scrollState;
131
- }
132
-
133
- function resolveOwnerContainer(root, owner) {
134
- const selector = SCROLL_OWNER_CONFIG[owner]?.containerSelector;
135
- if (!selector) {
136
- return null;
137
- }
138
-
139
- const element = root.querySelector(selector);
140
- return element instanceof HTMLElement ? element : null;
141
- }
142
-
143
- function buildFocusCandidates(owner, runtimeState, focusOverride, fallbackFocusSelectors = []) {
144
- const focusCandidates = [];
145
-
146
- if (focusOverride?.selector) {
147
- focusCandidates.push({ selector: focusOverride.selector, selection: focusOverride.selection ?? null });
148
- }
149
-
150
- if (runtimeState?.focusState?.selector) {
151
- focusCandidates.push({ selector: runtimeState.focusState.selector, selection: runtimeState.focusState.selection ?? null });
152
- }
153
-
154
- for (const selector of fallbackFocusSelectors) {
155
- if (typeof selector === "string" && selector.length > 0) {
156
- focusCandidates.push({ selector, selection: null });
157
- }
158
- }
159
-
160
- for (const selector of SCROLL_OWNER_CONFIG[owner]?.defaultFocusSelectors ?? []) {
161
- focusCandidates.push({ selector, selection: null });
162
- }
163
-
164
- if (owner === SCROLL_AUTHORITY.workspace || owner === SCROLL_AUTHORITY.page) {
165
- if (runtimeState?.fallbackTaskId) {
166
- focusCandidates.push({ selector: buildAttributeSelector("data-task-id", runtimeState.fallbackTaskId), selection: null });
167
- }
168
- if (runtimeState?.fallbackEpicId) {
169
- focusCandidates.push({ selector: buildAttributeSelector("data-open-epic", runtimeState.fallbackEpicId), selection: null });
170
- }
171
- focusCandidates.push({ selector: "#board-search-input", selection: null });
172
- }
173
-
174
- return focusCandidates;
175
- }
176
-
177
- export function resolveScrollAuthorityStack(boardState, options = {}) {
178
- const useTaskModal = options.useTaskModal === true;
179
- if (boardState?.screen !== "tasks") {
180
- return [SCROLL_AUTHORITY.page];
181
- }
182
-
183
- const stack = [SCROLL_AUTHORITY.workspace];
184
- if (boardState?.selectedTask) {
185
- stack.push(useTaskModal ? SCROLL_AUTHORITY.taskModal : SCROLL_AUTHORITY.inspector);
186
- }
187
- if (boardState?.selectedSubtask) {
188
- stack.push(SCROLL_AUTHORITY.subtaskModal);
189
- }
190
- return stack;
191
- }
192
-
193
- export function syncScrollAuthority(root, ownerStack) {
194
- if (!(root instanceof HTMLElement)) {
195
- return null;
196
- }
197
-
198
- const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
199
- root.dataset.scrollOwner = activeOwner;
200
- root.querySelectorAll("[data-scroll-surface]").forEach((element) => {
201
- if (!(element instanceof HTMLElement)) {
202
- return;
203
- }
204
-
205
- element.dataset.scrollActive = element.dataset.scrollSurface === activeOwner ? "true" : "false";
206
- });
207
- return activeOwner;
208
- }
209
-
210
- export function createScrollAuthorityStack(initialStack = [SCROLL_AUTHORITY.page]) {
211
- const runtimeStates = new Map();
212
- const returnFocusStates = new Map();
213
- let ownerStack = [...initialStack];
214
-
215
- return {
216
- capture(root, store) {
217
- const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
218
- const runtimeState = captureRuntimeState(root, store, activeOwner);
219
- if (runtimeState) {
220
- runtimeStates.set(activeOwner, runtimeState);
221
- }
222
- },
223
- rememberReturnFocus(owner, focusState) {
224
- if (!owner || !focusState?.selector) {
225
- return;
226
- }
227
- returnFocusStates.set(owner, focusState);
228
- },
229
- transition(nextOwnerStack) {
230
- ownerStack = Array.isArray(nextOwnerStack) && nextOwnerStack.length > 0
231
- ? [...nextOwnerStack]
232
- : [SCROLL_AUTHORITY.page];
233
- const activeOwner = ownerStack.at(-1) ?? SCROLL_AUTHORITY.page;
234
- return {
235
- owner: activeOwner,
236
- runtimeState: runtimeStates.get(activeOwner) ?? null,
237
- returnFocusState: returnFocusStates.get(activeOwner) ?? null,
238
- };
239
- },
240
- };
241
- }
242
-
243
- export function syncOverlayScrollLock(isLocked) {
244
- document.documentElement.style.overflow = isLocked ? "hidden" : "";
245
- document.body.style.overflow = isLocked ? "hidden" : "";
246
- }
247
-
248
- export function captureRuntimeState(root, store, owner = SCROLL_AUTHORITY.page) {
249
- if (!(root instanceof HTMLElement)) {
250
- return null;
251
- }
252
-
253
- const ownerContainer = resolveOwnerContainer(root, owner);
254
- const activeElement = document.activeElement;
255
- const focusState = ownerContainer instanceof HTMLElement && ownerContainer.contains(activeElement)
256
- ? resolveFocusSelector(activeElement)
257
- : null;
258
-
259
- return {
260
- owner,
261
- focusState,
262
- scrollState: captureScrollState(root, owner),
263
- fallbackTaskId: store?.selectedTaskId ?? null,
264
- fallbackEpicId: store?.selectedEpicId ?? null,
265
- };
266
- }
267
-
268
- export function restoreRuntimeState(root, payload) {
269
- if (!(root instanceof HTMLElement) || !payload) {
270
- return;
271
- }
272
-
273
- const owner = payload.owner ?? payload.runtimeState?.owner ?? SCROLL_AUTHORITY.page;
274
- const runtimeState = payload.runtimeState ?? payload;
275
-
276
- for (const { selector, top, left } of runtimeState.scrollState ?? []) {
277
- const element = root.querySelector(selector);
278
- if (element instanceof HTMLElement) {
279
- element.scrollTop = top;
280
- element.scrollLeft = left;
281
- }
282
- }
283
-
284
- const ownerContainer = resolveOwnerContainer(root, owner) ?? root;
285
- const focusCandidates = buildFocusCandidates(
286
- owner,
287
- runtimeState,
288
- payload.returnFocusState,
289
- payload.fallbackFocusSelectors ?? [],
290
- );
291
-
292
- for (const candidate of focusCandidates) {
293
- const element = root.querySelector(candidate.selector);
294
- if (!(element instanceof HTMLElement)) {
295
- continue;
296
- }
297
- if (ownerContainer instanceof HTMLElement && !ownerContainer.contains(element) && owner !== SCROLL_AUTHORITY.page) {
298
- continue;
299
- }
300
-
301
- element.focus({ preventScroll: true });
302
- const selection = candidate.selection;
303
- if (selection && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && selection.selectionStart !== null) {
304
- element.setSelectionRange(selection.selectionStart, selection.selectionEnd ?? selection.selectionStart);
305
- }
306
- break;
307
- }
308
- }