@starlove/orbit 1.3.5 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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.4e6fbe20.js +952 -0
  40. package/dist/full/js/app.4e6fbe20.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 +230 -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
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { state, getSettingsTab, statesForProject, ticketsForProject, currentBoard } from "./state.js";
6
6
  import { drawerInner, $ } from "./dom.js";
7
- import { escapeHtml, formatDate, repoLabelFromUrl, renderPreservedText, cleanText } from "./format.js";
7
+ import { escapeHtml, formatDate, renderPreservedText, cleanText } from "./format.js";
8
8
  import { api, withBoardQuery } from "./api.js";
9
9
  import { toast, downloadJson } from "./toast.js";
10
10
  import { renderDrawerShell } from "./drawer.js";
@@ -20,7 +20,22 @@ import {
20
20
  setReducedMotionPreference,
21
21
  storedReducedMotionPreference
22
22
  } from "./motion-preference.js";
23
- import { currentTheme, setThemePreference } from "./theme-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";
24
39
 
25
40
  const repositoryDelete = {
26
41
  boardId: "",
@@ -28,9 +43,35 @@ const repositoryDelete = {
28
43
  exported: false
29
44
  };
30
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
+
31
63
  export async function renderProjectDetail() {
32
64
  const context = await api(`/api/boards/${state.boardId}/context?include_struck=true`);
33
- const project = context.board;
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;
34
75
  const activeTab = getSettingsTab();
35
76
 
36
77
  const tabs = [
@@ -40,7 +81,7 @@ export async function renderProjectDetail() {
40
81
  { id: "notes", label: "Notes" },
41
82
  { id: "journal", label: "Journal" },
42
83
  ...(features.ai ? [{ id: "ai", label: "AI" }] : []),
43
- { id: "repository", label: "Repository" }
84
+ { id: "data", label: "Data" }
44
85
  ];
45
86
 
46
87
  const resolvedTab = activeTab === "ai" && !features.ai ? "lanes" : activeTab;
@@ -48,7 +89,6 @@ export async function renderProjectDetail() {
48
89
  renderDrawerShell({
49
90
  eyebrow: "SETTINGS",
50
91
  mode: "settings",
51
- title: project.name,
52
92
  tabs,
53
93
  activeTab: resolvedTab,
54
94
  onTabSelect: async (id) => {
@@ -78,9 +118,9 @@ function renderProjectTabBody(context, tab) {
78
118
  return renderProjectJournalTab(context);
79
119
  case "archive":
80
120
  return renderProjectArchiveTab();
81
- case "repository":
121
+ case "data":
82
122
  default:
83
- return renderProjectRepositoryTab(context);
123
+ return renderProjectDataTab(context);
84
124
  }
85
125
  }
86
126
 
@@ -143,31 +183,37 @@ const MCP_CLIENTS = {
143
183
  function renderProjectAiTab(context) {
144
184
  const project = context.board;
145
185
  const aiEnabled = project.ai_enabled !== 0;
146
- const aiDivider = aiEnabled ? `<div class="separator" aria-hidden="true"></div>` : "";
147
186
  const agentContextSetup = aiEnabled ? renderAgentContextSection(project) : "";
148
187
  const helperFilesSetup = aiEnabled && features.multiBoard ? renderHelperFilesSection(project) : "";
149
188
  const mcpSetup = aiEnabled ? renderMcpSetupSection(context) : "";
150
189
 
151
190
  return `
152
- <div class="section">
153
- <label class="orbit-check" for="aiEnabledToggle">
154
- <input
155
- type="checkbox"
156
- id="aiEnabledToggle"
157
- ${aiEnabled ? "checked" : ""}
158
- />
159
- <span class="orbit-check-box" aria-hidden="true">
160
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
161
- </span>
162
- <span class="orbit-check-label">Enable AI</span>
163
- </label>
164
- <p 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.</p>
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}
165
216
  </div>
166
- ${aiDivider}
167
- ${agentContextSetup}
168
- ${helperFilesSetup ? `${aiDivider}${helperFilesSetup}` : ""}
169
- ${aiDivider}
170
- ${mcpSetup}
171
217
  `;
172
218
  }
173
219
 
@@ -179,23 +225,26 @@ function renderAgentContextSection(project) {
179
225
  const placeholderClass = hasInstructions ? "" : "is-placeholder";
180
226
  const inner = hasInstructions ? renderPreservedText(instructions) : escapeHtml(instructionsPlaceholder);
181
227
  return `
182
- <div class="section ai-subsection ai-context-setup">
183
- <div class="ai-context-title-row">
184
- <span class="section-mark" aria-hidden="true">Context</span>
185
- <h3>Agent Instructions</h3>
186
- </div>
187
- <p class="description">This project-level context is provided to agents when they work on tickets.</p>
188
- <div class="inline-md-field">
189
- <div
190
- class="inline-md-field-body preserved-text-body editable-field settings-agent-instructions-body ${placeholderClass}"
191
- data-edit-field="agent_instructions"
192
- tabindex="0"
193
- title="Click to edit"
194
- role="button"
195
- aria-label="Edit Agent Instructions"
196
- >${inner}</div>
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>
197
246
  </div>
198
- </div>
247
+ </section>
199
248
  `;
200
249
  }
201
250
 
@@ -207,69 +256,71 @@ function renderMcpSetupSection(context) {
207
256
  const commandText = mcpRunCommand(osId, paths);
208
257
  const client = MCP_CLIENTS[clientId];
209
258
  return `
210
- <details class="section ai-subsection mcp-setup" data-mcp-setup>
211
- <summary>
212
- <div class="mcp-title-row">
213
- <span class="section-mark" aria-hidden="true">MCP</span>
214
- <h3>Connect Your Agent to MCP</h3>
215
- </div>
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>
216
263
  </summary>
217
- <div>
218
- <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>
219
- <div class="mcp-flow">
220
- <label>
221
- <span>Operating system</span>
222
- <select id="mcpOsSelect" class="select-chevron-field">
223
- ${Object.entries(MCP_OS_OPTIONS)
224
- .map(([id, option]) => `<option value="${id}" ${id === osId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
225
- .join("")}
226
- </select>
227
- </label>
228
- <label>
229
- <span>Agent app</span>
230
- <select id="mcpClientSelect" class="select-chevron-field">
231
- ${Object.entries(MCP_CLIENTS)
232
- .map(([id, option]) => `<option value="${id}" ${id === clientId ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
233
- .join("")}
234
- </select>
235
- </label>
236
- </div>
237
- <div class="mcp-path-grid">
238
- <label>
239
- <span>Orbit MCP server file</span>
240
- <input id="mcpServerPathInput" value="${escapeHtml(paths.serverPath)}" />
241
- </label>
242
- </div>
243
- </div>
244
-
245
- <div class="mcp-options-stack">
246
- <div class="mcp-option-card">
247
- <div class="mcp-option-card-head">
248
- <div>
249
- <strong data-mcp-output-title>${escapeHtml(client.label)} setup</strong>
250
- <p class="description" data-mcp-helper>${escapeHtml(client.helper)}</p>
251
- </div>
252
- <button type="button" data-variant="secondary" data-copy-mcp="config">Copy setup</button>
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>
253
284
  </div>
254
- <div class="mcp-terminal">
255
- <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
256
- <pre class="mcp-snippet"><code data-mcp-config>${escapeHtml(configText)}</code></pre>
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>
257
290
  </div>
258
291
  </div>
259
292
 
260
- <div class="mcp-or-divider" aria-hidden="true"><span>OR</span></div>
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>
261
308
 
262
- <div class="mcp-option-card">
263
- <div class="mcp-option-card-head">
264
- <div>
265
- <strong>Manual run command</strong>
266
- <p class="description">Use this only when an app asks you to start the MCP server yourself.</p>
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>
267
323
  </div>
268
- <button type="button" data-variant="secondary" data-copy-mcp="command">Copy command</button>
269
- </div>
270
- <div class="mcp-terminal">
271
- <div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
272
- <pre class="mcp-snippet mcp-snippet--compact"><code data-mcp-command>${escapeHtml(commandText)}</code></pre>
273
324
  </div>
274
325
  </div>
275
326
  </div>
@@ -387,7 +438,7 @@ function renderProjectArchiveTab() {
387
438
  `;
388
439
  }
389
440
 
390
- function renderProjectRepositoryTab(context) {
441
+ function renderProjectDataTab(context) {
391
442
  const project = context.board;
392
443
  if (repositoryDelete.boardId !== project.id) {
393
444
  repositoryDelete.boardId = project.id;
@@ -395,58 +446,74 @@ function renderProjectRepositoryTab(context) {
395
446
  repositoryDelete.exported = false;
396
447
  }
397
448
  const repoMeta = features.multiBoard
398
- ? `
399
- <div class="detail-head project-manual-head">
400
- <div class="meta-grid">
401
- <div class="meta meta--repo-path">
402
- <span>Repo</span>
403
- <strong>${escapeHtml(repoLabelFromUrl(project.repo_url))}</strong>
404
- <span>Path</span>
405
- <strong>${escapeHtml(project.system_path || context.deployment?.system_path || "Not available")}</strong>
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>
406
458
  </div>
407
459
  </div>
408
- </div>`
409
- : `
410
- <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>`;
460
+ </section>`;
411
461
  return `
412
- ${repoMeta}
413
-
414
- ${features.multiBoard ? renderBoardRenameSection(project) : ""}
415
-
416
- <div class="section">
417
- <h3>Board Snapshot</h3>
418
- <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>
419
- ${features.attachments ? `
420
- <label class="orbit-check">
421
- <input type="checkbox" id="exportProjectImages" />
422
- <span class="orbit-check-box" aria-hidden="true">
423
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
424
- </span>
425
- <span>Include attached images</span>
426
- </label>
427
- <p class="description">Image exports are embedded in the .orbit.json snapshot and may be much larger.</p>
428
- ` : ""}
429
- <div class="deployment-actions">
430
- <button type="button" data-variant="secondary" id="exportProject">Export</button>
431
- <input type="file" id="importSnapshotAsNewFile" class="import-file-input" accept=".orbit.json,.json,application/json" />
432
- <button type="button" data-variant="secondary" id="importSnapshotAsNewButton">Import as New Board</button>
433
- </div>
434
- </div>
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>
435
514
 
436
- <div class="section repository-danger-zone">
437
- <h3>Replace Current Board</h3>
438
- <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>
439
- <label class="repository-confirm-field">
440
- <span>Board slug</span>
441
- <input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
442
- </label>
443
- <div class="deployment-actions">
444
- <input type="file" id="replaceCurrentBoardImportFile" class="import-file-input" accept=".orbit.json,.json,application/json" disabled />
445
- <button type="button" data-variant="secondary" id="replaceCurrentBoardImportButton" disabled>Replace Current Board</button>
446
- </div>
515
+ ${features.multiBoard ? renderDeleteBoardSection(project) : ""}
447
516
  </div>
448
-
449
- ${features.multiBoard ? renderDeleteBoardSection(project) : ""}
450
517
  `;
451
518
  }
452
519
 
@@ -454,40 +521,51 @@ function renderHelperFilesSection(project) {
454
521
  const hasFolder = Boolean(project.system_path);
455
522
  const managed = Boolean(project.manage_helper_files);
456
523
  return `
457
- <div class="section ai-subsection helper-files-section">
458
- <div class="ai-context-title-row">
459
- <span class="section-mark" aria-hidden="true">Files</span>
460
- <h3>AI Helper Files</h3>
461
- </div>
462
- <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>
463
- <div class="folder-picker-field helper-files-folder">
464
- <input id="helperFolderInput" type="text" readonly placeholder="No folder linked" value="${escapeHtml(project.system_path || "")}" />
465
- <button type="button" id="helperPickFolderBtn">${hasFolder ? "Change" : "Browse"}</button>
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>
466
545
  </div>
467
- <label class="orbit-check">
468
- <input type="checkbox" id="manageHelperFilesToggle" ${managed ? "checked" : ""} ${hasFolder ? "" : "disabled"} />
469
- <span class="orbit-check-box" aria-hidden="true">
470
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
471
- </span>
472
- <span>Generate &amp; maintain helper files in this folder</span>
473
- </label>
474
- </div>
546
+ </section>
475
547
  `;
476
548
  }
477
549
 
478
550
  function renderBoardRenameSection(project) {
479
551
  return `
480
- <div class="section board-rename-section">
481
- <h3>Board Name</h3>
482
- <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>
483
- <form id="boardRenameForm" class="field-form">
484
- <label>
485
- <span>Display name</span>
486
- <input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
487
- </label>
488
- <button type="submit" data-variant="secondary">Rename Board</button>
489
- </form>
490
- </div>
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>
491
569
  `;
492
570
  }
493
571
 
@@ -495,15 +573,21 @@ function renderDeleteBoardSection(project) {
495
573
  const slug = escapeHtml(project.slug || "");
496
574
  if (repositoryDelete.step === "choice") {
497
575
  return `
498
- <div class="section repository-danger-zone">
499
- <h3>Delete Board</h3>
500
- <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>
501
- <div class="repository-delete-actions">
502
- <button type="button" data-variant="secondary" id="deleteBoardExportFirst">Export first</button>
503
- <button type="button" data-variant="ghost" class="danger-button" id="deleteBoardSkipExport">Delete without exporting</button>
504
- <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
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>
505
589
  </div>
506
- </div>
590
+ </section>
507
591
  `;
508
592
  }
509
593
 
@@ -512,28 +596,40 @@ function renderDeleteBoardSection(project) {
512
596
  ? "Snapshot exported. Type the board slug to confirm permanent deletion."
513
597
  : "Type the board slug to confirm permanent deletion.";
514
598
  return `
515
- <div class="section repository-danger-zone is-confirming">
516
- <h3>Delete Board</h3>
517
- <p class="description">${escapeHtml(exportedCopy)}</p>
518
- <label class="repository-confirm-field">
519
- <span>Board slug</span>
520
- <strong>${slug}</strong>
521
- <input id="deleteBoardConfirmInput" autocomplete="off" spellcheck="false" placeholder="${slug}" autofocus />
522
- </label>
523
- <div class="repository-delete-actions">
524
- <button type="button" data-variant="primary" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
525
- <button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
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>
526
616
  </div>
527
- </div>
617
+ </section>
528
618
  `;
529
619
  }
530
620
 
531
621
  return `
532
- <div class="section repository-danger-zone">
533
- <h3>Delete Board</h3>
534
- <p class="description">Remove this board from Orbit and delete its repo-local Orbit files. This cannot be undone without an exported snapshot.</p>
535
- <button type="button" data-variant="ghost" class="danger-button" id="deleteBoardStart">Delete Board</button>
536
- </div>
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>
537
633
  `;
538
634
  }
539
635
 
@@ -546,9 +642,9 @@ function renderNotesSettingsTab(context) {
546
642
  const placeholderClass = hasNotes ? "" : "is-placeholder";
547
643
  const inner = hasNotes ? renderPreservedText(notes) : escapeHtml(notesPlaceholder);
548
644
  return `
549
- <div class="section ai-fields">
645
+ <div class="section settings-notes-section">
550
646
  <div class="inline-md-field">
551
- <span class="inline-md-field-label">Notes For You</span>
647
+ <span class="inline-md-field-label font-orbitron">Notes For You</span>
552
648
  <div
553
649
  class="inline-md-field-body preserved-text-body editable-field settings-notes-body ${placeholderClass}"
554
650
  data-edit-field="project_notes"
@@ -566,7 +662,7 @@ function renderProjectLanesTab() {
566
662
  const showPriority = state.showPriority;
567
663
  return `
568
664
  <div class="section">
569
- <label class="orbit-check" for="showPriorityToggle">
665
+ <label class="orbit-check settings-toggle" for="showPriorityToggle">
570
666
  <input
571
667
  type="checkbox"
572
668
  id="showPriorityToggle"
@@ -575,7 +671,9 @@ function renderProjectLanesTab() {
575
671
  <span class="orbit-check-box" aria-hidden="true">
576
672
  <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
577
673
  </span>
578
- <span class="orbit-check-label">Show priority label on cards</span>
674
+ <span class="settings-toggle-text">
675
+ <span class="settings-row-title">Show priority label on cards</span>
676
+ </span>
579
677
  </label>
580
678
  <p class="description lanes-description">Rename, reorder, add, or remove lanes.</p>
581
679
  ${renderLaneManager()}
@@ -587,6 +685,87 @@ function renderProjectLanesTab() {
587
685
  `;
588
686
  }
589
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
+
590
769
  function renderProjectAppearanceTab() {
591
770
  const theme = currentTheme();
592
771
  const reduceMotion = effectiveReducedMotionPreference();
@@ -597,58 +776,114 @@ function renderProjectAppearanceTab() {
597
776
  : "Using your saved preference.";
598
777
 
599
778
  return `
600
- <div class="section appearance-theme-section">
601
- <fieldset class="appearance-theme-field">
602
- <legend>Theme</legend>
603
- <div class="appearance-theme-options">
604
- ${renderThemeOption("light", "Light", theme)}
605
- ${renderThemeOption("dark", "Dark", theme)}
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>
606
816
  </div>
607
- <p class="description">Applies the light/dark theme used by the top rail toggle.</p>
608
- </fieldset>
817
+ </section>
818
+ ${renderTypographyControls()}
609
819
  </div>
610
- <div class="section">
611
- <label class="orbit-check" for="reduceMotionToggle">
612
- <input
613
- type="checkbox"
614
- id="reduceMotionToggle"
615
- ${reduceMotion ? "checked" : ""}
616
- />
617
- <span class="orbit-check-box" aria-hidden="true">
618
- <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
619
- </span>
620
- <span class="orbit-check-label">Reduce theme motion</span>
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>
621
837
  </label>
622
- <p class="description">Turns off the animated starfield, meteors, and theme animations. ${escapeHtml(sourceLabel)}</p>
623
- </div>
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>
624
863
  `;
625
864
  }
626
865
 
627
- function renderThemeOption(value, label, selectedTheme) {
628
- const checked = selectedTheme === value ? "checked" : "";
629
- const icon = value === "dark"
866
+ // Mirrors the topbar theme toggle: moon when dark is active, sun when light.
867
+ function themeIconMarkup(theme) {
868
+ return theme === "dark"
630
869
  ? '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />'
631
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
+ }
632
872
 
633
- return `
634
- <label class="appearance-theme-option">
635
- <input type="radio" name="themePreference" value="${value}" ${checked} />
636
- <span class="appearance-theme-option-mark" aria-hidden="true">
637
- <svg
638
- class="appearance-theme-option-icon"
639
- width="16"
640
- height="16"
641
- viewBox="0 0 24 24"
642
- fill="none"
643
- stroke="currentColor"
644
- stroke-width="2"
645
- stroke-linecap="round"
646
- stroke-linejoin="round"
647
- >${icon}</svg>
648
- </span>
649
- <span class="appearance-theme-option-label">${label}</span>
650
- </label>
651
- `;
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>`;
652
887
  }
653
888
 
654
889
  function renderProjectJournalTab(context) {
@@ -656,8 +891,9 @@ function renderProjectJournalTab(context) {
656
891
  return `
657
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>` : ""}
658
893
  <div class="section">
659
- ${renderProjectEntries(context.entries)}
894
+ ${renderProjectEntries(context.entries, aiEnabled)}
660
895
  <form id="projectEntryForm" class="comment-form project-entry-form">
896
+ <div class="project-entry-form-header">Add entry</div>
661
897
  <select name="type" class="select-chevron-field" aria-label="Entry type">
662
898
  <option value="decision">Decision</option>
663
899
  <option value="lesson">Lesson</option>
@@ -685,7 +921,7 @@ function parseImportSnapshotText(text) {
685
921
  function bindProjectTabHandlers(context, tab) {
686
922
  const project = context.board;
687
923
 
688
- if (tab === "repository") {
924
+ if (tab === "data") {
689
925
  const exportSnapshot = async (includeAttachments = Boolean($("#exportProjectImages")?.checked)) => {
690
926
  const suffix = includeAttachments ? "?include_attachments=true" : "";
691
927
  const snapshot = await api(`/api/boards/${encodeURIComponent(project.id)}/export${suffix}`);
@@ -824,6 +1060,60 @@ function bindProjectTabHandlers(context, tab) {
824
1060
  toast.success(`Deleted board: ${project.name}`);
825
1061
  navigate({ boardId: state.boardId, view: "board" }, { replace: true });
826
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
+ });
827
1117
  }
828
1118
 
829
1119
  if (tab === "notes") {
@@ -895,21 +1185,54 @@ function bindProjectTabHandlers(context, tab) {
895
1185
  }
896
1186
 
897
1187
  if (tab === "appearance") {
898
- drawerInner.querySelectorAll("input[name='themePreference']").forEach((input) => {
899
- input.addEventListener("change", (event) => {
900
- if (!event.currentTarget.checked) return;
901
- const theme = setThemePreference(event.currentTarget.value);
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";
902
1196
  toast.success(theme === "dark" ? "Dark theme enabled" : "Light theme enabled");
903
1197
  });
904
- });
1198
+ }
905
1199
 
906
- $("#reduceMotionToggle")?.addEventListener("change", (event) => {
907
- const reduce = !!event.currentTarget.checked;
1200
+ const motionToggleBtn = $("#appearanceMotionToggle");
1201
+ motionToggleBtn?.addEventListener("click", () => {
1202
+ const reduce = motionToggleBtn.getAttribute("aria-pressed") !== "true";
1203
+ motionToggleBtn.setAttribute("aria-pressed", reduce ? "true" : "false");
908
1204
  setReducedMotionPreference(reduce);
909
1205
  applyReducedMotionPreference();
910
1206
  window.dispatchEvent(new CustomEvent("orbit:motion-preference-change", { detail: { reduce } }));
911
1207
  toast.success(reduce ? "Theme motion reduced" : "Theme motion enabled");
912
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
+ });
913
1236
  }
914
1237
 
915
1238
  if (tab === "ai") {
@@ -1087,7 +1410,7 @@ function bindProjectTabHandlers(context, tab) {
1087
1410
  const id = deleteBtn.dataset.archiveDelete;
1088
1411
  if (!confirm(`Permanently delete "${cardTitle}"? This cannot be undone.`)) return;
1089
1412
  await api(withBoardQuery(`/api/tickets/${encodeURIComponent(id)}`), { method: "DELETE" });
1090
- toast.success(`Deleted: ${cardTitle}`);
1413
+ toast.removed(`Deleted: ${cardTitle}`);
1091
1414
  await refresh();
1092
1415
  }
1093
1416
  });
@@ -1138,33 +1461,41 @@ async function copyText(value) {
1138
1461
  textArea.remove();
1139
1462
  }
1140
1463
 
1141
- function renderProjectEntries(entries = []) {
1464
+ function renderProjectEntries(entries = [], aiEnabled = false) {
1142
1465
  if (!entries.length) return `<p class="description">No project entries yet.</p>`;
1143
1466
  return entries
1144
1467
  .map(
1145
1468
  (entry) => {
1146
1469
  const isStruck = Boolean(entry.struck_at);
1470
+ const showStruckState = aiEnabled && isStruck;
1147
1471
  return `
1148
- <div class="comment project-entry ${escapeHtml(entry.type)} ${isStruck ? "is-struck" : ""}">
1149
- <div class="comment-meta">
1150
- <div>
1151
- <strong>${escapeHtml(entry.type.toUpperCase())}: ${escapeHtml(entry.title)}</strong>
1152
- ${isStruck ? `<span class="project-entry-status">Excluded from agent context</span>` : ""}
1153
- </div>
1154
- <div class="project-entry-actions">
1155
- <span>${escapeHtml(entry.created_by)} - ${formatDate(entry.created_at)}</span>
1156
- <button
1157
- type="button"
1158
- data-variant="ghost"
1159
- class="project-entry-strike"
1160
- data-entry-action="toggle-struck"
1161
- data-entry-id="${escapeHtml(entry.id)}"
1162
- data-struck="${isStruck ? "true" : "false"}"
1163
- aria-pressed="${isStruck ? "true" : "false"}"
1164
- >${isStruck ? "Restore" : "Strike"}</button>
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>` : ""}
1165
1480
  </div>
1166
1481
  </div>
1167
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>
1168
1499
  </div>
1169
1500
  `;
1170
1501
  }
@@ -1231,7 +1562,7 @@ function renderLaneManager() {
1231
1562
  <span class="lane-new-sun" aria-hidden="true"></span>
1232
1563
  <span class="lane-new-label">New</span>
1233
1564
  </button>
1234
- <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">×</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>
1235
1566
  </div>
1236
1567
  `;
1237
1568
  })