@starlove/orbit 1.4.0 → 2.0.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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -1,1634 +0,0 @@
1
- // Project settings drawer (the cog/settings icon). Tabs: Lanes, Appearance,
2
- // Card Archive, Notes, Journal, AI, Repository. Owns lane CRUD/reorder, the
3
- // archive list, project context/notes form persistence, and per-tab handler wiring.
4
-
5
- import { state, getSettingsTab, statesForProject, ticketsForProject, currentBoard } from "./state.js";
6
- import { drawerInner, $ } from "./dom.js";
7
- import { escapeHtml, formatDate, renderPreservedText, cleanText } from "./format.js";
8
- import { api, withBoardQuery } from "./api.js";
9
- import { toast, downloadJson } from "./toast.js";
10
- import { renderDrawerShell } from "./drawer.js";
11
- import { navigate } from "./router.js";
12
- import { renderBoard } from "./kanban.js";
13
- import { renderDetailCard, startInlineEdit } from "./ticket-detail.js";
14
- import { load } from "./app.js";
15
- import { features } from "./config.js";
16
- import {
17
- applyReducedMotionPreference,
18
- browserPrefersReducedMotion,
19
- effectiveReducedMotionPreference,
20
- setReducedMotionPreference,
21
- storedReducedMotionPreference
22
- } from "./motion-preference.js";
23
- import { currentTheme, toggleThemePreference } from "./theme-preference.js";
24
- import {
25
- FONT_OPTIONS,
26
- FONT_TARGETS,
27
- SITE_FONT_OPTIONS,
28
- USER_SCALE_MIN,
29
- USER_SCALE_MAX,
30
- USER_SCALE_STEP,
31
- storedFontPreferences,
32
- setSiteFont,
33
- setUserScale,
34
- setFontPreference
35
- } from "./font-preference.js";
36
- import { closeIconSvg } from "./icons.js";
37
- import { exportAllBoards } from "./board-export.js";
38
- import { getUpdateInfo, fetchVersionInfo, syncUpdateBadge } from "./update-check.js";
39
-
40
- const repositoryDelete = {
41
- boardId: "",
42
- step: "idle",
43
- exported: false
44
- };
45
-
46
- /** @starlove/ui slider: WebKit fill reads --value (0–100), set from min/max/value. */
47
- function syncSliderValue(input) {
48
- const min = Number(input.min) || 0;
49
- const max = Number(input.max) || 100;
50
- const value = Number(input.value);
51
- const span = max - min;
52
- const pct = span <= 0 ? 0 : ((value - min) / span) * 100;
53
- input.style.setProperty("--value", String(pct));
54
- }
55
-
56
- function bindSliders(root) {
57
- root.querySelectorAll('input[type="range"].slider').forEach((slider) => {
58
- syncSliderValue(slider);
59
- slider.addEventListener("input", () => syncSliderValue(slider));
60
- });
61
- }
62
-
63
- export async function renderProjectDetail() {
64
- const context = await api(`/api/boards/${state.boardId}/context?include_struck=true`);
65
- const bootstrapBoard = currentBoard();
66
- const project = {
67
- ...context.board,
68
- // `/api/boards/:id/context` is agent-safe and intentionally omits
69
- // human-only project notes. The Settings UI gets those notes from the
70
- // human bootstrap payload instead, so Notes can reload without turning
71
- // board_context/MCP into a notes surface.
72
- ...(bootstrapBoard?.id === context.board?.id ? { project_notes: bootstrapBoard.project_notes || "" } : {})
73
- };
74
- context.board = project;
75
- const activeTab = getSettingsTab();
76
-
77
- const tabs = [
78
- { id: "lanes", label: "Lanes" },
79
- { id: "appearance", label: "Appearance" },
80
- { id: "archive", label: "Card Archive" },
81
- { id: "notes", label: "Notes" },
82
- { id: "journal", label: "Journal" },
83
- ...(features.ai ? [{ id: "ai", label: "AI" }] : []),
84
- { id: "data", label: "Data" }
85
- ];
86
-
87
- const resolvedTab = activeTab === "ai" && !features.ai ? "lanes" : activeTab;
88
-
89
- renderDrawerShell({
90
- eyebrow: "SETTINGS",
91
- mode: "settings",
92
- tabs,
93
- activeTab: resolvedTab,
94
- onTabSelect: async (id) => {
95
- navigate({
96
- boardId: state.boardId,
97
- view: "settings",
98
- tab: id
99
- });
100
- },
101
- body: renderProjectTabBody(context, resolvedTab)
102
- });
103
-
104
- bindProjectTabHandlers(context, resolvedTab);
105
- }
106
-
107
- function renderProjectTabBody(context, tab) {
108
- switch (tab) {
109
- case "notes":
110
- return renderNotesSettingsTab(context);
111
- case "lanes":
112
- return renderProjectLanesTab();
113
- case "appearance":
114
- return renderProjectAppearanceTab();
115
- case "ai":
116
- return features.ai ? renderProjectAiTab(context) : renderProjectLanesTab();
117
- case "journal":
118
- return renderProjectJournalTab(context);
119
- case "archive":
120
- return renderProjectArchiveTab();
121
- case "data":
122
- default:
123
- return renderProjectDataTab(context);
124
- }
125
- }
126
-
127
- const MCP_OS_OPTIONS = {
128
- windows: {
129
- label: "Windows",
130
- shell: "PowerShell",
131
- serverPath: "C:\\Path\\To\\StarscapeOrbit\\src\\mcp-server.js"
132
- },
133
- macos: {
134
- label: "macOS",
135
- shell: "Terminal",
136
- serverPath: "/Users/you/apps/starscape-orbit/src/mcp-server.js"
137
- },
138
- linux: {
139
- label: "Linux",
140
- shell: "Terminal",
141
- serverPath: "/home/you/apps/starscape-orbit/src/mcp-server.js"
142
- }
143
- };
144
-
145
- const MCP_CLIENTS = {
146
- "claude-code": {
147
- label: "Claude Code",
148
- format: "CLI command",
149
- helper: "Run this command once. Claude Code records the local Orbit tool server for the project."
150
- },
151
- codex: {
152
- label: "Codex",
153
- format: "config.toml",
154
- helper: "Paste this TOML into Codex config. Project-local config may require the project to be trusted."
155
- },
156
- cursor: {
157
- label: "Cursor",
158
- format: "MCP JSON",
159
- helper: "Paste this JSON into Cursor MCP settings. Cursor starts Orbit when an agent needs the tools."
160
- },
161
- vscode: {
162
- label: "VS Code",
163
- format: "mcp.json",
164
- helper: "Paste this JSON into VS Code's user or workspace MCP configuration."
165
- },
166
- opencode: {
167
- label: "OpenCode",
168
- format: "opencode.json",
169
- helper: "Paste this into OpenCode config under the local MCP server list."
170
- },
171
- openclaw: {
172
- label: "OpenClaw",
173
- format: "MCP JSON",
174
- helper: "Paste this JSON into OpenClaw's MCP server list. OpenClaw will spawn Orbit on demand."
175
- },
176
- generic: {
177
- label: "Other MCP app",
178
- format: "MCP JSON",
179
- helper: "Most MCP clients accept this JSON shape for local stdio servers."
180
- }
181
- };
182
-
183
- function renderProjectAiTab(context) {
184
- const project = context.board;
185
- const aiEnabled = project.ai_enabled !== 0;
186
- const agentContextSetup = aiEnabled ? renderAgentContextSection(project) : "";
187
- const helperFilesSetup = aiEnabled && features.multiBoard ? renderHelperFilesSection(project) : "";
188
- const mcpSetup = aiEnabled ? renderMcpSetupSection(context) : "";
189
-
190
- return `
191
- <div class="settings-stack">
192
- <section class="settings-card">
193
- <header class="settings-card-head">
194
- <h3 class="settings-card-title">AI</h3>
195
- </header>
196
- <div class="settings-card-body">
197
- <label class="orbit-check settings-toggle" for="aiEnabledToggle">
198
- <input
199
- type="checkbox"
200
- id="aiEnabledToggle"
201
- ${aiEnabled ? "checked" : ""}
202
- />
203
- <span class="orbit-check-box" aria-hidden="true">
204
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
205
- </span>
206
- <span class="settings-toggle-text">
207
- <span class="settings-row-title">Enable AI</span>
208
- <span class="description">Provisions the <strong>AI Ready</strong>, <strong>In Progress</strong>, and <strong>Review</strong> lanes if missing, surfaces agent context fields, and exposes the MCP setup snippet. Disabling AI hides the AI Ready column but keeps its cards in place.</span>
209
- </span>
210
- </label>
211
- </div>
212
- </section>
213
- ${agentContextSetup}
214
- ${helperFilesSetup}
215
- ${mcpSetup}
216
- </div>
217
- `;
218
- }
219
-
220
- function renderAgentContextSection(project) {
221
- const instructionsPlaceholder =
222
- "This project is about…\n\nHow any AI agent should use this board and work in this repo.";
223
- const instructions = project.agent_instructions || "";
224
- const hasInstructions = Boolean(instructions.trim());
225
- const placeholderClass = hasInstructions ? "" : "is-placeholder";
226
- const inner = hasInstructions ? renderPreservedText(instructions) : escapeHtml(instructionsPlaceholder);
227
- return `
228
- <section class="settings-card ai-context-setup">
229
- <header class="settings-card-head">
230
- <h3 class="settings-card-title">Agent Instructions</h3>
231
- </header>
232
- <div class="settings-card-body">
233
- <div class="settings-row">
234
- <p class="description">This project-level context is provided to agents when they work on tickets.</p>
235
- <div class="inline-md-field">
236
- <div
237
- class="inline-md-field-body preserved-text-body editable-field settings-agent-instructions-body ${placeholderClass}"
238
- data-edit-field="agent_instructions"
239
- tabindex="0"
240
- title="Click to edit"
241
- role="button"
242
- aria-label="Edit Agent Instructions"
243
- >${inner}</div>
244
- </div>
245
- </div>
246
- </div>
247
- </section>
248
- `;
249
- }
250
-
251
- function renderMcpSetupSection(context) {
252
- const osId = detectedMcpOs();
253
- const clientId = "claude-code";
254
- const paths = mcpDetectedPaths(context, osId);
255
- const configText = mcpConfigText(clientId, paths);
256
- const commandText = mcpRunCommand(osId, paths);
257
- const client = MCP_CLIENTS[clientId];
258
- return `
259
- <details class="settings-card settings-card--collapsible mcp-setup" data-mcp-setup>
260
- <summary class="settings-card-head">
261
- <h3 class="settings-card-title">Connect Your Agent to MCP</h3>
262
- <svg class="settings-card-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
263
- </summary>
264
- <div class="settings-card-body">
265
- <div class="settings-row">
266
- <p class="description">A tiny service running on your computer alongside your AI agent, providing it with tools and context to collaborate with you more effectively. Tell your AI to use the snippet below, it knows what MCP is.</p>
267
- <div class="control-grid">
268
- <label class="control-field">
269
- <span class="control-field-label">Operating system</span>
270
- <select id="mcpOsSelect" class="select-chevron-field">
271
- ${Object.entries(MCP_OS_OPTIONS)
272
- .map(([id, option]) => `<option value="${id}" ${id === osId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
273
- .join("")}
274
- </select>
275
- </label>
276
- <label class="control-field">
277
- <span class="control-field-label">Agent app</span>
278
- <select id="mcpClientSelect" class="select-chevron-field">
279
- ${Object.entries(MCP_CLIENTS)
280
- .map(([id, option]) => `<option value="${id}" ${id === clientId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
281
- .join("")}
282
- </select>
283
- </label>
284
- </div>
285
- <div class="control-grid control-grid--single mcp-path-grid">
286
- <label class="control-field">
287
- <span class="control-field-label">Orbit MCP server file</span>
288
- <input id="mcpServerPathInput" value="${escapeHtml(paths.serverPath)}" />
289
- </label>
290
- </div>
291
- </div>
292
-
293
- <div class="settings-row">
294
- <div class="mcp-options-stack">
295
- <div class="mcp-option-card">
296
- <div class="mcp-option-card-head">
297
- <div>
298
- <strong data-mcp-output-title>${escapeHtml(client.label)} setup</strong>
299
- <p class="description" data-mcp-helper>${escapeHtml(client.helper)}</p>
300
- </div>
301
- <button type="button" data-variant="secondary" data-copy-mcp="config">Copy setup</button>
302
- </div>
303
- <div class="mcp-terminal">
304
- <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
305
- <pre class="mcp-snippet"><code data-mcp-config>${escapeHtml(configText)}</code></pre>
306
- </div>
307
- </div>
308
-
309
- <div class="mcp-or-divider" aria-hidden="true"><span>OR</span></div>
310
-
311
- <div class="mcp-option-card">
312
- <div class="mcp-option-card-head">
313
- <div>
314
- <strong>Manual run command</strong>
315
- <p class="description">Use this only when an app asks you to start the MCP server yourself.</p>
316
- </div>
317
- <button type="button" data-variant="secondary" data-copy-mcp="command">Copy command</button>
318
- </div>
319
- <div class="mcp-terminal">
320
- <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
321
- <pre class="mcp-snippet mcp-snippet--compact"><code data-mcp-command>${escapeHtml(commandText)}</code></pre>
322
- </div>
323
- </div>
324
- </div>
325
- </div>
326
- </div>
327
- </details>
328
- `;
329
- }
330
-
331
- function detectedMcpOs() {
332
- const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase();
333
- if (platform.includes("win")) return "windows";
334
- if (platform.includes("mac")) return "macos";
335
- return "linux";
336
- }
337
-
338
- function mcpDefaultPaths(osId) {
339
- const option = MCP_OS_OPTIONS[osId] || MCP_OS_OPTIONS.linux;
340
- return {
341
- serverPath: option.serverPath
342
- };
343
- }
344
-
345
- function mcpDetectedPaths(context, osId) {
346
- const template = mcpDefaultPaths(osId);
347
- const deployment = context.deployment || {};
348
- return {
349
- serverPath: deployment.mcp_server_path || template.serverPath
350
- };
351
- }
352
-
353
- function mcpPathsFromForm() {
354
- return {
355
- serverPath: $("#mcpServerPathInput")?.value || ""
356
- };
357
- }
358
-
359
- function mcpConfigText(clientId, paths) {
360
- if (clientId === "claude-code") return claudeCodeMcpCommand(paths);
361
- if (clientId === "codex") return codexMcpToml(paths);
362
- if (clientId === "opencode") return opencodeMcpJson(paths);
363
- if (clientId === "vscode") return vscodeMcpJson(paths);
364
-
365
- const config = {
366
- mcpServers: {
367
- "starscape-orbit": {
368
- command: "node",
369
- args: [paths.serverPath]
370
- }
371
- }
372
- };
373
- return JSON.stringify(config, null, 2);
374
- }
375
-
376
- function opencodeMcpJson(paths) {
377
- return JSON.stringify(
378
- {
379
- $schema: "https://opencode.ai/config.json",
380
- mcp: {
381
- "starscape-orbit": {
382
- type: "local",
383
- command: ["node", paths.serverPath],
384
- enabled: true
385
- }
386
- }
387
- },
388
- null,
389
- 2
390
- );
391
- }
392
-
393
- function vscodeMcpJson(paths) {
394
- return JSON.stringify(
395
- {
396
- servers: {
397
- starscapeOrbit: {
398
- type: "stdio",
399
- command: "node",
400
- args: [paths.serverPath]
401
- }
402
- }
403
- },
404
- null,
405
- 2
406
- );
407
- }
408
-
409
- function codexMcpToml(paths) {
410
- return `[mcp_servers.starscape-orbit]
411
- command = "node"
412
- args = ["${escapeToml(paths.serverPath)}"]
413
- enabled = true`;
414
- }
415
-
416
- function claudeCodeMcpCommand(paths) {
417
- return ["claude mcp add starscape-orbit", "--", "node", shellQuote(paths.serverPath)].join(" ");
418
- }
419
-
420
- function mcpRunCommand(osId, paths) {
421
- return `node ${shellQuote(paths.serverPath)}`;
422
- }
423
-
424
- function shellQuote(value) {
425
- return `"${String(value || "").replace(/"/g, '\\"')}"`;
426
- }
427
-
428
- function escapeToml(value) {
429
- return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
430
- }
431
-
432
- function renderProjectArchiveTab() {
433
- return `
434
- <p class="description">Archived cards are hidden from the kanban, search, and agent claim-next. Restore to put a card back on the board, or delete to permanently remove it.</p>
435
- <div class="section">
436
- <div id="archiveList" class="archive-list">Loading…</div>
437
- </div>
438
- `;
439
- }
440
-
441
- function renderProjectDataTab(context) {
442
- const project = context.board;
443
- if (repositoryDelete.boardId !== project.id) {
444
- repositoryDelete.boardId = project.id;
445
- repositoryDelete.step = "idle";
446
- repositoryDelete.exported = false;
447
- }
448
- const repoMeta = features.multiBoard
449
- ? ""
450
- : `
451
- <section class="settings-card">
452
- <header class="settings-card-head">
453
- <h3 class="settings-card-title">Data</h3>
454
- </header>
455
- <div class="settings-card-body">
456
- <div class="settings-row">
457
- <p class="description">This board lives in your browser only. Export a snapshot if you want to move it to a real Orbit install, or import one to seed this preview.</p>
458
- </div>
459
- </div>
460
- </section>`;
461
- return `
462
- <div class="settings-stack">
463
- ${renderUpdateCard()}
464
-
465
- ${repoMeta}
466
-
467
- ${features.multiBoard ? renderBoardRenameSection(project) : ""}
468
-
469
- <section class="settings-card">
470
- <header class="settings-card-head">
471
- <h3 class="settings-card-title">Board Snapshot</h3>
472
- </header>
473
- <div class="settings-card-body">
474
- <div class="settings-row">
475
- <p class="description">Import accepts Orbit snapshots or Trello board JSON exports. The default import creates a separate board and leaves this board untouched.</p>
476
- ${features.attachments ? `
477
- <label class="orbit-check settings-toggle">
478
- <input type="checkbox" id="exportProjectImages" />
479
- <span class="orbit-check-box" aria-hidden="true">
480
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
481
- </span>
482
- <span class="settings-toggle-text">
483
- <span class="settings-row-title">Include attached images</span>
484
- </span>
485
- </label>
486
- ` : ""}
487
- <div class="deployment-actions">
488
- <button type="button" data-variant="primary" id="exportProject">Export</button>
489
- <input type="file" id="importSnapshotAsNewFile" class="import-file-input" accept=".orbit.json,.json,application/json" />
490
- <button type="button" data-variant="primary" id="importSnapshotAsNewButton">Import as New Board</button>
491
- </div>
492
- </div>
493
- </div>
494
- </section>
495
-
496
- <section class="settings-card settings-card--danger">
497
- <header class="settings-card-head">
498
- <h3 class="settings-card-title">Replace Current Board</h3>
499
- </header>
500
- <div class="settings-card-body">
501
- <div class="settings-row">
502
- <p class="description">Advanced: replace this board from an Orbit snapshot or Trello export after making the normal pre-import backup. Type <strong>${escapeHtml(project.slug || "")}</strong> to enable replacement.</p>
503
- <label class="control-field repository-confirm-field">
504
- <span class="control-field-label">Board slug</span>
505
- <input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
506
- </label>
507
- <div class="deployment-actions">
508
- <input type="file" id="replaceCurrentBoardImportFile" class="import-file-input" accept=".orbit.json,.json,application/json" disabled />
509
- <button type="button" data-variant="primary" data-arc="danger" id="replaceCurrentBoardImportButton" disabled>Replace Current Board</button>
510
- </div>
511
- </div>
512
- </div>
513
- </section>
514
-
515
- ${features.multiBoard ? renderDeleteBoardSection(project) : ""}
516
- </div>
517
- `;
518
- }
519
-
520
- function renderHelperFilesSection(project) {
521
- const hasFolder = Boolean(project.system_path);
522
- const managed = Boolean(project.manage_helper_files);
523
- return `
524
- <section class="settings-card helper-files-section">
525
- <header class="settings-card-head">
526
- <h3 class="settings-card-title">AI Helper Files</h3>
527
- </header>
528
- <div class="settings-card-body">
529
- <div class="settings-row">
530
- <p class="description">Orbit can keep <code>SKILL-ORBIT.md</code> and <code>AGENTS.md</code> in this board's coding project folder so your AI agent knows how to use Orbit.${hasFolder ? "" : " Link a folder to enable this."}</p>
531
- <div class="folder-picker-field helper-files-folder">
532
- <input id="helperFolderInput" type="text" readonly placeholder="No folder linked" value="${escapeHtml(project.system_path || "")}" />
533
- <button type="button" id="helperPickFolderBtn">${hasFolder ? "Change" : "Browse"}</button>
534
- </div>
535
- <label class="orbit-check settings-toggle">
536
- <input type="checkbox" id="manageHelperFilesToggle" ${managed ? "checked" : ""} ${hasFolder ? "" : "disabled"} />
537
- <span class="orbit-check-box" aria-hidden="true">
538
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
539
- </span>
540
- <span class="settings-toggle-text">
541
- <span class="settings-row-title">Generate &amp; maintain helper files in this folder</span>
542
- </span>
543
- </label>
544
- </div>
545
- </div>
546
- </section>
547
- `;
548
- }
549
-
550
- function renderBoardRenameSection(project) {
551
- return `
552
- <section class="settings-card board-rename-section">
553
- <header class="settings-card-head">
554
- <h3 class="settings-card-title">Board Name</h3>
555
- </header>
556
- <div class="settings-card-body">
557
- <div class="settings-row">
558
- <p class="description">Rename the board display name only. The canonical URL slug stays <strong>${escapeHtml(project.slug || "")}</strong>, so existing board links keep working.</p>
559
- <form id="boardRenameForm" class="field-form">
560
- <label class="control-field">
561
- <span class="control-field-label">Display name</span>
562
- <input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
563
- </label>
564
- <button type="submit" data-variant="primary">Rename Board</button>
565
- </form>
566
- </div>
567
- </div>
568
- </section>
569
- `;
570
- }
571
-
572
- function renderDeleteBoardSection(project) {
573
- const slug = escapeHtml(project.slug || "");
574
- if (repositoryDelete.step === "choice") {
575
- return `
576
- <section class="settings-card settings-card--danger">
577
- <header class="settings-card-head">
578
- <h3 class="settings-card-title">Delete Board</h3>
579
- </header>
580
- <div class="settings-card-body">
581
- <div class="settings-row">
582
- <p class="description">Delete this board from Orbit and remove its repo-local Orbit files. Export a snapshot first if you may need this board again.</p>
583
- <div class="repository-delete-actions">
584
- <button type="button" data-variant="primary" id="deleteBoardExportFirst">Export first</button>
585
- <button type="button" data-variant="primary" data-arc="danger" id="deleteBoardSkipExport">Delete without exporting</button>
586
- <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
587
- </div>
588
- </div>
589
- </div>
590
- </section>
591
- `;
592
- }
593
-
594
- if (repositoryDelete.step === "confirm") {
595
- const exportedCopy = repositoryDelete.exported
596
- ? "Snapshot exported. Type the board slug to confirm permanent deletion."
597
- : "Type the board slug to confirm permanent deletion.";
598
- return `
599
- <section class="settings-card settings-card--danger is-confirming">
600
- <header class="settings-card-head">
601
- <h3 class="settings-card-title">Delete Board</h3>
602
- </header>
603
- <div class="settings-card-body">
604
- <div class="settings-row">
605
- <p class="description">${escapeHtml(exportedCopy)}</p>
606
- <label class="control-field repository-confirm-field">
607
- <span class="control-field-label">Board slug</span>
608
- <strong>${slug}</strong>
609
- <input id="deleteBoardConfirmInput" autocomplete="off" spellcheck="false" placeholder="${slug}" autofocus />
610
- </label>
611
- <div class="repository-delete-actions">
612
- <button type="button" data-variant="primary" data-arc="danger" id="deleteBoardConfirm" disabled>Delete board permanently</button>
613
- <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
614
- </div>
615
- </div>
616
- </div>
617
- </section>
618
- `;
619
- }
620
-
621
- return `
622
- <section class="settings-card settings-card--danger">
623
- <header class="settings-card-head">
624
- <h3 class="settings-card-title">Delete Board</h3>
625
- </header>
626
- <div class="settings-card-body">
627
- <div class="settings-row">
628
- <p class="description">Remove this board from Orbit and delete its repo-local Orbit files. This cannot be undone without an exported snapshot.</p>
629
- <button type="button" data-variant="primary" data-arc="danger" id="deleteBoardStart">Delete Board</button>
630
- </div>
631
- </div>
632
- </section>
633
- `;
634
- }
635
-
636
- function renderNotesSettingsTab(context) {
637
- const project = context.board;
638
- const notesPlaceholder =
639
- "Remember to mention SKILL-ORBIT.md to my AI agents if they get confused";
640
- const notes = project.project_notes || "";
641
- const hasNotes = Boolean(notes.trim());
642
- const placeholderClass = hasNotes ? "" : "is-placeholder";
643
- const inner = hasNotes ? renderPreservedText(notes) : escapeHtml(notesPlaceholder);
644
- return `
645
- <div class="section settings-notes-section">
646
- <div class="inline-md-field">
647
- <span class="inline-md-field-label font-orbitron">Notes For You</span>
648
- <div
649
- class="inline-md-field-body preserved-text-body editable-field settings-notes-body ${placeholderClass}"
650
- data-edit-field="project_notes"
651
- tabindex="0"
652
- title="Click to edit"
653
- role="button"
654
- aria-label="Edit Notes For You"
655
- >${inner}</div>
656
- </div>
657
- </div>
658
- `;
659
- }
660
-
661
- function renderProjectLanesTab() {
662
- const showPriority = state.showPriority;
663
- return `
664
- <div class="section">
665
- <label class="orbit-check settings-toggle" for="showPriorityToggle">
666
- <input
667
- type="checkbox"
668
- id="showPriorityToggle"
669
- ${showPriority ? "checked" : ""}
670
- />
671
- <span class="orbit-check-box" aria-hidden="true">
672
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
673
- </span>
674
- <span class="settings-toggle-text">
675
- <span class="settings-row-title">Show priority label on cards</span>
676
- </span>
677
- </label>
678
- <p class="description lanes-description">Rename, reorder, add, or remove lanes.</p>
679
- ${renderLaneManager()}
680
- <form id="laneCreateForm" class="lane-create-form">
681
- <input name="name" placeholder="New lane name..." required />
682
- <button type="submit" data-variant="primary">Add Lane</button>
683
- </form>
684
- </div>
685
- `;
686
- }
687
-
688
- const UPDATE_INSTRUCTIONS = {
689
- source: "git pull\npnpm install\npnpm run build:bundle\n# then restart Orbit",
690
- docker: "git pull\ndocker compose build\ndocker compose up -d",
691
- dist: "npm install -g @starlove/orbit@latest\n# then restart Orbit"
692
- };
693
-
694
- const UPDATE_MODE_LABEL = {
695
- source: "local / server",
696
- docker: "Docker",
697
- dist: "npm"
698
- };
699
-
700
- const updateCheckGlyph = `<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L4.3 10.7a1 1 0 1 1 1.4-1.4l2.8 2.8 6.8-6.8a1 1 0 0 1 1.4 0z" clip-rule="evenodd"/></svg>`;
701
-
702
- // "Version & Updates" card (top of the Data tab). Reads the cached /api/version
703
- // payload (populated by the boot-time check in update-check.js); renders
704
- // neutrally when it's missing (preview edition / check disabled). The whole
705
- // card gains an accent when an update is available so it reads at a glance.
706
- function renderUpdateCard() {
707
- const info = getUpdateInfo();
708
- const mode = info?.deployMode || "source";
709
- const available = Boolean(info?.updateAvailable);
710
- const current = info?.current ? `v${escapeHtml(info.current)}` : "—";
711
- const showSelfUpdate = Boolean(info?.selfUpdate && available);
712
-
713
- const chip = !info
714
- ? `<span class="update-status-chip update-status-chip--idle">Not checked</span>`
715
- : info.error
716
- ? `<span class="update-status-chip update-status-chip--warn">Couldn't check</span>`
717
- : available
718
- ? `<span class="update-status-chip update-status-chip--available"><span class="update-status-dot" aria-hidden="true"></span>Update available</span>`
719
- : `<span class="update-status-chip update-status-chip--current">${updateCheckGlyph} Up to date</span>`;
720
-
721
- const statusLine = !info
722
- ? "Check whether a newer version of Orbit has shipped."
723
- : info.error
724
- ? `Couldn't reach GitHub (${escapeHtml(info.error)}). Try again in a moment.`
725
- : available
726
- ? "Back up your boards, then update with the steps below."
727
- : "You have the latest version of Orbit.";
728
-
729
- const versionBlock = available
730
- ? `<span class="update-version-now">${current}</span>
731
- <span class="update-version-arrow" aria-hidden="true">→</span>
732
- <span class="update-version-next">v${escapeHtml(info.latest)}</span>`
733
- : `<span class="update-version-now">${current}</span>`;
734
-
735
- const howto = available
736
- ? `
737
- <details class="update-howto" open>
738
- <summary>How to update <span class="update-howto-mode">${escapeHtml(UPDATE_MODE_LABEL[mode] || mode)}</span></summary>
739
- <pre class="update-commands"><code id="updateCommands">${escapeHtml(UPDATE_INSTRUCTIONS[mode] || UPDATE_INSTRUCTIONS.source)}</code></pre>
740
- <div class="update-howto-foot">
741
- <button type="button" data-variant="ghost" id="updateCopyCommands">Copy commands</button>
742
- ${info.changesUrl ? `<a class="update-changes-link" href="${escapeHtml(info.changesUrl)}" target="_blank" rel="noopener">View changes ↗</a>` : ""}
743
- </div>
744
- </details>`
745
- : "";
746
-
747
- return `
748
- <section class="settings-card update-card${available ? " update-card--available" : ""}">
749
- <header class="settings-card-head">
750
- <h3 class="settings-card-title">Version &amp; Updates</h3>
751
- ${chip}
752
- </header>
753
- <div class="settings-card-body">
754
- <div class="settings-row update-row">
755
- <div class="update-version-line">${versionBlock}</div>
756
- <p class="description" id="updateStatusLine">${statusLine}</p>
757
- <div class="deployment-actions update-actions">
758
- <button type="button" data-variant="${available ? "secondary" : "primary"}" id="updateCheckNow">Check now</button>
759
- <button type="button" data-variant="secondary" id="updateBackupBoards">Back up all boards</button>
760
- ${showSelfUpdate ? `<button type="button" data-variant="primary" id="updateRunNow">Update now</button>` : ""}
761
- </div>
762
- ${howto}
763
- <p class="description update-docs-hint">Full guide: <a href="https://github.com/brucestarlove/ss-orbit/blob/master/docs/UPDATING.md" target="_blank" rel="noopener">docs/UPDATING.md</a></p>
764
- </div>
765
- </div>
766
- </section>`;
767
- }
768
-
769
- function renderProjectAppearanceTab() {
770
- const theme = currentTheme();
771
- const reduceMotion = effectiveReducedMotionPreference();
772
- const storedMotion = storedReducedMotionPreference();
773
- const browserDefault = browserPrefersReducedMotion();
774
- const sourceLabel = storedMotion === null
775
- ? `Using browser default (${browserDefault ? "reduced motion" : "motion allowed"}).`
776
- : "Using your saved preference.";
777
-
778
- return `
779
- <div class="settings-stack appearance-settings">
780
- <section class="settings-card">
781
- <header class="settings-card-head">
782
- <h3 class="settings-card-title">Theme</h3>
783
- </header>
784
- <div class="settings-card-body">
785
- <div class="settings-toggle">
786
- <button
787
- type="button"
788
- class="topbar-btn topbar-ctl-shrink"
789
- data-variant="theme"
790
- id="appearanceThemeToggle"
791
- aria-label="Toggle theme"
792
- >
793
- <svg class="topbar-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">${themeIconMarkup(theme)}</svg>
794
- </button>
795
- <span class="settings-toggle-text">
796
- <span class="settings-row-title" id="appearanceThemeLabel">${theme === "dark" ? "Dark" : "Light"}</span>
797
- <span class="description">Tap to switch between the light and dark theme used across the app.</span>
798
- </span>
799
- </div>
800
- <div class="settings-toggle">
801
- <button
802
- type="button"
803
- class="topbar-btn topbar-ctl-shrink motion-toggle"
804
- data-variant="theme"
805
- id="appearanceMotionToggle"
806
- aria-pressed="${reduceMotion ? "true" : "false"}"
807
- aria-label="Toggle reduced theme motion"
808
- >
809
- ${motionIconMarkup()}
810
- </button>
811
- <span class="settings-toggle-text">
812
- <span class="settings-row-title">Reduce theme motion</span>
813
- <span class="description">Turns off the animated starfield, meteors, and theme animations. ${escapeHtml(sourceLabel)}</span>
814
- </span>
815
- </div>
816
- </div>
817
- </section>
818
- ${renderTypographyControls()}
819
- </div>
820
- `;
821
- }
822
-
823
- function renderTypographyControls() {
824
- const prefs = storedFontPreferences();
825
- const siteOptions = SITE_FONT_OPTIONS
826
- .map((id) => `<option value="${id}"${id === prefs.site ? " selected" : ""}>${escapeHtml(FONT_OPTIONS[id]?.label || id)}</option>`)
827
- .join("");
828
- const overrideSelects = Object.entries(FONT_TARGETS).map(([target, config]) => {
829
- const current = prefs[target];
830
- const options = Object.entries(FONT_OPTIONS)
831
- .map(([id, font]) => `<option value="${id}"${id === current ? " selected" : ""}>${escapeHtml(font.label)}</option>`)
832
- .join("");
833
- return `
834
- <label class="control-field">
835
- <span class="control-field-label">${escapeHtml(config.label)}</span>
836
- <select class="select-chevron-field" data-font-target="${target}">${options}</select>
837
- </label>
838
- `;
839
- }).join("");
840
-
841
- return `
842
- <section class="settings-card appearance-typography-card">
843
- <header class="settings-card-head">
844
- <h3 class="settings-card-title">Typography</h3>
845
- </header>
846
- <div class="settings-card-body">
847
- <div class="settings-row settings-slider-row">
848
- <span class="control-field-label">Text size</span>
849
- <input type="range" class="slider" id="fontScaleSlider" min="${USER_SCALE_MIN}" max="${USER_SCALE_MAX}" step="${USER_SCALE_STEP}" value="${prefs.userScale}" aria-label="Text size" />
850
- <output id="fontScaleValue" class="control-value">${Math.round(prefs.userScale * 100)}%</output>
851
- </div>
852
- <div class="settings-row">
853
- <label class="control-field">
854
- <span class="control-field-label">Primary Font</span>
855
- <select class="select-chevron-field" id="siteFontSelect">${siteOptions}</select>
856
- </label>
857
- </div>
858
- <div class="settings-row">
859
- <div class="control-grid typography-overrides">${overrideSelects}</div>
860
- </div>
861
- </div>
862
- </section>
863
- `;
864
- }
865
-
866
- // Mirrors the topbar theme toggle: moon when dark is active, sun when light.
867
- function themeIconMarkup(theme) {
868
- return theme === "dark"
869
- ? '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />'
870
- : '<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />';
871
- }
872
-
873
- // Twinkling starfield for the motion toggle. Stars shimmer (CSS) while motion
874
- // is allowed and freeze when the button is pressed (reduced). Each star's
875
- // --d staggers its animation so the field reads as alive, not pulsing in unison.
876
- function motionIconMarkup() {
877
- const stars = [
878
- { cx: 6, cy: 7, r: 1.1, d: "0s" },
879
- { cx: 17.5, cy: 6, r: 1.5, d: "0.5s" },
880
- { cx: 12, cy: 12, r: 1, d: "0.9s" },
881
- { cx: 7.5, cy: 17, r: 1.4, d: "0.25s" },
882
- { cx: 18, cy: 16.5, r: 1.1, d: "0.7s" }
883
- ]
884
- .map((s) => `<circle class="motion-star" cx="${s.cx}" cy="${s.cy}" r="${s.r}" style="animation-delay:${s.d}" />`)
885
- .join("");
886
- return `<svg class="topbar-icon motion-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">${stars}</svg>`;
887
- }
888
-
889
- function renderProjectJournalTab(context) {
890
- const aiEnabled = context.board?.ai_enabled !== 0;
891
- return `
892
- ${aiEnabled ? `<p class="description">Agent-facing project memory: decisions to follow and lessons to apply. Struck entries stay visible here but are excluded from agent context.</p>` : ""}
893
- <div class="section">
894
- ${renderProjectEntries(context.entries, aiEnabled)}
895
- <form id="projectEntryForm" class="comment-form project-entry-form">
896
- <div class="project-entry-form-header">Add entry</div>
897
- <select name="type" class="select-chevron-field" aria-label="Entry type">
898
- <option value="decision">Decision</option>
899
- <option value="lesson">Lesson</option>
900
- </select>
901
- <input name="title" placeholder="Entry title..." required />
902
- <textarea name="body" placeholder="Decision rationale or lesson: do X instead of Y when working with Z." required></textarea>
903
- <button type="submit" data-variant="primary">Add Entry</button>
904
- </form>
905
- </div>
906
- `;
907
- }
908
-
909
- function parseImportSnapshotText(text) {
910
- const trimmed = String(text || "").trimStart();
911
- if (trimmed.startsWith("<")) {
912
- throw new Error("That file looks like an HTML page instead of JSON. In Trello, use Export JSON/download the board JSON, not Save Page As.");
913
- }
914
- try {
915
- return JSON.parse(text);
916
- } catch (error) {
917
- throw new Error(error?.message || "invalid JSON");
918
- }
919
- }
920
-
921
- function bindProjectTabHandlers(context, tab) {
922
- const project = context.board;
923
-
924
- if (tab === "data") {
925
- const exportSnapshot = async (includeAttachments = Boolean($("#exportProjectImages")?.checked)) => {
926
- const suffix = includeAttachments ? "?include_attachments=true" : "";
927
- const snapshot = await api(`/api/boards/${encodeURIComponent(project.id)}/export${suffix}`);
928
- const filename = includeAttachments ? `${project.slug}.with-images.orbit.json` : `${project.slug}.orbit.json`;
929
- downloadJson(filename, snapshot);
930
- };
931
-
932
- $("#exportProject")?.addEventListener("click", async () => {
933
- await exportSnapshot();
934
- });
935
-
936
- $("#boardRenameForm")?.addEventListener("submit", async (event) => {
937
- event.preventDefault();
938
- const form = new FormData(event.currentTarget);
939
- const nextName = String(form.get("name") || "").trim();
940
- if (!nextName || nextName === project.name) return;
941
- await api(`/api/boards/${encodeURIComponent(project.id)}`, {
942
- method: "PATCH",
943
- body: { name: nextName }
944
- });
945
- await load();
946
- state.detailMode = "settings";
947
- toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
948
- });
949
-
950
- const importSnapshotFile = async (file, bodyForSnapshot) => {
951
- const text = await file.text();
952
- const snapshot = parseImportSnapshotText(text);
953
- return api("/api/admin/import", {
954
- method: "POST",
955
- body: bodyForSnapshot(snapshot)
956
- });
957
- };
958
-
959
- const importSnapshotAsNewFile = $("#importSnapshotAsNewFile");
960
- $("#importSnapshotAsNewButton")?.addEventListener("click", () => {
961
- importSnapshotAsNewFile?.click();
962
- });
963
-
964
- importSnapshotAsNewFile?.addEventListener("change", async (event) => {
965
- const input = event.currentTarget;
966
- const file = input.files?.[0];
967
- if (!file) return;
968
- try {
969
- const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, create_new: true, board_id: project.id }));
970
- state.boardId = result.imported_board_id;
971
- state.boardSlug = result.imported_board_slug;
972
- state.detailMode = "empty";
973
- await load();
974
- await navigate({ boardId: result.imported_board_id, view: "board" });
975
- const counts = result.imported_counts || {};
976
- const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
977
- toast.success(result.source_format === "trello-board-export" ? `Trello board imported as new board${cardCount}` : "Board imported as new board");
978
- } catch (err) {
979
- toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
980
- } finally {
981
- input.value = "";
982
- }
983
- });
984
-
985
- const replaceImportConfirmInput = $("#replaceImportConfirmInput");
986
- const replaceCurrentBoardImportFile = $("#replaceCurrentBoardImportFile");
987
- const replaceCurrentBoardImportButton = $("#replaceCurrentBoardImportButton");
988
- replaceImportConfirmInput?.addEventListener("input", () => {
989
- const disabled = replaceImportConfirmInput.value.trim() !== project.slug;
990
- if (replaceCurrentBoardImportFile) replaceCurrentBoardImportFile.disabled = disabled;
991
- if (replaceCurrentBoardImportButton) replaceCurrentBoardImportButton.disabled = disabled;
992
- });
993
-
994
- replaceCurrentBoardImportButton?.addEventListener("click", () => {
995
- if (replaceCurrentBoardImportButton.disabled || replaceCurrentBoardImportFile?.disabled) return;
996
- replaceCurrentBoardImportFile?.click();
997
- });
998
-
999
- replaceCurrentBoardImportFile?.addEventListener("change", async (event) => {
1000
- const input = event.currentTarget;
1001
- const file = input.files?.[0];
1002
- if (!file) return;
1003
- if (replaceImportConfirmInput.value.trim() !== project.slug) {
1004
- toast.error("Type the board slug before replacing this board.");
1005
- input.value = "";
1006
- return;
1007
- }
1008
- try {
1009
- const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, replace_existing: true, board_id: project.id }));
1010
- await load();
1011
- state.detailMode = "settings";
1012
- const counts = result.imported_counts || {};
1013
- const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
1014
- toast.success(result.source_format === "trello-board-export" ? `Trello board replaced from Trello import${cardCount}` : "Board replaced from snapshot");
1015
- } catch (err) {
1016
- toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
1017
- } finally {
1018
- input.value = "";
1019
- }
1020
- });
1021
-
1022
- $("#deleteBoardStart")?.addEventListener("click", async () => {
1023
- repositoryDelete.step = "choice";
1024
- repositoryDelete.exported = false;
1025
- await renderProjectDetail();
1026
- });
1027
-
1028
- $("#deleteBoardExportFirst")?.addEventListener("click", async () => {
1029
- await exportSnapshot();
1030
- repositoryDelete.step = "confirm";
1031
- repositoryDelete.exported = true;
1032
- await renderProjectDetail();
1033
- });
1034
-
1035
- $("#deleteBoardSkipExport")?.addEventListener("click", async () => {
1036
- repositoryDelete.step = "confirm";
1037
- repositoryDelete.exported = false;
1038
- await renderProjectDetail();
1039
- });
1040
-
1041
- $("#deleteBoardCancel")?.addEventListener("click", async () => {
1042
- repositoryDelete.step = "idle";
1043
- repositoryDelete.exported = false;
1044
- await renderProjectDetail();
1045
- });
1046
-
1047
- const confirmInput = $("#deleteBoardConfirmInput");
1048
- const confirmButton = $("#deleteBoardConfirm");
1049
- confirmInput?.addEventListener("input", () => {
1050
- if (confirmButton) confirmButton.disabled = confirmInput.value.trim() !== project.slug;
1051
- });
1052
- confirmButton?.addEventListener("click", async () => {
1053
- await api(`/api/boards/${encodeURIComponent(project.id)}`, {
1054
- method: "DELETE",
1055
- body: { confirm_slug: project.slug, delete_files: true }
1056
- });
1057
- repositoryDelete.step = "idle";
1058
- repositoryDelete.exported = false;
1059
- await load();
1060
- toast.success(`Deleted board: ${project.name}`);
1061
- navigate({ boardId: state.boardId, view: "board" }, { replace: true });
1062
- });
1063
-
1064
- // Version & Updates card (top of the Data tab).
1065
- $("#updateCheckNow")?.addEventListener("click", async () => {
1066
- const btn = $("#updateCheckNow");
1067
- btn.disabled = true;
1068
- btn.textContent = "Checking…";
1069
- await fetchVersionInfo({ force: true });
1070
- syncUpdateBadge();
1071
- state.detailMode = "settings";
1072
- await renderProjectDetail();
1073
- });
1074
-
1075
- $("#updateBackupBoards")?.addEventListener("click", async () => {
1076
- const btn = $("#updateBackupBoards");
1077
- const orig = btn.textContent;
1078
- btn.disabled = true;
1079
- btn.textContent = "Backing up…";
1080
- try {
1081
- await exportAllBoards();
1082
- } catch (error) {
1083
- toast.error(`Backup failed: ${error?.message || "request failed"}`);
1084
- } finally {
1085
- btn.disabled = false;
1086
- btn.textContent = orig;
1087
- }
1088
- });
1089
-
1090
- $("#updateCopyCommands")?.addEventListener("click", async () => {
1091
- const text = $("#updateCommands")?.textContent || "";
1092
- try {
1093
- await navigator.clipboard.writeText(text);
1094
- toast.success("Update commands copied");
1095
- } catch {
1096
- toast.error("Couldn't copy — select the commands manually");
1097
- }
1098
- });
1099
-
1100
- $("#updateRunNow")?.addEventListener("click", async () => {
1101
- const ok = window.confirm(
1102
- "Update Orbit now? It will pull the latest code, rebuild, and restart. Back up your boards first."
1103
- );
1104
- if (!ok) return;
1105
- const btn = $("#updateRunNow");
1106
- btn.disabled = true;
1107
- btn.textContent = "Updating…";
1108
- try {
1109
- await api("/api/update", { method: "POST" });
1110
- toast.success("Update started — Orbit will restart shortly. Reload in a minute.", 9000);
1111
- } catch (error) {
1112
- toast.error(`Update failed to start: ${error?.message || "request failed"}`);
1113
- btn.disabled = false;
1114
- btn.textContent = "Update now";
1115
- }
1116
- });
1117
- }
1118
-
1119
- if (tab === "notes") {
1120
- const notesEl = drawerInner.querySelector('[data-edit-field="project_notes"]');
1121
- if (notesEl) {
1122
- const edit = () =>
1123
- startInlineEdit(notesEl, {
1124
- fieldName: "project_notes",
1125
- multiline: true,
1126
- initialValue: project.project_notes || "",
1127
- ariaLabel: "Edit Notes For You",
1128
- rerender: async () => {
1129
- state.detailMode = "settings";
1130
- await renderProjectDetail();
1131
- },
1132
- commit: async (next) => {
1133
- await api(`/api/boards/${project.id}`, {
1134
- method: "PATCH",
1135
- body: { project_notes: cleanText(next) }
1136
- });
1137
- await load();
1138
- state.detailMode = "settings";
1139
- toast.success("Saved");
1140
- }
1141
- });
1142
- notesEl.addEventListener("click", (event) => {
1143
- event.preventDefault();
1144
- edit();
1145
- });
1146
- notesEl.addEventListener("keydown", (event) => {
1147
- if (event.key === "Enter" || event.key === " ") {
1148
- event.preventDefault();
1149
- edit();
1150
- }
1151
- });
1152
- }
1153
- }
1154
-
1155
- if (tab === "lanes") {
1156
- drawerInner.querySelectorAll("[data-lane-action]").forEach((button) => {
1157
- button.addEventListener("click", async () => {
1158
- await handleLaneAction(button);
1159
- });
1160
- });
1161
-
1162
- drawerInner.querySelectorAll(".lane-row input, .lane-row select").forEach((field) => {
1163
- field.addEventListener("change", async () => {
1164
- await updateLane(field.closest(".lane-row"));
1165
- });
1166
- });
1167
-
1168
- $("#laneCreateForm")?.addEventListener("submit", async (event) => {
1169
- event.preventDefault();
1170
- const form = new FormData(event.currentTarget);
1171
- await api(`/api/boards/${project.id}/states`, {
1172
- method: "POST",
1173
- body: Object.fromEntries(form.entries())
1174
- });
1175
- await load();
1176
- state.detailMode = "settings";
1177
- toast.success("Lane added");
1178
- });
1179
-
1180
- $("#showPriorityToggle")?.addEventListener("change", (event) => {
1181
- state.showPriority = !!event.currentTarget.checked;
1182
- localStorage.setItem("mab_show_priority", state.showPriority ? "true" : "false");
1183
- renderBoard();
1184
- });
1185
- }
1186
-
1187
- if (tab === "appearance") {
1188
- const themeToggleBtn = $("#appearanceThemeToggle");
1189
- if (themeToggleBtn) {
1190
- const themeLabel = $("#appearanceThemeLabel");
1191
- const themeIcon = themeToggleBtn.querySelector(".topbar-icon");
1192
- themeToggleBtn.addEventListener("click", () => {
1193
- const theme = toggleThemePreference();
1194
- if (themeIcon) themeIcon.innerHTML = themeIconMarkup(theme);
1195
- if (themeLabel) themeLabel.textContent = theme === "dark" ? "Dark" : "Light";
1196
- toast.success(theme === "dark" ? "Dark theme enabled" : "Light theme enabled");
1197
- });
1198
- }
1199
-
1200
- const motionToggleBtn = $("#appearanceMotionToggle");
1201
- motionToggleBtn?.addEventListener("click", () => {
1202
- const reduce = motionToggleBtn.getAttribute("aria-pressed") !== "true";
1203
- motionToggleBtn.setAttribute("aria-pressed", reduce ? "true" : "false");
1204
- setReducedMotionPreference(reduce);
1205
- applyReducedMotionPreference();
1206
- window.dispatchEvent(new CustomEvent("orbit:motion-preference-change", { detail: { reduce } }));
1207
- toast.success(reduce ? "Theme motion reduced" : "Theme motion enabled");
1208
- });
1209
-
1210
- // Primary font swap resets every override, so reflect that in the category
1211
- // selects without re-fetching/re-rendering the whole drawer.
1212
- $("#siteFontSelect")?.addEventListener("change", (event) => {
1213
- const next = setSiteFont(event.currentTarget.value);
1214
- drawerInner.querySelectorAll("select[data-font-target]").forEach((select) => {
1215
- select.value = next[select.dataset.fontTarget];
1216
- });
1217
- toast.success(`Primary font: ${FONT_OPTIONS[next.site]?.label || "updated"}`);
1218
- });
1219
-
1220
- const fontScaleSlider = $("#fontScaleSlider");
1221
- if (fontScaleSlider) {
1222
- const scaleValue = $("#fontScaleValue");
1223
- fontScaleSlider.addEventListener("input", (event) => {
1224
- const next = setUserScale(event.currentTarget.value);
1225
- if (scaleValue) scaleValue.textContent = `${Math.round(next.userScale * 100)}%`;
1226
- });
1227
- }
1228
-
1229
- bindSliders(drawerInner);
1230
-
1231
- drawerInner.querySelectorAll("select[data-font-target]").forEach((select) => {
1232
- select.addEventListener("change", (event) => {
1233
- setFontPreference(event.currentTarget.dataset.fontTarget, event.currentTarget.value);
1234
- });
1235
- });
1236
- }
1237
-
1238
- if (tab === "ai") {
1239
- const updateMcpPreview = () => {
1240
- const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
1241
- const clientId = $("#mcpClientSelect")?.value || "claude-code";
1242
- const client = MCP_CLIENTS[clientId] || MCP_CLIENTS.generic;
1243
- const paths = mcpPathsFromForm();
1244
- const configText = mcpConfigText(clientId, paths);
1245
- const commandText = mcpRunCommand(osId, paths);
1246
-
1247
- const osNote = drawerInner.querySelector("[data-mcp-os-note]");
1248
- if (osNote) osNote.textContent = `Detected: ${MCP_OS_OPTIONS[detectedMcpOs()]?.label || "Linux"}`;
1249
-
1250
- const format = drawerInner.querySelector("[data-mcp-client-format]");
1251
- if (format) format.textContent = client.format;
1252
-
1253
- const title = drawerInner.querySelector("[data-mcp-output-title]");
1254
- if (title) title.textContent = `${client.label} setup`;
1255
-
1256
- const helper = drawerInner.querySelector("[data-mcp-helper]");
1257
- if (helper) helper.textContent = client.helper;
1258
-
1259
- const configEl = drawerInner.querySelector("[data-mcp-config]");
1260
- if (configEl) configEl.textContent = configText;
1261
-
1262
- const commandEl = drawerInner.querySelector("[data-mcp-command]");
1263
- if (commandEl) commandEl.textContent = commandText;
1264
- };
1265
-
1266
- $("#mcpOsSelect")?.addEventListener("change", () => updateMcpPreview());
1267
- $("#mcpClientSelect")?.addEventListener("change", () => updateMcpPreview());
1268
- ["#mcpServerPathInput"].forEach((selector) => {
1269
- $(selector)?.addEventListener("input", () => updateMcpPreview());
1270
- });
1271
-
1272
- drawerInner.querySelectorAll("[data-copy-mcp]").forEach((button) => {
1273
- button.addEventListener("click", async () => {
1274
- const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
1275
- const clientId = $("#mcpClientSelect")?.value || "claude-code";
1276
- const paths = mcpPathsFromForm();
1277
- const value = button.dataset.copyMcp === "command" ? mcpRunCommand(osId, paths) : mcpConfigText(clientId, paths);
1278
- await copyText(value);
1279
- toast.success(button.dataset.copyMcp === "command" ? "MCP command copied" : "MCP config copied");
1280
- });
1281
- });
1282
-
1283
- const instructionsEl = drawerInner.querySelector('[data-edit-field="agent_instructions"]');
1284
- if (instructionsEl) {
1285
- const edit = () =>
1286
- startInlineEdit(instructionsEl, {
1287
- fieldName: "agent_instructions",
1288
- multiline: true,
1289
- initialValue: project.agent_instructions || "",
1290
- ariaLabel: "Edit Agent Instructions",
1291
- rerender: async () => {
1292
- state.detailMode = "settings";
1293
- await renderProjectDetail();
1294
- },
1295
- commit: async (next) => {
1296
- await api(`/api/boards/${project.id}`, {
1297
- method: "PATCH",
1298
- body: { agent_instructions: cleanText(next) }
1299
- });
1300
- await load();
1301
- state.detailMode = "settings";
1302
- toast.success("Saved");
1303
- }
1304
- });
1305
- instructionsEl.addEventListener("click", (event) => {
1306
- event.preventDefault();
1307
- edit();
1308
- });
1309
- instructionsEl.addEventListener("keydown", (event) => {
1310
- if (event.key === "Enter" || event.key === " ") {
1311
- event.preventDefault();
1312
- edit();
1313
- }
1314
- });
1315
- }
1316
-
1317
- $("#aiEnabledToggle")?.addEventListener("change", async (event) => {
1318
- const enabled = !!event.currentTarget.checked;
1319
- await api(`/api/boards/${project.id}`, {
1320
- method: "PATCH",
1321
- body: { ai_enabled: enabled }
1322
- });
1323
- await load();
1324
- state.detailMode = "settings";
1325
- toast.success(enabled ? "AI features enabled — agent lanes provisioned" : "AI features disabled — AI Ready hidden");
1326
- });
1327
-
1328
- const helperPickFolderBtn = $("#helperPickFolderBtn");
1329
- helperPickFolderBtn?.addEventListener("click", async () => {
1330
- helperPickFolderBtn.disabled = true;
1331
- try {
1332
- const result = await api("/api/system/pick-folder", { method: "POST" });
1333
- if (!result.path) return;
1334
- await api(`/api/boards/${encodeURIComponent(project.id)}`, {
1335
- method: "PATCH",
1336
- body: { system_path: result.path }
1337
- });
1338
- await load();
1339
- state.detailMode = "settings";
1340
- toast.success("Linked coding project folder updated");
1341
- } catch (error) {
1342
- toast.error(error?.message || "Folder picker unavailable");
1343
- } finally {
1344
- helperPickFolderBtn.disabled = false;
1345
- }
1346
- });
1347
-
1348
- const manageHelperFilesToggle = $("#manageHelperFilesToggle");
1349
- manageHelperFilesToggle?.addEventListener("change", async () => {
1350
- const enabled = manageHelperFilesToggle.checked;
1351
- manageHelperFilesToggle.disabled = true;
1352
- try {
1353
- await api(`/api/boards/${encodeURIComponent(project.id)}`, {
1354
- method: "PATCH",
1355
- body: { manage_helper_files: enabled }
1356
- });
1357
- await load();
1358
- state.detailMode = "settings";
1359
- toast.success(
1360
- enabled
1361
- ? "Generated SKILL-ORBIT.md and AGENTS.md — Orbit will keep them in sync"
1362
- : "Stopped maintaining helper files (existing files left in place)"
1363
- );
1364
- } catch (error) {
1365
- manageHelperFilesToggle.checked = !enabled;
1366
- manageHelperFilesToggle.disabled = false;
1367
- toast.error(error?.message || "Could not update helper files setting");
1368
- }
1369
- });
1370
-
1371
- }
1372
-
1373
- if (tab === "archive") {
1374
- const listEl = $("#archiveList");
1375
- if (listEl) {
1376
- const refresh = async () => {
1377
- try {
1378
- const result = await api(`/api/boards/${encodeURIComponent(project.id)}/archive`);
1379
- const tickets = result.tickets || [];
1380
- if (tickets.length === 0) {
1381
- listEl.innerHTML = `<p class="description">No archived cards yet. Click the trash icon in the card detail header to archive a card.</p>`;
1382
- return;
1383
- }
1384
- listEl.innerHTML = `<div class="detail-card-grid">${tickets
1385
- .map((t) => renderDetailCard(t, { archive: { id: t.id }, disableOpen: true }))
1386
- .join("")}</div>`;
1387
- } catch (err) {
1388
- listEl.innerHTML = `<p class="description">Could not load archive: ${escapeHtml(err?.message || "unknown error")}</p>`;
1389
- }
1390
- };
1391
-
1392
- refresh();
1393
-
1394
- listEl.addEventListener("click", async (event) => {
1395
- const restoreBtn = event.target.closest("[data-archive-restore]");
1396
- const deleteBtn = event.target.closest("[data-archive-delete]");
1397
- if (!restoreBtn && !deleteBtn) return;
1398
- event.stopPropagation();
1399
- const card = (restoreBtn || deleteBtn).closest("article");
1400
- const cardTitle = card?.querySelector("h3")?.textContent || "card";
1401
- if (restoreBtn) {
1402
- const id = restoreBtn.dataset.archiveRestore;
1403
- await api(withBoardQuery(`/api/tickets/${encodeURIComponent(id)}/restore`), { method: "POST" });
1404
- toast.success(`Restored: ${cardTitle}`);
1405
- await load();
1406
- await refresh();
1407
- return;
1408
- }
1409
- if (deleteBtn) {
1410
- const id = deleteBtn.dataset.archiveDelete;
1411
- if (!confirm(`Permanently delete "${cardTitle}"? This cannot be undone.`)) return;
1412
- await api(withBoardQuery(`/api/tickets/${encodeURIComponent(id)}`), { method: "DELETE" });
1413
- toast.removed(`Deleted: ${cardTitle}`);
1414
- await refresh();
1415
- }
1416
- });
1417
- }
1418
- }
1419
-
1420
- if (tab === "journal") {
1421
- drawerInner.querySelectorAll("[data-entry-action='toggle-struck']").forEach((button) => {
1422
- button.addEventListener("click", async () => {
1423
- await api(withBoardQuery(`/api/board-entries/${button.dataset.entryId}`), {
1424
- method: "PATCH",
1425
- body: { struck: button.dataset.struck !== "true" }
1426
- });
1427
- await load();
1428
- state.detailMode = "settings";
1429
- toast.info(button.dataset.struck === "true" ? "Project entry restored" : "Project entry struck");
1430
- });
1431
- });
1432
-
1433
- $("#projectEntryForm")?.addEventListener("submit", async (event) => {
1434
- event.preventDefault();
1435
- const form = new FormData(event.currentTarget);
1436
- await api(`/api/boards/${project.id}/entries`, {
1437
- method: "POST",
1438
- body: Object.fromEntries(form.entries())
1439
- });
1440
- await load();
1441
- state.detailMode = "settings";
1442
- toast.success("Project entry added");
1443
- });
1444
- }
1445
- }
1446
-
1447
- async function copyText(value) {
1448
- if (navigator.clipboard?.writeText) {
1449
- await navigator.clipboard.writeText(value);
1450
- return;
1451
- }
1452
-
1453
- const textArea = document.createElement("textarea");
1454
- textArea.value = value;
1455
- textArea.setAttribute("readonly", "");
1456
- textArea.style.position = "fixed";
1457
- textArea.style.left = "-9999px";
1458
- document.body.append(textArea);
1459
- textArea.select();
1460
- document.execCommand("copy");
1461
- textArea.remove();
1462
- }
1463
-
1464
- function renderProjectEntries(entries = [], aiEnabled = false) {
1465
- if (!entries.length) return `<p class="description">No project entries yet.</p>`;
1466
- return entries
1467
- .map(
1468
- (entry) => {
1469
- const isStruck = Boolean(entry.struck_at);
1470
- const showStruckState = aiEnabled && isStruck;
1471
- return `
1472
- <div class="comment project-entry ${escapeHtml(entry.type)} ${showStruckState ? "is-struck" : ""}">
1473
- <div class="comment-head">
1474
- <div class="comment-head-main">
1475
- <div class="comment-head-title">
1476
- <span class="entry-type-pill ${escapeHtml(entry.type)}">${escapeHtml(entry.type)}</span>
1477
- <strong class="entry-title">${escapeHtml(entry.title)}</strong>
1478
- </div>
1479
- ${showStruckState ? `<span class="project-entry-status">Excluded from agent context</span>` : ""}
1480
- </div>
1481
- </div>
1482
- <div class="comment-body">${escapeHtml(entry.body)}</div>
1483
- <div class="comment-footer">
1484
- <span class="comment-footer-meta">${escapeHtml(entry.created_by)} · ${formatDate(entry.created_at)}</span>
1485
- ${
1486
- aiEnabled
1487
- ? `<button
1488
- type="button"
1489
- data-variant="ghost"
1490
- class="project-entry-strike detail-action-btn"
1491
- data-entry-action="toggle-struck"
1492
- data-entry-id="${escapeHtml(entry.id)}"
1493
- data-struck="${isStruck ? "true" : "false"}"
1494
- aria-pressed="${isStruck ? "true" : "false"}"
1495
- >${isStruck ? "Restore" : "Strike"}</button>`
1496
- : ""
1497
- }
1498
- </div>
1499
- </div>
1500
- `;
1501
- }
1502
- )
1503
- .join("");
1504
- }
1505
-
1506
- // In Progress and Review are core kanban lanes — undeletable regardless of
1507
- // AI. AI Ready is only locked while AI is enabled; disabling AI hides it from
1508
- // the board without moving its cards.
1509
- const CORE_LANE_ROLES = new Set(["in_progress", "review"]);
1510
-
1511
- /** A lane can't be deleted if it has cards (orphan risk), if it's the default
1512
- * "new cards land here" lane (every project must keep exactly one), if it's
1513
- * a core kanban lane (in_progress / review), or if it's the AI Ready lane
1514
- * while AI is enabled. */
1515
- function deleteDisabled(lane, counts, aiEnabled) {
1516
- if (counts.get(lane.id)) return true;
1517
- if (lane.is_default) return true;
1518
- if (CORE_LANE_ROLES.has(lane.role)) return true;
1519
- if (aiEnabled && lane.role === "ai_ready") return true;
1520
- return false;
1521
- }
1522
-
1523
- function deleteTitle(lane, counts, aiEnabled) {
1524
- const count = counts.get(lane.id) || 0;
1525
- if (count > 0) {
1526
- return `Cannot delete — ${count} card${count === 1 ? "" : "s"} in this lane`;
1527
- }
1528
- if (lane.is_default) {
1529
- return "Cannot delete — default lane for new cards (move it first)";
1530
- }
1531
- if (CORE_LANE_ROLES.has(lane.role)) {
1532
- return "Cannot delete — core kanban lane";
1533
- }
1534
- if (aiEnabled && lane.role === "ai_ready") {
1535
- return "Cannot delete — required by agent flow while AI is enabled";
1536
- }
1537
- return "Delete lane";
1538
- }
1539
-
1540
- function renderLaneManager() {
1541
- const lanes = statesForProject();
1542
- const aiEnabled = currentBoard()?.ai_enabled !== 0;
1543
- const counts = new Map();
1544
- for (const ticket of ticketsForProject()) {
1545
- counts.set(ticket.state_id, (counts.get(ticket.state_id) || 0) + 1);
1546
- }
1547
-
1548
- return `
1549
- <div class="lane-list">
1550
- ${lanes
1551
- .map((lane, index) => {
1552
- const isAiReady = lane.role === "ai_ready";
1553
- const nameTitle = isAiReady
1554
- ? "AI Ready lane name is locked — disable AI to hide this column"
1555
- : "";
1556
- return `
1557
- <div class="lane-row${isAiReady ? " lane-row--locked" : ""}" data-lane-id="${lane.id}">
1558
- <button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
1559
- <button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
1560
- <input name="name" value="${escapeHtml(lane.name)}" aria-label="Lane name" ${isAiReady ? `readonly title="${escapeHtml(nameTitle)}"` : ""} />
1561
- <button type="button" data-variant="${lane.is_default ? "secondary" : "ghost"}" class="settings-lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
1562
- <span class="lane-new-sun" aria-hidden="true"></span>
1563
- <span class="lane-new-label">New</span>
1564
- </button>
1565
- <button type="button" data-variant="ghost" class="settings-lane-delete-btn danger-button" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">${closeIconSvg}</button>
1566
- </div>
1567
- `;
1568
- })
1569
- .join("")}
1570
- </div>
1571
- `;
1572
- }
1573
-
1574
- async function handleLaneAction(button) {
1575
- const row = button.closest(".lane-row");
1576
- const laneId = row.dataset.laneId;
1577
- const action = button.dataset.laneAction;
1578
- if (action === "delete") {
1579
- await api(withBoardQuery(`/api/states/${laneId}`), { method: "DELETE" });
1580
- await load();
1581
- state.detailMode = "settings";
1582
- toast.success("Lane deleted");
1583
- return;
1584
- }
1585
-
1586
- if (action === "toggle-default") {
1587
- // Radio-style: one lane is always the default. Clicking the current
1588
- // default is a no-op — to move it you click a different lane. This
1589
- // guarantees new-card creation always has somewhere to land.
1590
- const wasOn = button.getAttribute("aria-pressed") === "true";
1591
- if (wasOn) return;
1592
- drawerInner.querySelectorAll('[data-lane-action="toggle-default"]').forEach((btn) => {
1593
- btn.setAttribute("aria-pressed", "false");
1594
- btn.setAttribute("data-variant", "ghost");
1595
- btn.classList.remove("is-on");
1596
- });
1597
- button.setAttribute("aria-pressed", "true");
1598
- button.setAttribute("data-variant", "secondary");
1599
- button.classList.add("is-on");
1600
- await updateLane(row, { silent: true });
1601
- toast.success("Default lane updated");
1602
- return;
1603
- }
1604
-
1605
- const lanes = statesForProject().map((lane) => lane.id);
1606
- const index = lanes.indexOf(laneId);
1607
- const swapWith = action === "up" ? index - 1 : index + 1;
1608
- if (swapWith < 0 || swapWith >= lanes.length) return;
1609
- [lanes[index], lanes[swapWith]] = [lanes[swapWith], lanes[index]];
1610
- await api(`/api/boards/${state.boardId}/states`, {
1611
- method: "PATCH",
1612
- body: { state_ids: lanes }
1613
- });
1614
- await load();
1615
- state.detailMode = "settings";
1616
- toast.success("Lanes reordered");
1617
- }
1618
-
1619
- async function updateLane(row, { silent = false } = {}) {
1620
- const name = row.querySelector('input[name="name"]').value;
1621
- const toggle = row.querySelector('[data-lane-action="toggle-default"]');
1622
- const isDefault = toggle?.getAttribute("aria-pressed") === "true";
1623
-
1624
- await api(withBoardQuery(`/api/states/${row.dataset.laneId}`), {
1625
- method: "PATCH",
1626
- body: {
1627
- name,
1628
- is_default: isDefault
1629
- }
1630
- });
1631
- await load();
1632
- state.detailMode = "settings";
1633
- if (!silent) toast.success("Lane updated");
1634
- }