trekoon 0.2.9 → 0.3.0

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 CHANGED
@@ -28,7 +28,7 @@ Recommended (global install with Bun):
28
28
  bun add -g trekoon
29
29
  ```
30
30
 
31
- Alternative (npm global install):
31
+ Alternative (npm global install — Bun must still be installed as the runtime):
32
32
 
33
33
  ```bash
34
34
  npm i -g trekoon
@@ -99,20 +99,19 @@ The browser flow is local-only by design:
99
99
  Current runtime expectations:
100
100
 
101
101
  - the local runtime is served from `.trekoon/board`
102
- - the shell currently loads Vue from `unpkg.com`, Tailwind from
103
- `cdn.tailwindcss.com`, and Google-hosted fonts/icons when the page renders
104
- - if your environment blocks those hosts, `trekoon board open` still starts the
105
- local server, but the browser UI may render without the enhanced shell until
106
- network access is restored
102
+ - all assets are self-hosted: the board ships its own CSS, fonts (Inter,
103
+ Material Symbols), and vanilla JS with no framework or CDN dependencies
104
+ - the board works fully offline once `trekoon board open` copies the runtime
105
+ assets into `.trekoon/board`
107
106
 
108
107
  Current board behavior to expect:
109
108
 
110
- - the topbar is a compact single-row navbar showing the workspace name, active
111
- epic context, Epics and Board navigation, search, theme toggle, and a
112
- workspace info popover
113
- - wide screens show the epic switcher sidebar, task workspace, and task
114
- inspector together; narrower screens collapse these into stacked panels or
115
- drawer-style views
109
+ - the topbar is a compact single-row navbar showing the Trekoon brand, Epics
110
+ and Board navigation, search, theme toggle, and a workspace info popover;
111
+ selecting an epic adds the active epic context to the topbar
112
+ - the board toggles between an epics overview and a task workspace view; task
113
+ detail opens as a modal overlay; responsive breakpoints adjust kanban column
114
+ counts and component spacing
116
115
  - the page scrolls naturally as a single SPA surface; modal overlays (task
117
116
  detail, subtask editor) lock body scroll while open
118
117
  - overview cards are the primary entry point into an epic; clicking a card opens
@@ -132,8 +131,9 @@ agent to:
132
131
 
133
132
  - use `--toon` by default
134
133
  - prefer the smallest sufficient read
135
- - use bulk planning commands when possible
136
- - keep progress in Trekoon with append-based updates
134
+ - use transactional bulk planning commands when possible
135
+ - append progress and blocker notes instead of rewriting full descriptions
136
+ - preview scoped replace before `--apply`
137
137
  - treat `.trekoon` as shared repo-scoped operational state
138
138
 
139
139
  Read [AI agents and the Trekoon skill](docs/ai-agents.md) for installation,
package/docs/commands.md CHANGED
@@ -79,16 +79,15 @@ Current shell/runtime notes:
79
79
 
80
80
  - the runtime copied into `.trekoon/board` includes the HTML shell, local app
81
81
  modules, and shared styles
82
- - the current shell also requests Vue from `unpkg.com`, Tailwind from
83
- `cdn.tailwindcss.com`, and Google-hosted fonts/icons in the browser
84
- - if those remote dependencies are blocked, the local server still starts and
85
- the fallback URL remains valid, but the browser UI may not fully load until
86
- network access is restored
82
+ - all assets are self-hosted: the board ships its own CSS, fonts (Inter,
83
+ Material Symbols), and vanilla JS with no framework or CDN dependencies
84
+ - the board works fully offline once the runtime assets are copied into
85
+ `.trekoon/board`
87
86
 
88
87
  Board UI architecture:
89
88
 
90
- - the board is a single-page application served from `.trekoon/board` with Vue 3
91
- (shell) and vanilla JS (board app) loaded from CDN dependencies
89
+ - the board is a single-page application served from `.trekoon/board` using a
90
+ zero-dependency vanilla JS component runtime with locally bundled CSS and fonts
92
91
  - the backend is a Bun HTTP server exposing a REST API at `/api/*` with
93
92
  token-based authentication; every mutation response includes an updated
94
93
  snapshot so the client always has fresh state
@@ -100,10 +99,9 @@ Board layout behavior:
100
99
  - the topbar is a compact flex-row navbar with workspace identity, Epics and
101
100
  Board navigation pills, a debounced search input, theme toggle, and a
