@starlove/orbit 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/Dockerfile +2 -1
  2. package/README.md +10 -9
  3. package/SKILL-ORBIT.md +41 -17
  4. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  5. package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  6. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  7. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  8. package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  9. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  10. package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  11. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  12. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  13. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  14. package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
  15. package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  16. package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  17. package/dist/full/fonts/Inter/OFL.txt +93 -0
  18. package/dist/full/fonts/Inter/README.txt +118 -0
  19. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  20. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  21. package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
  22. package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
  23. package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
  24. package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
  25. package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  26. package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  27. package/dist/full/fonts/Open_Sans/README.txt +100 -0
  28. package/dist/full/fonts/Orbitron/OFL.txt +93 -0
  29. package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  30. package/dist/full/fonts/Orbitron/README.txt +68 -0
  31. package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
  32. package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  33. package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  34. package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  35. package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  36. package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  37. package/dist/full/fonts/runescape-uf.ttf +0 -0
  38. package/dist/full/index.html +135 -25
  39. package/dist/full/js/app.62905fcb.js +952 -0
  40. package/dist/full/js/app.62905fcb.js.map +7 -0
  41. package/dist/full/styles.4e60d824.css +1 -0
  42. package/dist/full/version.json +2 -2
  43. package/docs/AGENT_PROTOCOL.md +20 -13
  44. package/docs/ORBIT_DISPATCH.md +73 -38
  45. package/docs/UPDATING.md +114 -0
  46. package/package.json +5 -7
  47. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  48. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  49. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  50. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  51. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  52. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  53. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  54. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  55. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  56. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  57. package/public/fonts/Chakra_Petch/OFL.txt +93 -0
  58. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  59. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  60. package/public/fonts/Inter/OFL.txt +93 -0
  61. package/public/fonts/Inter/README.txt +118 -0
  62. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  63. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  64. package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
  65. package/public/fonts/JetBrains_Mono/README.txt +79 -0
  66. package/public/fonts/OpenRing-Regular.ttf +0 -0
  67. package/public/fonts/Open_Sans/OFL.txt +93 -0
  68. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  69. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  70. package/public/fonts/Open_Sans/README.txt +100 -0
  71. package/public/fonts/Orbitron/OFL.txt +93 -0
  72. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  73. package/public/fonts/Orbitron/README.txt +68 -0
  74. package/public/fonts/Rajdhani/OFL.txt +93 -0
  75. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  76. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  77. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  78. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  79. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  80. package/public/fonts/runescape-uf.ttf +0 -0
  81. package/public/index.html +133 -23
  82. package/public/js/artifact-drawer.js +79 -0
  83. package/public/js/board-export.js +161 -0
  84. package/public/js/board-menu.js +219 -17
  85. package/public/js/card-actions.js +1 -1
  86. package/public/js/create-card.js +60 -23
  87. package/public/js/drawer.js +164 -4
  88. package/public/js/font-preference.js +229 -0
  89. package/public/js/format.js +0 -10
  90. package/public/js/icons.js +16 -0
  91. package/public/js/local-backend.js +31 -4
  92. package/public/js/main.js +7 -1
  93. package/public/js/modal.js +3 -1
  94. package/public/js/panel.js +58 -0
  95. package/public/js/settings-tabs.js +1 -1
  96. package/public/js/settings.js +606 -275
  97. package/public/js/sse.js +3 -0
  98. package/public/js/state.js +1 -1
  99. package/public/js/text-editing.js +128 -0
  100. package/public/js/ticket-detail.js +377 -148
  101. package/public/js/ticket-typeahead.js +49 -0
  102. package/public/js/toast.js +19 -4
  103. package/public/js/topbar-notes.js +3 -3
  104. package/public/js/typeahead.js +206 -0
  105. package/public/js/update-check.js +58 -0
  106. package/public/styles.css +5447 -3572
  107. package/src/cli/orbit.js +9 -5
  108. package/src/core/agent.js +12 -4
  109. package/src/core/agents-md.js +3 -3
  110. package/src/core/artifacts.js +149 -0
  111. package/src/core/board.js +3 -2
  112. package/src/core/boards.js +0 -1
  113. package/src/core/css-bundle.js +1 -0
  114. package/src/core/db.js +6 -0
  115. package/src/core/dispatch.js +344 -34
  116. package/src/core/http.js +9 -2
  117. package/src/core/paths.js +26 -2
  118. package/src/core/router.js +33 -0
  119. package/src/core/search.js +3 -2
  120. package/src/core/snapshots.js +10 -5
  121. package/src/core/tickets.js +34 -3
  122. package/src/core/util.js +3 -2
  123. package/src/core/version-check.js +154 -0
  124. package/src/mcp/orbit-client.js +18 -2
  125. package/src/mcp-server.js +19 -14
  126. package/src/server.js +28 -3
  127. package/dist/full/js/app.4ae80764.js +0 -742
  128. package/dist/full/js/app.4ae80764.js.map +0 -7
  129. package/dist/full/styles.b9e0c130.css +0 -1
