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.
Files changed (45) hide show
  1. package/README.md +60 -0
  2. package/docs/commands.md +100 -0
  3. package/docs/quickstart.md +74 -1
  4. package/package.json +2 -1
  5. package/src/board/assets/app.js +589 -0
  6. package/src/board/assets/components/ClampedText.js +31 -0
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +64 -0
  10. package/src/board/assets/components/EpicsOverview.js +80 -0
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +80 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +308 -0
  18. package/src/board/assets/components/assetMap.js +80 -0
  19. package/src/board/assets/components/helpers.js +244 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +39 -0
  23. package/src/board/assets/main.js +11 -0
  24. package/src/board/assets/manifest.json +12 -0
  25. package/src/board/assets/runtime/delegation.js +309 -0
  26. package/src/board/assets/state/actions.js +454 -0
  27. package/src/board/assets/state/api.js +281 -0
  28. package/src/board/assets/state/store.js +472 -0
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +1811 -0
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/install.ts +196 -0
  34. package/src/board/open-browser.ts +131 -0
  35. package/src/board/routes.ts +308 -0
  36. package/src/board/server.ts +185 -0
  37. package/src/board/snapshot.ts +277 -0
  38. package/src/board/types.ts +43 -0
  39. package/src/commands/board.ts +158 -0
  40. package/src/commands/help.ts +21 -0
  41. package/src/commands/init.ts +29 -0
  42. package/src/domain/mutation-service.ts +40 -0
  43. package/src/domain/tracker-domain.ts +11 -3
  44. package/src/runtime/cli-shell.ts +5 -0
  45. package/src/storage/path.ts +36 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Base component utilities for the board UI.