102
101
  workspace info popover
103
- - extra-wide layouts show the epic switcher sidebar, task workspace, and task
104
- inspector or detail modal together
105
- - narrower layouts progressively collapse support surfaces into stacked panels
106
- or drawer-style views
102
+ - the board toggles between an epics overview and a task workspace view; task
103
+ detail opens as a modal overlay
104
+ - responsive breakpoints adjust kanban column counts and component spacing
107
105
  - task cards show truncated descriptions; clicking anywhere on a card opens the
108
106
  task detail modal
109
107
 
@@ -75,20 +75,19 @@ Current runtime expectations for operators:
75
75
 
76
76
  - the served HTML, styles, and board app files come from the local
77
77
  `.trekoon/board` runtime directory
78
- - the current shell also pulls Vue from `unpkg.com`, Tailwind from
79
- `cdn.tailwindcss.com`, and Google-hosted fonts/icons at page load time
80
- - if those hosts are unavailable, the command still starts the loopback server
81
- and prints the fallback URL, but the shell may not fully hydrate in the
82
- browser until network access is available again
78
+ - all assets are self-hosted: the board ships its own CSS, fonts (Inter,
79
+ Material Symbols), and vanilla JS with no framework or CDN dependencies
80
+ - the board works fully offline once the runtime assets are copied into
81
+ `.trekoon/board`
83
82
 
84
83
  Current layout behavior:
85
84
 
86
85
  - the topbar is a compact navbar with workspace identity, Epics and Board
87
86
  navigation, debounced search, theme toggle, and workspace info
88
- - on wide displays, expect a three-surface workspace: epic switcher sidebar,
89
- task workspace, and task inspector
90
- - on medium and small displays, secondary surfaces collapse into stacked panels
91
- or drawer-style views so the board remains navigable on narrower widths
87
+ - the board toggles between an epics overview and a task workspace view; task
88
+ detail opens as a modal overlay
89
+ - responsive breakpoints adjust kanban column counts and component spacing so
90
+ the board remains navigable on narrower widths
92
91
  - the page scrolls naturally as one document; modal overlays lock body scroll
93
92
  while open
94
93
  - task cards show truncated descriptions; clicking a card opens the task detail
@@ -106,9 +105,8 @@ Verification checklist for operators:
106
105
  the search input while typing.
107
106
  4. Click a task card and confirm the task detail modal opens with the full
108
107
  description, edit form, and subtask list.
109
- 5. On desktop width, confirm the epic switcher sidebar, workspace, and detail
110
- panel can all be visible simultaneously.
111
- 6. Resize to a narrow viewport and confirm the layout stacks cleanly without
108
+ 5. On desktop width, confirm the kanban board shows multiple columns.
109
+ 6. Resize to a narrow viewport and confirm columns reflow cleanly without
112
110
  horizontal overflow.
113
111
  7. Close modals and confirm you return to the previous board context.
114
112
 
package/package.json CHANGED
@@ -1,8 +1,30 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "AI-first local issue tracker CLI.",
5
+ "keywords": [
6
+ "ai",
7
+ "agent",
8
+ "skill",
9
+ "cli",
10
+ "board",
11
+ "tracker",
12
+ "agent-tracker",
13
+ "issue-tracker",
14
+ "task-tracker",
15
+ "local-first",
16
+ "planning",
17
+ "worktree"
18
+ ],
5
19
  "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/KristjanPikhof/Trekoon.git"
23
+ },
24
+ "homepage": "https://github.com/KristjanPikhof/Trekoon#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/KristjanPikhof/Trekoon/issues"
27
+ },
6
28
  "type": "module",
