@starlove/orbit 1.1.1 → 1.1.11

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 (103) hide show
  1. package/.dockerignore +0 -0
  2. package/Dockerfile +0 -0
  3. package/LICENSE +0 -0
  4. package/README.md +2 -2
  5. package/SKILL-ORBIT.md +2 -0
  6. package/build.mjs +0 -0
  7. package/dist/full/apple-touch-icon.png +0 -0
  8. package/dist/full/cursors/live_arrow.cur +0 -0
  9. package/dist/full/cursors/live_link.cur +0 -0
  10. package/dist/full/cursors/live_move.cur +0 -0
  11. package/dist/full/cursors/live_select.cur +0 -0
  12. package/dist/full/cursors/live_unavail.cur +0 -0
  13. package/dist/full/favicon-32x32.png +0 -0
  14. package/dist/full/index.html +160 -15
  15. package/dist/full/js/app.55b38e12.js +698 -0
  16. package/dist/full/js/app.55b38e12.js.map +7 -0
  17. package/dist/full/orbits-favicon.png +0 -0
  18. package/dist/full/orbits-logo-bg.png +0 -0
  19. package/dist/full/orbits-logo.png +0 -0
  20. package/dist/full/{styles.836872e7.css → styles.273fc37a.css} +79 -39
  21. package/dist/full/version.json +2 -2
  22. package/docs/AGENT_PROTOCOL.md +22 -6
  23. package/docs/MCP_SETUP.md +0 -0
  24. package/docs/ORBIT_DISPATCH.md +68 -8
  25. package/docs/SS-Orbit-Dark.png +0 -0
  26. package/docs/SS-Orbit-Light.png +0 -0
  27. package/docs/mcp-client.example.json +0 -0
  28. package/docs/setup/orbit-init-wizard.md +0 -0
  29. package/package.json +1 -1
  30. package/public/apple-touch-icon.png +0 -0
  31. package/public/cursors/live_arrow.cur +0 -0
  32. package/public/cursors/live_link.cur +0 -0
  33. package/public/cursors/live_move.cur +0 -0
  34. package/public/cursors/live_select.cur +0 -0
  35. package/public/cursors/live_unavail.cur +0 -0
  36. package/public/favicon-32x32.png +0 -0
  37. package/public/index.html +158 -13
  38. package/public/js/actor-labels.js +0 -0
  39. package/public/js/api.js +0 -0
  40. package/public/js/app.js +0 -0
  41. package/public/js/board-menu.js +0 -0
  42. package/public/js/card-actions.js +19 -0
  43. package/public/js/config.js +0 -0
  44. package/public/js/create-card.js +0 -0
  45. package/public/js/dom.js +0 -0
  46. package/public/js/drawer.js +17 -43
  47. package/public/js/format.js +5 -5
  48. package/public/js/kanban.js +157 -92
  49. package/public/js/local-backend.js +15 -4
  50. package/public/js/main.js +2 -1
  51. package/public/js/modal.js +0 -0
  52. package/public/js/motion-preference.js +14 -3
  53. package/public/js/router.js +0 -0
  54. package/public/js/search.js +0 -0
  55. package/public/js/settings-tabs.js +0 -0
  56. package/public/js/settings.js +131 -27
  57. package/public/js/sse.js +0 -0
  58. package/public/js/starscape.js +0 -0
  59. package/public/js/state.js +0 -0
  60. package/public/js/theme-preference.js +0 -0
  61. package/public/js/ticket-detail.js +88 -20
  62. package/public/js/toast.js +0 -0
  63. package/public/js/transport.js +0 -0
  64. package/public/js/unread.js +0 -0
  65. package/public/js/url-routes.js +0 -0
  66. package/public/orbits-favicon.png +0 -0
  67. package/public/orbits-logo-bg.png +0 -0
  68. package/public/orbits-logo.png +0 -0
  69. package/public/styles.css +79 -39
  70. package/src/cli/orbit.js +22 -0
  71. package/src/core/agent.js +25 -4
  72. package/src/core/agents-md.js +0 -0
  73. package/src/core/attachments.js +0 -0
  74. package/src/core/auth.js +0 -0
  75. package/src/core/backups.js +0 -0
  76. package/src/core/board.js +2 -1
  77. package/src/core/boards.js +0 -0
  78. package/src/core/bootstrap.js +0 -0
  79. package/src/core/db.js +19 -0
  80. package/src/core/delete-board.js +0 -0
  81. package/src/core/dispatch.js +178 -13
  82. package/src/core/events.js +0 -0
  83. package/src/core/http.js +0 -0
  84. package/src/core/migrate.js +0 -0
  85. package/src/core/paths.js +0 -0
  86. package/src/core/provision-repo-board.js +0 -0
  87. package/src/core/queries.js +0 -0
  88. package/src/core/registry.js +2 -2
  89. package/src/core/relations.js +0 -0
  90. package/src/core/review-verdicts.js +138 -0
  91. package/src/core/router.js +38 -3
  92. package/src/core/search.js +0 -0
  93. package/src/core/seed.js +0 -0
  94. package/src/core/snapshots.js +429 -9
  95. package/src/core/states.js +0 -0
  96. package/src/core/system-picker.js +0 -0
  97. package/src/core/tickets.js +0 -0
  98. package/src/core/util.js +0 -0
  99. package/src/mcp/orbit-client.js +47 -19
  100. package/src/mcp-server.js +122 -26
  101. package/src/server.js +0 -0
  102. package/dist/full/js/app.a4f045fc.js +0 -676
  103. package/dist/full/js/app.a4f045fc.js.map +0 -7
