@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.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +28 -1
- package/build.mjs +2 -2
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.a4f045fc.js +676 -0
- package/dist/full/js/app.a4f045fc.js.map +7 -0
- package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +27 -5
- package/docs/MCP_SETUP.md +7 -3
- package/docs/ORBIT_DISPATCH.md +185 -0
- package/docs/setup/orbit-init-wizard.md +174 -0
- package/package.json +2 -2
- package/public/js/actor-labels.js +65 -0
- package/public/js/api.js +5 -1
- package/public/js/app.js +8 -1
- package/public/js/card-actions.js +526 -0
- package/public/js/config.js +2 -1
- package/public/js/drawer.js +2 -0
- package/public/js/format.js +336 -0
- package/public/js/kanban.js +25 -2
- package/public/js/local-backend.js +24 -8
- package/public/js/main.js +14 -6
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +78 -58
- package/public/js/search.js +6 -3
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +192 -29
- package/public/js/starscape.js +25 -18
- package/public/js/state.js +61 -2
- package/public/js/theme-preference.js +88 -0
- package/public/js/ticket-detail.js +432 -126
- package/public/js/url-routes.js +94 -0
- package/public/styles.css +1117 -47
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +1 -18
- package/src/core/agents-md.js +16 -10
- package/src/core/attachments.js +238 -0
- package/src/core/auth.js +36 -0
- package/src/core/backups.js +9 -3
- package/src/core/board.js +32 -1
- package/src/core/boards.js +1 -1
- package/src/core/bootstrap.js +9 -1
- package/src/core/db.js +42 -2
- package/src/core/delete-board.js +87 -21
- package/src/core/dispatch.js +532 -0
- package/src/core/http.js +31 -11
- package/src/core/migrate.js +198 -0
- package/src/core/paths.js +6 -0
- package/src/core/registry.js +12 -9
- package/src/core/router.js +62 -6
- package/src/core/search.js +56 -22
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +223 -0
- package/src/mcp-server.js +89 -129
- package/src/server.js +20 -0
- package/dist/full/js/app.aedc1bfe.js +0 -554
- package/dist/full/js/app.aedc1bfe.js.map +0 -7
package/public/js/format.js
CHANGED
|
@@ -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";
|
package/public/js/kanban.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
131
|
-
// view; otherwise canonicalize the URL to reflect the current
|
|
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
|
-
|
|
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
|
+
}
|