3
+ *
4
+ * Each component is a factory function returning { mount, update, unmount }.
5
+ * - mount(container) binds the component to a DOM element.
6
+ * - update(props) renders or patches DOM based on new props.
7
+ * - unmount() cleans up and removes content.
8
+ */
9
+
10
+ /**
11
+ * Save and restore the value + cursor of an input or textarea across a DOM write.
12
+ *
13
+ * @param {HTMLElement} container
14
+ * @param {string} selector CSS selector for the input element
15
+ * @param {() => void} writeFn Function that mutates the DOM
16
+ */
17
+ export function preserveInput(container, selector, writeFn) {
18
+ const input = container.querySelector(selector);
19
+ const state = input
20
+ ? {
21
+ value: input.value,
22
+ selectionStart: input.selectionStart,
23
+ selectionEnd: input.selectionEnd,
24
+ focused: document.activeElement === input,
25
+ }
26
+ : null;
27
+
28
+ writeFn();
29
+
30
+ if (state) {
31
+ const restored = container.querySelector(selector);
32
+ if (restored) {
33
+ restored.value = state.value;
34
+ if (state.focused) {
35
+ restored.focus({ preventScroll: true });
36
+ try {
37
+ restored.setSelectionRange(state.selectionStart, state.selectionEnd);
38
+ } catch {
39
+ // setSelectionRange not supported on all input types
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ const FORM_ROOT_SELECTOR = [
47
+ "form",
48
+ "[data-form-id]",
49
+ "[data-task-form]",
50
+ "[data-subtask-form]",
51
+ "[data-create-subtask-form]",
52
+ "[data-dependency-form]",
53
+ ].join(", ");
54
+
55
+ function getFormRoot(el) {
56
+ if (!el) return null;
57
+ return el.matches(FORM_ROOT_SELECTOR) ? el : el.closest(FORM_ROOT_SELECTOR);
58
+ }
59
+
60
+ function getNamespacedFormIdentity(form) {
61
+ if (!form) return "default-form";
62
+ if (form.hasAttribute("data-form-id")) {
63
+ return `form:${form.getAttribute("data-form-id")}`;
64
+ }
65
+ if (form.hasAttribute("data-task-form")) {
66
+ return `task:${form.getAttribute("data-task-form")}`;
67
+ }
68
+ if (form.hasAttribute("data-subtask-form")) {
69
+ return `subtask:${form.getAttribute("data-subtask-form")}`;
70
+ }
71
+ if (form.hasAttribute("data-create-subtask-form")) {
72
+ return `create-subtask:${form.getAttribute("data-create-subtask-form")}`;
73
+ }
74
+ if (form.hasAttribute("data-dependency-form")) {
75
+ return `dependency:${form.getAttribute("data-dependency-form")}`;
76
+ }
77
+ if (form.id) {
78
+ return `id:${form.id}`;
79
+ }
80
+ return "anonymous-form";
81
+ }
82
+
83
+ function getManagedControls(root) {
84
+ return Array.from(root.querySelectorAll("input, textarea, select"));
85
+ }
86
+
87
+ function getControlIdentity(el, form) {
88
+ const controlKey = el.getAttribute("data-control-id");
89
+ if (controlKey) {
90
+ return `control:${controlKey}`;
91
+ }
92
+
93
+ if (el.id) {
94
+ return `id:${el.id}`;
95
+ }
96
+
97
+ const name = el.getAttribute("name");
98
+ if (name) {
99
+ const tagName = el.tagName.toLowerCase();
100
+ const type = tagName === "input" ? (el.getAttribute("type") ?? "text") : tagName;
101
+ const peers = getManagedControls(form).filter((candidate) => candidate.getAttribute("name") === name);
102
+ const index = peers.indexOf(el);
103
+ return `name:${name}:${type}:${index}`;
104
+ }
105
+
106
+ const index = getManagedControls(form).indexOf(el);
107
+ return `index:${index}:${el.tagName.toLowerCase()}`;
108
+ }
109
+
110
+ function captureSelection(el) {
111
+ if (typeof el.selectionStart !== "number") {
112
+ return { selectionStart: null, selectionEnd: null };
113
+ }
114
+
115
+ return {
116
+ selectionStart: el.selectionStart,
117
+ selectionEnd: el.selectionEnd,
118
+ };
119
+ }
120
+
121
+ function restoreSelection(el, selectionStart, selectionEnd) {
122
+ if (typeof selectionStart !== "number") {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ el.setSelectionRange(selectionStart, selectionEnd ?? selectionStart);
128
+ } catch {
129
+ // setSelectionRange is not supported on all controls
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Capture the full state of every form input inside a container, execute a DOM
135
+ * write, then restore all captured values and focus.
136
+ *
137
+ * It uses a hierarchical identity (closest [data-form-id] or [data-task-form] etc.
138
+ * plus the input name/id) to ensure correct restoration even when multiple
139
+ * forms with similar field names exist in the same container.
140
+ *
141
+ * @param {HTMLElement} container
142
+ * @param {() => void} writeFn
143
+ */
144
+ export function preserveFormState(container, writeFn, options = {}) {
145
+ const resetFormIds = new Set(options.resetFormIds ?? []);
146
+ const inputs = getManagedControls(container);
147
+
148
+ const activeElement = document.activeElement;
149
+ let focusedIdentity = null;
150
+
151
+ const savedStates = inputs.map((el) => {
152
+ const form = getFormRoot(el);
153
+ const formId = getNamespacedFormIdentity(form);
154
+ const controlId = form ? getControlIdentity(el, form) : null;
155
+ const identity = controlId ? { formId, controlId } : null;
156
+
157
+ if (activeElement === el) {
158
+ focusedIdentity = identity;
159
+ }
160
+
161
+ const { selectionStart, selectionEnd } = captureSelection(el);
162
+
163
+ return {
164
+ identity,
165
+ value: el.value,
166
+ selectionStart,
167
+ selectionEnd,
168
+ };
169
+ }).filter(s => s.identity);
170
+
171
+ writeFn();
172
+
173
+ const formsByIdentity = new Map(
174
+ Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
175
+ getNamespacedFormIdentity(form),
176
+ form,
177
+ ]),
178
+ );
179
+
180
+ for (const state of savedStates) {
181
+ const { formId, controlId } = state.identity;
182
+ if (resetFormIds.has(formId)) {
183
+ continue;
184
+ }
185
+ const form = formsByIdentity.get(formId) ?? container;
186
+ const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
187
+ if (restored && restored.value !== state.value) {
188
+ restored.value = state.value;
189
+ }
190
+
191
+ if (restored && activeElement === container.ownerDocument?.body) {
192
+ restoreSelection(restored, state.selectionStart, state.selectionEnd);
193
+ }
194
+ }
195
+
196
+ if (focusedIdentity) {
197
+ const { formId, controlId } = focusedIdentity;
198
+ if (resetFormIds.has(formId)) {
199
+ return;
200
+ }
201
+ const form = formsByIdentity.get(formId) ?? container;
202
+ const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
203
+ if (restored) {
204
+ restored.focus({ preventScroll: true });
205
+ const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
206
+ if (focusedState) {
207
+ restoreSelection(restored, focusedState.selectionStart, focusedState.selectionEnd);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Capture open/closed state of all `<details>` elements and restore after a DOM write.
215
+ *
216
+ * @param {HTMLElement} container
217
+ * @param {() => void} writeFn
218
+ */
219
+ export function preserveDetailsState(container, writeFn) {
220
+ const details = Array.from(container.querySelectorAll("details"));
221
+ const openStates = details.map((el, i) => ({
222
+ index: i,
223
+ open: el.open,
224
+ }));
225
+
226
+ writeFn();
227
+
228
+ const newDetails = Array.from(container.querySelectorAll("details"));
229
+ for (const state of openStates) {
230
+ const target = newDetails[state.index];
231
+ if (target && target.open !== state.open) {
232
+ target.open = state.open;
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Create a simple component from a render function.
239
+ * Replaces innerHTML on every update. Suitable for components that don't
240
+ * contain user-editable inputs.
241
+ *
242
+ * @param {(props: object) => string} renderFn
243
+ * @returns {{ mount: (el: HTMLElement) => object, update: (props: object) => void, unmount: () => void }}
244
+ */
245
+ export function createSimpleComponent(renderFn) {
246
+ let container = null;
247
+ let lastHtml = null;
248
+
249
+ const component = {
250
+ mount(el) {
251
+ container = el;
252
+ lastHtml = null;
253
+ return component;
254
+ },
255
+ update(props) {
256
+ if (!container) return;
257
+ const html = renderFn(props);
258
+ if (html !== lastHtml) {
259
+ container.innerHTML = html;
260
+ lastHtml = html;
261
+ }
262
+ },
263
+ unmount() {
264
+ if (container) container.innerHTML = "";
265
+ container = null;
266
+ lastHtml = null;
267
+ },
268
+ };
269
+
270
+ return component;
271
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ buttonClasses,
3
+ escapeHtml,
4
+ panelClasses,
5
+ renderIcon,
6
+ sectionLabelClasses,
7
+ } from "./helpers.js";
8
+
9
+ /**
10
+ * ConfirmDialog component — modal for destructive actions.
11
+ * Shows confirmation with cancel/confirm buttons before executing.
12
+ */
13
+ export function createConfirmDialog() {
14
+ let container = null;
15
+ let lastProps = null;
16
+
17
+ return {
18
+ mount(el) {
19
+ container = el;
20
+ return this;
21
+ },
22
+
23
+ /**
24
+ * @param {{
25
+ * open: boolean,
26
+ * title?: string,
27
+ * message?: string,
28
+ * confirmLabel?: string,
29
+ * cancelLabel?: string,
30
+ * tone?: string,
31
+ * } | null} props
32
+ */
33
+ update(props) {
34
+ if (!container) return;
35
+
36
+ if (!props || !props.open) {
37
+ if (lastProps?.open) {
38
+ container.innerHTML = "";
39
+ }
40
+ lastProps = props;
41
+ return;
42
+ }
43
+
44
+ const {
45
+ title = "Confirm action",
46
+ message = "This action cannot be undone. Are you sure?",
47
+ confirmLabel = "Confirm",
48
+ cancelLabel = "Cancel",
49
+ tone = "destructive",
50
+ } = props;
51
+
52
+ container.innerHTML = `
53
+ <div class="board-confirm-backdrop fixed inset-0 z-50 grid place-items-center bg-slate-950/70 p-4 backdrop-blur-md" data-close-confirm role="presentation">
54
+ <section class="${panelClasses("w-full max-w-md p-6")}" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-desc" data-overlay-root tabindex="-1">
55
+ <div class="flex items-start gap-4">
56
+ <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
57
+ ${renderIcon("warning", "text-[20px]")}
58
+ </div>
59
+ <div class="min-w-0">
60
+ <p class="${sectionLabelClasses()}">Destructive action</p>
61
+ <h3 id="confirm-dialog-title" class="mt-2 text-lg font-semibold text-[var(--board-text)]">${escapeHtml(title)}</h3>
62
+ <p id="confirm-dialog-desc" class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(message)}</p>
63
+ </div>
64
+ </div>
65
+ <div class="mt-6 flex justify-end gap-3">
66
+ <button type="button" class="${buttonClasses()}" data-action="cancel-delete" data-overlay-initial-focus>${escapeHtml(cancelLabel)}</button>
67
+ <button type="button" class="${buttonClasses({ kind: "primary" })} ${tone === "destructive" ? "board-button--destructive" : ""}" data-action="confirm-delete">${escapeHtml(confirmLabel)}</button>
68
+ </div>
69
+ </section>
70
+ </div>
71
+ `;
72
+ lastProps = props;
73
+ },
74
+
75
+ unmount() {
76
+ if (container) container.innerHTML = "";
77
+ container = null;
78
+ lastProps = null;
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,64 @@
1
+ import {
2
+ escapeHtml,
3
+ formatDate,
4
+ neutralChipClasses,
5
+ renderIcon,
6
+ renderStatusBadge,
7
+ } from "./helpers.js";
8
+
9
+ /**
10
+ * Render a single epic row for the overview table.
11
+ *
12
+ * @param {object} props
13
+ * @param {object} props.epic
14
+ * @param {boolean} [props.selected]
15
+ * @returns {string}
16
+ */
17
+ export function renderEpicRow(props) {
18
+ const { epic, selected = false } = props;
19
+
20
+ const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
21
+ const counts = epic.counts || { blocked: 0, done: 0, in_progress: 0 };
22
+ const statusLabel = String(epic.status ?? "todo").replace(/_/g, " ");
23
+ const openLabel = `Open epic ${epic.title}`;
24
+ const tooltip = `${openLabel}. ${totalTasks} task${totalTasks === 1 ? "" : "s"}.`;
25
+ const descriptionMarkup = epic.description?.trim()
26
+ ? `<p class="board-epic-row__description text-sm text-[var(--board-text-muted)] board-clamped-text__preview board-clamped-text__preview--2">${escapeHtml(epic.description.trim())}</p>`
27
+ : "";
28
+
29
+ return `
30
+ <button
31
+ type="button"
32
+ class="board-epic-row ${selected ? "board-epic-row--selected" : ""}"
33
+ aria-current="${selected}"
34
+ aria-label="${escapeHtml(`${openLabel}. ${totalTasks} tasks. Status ${statusLabel}.`)}"
35
+ title="${escapeHtml(tooltip)}"
36
+ data-open-epic="${escapeHtml(epic.id)}"
37
+ >
38
+ <span class="board-epic-row__summary">
39
+ <span class="board-epic-row__title-row">
40
+ <span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
41
+ <strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
42
+ </span>
43
+ ${descriptionMarkup}
44
+ </span>
45
+ <span class="board-epic-row__status">${renderStatusBadge(epic.status ?? "todo")}</span>
46
+ <span class="board-epic-row__counts" aria-label="Epic progress counts">
47
+ <span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
48
+ <span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
49
+ <span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
50
+ ${(counts.blocked ?? 0) > 0 ? `<span class="${neutralChipClasses()}">${counts.blocked} blocked</span>` : ""}
51
+ </span>
52
+ <span class="board-epic-row__updated">
53
+ <span class="board-epic-row__label">Updated</span>
54
+ <span>${escapeHtml(formatDate(epic.updatedAt))}</span>
55
+ </span>
56
+ <span class="board-epic-row__action-wrap" aria-hidden="true">
57
+ <span class="board-epic-row__action">
58
+ <span>View</span>
59
+ ${renderIcon("chevron_right", "text-[16px]")}
60
+ </span>
61
+ </span>
62
+ </button>
63
+ `;
64
+ }
@@ -0,0 +1,80 @@
1
+ import { renderEpicRow } from "./EpicRow.js";
2
+ import {
3
+ panelClasses,
4
+ renderEmptyState,
5
+ sectionLabelClasses,
6
+ } from "./helpers.js";
7
+
8
+ /**
9
+ * Render the epics overview HTML.
10
+ *
11
+ * @param {object} props
12
+ * @param {object[]} props.visibleEpics
13
+ * @param {string|null} props.selectedEpicId
14
+ * @param {{ snapshot: object, isMutating: boolean }} props.store
15
+ * @returns {string}
16
+ */
17
+ function render(props) {
18
+ const { visibleEpics, selectedEpicId, store } = props;
19
+
20
+ return `
21
+ <div class="board-root board-root--epics">
22
+ <section class="board-overview ${panelClasses("board-overview--dense p-4 sm:p-5")}" aria-label="Epics overview">
23
+ <header class="board-section-head board-overview__header">
24
+ <div>
25
+ <span class="${sectionLabelClasses()}">Epics overview</span>
26
+ <h2 class="board-overview__title">Open an initiative and drive the next move</h2>
27
+ <p class="board-overview__summary">Each card is the entry point, so status, task counts, and freshness stay visible at a glance.</p>
28
+ </div>
29
+ <div class="board-legend board-overview__legend">
30
+ <span class="board-chip board-chip--neutral">${visibleEpics.length} visible epic${visibleEpics.length === 1 ? "" : "s"}</span>
31
+ <span class="board-chip board-chip--neutral">${store.snapshot.tasks.length} total tasks</span>
32
+ ${store.isMutating ? '<span class="board-chip board-chip--neutral">Saving\u2026</span>' : ""}
33
+ </div>
34
+ </header>
35
+ <div class="board-table board-table--epics">
36
+ <div class="board-table__header board-table__header--epics hidden md:grid">
37
+ <span>Epic</span>
38
+ <span>Status</span>
39
+ <span>Counts</span>
40
+ <span>Updated</span>
41
+ <span>Action</span>
42
+ </div>
43
+ <div class="board-table__rows board-table__rows--epics">
44
+ ${visibleEpics.length === 0
45
+ ? renderEmptyState("No matching epics", "Try a different search or publish more work to the board.", "/")
46
+ : visibleEpics.map((epic) => renderEpicRow({ epic, selected: selectedEpicId === epic.id })).join("")}
47
+ </div>
48
+ </div>
49
+ </section>
50
+ </div>
51
+ `;
52
+ }
53
+
54
+ /**
55
+ * EpicsOverview component with mount/update/unmount lifecycle.
56
+ */
57
+ export function createEpicsOverview() {
58
+ let container = null;
59
+ let lastHtml = null;
60
+
61
+ return {
62
+ mount(el) {
63
+ container = el;
64
+ return this;
65
+ },
66
+ update(props) {
67
+ if (!container) return;
68
+ const html = render(props);
69
+ if (html !== lastHtml) {
70
+ container.innerHTML = html;
71
+ lastHtml = html;
72
+ }
73
+ },
74
+ unmount() {
75
+ if (container) container.innerHTML = "";
76
+ container = null;
77
+ lastHtml = null;
78
+ },
79
+ };
80
+ }