7
29
  "bin": {
8
30
  "trekoon": "./bin/trekoon"
@@ -525,6 +525,7 @@ export async function bootLegacyBoard(options = {}) {
525
525
  showBoard: () => actions.showBoard(),
526
526
  scrollToDetail: () => document.querySelector(".board-drawer, .board-task-modal")?.scrollIntoView({ block: "nearest", behavior: getScrollBehavior() }),
527
527
  setView: (view) => actions.setView(view),
528
+ copyEpicId: (id) => actions.copyEpicId(id),
528
529
  toggleTheme: () => actions.toggleTheme(),
529
530
  toggleNotesPanel: () => actions.toggleNotesPanel(),
530
531
  confirmDelete: () => {
@@ -2,6 +2,8 @@ import {
2
2
  escapeHtml,
3
3
  formatDate,
4
4
  neutralChipClasses,
5
+ renderCheckIcon,
6
+ renderCopyIcon,
5
7
  renderIcon,
6
8
  renderStatusBadge,
7
9
  } from "./helpers.js";
@@ -12,34 +14,47 @@ import {
12
14
  * @param {object} props
13
15
  * @param {object} props.epic
14
16
  * @param {boolean} [props.selected]
17
+ * @param {boolean} [props.copied]
15
18
  * @returns {string}
16
19
  */
17
20
  export function renderEpicRow(props) {
18
- const { epic, selected = false } = props;
21
+ const { epic, selected = false, copied = false } = props;
19
22
 
20
23
  const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
21
24
  const counts = epic.counts || { blocked: 0, done: 0, in_progress: 0 };
22
25
  const statusLabel = String(epic.status ?? "todo").replace(/_/g, " ");
23
26
  const openLabel = `Open epic ${epic.title}`;
24
27
  const tooltip = `${openLabel}. ${totalTasks} task${totalTasks === 1 ? "" : "s"}.`;
28
+ const copyLabel = `Copy epic UUID for ${epic.title}`;
29
+ const copyTooltip = `Copy epic UUID ${epic.id}.`;
25
30
  const descriptionMarkup = epic.description?.trim()
26
31
  ? `<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
32
  : "";
28
33
 
29
34
  return `
30
- <button
31
- type="button"
35
+ <div
32
36
  class="board-epic-row ${selected ? "board-epic-row--selected" : ""}"
37
+ role="button"
38
+ tabindex="0"
33
39
  aria-current="${selected}"
34
40
  aria-label="${escapeHtml(`${openLabel}. ${totalTasks} tasks. Status ${statusLabel}.`)}"
35
41
  title="${escapeHtml(tooltip)}"
36
42
  data-open-epic="${escapeHtml(epic.id)}"
37
43
  >
38
44
  <span class="board-epic-row__summary">
39
- <span class="board-epic-row__title-row">
45
+ <span class="board-epic-row__meta-row">
40
46
  <span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
41
- <strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
47
+ <button
48
+ type="button"
49
+ class="board-copy-btn board-copy-btn--icon board-copy-btn--epic-row ${copied ? "board-copy-btn--active" : ""}"
50
+ data-copy-epic-id="${escapeHtml(epic.id)}"
51
+ aria-label="${escapeHtml(copied ? `Copied epic UUID for ${epic.title}` : copyLabel)}"
52
+ title="${escapeHtml(copied ? "Epic UUID copied." : copyTooltip)}"
53
+ >
54
+ ${copied ? renderCheckIcon("board-inline-icon--sm") : renderCopyIcon("board-inline-icon--sm")}
55
+ </button>
42
56
  </span>
57
+ <strong class="board-epic-row__title">${escapeHtml(epic.title)}</strong>
43
58
  ${descriptionMarkup}
44
59
  </span>
45
60
  <span class="board-epic-row__status">${renderStatusBadge(epic.status ?? "todo")}</span>
@@ -59,6 +74,6 @@ export function renderEpicRow(props) {
59
74
  ${renderIcon("chevron_right", "text-[16px]")}
60
75
  </span>
61
76
  </span>
62
- </button>
77
+ </div>
63
78
  `;
64
79
  }
@@ -43,7 +43,11 @@ function render(props) {
43
43
  <div class="board-table__rows board-table__rows--epics">
44
44
  ${visibleEpics.length === 0
45
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("")}
46
+ : visibleEpics.map((epic) => renderEpicRow({
47
+ epic,
48
+ selected: selectedEpicId === epic.id,
49
+ copied: store.copyFeedback?.epicId === epic.id,
50
+ })).join("")}
47
51
  </div>
48
52
  </div>
49
53
  </section>
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  escapeHtml,
3
- panelClasses,
3
+ renderCheckIcon,
4
4
  renderIcon,
5
- sectionLabelClasses,
6
5
  } from "./helpers.js";
7
6
 
8
7
  /**
@@ -27,7 +26,7 @@ export function createNotice() {
27
26
  },
28
27
 
29
28
  /**
30
- * @param {{ notice: { type: string, message: string } | null, onDismiss?: () => void }} props
29
+ * @param {{ notice: { type: string, message: string, title?: string } | null, onDismiss?: () => void }} props
31
30
  */
32
31
  update(props) {
33
32
  if (!container) return;
@@ -47,16 +46,24 @@ export function createNotice() {
47
46
  return;
48
47
  }
49
48
 
49
+ const noticeTitle = typeof notice.title === "string" && notice.title.trim().length > 0
50
+ ? notice.title.trim()
51
+ : notice.type === "error"
52
+ ? "Action blocked"
53
+ : "Saved";
54
+
50
55
  container.innerHTML = `
51
- <section class="${panelClasses("mb-4 flex items-start gap-3 p-4 sm:p-5")}" role="${notice.type === "error" ? "alert" : "status"}" aria-live="${notice.type === "error" ? "assertive" : "polite"}" aria-atomic="true">
52
- <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${notice.type === "error" ? "bg-red-500/10 text-red-300 ring-1 ring-red-500/20" : "bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/20"}">
53
- ${renderIcon(notice.type === "error" ? "warning" : "check_circle", "text-[20px]")}
54
- </div>
55
- <div class="min-w-0">
56
- <p class="${sectionLabelClasses()}" id="board-notice-title">${notice.type === "error" ? "Action blocked" : "Saved"}</p>
57
- <p class="mt-1 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(notice.message)}</p>
58
- </div>
59
- </section>
56
+ <div class="board-toast-region" role="presentation">
57
+ <section class="board-toast board-toast--${notice.type === "error" ? "error" : "success"}" role="${notice.type === "error" ? "alert" : "status"}" aria-live="${notice.type === "error" ? "assertive" : "polite"}" aria-atomic="true">
58
+ <div class="board-toast__icon ${notice.type === "error" ? "board-toast__icon--error" : "board-toast__icon--success"}">
59
+ ${notice.type === "error" ? renderIcon("warning") : renderCheckIcon()}
60
+ </div>
61
+ <div class="board-toast__content">
62
+ <p class="board-toast__title" id="board-notice-title">${escapeHtml(noticeTitle)}</p>
63
+ <p class="board-toast__message">${escapeHtml(notice.message)}</p>
64
+ </div>
65
+ </section>
66
+ </div>
60
67
  `;
61
68
  lastNotice = { type: notice.type, message: notice.message };
62
69
 
@@ -8,6 +8,8 @@ import {
8
8
  neutralChipClasses,
9
9
  panelClasses,
10
10
  readStatusLabel,
11
+ renderCheckIcon,
12
+ renderCopyIcon,
11
13
  renderEpicCountSummary,
12
14
  renderEmptyState,
13
15
  renderIcon,
@@ -62,6 +64,9 @@ function renderWorkspaceHeader(props) {
62
64
  const epicSelectTooltip = "Switch the workspace to a different epic.";
63
65
  const bulkStatusTooltip = "Set the same status for every task in this epic.";
64
66
  const notesTooltip = "Show or hide this epic's description.";
67
+ const copyTooltip = "Copy this epic's UUID.";
68
+ const copyLabel = `Copy epic UUID for ${selectedEpic.title}`;
69
+ const isCopied = store.copyFeedback?.epicId === selectedEpic.id;
65
70
  const notesPanelId = `board-notes-panel-${selectedEpic.id}`;
66
71
  const orderedEpics = orderEpicsNewestFirst(snapshotEpics);
67
72
 
@@ -104,12 +109,17 @@ function renderWorkspaceHeader(props) {
104
109
 
105
110
  <div class="board-wh__row-2">
106
111
  <div class="board-wh__meta">
112
+ <span class="${neutralChipClasses()}">${escapeHtml(selectedEpic.id)}</span>
107
113
  ${renderEpicCountSummary(selectedEpic)}
108
114
  <span class="${neutralChipClasses()}">${visibleTasks.length} visible</span>
109
115
  ${store.isMutating ? `<span class="${neutralChipClasses()}">Saving\u2026</span>` : ""}
110
116
  </div>
111
117
  <div class="board-wh__actions">
112
118
  <div class="board-wh__action-group">
119
+ <button type="button" class="board-copy-btn ${isCopied ? "board-copy-btn--active" : ""}" data-copy-epic-id="${escapeHtml(selectedEpic.id)}" aria-label="${escapeHtml(isCopied ? `Copied epic UUID for ${selectedEpic.title}` : copyLabel)}" title="${escapeHtml(isCopied ? "Epic UUID copied." : copyTooltip)}">
120
+ ${isCopied ? renderCheckIcon() : renderCopyIcon()}
121
+ <span class="board-copy-btn__label">${isCopied ? "Copied" : "Copy ID"}</span>
122
+ </button>
113
123
  ${description ? `
114
124
  <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
125
  ${renderIcon("subject", "text-[16px]")}
@@ -255,11 +265,12 @@ function render(props) {
255
265
  const headerMarkup = renderWorkspaceHeader({
256
266
  selectedEpic,
257
267
  snapshotEpics,
258
- store: {
259
- notesPanelOpen: store.notesPanelOpen,
260
- isMutating: store.isMutating,
261
- selectedEpicId: selectedEpic.id,
262
- view: store.view,
268
+ store: {
269
+ copyFeedback: store.copyFeedback,
270
+ notesPanelOpen: store.notesPanelOpen,
271
+ isMutating: store.isMutating,
272
+ selectedEpicId: selectedEpic.id,
273
+ view: store.view,
263
274
  viewModes,
264
275
  },
265
276
  visibleTasks,
@@ -88,6 +88,23 @@ export function renderIcon(name, className = "") {
88
88
  return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
89
89
  }
90
90
 
91
+ export function renderCopyIcon(className = "") {
92
+ return `
93
+ <svg class="${cx("board-inline-icon", className)}" aria-hidden="true" viewBox="0 0 16 16" fill="none">
94
+ <rect x="5" y="2.75" width="7" height="9" rx="1.5" stroke="currentColor" stroke-width="1.35"></rect>
95
+ <path d="M4 5.75H3.5C2.67157 5.75 2 6.42157 2 7.25V12C2 12.8284 2.67157 13.5 3.5 13.5H8.25C9.07843 13.5 9.75 12.8284 9.75 12V11.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"></path>
96
+ </svg>
97
+ `;
98
+ }
99
+
100
+ export function renderCheckIcon(className = "") {
101
+ return `
102
+ <svg class="${cx("board-inline-icon", className)}" aria-hidden="true" viewBox="0 0 16 16" fill="none">
103
+ <path d="M3.5 8.4L6.4 11.1L12.5 4.9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
104
+ </svg>
105
+ `;
106
+ }
107
+
91
108
  export function readStatusLabel(rawStatus) {
92
109
  if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
93
110
  return "Unknown";
@@ -0,0 +1,34 @@
1
+ export async function copyTextToClipboard(value) {
2
+ const text = typeof value === "string" ? value : String(value ?? "");
3
+
4
+ if (!text) {
5
+ throw new Error("Clipboard text is empty.");
6
+ }
7
+
8
+ if (navigator?.clipboard?.writeText) {
9
+ await navigator.clipboard.writeText(text);
10
+ return;
11
+ }
12
+
13
+ const textarea = document.createElement("textarea");
14
+ textarea.value = text;
15
+ textarea.setAttribute("readonly", "true");
16
+ textarea.setAttribute("aria-hidden", "true");
17
+ textarea.style.position = "fixed";
18
+ textarea.style.top = "0";
19
+ textarea.style.left = "0";
20
+ textarea.style.opacity = "0";
21
+ textarea.style.pointerEvents = "none";
22
+
23
+ document.body.append(textarea);
24
+ textarea.focus({ preventScroll: true });
25
+ textarea.select();
26
+ textarea.setSelectionRange(0, text.length);
27
+
28
+ const didCopy = document.execCommand("copy");
29
+ textarea.remove();
30
+
31
+ if (!didCopy) {
32
+ throw new Error("Clipboard API unavailable.");
33
+ }
34
+ }
@@ -43,6 +43,12 @@ export function createDelegation(rootElement, actions) {
43
43
  return;
44
44
  }
45
45
 
46
+ const copyEpicIdEl = target.closest("[data-copy-epic-id]");
47
+ if (copyEpicIdEl) {
48
+ actions.copyEpicId(copyEpicIdEl.dataset.copyEpicId || null);
49
+ return;
50
+ }
51
+
46
52
  // -- Backdrop-style close handlers ----------------------------------------
47
53
  // Only close when the click lands directly on the backdrop element itself,
48
54
  // not on any child content rendered inside the overlay.
@@ -242,6 +248,31 @@ export function createDelegation(rootElement, actions) {
242
248
  actions.handleKeydown(event);
243
249
  }
244
250
 
251
+ function handleDelegatedKeydown(event) {
252
+ if (event.defaultPrevented) {
253
+ return;
254
+ }
255
+
256
+ const target = event.target instanceof Element ? event.target : null;
257
+ if (!target) {
258
+ return;
259
+ }
260
+
261
+ if (target.closest("[data-copy-epic-id]")) {
262
+ return;
263
+ }
264
+
265
+ const openEpicEl = target.closest("[data-open-epic]");
266
+ if (!openEpicEl) {
267
+ return;
268
+ }
269
+
270
+ if (event.key === "Enter" || event.key === " ") {
271
+ event.preventDefault();
272
+ actions.openEpic(openEpicEl.dataset.openEpic || null);
273
+ }
274
+ }
275
+
245
276
  // ---------------------------------------------------------------------------
246
277
  // Drag-and-drop delegation
247
278
  // ---------------------------------------------------------------------------
@@ -291,6 +322,7 @@ export function createDelegation(rootElement, actions) {
291
322
  rootElement.addEventListener("dragstart", handleDragstart);
292
323
  rootElement.addEventListener("dragover", handleDragover);
293
324
  rootElement.addEventListener("drop", handleDrop);
325
+ rootElement.addEventListener("keydown", handleDelegatedKeydown);
294
326
  window.addEventListener("keydown", handleKeydown);
295
327
 
296
328
  // ---------------------------------------------------------------------------
@@ -304,6 +336,7 @@ export function createDelegation(rootElement, actions) {
304
336
  rootElement.removeEventListener("dragstart", handleDragstart);
305
337
  rootElement.removeEventListener("dragover", handleDragover);
306
338
  rootElement.removeEventListener("drop", handleDrop);
339
+ rootElement.removeEventListener("keydown", handleDelegatedKeydown);
307
340
  window.removeEventListener("keydown", handleKeydown);
308
341
  };
309
342
  }
@@ -1,3 +1,4 @@
1
+ import { copyTextToClipboard } from "../runtime/clipboard.js";
1
2
  import { orderEpicsNewestFirst } from "./store.js";
2
3
 
3
4
  function cloneSnapshot(snapshot) {
@@ -156,6 +157,7 @@ export function createBoardActions(options) {
156
157
 
157
158
  let searchTimer = null;
158
159
  let pendingSearchValue = null;
160
+ let copyFeedbackTimer = null;
159
161
 
160
162
  const syncSearchInputToState = () => {
161
163
  const input = document.querySelector("#board-search-input");
@@ -186,6 +188,38 @@ export function createBoardActions(options) {
186
188
 
187
189
  const shouldRefocusSearchInput = () => document.activeElement?.id === "board-search-input";
188
190
 
191
+ const clearCopyFeedback = ({ rerenderBoard = true } = {}) => {
192
+ if (copyFeedbackTimer !== null) {
193
+ clearTimeout(copyFeedbackTimer);
194
+ copyFeedbackTimer = null;
195
+ }
196
+
197
+ if (!store.copyFeedback) {
198
+ return;
199
+ }
200
+
201
+ store.copyFeedback = null;
202
+ if (rerenderBoard) {
203
+ rerender();
204
+ }
205
+ };
206
+
207
+ const scheduleCopyFeedbackClear = (epicId) => {
208
+ if (copyFeedbackTimer !== null) {
209
+ clearTimeout(copyFeedbackTimer);
210
+ }
211
+
212
+ copyFeedbackTimer = setTimeout(() => {
213
+ copyFeedbackTimer = null;
214
+ if (store.copyFeedback?.epicId !== epicId) {
215
+ return;
216
+ }
217
+
218
+ store.copyFeedback = null;
219
+ rerender();
220
+ }, 1800);
221
+ };
222
+
189
223
  const commitSearch = (nextSearch, options = {}) => {
190
224
  const { focusInput = false } = options;
191
225
  cancelPendingSearch({ syncInput: false });
@@ -266,6 +300,37 @@ export function createBoardActions(options) {
266
300
  setView(view) {
267
301
  transition({ view });
268
302
  },
303
+ async copyEpicId(epicId) {
304
+ const normalizedEpicId = typeof epicId === "string" ? epicId.trim() : "";
305
+
306
+ if (!normalizedEpicId) {
307
+ clearCopyFeedback({ rerenderBoard: false });
308
+ store.notice = {
309
+ type: "error",
310
+ title: "Copy failed",
311
+ message: "Epic UUID is unavailable.",
312
+ };
313
+ rerender();
314
+ return;
315
+ }
316
+
317
+ try {
318
+ await copyTextToClipboard(normalizedEpicId);
319
+ store.copyFeedback = {
320
+ epicId: normalizedEpicId,
321
+ };
322
+ scheduleCopyFeedbackClear(normalizedEpicId);
323
+ } catch {
324
+ clearCopyFeedback({ rerenderBoard: false });
325
+ store.notice = {
326
+ type: "error",
327
+ title: "Copy failed",
328
+ message: "Could not copy the epic UUID.",
329
+ };
330
+ }
331
+
332
+ rerender();
333
+ },
269
334
  selectTask(taskId) {
270
335
  const task = getTaskById(taskId);
271
336
  if (!task) {
@@ -409,6 +474,9 @@ export function createBoardActions(options) {
409
474
  } else if (boardState.screen === "tasks") {
410
475
  event.preventDefault();
411
476
  this.showEpics();
477
+ } else if (store.copyFeedback) {
478
+ event.preventDefault();
479
+ clearCopyFeedback();
412
480
  } else if (store.notice) {
413
481
  event.preventDefault();
414
482
  store.notice = null;
@@ -283,6 +283,7 @@ export function createStore(initialSnapshot, options = {}) {
283
283
  selectedSubtaskId: null,
284
284
  theme: readThemePreference(),
285
285
  focusedEpicIndex: 0,
286
+ copyFeedback: null,
286
287
  notice: null,
287
288
  isMutating: false,
288
289
  notesPanelOpen: storedState.notesPanelOpen === true,
@@ -381,11 +381,165 @@ body.board-scroll-locked {
381
381
  min-width: 0;
382
382
  }
383
383
 
384
- .board-epic-row__title-row {
384
+ .board-epic-row__meta-row {
385
385
  display: flex;
386
386
  flex-wrap: wrap;
387
387
  align-items: center;
388
- gap: 8px;
388
+ gap: 6px;
389
+ }
390
+
391
+ .board-copy-btn,
392
+ .board-wh__notes-btn {
393
+ display: inline-flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ gap: 6px;
397
+ min-height: 36px;
398
+ padding: 0 10px;
399
+ border: 1px solid var(--board-border);
400
+ border-radius: 10px;
401
+ background: rgba(255, 255, 255, 0.03);
402
+ color: var(--board-text-muted);
403
+ font-size: 0.75rem;
404
+ font-weight: 600;
405
+ white-space: nowrap;
406
+ cursor: pointer;
407
+ touch-action: manipulation;
408
+ transition:
409
+ border-color 0.15s ease,
410
+ background-color 0.15s ease,
411
+ color 0.15s ease,
412
+ box-shadow 0.15s ease;
413
+ }
414
+
415
+ .board-copy-btn:hover,
416
+ .board-wh__notes-btn:hover {
417
+ border-color: var(--board-border-strong);
418
+ background: rgba(255, 255, 255, 0.045);
419
+ color: var(--board-text);
420
+ }
421
+
422
+ .board-copy-btn:focus-visible,
423
+ .board-wh__notes-btn:focus-visible {
424
+ outline: none;
425
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--board-bg) 78%, transparent), 0 0 0 4px var(--board-border-strong);
426
+ }
427
+
428
+ .board-copy-btn--active {
429
+ border-color: color-mix(in srgb, var(--board-success) 26%, var(--board-border-strong));
430
+ background: color-mix(in srgb, var(--board-success) 12%, rgba(255, 255, 255, 0.05));
431
+ color: var(--board-text);
432
+ }
433
+
434
+ .board-copy-btn--active:hover {
435
+ border-color: color-mix(in srgb, var(--board-success) 36%, var(--board-border-strong));
436
+ background: color-mix(in srgb, var(--board-success) 16%, rgba(255, 255, 255, 0.06));
437
+ }
438
+
439
+ .board-inline-icon {
440
+ display: block;
441
+ width: 16px;
442
+ height: 16px;
443
+ flex-shrink: 0;
444
+ }
445
+
446
+ .board-inline-icon--sm {
447
+ width: 14px;
448
+ height: 14px;
449
+ }
450
+
451
+ .board-copy-btn--icon {
452
+ min-height: 30px;
453
+ width: 30px;
454
+ padding: 0;
455
+ border-radius: 999px;
456
+ flex-shrink: 0;
457
+ }
458
+
459
+ .board-copy-btn--epic-row {
460
+ min-height: 24px;
461
+ width: 24px;
462
+ }
463
+
464
+ .board-copy-btn--epic-row .board-inline-icon--sm {
465
+ width: 12px;
466
+ height: 12px;
467
+ }
468
+
469
+ .board-copy-btn__label {
470
+ line-height: 1;
471
+ }
472
+
473
+ .board-toast-region {
474
+ position: fixed;
475
+ right: 16px;
476
+ bottom: 16px;
477
+ z-index: 30;
478
+ width: min(100vw - 24px, 380px);
479
+ pointer-events: none;
480
+ }
481
+
482
+ .board-toast {
483
+ display: flex;
484
+ align-items: flex-start;
485
+ gap: 12px;
486
+ width: 100%;
487
+ padding: 12px 14px;
488
+ border: 1px solid var(--board-border);
489
+ border-radius: 18px;
490
+ background: color-mix(in srgb, var(--board-shell) 94%, transparent);
491
+ box-shadow: var(--board-shadow);
492
+ backdrop-filter: blur(18px);
493
+ pointer-events: auto;
494
+ }
495
+
496
+ .board-toast--success {
497
+ border-color: color-mix(in srgb, var(--board-success) 24%, var(--board-border));
498
+ }
499
+
500
+ .board-toast--error {
501
+ border-color: color-mix(in srgb, var(--board-danger) 28%, var(--board-border));
502
+ }
503
+
504
+ .board-toast__icon {
505
+ display: inline-flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ width: 34px;
509
+ height: 34px;
510
+ border-radius: 999px;
511
+ flex-shrink: 0;
512
+ }
513
+
514
+ .board-toast__icon--success {
515
+ background: color-mix(in srgb, var(--board-success) 16%, transparent);
516
+ color: color-mix(in srgb, var(--board-success) 78%, white);
517
+ }
518
+
519
+ .board-toast__icon--error {
520
+ background: color-mix(in srgb, var(--board-danger) 14%, transparent);
521
+ color: color-mix(in srgb, var(--board-danger) 72%, white);
522
+ }
523
+
524
+ .board-toast__content {
525
+ min-width: 0;
526
+ }
527
+
528
+ .board-toast__title {
529
+ margin: 0;
530
+ font-size: 0.72rem;
531
+ font-weight: 700;
532
+ line-height: 1.2;
533
+ letter-spacing: 0.12em;
534
+ text-transform: uppercase;
535
+ color: var(--board-text-soft);
536
+ }
537
+
538
+ .board-toast__message {
539
+ margin: 4px 0 0;
540
+ font-size: 0.9rem;
541
+ line-height: 1.45;
542
+ color: var(--board-text-muted);
389
543
  }
390
544
 
391
545
  .board-epic-row__title {
@@ -1045,40 +1199,6 @@ body.board-scroll-locked {
1045
1199
  line-height: 1;
1046
1200
  }
1047
1201
 
1048
- .board-wh__notes-btn {
1049
- display: inline-flex;
1050
- align-items: center;
1051
- justify-content: center;
1052
- gap: 6px;
1053
- min-height: 36px;
1054
- padding: 0 10px;
1055
- border: 1px solid var(--board-border);
1056
- border-radius: 10px;
1057
- background: rgba(255, 255, 255, 0.03);
1058
- color: var(--board-text-muted);
1059
- font-size: 0.75rem;
1060
- font-weight: 600;
1061
- white-space: nowrap;
1062
- cursor: pointer;
1063
- touch-action: manipulation;
1064
- transition:
1065
- border-color 0.15s ease,
1066
- background-color 0.15s ease,
1067
- color 0.15s ease,
1068
- box-shadow 0.15s ease;
1069
- }
1070
-
1071
- .board-wh__notes-btn:hover {
1072
- border-color: var(--board-border-strong);
1073
- background: rgba(255, 255, 255, 0.045);
1074
- color: var(--board-text);
1075
- }
1076
-
1077
- .board-wh__notes-btn:focus-visible {
1078
- outline: none;
1079
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--board-bg) 78%, transparent), 0 0 0 4px var(--board-border-strong);
1080
- }
1081
-
1082
1202
  .board-wh__notes-btn--active {
1083
1203
  border-color: color-mix(in srgb, var(--board-border-strong) 60%, var(--board-border));
1084
1204
  background: color-mix(in srgb, var(--board-accent-soft) 55%, rgba(255, 255, 255, 0.03));