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,99 @@
1
+ import { preserveFormState, preserveDetailsState } from "./Component.js";
2
+ import { renderTaskSurface } from "./Inspector.js";
3
+ import {
4
+ isCompactViewport,
5
+ panelClasses,
6
+ } from "./helpers.js";
7
+
8
+ /**
9
+ * TaskModal component — full-screen backdrop modal for task detail.
10
+ * Preserves form state across updates within the same task.
11
+ */
12
+ export function createTaskModal() {
13
+ let container = null;
14
+ let currentTaskId = null;
15
+ let previousTask = null;
16
+
17
+ function getResetFormIds(nextTask) {
18
+ if (!previousTask || previousTask.id !== nextTask.id) {
19
+ return [];
20
+ }
21
+
22
+ const resetFormIds = [];
23
+ if (nextTask.subtasks.length > previousTask.subtasks.length) {
24
+ resetFormIds.push(`form:task-create-subtask:${nextTask.id}`);
25
+ }
26
+ if (nextTask.blockedBy.length > previousTask.blockedBy.length) {
27
+ resetFormIds.push(`form:task-dependency:${nextTask.id}`);
28
+ }
29
+ return resetFormIds;
30
+ }
31
+
32
+ function render(props) {
33
+ const { task, epics, snapshot, isMutating = false } = props;
34
+ const compact = isCompactViewport();
35
+
36
+ const surfaceOptions = {
37
+ titleId: "board-task-modal-title",
38
+ closeLabel: compact ? "Back to board" : "Close",
39
+ containerClassName: "board-detail-surface board-detail-surface--modal",
40
+ detailEyebrow: compact ? "Task focus mode" : "Task detail",
41
+ scrollSurface: "task-modal",
42
+ };
43
+
44
+ return `
45
+ <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>
46
+ <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" data-overlay-root tabindex="-1">
47
+ <div class="h-full min-h-0">
48
+ ${renderTaskSurface({ task, epics, snapshot, isMutating, options: surfaceOptions })}
49
+ </div>
50
+ </section>
51
+ </div>
52
+ `;
53
+ }
54
+
55
+ return {
56
+ mount(el) {
57
+ container = el;
58
+ return this;
59
+ },
60
+
61
+ /**
62
+ * @param {{ task: object|null, epics: object[], snapshot: object, isMutating: boolean } | null} props
63
+ */
64
+ update(props) {
65
+ if (!container) return;
66
+
67
+ if (!props || !props.task) {
68
+ if (currentTaskId) {
69
+ container.innerHTML = "";
70
+ currentTaskId = null;
71
+ previousTask = null;
72
+ }
73
+ return;
74
+ }
75
+
76
+ const resetFormIds = getResetFormIds(props.task);
77
+
78
+ if (currentTaskId === props.task.id) {
79
+ preserveDetailsState(container, () => {
80
+ preserveFormState(container, () => {
81
+ container.innerHTML = render(props);
82
+ }, { resetFormIds });
83
+ });
84
+ } else {
85
+ container.innerHTML = render(props);
86
+ currentTaskId = props.task.id;
87
+ }
88
+
89
+ previousTask = props.task;
90
+ },
91
+
92
+ unmount() {
93
+ if (container) container.innerHTML = "";
94
+ container = null;
95
+ currentTaskId = null;
96
+ previousTask = null;
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,167 @@
1
+ import { preserveDetailsState, preserveInput } from "./Component.js";
2
+ import {
3
+ escapeHtml,
4
+ renderIcon,
5
+ } from "./helpers.js";
6
+
7
+ /**
8
+ * Render the topbar HTML.
9
+ * @param {object} props
10
+ * @returns {string}
11
+ */
12
+ function render(props) {
13
+ const {
14
+ currentNav,
15
+ screen,
16
+ search,
17
+ searchScope,
18
+ selectedEpic,
19
+ theme,
20
+ isMutating,
21
+ } = props;
22
+
23
+ const navItems = [
24
+ {
25
+ id: "epics",
26
+ label: "Epics",
27
+ icon: "layers",
28
+ action: 'data-nav="epics"',
29
+ tooltip: "Open the epic list and overview.",
30
+ },
31
+ {
32
+ id: "board",
33
+ label: "Board",
34
+ icon: "view_kanban",
35
+ action: 'data-nav-board="true"',
36
+ tooltip: selectedEpic
37
+ ? "Open the selected epic board."
38
+ : "Open the newest epic board.",
39
+ },
40
+ ];
41
+
42
+ const navMarkup = navItems.map((item) => {
43
+ const isActive = currentNav === item.id;
44
+ const classes = [
45
+ "board-shell-topbar__nav-item",
46
+ isActive ? "is-active" : "",
47
+ ].filter(Boolean).join(" ");
48
+
49
+ return `
50
+ <button type="button" class="${classes}" ${item.action} aria-current="${isActive ? "page" : "false"}" aria-label="${escapeHtml(item.label)} view" title="${escapeHtml(item.tooltip)}">
51
+ ${renderIcon(item.icon, "text-[16px]")} <span>${escapeHtml(item.label)}</span>
52
+ </button>
53
+ `;
54
+ }).join("");
55
+
56
+ const epicContext = selectedEpic
57
+ ? escapeHtml(selectedEpic.title)
58
+ : escapeHtml(searchScope?.summary ?? "No epic selected");
59
+ const currentScope = selectedEpic?.title ?? searchScope?.summary ?? "Epic overview";
60
+ const scopeIntro = selectedEpic
61
+ ? "This workspace is currently focused on the selected epic below."
62
+ : "This workspace is currently showing the broader board scope below.";
63
+
64
+ return `
65
+ <header class="board-shell-topbar ${screen === "tasks" ? "board-shell-topbar--workspace" : ""}">
66
+ <div class="board-shell-topbar__identity">
67
+ <div class="board-shell-topbar__brand-mark" aria-hidden="true">
68
+ ${renderIcon("rocket_launch", "text-[18px]")}
69
+ </div>
70
+ <div class="min-w-0">
71
+ <div class="board-shell-topbar__title-row">
72
+ <h1>Trekoon</h1>
73
+ </div>
74
+ <p class="board-shell-topbar__context">${epicContext}</p>
75
+ </div>
76
+ </div>
77
+
78
+ <nav class="board-shell-topbar__nav" aria-label="Board sections">
79
+ ${navMarkup}
80
+ </nav>
81
+
82
+ <div class="board-shell-topbar__tools">
83
+ <label class="board-shell-topbar__search" for="board-search-input">
84
+ <span class="board-shell-topbar__search-label">Search board</span>
85
+ ${renderIcon("search", "text-[16px] text-[var(--board-text-soft)]")}
86
+ <input id="board-search-input" type="search" autocomplete="off" placeholder="Search epics, tasks, subtasks\u2026" value="${escapeHtml(search)}" aria-describedby="board-search-shortcut board-search-scope" />
87
+ <span class="board-shell-topbar__search-kbd">/</span>
88
+ </label>
89
+ <span id="board-search-scope" class="board-shell-topbar__assistive-copy">${escapeHtml(searchScope?.detail ?? "Search across epics, tasks, and subtasks.")}</span>
90
+ <span id="board-search-shortcut" class="board-shell-topbar__assistive-copy">Press slash to focus search. Press Escape to clear search before navigating away.</span>
91
+ <div class="board-shell-topbar__actions">
92
+ <button type="button" class="board-shell-topbar__icon-btn" data-action="toggle-theme" aria-label="Switch to ${theme === "dark" ? "light" : "dark"} theme" title="Switch to ${theme === "dark" ? "light" : "dark"} theme" ${isMutating ? "disabled" : ""}>
93
+ ${renderIcon(theme === "dark" ? "light_mode" : "dark_mode", "text-[16px]")}
94
+ </button>
95
+ <details class="board-shell-topbar__meta">
96
+ <summary aria-label="Board information">${renderIcon("info", "text-[16px]")}</summary>
97
+ <div>
98
+ <h3 class="board-shell-topbar__meta-title">How this board stores data</h3>
99
+ <div class="board-shell-topbar__meta-section">
100
+ <strong>Repository-backed board data</strong>
101
+ <p>Epics, tasks, and status changes are backed by files in this repository. When you update board content here, you are updating project state that belongs to this repo.</p>
102
+ </div>
103
+ <div class="board-shell-topbar__meta-section">
104
+ <strong>Workspace-local preferences</strong>
105
+ <p>UI preferences such as theme, selected view, and similar workspace settings are stored only in this local workspace. They are not written into the repository and are not shared automatically with other clones or teammates.</p>
106
+ </div>
107
+ <div class="board-shell-topbar__meta-section">
108
+ <strong>Current scope</strong>
109
+ <p>${escapeHtml(scopeIntro)}</p>
110
+ <p class="board-shell-topbar__meta-scope">${escapeHtml(currentScope)}</p>
111
+ </div>
112
+ </div>
113
+ </details>
114
+ </div>
115
+ </div>
116
+ </header>
117
+ `;
118
+ }
119
+
120
+ /**
121
+ * TopBar component — manages search input, preserves value/cursor on update.
122
+ */
123
+ export function createTopBar() {
124
+ let container = null;
125
+ let lastProps = null;
126
+
127
+ return {
128
+ mount(el) {
129
+ container = el;
130
+ return this;
131
+ },
132
+
133
+ update(props) {
134
+ if (!container) return;
135
+
136
+ // On first render, just set innerHTML
137
+ if (!lastProps) {
138
+ container.innerHTML = render(props);
139
+ lastProps = props;
140
+ return;
141
+ }
142
+
143
+ if (props.search !== lastProps.search) {
144
+ preserveDetailsState(container, () => {
145
+ container.innerHTML = render(props);
146
+ });
147
+ lastProps = props;
148
+ return;
149
+ }
150
+
151
+ // Preserve search input value and cursor across re-renders
152
+ preserveDetailsState(container, () => {
153
+ preserveInput(container, "#board-search-input", () => {
154
+ container.innerHTML = render(props);
155
+ });
156
+ });
157
+
158
+ lastProps = props;
159
+ },
160
+
161
+ unmount() {
162
+ if (container) container.innerHTML = "";
163
+ container = null;
164
+ lastProps = null;
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,308 @@
1
+ import { renderTaskCard } from "./TaskCard.js";
2
+ import {
3
+ cx,
4
+ escapeHtml,
5
+ fieldClasses,
6
+ formatDate,
7
+ hasLongTaskTitle,
8
+ neutralChipClasses,
9
+ panelClasses,
10
+ readStatusLabel,
11
+ renderEpicCountSummary,
12
+ renderEmptyState,
13
+ renderIcon,
14
+ renderStatusBadge,
15
+ renderTaskMeta,
16
+ secondaryPanelClasses,
17
+ sectionLabelClasses,
18
+ STATUS_LABELS,
19
+ STATUS_ORDER,
20
+ } from "./helpers.js";
21
+ import { orderEpicsNewestFirst } from "../state/store.js";
22
+ import { VIEW_MODES } from "../state/utils.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Workspace header
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function renderViewModeIcon(viewId) {
29
+ if (viewId === "kanban") {
30
+ return `
31
+ <svg class="board-view-switch__icon" aria-hidden="true" viewBox="0 0 16 16" fill="none">
32
+ <rect x="2.25" y="3" width="3" height="10" rx="1.1" fill="currentColor"></rect>
33
+ <rect x="6.5" y="5" width="3" height="8" rx="1.1" fill="currentColor" opacity="0.92"></rect>
34
+ <rect x="10.75" y="4" width="3" height="9" rx="1.1" fill="currentColor" opacity="0.8"></rect>
35
+ </svg>
36
+ `;
37
+ }
38
+
39
+ return `
40
+ <svg class="board-view-switch__icon" aria-hidden="true" viewBox="0 0 16 16" fill="none">
41
+ <circle cx="3" cy="4" r="1" fill="currentColor"></circle>
42
+ <circle cx="3" cy="8" r="1" fill="currentColor"></circle>
43
+ <circle cx="3" cy="12" r="1" fill="currentColor"></circle>
44
+ <path d="M6 4H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
45
+ <path d="M6 8H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
46
+ <path d="M6 12H13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
47
+ </svg>
48
+ `;
49
+ }
50
+
51
+ function renderWorkspaceHeader(props) {
52
+ const {
53
+ selectedEpic,
54
+ snapshotEpics,
55
+ store,
56
+ visibleTasks,
57
+ } = props;
58
+
59
+ const description = selectedEpic.description?.trim() || "";
60
+ const inlineSelect = `${fieldClasses()} !py-1 !px-2 !text-xs !min-h-0 !rounded-xl`;
61
+ const epicStatusTooltip = "Change this epic's overall status.";
62
+ const epicSelectTooltip = "Switch the workspace to a different epic.";
63
+ const bulkStatusTooltip = "Set the same status for every task in this epic.";
64
+ const notesTooltip = "Show or hide this epic's description.";
65
+ const notesPanelId = `board-notes-panel-${selectedEpic.id}`;
66
+ const orderedEpics = orderEpicsNewestFirst(snapshotEpics);
67
+
68
+ return `
69
+ <header class="board-workspace-header">
70
+ <div class="board-wh__row-1">
71
+ <h2 class="board-wh__title">${escapeHtml(selectedEpic.title)}</h2>
72
+ <div class="board-wh__controls">
73
+ <form class="inline-flex" data-epic-status-form="${escapeHtml(selectedEpic.id)}">
74
+ <label class="board-wh__control-label">
75
+ <span class="board-wh__control-text">Epic status</span>
76
+ <select class="${inlineSelect}" name="status" aria-label="Epic status" title="${escapeHtml(epicStatusTooltip)}" ${store.isMutating ? "disabled" : ""}>
77
+ ${STATUS_ORDER.map(s => `<option value="${escapeHtml(s)}" ${selectedEpic.status === s ? 'selected' : ''}>${escapeHtml(STATUS_LABELS[s] ?? s)}</option>`).join('')}
78
+ </select>
79
+ </label>
80
+ </form>
81
+ <span class="board-wh__sep" aria-hidden="true"></span>
82
+ <label class="board-wh__control-label board-wh__inline-label" for="board-epic-select">
83
+ <span class="board-wh__control-text">Epic</span>
84
+ <select class="${inlineSelect}" id="board-epic-select" title="${escapeHtml(epicSelectTooltip)}" aria-label="Choose epic" ${store.isMutating ? "disabled" : ""}>
85
+ ${orderedEpics.map((epic) => `
86
+ <option value="${escapeHtml(epic.id)}" ${store.selectedEpicId === epic.id ? "selected" : ""}>
87
+ ${escapeHtml(epic.title)}
88
+ </option>
89
+ `).join("")}
90
+ </select>
91
+ </label>
92
+ <span class="board-wh__sep" aria-hidden="true"></span>
93
+ <form class="inline-flex" data-bulk-status-form="${escapeHtml(selectedEpic.id)}">
94
+ <label class="board-wh__control-label">
95
+ <span class="board-wh__control-text">Bulk status</span>
96
+ <select class="${inlineSelect}" name="status" aria-label="Set all tasks to status" title="${escapeHtml(bulkStatusTooltip)}" ${store.isMutating ? "disabled" : ""}>
97
+ <option value="">Set all\u2026</option>
98
+ ${STATUS_ORDER.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(STATUS_LABELS[s] ?? s)}</option>`).join('')}
99
+ </select>
100
+ </label>
101
+ </form>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="board-wh__row-2">
106
+ <div class="board-wh__meta">
107
+ ${renderEpicCountSummary(selectedEpic)}
108
+ <span class="${neutralChipClasses()}">${visibleTasks.length} visible</span>
109
+ ${store.isMutating ? `<span class="${neutralChipClasses()}">Saving\u2026</span>` : ""}
110
+ </div>
111
+ <div class="board-wh__actions">
112
+ <div class="board-wh__action-group">
113
+ ${description ? `
114
+ <button type="button" class="board-wh__notes-btn ${store.notesPanelOpen ? "board-wh__notes-btn--active" : ""}" data-toggle-notes aria-label="Toggle epic notes" aria-expanded="${store.notesPanelOpen}" aria-controls="${escapeHtml(notesPanelId)}" title="${escapeHtml(notesTooltip)}">
115
+ ${renderIcon("subject", "text-[16px]")}
116
+ <span>Description</span>
117
+ </button>
118
+ ` : ""}
119
+ <div class="board-view-switch" role="group" aria-label="Board views">
120
+ ${store.viewModes.map((view) => {
121
+ const icon = renderViewModeIcon(view.id);
122
+ return `<button class="${cx(
123
+ "board-view-switch__tab",
124
+ view.active
125
+ ? "board-view-switch__tab--active"
126
+ : "",
127
+ )}" type="button" aria-label="${escapeHtml(view.label)} view" aria-pressed="${view.active}" data-view="${view.id}" title="${escapeHtml(`Switch to the ${view.label.toLowerCase()} view.`)}" ${store.isMutating ? "disabled" : ""}>${icon}<span class="board-view-switch__label">${escapeHtml(view.label)}</span></button>`;
128
+ }).join("")}
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ ${description ? `
135
+ <div class="board-wh__notes-panel" id="${escapeHtml(notesPanelId)}" data-notes-panel ${store.notesPanelOpen ? "" : "hidden"}>
136
+ <div class="board-wh__notes-body">${escapeHtml(description)}</div>
137
+ </div>
138
+ ` : ""}
139
+ </header>
140
+ `;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Kanban columns
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function renderKanbanColumns(props) {
148
+ const { visibleTasks, selectedTaskId, isMutating } = props;
149
+
150
+ const columnsMarkup = STATUS_ORDER.map((status) => {
151
+ const columnTasks = visibleTasks.filter((t) => t.status === status);
152
+ const columnTitle = readStatusLabel(status);
153
+ const content = columnTasks.length === 0
154
+ ? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
155
+ : columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
156
+
157
+ return `
158
+ <section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
159
+ <header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
160
+ <div class="min-w-0">
161
+ <p class="${sectionLabelClasses()}">${escapeHtml(columnTitle)}</p>
162
+ <div class="mt-2 flex flex-wrap items-center gap-2">
163
+ ${renderStatusBadge(status)}
164
+ <span class="${neutralChipClasses()}">${columnTasks.length} item${columnTasks.length === 1 ? "" : "s"}</span>
165
+ </div>
166
+ </div>
167
+ ${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
168
+ </header>
169
+ <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
170
+ </section>
171
+ `;
172
+ }).join("");
173
+
174
+ return `<div class="board-kanban board-kanban--dense min-h-0 min-w-0 overflow-y-auto pr-1">${columnsMarkup}</div>`;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // List rows
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function renderListRow(task, selected) {
182
+ const longTitle = hasLongTaskTitle(task.title);
183
+ const taskTooltip = `Open task details for ${task.title}.`;
184
+
185
+ return `
186
+ <button
187
+ type="button"
188
+ class="board-list-row ${cx(
189
+ "w-full text-left grid gap-3 rounded-[22px] border px-4 py-3 transition duration-200 lg:grid-cols-[minmax(0,2fr)_150px_minmax(0,210px)_150px] lg:items-start",
190
+ "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)]",
191
+ selected
192
+ ? "border-[var(--board-border-strong)] bg-[var(--board-accent-soft)] shadow-focus"
193
+ : "border-[var(--board-border)] bg-white/[0.02] hover:border-[var(--board-border-strong)] hover:bg-white/[0.04]",
194
+ )}"
195
+ data-task-id="${escapeHtml(task.id)}"
196
+ aria-pressed="${selected}"
197
+ aria-label="${escapeHtml(task.title)}"
198
+ title="${escapeHtml(taskTooltip)}"
199
+ >
200
+ <div class="board-list-row__summary min-w-0">
201
+ <div class="board-list-row__summary-head flex min-w-0 flex-wrap items-start justify-between gap-2">
202
+ <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>
203
+ ${longTitle ? `<span class="board-list-row__cue ${neutralChipClasses()}">Open</span>` : ""}
204
+ </div>
205
+ ${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>` : ""}
206
+ </div>
207
+ <div class="board-list-row__status">${renderStatusBadge(task.status)}</div>
208
+ <div class="board-list-row__meta flex min-w-0 flex-wrap gap-2">${renderTaskMeta(task)}</div>
209
+ <span class="board-list-row__updated text-sm text-[var(--board-text-muted)]">${escapeHtml(formatDate(task.updatedAt))}</span>
210
+ </button>
211
+ `;
212
+ }
213
+
214
+ function renderListView(props) {
215
+ const { visibleTasks, selectedTaskId } = props;
216
+
217
+ const rows = visibleTasks.length === 0
218
+ ? renderEmptyState("No matching tasks", "Nothing in this slice matches the active search and epic filters.", "/")
219
+ : visibleTasks.map((task) => renderListRow(task, selectedTaskId === task.id)).join("");
220
+
221
+ return `
222
+ <div class="board-list board-list--dense grid min-h-0 gap-4 grid-rows-[auto_1fr]">
223
+ <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)_150px]">
224
+ <span>Task</span>
225
+ <span>Status</span>
226
+ <span>Workflow</span>
227
+ <span>Updated</span>
228
+ </div>
229
+ <div class="board-list__rows min-h-0 space-y-3 overflow-auto pr-1 overscroll-contain">${rows}</div>
230
+ </div>
231
+ `;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Workspace component
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function render(props) {
239
+ const {
240
+ selectedEpic,
241
+ selectedTask,
242
+ snapshotEpics,
243
+ store,
244
+ visibleTasks,
245
+ } = props;
246
+
247
+ const selectedTaskId = selectedTask?.id ?? null;
248
+
249
+ const viewModes = VIEW_MODES.map((view) => ({
250
+ active: store.view === view,
251
+ id: view,
252
+ label: view === "kanban" ? "Kanban" : "Rows",
253
+ }));
254
+
255
+ const headerMarkup = renderWorkspaceHeader({
256
+ selectedEpic,
257
+ snapshotEpics,
258
+ store: {
259
+ notesPanelOpen: store.notesPanelOpen,
260
+ isMutating: store.isMutating,
261
+ selectedEpicId: selectedEpic.id,
262
+ view: store.view,
263
+ viewModes,
264
+ },
265
+ visibleTasks,
266
+ });
267
+
268
+ const contentMarkup = store.view === "kanban"
269
+ ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating })
270
+ : renderListView({ visibleTasks, selectedTaskId });
271
+
272
+ return `
273
+ <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">
274
+ ${headerMarkup}
275
+ <div class="board-content mt-4 min-h-0 min-w-0 overflow-hidden">
276
+ ${contentMarkup}
277
+ </div>
278
+ </section>
279
+ `;
280
+ }
281
+
282
+ /**
283
+ * Workspace component — workspace header + content (kanban or list view).
284
+ */
285
+ export function createWorkspace() {
286
+ let container = null;
287
+ let lastHtml = null;
288
+
289
+ return {
290
+ mount(el) {
291
+ container = el;
292
+ return this;
293
+ },
294
+ update(props) {
295
+ if (!container) return;
296
+ const html = render(props);
297
+ if (html !== lastHtml) {
298
+ container.innerHTML = html;
299
+ lastHtml = html;
300
+ }
301
+ },
302
+ unmount() {
303
+ if (container) container.innerHTML = "";
304
+ container = null;
305
+ lastHtml = null;
306
+ },
307
+ };
308
+ }
@@ -1,6 +1,6 @@
1
1
  export const BOARD_SHARED_TOKENS = {
2
2
  theme: "src/board/assets/styles/board.css",
3
- shell: "src/board/assets/components/AppShell.js",
3
+ fonts: "src/board/assets/styles/fonts.css",
4
4
  bootstrap: "src/board/assets/main.js",
5
5
  legacyRuntime: "src/board/assets/app.js",
6
6
  };
@@ -11,37 +11,43 @@ export const BOARD_ASSET_MAP = {
11
11
  files: [
12
12
  "src/board/assets/index.html",
13
13
  "src/board/assets/main.js",
14
+ "src/board/assets/app.js",
14
15
  "src/board/assets/styles/board.css",
15
- "src/board/assets/components/AppShell.js",
16
+ "src/board/assets/styles/fonts.css",
17
+ "src/board/assets/fonts/inter-latin.woff2",
18
+ "src/board/assets/fonts/material-symbols-rounded.woff2",
16
19
  "src/board/assets/components/assetMap.js",
20
+ "src/board/assets/components/helpers.js",
21
+ "src/board/assets/components/Component.js",
17
22
  ],
18
23
  owner: "board-runtime",
19
- description: "Zero-build CDN entry shell and runtime handoff.",
24
+ description: "Zero-build CDN entry shell, runtime handoff, and shared helpers.",
20
25
  },
21
26
  overview: {
22
- files: ["src/board/assets/components/EpicsOverview.js", "src/board/assets/components/EpicRow.js", "src/board/assets/components/ClampedText.js"],
27
+ files: [
28
+ "src/board/assets/components/EpicsOverview.js",
29
+ "src/board/assets/components/EpicRow.js",
30
+ "src/board/assets/components/ClampedText.js",
31
+ ],
23
32
  owner: "overview-lane",
24
33
  description: "Epic list density, overview rows, and long-copy disclosure.",
25
34
  },
26
35
  workspace: {
27
36
  files: [
28
- "src/board/assets/components/TaskWorkspace.js",
29
- "src/board/assets/components/KanbanBoard.js",
30
- "src/board/assets/components/KanbanColumn.js",
37
+ "src/board/assets/components/TopBar.js",
38
+ "src/board/assets/components/Workspace.js",
31
39
  "src/board/assets/components/TaskCard.js",
32
- "src/board/assets/components/TaskList.js",
33
- "src/board/assets/components/TaskListRow.js",
40
+ "src/board/assets/components/Notice.js",
34
41
  ],
35
42
  owner: "workspace-lane",
36
- description: "Task browsing surfaces and drag-friendly work views.",
43
+ description: "Task browsing surfaces, topbar, notices, and drag-friendly work views.",
37
44
  },
38
45
  detail: {
39
46
  files: [
40
- "src/board/assets/components/TaskInspector.js",
47
+ "src/board/assets/components/Inspector.js",
41
48
  "src/board/assets/components/TaskModal.js",
42
49
  "src/board/assets/components/SubtaskModal.js",
43
- "src/board/assets/components/DependencyList.js",
44
- "src/board/assets/components/SubtaskList.js",
50
+ "src/board/assets/components/ConfirmDialog.js",
45
51
  ],
46
52
  owner: "detail-lane",
47
53
  description: "Task and subtask detail surfaces, forms, and disclosures.",
@@ -51,9 +57,18 @@ export const BOARD_ASSET_MAP = {
51
57
  "src/board/assets/state/store.js",
52
58
  "src/board/assets/state/actions.js",
53
59
  "src/board/assets/state/api.js",
60
+ "src/board/assets/state/utils.js",
61
+ "src/board/assets/state/url.js",
54
62
  ],
55
63
  owner: "state-lane",
56
- description: "Snapshot normalization, persistence, mutations, and API wiring.",
64
+ description: "Observable store, mutations, API wiring, URL hash sync, and shared utilities.",
65
+ },
66
+ runtime: {
67
+ files: [
68
+ "src/board/assets/runtime/delegation.js",
69
+ ],
70
+ owner: "runtime-lane",
71
+ description: "Event delegation and DOM utility helpers.",
57
72
  },
58
73
  };
59
74