@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.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +28 -1
- package/build.mjs +2 -2
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.a4f045fc.js +676 -0
- package/dist/full/js/app.a4f045fc.js.map +7 -0
- package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +27 -5
- package/docs/MCP_SETUP.md +7 -3
- package/docs/ORBIT_DISPATCH.md +185 -0
- package/docs/setup/orbit-init-wizard.md +174 -0
- package/package.json +2 -2
- package/public/js/actor-labels.js +65 -0
- package/public/js/api.js +5 -1
- package/public/js/app.js +8 -1
- package/public/js/card-actions.js +526 -0
- package/public/js/config.js +2 -1
- package/public/js/drawer.js +2 -0
- package/public/js/format.js +336 -0
- package/public/js/kanban.js +25 -2
- package/public/js/local-backend.js +24 -8
- package/public/js/main.js +14 -6
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +78 -58
- package/public/js/search.js +6 -3
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +192 -29
- package/public/js/starscape.js +25 -18
- package/public/js/state.js +61 -2
- package/public/js/theme-preference.js +88 -0
- package/public/js/ticket-detail.js +432 -126
- package/public/js/url-routes.js +94 -0
- package/public/styles.css +1117 -47
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +1 -18
- package/src/core/agents-md.js +16 -10
- package/src/core/attachments.js +238 -0
- package/src/core/auth.js +36 -0
- package/src/core/backups.js +9 -3
- package/src/core/board.js +32 -1
- package/src/core/boards.js +1 -1
- package/src/core/bootstrap.js +9 -1
- package/src/core/db.js +42 -2
- package/src/core/delete-board.js +87 -21
- package/src/core/dispatch.js +532 -0
- package/src/core/http.js +31 -11
- package/src/core/migrate.js +198 -0
- package/src/core/paths.js +6 -0
- package/src/core/registry.js +12 -9
- package/src/core/router.js +62 -6
- package/src/core/search.js +56 -22
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +223 -0
- package/src/mcp-server.js +89 -129
- package/src/server.js +20 -0
- package/dist/full/js/app.aedc1bfe.js +0 -554
- package/dist/full/js/app.aedc1bfe.js.map +0 -7
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
|
-
const { load,
|
|
80
|
-
const r =
|
|
84
|
+
const { load, renderDetailOnly } = await import("./app.js");
|
|
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,10 +122,11 @@ 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();
|
|
127
|
+
return;
|
|
108
128
|
}
|
|
109
|
-
await
|
|
129
|
+
await renderDetailOnly();
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
window.addEventListener("popstate", () => {
|
package/public/js/search.js
CHANGED
|
@@ -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
|
-
<
|
|
32
|
-
|
|
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";
|
package/public/js/settings.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
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";
|
|
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
|
-
<
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
</
|
|
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
|
|
501
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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();
|
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();
|