@starlove/orbit 1.0.1

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 (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,153 @@
1
+ // "New card" flyout (top-right panel, separate from the main ticket
2
+ // drawer). Handles open/close transitions, form rendering, and submission.
3
+
4
+ import {
5
+ $,
6
+ createFlyout,
7
+ createFlyoutInner,
8
+ createFlyoutBackdrop
9
+ } from "./dom.js";
10
+ import { state, statesForProject, ticketsForProject } from "./state.js";
11
+ import { escapeHtml, ticketLabel } from "./format.js";
12
+ import { api } from "./api.js";
13
+ import { syncUrlFromState } from "./router.js";
14
+ import { closeDrawer } from "./drawer.js";
15
+ import { closeMenuFlyouts } from "./board-menu.js";
16
+ import { load } from "./app.js";
17
+
18
+ /** Clears any pending hide timeout when reopening the create flyout quickly. */
19
+ let createFlyoutHideTimer = 0;
20
+
21
+ export function isCreateFlyoutOpen() {
22
+ return createFlyout?.classList.contains("is-open");
23
+ }
24
+
25
+ /** Compact top-right panel for creating a card (separate from the main ticket drawer). */
26
+ export function openCreateFlyout(preselectedLaneId) {
27
+ clearTimeout(createFlyoutHideTimer);
28
+ closeMenuFlyouts();
29
+ closeDrawer();
30
+ createFlyoutInner.innerHTML = renderCreateForm(preselectedLaneId || "");
31
+ wireCreateForm();
32
+ $("#createFlyoutClose")?.addEventListener("click", () => closeCreateFlyout());
33
+ createFlyout.hidden = false;
34
+ createFlyoutBackdrop.hidden = false;
35
+ requestAnimationFrame(() => {
36
+ createFlyout.classList.add("is-open");
37
+ createFlyoutBackdrop.classList.add("is-visible");
38
+ });
39
+ requestAnimationFrame(() => {
40
+ $("#createTicketTitle")?.focus();
41
+ });
42
+ }
43
+
44
+ export function closeCreateFlyout() {
45
+ clearTimeout(createFlyoutHideTimer);
46
+ createFlyout.classList.remove("is-open");
47
+ createFlyoutBackdrop.classList.remove("is-visible");
48
+ createFlyoutHideTimer = setTimeout(() => {
49
+ createFlyout.hidden = true;
50
+ createFlyoutBackdrop.hidden = true;
51
+ createFlyoutInner.innerHTML = "";
52
+ }, 220);
53
+ }
54
+
55
+ /** HTML for the “New” flyout: lane + type + priority on one row, then parent and labels. */
56
+ function renderCreateForm(preselectedLaneId) {
57
+ const states = statesForProject();
58
+ const defaultState = states.find((item) => item.is_default) || states[0];
59
+ const effectiveStateId =
60
+ preselectedLaneId && states.some((s) => s.id === preselectedLaneId)
61
+ ? preselectedLaneId
62
+ : defaultState?.id || "";
63
+ const stateOptions = states
64
+ .map(
65
+ (item) =>
66
+ `<option value="${escapeHtml(item.id)}" ${item.id === effectiveStateId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
67
+ )
68
+ .join("");
69
+ const parentOptions = ticketsForProject()
70
+ .filter((ticket) => ticket.type === "epic")
71
+ .map((ticket) => `<option value="${escapeHtml(ticket.id)}">${escapeHtml(ticketLabel(ticket))}</option>`)
72
+ .join("");
73
+ return `
74
+ <div class="create-flyout-head">
75
+ <h2>New card</h2>
76
+ <button type="button" class="create-flyout-close ghost" id="createFlyoutClose" aria-label="Close">×</button>
77
+ </div>
78
+ <form id="createTicketForm" class="create-flyout-form">
79
+ <input id="createTicketTitle" name="title" type="text" placeholder="Title — what is this card?" autocomplete="off" required />
80
+ <textarea id="createTicketDescription" name="description" placeholder="Description (optional)"></textarea>
81
+ <div class="create-flyout-grid">
82
+ <label>
83
+ Lane
84
+ <select id="createTicketState" name="state" class="select-chevron-field" aria-label="Lane">${stateOptions}</select>
85
+ </label>
86
+ <label>
87
+ Type
88
+ <select id="createTicketType" name="type" class="select-chevron-field" aria-label="Ticket type">
89
+ <option value="epic">Epic / Story</option>
90
+ <option value="feature">Feature</option>
91
+ <option value="task" selected>Task</option>
92
+ <option value="bug">Bug</option>
93
+ </select>
94
+ </label>
95
+ <label>
96
+ Priority
97
+ <select id="createTicketPriority" name="priority" class="select-chevron-field" aria-label="Priority">
98
+ <option value="0">MAYBE</option>
99
+ <option value="1">LOW</option>
100
+ <option value="2" selected>MED</option>
101
+ <option value="3">HIGH</option>
102
+ <option value="4">URGENT</option>
103
+ </select>
104
+ </label>
105
+ </div>
106
+ <label>
107
+ Parent epic
108
+ <select id="createTicketParent" name="parent_ticket_id" class="select-chevron-field" aria-label="Parent epic">
109
+ <option value="">None</option>
110
+ ${parentOptions}
111
+ </select>
112
+ </label>
113
+ <input id="createTicketLabels" name="labels" type="text" placeholder="Labels (comma-separated)" />
114
+ <button type="submit">Create card</button>
115
+ </form>
116
+ `;
117
+ }
118
+
119
+ function wireCreateForm() {
120
+ const form = $("#createTicketForm");
121
+ if (!form) return;
122
+ form.addEventListener("submit", createTicketFromDrawer);
123
+ }
124
+
125
+ async function createTicketFromDrawer(event) {
126
+ event.preventDefault();
127
+ const form = event.currentTarget;
128
+ const data = new FormData(form);
129
+ const labels = String(data.get("labels") || "")
130
+ .split(",")
131
+ .map((item) => item.trim())
132
+ .filter(Boolean);
133
+
134
+ const created = await api("/api/tickets", {
135
+ method: "POST",
136
+ body: {
137
+ board_id: state.boardId,
138
+ title: data.get("title"),
139
+ description: data.get("description"),
140
+ type: data.get("type"),
141
+ parent_ticket_id: data.get("parent_ticket_id") || null,
142
+ state_id: data.get("state"),
143
+ priority: Number(data.get("priority")),
144
+ labels
145
+ }
146
+ });
147
+
148
+ closeCreateFlyout();
149
+ state.selectedTicketId = created.id;
150
+ state.detailMode = "ticket";
151
+ await load();
152
+ syncUrlFromState();
153
+ }
@@ -0,0 +1,19 @@
1
+ // Cached DOM references queried once at module-load time. Importing this
2
+ // module assumes the body has been parsed (the script tag is at the bottom
3
+ // of <body> and uses type="module", so this holds).
4
+
5
+ export const $ = (selector) => document.querySelector(selector);
6
+
7
+ export const boardMenuBtn = $("#boardMenuBtn");
8
+ export const boardFlyout = $("#boardFlyout");
9
+ export const kanban = $("#kanban");
10
+ export const drawer = $("#drawer");
11
+ export const drawerInner = $("#drawerInner");
12
+ export const drawerBackdrop = $("#drawerBackdrop");
13
+ export const createFlyout = $("#createFlyout");
14
+ export const createFlyoutInner = $("#createFlyoutInner");
15
+ export const createFlyoutBackdrop = $("#createFlyoutBackdrop");
16
+ export const searchInput = $("#searchInput");
17
+ export const searchResults = $("#searchResults");
18
+ export const themeToggle = $("#themeToggle");
19
+ export const themeIcon = $("#themeIcon");
@@ -0,0 +1,137 @@
1
+ // Right-side overlay drawer used for ticket detail and project settings.
2
+ // Owns the open/close transition state and the shared header layout
3
+ // (eyebrow, title, subtitle, tabs, archive/close buttons). Bodies are
4
+ // rendered by the feature modules that call `renderDrawerShell`.
5
+
6
+ import { drawer, drawerInner, drawerBackdrop } from "./dom.js";
7
+ import { state } from "./state.js";
8
+ import { escapeHtml } from "./format.js";
9
+
10
+ /** Bumps on each open/close so a stale `transitionend` from an old close cannot clear a newly opened drawer. */
11
+ let drawerToken = 0;
12
+
13
+ /** Slide the right drawer in (ticket detail, project settings, or create form). */
14
+ export function openDrawer() {
15
+ drawerToken += 1;
16
+ drawer.hidden = false;
17
+ drawerBackdrop.hidden = false;
18
+ requestAnimationFrame(() => {
19
+ drawer.classList.add("is-open");
20
+ drawerBackdrop.classList.add("is-visible");
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Close the drawer overlay. Uses transitionend with a timeout fallback for `hidden`.
26
+ * `drawerToken` invalidates pending finish callbacks if the user opens the drawer again mid-animation.
27
+ */
28
+ export function closeDrawer() {
29
+ const finishToken = ++drawerToken;
30
+ const wasOpen = drawer.classList.contains("is-open");
31
+ drawer.classList.remove("is-open");
32
+ drawerBackdrop.classList.remove("is-visible");
33
+
34
+ const finish = () => {
35
+ if (drawerToken !== finishToken) return;
36
+ drawer.hidden = true;
37
+ drawerBackdrop.hidden = true;
38
+ drawerInner.innerHTML = "";
39
+ const eyebrowEl = document.getElementById("drawerEyebrow");
40
+ const titleEl = document.getElementById("drawerTitle");
41
+ const subtitleEl = document.getElementById("drawerSubtitle");
42
+ 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; }
52
+ if (tabsEl) { tabsEl.innerHTML = ""; tabsEl.hidden = true; }
53
+ };
54
+
55
+ if (!wasOpen) {
56
+ finish();
57
+ return;
58
+ }
59
+
60
+ let ended = false;
61
+ const end = () => {
62
+ if (ended) return;
63
+ ended = true;
64
+ finish();
65
+ };
66
+
67
+ drawer.addEventListener("transitionend", end, { once: true });
68
+ setTimeout(end, 360);
69
+ }
70
+
71
+ /**
72
+ * Fill the drawer's header + body slots. All drawer modes (ticket, project,
73
+ * create) should route through this so the header/title/close/tabs stay
74
+ * structurally consistent. `body` is an HTML string assigned to drawerInner.
75
+ */
76
+ export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, tabs, activeTab, onTabSelect, body, mode }) {
77
+ drawer.classList.toggle("is-settings", mode === "settings");
78
+ const eyebrowEl = document.getElementById("drawerEyebrow");
79
+ const titleEl = document.getElementById("drawerTitle");
80
+ const subtitleEl = document.getElementById("drawerSubtitle");
81
+ const tabsEl = document.getElementById("drawerTabs");
82
+
83
+ if (eyebrow) {
84
+ eyebrowEl.textContent = eyebrow;
85
+ eyebrowEl.hidden = false;
86
+ } else {
87
+ eyebrowEl.textContent = "";
88
+ eyebrowEl.hidden = true;
89
+ }
90
+
91
+ if (subtitleHtml) {
92
+ subtitleEl.innerHTML = subtitleHtml;
93
+ subtitleEl.hidden = false;
94
+ } else {
95
+ subtitleEl.innerHTML = "";
96
+ subtitleEl.hidden = true;
97
+ }
98
+
99
+ // Reset prior edit-attrs / classes each render so different modes don't
100
+ // carry state from one another.
101
+ for (const attr of Array.from(titleEl.attributes)) {
102
+ if (attr.name !== "id") titleEl.removeAttribute(attr.name);
103
+ }
104
+ titleEl.className = "drawer-title-text";
105
+ if (titleAttrs) {
106
+ for (const [key, value] of Object.entries(titleAttrs)) {
107
+ if (key === "class") titleEl.className = `drawer-title-text ${value}`;
108
+ else titleEl.setAttribute(key, value);
109
+ }
110
+ }
111
+ titleEl.textContent = title ?? "";
112
+
113
+ if (tabs && tabs.length) {
114
+ tabsEl.hidden = false;
115
+ tabsEl.innerHTML = tabs
116
+ .map(
117
+ (t) =>
118
+ `<button type="button" role="tab" class="drawer-tab ${
119
+ t.id === activeTab ? "is-active" : ""
120
+ }" data-drawer-tab="${escapeHtml(t.id)}" aria-selected="${t.id === activeTab}">${escapeHtml(t.label)}</button>`
121
+ )
122
+ .join("");
123
+ if (typeof onTabSelect === "function") {
124
+ tabsEl.querySelectorAll(".drawer-tab").forEach((btn) => {
125
+ btn.addEventListener("click", () => onTabSelect(btn.dataset.drawerTab));
126
+ });
127
+ }
128
+ } else {
129
+ tabsEl.hidden = true;
130
+ tabsEl.innerHTML = "";
131
+ }
132
+
133
+ const archiveBtn = document.getElementById("drawerArchiveBtn");
134
+ if (archiveBtn) archiveBtn.hidden = state.detailMode !== "ticket";
135
+
136
+ drawerInner.innerHTML = body ?? "";
137
+ }
@@ -0,0 +1,108 @@
1
+ // Pure string helpers: escape, dates, ticket/type/priority labels.
2
+ // Zero side effects, no imports — safe to import from anywhere.
3
+
4
+ export function escapeHtml(value) {
5
+ return String(value ?? "")
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;")
10
+ .replaceAll("'", "&#039;");
11
+ }
12
+
13
+ export function formatDate(value) {
14
+ if (!value) return "";
15
+ return new Intl.DateTimeFormat(undefined, {
16
+ month: "short",
17
+ day: "numeric",
18
+ hour: "numeric",
19
+ minute: "2-digit"
20
+ }).format(new Date(value));
21
+ }
22
+
23
+ /** Richer timestamp for the detail pane “Updated” line (refreshes after every save). */
24
+ export function formatDateDetail(value) {
25
+ if (!value) return "";
26
+ return new Intl.DateTimeFormat(undefined, {
27
+ year: "numeric",
28
+ month: "short",
29
+ day: "numeric",
30
+ hour: "numeric",
31
+ minute: "2-digit",
32
+ second: "2-digit"
33
+ }).format(new Date(value));
34
+ }
35
+
36
+ export function ticketLabel(ticket) {
37
+ return `#${ticket.number}`;
38
+ }
39
+
40
+ export function canonicalTicketType(type) {
41
+ if (type === "epic" || type === "feature" || type === "bug" || type === "task") return type;
42
+ return "task";
43
+ }
44
+
45
+ // Short label for dense surfaces (card pills, child-card pills).
46
+ export function typeLabel(type) {
47
+ const labels = {
48
+ epic: "Epic",
49
+ feature: "Feature",
50
+ task: "Task",
51
+ bug: "Bug"
52
+ };
53
+ return labels[canonicalTicketType(type)] || "Task";
54
+ }
55
+
56
+ // Long, hierarchy-aware label for dropdowns where we have the room. Epic/Story
57
+ // pairs older vocabulary so existing terminology still resonates.
58
+ export function typeLabelLong(type) {
59
+ const labels = {
60
+ epic: "Epic / Story",
61
+ feature: "Feature",
62
+ task: "Task",
63
+ bug: "Bug"
64
+ };
65
+ return labels[canonicalTicketType(type)] || "Task";
66
+ }
67
+
68
+ export function priorityLabel(priority) {
69
+ const labels = {
70
+ 0: "MAYBE",
71
+ 1: "LOW",
72
+ 2: "MED",
73
+ 3: "HIGH",
74
+ 4: "URGENT"
75
+ };
76
+ return labels[Number(priority)] || "MED";
77
+ }
78
+
79
+ /** Lowercase key used for CSS class targeting: `priority-maybe`, `priority-urgent`, etc. */
80
+ export function priorityKeyFor(priority) {
81
+ const keys = ["maybe", "low", "med", "high", "urgent"];
82
+ const n = Number(priority);
83
+ return keys[Number.isFinite(n) ? Math.max(0, Math.min(4, n)) : 2];
84
+ }
85
+
86
+ export function renderLabels(labels = []) {
87
+ if (!labels.length) return "";
88
+ return `
89
+ <div class="labels">
90
+ ${labels
91
+ .map(
92
+ (label) =>
93
+ `<span class="label" style="--label-color: ${escapeHtml(label.color)}">${escapeHtml(label.name)}</span>`
94
+ )
95
+ .join("")}
96
+ </div>
97
+ `;
98
+ }
99
+
100
+ export function repoLabelFromUrl(repoUrl) {
101
+ const raw = String(repoUrl || "").trim();
102
+ if (!raw) return "Not set";
103
+ const githubHttps = raw.match(/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
104
+ if (githubHttps) return `${githubHttps[1]}/${githubHttps[2]}`;
105
+ const githubSsh = raw.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
106
+ if (githubSsh) return `${githubSsh[1]}/${githubSsh[2]}`;
107
+ return raw;
108
+ }