@starlove/orbit 1.0.6 → 1.1.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 (58) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +28 -1
  3. package/build.mjs +2 -2
  4. package/dist/full/index.html +2 -2
  5. package/dist/full/js/app.a4f045fc.js +676 -0
  6. package/dist/full/js/app.a4f045fc.js.map +7 -0
  7. package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
  8. package/dist/full/version.json +2 -2
  9. package/docs/AGENT_PROTOCOL.md +27 -5
  10. package/docs/MCP_SETUP.md +7 -3
  11. package/docs/ORBIT_DISPATCH.md +185 -0
  12. package/docs/setup/orbit-init-wizard.md +174 -0
  13. package/package.json +2 -2
  14. package/public/js/actor-labels.js +65 -0
  15. package/public/js/api.js +5 -1
  16. package/public/js/app.js +8 -1
  17. package/public/js/card-actions.js +526 -0
  18. package/public/js/config.js +2 -1
  19. package/public/js/drawer.js +2 -0
  20. package/public/js/format.js +336 -0
  21. package/public/js/kanban.js +25 -2
  22. package/public/js/local-backend.js +24 -8
  23. package/public/js/main.js +14 -6
  24. package/public/js/motion-preference.js +53 -0
  25. package/public/js/router.js +78 -58
  26. package/public/js/search.js +6 -3
  27. package/public/js/settings-tabs.js +1 -1
  28. package/public/js/settings.js +192 -29
  29. package/public/js/starscape.js +25 -18
  30. package/public/js/state.js +61 -2
  31. package/public/js/theme-preference.js +88 -0
  32. package/public/js/ticket-detail.js +432 -126
  33. package/public/js/url-routes.js +94 -0
  34. package/public/styles.css +1117 -47
  35. package/src/cli/orbit.js +217 -32
  36. package/src/core/agent.js +1 -18
  37. package/src/core/agents-md.js +16 -10
  38. package/src/core/attachments.js +238 -0
  39. package/src/core/auth.js +36 -0
  40. package/src/core/backups.js +9 -3
  41. package/src/core/board.js +32 -1
  42. package/src/core/boards.js +1 -1
  43. package/src/core/bootstrap.js +9 -1
  44. package/src/core/db.js +42 -2
  45. package/src/core/delete-board.js +87 -21
  46. package/src/core/dispatch.js +532 -0
  47. package/src/core/http.js +31 -11
  48. package/src/core/migrate.js +198 -0
  49. package/src/core/paths.js +6 -0
  50. package/src/core/registry.js +12 -9
  51. package/src/core/router.js +62 -6
  52. package/src/core/search.js +56 -22
  53. package/src/core/snapshots.js +14 -2
  54. package/src/mcp/orbit-client.js +223 -0
  55. package/src/mcp-server.js +89 -129
  56. package/src/server.js +20 -0
  57. package/dist/full/js/app.aedc1bfe.js +0 -554
  58. package/dist/full/js/app.aedc1bfe.js.map +0 -7
@@ -10,6 +10,333 @@ export function escapeHtml(value) {
10
10
  .replaceAll("'", "'");
11
11
  }
12
12
 