@@ -0,0 +1,161 @@
1
+ // Board snapshot export: builds a ZIP of every board's `.orbit.json` and
2
+ // triggers a browser download. Extracted from board-menu.js so both the Board
3
+ // flyout's "Export All" and Settings → Appearance's "Back up all boards" can
4
+ // reuse the exact same archive without importing the heavier board-menu module
5
+ // (which would create a settings ↔ board-menu import cycle). Depends only on
6
+ // state/api/toast, so it stays safe to import from anywhere.
7
+
8
+ import { state } from "./state.js";
9
+ import { api } from "./api.js";
10
+ import { toast } from "./toast.js";
11
+
12
+ const crcTable = Array.from({ length: 256 }, (_, index) => {
13
+ let crc = index;
14
+ for (let bit = 0; bit < 8; bit += 1) {
15
+ crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
16
+ }
17
+ return crc >>> 0;
18
+ });
19
+
20
+ function crc32(bytes) {
21
+ let crc = 0xffffffff;
22
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
23
+ return (crc ^ 0xffffffff) >>> 0;
24
+ }
25
+
26
+ function writeUint16(bytes, value) {
27
+ bytes.push(value & 0xff, (value >>> 8) & 0xff);
28
+ }
29
+
30
+ function writeUint32(bytes, value) {
31
+ bytes.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff);
32
+ }
33
+
34
+ function zipDateTime(date = new Date()) {
35
+ return {
36
+ time:
37
+ ((date.getHours() & 0x1f) << 11) |
38
+ ((date.getMinutes() & 0x3f) << 5) |
39
+ (Math.floor(date.getSeconds() / 2) & 0x1f),
40
+ date:
41
+ (((date.getFullYear() - 1980) & 0x7f) << 9) |
42
+ (((date.getMonth() + 1) & 0xf) << 5) |
43
+ (date.getDate() & 0x1f)
44
+ };
45
+ }
46
+
47
+ export function createZip(files) {
48
+ const encoder = new TextEncoder();
49
+ const localParts = [];
50
+ const centralParts = [];
51
+ let offset = 0;
52
+ const timestamp = zipDateTime();
53
+
54
+ files.forEach((file) => {
55
+ const nameBytes = encoder.encode(file.name);
56
+ const dataBytes = encoder.encode(file.content);
57
+ const checksum = crc32(dataBytes);
58
+ const localHeader = [];
59
+ writeUint32(localHeader, 0x04034b50);
60
+ writeUint16(localHeader, 20);
61
+ writeUint16(localHeader, 0x0800);
62
+ writeUint16(localHeader, 0);
63
+ writeUint16(localHeader, timestamp.time);
64
+ writeUint16(localHeader, timestamp.date);
65
+ writeUint32(localHeader, checksum);
66
+ writeUint32(localHeader, dataBytes.length);
67
+ writeUint32(localHeader, dataBytes.length);
68
+ writeUint16(localHeader, nameBytes.length);
69
+ writeUint16(localHeader, 0);
70
+ localParts.push(new Uint8Array(localHeader), nameBytes, dataBytes);
71
+
72
+ const centralHeader = [];
73
+ writeUint32(centralHeader, 0x02014b50);
74
+ writeUint16(centralHeader, 20);
75
+ writeUint16(centralHeader, 20);
76
+ writeUint16(centralHeader, 0x0800);
77
+ writeUint16(centralHeader, 0);
78
+ writeUint16(centralHeader, timestamp.time);
79
+ writeUint16(centralHeader, timestamp.date);
80
+ writeUint32(centralHeader, checksum);
81
+ writeUint32(centralHeader, dataBytes.length);
82
+ writeUint32(centralHeader, dataBytes.length);
83
+ writeUint16(centralHeader, nameBytes.length);
84
+ writeUint16(centralHeader, 0);
85
+ writeUint16(centralHeader, 0);
86
+ writeUint16(centralHeader, 0);
87
+ writeUint16(centralHeader, 0);
88
+ writeUint32(centralHeader, 0);
89
+ writeUint32(centralHeader, offset);
90
+ centralParts.push(new Uint8Array(centralHeader), nameBytes);
91
+
92
+ offset += localHeader.length + nameBytes.length + dataBytes.length;
93
+ });
94
+
95
+ const centralOffset = offset;
96
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
97
+ const endHeader = [];
98
+ writeUint32(endHeader, 0x06054b50);
99
+ writeUint16(endHeader, 0);
100
+ writeUint16(endHeader, 0);
101
+ writeUint16(endHeader, files.length);
102
+ writeUint16(endHeader, files.length);
103
+ writeUint32(endHeader, centralSize);
104
+ writeUint32(endHeader, centralOffset);
105
+ writeUint16(endHeader, 0);
106
+
107
+ return new Blob([...localParts, ...centralParts, new Uint8Array(endHeader)], { type: "application/zip" });
108
+ }
109
+
110
+ export function downloadBlob(filename, blob) {
111
+ const url = URL.createObjectURL(blob);
112
+ const anchor = document.createElement("a");
113
+ anchor.href = url;
114
+ anchor.download = filename;
115
+ document.body.append(anchor);
116
+ anchor.click();
117
+ anchor.remove();
118
+ URL.revokeObjectURL(url);
119
+ }
120
+
121
+ export function safeExportName(board, usedNames) {
122
+ const base = String(board?.slug || board?.name || board?.id || "board")
123
+ .trim()
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9._-]+/g, "-")
126
+ .replace(/^-+|-+$/g, "") || "board";
127
+ let name = `${base}.orbit.json`;
128
+ let suffix = 2;
129
+ while (usedNames.has(name)) {
130
+ name = `${base}-${suffix}.orbit.json`;
131
+ suffix += 1;
132
+ }
133
+ usedNames.add(name);
134
+ return name;
135
+ }
136
+
137
+ /** Export every board the actor can see as a single ZIP of `.orbit.json`
138
+ * snapshots. Resolves `true` once the download starts, `false` if there are
139
+ * no boards. Throws on a failed export request so callers can surface it. */
140
+ export async function exportAllBoards({ includeAttachments = false } = {}) {
141
+ const boards = state.data?.boards || [];
142
+ if (!boards.length) {
143
+ toast.error("No boards to export");
144
+ return false;
145
+ }
146
+ const suffix = includeAttachments ? "?include_attachments=true" : "";
147
+ const usedNames = new Set();
148
+ const files = [];
149
+ for (const board of boards) {
150
+ const snapshot = await api(`/api/boards/${encodeURIComponent(board.id)}/export${suffix}`);
151
+ files.push({
152
+ name: safeExportName(board, usedNames),
153
+ content: JSON.stringify(snapshot, null, 2)
154
+ });
155
+ }
156
+ const archive = createZip(files);
157
+ const date = new Date().toISOString().slice(0, 10);
158
+ downloadBlob(`orbit-boards-${date}${includeAttachments ? ".with-images" : ""}.zip`, archive);
159
+ toast.success(`Exported ${files.length} board${files.length === 1 ? "" : "s"}`);
160
+ return true;
161
+ }
@@ -11,11 +11,15 @@ import { closeCreateFlyout } from "./create-card.js";
11
11
  import { toast } from "./toast.js";
