trekoon 0.2.8 → 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.
Files changed (38) hide show
  1. package/README.md +14 -14
  2. package/docs/commands.md +9 -11
  3. package/docs/quickstart.md +10 -12
  4. package/package.json +23 -1
  5. package/src/board/assets/app.js +469 -1377
  6. package/src/board/assets/components/ClampedText.js +1 -1
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +43 -26
  10. package/src/board/assets/components/EpicsOverview.js +52 -11
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +87 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +319 -0
  18. package/src/board/assets/components/assetMap.js +29 -14
  19. package/src/board/assets/components/helpers.js +261 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +20 -57
  23. package/src/board/assets/main.js +2 -18
  24. package/src/board/assets/runtime/clipboard.js +34 -0
  25. package/src/board/assets/runtime/delegation.js +342 -0
  26. package/src/board/assets/state/actions.js +204 -16
  27. package/src/board/assets/state/api.js +201 -46
  28. package/src/board/assets/state/store.js +418 -117
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +933 -129
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/routes.ts +15 -6
  34. package/src/board/server.ts +1 -0
  35. package/src/board/assets/components/AppShell.js +0 -17
  36. package/src/board/assets/components/BoardTopbar.js +0 -78
  37. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  38. package/src/board/assets/utils/dom.js +0 -308