13
+ // UTF-8 bytes mis-decoded as CP-437 (the typical Windows-tmux artifact).
14
+ // Ported from aeva-dashboard's fix-newlines tool — every entry is
15
+ // [mojibake sequence, what it should have been]. Long sequences first so
16
+ // shorter prefixes do not eat them.
17
+ const MOJIBAKE_REPLACEMENTS = [
18
+ // General Punctuation (UTF-8 E2 80 XX → CP-437 "ΓÇ" + glyph)
19
+ ["ΓÇô", "–"],
20
+ ["ΓÇö", "—"],
21
+ ["ΓÇ£", '"'],
22
+ ["ΓÇ¥", '"'],
23
+ ["ΓÇÿ", "'"],
24
+ ["ΓÇÖ", "'"],
25
+ ["ΓǪ", "…"],
26
+ ["ΓÇó", "•"],
27
+ ["ΓÇ░", "‰"],
28
+ ["ΓÇ╣", "‹"],
29
+ ["ΓÇ║", "›"],
30
+
31
+ // Box drawing — light (UTF-8 E2 94 XX → CP-437 "Γö" + glyph)
32
+ ["ΓöÇ", "─"],
33
+ ["Γöé", "│"],
34
+ ["Γöî", "┌"],
35
+ ["ΓöÉ", "┐"],
36
+ ["Γöö", "└"],
37
+ ["Γöÿ", "┘"],
38
+ ["Γö£", "├"],
39
+ ["Γöñ", "┤"],
40
+ ["Γö¼", "┬"],
41
+ ["Γö┤", "┴"],
42
+ ["Γö╝", "┼"],
43
+
44
+ // Box drawing — heavy/double + rounded corners (UTF-8 E2 95 XX → "Γò" + glyph)
45
+ ["ΓòÉ", "═"],
46
+ ["Γòæ", "║"],
47
+ ["ΓòÆ", "╒"],
48
+ ["Γòö", "╔"],
49
+ ["Γò¡", "╭"],
50
+ ["Γò«", "╮"],
51
+ ["Γò»", "╯"],
52
+ ["Γò░", "╰"],
53
+
54
+ // Geometric shapes (UTF-8 E2 96 XX / E2 97 XX → "Γû" / "Γù" + glyph)
55
+ ["Γûæ", "░"],
56
+ ["ΓûÆ", "▒"],
57
+ ["Γûô", "▓"],
58
+ ["ΓûÉ", "▐"],
59
+ ["Γûî", "▌"],
60
+ ["ΓûÄ", ">"],
61
+ ["Γûá", "■"],
62
+ ["Γû╝", "▼"],
63
+ ["ΓùÅ", "●"],
64
+ ["Γùï", "○"],
65
+
66
+ // Misc symbols (UTF-8 E2 9A XX → "ΓÜ" + glyph)
67
+ ["ΓÜò", "⚕"],
68
+ ["ΓÜá", "⚠"],
69
+ ["ΓÜí", "⚡"],
70
+ ["ΓÜô", "⚓"],
71
+
72
+ // Arrows (UTF-8 E2 86 XX → "Γå" + glyph)
73
+ ["ΓåÉ", "←"],
74
+ ["Γåæ", "↑"],
75
+ ["ΓåÆ", "→"],
76
+ ["Γåô", "↓"],
77
+ ["Γåö", "↔"],
78
+
79
+ // Latin-1 Supplement (UTF-8 C2 XX → CP-437 "┬" + glyph)
80
+ [" ", " "],
81
+ ["┬⌐", "©"],
82
+ ["┬«", "®"],
83
+ ["┬░", "°"],
84
+ ["┬º", "§"],
85
+ ["┬╖", "·"],
86
+
87
+ // Stray curly quotes that survived as proper Unicode — Bruce writes with
88
+ // ASCII straight quotes, so normalize for consistency.
89
+ ["“", '"'],
90
+ ["”", '"'],
91
+ ["‘", "'"],
92
+ ["’", "'"],
93
+ ];
94
+
95
+ function fixMojibake(text) {
96
+ if (!text) return text;
97
+ let out = text;
98
+ for (const [from, to] of MOJIBAKE_REPLACEMENTS) {
99
+ out = out.split(from).join(to);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Repair common mojibake and normalize line endings without disturbing the
106
+ * author's indentation or soft line breaks. Use this on inbound text before
107
+ * saving (so the DB stays clean going forward) and again before rendering (so
108
+ * existing dirty rows display fixed without a migration).
109
+ */
110
+ export function cleanText(value) {
111
+ if (!value) return "";
112
+ return fixMojibake(String(value)).replace(/\r\n?/g, "\n");
113
+ }
114
+
115
+ export function renderPreservedText(value) {
116
+ return escapeHtml(cleanText(value));
117
+ }
118
+
119
+ const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
120
+
121
+ function sanitizeMarkdownUrl(value) {
122
+ const href = String(value || "").trim();
123
+ if (!href) return "";
124
+
125
+ // Collapse control characters/whitespace before checking the scheme so inputs
126
+ // like `java\nscript:` or `jav\tascript:` cannot bypass the blocklist.
127
+ const compact = href.replace(/[\u0000-\u001f\u007f\s]+/g, "").toLowerCase();
128
+ if (/^(?:javascript|data|vbscript):/.test(compact)) return "";
129
+
130
+ const scheme = compact.match(/^([a-z][a-z0-9+.-]*):/);
131
+ if (scheme && !SAFE_LINK_PROTOCOLS.has(`${scheme[1]}:`)) return "";
132
+
133
+ return href;
134
+ }
135
+
136
+ function renderEmphasis(text) {
137
+ let html = escapeHtml(text);
138
+ html = html.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>");
139
+ html = html.replace(/(\*|_)(?=\S)([^*_]*?\S)\1/g, "<em>$2</em>");
140
+ return html;
141
+ }
142
+
143
+ function renderLinksAndEmphasis(text) {
144
+ const linkPattern = /\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
145
+ let html = "";
146
+ let lastIndex = 0;
147
+ let match;
148
+
149
+ while ((match = linkPattern.exec(text))) {
150
+ html += renderEmphasis(text.slice(lastIndex, match.index));
151
+
152
+ const labelHtml = renderEmphasis(match[1]);
153
+ const href = sanitizeMarkdownUrl(match[2]);
154
+ html += href
155
+ ? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${labelHtml}</a>`
156
+ : labelHtml;
157
+
158
+ lastIndex = match.index + match[0].length;
159
+ }
160
+
161
+ html += renderEmphasis(text.slice(lastIndex));
162
+ return html;
163
+ }
164
+
165
+ function renderInlineMarkdown(text) {
166
+ const source = String(text ?? "");
167
+ let html = "";
168
+ let cursor = 0;
169
+
170
+ while (cursor < source.length) {
171
+ const codeStart = source.indexOf("`", cursor);
172
+ if (codeStart === -1) {
173
+ html += renderLinksAndEmphasis(source.slice(cursor));
174
+ break;
175
+ }
176
+
177
+ const codeEnd = source.indexOf("`", codeStart + 1);
178
+ if (codeEnd === -1) {
179
+ html += renderLinksAndEmphasis(source.slice(cursor));
180
+ break;
181
+ }
182
+
183
+ html += renderLinksAndEmphasis(source.slice(cursor, codeStart));
184
+ html += `<code>${escapeHtml(source.slice(codeStart + 1, codeEnd))}</code>`;
185
+ cursor = codeEnd + 1;
186
+ }
187
+
188
+ return html;
189
+ }
190
+
191
+ function renderParagraph(lines) {
192
+ return `<p>${renderInlineMarkdown(lines.join("\n")).replaceAll("\n", "<br>")}</p>`;
193
+ }
194
+
195
+ function renderTaskItem(rawContent) {
196
+ // GFM-style `- [ ] text` or `- [x] text`. We render as a disabled checkbox
197
+ // followed by the inline-rendered remainder. The list itself gets a
198
+ // `task-list` class so CSS can drop the default bullet.
199
+ const match = rawContent.match(/^\[( |x|X)\]\s+(.*)$/);
200
+ if (!match) return null;
201
+ const checked = match[1].toLowerCase() === "x";
202
+ const body = renderInlineMarkdown(match[2].trim()).replaceAll("\n", "<br>");
203
+ return {
204
+ checked,
205
+ html: `<input type="checkbox" disabled${checked ? " checked" : ""}> ${body}`,
206
+ };
207
+ }
208
+
209
+ export function renderMarkdown(value) {
210
+ // Mojibake repair runs at render time too so existing dirty rows display
211
+ // cleanly without a backfill. cleanText also normalizes line endings.
212
+ const source = cleanText(value);
213
+ if (!source.trim()) return "";
214
+
215
+ const blocks = [];
216
+ const lines = source.split("\n");
217
+ let paragraph = [];
218
+ let list = null;
219
+ let blockquote = null;
220
+
221
+ const flushParagraph = () => {
222
+ if (!paragraph.length) return;
223
+ blocks.push(renderParagraph(paragraph));
224
+ paragraph = [];
225
+ };
226
+
227
+ const flushList = () => {
228
+ if (!list) return;
229
+ const classAttr = list.taskList ? ' class="task-list"' : "";
230
+ blocks.push(
231
+ `<${list.type}${classAttr}>${list.items
232
+ .map((item) => `<li${item.taskClass ? ` class="${item.taskClass}"` : ""}>${item.html}</li>`)
233
+ .join("")}</${list.type}>`
234
+ );
235
+ list = null;
236
+ };
237
+
238
+ const flushBlockquote = () => {
239
+ if (!blockquote) return;
240
+ blocks.push(`<blockquote>${renderMarkdown(blockquote.join("\n"))}</blockquote>`);
241
+ blockquote = null;
242
+ };
243
+
244
+ const flushAll = () => {
245
+ flushParagraph();
246
+ flushList();
247
+ flushBlockquote();
248
+ };
249
+
250
+ for (let i = 0; i < lines.length; i += 1) {
251
+ const line = lines[i];
252
+
253
+ if (/^\s*```/.test(line)) {
254
+ flushAll();
255
+ const codeLines = [];
256
+ i += 1;
257
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
258
+ codeLines.push(lines[i]);
259
+ i += 1;
260
+ }
261
+ blocks.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
262
+ continue;
263
+ }
264
+
265
+ // Horizontal rule: a line of three+ dashes / asterisks / underscores.
266
+ if (/^\s*(?:-\s*){3,}\s*$/.test(line) || /^\s*(?:\*\s*){3,}\s*$/.test(line) || /^\s*(?:_\s*){3,}\s*$/.test(line)) {
267
+ flushAll();
268
+ blocks.push("<hr>");
269
+ continue;
270
+ }
271
+
272
+ // ATX header: 1-6 # followed by a space and content.
273
+ const header = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
274
+ if (header) {
275
+ flushAll();
276
+ const level = header[1].length;
277
+ blocks.push(`<h${level}>${renderInlineMarkdown(header[2])}</h${level}>`);
278
+ continue;
279
+ }
280
+
281
+ // Blockquote: `> ...`. We collect consecutive `>` lines, strip the leading
282
+ // marker, and recurse so quoted content can itself contain lists/headers.
283
+ const quoteMatch = line.match(/^\s{0,3}>\s?(.*)$/);
284
+ if (quoteMatch) {
285
+ flushParagraph();
286
+ flushList();
287
+ if (!blockquote) blockquote = [];
288
+ blockquote.push(quoteMatch[1]);
289
+ continue;
290
+ }
291
+ if (blockquote && !line.trim()) {
292
+ flushBlockquote();
293
+ continue;
294
+ }
295
+ flushBlockquote();
296
+
297
+ if (!line.trim()) {
298
+ flushParagraph();
299
+ flushList();
300
+ continue;
301
+ }
302
+
303
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
304
+ const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
305
+ if (ordered || unordered) {
306
+ flushParagraph();
307
+ const type = ordered ? "ol" : "ul";
308
+ const content = (ordered || unordered)[1].trim();
309
+
310
+ let task = null;
311
+ if (unordered) task = renderTaskItem(content);
312
+
313
+ if (!list || list.type !== type || Boolean(list.taskList) !== Boolean(task)) {
314
+ flushList();
315
+ list = { type, items: [], taskList: Boolean(task) };
316
+ }
317
+
318
+ if (task) {
319
+ list.items.push({
320
+ html: task.html,
321
+ taskClass: task.checked ? "task-item task-item-done" : "task-item",
322
+ });
323
+ } else {
324
+ list.items.push({
325
+ html: renderInlineMarkdown(content).replaceAll("\n", "<br>"),
326
+ });
327
+ }
328
+ continue;
329
+ }
330
+
331
+ flushList();
332
+ paragraph.push(line);
333
+ }
334
+
335
+ flushAll();
336
+
337
+ return blocks.join("");
338
+ }
339
+
13
340
  export function formatDate(value) {
14
341
  if (!value) return "";
15
342
  return new Intl.DateTimeFormat(undefined, {
@@ -37,6 +364,15 @@ export function ticketLabel(ticket) {
37
364
  return `#${ticket.number}`;
38
365
  }
39
366
 
367
+ export function stateClassFor(ticketOrState) {
368
+ const source =
369
+ ticketOrState?.state_role || ticketOrState?.role || ticketOrState?.state_name || ticketOrState?.name || "state";
370
+ return String(source)
371
+ .toLowerCase()
372
+ .replace(/[^a-z0-9]+/g, "-")
373
+ .replace(/^-|-$/g, "") || "state";
374
+ }
375
+
40
376
  export function canonicalTicketType(type) {
41
377
  if (type === "epic" || type === "feature" || type === "bug" || type === "task") return type;
42
378
  return "task";
@@ -12,12 +12,14 @@ import {
12
12
  typeLabel,
13
13
  priorityLabel,
14
14
  priorityKeyFor,
15
- renderLabels
15
+ renderLabels,
16
+ renderMarkdown
16
17
  } from "./format.js";
17
18
  import { api, withBoardQuery } from "./api.js";
18
19
  import { navigate } from "./router.js";
19
20
  import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
20
21
  import { load } from "./app.js";
22
+ import { bindCardContextMenu, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
21
23
  import { unreadCount } from "./unread.js";
22
24
 
23
25
  export function renderBoard() {
@@ -65,11 +67,19 @@ export function renderBoard() {
65
67
  });
66
68
  });
67
69
 
70
+ const cardActionHandlers = createCardActionHandlers({ navigator: navigate });
68
71
  kanban.querySelectorAll(".card").forEach((card) => {
72
+ const ticket = tickets.find((item) => item.id === card.dataset.ticketId);
73
+ if (ticket) bindCardContextMenu(card, ticket, { handlers: cardActionHandlers, onBeforeOpen: closeCreateFlyout });
69
74
  // HTML5 DnD ends with a synthetic click on the source element; ignore that
70
75
  // follow-up click so dropping a card does not also open the detail drawer.
71
76
  let suppressClickAfterDrag = false;
72
77
  card.addEventListener("click", async (event) => {
78
+ const clickTarget = event.target instanceof Element ? event.target : event.target?.parentElement;
79
+ if (clickTarget?.closest("a")) {
80
+ event.stopPropagation();
81
+ return;
82
+ }
73
83
  if (suppressClickAfterDrag) {
74
84
  suppressClickAfterDrag = false;
75
85
  event.preventDefault();
@@ -96,10 +106,13 @@ export function renderBoard() {
96
106
  });
97
107
  }
98
108
  card.addEventListener("dragstart", (event) => {
109
+ closeCardActionMenu();
110
+ setCardActionDragInFlight(true);
99
111
  event.dataTransfer.setData("text/plain", card.dataset.ticketId);
100
112
  event.dataTransfer.effectAllowed = "move";
101
113
  });
102
114
  card.addEventListener("dragend", () => {
115
+ setCardActionDragInFlight(false);
103
116
  suppressClickAfterDrag = true;
104
117
  // If no click follows (e.g. drag cancelled oddly), do not eat the next real click.
105
118
  setTimeout(() => {
@@ -109,6 +122,14 @@ export function renderBoard() {
109
122
  });
110
123
  }
111
124
 
125
+ export function renderBoardSelection() {
126
+ kanban.querySelectorAll(".card.selected").forEach((card) => card.classList.remove("selected"));
127
+ if (!state.selectedTicketId) return;
128
+ kanban
129
+ .querySelectorAll(`.card[data-ticket-id="${CSS.escape(state.selectedTicketId)}"]`)
130
+ .forEach((card) => card.classList.add("selected"));
131
+ }
132
+
112
133
  function renderEmptyState(columnName) {
113
134
  const name = columnName.toLowerCase();
114
135
  let flair = {
@@ -336,7 +357,8 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
336
357
  const priorityKey = priorityKeyFor(ticket.priority);
337
358
  const typeText = typeLabel(ticket.type).toUpperCase();
338
359
  const isExpanded = state.expandedCardIds.has(ticket.id);
339
- const hasExpandable = (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
360
+ const hasDescription = Boolean(String(ticket.description || "").trim());
361
+ const hasExpandable = hasDescription || (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
340
362
 
341
363
  const triggerInner = `
342
364
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
@@ -368,6 +390,7 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
368
390
 
369
391
  ${hasExpandable ? `
370
392
  <div class="card-expandable">
393
+ ${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
371
394
  ${renderLabels(ticket.labels)}
372
395
  ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
373
396
  ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
@@ -464,7 +464,6 @@ async function handleBoardPatch(boardId, body) {
464
464
  const board = await reqPromise(stores.boards.get(BOARD_ID));
465
465
  const ALLOWED = [
466
466
  "name",
467
- "slug",
468
467
  "repo_url",
469
468
  "system_path",
470
469
  "default_branch",
@@ -472,7 +471,16 @@ async function handleBoardPatch(boardId, body) {
472
471
  "agent_instructions",
473
472
  "ai_enabled"
474
473
  ];
475
- for (const k of ALLOWED) if (k in body) board[k] = body[k];
474
+ for (const k of ALLOWED) {
475
+ if (!(k in body)) continue;
476
+ if (k === "name") {
477
+ const name = String(body[k] || "").trim();
478
+ if (!name) throw err(400, "missing_name");
479
+ board[k] = name;
480
+ } else {
481
+ board[k] = body[k];
482
+ }
483
+ }
476
484
  board.updated_at = nowIso();
477
485
  stores.boards.put(board);
478
486
  recordEvent(stores, "board_updated", null, { fields: Object.keys(body) });
@@ -728,9 +736,6 @@ async function handleTicketContext(ticketId) {
728
736
  .map((tl) => snap.labels.find((l) => l.id === tl.label_id))
729
737
  .filter(Boolean)
730
738
  .map((l) => ({ ticket_id: ticketId, id: l.id, name: l.name, color: l.color }));
731
- const comments = snap.comments
732
- .filter((c) => c.ticket_id === ticketId)
733
- .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
734
739
  const stateById = Object.fromEntries(snap.states.map((s) => [s.id, s]));
735
740
  const compact = (t) => {
736
741
  if (!t) return null;
@@ -775,13 +780,23 @@ async function handleTicketContext(ticketId) {
775
780
  ticket: { ...ticket, labels },
776
781
  board: snap.board,
777
782
  board_manual: { entries: snap.boardEntries.filter((e) => !e.struck_at) },
778
- comments,
779
783
  parent_ticket: compact(parent),
780
784
  child_tickets: childTickets,
781
785
  relations,
782
786
  blockers: [],
783
- related_tickets: [...childTickets, ...(parent ? [parent] : [])],
784
- related_comments: []
787
+ related_tickets: [...childTickets, ...(parent ? [parent] : [])]
788
+ };
789
+ }
790
+
791
+ async function handleTicketComments(ticketId) {
792
+ const snap = await snapshotAll();
793
+ const ticket = snap.tickets.find((t) => t.id === ticketId && !t.archived_at);
794
+ if (!ticket) throw err(404, "ticket_not_found");
795
+ return {
796
+ ticket_id: ticketId,
797
+ comments: snap.comments
798
+ .filter((c) => c.ticket_id === ticketId)
799
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
785
800
  };
786
801
  }
787
802
 
@@ -941,6 +956,7 @@ const ROUTES = [
941
956
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
942
957
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
943
958
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
959
+ { m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
944
960
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
945
961
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m, u, body) => handleCommentCreate(decodeURIComponent(m[1]), body) },
946
962
  { m: "POST", re: /^\/api\/relations$/, fn: (m, u, body) => handleRelationCreate(body) },
package/public/js/main.js CHANGED
@@ -18,7 +18,14 @@ import {
18
18
  import { state } from "./state.js";
19
19
  import { api, withBoardQuery } from "./api.js";
20
20
  import { toast, debounce } from "./toast.js";
21
- import { navigate, applyRoute, syncUrlFromState, setRouterReady } from "./router.js";
21
+ import {
22
+ navigate,
23
+ applyRoute,
24
+ currentRoute,
25
+ hasRoute,
26
+ syncUrlFromState,
27
+ setRouterReady
28
+ } from "./router.js";
22
29
  import {
23
30
  openBoardFlyout,
24
31
  closeBoardFlyout,
@@ -125,14 +132,15 @@ async function init() {
125
132
 
126
133
  enableKanbanDragScroll();
127
134
 
135
+ const initialRoute = currentRoute();
136
+ if (initialRoute.boardSlug) state.boardSlug = initialRoute.boardSlug;
128
137
  await load();
129
138
 
130
- // Activate the router. If the page was loaded with a hash, restore that
131
- // view; otherwise canonicalize the URL to reflect the current state so the
132
- // back button has a starting point.
139
+ // Activate the router. If the page was loaded with a hash route, restore
140
+ // that view; otherwise canonicalize the URL to reflect the current
141
+ // state so the back button has a starting point.
133
142
  setRouterReady(true);
134
- const initialHash = location.hash;
135
- if (initialHash && initialHash !== "#" && initialHash !== "#/") {
143
+ if (hasRoute()) {
136
144
  await applyRoute();
137
145
  } else {
138
146
  syncUrlFromState({ replace: true });
@@ -0,0 +1,53 @@
1
+ export const REDUCE_MOTION_STORAGE_KEY = "mab_reduce_motion";
2
+
3
+ function safeStorage(storage) {
4
+ try {
5
+ return storage ?? globalThis.localStorage ?? null;
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ function defaultMediaMatcher(query) {
12
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
13
+ ? window.matchMedia(query)
14
+ : { matches: false };
15
+ }
16
+
17
+ export function storedReducedMotionPreference(storage = safeStorage()) {
18
+ const store = safeStorage(storage);
19
+ const value = store?.getItem?.(REDUCE_MOTION_STORAGE_KEY);
20
+ if (value === "true") return true;
21
+ if (value === "false") return false;
22
+ return null;
23
+ }
24
+
25
+ export function browserPrefersReducedMotion(mediaMatcher = defaultMediaMatcher) {
26
+ try {
27
+ return !!mediaMatcher("(prefers-reduced-motion: reduce)")?.matches;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export function effectiveReducedMotionPreference({ storage = safeStorage(), mediaMatcher = defaultMediaMatcher } = {}) {
34
+ const stored = storedReducedMotionPreference(storage);
35
+ return stored ?? browserPrefersReducedMotion(mediaMatcher);
36
+ }
37
+
38
+ export function setReducedMotionPreference(reduce, storage = safeStorage()) {
39
+ const store = safeStorage(storage);
40
+ const value = reduce ? "true" : "false";
41
+ store?.setItem?.(REDUCE_MOTION_STORAGE_KEY, value);
42
+ return reduce;
43
+ }
44
+
45
+ export function applyReducedMotionPreference({
46
+ doc = globalThis.document,
47
+ storage = safeStorage(),
48
+ mediaMatcher = defaultMediaMatcher
49
+ } = {}) {
50
+ const reduce = effectiveReducedMotionPreference({ storage, mediaMatcher });
51
+ doc?.documentElement?.setAttribute("data-reduced-motion", reduce ? "reduce" : "allow");
52
+ return reduce;
53
+ }