12
12
  import { load } from "./app.js";
13
13
  import { features } from "./config.js";
14
+ import { exportAllBoards } from "./board-export.js";
15
+
16
+ let boardFlyoutView = "list";
14
17
 
15
18
  export function closeBoardFlyout() {
16
19
  if (!boardFlyout) return;
17
20
  boardFlyout.hidden = true;
18
21
  boardFlyout.innerHTML = "";
22
+ boardFlyoutView = "list";
19
23
  boardMenuBtn?.setAttribute("aria-expanded", "false");
20
24
  }
21
25
 
@@ -32,8 +36,8 @@ const boardChipIcon = `
32
36
  fill="none"
33
37
  aria-hidden="true"
34
38
  >
35
- <rect x="4" y="4" width="6" height="16" rx="0.75" stroke="currentColor" stroke-width="2" />
36
- <rect x="14" y="4" width="6" height="16" rx="0.75" stroke="currentColor" stroke-width="2" />
39
+ <rect x="4" y="2" width="6" height="20" rx="0.75" stroke="currentColor" stroke-width="2" />
40
+ <rect x="14" y="2" width="6" height="20" rx="0.75" stroke="currentColor" stroke-width="2" />
37
41
  </svg>`;
38
42
 
39
43
  export function updateTopbarChips() {
@@ -46,6 +50,88 @@ export function updateTopbarChips() {
46
50
  }
47
51
  }
