@starlove/orbit 1.0.5 → 1.0.7

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 (94) hide show
  1. package/.dockerignore +0 -0
  2. package/Dockerfile +0 -0
  3. package/LICENSE +0 -0
  4. package/README.md +8 -0
  5. package/SKILL-ORBIT.md +2 -0
  6. package/build.mjs +2 -2
  7. package/dist/full/apple-touch-icon.png +0 -0
  8. package/dist/full/cursors/live_arrow.cur +0 -0
  9. package/dist/full/cursors/live_link.cur +0 -0
  10. package/dist/full/cursors/live_move.cur +0 -0
  11. package/dist/full/cursors/live_select.cur +0 -0
  12. package/dist/full/cursors/live_unavail.cur +0 -0
  13. package/dist/full/favicon-32x32.png +0 -0
  14. package/dist/full/index.html +2 -2
  15. package/dist/full/js/app.f4fd5e94.js +604 -0
  16. package/dist/full/js/app.f4fd5e94.js.map +7 -0
  17. package/dist/full/orbits-favicon.png +0 -0
  18. package/dist/full/orbits-logo-bg.png +0 -0
  19. package/dist/full/orbits-logo.png +0 -0
  20. package/dist/full/{styles.c2e3d20a.css → styles.14e9dbbd.css} +295 -22
  21. package/dist/full/version.json +2 -2
  22. package/docs/AGENT_PROTOCOL.md +4 -0
  23. package/docs/MCP_SETUP.md +3 -1
  24. package/docs/SS-Orbit-Dark.png +0 -0
  25. package/docs/SS-Orbit-Light.png +0 -0
  26. package/docs/mcp-client.example.json +0 -0
  27. package/package.json +2 -2
  28. package/public/apple-touch-icon.png +0 -0
  29. package/public/cursors/live_arrow.cur +0 -0
  30. package/public/cursors/live_link.cur +0 -0
  31. package/public/cursors/live_move.cur +0 -0
  32. package/public/cursors/live_select.cur +0 -0
  33. package/public/cursors/live_unavail.cur +0 -0
  34. package/public/favicon-32x32.png +0 -0
  35. package/public/index.html +0 -0
  36. package/public/js/api.js +5 -1
  37. package/public/js/app.js +0 -0
  38. package/public/js/board-menu.js +0 -0
  39. package/public/js/config.js +0 -0
  40. package/public/js/create-card.js +0 -0
  41. package/public/js/dom.js +0 -0
  42. package/public/js/drawer.js +2 -0
  43. package/public/js/format.js +142 -0
  44. package/public/js/kanban.js +18 -2
  45. package/public/js/local-backend.js +29 -2
  46. package/public/js/main.js +14 -6
  47. package/public/js/modal.js +0 -0
  48. package/public/js/motion-preference.js +53 -0
  49. package/public/js/router.js +75 -56
  50. package/public/js/search.js +0 -0
  51. package/public/js/settings-tabs.js +1 -1
  52. package/public/js/settings.js +96 -3
  53. package/public/js/sse.js +0 -0
  54. package/public/js/starscape.js +25 -18
  55. package/public/js/state.js +11 -2
  56. package/public/js/theme-preference.js +88 -0
  57. package/public/js/ticket-detail.js +6 -3
  58. package/public/js/toast.js +0 -0
  59. package/public/js/transport.js +0 -0
  60. package/public/js/unread.js +0 -0
  61. package/public/js/url-routes.js +94 -0
  62. package/public/orbits-favicon.png +0 -0
  63. package/public/orbits-logo-bg.png +0 -0
  64. package/public/orbits-logo.png +0 -0
  65. package/public/styles.css +295 -22
  66. package/src/core/agent.js +10 -4
  67. package/src/core/agents-md.js +1 -0
  68. package/src/core/auth.js +0 -0
  69. package/src/core/backups.js +0 -0
  70. package/src/core/board.js +1 -1
  71. package/src/core/boards.js +0 -0
  72. package/src/core/bootstrap.js +10 -2
  73. package/src/core/db.js +0 -0
  74. package/src/core/delete-board.js +0 -0
  75. package/src/core/events.js +0 -0
  76. package/src/core/http.js +0 -0
  77. package/src/core/paths.js +0 -0
  78. package/src/core/provision-repo-board.js +0 -0
  79. package/src/core/queries.js +0 -0
  80. package/src/core/registry.js +0 -0
  81. package/src/core/relations.js +0 -0
  82. package/src/core/router.js +1 -1
  83. package/src/core/search.js +62 -23
  84. package/src/core/seed.js +0 -0
  85. package/src/core/snapshots.js +0 -0
  86. package/src/core/states.js +0 -0
  87. package/src/core/system-picker.js +0 -0
  88. package/src/core/tickets.js +28 -3
  89. package/src/core/util.js +0 -0
  90. package/src/mcp/orbit-client.js +180 -0
  91. package/src/mcp-server.js +50 -274
  92. package/src/server.js +0 -0
  93. package/dist/full/js/app.7e25f59b.js +0 -554
  94. package/dist/full/js/app.7e25f59b.js.map +0 -7
