@starlove/orbit 1.1.2 → 1.2.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/SKILL-ORBIT.md +2 -0
- package/build.mjs +11 -5
- package/dist/full/index.html +169 -20
- package/dist/full/js/app.ccc442f7.js +706 -0
- package/dist/full/js/app.ccc442f7.js.map +7 -0
- package/dist/full/styles.baccc8e3.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/package.json +19 -18
- package/public/index.html +167 -18
- package/public/js/board-menu.js +16 -2
- package/public/js/card-actions.js +19 -0
- package/public/js/create-card.js +1 -1
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +7 -8
- package/public/js/kanban.js +167 -101
- package/public/js/main.js +2 -1
- package/public/js/motion-preference.js +14 -3
- package/public/js/search.js +1 -1
- package/public/js/settings.js +162 -46
- package/public/js/ticket-detail.js +92 -24
- package/public/styles.css +569 -476
- package/src/cli/orbit.js +22 -0
- package/src/core/board.js +1 -0
- package/src/core/css-bundle.js +29 -0
- package/src/core/db.js +19 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/http.js +10 -3
- package/src/core/registry.js +2 -2
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/snapshots.js +429 -9
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
- package/dist/full/styles.836872e7.css +0 -5962
package/public/js/kanban.js
CHANGED
|
@@ -19,106 +19,162 @@ import { api, withBoardQuery } from "./api.js";
|
|
|
19
19
|
import { navigate } from "./router.js";
|
|
20
20
|
import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
|
|
21
21
|
import { load } from "./app.js";
|
|
22
|
-
import {
|
|
22
|
+
import { bindCardContextMenuDelegated, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
|
|
23
23
|
import { unreadCount } from "./unread.js";
|
|
24
24
|
|
|
25
|
+
// Lookup for the current board's tickets, keyed by id. Refreshed on every
|
|
26
|
+
// renderBoard() so the single, persistent set of delegated listeners can
|
|
27
|
+
// resolve a card's ticket in O(1) without re-binding anything per render.
|
|
28
|
+
let boardTicketsById = new Map();
|
|
29
|
+
// Created once (lazily) and reused — handlers are stateless w.r.t. a given card.
|
|
30
|
+
let cardActionHandlers = null;
|
|
31
|
+
// HTML5 DnD ends with a synthetic click on the drag source; this flag lets the
|
|
32
|
+
// delegated click handler swallow that one click so a drop doesn't also open
|
|
33
|
+
// the ticket drawer. Module-scoped because there's now one shared listener.
|
|
34
|
+
let suppressClickAfterDrag = false;
|
|
35
|
+
|
|
36
|
+
// renderBoard() is hot: it runs on every drag-drop, every SSE board event, and
|
|
37
|
+
// every settings toggle. Keep it to a single innerHTML write plus a Map refresh.
|
|
38
|
+
// All interaction wiring lives in enableKanbanInteractions(), attached ONCE to
|
|
39
|
+
// the persistent `kanban` element (see below), so re-rendering never re-binds
|
|
40
|
+
// per-card/column/button listeners or rescans the ticket list.
|
|
25
41
|
export function renderBoard() {
|
|
26
42
|
const states = visibleStatesForProject();
|
|
27
43
|
const tickets = ticketsForProject();
|
|
44
|
+
boardTicketsById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
|
|
28
45
|
kanban.innerHTML = states.map((column) => renderColumn(column, tickets)).join("");
|
|
46
|
+
}
|
|
29
47
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
function toggleCardExpansion(card) {
|
|
49
|
+
if (!card) return;
|
|
50
|
+
const id = card.dataset.ticketId;
|
|
51
|
+
const willExpand = !state.expandedCardIds.has(id);
|
|
52
|
+
if (willExpand) {
|
|
53
|
+
state.expandedCardIds.add(id);
|
|
54
|
+
// Build the expandable body (markdown parse + labels + relations) the first
|
|
55
|
+
// time a card opens. Collapsed cards — the overwhelming majority on render —
|
|
56
|
+
// never pay this cost. Once built it stays cached in the DOM for re-toggles.
|
|
57
|
+
if (!card.querySelector(".card-expandable")) {
|
|
58
|
+
const ticket = boardTicketsById.get(id);
|
|
59
|
+
const meta = card.querySelector(".card-meta");
|
|
60
|
+
if (ticket && meta) meta.insertAdjacentHTML("afterend", renderCardExpandable(ticket));
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
state.expandedCardIds.delete(id);
|
|
64
|
+
}
|
|
65
|
+
card.classList.toggle("is-expanded", willExpand);
|
|
66
|
+
const toggleBtn = card.querySelector("button.card-expand-trigger");
|
|
67
|
+
if (toggleBtn) toggleBtn.setAttribute("aria-expanded", willExpand ? "true" : "false");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One-time setup: attach delegated listeners to the persistent board container.
|
|
71
|
+
// Because events bubble to `kanban`, these survive every innerHTML swap, so
|
|
72
|
+
// renderBoard() does zero listener work regardless of card count.
|
|
73
|
+
export function enableKanbanInteractions() {
|
|
74
|
+
cardActionHandlers = cardActionHandlers || createCardActionHandlers({ navigator: navigate });
|
|
75
|
+
|
|
76
|
+
// --- Drag & drop (HTML5 DnD events bubble, so one set covers all cards) ---
|
|
77
|
+
let dragOverColumn = null;
|
|
78
|
+
const setDragOverColumn = (column) => {
|
|
79
|
+
if (dragOverColumn === column) return;
|
|
80
|
+
if (dragOverColumn) dragOverColumn.classList.remove("drag-over");
|
|
81
|
+
dragOverColumn = column;
|
|
82
|
+
if (dragOverColumn) dragOverColumn.classList.add("drag-over");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
kanban.addEventListener("dragstart", (event) => {
|
|
86
|
+
const card = event.target instanceof Element ? event.target.closest(".card") : null;
|
|
87
|
+
if (!card) return;
|
|
88
|
+
closeCardActionMenu();
|
|
89
|
+
setCardActionDragInFlight(true);
|
|
90
|
+
event.dataTransfer.setData("text/plain", card.dataset.ticketId);
|
|
91
|
+
event.dataTransfer.effectAllowed = "move";
|
|
50
92
|
});
|
|
51
93
|
|
|
52
|
-
kanban.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
kanban.addEventListener("dragover", (event) => {
|
|
95
|
+
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
96
|
+
if (!column) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
setDragOverColumn(column);
|
|
57
99
|
});
|
|
58
100
|
|
|
59
|
-
kanban.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
kanban.addEventListener("dragend", () => {
|
|
102
|
+
setCardActionDragInFlight(false);
|
|
103
|
+
setDragOverColumn(null);
|
|
104
|
+
suppressClickAfterDrag = true;
|
|
105
|
+
// If no synthetic click follows (e.g. drag cancelled oddly), don't eat the
|
|
106
|
+
// next real click.
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
suppressClickAfterDrag = false;
|
|
109
|
+
}, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
kanban.addEventListener("drop", async (event) => {
|
|
113
|
+
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
114
|
+
if (!column) return;
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setDragOverColumn(null);
|
|
117
|
+
const ticketId = event.dataTransfer.getData("text/plain");
|
|
118
|
+
if (!ticketId) return;
|
|
119
|
+
closeCreateFlyout();
|
|
120
|
+
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
121
|
+
method: "PATCH",
|
|
122
|
+
body: { state_id: column.dataset.stateId }
|
|
67
123
|
});
|
|
124
|
+
// Refresh board only; do not open the ticket drawer (DnD also fires a
|
|
125
|
+
// synthetic click on the card — suppressed by suppressClickAfterDrag).
|
|
126
|
+
await load();
|
|
68
127
|
});
|
|
69
128
|
|
|
70
|
-
|
|
71
|
-
kanban.
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
// --- Clicks (precedence: add-button > expand toggle > link > epic header > card) ---
|
|
130
|
+
kanban.addEventListener("click", (event) => {
|
|
131
|
+
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
|
132
|
+
if (!target) return;
|
|
133
|
+
|
|
134
|
+
const addBtn = target.closest("[data-add-to-state]");
|
|
135
|
+
if (addBtn) {
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
openCreateFlyout(addBtn.dataset.addToState || "");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toggle = target.closest("button.card-expand-trigger");
|
|
142
|
+
if (toggle) {
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
toggleCardExpansion(toggle.closest(".card"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Anchors inside a card keep their native behavior.
|
|
150
|
+
if (target.closest("a")) {
|
|
151
|
+
event.stopPropagation();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const epicHeader = target.closest(".epic-mini-header[data-ticket-id]");
|
|
156
|
+
if (epicHeader) {
|
|
89
157
|
closeCreateFlyout();
|
|
90
|
-
navigate({
|
|
91
|
-
|
|
92
|
-
view: "ticket",
|
|
93
|
-
ticketId: card.dataset.ticketId
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
const toggleBtn = card.querySelector("button.card-expand-trigger");
|
|
97
|
-
if (toggleBtn) {
|
|
98
|
-
toggleBtn.addEventListener("click", (event) => {
|
|
99
|
-
event.stopPropagation();
|
|
100
|
-
event.preventDefault();
|
|
101
|
-
const id = card.dataset.ticketId;
|
|
102
|
-
if (state.expandedCardIds.has(id)) state.expandedCardIds.delete(id);
|
|
103
|
-
else state.expandedCardIds.add(id);
|
|
104
|
-
const expanded = card.classList.toggle("is-expanded");
|
|
105
|
-
toggleBtn.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
106
|
-
});
|
|
158
|
+
navigate({ boardId: state.boardId, view: "ticket", ticketId: epicHeader.dataset.ticketId });
|
|
159
|
+
return;
|
|
107
160
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
|
|
162
|
+
const card = target.closest(".card");
|
|
163
|
+
if (!card) return;
|
|
164
|
+
if (suppressClickAfterDrag) {
|
|
165
|
+
suppressClickAfterDrag = false;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
event.stopPropagation();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
closeCreateFlyout();
|
|
171
|
+
navigate({ boardId: state.boardId, view: "ticket", ticketId: card.dataset.ticketId });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// --- Context menu ---
|
|
175
|
+
bindCardContextMenuDelegated(kanban, (id) => boardTicketsById.get(id), {
|
|
176
|
+
handlers: cardActionHandlers,
|
|
177
|
+
onBeforeOpen: closeCreateFlyout
|
|
122
178
|
});
|
|
123
179
|
}
|
|
124
180
|
|
|
@@ -213,17 +269,18 @@ function renderColumn(column, tickets) {
|
|
|
213
269
|
// are the queue the claim-next MCP tool pulls from. Mark it so humans can
|
|
214
270
|
// instantly see where agent-facing staging lives.
|
|
215
271
|
const isAiReady = column.name === "AI Ready";
|
|
216
|
-
const columnClasses = ["column"];
|
|
272
|
+
const columnClasses = ["column", "lane"];
|
|
217
273
|
if (isAiReady) columnClasses.push("column--ai-ready");
|
|
274
|
+
const accentAttr = isAiReady ? ' data-accent="ai-ready"' : "";
|
|
218
275
|
|
|
219
276
|
return `
|
|
220
|
-
<section class="${columnClasses.join(" ")}" data-state-id="${column.id}" data-state-name="${escapeHtml(column.name)}">
|
|
221
|
-
<div class="column-head">
|
|
222
|
-
<h2${isAiReady ? ' title="Agent staging — cards here are picked up by the claim-next tool when unblocked"' : ""}>
|
|
277
|
+
<section class="${columnClasses.join(" ")}" data-flat${accentAttr} data-state-id="${column.id}" data-state-name="${escapeHtml(column.name)}">
|
|
278
|
+
<div class="column-head lane-head">
|
|
279
|
+
<h2 class="lane-title"${isAiReady ? ' title="Agent staging — cards here are picked up by the claim-next tool when unblocked"' : ""}>
|
|
223
280
|
${isAiReady ? '<span class="agent-dot" aria-hidden="true"></span>' : ""}${escapeHtml(column.name)}
|
|
224
281
|
</h2>
|
|
225
|
-
<div class="column-head-actions">
|
|
226
|
-
<button type="button" class="column-add-btn
|
|
282
|
+
<div class="column-head-actions lane-actions">
|
|
283
|
+
<button type="button" class="column-add-btn" data-variant="plus" data-add-to-state="${escapeHtml(column.id)}" title="New card">+</button>
|
|
227
284
|
<span class="count">${columnTickets.length}</span>
|
|
228
285
|
</div>
|
|
229
286
|
</div>
|
|
@@ -319,7 +376,7 @@ function renderEpicMiniHeader(epic) {
|
|
|
319
376
|
export function renderPriorityPill(priority) {
|
|
320
377
|
const key = priorityKeyFor(priority);
|
|
321
378
|
const label = priorityLabel(priority);
|
|
322
|
-
return `<div class="card-priority-id priority-pill
|
|
379
|
+
return `<div class="card-priority-id priority-pill" data-variant="${escapeHtml(key)}"><span class="card-priority-label">${escapeHtml(label)}</span></div>`;
|
|
323
380
|
}
|
|
324
381
|
|
|
325
382
|
export function renderTypeIcon(type) {
|
|
@@ -378,28 +435,37 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
|
|
|
378
435
|
: `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
|
|
379
436
|
|
|
380
437
|
return `
|
|
381
|
-
<article class="card type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}${isExpanded ? " is-expanded" : ""}${asEpicChild ? " is-epic-child" : ""} ${ticket.id === state.selectedTicketId ? "selected" : ""}" draggable="true" data-ticket-id="${ticket.id}">
|
|
438
|
+
<article class="card type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}${isExpanded ? " is-expanded" : ""}${asEpicChild ? " is-epic-child" : ""} ${ticket.id === state.selectedTicketId ? "selected" : ""}" data-variant="${escapeHtml(canonicalType)}" draggable="true" data-ticket-id="${ticket.id}">
|
|
382
439
|
${unreadDot}
|
|
383
440
|
<h3>${escapeHtml(ticket.title)}</h3>
|
|
384
441
|
|
|
385
442
|
<div class="card-meta">
|
|
386
443
|
${hasExpandable
|
|
387
|
-
? `<button type="button" class="card-expand-trigger" data-card-toggle aria-expanded="${isExpanded ? "true" : "false"}">${triggerInner}</button>`
|
|
444
|
+
? `<button type="button" class="card-expand-trigger" data-variant="card-accordion" data-card-toggle aria-expanded="${isExpanded ? "true" : "false"}">${triggerInner}</button>`
|
|
388
445
|
: `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
|
|
389
446
|
</div>
|
|
390
447
|
|
|
391
|
-
${hasExpandable ?
|
|
392
|
-
<div class="card-expandable">
|
|
393
|
-
${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
|
|
394
|
-
${renderLabels(ticket.labels)}
|
|
395
|
-
${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
|
|
396
|
-
${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
|
|
397
|
-
</div>
|
|
398
|
-
` : ""}
|
|
448
|
+
${hasExpandable && isExpanded ? renderCardExpandable(ticket) : ""}
|
|
399
449
|
</article>
|
|
400
450
|
`;
|
|
401
451
|
}
|
|
402
452
|
|
|
453
|
+
// The expandable body holds the only genuinely expensive per-card work: a full
|
|
454
|
+
// markdown parse of the description. It's rendered eagerly only for cards that
|
|
455
|
+
// are already expanded; collapsed cards get it injected on first open (see
|
|
456
|
+
// toggleCardExpansion). Kept as its own function so both paths share one source.
|
|
457
|
+
function renderCardExpandable(ticket) {
|
|
458
|
+
const hasDescription = Boolean(String(ticket.description || "").trim());
|
|
459
|
+
return `
|
|
460
|
+
<div class="card-expandable">
|
|
461
|
+
${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
|
|
462
|
+
${renderLabels(ticket.labels)}
|
|
463
|
+
${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
|
|
464
|
+
${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
|
|
465
|
+
</div>
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
|
|
403
469
|
/**
|
|
404
470
|
* Click-and-drag horizontal panning on the kanban container.
|
|
405
471
|
* Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
|
package/public/js/main.js
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
closeCreateFlyout,
|
|
37
37
|
isCreateFlyoutOpen
|
|
38
38
|
} from "./create-card.js";
|
|
39
|
-
import { enableKanbanDragScroll } from "./kanban.js";
|
|
39
|
+
import { enableKanbanDragScroll, enableKanbanInteractions } from "./kanban.js";
|
|
40
40
|
import { runSearch, hideSearch } from "./search.js";
|
|
41
41
|
import { load } from "./app.js";
|
|
42
42
|
import { edition } from "./config.js";
|
|
@@ -131,6 +131,7 @@ async function init() {
|
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
enableKanbanDragScroll();
|
|
134
|
+
enableKanbanInteractions();
|
|
134
135
|
|
|
135
136
|
const initialRoute = currentRoute();
|
|
136
137
|
if (initialRoute.boardSlug) state.boardSlug = initialRoute.boardSlug;
|
|
@@ -47,7 +47,18 @@ export function applyReducedMotionPreference({
|
|
|
47
47
|
storage = safeStorage(),
|
|
48
48
|
mediaMatcher = defaultMediaMatcher
|
|
49
49
|
} = {}) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Starscape v3 `data-motion` contract (three-state; shared with the
|
|
51
|
+
// ui-system-v3 package and ss-nebula). Replaces the legacy two-state
|
|
52
|
+
// `data-reduced-motion="reduce|allow"`:
|
|
53
|
+
// • stored reduce → data-motion="reduce" (always reduce)
|
|
54
|
+
// • stored allow → data-motion="full" (ignore OS reduce)
|
|
55
|
+
// • no stored pref → no attribute (follow OS prefers-reduced-motion)
|
|
56
|
+
const root = doc?.documentElement;
|
|
57
|
+
const stored = storedReducedMotionPreference(storage);
|
|
58
|
+
if (root) {
|
|
59
|
+
if (stored === true) root.setAttribute("data-motion", "reduce");
|
|
60
|
+
else if (stored === false) root.setAttribute("data-motion", "full");
|
|
61
|
+
else root.removeAttribute("data-motion");
|
|
62
|
+
}
|
|
63
|
+
return effectiveReducedMotionPreference({ storage, mediaMatcher });
|
|
53
64
|
}
|
package/public/js/search.js
CHANGED
|
@@ -32,7 +32,7 @@ export async function runSearch() {
|
|
|
32
32
|
<strong>${escapeHtml(ticketLabel(ticket))}</strong>
|
|
33
33
|
<span class="search-hit-title">${escapeHtml(ticket.title)}</span>
|
|
34
34
|
</span>
|
|
35
|
-
<span class="search-hit-state
|
|
35
|
+
<span class="search-hit-state" data-variant="${escapeHtml(stateClassFor(ticket))}">${escapeHtml(ticket.state_name || "State")}</span>
|
|
36
36
|
</div>
|
|
37
37
|
`
|
|
38
38
|
)
|