48
52
 
53
+ async function inflateZipEntry(bytes) {
54
+ if (typeof DecompressionStream === "undefined") {
55
+ throw new Error("Compressed ZIP entries are not supported in this browser.");
56
+ }
57
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
58
+ return new Uint8Array(await new Response(stream).arrayBuffer());
59
+ }
60
+
61
+ function parseImportSnapshotText(text, filename = "snapshot") {
62
+ const trimmed = String(text || "").trimStart();
63
+ if (trimmed.startsWith("<")) throw new Error(`${filename} looks like HTML instead of JSON.`);
64
+ try {
65
+ const snapshot = JSON.parse(text);
66
+ if (!snapshot?.board) throw new Error("snapshot_missing_board");
67
+ return snapshot;
68
+ } catch (error) {
69
+ throw new Error(`${filename}: ${error?.message || "invalid JSON"}`);
70
+ }
71
+ }
72
+
73
+ async function readZipSnapshots(file) {
74
+ const buffer = await file.arrayBuffer();
75
+ const view = new DataView(buffer);
76
+ const bytes = new Uint8Array(buffer);
77
+ const decoder = new TextDecoder();
78
+ const snapshots = [];
79
+ let offset = 0;
80
+
81
+ while (offset + 30 <= bytes.length) {
82
+ const signature = view.getUint32(offset, true);
83
+ if (signature === 0x02014b50 || signature === 0x06054b50) break;
84
+ if (signature !== 0x04034b50) throw new Error("Invalid ZIP archive.");
85
+
86
+ const flags = view.getUint16(offset + 6, true);
87
+ const method = view.getUint16(offset + 8, true);
88
+ const compressedSize = view.getUint32(offset + 18, true);
89
+ const filenameLength = view.getUint16(offset + 26, true);
90
+ const extraLength = view.getUint16(offset + 28, true);
91
+ const nameStart = offset + 30;
92
+ const dataStart = nameStart + filenameLength + extraLength;
93
+ const dataEnd = dataStart + compressedSize;
94
+ if (flags & 0x0008) throw new Error("ZIP entries with data descriptors are not supported.");
95
+ if (dataEnd > bytes.length) throw new Error("Invalid ZIP archive.");
96
+
97
+ const name = decoder.decode(bytes.slice(nameStart, nameStart + filenameLength));
98
+ if (!name.endsWith("/")) {
99
+ if (!name.toLowerCase().endsWith(".orbit.json")) {
100
+ throw new Error("ZIP imports may contain only .orbit.json files.");
101
+ }
102
+ const compressed = bytes.slice(dataStart, dataEnd);
103
+ const data = method === 0 ? compressed : method === 8 ? await inflateZipEntry(compressed) : null;
104
+ if (!data) throw new Error("Unsupported ZIP compression method.");
105
+ snapshots.push(parseImportSnapshotText(decoder.decode(data), name));
106
+ }
107
+
108
+ offset = dataEnd;
109
+ }
110
+
111
+ if (!snapshots.length) throw new Error("ZIP archive did not contain any .orbit.json snapshots.");
112
+ return snapshots;
113
+ }
114
+
115
+ async function readImportSnapshots(file) {
116
+ const name = String(file?.name || "").toLowerCase();
117
+ if (name.endsWith(".zip")) return readZipSnapshots(file);
118
+ if (name.endsWith(".orbit.json")) return [parseImportSnapshotText(await file.text(), file.name)];
119
+ throw new Error("Choose a .zip archive or a single .orbit.json snapshot.");
120
+ }
121
+
122
+ function renderBoardFlyoutHeader() {
123
+ const showListButton = boardFlyoutView !== "list";
124
+ const newLabel = showListButton ? "List" : "New";
125
+ return `
126
+ <div class="menu-flyout-head menu-flyout-head--actions">
127
+ <span>Board</span>
128
+ <span class="menu-flyout-actions">
129
+ ${features.multiBoard ? `<button type="button" data-variant="ghost" data-board-view="${showListButton ? "list" : "new"}">${newLabel}</button>` : ""}
130
+ <button type="button" data-variant="ghost" data-board-view="export">Export</button>
131
+ </span>
132
+ </div>`;
133
+ }
134
+
49
135
  function renderBoardFlyoutInner() {
50
136
  const boards = state.data.boards || [];
51
137
  const rows = boards
@@ -60,10 +146,10 @@ function renderBoardFlyoutInner() {
60
146
  const listBody =
61
147
  rows ||
62
148
  `<p class="menu-flyout-hint menu-flyout-hint--empty">No boards yet. Create one below if you can.</p>`;
63
- const createBlock = features.multiBoard
64
- ? `
149
+ const listBlock = `<div class="menu-flyout-list">${listBody}</div>`;
150
+ const createBlock = `
65
151
  <div class="menu-flyout-section">
66
- <h3 class="menu-flyout-heading">New board</h3>
152
+ <h3 class="menu-flyout-heading">New Board</h3>
67
153
  <form id="createBoardForm" class="menu-flyout-form">
68
154
  <input name="name" type="text" required placeholder="Board name" autocomplete="off" />
69
155
  <div class="folder-picker-field">
@@ -78,20 +164,57 @@ function renderBoardFlyoutInner() {
78
164
  </label>
79
165
  </div>
80
166
  <input name="slug" type="text" placeholder="Slug (optional)" autocomplete="off" />
81
- <input name="default_branch" type="text" placeholder="Default branch (optional)" autocomplete="off" />
82
- <input name="repo_url" type="text" placeholder="Repo URL (optional)" autocomplete="off" />
83
167
  <button type="submit">Create board</button>
84
168
  </form>
85
- </div>`
86
- : "";
169
+ </div>`;
170
+ const exportBlock = `
171
+ <div class="menu-flyout-section menu-flyout-section--export">
172
+ <p class="menu-flyout-copy">Export every board as Orbit snapshots in a ZIP archive.</p>
173
+ ${features.attachments ? `
174
+ <label class="orbit-check menu-flyout-toggle">
175
+ <input type="checkbox" id="exportAllBoardImages" />
176
+ <span class="orbit-check-box" aria-hidden="true">
177
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
178
+ </span>
179
+ <span class="menu-flyout-toggle-text">Include attached images</span>
180
+ </label>
181
+ ` : ""}
182
+ <div class="menu-flyout-actions-row">
183
+ <button type="button" id="exportAllBoards">Export All</button>
184
+ </div>
185
+ <div class="menu-flyout-divider" aria-hidden="true"></div>
186
+ <section class="menu-flyout-danger-section" aria-label="Import All">
187
+ <h3 class="menu-flyout-danger-heading">Import All</h3>
188
+ <p class="menu-flyout-danger-copy">Imports fully overwrite each matching board. Snapshots without a matching board create new boards.</p>
189
+ <div class="menu-flyout-actions-row">
190
+ <input type="file" id="importAllBoardsFile" class="import-file-input" accept=".zip,.orbit.json,application/zip" />
191
+ <button type="button" data-arc="danger" id="importAllBoards">Import All</button>
192
+ </div>
193
+ </section>
194
+ </div>`;
195
+ const body = boardFlyoutView === "export" ? exportBlock : boardFlyoutView === "new" && features.multiBoard ? createBlock : listBlock;
87
196
  return `
88
- <div class="menu-flyout-head">Board</div>
89
- <div class="menu-flyout-list">${listBody}</div>
90
- ${createBlock}
197
+ ${renderBoardFlyoutHeader()}
198
+ ${body}
91
199
  `;
92
200
  }
93
201
 
202
+ function rerenderBoardFlyout(view) {
203
+ boardFlyoutView = view;
204
+ boardFlyout.innerHTML = renderBoardFlyoutInner();
205
+ wireBoardFlyout();
206
+ if (view === "new") boardFlyout.querySelector('input[name="name"]')?.focus();
207
+ }
208
+
94
209
  function wireBoardFlyout() {
210
+ boardFlyout.querySelectorAll("[data-board-view]").forEach((button) => {
211
+ button.addEventListener("click", (event) => {
212
+ event.stopPropagation();
213
+ const view = button.getAttribute("data-board-view") || "list";
214
+ rerenderBoardFlyout(view);
215
+ });
216
+ });
217
+
95
218
  boardFlyout.querySelectorAll("[data-pick-board]").forEach((btn) => {
96
219
  btn.addEventListener("click", async (event) => {
97
220
  event.stopPropagation();
@@ -137,8 +260,6 @@ function wireBoardFlyout() {
137
260
  event.preventDefault();
138
261
  const fd = new FormData(form);
139
262
  const slug = String(fd.get("slug") || "").trim();
140
- const branch = String(fd.get("default_branch") || "").trim();
141
- const repo = String(fd.get("repo_url") || "").trim();
142
263
  const repoPath = String(fd.get("repo_path") || "").trim();
143
264
  // Folder is optional. Only forward the helper-files choice when a folder
144
265
  // is set; the server ignores it otherwise.
@@ -147,9 +268,7 @@ function wireBoardFlyout() {
147
268
  body: {
148
269
  name: fd.get("name"),
149
270
  ...(repoPath ? { repo_path: repoPath, manage_helper_files: fd.get("manage_helper_files") === "on" } : {}),
150
- ...(slug ? { slug } : {}),
151
- ...(branch ? { default_branch: branch } : {}),
152
- ...(repo ? { repo_url: repo } : {})
271
+ ...(slug ? { slug } : {})
153
272
  }
154
273
  });
155
274
  closeBoardFlyout();
@@ -161,10 +280,93 @@ function wireBoardFlyout() {
161
280
  toast.success("Board created");
162
281
  });
163
282
  }
283
+
284
+ boardFlyout.querySelector("#exportAllBoards")?.addEventListener("click", async (event) => {
285
+ const button = event.currentTarget;
286
+ const includeAttachments = Boolean(boardFlyout.querySelector("#exportAllBoardImages")?.checked);
287
+ button.disabled = true;
288
+ const originalText = button.textContent;
289
+ button.textContent = "Exporting...";
290
+ try {
291
+ await exportAllBoards({ includeAttachments });
292
+ } catch (error) {
293
+ toast.error(`Export failed: ${error?.message || "request failed"}`);
294
+ } finally {
295
+ button.disabled = false;
296
+ button.textContent = originalText;
297
+ }
298
+ });
299
+
300
+ const importAllBoardsFile = boardFlyout.querySelector("#importAllBoardsFile");
301
+ boardFlyout.querySelector("#importAllBoards")?.addEventListener("click", () => {
302
+ importAllBoardsFile?.click();
303
+ });
304
+
305
+ importAllBoardsFile?.addEventListener("change", async (event) => {
306
+ const input = event.currentTarget;
307
+ const file = input.files?.[0];
308
+ if (!file) return;
309
+ const importButton = boardFlyout.querySelector("#importAllBoards");
310
+ const originalText = importButton?.textContent;
311
+ if (importButton) {
312
+ importButton.disabled = true;
313
+ importButton.textContent = "Importing...";
314
+ }
315
+ try {
316
+ const snapshots = await readImportSnapshots(file);
317
+ const boardsById = new Map((state.data.boards || []).map((board) => [board.id, board]));
318
+ const boardsBySlug = new Map((state.data.boards || []).map((board) => [board.slug, board]));
319
+ let replaced = 0;
320
+ let created = 0;
321
+
322
+ for (const snapshot of snapshots) {
323
+ const incomingBoard = snapshot.board || {};
324
+ const match = boardsBySlug.get(incomingBoard.slug) || boardsById.get(incomingBoard.id);
325
+ if (match) {
326
+ await api("/api/admin/import", {
327
+ method: "POST",
328
+ body: {
329
+ board_id: match.id,
330
+ replace_existing: true,
331
+ snapshot
332
+ }
333
+ });
334
+ replaced += 1;
335
+ continue;
336
+ }
337
+
338
+ const result = await api("/api/admin/import", {
339
+ method: "POST",
340
+ body: {
341
+ ...(state.boardId ? { board_id: state.boardId } : {}),
342
+ create_new: true,
343
+ snapshot
344
+ }
345
+ });
346
+ created += 1;
347
+ if (result.imported_board_id) boardsById.set(result.imported_board_id, { id: result.imported_board_id, slug: result.imported_board_slug });
348
+ if (result.imported_board_slug) boardsBySlug.set(result.imported_board_slug, { id: result.imported_board_id, slug: result.imported_board_slug });
349
+ }
350
+
351
+ await load();
352
+ toast.success(`Imported ${snapshots.length} board${snapshots.length === 1 ? "" : "s"} (${replaced} replaced, ${created} created)`);
353
+ closeBoardFlyout();
354
+ await navigate({ boardId: state.boardId, view: "board" }, { replace: true });
355
+ } catch (error) {
356
+ toast.error(`Import failed: ${error?.message || "invalid import file"}`);
357
+ } finally {
358
+ input.value = "";
359
+ if (importButton) {
360
+ importButton.disabled = false;
361
+ importButton.textContent = originalText;
362
+ }
363
+ }
364
+ });
164
365
  }
165
366
 
166
367
  export function openBoardFlyout() {
167
368
  closeCreateFlyout();
369
+ if (boardFlyoutView === "new" && !features.multiBoard) boardFlyoutView = "list";
168
370
  boardFlyout.innerHTML = renderBoardFlyoutInner();
169
371
  boardFlyout.hidden = false;
170
372
  boardMenuBtn?.setAttribute("aria-expanded", "true");
@@ -133,7 +133,7 @@ export function createCardActionHandlers({ apiClient = api, navigator = null, co
133
133
  async archive(ticket) {
134
134
  if (typeof confirmer === "function" && !confirmer(`Archive "${ticket.title || "card"}"? You can restore it from Settings → Card Archive.`)) return false;
135
135
  await apiClient(withBoardQuery(`/api/tickets/${encodeURIComponent(ticket.id)}/archive`), { method: "POST" });
136
- toast.error(`Archived: ${ticket.title || "card"}`);
136
+ toast.removed(`Archived: ${ticket.title || "card"}`);
137
137
  return true;
138
138
  }
139
139
  };
@@ -8,12 +8,15 @@ import {
8
8
  createFlyoutBackdrop
9
9
  } from "./dom.js";
10
10
  import { state, visibleStatesForProject, ticketsForProject } from "./state.js";
11
- import { escapeHtml, ticketLabel } from "./format.js";
11
+ import { escapeHtml } from "./format.js";
12
12
  import { api } from "./api.js";
13
13
  import { syncUrlFromState } from "./router.js";
14
+ import { handleTextareaIndentationKeydown } from "./text-editing.js";
14
15
  import { closeDrawer } from "./drawer.js";
15
16
  import { closeMenuFlyouts } from "./board-menu.js";
16
17
  import { load } from "./app.js";
18
+ import { closeIconSvg } from "./icons.js";
19
+ import { attachTicketTypeahead, ticketCanonicalLabel } from "./ticket-typeahead.js";
17
20
 
18
21
  /** Clears any pending hide timeout when reopening the create flyout quickly. */
19
22
  let createFlyoutHideTimer = 0;
@@ -66,25 +69,21 @@ function renderCreateForm(preselectedLaneId) {
66
69
  `<option value="${escapeHtml(item.id)}" ${item.id === effectiveStateId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
67
70
  )
68
71
  .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
72
  return `
74
73
  <div class="create-flyout-head">
75
74
  <h2>New card</h2>
76
- <button type="button" class="create-flyout-close ghost" id="createFlyoutClose" aria-label="Close">×</button>
75
+ <button type="button" class="create-flyout-close ghost" id="createFlyoutClose" aria-label="Close">${closeIconSvg}</button>
77
76
  </div>
78
77
  <form id="createTicketForm" class="create-flyout-form">
79
78
  <input id="createTicketTitle" name="title" type="text" placeholder="Title — what is this card?" autocomplete="off" required />
80
79
  <textarea id="createTicketDescription" name="description" placeholder="Description (optional)"></textarea>
81
- <div class="create-flyout-grid">
82
- <label>
83
- Lane
80
+ <div class="control-grid">
81
+ <label class="control-field">
82
+ <span class="control-field-label">LANE</span>
84
83
  <select id="createTicketState" name="state" class="select-chevron-field" aria-label="Lane">${stateOptions}</select>
85
84
  </label>
86
- <label>
87
- Type
85
+ <label class="control-field">
86
+ <span class="control-field-label">TYPE</span>
88
87
  <select id="createTicketType" name="type" class="select-chevron-field" aria-label="Ticket type">
89
88
  <option value="epic">Epic</option>
90
89
  <option value="feature">Feature</option>
@@ -92,23 +91,21 @@ function renderCreateForm(preselectedLaneId) {
92
91
  <option value="bug">Bug</option>
93
92
  </select>
94
93
  </label>
95
- <label>
96
- Priority
94
+ <label class="control-field">
95
+ <span class="control-field-label">PRIORITY</span>
97
96
  <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
97
  <option value="4">URGENT</option>
98
+ <option value="3">HIGH</option>
99
+ <option value="2" selected>MED</option>
100
+ <option value="1">LOW</option>
101
+ <option value="0">MAYBE</option>
103
102
  </select>
104
103
  </label>
105
104
  </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>
105
+ <label class="control-field" id="createTicketParentField">
106
+ <span class="control-field-label">Parent epic</span>
107
+ <input id="createTicketParent" name="parent_target" type="text" placeholder="Search epic… (optional)" autocomplete="off" aria-label="Parent epic" />
108
+ <input type="hidden" name="parent_ticket_id" id="createTicketParentId" value="" />
112
109
  </label>
113
110
  <input id="createTicketLabels" name="labels" type="text" placeholder="Labels (comma-separated)" />
114
111
  <button type="submit">Create card</button>
@@ -119,6 +116,46 @@ function renderCreateForm(preselectedLaneId) {
119
116
  function wireCreateForm() {
120
117
  const form = $("#createTicketForm");
121
118
  if (!form) return;
119
+ const description = form.querySelector("#createTicketDescription");
120
+ description?.addEventListener("keydown", (event) => {
121
+ handleTextareaIndentationKeydown(event, description);
122
+ });
123
+
124
+ // Parent epic is chosen via the shared styled typeahead. The visible field
125
+ // holds the "#12 — Title" label; the resolved epic id lives in a hidden input
126
+ // that the submit reads. Typing (or clearing) drops a stale id so only an
127
+ // explicit pick is submitted.
128
+ const parentInput = form.querySelector("#createTicketParent");
129
+ const parentId = form.querySelector("#createTicketParentId");
130
+ parentInput.addEventListener("input", () => {
131
+ parentId.value = "";
132
+ });
133
+ attachTicketTypeahead(
134
+ parentInput,
135
+ () => ticketsForProject().filter((ticket) => ticket.type === "epic"),
136
+ {
137
+ onSelect: (ticket) => {
138
+ parentId.value = ticket.id;
139
+ parentInput.value = ticketCanonicalLabel(ticket);
140
+ },
141
+ },
142
+ );
143
+
144
+ // Epics cannot have a parent (server rejects with `epic_cannot_have_parent`),
145
+ // so hide the Parent epic field whenever Type is Epic.
146
+ const typeSelect = form.querySelector("#createTicketType");
147
+ const parentField = form.querySelector("#createTicketParentField");
148
+ const syncParentVisibility = () => {
149
+ const isEpic = typeSelect.value === "epic";
150
+ parentField.hidden = isEpic;
151
+ if (isEpic) {
152
+ parentInput.value = "";
153
+ parentId.value = "";
154
+ }
155
+ };
156
+ typeSelect.addEventListener("change", syncParentVisibility);
157
+ syncParentVisibility();
158
+
122
159
  form.addEventListener("submit", createTicketFromDrawer);
123
160
  }
124
161