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 +14 -14
- package/docs/commands.md +9 -11
- package/docs/quickstart.md +10 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +1 -0
- package/src/board/assets/components/EpicRow.js +21 -6
- package/src/board/assets/components/EpicsOverview.js +5 -1
- package/src/board/assets/components/Notice.js +19 -12
- package/src/board/assets/components/Workspace.js +16 -5
- package/src/board/assets/components/helpers.js +17 -0
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +33 -0
- package/src/board/assets/state/actions.js +68 -0
- package/src/board/assets/state/store.js +1 -0
- package/src/board/assets/styles/board.css +156 -36
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
|
|
103
|
-
|
|
104
|
-
-
|
|
105
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
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`
|
|
91
|
-
|
|
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
|
-
-
|
|
104
|
-
|
|
105
|
-
-
|
|
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
|
|
package/docs/quickstart.md
CHANGED
|
@@ -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
|
-
-
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
|
|
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
|
-
-
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
|
|
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
|
|
110
|
-
|
|
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.
|
|
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"
|
package/src/board/assets/app.js
CHANGED
|
@@ -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
|
-
<
|
|
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-
|
|
45
|
+
<span class="board-epic-row__meta-row">
|
|
40
46
|
<span class="${neutralChipClasses()}">${escapeHtml(epic.id)}</span>
|
|
41
|
-
<
|
|
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
|
-
</
|
|
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({
|
|
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
|
-
|
|
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
|
-
<
|
|
52
|
-
<
|
|
53
|
-
${
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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-
|
|
384
|
+
.board-epic-row__meta-row {
|
|
385
385
|
display: flex;
|
|
386
386
|
flex-wrap: wrap;
|
|
387
387
|
align-items: center;
|
|
388
|
-
gap:
|
|
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));
|