package/public/js/api.js CHANGED
File without changes
package/public/js/app.js CHANGED
File without changes
File without changes
@@ -245,6 +245,25 @@ export function bindCardContextMenu(card, ticket, { handlers = createCardActionH
245
245
  });
246
246
  }
247
247
 
248
+ // Delegated variant: a single listener on the board container resolves the card
249
+ // and its ticket per event. Lets renderBoard() swap innerHTML freely without
250
+ // re-binding a contextmenu listener onto every card on every render.
251
+ export function bindCardContextMenuDelegated(container, resolveTicket, { handlers = createCardActionHandlers(), onBeforeOpen = null } = {}) {
252
+ container.addEventListener("contextmenu", (event) => {
253
+ const card = event.target instanceof Element ? event.target.closest(".card") : null;
254
+ if (!card) return;
255
+ const ticket = resolveTicket(card.dataset.ticketId);
256
+ if (!ticket) return;
257
+ event.preventDefault();
258
+ event.stopPropagation();
259
+ if (dragInFlight) {
260
+ closeCardActionMenu();
261
+ return;
262
+ }
263
+ openCardActionMenu({ ticket, anchor: card, x: event.clientX, y: event.clientY, handlers, onBeforeOpen });
264
+ });
265
+ }
266
+
248
267
  function renderMenuItems(items) {
249
268
  return `<ul class="card-action-menu-list" role="none">${items.map(renderMenuItem).join("")}</ul>`;
250
269
  }
File without changes
File without changes
package/public/js/dom.js CHANGED
File without changes
@@ -36,19 +36,9 @@ export function closeDrawer() {
36
36
  drawer.hidden = true;
37
37
  drawerBackdrop.hidden = true;
38
38
  drawerInner.innerHTML = "";
39
- const eyebrowEl = document.getElementById("drawerEyebrow");
40
- const titleEl = document.getElementById("drawerTitle");
41
- const subtitleEl = document.getElementById("drawerSubtitle");
39
+ const titleBlockEl = document.getElementById("drawerTitleBlock");
42
40
  const tabsEl = document.getElementById("drawerTabs");
43
- if (eyebrowEl) { eyebrowEl.textContent = ""; eyebrowEl.hidden = true; }
44
- if (titleEl) {
45
- for (const attr of Array.from(titleEl.attributes)) {
46
- if (attr.name !== "id") titleEl.removeAttribute(attr.name);
47
- }
48
- titleEl.className = "drawer-title-text";
49
- titleEl.textContent = "";
50
- }
51
- if (subtitleEl) { subtitleEl.innerHTML = ""; subtitleEl.hidden = true; }
41
+ if (titleBlockEl) titleBlockEl.innerHTML = "";
52
42
  if (tabsEl) { tabsEl.innerHTML = ""; tabsEl.hidden = true; }
53
43
  drawer.classList.remove("is-wide");
54
44
  };