@@ -12,7 +12,8 @@ import {
12
12
  typeLabel,
13
13
  priorityLabel,
14
14
  priorityKeyFor,
15
- renderLabels
15
+ renderLabels,
16
+ renderMarkdown
16
17
  } from "./format.js";
17
18
  import { api, withBoardQuery } from "./api.js";
18
19
  import { navigate } from "./router.js";
@@ -70,6 +71,11 @@ export function renderBoard() {
70
71
  // follow-up click so dropping a card does not also open the detail drawer.
71
72
  let suppressClickAfterDrag = false;
72
73
  card.addEventListener("click", async (event) => {
74
+ const clickTarget = event.target instanceof Element ? event.target : event.target?.parentElement;
75
+ if (clickTarget?.closest("a")) {
76
+ event.stopPropagation();
77
+ return;
78
+ }
73
79
  if (suppressClickAfterDrag) {
74
80
  suppressClickAfterDrag = false;
75
81
  event.preventDefault();
@@ -257,6 +263,14 @@ function renderColumnBody(columnTickets, allTickets) {
257
263
  continue;
258
264
  }
259
265
 
266
+ // Defer children whose epic is in this column — they'll render under the
267
+ // epic when the loop reaches it. Without this skip, a child iterated before
268
+ // its epic would render standalone here, then again inside epic-children
269
+ // (rendered.add happens at the epic, which may come later in updated_at order).
270
+ if (t.parent_ticket_id && epicIdsInColumn.has(t.parent_ticket_id)) {
271
+ continue;
272
+ }
273
+
260
274
  if (t.parent_ticket_id && epicById.has(t.parent_ticket_id) && !epicIdsInColumn.has(t.parent_ticket_id)) {
261
275
  const epic = epicById.get(t.parent_ticket_id);
262
276
  const siblings = childrenByEpicId.get(t.parent_ticket_id) || [];
@@ -328,7 +342,8 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
328
342
  const priorityKey = priorityKeyFor(ticket.priority);
329
343
  const typeText = typeLabel(ticket.type).toUpperCase();
330
344
  const isExpanded = state.expandedCardIds.has(ticket.id);
331
- const hasExpandable = (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
345
+ const hasDescription = Boolean(String(ticket.description || "").trim());
346
+ const hasExpandable = hasDescription || (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
332
347
 
333
348
  const triggerInner = `
334
349
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
@@ -360,6 +375,7 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
360
375
 
361
376
  ${hasExpandable ? `
362
377
  <div class="card-expandable">
378
+ ${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
363
379
  ${renderLabels(ticket.labels)}
364
380
  ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
365
381
  ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
@@ -307,7 +307,11 @@ function decorateTickets(snapshot, includeArchived = false) {
307
307
  const labelById = Object.fromEntries(labels.map((l) => [l.id, l]));
308
308
  const stateById = Object.fromEntries(states.map((s) => [s.id, s]));
309
309
  const visible = tickets.filter((t) => includeArchived || !t.archived_at);
310
- const sorted = [...visible].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
310
+ const sorted = [...visible].sort((a, b) => {
311
+ const byTime = String(b.updated_at).localeCompare(String(a.updated_at));
312
+ if (byTime !== 0) return byTime;
313
+ return String(a.id).localeCompare(String(b.id));
314
+ });
311
315
  return sorted.map((ticket) => {
312
316
  const stateRow = stateById[ticket.state_id];
313
317
  const tLabels = ticketLabels
@@ -575,6 +579,17 @@ async function handleStateReorder(boardId, body) {
575
579
  });
576
580
  }
577
581
 
582
+ // Mirrors src/core/tickets.js#bumpTicketUpdatedAt — rolls child activity up to
583
+ // the parent epic so the column sort (updated_at DESC) keeps the epic group
584
+ // near recent child activity even when only its children changed.
585
+ async function bumpTicketUpdatedAt(stores, ticketId, time) {
586
+ if (!ticketId) return;
587
+ const parent = await reqPromise(stores.tickets.get(ticketId));
588
+ if (!parent) return;
589
+ parent.updated_at = time;
590
+ stores.tickets.put(parent);
591
+ }
592
+
578
593
  async function handleTicketCreate(body) {
579
594
  const db = await openDb();
580
595
  return putAndEvent(db, async (stores) => {
@@ -610,6 +625,7 @@ async function handleTicketCreate(body) {
610
625
  stores.ticket_labels.put({ key: tlKey(ticket.id, label.id), ticket_id: ticket.id, label_id: label.id });
611
626
  }
612
627
  recordEvent(stores, "ticket_created", ticket.id, { title: ticket.title, type: ticket.type });
628
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
613
629
  return ticket;
614
630
  });
615
631
  }
@@ -620,6 +636,7 @@ async function handleTicketPatch(ticketId, body) {
620
636
  const ticket = await reqPromise(stores.tickets.get(ticketId));
621
637
  if (!ticket) throw err(404, "ticket_not_found");
622
638
  const oldStateId = ticket.state_id;
639
+ const oldParentId = ticket.parent_ticket_id;
623
640
  const SIMPLE = [
624
641
  "title",
625
642
  "description",
@@ -663,6 +680,10 @@ async function handleTicketPatch(ticketId, body) {
663
680
  } else {
664
681
  recordEvent(stores, "ticket_updated", ticketId, { fields: Object.keys(body) });
665
682
  }
683
+ await bumpTicketUpdatedAt(stores, oldParentId, ticket.updated_at);
684
+ if (ticket.parent_ticket_id && ticket.parent_ticket_id !== oldParentId) {
685
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
686
+ }
666
687
  return ticket;
667
688
  });
668
689
  }
@@ -678,6 +699,7 @@ async function handleTicketDelete(ticketId) {
678
699
  const cmts = await getAll(stores.comments, ticketId, "ticket_id");
679
700
  for (const c of cmts) stores.comments.delete(c.id);
680
701
  recordEvent(stores, "ticket_deleted", ticketId, {});
702
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, nowIso());
681
703
  return { ok: true };
682
704
  });
683
705
  }
@@ -691,6 +713,7 @@ async function handleTicketArchive(ticketId, archive) {
691
713
  ticket.updated_at = nowIso();
692
714
  stores.tickets.put(ticket);
693
715
  recordEvent(stores, archive ? "ticket_archived" : "ticket_restored", ticketId, {});
716
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
694
717
  return ticket;
695
718
  });
696
719
  }
@@ -785,16 +808,20 @@ async function handleCommentCreate(ticketId, body) {
785
808
  return putAndEvent(db, async (stores) => {
786
809
  const ticket = await reqPromise(stores.tickets.get(ticketId));
787
810
  if (!ticket) throw err(404, "ticket_not_found");
811
+ const time = nowIso();
788
812
  const comment = {
789
813
  id: uid(),
790
814
  ticket_id: ticketId,
791
815
  author: "you",
792
816
  kind: body.kind || "note",
793
817
  body: body.body || "",
794
- created_at: nowIso()
818
+ created_at: time
795
819
  };
796
820
  stores.comments.put(comment);
821
+ ticket.updated_at = time;
822
+ stores.tickets.put(ticket);
797
823
  recordEvent(stores, "comment_created", ticketId, { kind: comment.kind });
824
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, time);
798
825
  return comment;
799
826
  });
800
827
  }
package/public/js/main.js CHANGED
@@ -18,7 +18,14 @@ import {
18
18
  import { state } from "./state.js";
19
19
  import { api, withBoardQuery } from "./api.js";
20
20
  import { toast, debounce } from "./toast.js";
21
- import { navigate, applyRoute, syncUrlFromState, setRouterReady } from "./router.js";
21
+ import {
22
+ navigate,
23
+ applyRoute,
24
+ currentRoute,
25
+ hasRoute,
26
+ syncUrlFromState,
27
+ setRouterReady
28
+ } from "./router.js";
22
29
  import {
23
30
  openBoardFlyout,
24
31
  closeBoardFlyout,
@@ -125,14 +132,15 @@ async function init() {
125
132
 
126
133
  enableKanbanDragScroll();
127
134
 
135
+ const initialRoute = currentRoute();
136
+ if (initialRoute.boardSlug) state.boardSlug = initialRoute.boardSlug;
128
137
  await load();
129
138
 
130
- // Activate the router. If the page was loaded with a hash, restore that
131
- // view; otherwise canonicalize the URL to reflect the current state so the
132
- // back button has a starting point.
139
+ // Activate the router. If the page was loaded with a hash route, restore
140
+ // that view; otherwise canonicalize the URL to reflect the current
141
+ // state so the back button has a starting point.
133
142
  setRouterReady(true);
134
- const initialHash = location.hash;
135
- if (initialHash && initialHash !== "#" && initialHash !== "#/") {
143
+ if (hasRoute()) {
136
144
  await applyRoute();
137
145
  } else {
138
146
  syncUrlFromState({ replace: true });
File without changes
@@ -0,0 +1,53 @@
1
+ export const REDUCE_MOTION_STORAGE_KEY = "mab_reduce_motion";
2
+
3
+ function safeStorage(storage) {
4
+ try {
5
+ return storage ?? globalThis.localStorage ?? null;
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ function defaultMediaMatcher(query) {
12
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
13
+ ? window.matchMedia(query)
14
+ : { matches: false };
15
+ }
16
+
17
+ export function storedReducedMotionPreference(storage = safeStorage()) {
18
+ const store = safeStorage(storage);
19
+ const value = store?.getItem?.(REDUCE_MOTION_STORAGE_KEY);
20
+ if (value === "true") return true;
21
+ if (value === "false") return false;
22
+ return null;
23
+ }
24
+
25
+ export function browserPrefersReducedMotion(mediaMatcher = defaultMediaMatcher) {
26
+ try {
27
+ return !!mediaMatcher("(prefers-reduced-motion: reduce)")?.matches;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export function effectiveReducedMotionPreference({ storage = safeStorage(), mediaMatcher = defaultMediaMatcher } = {}) {
34
+ const stored = storedReducedMotionPreference(storage);
35
+ return stored ?? browserPrefersReducedMotion(mediaMatcher);
36
+ }
37
+
38
+ export function setReducedMotionPreference(reduce, storage = safeStorage()) {
39
+ const store = safeStorage(storage);
40
+ const value = reduce ? "true" : "false";
41
+ store?.setItem?.(REDUCE_MOTION_STORAGE_KEY, value);
42
+ return reduce;
43
+ }
44
+
45
+ export function applyReducedMotionPreference({
46
+ doc = globalThis.document,
47
+ storage = safeStorage(),
48
+ mediaMatcher = defaultMediaMatcher
49
+ } = {}) {
50
+ const reduce = effectiveReducedMotionPreference({ storage, mediaMatcher });
51
+ doc?.documentElement?.setAttribute("data-reduced-motion", reduce ? "reduce" : "allow");
52
+ return reduce;
53
+ }
@@ -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
84
  const { load, render } = await import("./app.js");
80
- const r = Route.parse(location.hash);
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,7 +122,7 @@ 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();
108
127
  }
109
128
  await render();
File without changes
@@ -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,5 +1,5 @@
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";
@@ -13,6 +13,14 @@ import { renderBoard } from "./kanban.js";
13
13
  import { renderDetailCard } 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":
@@ -473,6 +484,70 @@ function renderProjectLanesTab() {
473
484
  `;
474
485
  }
475
486
 
487
+ function renderProjectAppearanceTab() {
488
+ const theme = currentTheme();
489
+ const reduceMotion = effectiveReducedMotionPreference();
490
+ const storedMotion = storedReducedMotionPreference();
491
+ const browserDefault = browserPrefersReducedMotion();
492
+ const sourceLabel = storedMotion === null
493
+ ? `Using browser default (${browserDefault ? "reduced motion" : "motion allowed"}).`
494
+ : "Using your saved preference.";
495
+
496
+ return `
497
+ <div class="section appearance-theme-section">
498
+ <fieldset class="appearance-theme-field">
499
+ <legend>Theme</legend>
500
+ <div class="appearance-theme-options">
501
+ ${renderThemeOption("light", "Light", theme)}
502
+ ${renderThemeOption("dark", "Dark", theme)}
503
+ </div>
504
+ <p class="description">Applies the light/dark theme used by the top rail toggle.</p>
505
+ </fieldset>
506
+ </div>
507
+ <div class="section">
508
+ <label class="orbit-check" for="reduceMotionToggle">
509
+ <input
510
+ type="checkbox"
511
+ id="reduceMotionToggle"
512
+ ${reduceMotion ? "checked" : ""}
513
+ />
514
+ <span class="orbit-check-box" aria-hidden="true">
515
+ <svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
516
+ </span>
517
+ <span class="orbit-check-label">Reduce theme motion</span>
518
+ </label>
519
+ <p class="description">Turns off the animated starfield, meteors, and theme animations. ${escapeHtml(sourceLabel)}</p>
520
+ </div>
521
+ `;
522
+ }
523
+
524
+ function renderThemeOption(value, label, selectedTheme) {
525
+ const checked = selectedTheme === value ? "checked" : "";
526
+ const icon = value === "dark"
527
+ ? '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />'
528
+ : '<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" />';
529
+
530
+ return `
531
+ <label class="appearance-theme-option">
532
+ <input type="radio" name="themePreference" value="${value}" ${checked} />
533
+ <span class="appearance-theme-option-mark" aria-hidden="true">
534
+ <svg
535
+ class="appearance-theme-option-icon"
536
+ width="16"
537
+ height="16"
538
+ viewBox="0 0 24 24"
539
+ fill="none"
540
+ stroke="currentColor"
541
+ stroke-width="2"
542
+ stroke-linecap="round"
543
+ stroke-linejoin="round"
544
+ >${icon}</svg>
545
+ </span>
546
+ <span class="appearance-theme-option-label">${label}</span>
547
+ </label>
548
+ `;
549
+ }
550
+
476
551
  function renderProjectJournalTab(context) {
477
552
  const aiEnabled = context.board?.ai_enabled !== 0;
478
553
  return `
@@ -609,6 +684,24 @@ function bindProjectTabHandlers(context, tab) {
609
684
  });
610
685
  }
611
686
 
687
+ if (tab === "appearance") {
688
+ drawerInner.querySelectorAll("input[name='themePreference']").forEach((input) => {
689
+ input.addEventListener("change", (event) => {
690
+ if (!event.currentTarget.checked) return;
691
+ const theme = setThemePreference(event.currentTarget.value);
692
+ toast.success(theme === "dark" ? "Dark theme enabled" : "Light theme enabled");
693
+ });
694
+ });
695
+
696
+ $("#reduceMotionToggle")?.addEventListener("change", (event) => {
697
+ const reduce = !!event.currentTarget.checked;
698
+ setReducedMotionPreference(reduce);
699
+ applyReducedMotionPreference();
700
+ window.dispatchEvent(new CustomEvent("orbit:motion-preference-change", { detail: { reduce } }));
701
+ toast.success(reduce ? "Theme motion reduced" : "Theme motion enabled");
702
+ });
703
+ }
704
+
612
705
  if (tab === "ai") {
613
706
  const updateMcpPreview = () => {
614
707
  const osId = $("#mcpOsSelect")?.value || detectedMcpOs();
package/public/js/sse.js CHANGED
File without changes
@@ -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();