@@ -0,0 +1,261 @@
1
+ import { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
2
+
3
+ export { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER };
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Status labels & styles
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const STATUS_LABELS = {
10
+ todo: "Todo",
11
+ blocked: "Blocked",
12
+ in_progress: "In progress",
13
+ done: "Done",
14
+ };
15
+
16
+ const STATUS_BADGE_STYLES = {
17
+ todo: "border-white/10 bg-white/[0.05] text-[var(--board-text-muted)]",
18
+ blocked: "border-amber-500/20 bg-amber-500/10 text-amber-300",
19
+ in_progress: "border-sky-400/20 bg-sky-400/10 text-sky-300",
20
+ done: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300",
21
+ default: "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text-muted)]",
22
+ };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Class-name helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export function cx(...classNames) {
29
+ return classNames.filter(Boolean).join(" ");
30
+ }
31
+
32
+ export function panelClasses(extra = "") {
33
+ return cx(
34
+ "rounded-[28px] border border-[var(--board-border)] bg-[var(--board-surface)] shadow-panel",
35
+ extra,
36
+ );
37
+ }
38
+
39
+ export function secondaryPanelClasses(extra = "") {
40
+ return cx(
41
+ "rounded-[24px] border border-[var(--board-border)] bg-[var(--board-surface-2)]",
42
+ extra,
43
+ );
44
+ }
45
+
46
+ export function sectionLabelClasses() {
47
+ return "text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]";
48
+ }
49
+
50
+ export function neutralChipClasses() {
51
+ return "inline-flex items-center gap-1 rounded-full border border-[var(--board-border)] bg-white/[0.04] px-2.5 py-1 text-[11px] font-medium text-[var(--board-text-muted)]";
52
+ }
53
+
54
+ export function buttonClasses(options = {}) {
55
+ const kind = options.kind ?? "secondary";
56
+ const iconOnly = options.iconOnly ?? false;
57
+
58
+ return cx(
59
+ "inline-flex items-center justify-center gap-2 rounded-2xl border text-sm font-medium transition duration-200",
60
+ "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-bg)]",
61
+ iconOnly ? "h-10 w-10 px-0" : "min-h-10 px-4 py-2.5",
62
+ kind === "primary"
63
+ ? "border-[var(--board-accent)] bg-[var(--board-accent)] text-white hover:bg-[var(--board-accent-strong)] hover:border-[var(--board-accent-strong)]"
64
+ : "border-[var(--board-border)] bg-white/[0.04] text-[var(--board-text)] hover:bg-white/[0.08] hover:border-[var(--board-border-strong)]",
65
+ );
66
+ }
67
+
68
+ export function fieldClasses() {
69
+ return cx(
70
+ "w-full rounded-2xl border border-[var(--board-border)] bg-[var(--board-surface-2)] px-3.5 py-3 text-sm text-[var(--board-text)] shadow-sm transition",
71
+ "placeholder:text-[var(--board-text-soft)] focus:border-[var(--board-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--board-accent-soft)]",
72
+ "disabled:cursor-not-allowed disabled:opacity-60",
73
+ );
74
+ }
75
+
76
+ function statusBadgeClasses(status) {
77
+ return cx(
78
+ "inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
79
+ STATUS_BADGE_STYLES[normalizeStatus(status)] ?? STATUS_BADGE_STYLES.default,
80
+ );
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Render helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function renderIcon(name, className = "") {
88
+ return `<span class="${cx("material-symbols-rounded shrink-0", className)}" aria-hidden="true">${name}</span>`;
89
+ }
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
+
108
+ export function readStatusLabel(rawStatus) {
109
+ if (typeof rawStatus !== "string" || rawStatus.trim().length === 0) {
110
+ return "Unknown";
111
+ }
112
+
113
+ const normalized = normalizeStatus(rawStatus);
114
+ if (STATUS_LABELS[normalized]) {
115
+ return STATUS_LABELS[normalized];
116
+ }
117
+
118
+ return rawStatus.replaceAll("_", " ").replaceAll("-", " ");
119
+ }
120
+
121
+ export function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus)) {
122
+ return `<span class="${statusBadgeClasses(rawStatus)}">${escapeHtml(label)}</span>`;
123
+ }
124
+
125
+ export function renderStatusSelect(name, selectedStatus, disabled = false) {
126
+ return `
127
+ <select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
128
+ ${STATUS_ORDER.map((status) => `
129
+ <option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
130
+ `).join("")}
131
+ </select>
132
+ `;
133
+ }
134
+
135
+ export function renderEmptyState(title, description, shortcut) {
136
+ return `
137
+ <div class="rounded-[24px] border border-dashed border-[var(--board-border-strong)] bg-[var(--board-accent-soft)]/40 px-5 py-6 text-center">
138
+ <strong class="block text-base font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
139
+ <p class="mt-2 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(description)}</p>
140
+ ${shortcut
141
+ ? `<p class="mt-3 text-xs text-[var(--board-text-soft)]">Try <span class="inline-flex items-center rounded-lg border border-[var(--board-border)] bg-white/[0.04] px-2 py-1 font-medium text-[var(--board-text-muted)]">${escapeHtml(shortcut)}</span></p>`
142
+ : ""}
143
+ </div>
144
+ `;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Description rendering helpers
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export function renderDescriptionPreview(description, className = "mt-1 text-sm leading-6 text-[var(--board-text-muted)]") {
152
+ if (!description || description.trim().length === 0) return "";
153
+ return `<p class="${escapeHtml(className)}">${escapeHtml(description)}</p>`;
154
+ }
155
+
156
+ export function renderDescriptionBody(description, className = "text-sm leading-7 text-[var(--board-text-muted)]") {
157
+ if (!description || description.trim().length === 0) {
158
+ return `<p class="${escapeHtml(className)}">No description provided.</p>`;
159
+ }
160
+ return `<div class="${escapeHtml(className)}" style="white-space:pre-wrap;word-break:break-word">${escapeHtml(description)}</div>`;
161
+ }
162
+
163
+ export function shouldCollapseDescription(description) {
164
+ if (!description) return false;
165
+ const trimmed = description.trim();
166
+ return trimmed.length > 260 || trimmed.split("\n").length > 5;
167
+ }
168
+
169
+ export function renderDescriptionSection(title, description, options = {}) {
170
+ const {
171
+ open = false,
172
+ compact = false,
173
+ emptyText = "Add context so collaborators know what done looks like.",
174
+ } = options;
175
+
176
+ if (!description || description.trim().length === 0) {
177
+ return `
178
+ <section class="${secondaryPanelClasses("board-detail-card p-4")}">
179
+ <div class="board-section__header flex items-center justify-between gap-3">
180
+ <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
181
+ <span class="${neutralChipClasses()}">Empty</span>
182
+ </div>
183
+ <p class="mt-3 text-sm leading-6 text-[var(--board-text-muted)]">${escapeHtml(emptyText)}</p>
184
+ </section>
185
+ `;
186
+ }
187
+
188
+ if (!shouldCollapseDescription(description)) {
189
+ return `
190
+ <section class="${secondaryPanelClasses("board-detail-card p-4")}">
191
+ <div class="board-section__header flex items-center justify-between gap-3">
192
+ <strong class="text-sm font-semibold text-[var(--board-text)]">${escapeHtml(title)}</strong>
193
+ <span class="${neutralChipClasses()}">${escapeHtml(`${description.trim().length} chars`)}</span>
194
+ </div>
195
+ <div class="mt-3 ${compact ? "board-detail-copy board-detail-copy--compact" : "board-detail-copy"}">
196
+ ${renderDescriptionBody(description)}
197
+ </div>
198
+ </section>
199
+ `;
200
+ }
201
+
202
+ return `
203
+ <details class="board-disclosure ${secondaryPanelClasses("board-detail-card p-4")}" ${open ? "open" : ""}>
204
+ <summary class="board-detail-summary-row cursor-pointer list-none text-sm font-semibold text-[var(--board-text)]">
205
+ <span>${escapeHtml(title)}</span>
206
+ <span class="${neutralChipClasses()}">Long</span>
207
+ </summary>
208
+ <div class="mt-3 board-detail-copy ${compact ? "board-detail-copy--compact" : ""}">
209
+ ${renderDescriptionBody(description)}
210
+ </div>
211
+ </details>
212
+ `;
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Misc shared helpers
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export function readNodeLabel(kind, title) {
220
+ if (kind === "task") return `Task: ${title}`;
221
+ if (kind === "subtask") return `Subtask: ${title}`;
222
+ return title;
223
+ }
224
+
225
+ export function renderEpicCountSummary(epic) {
226
+ const totalTasks = Array.isArray(epic.taskIds) ? epic.taskIds.length : 0;
227
+ const counts = epic.counts || { todo: 0, blocked: 0, in_progress: 0, done: 0 };
228
+ return `
229
+ <span class="${neutralChipClasses()}">${totalTasks} task${totalTasks === 1 ? "" : "s"}</span>
230
+ <span class="${neutralChipClasses()}">${counts.in_progress ?? 0} doing</span>
231
+ <span class="${neutralChipClasses()}">${counts.done ?? 0} done</span>
232
+ `;
233
+ }
234
+
235
+ export function renderTaskMeta(task, includeStatus = false) {
236
+ return `
237
+ ${includeStatus ? renderStatusBadge(task.status) : ""}
238
+ <span class="${neutralChipClasses()}">${task.subtasks.length} subtask${task.subtasks.length === 1 ? "" : "s"}</span>
239
+ ${task.blockedBy.length > 0 ? `<span class="${neutralChipClasses()}">${task.blockedBy.length} blocker${task.blockedBy.length === 1 ? "" : "s"}</span>` : ""}
240
+ `;
241
+ }
242
+
243
+ export function hasLongTaskTitle(title) {
244
+ if (!title) return false;
245
+ const trimmed = title.trim();
246
+ return trimmed.length > 72 || trimmed.split("\n").length > 2;
247
+ }
248
+
249
+ export function isCompactViewport() {
250
+ return typeof window !== "undefined" && window.matchMedia?.("(max-width: 900px)")?.matches;
251
+ }
252
+
253
+ export function shouldUseTaskModal(boardState, store) {
254
+ return Boolean(boardState?.selectedTask);
255
+ }
256
+
257
+ export function lookupNode(snapshot, id) {
258
+ return snapshot.tasks.find((task) => task.id === id)
259
+ ?? snapshot.subtasks.find((subtask) => subtask.id === id)
260
+ ?? null;
261
+ }
@@ -3,72 +3,35 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="theme-color" content="#0b0d12" data-board-theme-color="active" />
8
+ <meta name="theme-color" content="#0b0d12" media="(prefers-color-scheme: dark)" />
9
+ <meta name="theme-color" content="#f4f6fb" media="(prefers-color-scheme: light)" />
6
10
  <meta
7
11
  name="description"
8
12
  content="Trekoon board — local-first workspace for browsing epics, tasks, and flow states."
9
13
  />
10
14
  <title>Trekoon Board</title>
11
- <link rel="preconnect" href="https://fonts.googleapis.com" />
12
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
13
- <link
14
- rel="stylesheet"
15
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Material+Symbols+Rounded:FILL@0&display=swap"
16
- />
17
- <script>
18
- tailwind = window.tailwind || {};
19
- tailwind.config = {
20
- theme: {
21
- extend: {
22
- fontFamily: {
23
- sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
24
- },
25
- boxShadow: {
26
- panel: "var(--board-shadow)",
27
- lift: "0 18px 52px rgba(0, 0, 0, 0.34)",
28
- focus: "0 0 0 1px var(--board-border-strong), 0 16px 40px rgba(88, 28, 135, 0.2)",
29
- },
30
- },
31
- },
32
- };
33
- </script>
34
- <script src="https://cdn.tailwindcss.com"></script>
15
+ <link rel="stylesheet" href="./styles/fonts.css" />
35
16
  <link rel="stylesheet" href="./styles/board.css" />
36
- <style>
37
- .material-symbols-rounded {
38
- font-family: "Material Symbols Rounded";
39
- font-weight: 400;
40
- font-style: normal;
41
- font-size: 20px;
42
- line-height: 1;
43
- letter-spacing: normal;
44
- text-transform: none;
45
- display: inline-block;
46
- white-space: nowrap;
47
- direction: ltr;
48
- font-variation-settings:
49
- "FILL" 0,
50
- "wght" 400,
51
- "GRAD" 0,
52
- "opsz" 20;
53
- }
54
- </style>
55
17
  </head>
56
18
  <body>
57
- <main id="app" aria-live="polite" class="min-h-screen">
58
- <section class="mx-auto flex min-h-screen max-w-4xl items-center justify-center px-4 py-10 sm:px-6">
59
- <div class="w-full rounded-[32px] border border-[var(--board-border)] bg-[var(--board-surface)]/95 p-8 text-center shadow-panel backdrop-blur-xl">
60
- <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--board-accent-soft)] text-[var(--board-accent)] ring-1 ring-[var(--board-border-strong)]">
61
- <span class="material-symbols-rounded" aria-hidden="true">rocket_launch</span>
62
- </div>
63
- <div class="mb-3 inline-flex items-center gap-2 rounded-full border border-[var(--board-border)] bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--board-text-soft)]">
64
- Trekoon board
19
+ <a href="#board-runtime-root" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-[var(--board-accent)] focus:px-4 focus:py-2 focus:text-white">
20
+ Skip to content
21
+ </a>
22
+
23
+ <main id="app">
24
+ <div class="board-shell-v2">
25
+ <section class="board-shell-v2__frame">
26
+ <div class="board-shell-v2__runtime-shell">
27
+ <div
28
+ id="board-runtime-root"
29
+ class="board-shell-v2__runtime"
30
+ data-board-runtime-root
31
+ ></div>
65
32
  </div>
