@starlove/orbit 1.0.6 → 1.1.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 (58) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +28 -1
  3. package/build.mjs +2 -2
  4. package/dist/full/index.html +2 -2
  5. package/dist/full/js/app.a4f045fc.js +676 -0
  6. package/dist/full/js/app.a4f045fc.js.map +7 -0
  7. package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
  8. package/dist/full/version.json +2 -2
  9. package/docs/AGENT_PROTOCOL.md +27 -5
  10. package/docs/MCP_SETUP.md +7 -3
  11. package/docs/ORBIT_DISPATCH.md +185 -0
  12. package/docs/setup/orbit-init-wizard.md +174 -0
  13. package/package.json +2 -2
  14. package/public/js/actor-labels.js +65 -0
  15. package/public/js/api.js +5 -1
  16. package/public/js/app.js +8 -1
  17. package/public/js/card-actions.js +526 -0
  18. package/public/js/config.js +2 -1
  19. package/public/js/drawer.js +2 -0
  20. package/public/js/format.js +336 -0
  21. package/public/js/kanban.js +25 -2
  22. package/public/js/local-backend.js +24 -8
  23. package/public/js/main.js +14 -6
  24. package/public/js/motion-preference.js +53 -0
  25. package/public/js/router.js +78 -58
  26. package/public/js/search.js +6 -3
  27. package/public/js/settings-tabs.js +1 -1
  28. package/public/js/settings.js +192 -29
  29. package/public/js/starscape.js +25 -18
  30. package/public/js/state.js +61 -2
  31. package/public/js/theme-preference.js +88 -0
  32. package/public/js/ticket-detail.js +432 -126
  33. package/public/js/url-routes.js +94 -0
  34. package/public/styles.css +1117 -47
  35. package/src/cli/orbit.js +217 -32
  36. package/src/core/agent.js +1 -18
  37. package/src/core/agents-md.js +16 -10
  38. package/src/core/attachments.js +238 -0
  39. package/src/core/auth.js +36 -0
  40. package/src/core/backups.js +9 -3
  41. package/src/core/board.js +32 -1
  42. package/src/core/boards.js +1 -1
  43. package/src/core/bootstrap.js +9 -1
  44. package/src/core/db.js +42 -2
  45. package/src/core/delete-board.js +87 -21
  46. package/src/core/dispatch.js +532 -0
  47. package/src/core/http.js +31 -11
  48. package/src/core/migrate.js +198 -0
  49. package/src/core/paths.js +6 -0
  50. package/src/core/registry.js +12 -9
  51. package/src/core/router.js +62 -6
  52. package/src/core/search.js +56 -22
  53. package/src/core/snapshots.js +14 -2
  54. package/src/mcp/orbit-client.js +223 -0
  55. package/src/mcp-server.js +89 -129
  56. package/src/server.js +20 -0
  57. package/dist/full/js/app.aedc1bfe.js +0 -554
  58. package/dist/full/js/app.aedc1bfe.js.map +0 -7
@@ -1,52 +1,51 @@
1
- // Hash-based router. The URL hash is the source of truth for which view is
2
- // shown. `navigate()` writes to it; the popstate listener replays it. The
3
- // in-memory `state` object is still used for rendering — applyRoute() just
4
- // mirrors the parsed hash into `state` before calling render().
1
+ // Hash router. Copy/share URLs use board slugs, such as
2
+ // `#/b/<boardSlug>`, `#/b/<boardSlug>/t/<ticketId>`, and
3
+ // `#/b/<boardSlug>/settings/<tab>`. Keeping routing in the hash lets the
4
+ // preview app keep working when mounted under `/app/` or opened as static HTML.
5
5
 
6
- import { state } from "./state.js";
7
- import { SETTINGS_TAB_IDS, normalizeSettingsTab } from "./settings-tabs.js";
6
+ import { currentBoard, state } from "./state.js";
7
+ import { normalizeSettingsTab } from "./settings-tabs.js";
8
8
  import { closeMenuFlyouts } from "./board-menu.js";
9
9
  import { closeCreateFlyout } from "./create-card.js";
10
+ import {
11
+ buildRoute,
12
+ hasRoute,
13
+ isCanonicalRouteUrl,
14
+ parseRoute
15
+ } from "./url-routes.js";
10
16
 