@@ -77,40 +67,24 @@ export function closeDrawer() {
77
67
  export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, tabs, activeTab, onTabSelect, body, mode }) {
78
68
  drawer.classList.toggle("is-settings", mode === "settings");
79
69
  drawer.classList.toggle("is-wide", mode === "ticket" || mode === "settings");
80
- const eyebrowEl = document.getElementById("drawerEyebrow");
81
- const titleEl = document.getElementById("drawerTitle");
82
- const subtitleEl = document.getElementById("drawerSubtitle");
70
+ const titleBlockEl = document.getElementById("drawerTitleBlock");
83
71
  const tabsEl = document.getElementById("drawerTabs");
84
72
 
85
- if (eyebrow) {
86
- eyebrowEl.textContent = eyebrow;
87
- eyebrowEl.hidden = false;
88
- } else {
89
- eyebrowEl.textContent = "";
90
- eyebrowEl.hidden = true;
91
- }
92
-
93
- if (subtitleHtml) {
94
- subtitleEl.innerHTML = subtitleHtml;
95
- subtitleEl.hidden = false;
96
- } else {
97
- subtitleEl.innerHTML = "";
98
- subtitleEl.hidden = true;
99
- }
100
-
101
- // Reset prior edit-attrs / classes each render so different modes don't
102
- // carry state from one another.
103
- for (const attr of Array.from(titleEl.attributes)) {
104
- if (attr.name !== "id") titleEl.removeAttribute(attr.name);
105
- }
106
- titleEl.className = "drawer-title-text";
107
- if (titleAttrs) {
108
- for (const [key, value] of Object.entries(titleAttrs)) {
109
- if (key === "class") titleEl.className = `drawer-title-text ${value}`;
110
- else titleEl.setAttribute(key, value);
111
- }
73
+ // Rebuild the eyebrow / title / subtitle wholesale each render. Like
74
+ // drawerInner.innerHTML below, this means any listeners the caller attached
75
+ // last render (e.g. the inline title editor's click handler) die with the
76
+ // old nodes, instead of stacking on persistent shell elements.
77
+ const extraTitleClass = titleAttrs?.class ? ` ${titleAttrs.class}` : "";
78
+ let titleAttrHtml = ` class="drawer-title-text${extraTitleClass}"`;
79
+ for (const [key, value] of Object.entries(titleAttrs || {})) {
80
+ if (key === "class") continue;
81
+ titleAttrHtml += ` ${key}="${escapeHtml(String(value))}"`;
112
82
  }
113
- titleEl.textContent = title ?? "";
83
+ const segments = [];
84
+ if (eyebrow) segments.push(`<span class="drawer-eyebrow">${escapeHtml(eyebrow)}</span>`);
85
+ segments.push(`<h2${titleAttrHtml}>${escapeHtml(title ?? "")}</h2>`);
86
+ if (subtitleHtml) segments.push(`<div class="drawer-subtitle">${subtitleHtml}</div>`);
87
+ titleBlockEl.innerHTML = segments.join("");
114
88
 
115
89
  if (tabs && tabs.length) {
116
90
  tabsEl.hidden = false;
@@ -11,9 +11,9 @@ export function escapeHtml(value) {
11
11
  }
12
12
 
13
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.
14
+ // Ported from a local fix-newlines utility — every entry is [mojibake sequence,
15
+ // what it should have been]. Long sequences first so shorter prefixes do not
16
+ // eat them.
17
17
  const MOJIBAKE_REPLACEMENTS = [
18
18
  // General Punctuation (UTF-8 E2 80 XX → CP-437 "ΓÇ" + glyph)
19
19
  ["ΓÇô", "–"],
@@ -84,8 +84,8 @@ const MOJIBAKE_REPLACEMENTS = [
84
84
  ["┬º", "§"],
85
85
  ["┬╖", "·"],
86
86
 
87
- // Stray curly quotes that survived as proper Unicode — Bruce writes with
88
- // ASCII straight quotes, so normalize for consistency.
87
+ // Stray curly quotes that survived as proper Unicode; normalize to ASCII
88
+ // straight quotes so pasted terminal/log text stays consistent.
89
89
  ["“", '"'],
90
90
  ["”", '"'],
91
91
  ["‘", "'"],
@@ -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 { bindCardContextMenu, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
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
- kanban.querySelectorAll(".column").forEach((column) => {
31
- column.addEventListener("dragover", (event) => {
32
- event.preventDefault();
33
- column.classList.add("drag-over");
34
- });
35
- column.addEventListener("dragleave", () => column.classList.remove("drag-over"));
36
- column.addEventListener("drop", async (event) => {
37
- event.preventDefault();
38
- column.classList.remove("drag-over");
39
- const ticketId = event.dataTransfer.getData("text/plain");
40
- if (!ticketId) return;
41
- closeCreateFlyout();
42
- await api(withBoardQuery(`/api/tickets/${ticketId}`), {
43
- method: "PATCH",
44
- body: { state_id: column.dataset.stateId }
45
- });
46
- // Refresh board only; do not open the ticket drawer (DnD also fires a
47
- // synthetic click on the card — suppressed in the card listener).
48
- await load();
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.querySelectorAll("[data-add-to-state]").forEach((btn) => {
53
- btn.addEventListener("click", (event) => {
54
- event.stopPropagation();
55
- openCreateFlyout(btn.dataset.addToState || "");
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.querySelectorAll(".epic-mini-header[data-ticket-id]").forEach((header) => {
60
- header.addEventListener("click", () => {
61
- closeCreateFlyout();
62
- navigate({
63
- boardId: state.boardId,
64
- view: "ticket",
65
- ticketId: header.dataset.ticketId
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
- const cardActionHandlers = createCardActionHandlers({ navigator: navigate });
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 });
74
- // HTML5 DnD ends with a synthetic click on the source element; ignore that
75
- // follow-up click so dropping a card does not also open the detail drawer.
76
- let suppressClickAfterDrag = false;
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
- }
83
- if (suppressClickAfterDrag) {
84
- suppressClickAfterDrag = false;
85
- event.preventDefault();
86
- event.stopPropagation();
87
- return;
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
- boardId: state.boardId,
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
- card.addEventListener("dragstart", (event) => {
109
- closeCardActionMenu();
110
- setCardActionDragInFlight(true);
111
- event.dataTransfer.setData("text/plain", card.dataset.ticketId);
112
- event.dataTransfer.effectAllowed = "move";
113
- });
114
- card.addEventListener("dragend", () => {
115
- setCardActionDragInFlight(false);
116
- suppressClickAfterDrag = true;
117
- // If no click follows (e.g. drag cancelled oddly), do not eat the next real click.
118
- setTimeout(() => {
119
- suppressClickAfterDrag = false;
120
- }, 0);
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
 
@@ -388,18 +444,27 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
388
444
  : `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
389
445
  </div>
390
446
 
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
- ` : ""}
447
+ ${hasExpandable && isExpanded ? renderCardExpandable(ticket) : ""}
399
448
  </article>
400
449
  `;
401
450
  }
402
451
 
452
+ // The expandable body holds the only genuinely expensive per-card work: a full
453
+ // markdown parse of the description. It's rendered eagerly only for cards that
454
+ // are already expanded; collapsed cards get it injected on first open (see
455
+ // toggleCardExpansion). Kept as its own function so both paths share one source.
456
+ function renderCardExpandable(ticket) {
457
+ const hasDescription = Boolean(String(ticket.description || "").trim());
458
+ return `
459
+ <div class="card-expandable">
460
+ ${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
461
+ ${renderLabels(ticket.labels)}
462
+ ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
463
+ ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
464
+ </div>
465
+ `;
466
+ }
467
+
403
468
  /**
404
469
  * Click-and-drag horizontal panning on the kanban container.
405
470
  * Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
@@ -726,7 +726,7 @@ async function handleTicketArchive(ticketId, archive) {
726
726
  });
727
727
  }
728
728
 
729
- async function handleTicketContext(ticketId) {
729
+ async function handleTicketContext(ticketId, { includeProjectContext = false } = {}) {
730
730
  const db = await openDb();
731
731
  const snap = await readBoardSnapshot(db);
732
732
  const ticket = snap.tickets.find((t) => t.id === ticketId);
@@ -776,16 +776,26 @@ async function handleTicketContext(ticketId) {
776
776
  other_ticket: compact(other)
777
777
  };
778
778
  });
779
- return {
779
+ const context = {
780
780
  ticket: { ...ticket, labels },
781
- board: snap.board,
782
- board_manual: { entries: snap.boardEntries.filter((e) => !e.struck_at) },
781
+ board: includeProjectContext
782
+ ? snap.board
783
+ : {
784
+ id: snap.board.id,
785
+ slug: snap.board.slug,
786
+ name: snap.board.name,
787
+ repo_path: snap.board.system_path || "",
788
+ system_path: snap.board.system_path || "",
789
+ default_branch: snap.board.default_branch || "main"
790
+ },
783
791
  parent_ticket: compact(parent),
784
792
  child_tickets: childTickets,
785
793
  relations,
786
794
  blockers: [],
787
795
  related_tickets: [...childTickets, ...(parent ? [parent] : [])]
788
796
  };
797
+ if (includeProjectContext) context.board_manual = { entries: snap.boardEntries.filter((e) => !e.struck_at) };
798
+ return context;
789
799
  }
790
800
 
791
801
  async function handleTicketComments(ticketId) {
@@ -955,6 +965,7 @@ const ROUTES = [
955
965
  { m: "DELETE", re: /^\/api\/tickets\/([^/]+)$/, fn: (m) => handleTicketDelete(decodeURIComponent(m[1])) },
956
966
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
957
967
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
968
+ { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context\/full$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1]), { includeProjectContext: true }) },
958
969
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
959
970
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
960
971
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
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;
File without changes
@@ -47,7 +47,18 @@ export function applyReducedMotionPreference({
47
47
  storage = safeStorage(),
48
48
  mediaMatcher = defaultMediaMatcher
49
49
  } = {}) {
50
- const reduce = effectiveReducedMotionPreference({ storage, mediaMatcher });
51
- doc?.documentElement?.setAttribute("data-reduced-motion", reduce ? "reduce" : "allow");
52
- return reduce;
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
  }
File without changes
File without changes
File without changes