66
- <h1 class="text-3xl font-semibold tracking-tight text-[var(--board-text)]">Preparing your local workspace…</h1>
67
- <p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-[var(--board-text-muted)] sm:text-base">
68
- Loading epics, tasks, and saved board context from your repo-shared storage.
69
- </p>
70
- </div>
71
- </section>
33
+ </section>
34
+ </div>
72
35
  </main>
73
36
 
74
37
  <script type="module" src="./main.js"></script>
@@ -1,20 +1,6 @@
1
- import { createApp, nextTick } from "https://unpkg.com/vue@3.5.13/dist/vue.esm-browser.js";
2
-
3
- import { createBoardShellComponent } from "./components/AppShell.js";
4
-
5
1
  window.__TREKOON_BOARD_BOOTSTRAP__ = "main";
6
2
 
7
- const appRoot = document.querySelector("#app");
8
-
9
- if (!(appRoot instanceof HTMLElement)) {
10
- throw new Error("Board shell could not find the app root.");
11
- }
12
-
13
- const shellApp = createApp(createBoardShellComponent());
14
- shellApp.mount(appRoot);
15
- await nextTick();
16
-
17
- const runtimeRoot = appRoot.querySelector("[data-board-runtime-root]");
3
+ const runtimeRoot = document.querySelector("[data-board-runtime-root]");
18
4
 
19
5
  if (!(runtimeRoot instanceof HTMLElement)) {
20
6
  throw new Error("Board shell could not find the runtime mount root.");
@@ -22,6 +8,4 @@ if (!(runtimeRoot instanceof HTMLElement)) {
22
8
 
23
9
  const { bootLegacyBoard } = await import("./app.js");
24
10
 
25
- await bootLegacyBoard({
26
- mountElement: runtimeRoot,
27
- });
11
+ await bootLegacyBoard({ mountElement: runtimeRoot });
@@ -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
+ }