11
- const Route = {
12
- parse(hash) {
13
- const cleaned = String(hash || "").replace(/^#/, "").replace(/^\/+/, "");
14
- const parts = cleaned ? cleaned.split("/") : [];
15
- const out = { boardId: "", view: "board", ticketId: "", tab: "" };
16
- let i = 0;
17
- if (parts[i] === "b" && parts[i + 1]) { out.boardId = decodeURIComponent(parts[i + 1]); i += 2; }
18
- if (parts[i] === "t" && parts[i + 1]) {
19
- out.view = "ticket";
20
- out.ticketId = decodeURIComponent(parts[i + 1]);
21
- } else if (parts[i] === "settings") {
22
- out.view = "settings";
23
- const t = parts[i + 1];
24
- if (t && SETTINGS_TAB_IDS.has(t)) out.tab = t;
25
- }
26
- return out;
27
- },
28
- build({ boardId = "", view = "board", ticketId = "", tab = "" } = {}) {
29
- const segs = [];
30
- if (boardId) segs.push("b", encodeURIComponent(boardId));
31
- if (view === "ticket" && ticketId) segs.push("t", encodeURIComponent(ticketId));
32
- else if (view === "settings") {
33
- segs.push("settings");
34
- if (tab && SETTINGS_TAB_IDS.has(tab)) segs.push(tab);
35
- }
36
- return "#/" + segs.join("/");
37
- },
38
- fromState() {
39
- let view = "board";
40
- if (state.detailMode === "ticket" && state.selectedTicketId) view = "ticket";
41
- else if (state.detailMode === "settings") view = "settings";
42
- return {
43
- boardId: state.boardId,
44
- view,
45
- ticketId: state.selectedTicketId,
46
- tab: view === "settings" ? normalizeSettingsTab(state.settingsTab) : ""
47
- };
48
- }
49
- };
17
+ export { buildRoute, hasRoute, parseRoute } from "./url-routes.js";
18
+
19
+ function boardBySlugOrId(value) {
20
+ if (!value) return null;
21
+ const boards = state.data?.boards || [];
22
+ return boards.find((b) => b.slug === value) || boards.find((b) => b.id === value) || null;
23
+ }
24
+
25
+ function routeBoardSlug(routeObj = {}) {
26
+ if (routeObj.boardSlug) return routeObj.boardSlug;
27
+ if (routeObj.boardId) return boardBySlugOrId(routeObj.boardId)?.slug || routeObj.boardId;
28
+ return currentBoard()?.slug || state.boardSlug || "";
29
+ }
30
+
31
+ function normalizeRouteForUrl(routeObj = {}) {
32
+ return {
33
+ ...routeObj,
34
+ boardSlug: routeBoardSlug(routeObj)
35
+ };
36
+ }
37
+
38
+ function routeFromState() {
39
+ let view = "board";
40
+ if (state.detailMode === "ticket" && state.selectedTicketId) view = "ticket";
41
+ else if (state.detailMode === "settings") view = "settings";
42
+ return {
43
+ boardSlug: currentBoard()?.slug || state.boardSlug,
44
+ view,
45
+ ticketId: state.selectedTicketId,
46
+ tab: view === "settings" ? normalizeSettingsTab(state.settingsTab) : ""
47
+ };
48
+ }
50
49
 
51
50
  let routerReady = false;
52
51
 
@@ -54,14 +53,19 @@ export function setRouterReady(value) {
54
53
  routerReady = value;
55
54
  }
56
55
 
56
+ export function currentRoute() {
57
+ return parseRoute(location);
58
+ }
59
+
57
60
  export async function navigate(routeObj, { replace = false } = {}) {
58
- const hash = Route.build(routeObj);
59
- if (location.hash === hash) {
61
+ const route = normalizeRouteForUrl(routeObj);
62
+ const url = buildRoute(route);
63
+ if (isCanonicalRouteUrl(location, route)) {
60
64
  if (routerReady) await applyRoute();
61
65
  return;
62
66
  }
63
- if (replace) history.replaceState(null, "", hash);
64
- else history.pushState(null, "", hash);
67
+ if (replace) history.replaceState(null, "", url);
68
+ else history.pushState(null, "", url);
65
69
  if (routerReady) await applyRoute();
66
70
  }
67
71
 
@@ -69,17 +73,32 @@ export async function navigate(routeObj, { replace = false } = {}) {
69
73
  // Used after flows that already mutated state and called load()/render()
70
74
  // (e.g. ticket/board creation).
71
75
  export function syncUrlFromState({ replace = false } = {}) {
72
- const hash = Route.build(Route.fromState());
73
- if (location.hash === hash) return;
74
- if (replace) history.replaceState(null, "", hash);
75
- else history.pushState(null, "", hash);
76
+ const route = routeFromState();
77
+ const url = buildRoute(route);
78
+ if (isCanonicalRouteUrl(location, route)) return;
79
+ if (replace) history.replaceState(null, "", url);
80
+ else history.pushState(null, "", url);
76
81
  }
77
82
 
78
83
  export async function applyRoute() {
79
- const { load, render } = await import("./app.js");
80
- const r = Route.parse(location.hash);
84
+ const { load, renderDetailOnly } = await import("./app.js");
85
+ const r = currentRoute();
86
+ const urlHadRoute = hasRoute(location);
81
87
  const prevBoardId = state.boardId;
82
- if (r.boardId) state.boardId = r.boardId;
88
+ const routeBoard = boardBySlugOrId(r.boardSlug);
89
+ const fallbackBoard = routeBoard || (r.boardSlug ? currentBoard() : null);
90
+
91
+ if (fallbackBoard) {
92
+ state.boardId = fallbackBoard.id;
93
+ state.boardSlug = fallbackBoard.slug;
94
+ } else if (r.boardSlug) {
95
+ state.boardSlug = r.boardSlug;
96
+ }
97
+
98
+ const canonicalRoute = fallbackBoard ? { ...r, boardSlug: fallbackBoard.slug } : r;
99
+ if (urlHadRoute && !isCanonicalRouteUrl(location, canonicalRoute)) {
100
+ history.replaceState(null, "", buildRoute(canonicalRoute));
101
+ }
83
102
 
84
103
  if (r.view === "ticket") {
85
104
  state.selectedTicketId = r.ticketId || "";
@@ -103,10 +122,11 @@ export async function applyRoute() {
103
122
  closeMenuFlyouts();
104
123
  closeCreateFlyout();
105
124
 
106
- if (r.boardId && r.boardId !== prevBoardId) {
125
+ if (state.boardId && state.boardId !== prevBoardId) {
107
126
  await load();
127
+ return;
108
128
  }
109
- await render();
129
+ await renderDetailOnly();
110
130
  }
111
131
 
112
132
  window.addEventListener("popstate", () => {
@@ -6,7 +6,7 @@ import { searchInput, searchResults } from "./dom.js";
6
6
  import { state, currentBoard } from "./state.js";
7
7
  import { api } from "./api.js";
8
8
  import { navigate } from "./router.js";
9
- import { escapeHtml, ticketLabel } from "./format.js";
9
+ import { escapeHtml, stateClassFor, ticketLabel } from "./format.js";
10
10
  import { closeCreateFlyout } from "./create-card.js";
11
11
 
12
12
  export async function runSearch() {
@@ -28,8 +28,11 @@ export async function runSearch() {
28
28
  .map(
29
29
  (ticket) => `
30
30
  <div class="search-hit" data-ticket-id="${ticket.id}">
31
- <strong>${escapeHtml(ticketLabel(ticket))}</strong>
32
- ${escapeHtml(ticket.title)}
31
+ <span class="search-hit-main">
32
+ <strong>${escapeHtml(ticketLabel(ticket))}</strong>
33
+ <span class="search-hit-title">${escapeHtml(ticket.title)}</span>
34
+ </span>
35
+ <span class="search-hit-state state-pill-${escapeHtml(stateClassFor(ticket))}">${escapeHtml(ticket.state_name || "State")}</span>
33
36
  </div>
34
37
  `
35
38
  )
@@ -1,5 +1,5 @@
1
1
  /** Settings drawer tab ids — must match URL segments `#/b/…/settings/:tab` and `settings.js` tab config. */
2
- export const SETTINGS_TAB_IDS = new Set(["lanes", "ai", "notes", "journal", "repository", "archive"]);
2
+ export const SETTINGS_TAB_IDS = new Set(["lanes", "appearance", "ai", "notes", "journal", "repository", "archive"]);
3
3
 
4
4
  export function normalizeSettingsTab(raw) {
5
5
  if (!raw || !SETTINGS_TAB_IDS.has(raw)) return "lanes";
@@ -1,18 +1,26 @@
1
- // Project settings drawer (the cog/settings icon). Six tabs: Lanes, AI,
2
- // Notes, Journal, Repository, Archive. Owns lane CRUD/reorder, the
1
+ // Project settings drawer (the cog/settings icon). Tabs: Lanes, Appearance,
2
+ // Card Archive, Notes, Journal, AI, Repository. Owns lane CRUD/reorder, the
3
3
  // archive list, project context/notes form persistence, and per-tab handler wiring.
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 } from "./format.js";
7
+ import { escapeHtml, formatDate, repoLabelFromUrl, 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";
11
11
  import { navigate } from "./router.js";
12
12
  import { renderBoard } from "./kanban.js";
13
- import { renderDetailCard } from "./ticket-detail.js";
13
+ import { renderDetailCard, startInlineEdit } from "./ticket-detail.js";
14
14
  import { load } from "./app.js";
15
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, setThemePreference } from "./theme-preference.js";
16
24
 
17
25
  const repositoryDelete = {
18
26
  boardId: "",
@@ -27,10 +35,11 @@ export async function renderProjectDetail() {
27
35
 
28
36
  const tabs = [
29
37
  { id: "lanes", label: "Lanes" },
38
+ { id: "appearance", label: "Appearance" },
30
39
  { id: "archive", label: "Card Archive" },
31
40
  { id: "notes", label: "Notes" },
32
- ...(features.ai ? [{ id: "ai", label: "AI" }] : []),
33
41
  { id: "journal", label: "Journal" },
42
+ ...(features.ai ? [{ id: "ai", label: "AI" }] : []),
34
43
  { id: "repository", label: "Repository" }
35
44
  ];
36
45
 
@@ -61,6 +70,8 @@ function renderProjectTabBody(context, tab) {
61
70
  return renderNotesSettingsTab(context);
62
71
  case "lanes":
63
72
  return renderProjectLanesTab();
73
+ case "appearance":
74
+ return renderProjectAppearanceTab();
64
75
  case "ai":
65
76
  return features.ai ? renderProjectAiTab(context) : renderProjectLanesTab();
66
77
  case "journal":
@@ -366,8 +377,20 @@ function renderProjectRepositoryTab(context) {
366
377
  return `
367
378
  ${repoMeta}
368
379
 
380
+ ${features.multiBoard ? renderBoardRenameSection(project) : ""}
381
+
369
382
  <div class="section">
370
383
  <h3>Board Snapshot</h3>
384
+ ${features.attachments ? `
385
+ <label class="orbit-check">
386
+ <input type="checkbox" id="exportProjectImages" />
387
+ <span class="orbit-check-box" aria-hidden="true">
388
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
389
+ </span>
390
+ <span>Include attached images</span>
391
+ </label>
392
+ <p class="description">Image exports are embedded in the .orbit.json snapshot and may be much larger.</p>
393
+ ` : ""}
371
394
  <div class="deployment-actions">
372
395
  <button type="button" class="ghost" id="exportProject">Export</button>
373
396
  <label class="import-button">
@@ -381,6 +404,22 @@ function renderProjectRepositoryTab(context) {
381
404
  `;
382
405
  }
383
406
 
407
+ function renderBoardRenameSection(project) {
408
+ return `
409
+ <div class="section board-rename-section">
410
+ <h3>Board Name</h3>
411
+ <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>
412
+ <form id="boardRenameForm" class="field-form">
413
+ <label>
414
+ <span>Display name</span>
415
+ <input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
416
+ </label>
417
+ <button type="submit">Rename Board</button>
418
+ </form>
419
+ </div>
420
+ `;
421
+ }
422
+
384
423
  function renderDeleteBoardSection(project) {
385
424
  const slug = escapeHtml(project.slug || "");
386
425
  if (repositoryDelete.step === "choice") {
@@ -431,19 +470,23 @@ function renderNotesSettingsTab(context) {
431
470
  const project = context.board;
432
471
  const notesPlaceholder =
433
472
  "Remember to mention SKILL-ORBIT.md to my AI agents if they get confused";
473
+ const notes = project.project_notes || "";
474
+ const hasNotes = Boolean(notes.trim());
475
+ const placeholderClass = hasNotes ? "" : "is-placeholder";
476
+ const inner = hasNotes ? renderPreservedText(notes) : escapeHtml(notesPlaceholder);
434
477
  return `
435
478
  <div class="section ai-fields">
436
- <form id="notesSettingsForm" class="field-form settings-notes-form">
437
- <label class="settings-notes-field-group">
438
- <span>Notes For You</span>
439
- <textarea
440
- name="project_notes"
441
- class="settings-notes-textarea"
442
- placeholder="${escapeHtml(notesPlaceholder)}"
443
- >${escapeHtml(project.project_notes || "")}</textarea>
444
- </label>
445
- <button type="submit" class="settings-notes-save">Save</button>
446
- </form>
479
+ <div class="inline-md-field">
480
+ <span class="inline-md-field-label">Notes For You</span>
481
+ <div
482
+ class="inline-md-field-body preserved-text-body editable-field settings-notes-body ${placeholderClass}"
483
+ data-edit-field="project_notes"
484
+ tabindex="0"
485
+ title="Click to edit"
486
+ role="button"
487
+ aria-label="Edit Notes For You"
488
+ >${inner}</div>
489
+ </div>
447
490
  </div>
448
491
  `;
449
492
  }
@@ -473,6 +516,70 @@ function renderProjectLanesTab() {
473
516
  `;
474
517
  }
475
518
 
519
+ function renderProjectAppearanceTab() {
520
+ const theme = currentTheme();
521
+ const reduceMotion = effectiveReducedMotionPreference();
522
+ const storedMotion = storedReducedMotionPreference();
523
+ const browserDefault = browserPrefersReducedMotion();
524
+ const sourceLabel = storedMotion === null
525
+ ? `Using browser default (${browserDefault ? "reduced motion" : "motion allowed"}).`
526
+ : "Using your saved preference.";
527
+
528
+ return `
529
+ <div class="section appearance-theme-section">
530
+ <fieldset class="appearance-theme-field">
531
+ <legend>Theme</legend>
532
+ <div class="appearance-theme-options">
533
+ ${renderThemeOption("light", "Light", theme)}
534
+ ${renderThemeOption("dark", "Dark", theme)}
535
+ </div>
536
+ <p class="description">Applies the light/dark theme used by the top rail toggle.</p>
537
+ </fieldset>
538
+ </div>
539
+ <div class="section">
540
+ <label class="orbit-check" for="reduceMotionToggle">
541
+ <input
542
+ type="checkbox"
543
+ id="reduceMotionToggle"
544
+ ${reduceMotion ? "checked" : ""}
545
+ />
546
+ <span class="orbit-check-box" aria-hidden="true">
547
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
548
+ </span>
549
+ <span class="orbit-check-label">Reduce theme motion</span>
550
+ </label>
551
+ <p class="description">Turns off the animated starfield, meteors, and theme animations. ${escapeHtml(sourceLabel)}</p>
552
+ </div>
553
+ `;
554
+ }
555
+
556
+ function renderThemeOption(value, label, selectedTheme) {
557
+ const checked = selectedTheme === value ? "checked" : "";
558
+ const icon = value === "dark"
559
+ ? '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />'
560
+ : '<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" />';
561
+
562
+ return `
563
+ <label class="appearance-theme-option">
564
+ <input type="radio" name="themePreference" value="${value}" ${checked} />
565
+ <span class="appearance-theme-option-mark" aria-hidden="true">
566
+ <svg
567
+ class="appearance-theme-option-icon"
568
+ width="16"
569
+ height="16"
570
+ viewBox="0 0 24 24"
571
+ fill="none"
572
+ stroke="currentColor"
573
+ stroke-width="2"
574
+ stroke-linecap="round"
575
+ stroke-linejoin="round"
576
+ >${icon}</svg>
577
+ </span>
578
+ <span class="appearance-theme-option-label">${label}</span>
579
+ </label>
580
+ `;
581
+ }
582
+
476
583
  function renderProjectJournalTab(context) {
477
584
  const aiEnabled = context.board?.ai_enabled !== 0;
478
585
  return `
@@ -496,15 +603,31 @@ function bindProjectTabHandlers(context, tab) {
496
603
  const project = context.board;
497
604
 
498
605
  if (tab === "repository") {
499
- const exportSnapshot = async () => {
500
- const snapshot = await api(`/api/boards/${project.id}/export`);
501
- downloadJson(`${project.slug}.orbit.json`, snapshot);
606
+ const exportSnapshot = async (includeAttachments = Boolean($("#exportProjectImages")?.checked)) => {
607
+ const suffix = includeAttachments ? "?include_attachments=true" : "";
608
+ const snapshot = await api(`/api/boards/${encodeURIComponent(project.id)}/export${suffix}`);
609
+ const filename = includeAttachments ? `${project.slug}.with-images.orbit.json` : `${project.slug}.orbit.json`;
610
+ downloadJson(filename, snapshot);
502
611
  };
503
612
 
504
613
  $("#exportProject")?.addEventListener("click", async () => {
505
614
  await exportSnapshot();
506
615
  });
507
616
 
617
+ $("#boardRenameForm")?.addEventListener("submit", async (event) => {
618
+ event.preventDefault();
619
+ const form = new FormData(event.currentTarget);
620
+ const nextName = String(form.get("name") || "").trim();
621
+ if (!nextName || nextName === project.name) return;
622
+ await api(`/api/boards/${encodeURIComponent(project.id)}`, {
623
+ method: "PATCH",
624
+ body: { name: nextName }
625
+ });
626
+ await load();
627
+ state.detailMode = "settings";
628
+ toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
629
+ });
630
+
508
631
  $("#importSnapshotFile")?.addEventListener("change", async (event) => {
509
632
  const file = event.currentTarget.files?.[0];
510
633
  if (!file) return;
@@ -564,17 +687,39 @@ function bindProjectTabHandlers(context, tab) {
564
687
  }
565
688
 
566
689
  if (tab === "notes") {
567
- $("#notesSettingsForm")?.addEventListener("submit", async (event) => {
568
- event.preventDefault();
569
- const form = new FormData(event.currentTarget);
570
- await api(`/api/boards/${project.id}`, {
571
- method: "PATCH",
572
- body: { project_notes: String(form.get("project_notes") ?? "") }
690
+ const notesEl = drawerInner.querySelector('[data-edit-field="project_notes"]');
691
+ if (notesEl) {
692
+ const edit = () =>
693
+ startInlineEdit(notesEl, {
694
+ fieldName: "project_notes",
695
+ multiline: true,
696
+ initialValue: project.project_notes || "",
697
+ ariaLabel: "Edit Notes For You",
698
+ rerender: async () => {
699
+ state.detailMode = "settings";
700
+ await renderProjectDetail();
701
+ },
702
+ commit: async (next) => {
703
+ await api(`/api/boards/${project.id}`, {
704
+ method: "PATCH",
705
+ body: { project_notes: cleanText(next) }
706
+ });
707
+ await load();
708
+ state.detailMode = "settings";
709
+ toast.success("Saved");
710
+ }
711
+ });
712
+ notesEl.addEventListener("click", (event) => {
713
+ event.preventDefault();
714
+ edit();
573
715
  });
574
- await load();
575
- state.detailMode = "settings";
576
- toast.success("Saved");
577
- });
716
+ notesEl.addEventListener("keydown", (event) => {
717
+ if (event.key === "Enter" || event.key === " ") {
718
+ event.preventDefault();
719
+ edit();
720
+ }
721
+ });
722
+ }
578
723
  }
579
724
 
580
725
  if (tab === "lanes") {
@@ -609,6 +754,24 @@ function bindProjectTabHandlers(context, tab) {
609
754
  });
610
755
  }
611
756
 
757
+ if (tab === "appearance") {
758
+ drawerInner.querySelectorAll("input[name='themePreference']").forEach((input) => {
759
+ input.addEventListener("change", (event) => {
760
+ if (!event.currentTarget.checked) return;
761
+ const theme = setThemePreference(event.currentTarget.value);
762
+ toast.success(theme === "dark" ? "Dark theme enabled" : "Light theme enabled");
763
+ });
764
+ });
765
+
766
+ $("#reduceMotionToggle")?.addEventListener("change", (event) => {
767
+ const reduce = !!event.currentTarget.checked;
768
+ setReducedMotionPreference(reduce);
769
+ applyReducedMotionPreference();
770
+ window.dispatchEvent(new CustomEvent("orbit:motion-preference-change", { detail: { reduce } }));
771
+ toast.success(reduce ? "Theme motion reduced" : "Theme motion enabled");
772
+ });
773
+ }
774
+
612
775
  if (tab === "ai") {
613
776
  const updateMcpPreview = () => {
614
777
  const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
@@ -9,12 +9,19 @@
9
9
  // nebula ghosts, signature stars with spikes, and meteors stay in CSS.
10
10
 
11
11
  import { themeToggle, themeIcon } from "./dom.js";
12
+ import { applyReducedMotionPreference, effectiveReducedMotionPreference } from "./motion-preference.js";
13
+ import {
14
+ THEME_CHANGE_EVENT,
15
+ applyStoredThemePreference,
16
+ currentTheme,
17
+ toggleThemePreference
18
+ } from "./theme-preference.js";
12
19
 
13
20
  // Stored theme must be applied before startStarscape() so its first sync()
14
21
  // sees the real theme; otherwise the canvas stays stopped until next resize.
15
22
  {
16
- const storedTheme = localStorage.getItem("mab_theme");
17
- if (storedTheme) document.documentElement.setAttribute("data-theme", storedTheme);
23
+ applyStoredThemePreference();
24
+ applyReducedMotionPreference();
18
25
  }
19
26
 
20
27
  const starscape = startStarscape();
@@ -31,19 +38,18 @@ scheduleMeteor();
31
38
  }
32
39
 
33
40
  syncThemeIcon();
34
- themeToggle.addEventListener("click", () => {
35
- const next =
36
- document.documentElement.getAttribute("data-theme") === "dark"
37
- ? "light"
38
- : "dark";
39
- document.documentElement.setAttribute("data-theme", next);
40
- localStorage.setItem("mab_theme", next);
41
+ themeToggle?.addEventListener("click", () => {
42
+ toggleThemePreference();
43
+ });
44
+
45
+ window.addEventListener(THEME_CHANGE_EVENT, () => {
41
46
  syncThemeIcon();
42
47
  starscape.sync();
43
48
  });
44
49
 
45
50
  function syncThemeIcon() {
46
- const dark = document.documentElement.getAttribute("data-theme") === "dark";
51
+ if (!themeIcon) return;
52
+ const dark = currentTheme() === "dark";
47
53
  themeIcon.innerHTML = dark
48
54
  ? '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />'
49
55
  : '<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" />';
@@ -207,8 +213,8 @@ function startStarscape() {
207
213
  }
208
214
 
209
215
  function sync() {
210
- const isDark = document.documentElement.getAttribute("data-theme") === "dark";
211
- const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
216
+ const isDark = currentTheme() === "dark";
217
+ const reduced = effectiveReducedMotionPreference();
212
218
  if (!isDark) {
213
219
  stop();
214
220
  ctx.clearRect(0, 0, width, height);
@@ -229,7 +235,11 @@ function startStarscape() {
229
235
  }, { passive: true });
230
236
  window.addEventListener("pointermove", onPointerMove, { passive: true });
231
237
  document.addEventListener("visibilitychange", sync);
232
- window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", sync);
238
+ window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => {
239
+ applyReducedMotionPreference();
240
+ sync();
241
+ });
242
+ window.addEventListener("orbit:motion-preference-change", sync);
233
243
 
234
244
  resize();
235
245
  sync();
@@ -259,11 +269,8 @@ function scheduleMeteor() {
259
269
  }
260
270
 
261
271
  function spawnMeteor() {
262
- const isDark =
263
- document.documentElement.getAttribute("data-theme") === "dark";
264
- const reduced = window.matchMedia(
265
- "(prefers-reduced-motion: reduce)"
266
- ).matches;
272
+ const isDark = currentTheme() === "dark";
273
+ const reduced = effectiveReducedMotionPreference();
267
274
  const grain = document.querySelector(".grain");
268
275
  if (!isDark || reduced || document.hidden || !grain) {
269
276
  scheduleMeteor();