@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.
- package/.dockerignore +0 -0
- package/Dockerfile +0 -0
- package/LICENSE +0 -0
- package/README.md +8 -0
- package/SKILL-ORBIT.md +2 -0
- package/build.mjs +2 -2
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.f4fd5e94.js +604 -0
- package/dist/full/js/app.f4fd5e94.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/{styles.c2e3d20a.css → styles.14e9dbbd.css} +295 -22
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +4 -0
- package/docs/MCP_SETUP.md +3 -1
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -0
- package/package.json +2 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +0 -0
- package/public/js/api.js +5 -1
- package/public/js/app.js +0 -0
- package/public/js/board-menu.js +0 -0
- package/public/js/config.js +0 -0
- package/public/js/create-card.js +0 -0
- package/public/js/dom.js +0 -0
- package/public/js/drawer.js +2 -0
- package/public/js/format.js +142 -0
- package/public/js/kanban.js +18 -2
- package/public/js/local-backend.js +29 -2
- package/public/js/main.js +14 -6
- package/public/js/modal.js +0 -0
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +75 -56
- package/public/js/search.js +0 -0
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +96 -3
- package/public/js/sse.js +0 -0
- package/public/js/starscape.js +25 -18
- package/public/js/state.js +11 -2
- package/public/js/theme-preference.js +88 -0
- package/public/js/ticket-detail.js +6 -3
- package/public/js/toast.js +0 -0
- package/public/js/transport.js +0 -0
- package/public/js/unread.js +0 -0
- package/public/js/url-routes.js +94 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +295 -22
- package/src/core/agent.js +10 -4
- package/src/core/agents-md.js +1 -0
- package/src/core/auth.js +0 -0
- package/src/core/backups.js +0 -0
- package/src/core/board.js +1 -1
- package/src/core/boards.js +0 -0
- package/src/core/bootstrap.js +10 -2
- package/src/core/db.js +0 -0
- package/src/core/delete-board.js +0 -0
- package/src/core/events.js +0 -0
- package/src/core/http.js +0 -0
- package/src/core/paths.js +0 -0
- package/src/core/provision-repo-board.js +0 -0
- package/src/core/queries.js +0 -0
- package/src/core/registry.js +0 -0
- package/src/core/relations.js +0 -0
- package/src/core/router.js +1 -1
- package/src/core/search.js +62 -23
- package/src/core/seed.js +0 -0
- package/src/core/snapshots.js +0 -0
- package/src/core/states.js +0 -0
- package/src/core/system-picker.js +0 -0
- package/src/core/tickets.js +28 -3
- package/src/core/util.js +0 -0
- package/src/mcp/orbit-client.js +180 -0
- package/src/mcp-server.js +50 -274
- package/src/server.js +0 -0
- package/dist/full/js/app.7e25f59b.js +0 -554
- package/dist/full/js/app.7e25f59b.js.map +0 -7
package/public/js/kanban.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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:
|
|
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 {
|
|
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
|
|
131
|
-
// view; otherwise canonicalize the URL to reflect the current
|
|
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
|
-
|
|
135
|
-
if (initialHash && initialHash !== "#" && initialHash !== "#/") {
|
|
143
|
+
if (hasRoute()) {
|
|
136
144
|
await applyRoute();
|
|
137
145
|
} else {
|
|
138
146
|
syncUrlFromState({ replace: true });
|
package/public/js/modal.js
CHANGED
|
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
|
+
}
|
package/public/js/router.js
CHANGED
|
@@ -1,52 +1,51 @@
|
|
|
1
|
-
// Hash
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
59
|
-
|
|
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, "",
|
|
64
|
-
else history.pushState(null, "",
|
|
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
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
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 =
|
|
85
|
+
const r = currentRoute();
|
|
86
|
+
const urlHadRoute = hasRoute(location);
|
|
81
87
|
const prevBoardId = state.boardId;
|
|
82
|
-
|
|
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 (
|
|
125
|
+
if (state.boardId && state.boardId !== prevBoardId) {
|
|
107
126
|
await load();
|
|
108
127
|
}
|
|
109
128
|
await render();
|
package/public/js/search.js
CHANGED
|
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";
|
package/public/js/settings.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Project settings drawer (the cog/settings icon).
|
|
2
|
-
// Notes, Journal,
|
|
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
|
package/public/js/starscape.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 =
|
|
211
|
-
const reduced =
|
|
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",
|
|
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
|
-
|
|
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();
|