@starlove/orbit 1.0.6 → 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/SKILL-ORBIT.md +2 -0
- package/build.mjs +2 -2
- 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/{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/package.json +2 -2
- package/public/js/api.js +5 -1
- package/public/js/drawer.js +2 -0
- package/public/js/format.js +142 -0
- package/public/js/kanban.js +10 -2
- package/public/js/main.js +14 -6
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +75 -56
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +96 -3
- 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/url-routes.js +94 -0
- package/public/styles.css +295 -22
- package/src/cli/orbit.js +0 -0
- package/src/core/agents-md.js +1 -0
- package/src/core/board.js +1 -1
- package/src/core/bootstrap.js +9 -1
- package/src/core/router.js +1 -1
- package/src/core/search.js +56 -22
- package/src/mcp/orbit-client.js +180 -0
- package/src/mcp-server.js +50 -274
- package/dist/full/js/app.aedc1bfe.js +0 -554
- package/dist/full/js/app.aedc1bfe.js.map +0 -7
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 });
|
|
@@ -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();
|
|
@@ -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/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();
|
package/public/js/state.js
CHANGED
|
@@ -10,6 +10,8 @@ export const state = {
|
|
|
10
10
|
/** Currently selected board id. Single-board UI; this points at the active
|
|
11
11
|
* board in `state.data.boards`. */
|
|
12
12
|
boardId: "",
|
|
13
|
+
/** URL-facing board identifier. Routes use slugs; API calls keep using ids. */
|
|
14
|
+
boardSlug: "",
|
|
13
15
|
selectedTicketId: "",
|
|
14
16
|
expandedCardIds: new Set(),
|
|
15
17
|
/** "empty" | "ticket" | "settings" — drives the main right overlay drawer (not the New flyout). */
|
|
@@ -30,9 +32,16 @@ export function getSettingsTab() {
|
|
|
30
32
|
export function syncBoardSelection() {
|
|
31
33
|
if (!state.data) return;
|
|
32
34
|
const boards = state.data.boards || [];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
let board = state.boardId ? boards.find((b) => b.id === state.boardId) : null;
|
|
36
|
+
if (!board && state.boardSlug) {
|
|
37
|
+
board = boards.find((b) => b.slug === state.boardSlug) || boards.find((b) => b.id === state.boardSlug);
|
|
35
38
|
}
|
|
39
|
+
if (!board) {
|
|
40
|
+
const activeBoardId = state.data.active_board_id;
|
|
41
|
+
board = boards.find((b) => b.id === activeBoardId) || boards[0] || null;
|
|
42
|
+
}
|
|
43
|
+
state.boardId = board?.id || "";
|
|
44
|
+
state.boardSlug = board?.slug || "";
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export function currentBoard() {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const THEME_STORAGE_KEY = "mab_theme";
|
|
2
|
+
export const THEME_CHANGE_EVENT = "orbit:theme-change";
|
|
3
|
+
|
|
4
|
+
const THEMES = new Set(["light", "dark"]);
|
|
5
|
+
|
|
6
|
+
function safeStorage(storage) {
|
|
7
|
+
try {
|
|
8
|
+
return storage ?? globalThis.localStorage ?? null;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeDocument(doc) {
|
|
15
|
+
try {
|
|
16
|
+
return doc ?? globalThis.document ?? null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeEventTarget(eventTarget) {
|
|
23
|
+
try {
|
|
24
|
+
return eventTarget ?? globalThis.window ?? null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeTheme(theme) {
|
|
31
|
+
return THEMES.has(theme) ? theme : "light";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function storedThemePreference(storage = safeStorage()) {
|
|
35
|
+
const store = safeStorage(storage);
|
|
36
|
+
const value = store?.getItem?.(THEME_STORAGE_KEY);
|
|
37
|
+
return THEMES.has(value) ? value : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function currentTheme({ doc = safeDocument(), storage = safeStorage() } = {}) {
|
|
41
|
+
const rootTheme = safeDocument(doc)?.documentElement?.getAttribute?.("data-theme");
|
|
42
|
+
return THEMES.has(rootTheme) ? rootTheme : storedThemePreference(storage) ?? "light";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyStoredThemePreference({
|
|
46
|
+
doc = safeDocument(),
|
|
47
|
+
storage = safeStorage()
|
|
48
|
+
} = {}) {
|
|
49
|
+
const storedTheme = storedThemePreference(storage);
|
|
50
|
+
if (storedTheme) {
|
|
51
|
+
safeDocument(doc)?.documentElement?.setAttribute?.("data-theme", storedTheme);
|
|
52
|
+
}
|
|
53
|
+
return currentTheme({ doc, storage });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setThemePreference(theme, {
|
|
57
|
+
doc = safeDocument(),
|
|
58
|
+
storage = safeStorage(),
|
|
59
|
+
eventTarget = safeEventTarget(),
|
|
60
|
+
notify = true
|
|
61
|
+
} = {}) {
|
|
62
|
+
const next = normalizeTheme(theme);
|
|
63
|
+
safeDocument(doc)?.documentElement?.setAttribute?.("data-theme", next);
|
|
64
|
+
safeStorage(storage)?.setItem?.(THEME_STORAGE_KEY, next);
|
|
65
|
+
|
|
66
|
+
if (notify) {
|
|
67
|
+
dispatchThemeChange(next, eventTarget);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function toggleThemePreference(options = {}) {
|
|
74
|
+
const next = currentTheme(options) === "dark" ? "light" : "dark";
|
|
75
|
+
return setThemePreference(next, options);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dispatchThemeChange(theme, eventTarget = safeEventTarget()) {
|
|
79
|
+
const target = safeEventTarget(eventTarget);
|
|
80
|
+
if (typeof target?.dispatchEvent !== "function") return;
|
|
81
|
+
|
|
82
|
+
if (typeof CustomEvent === "function") {
|
|
83
|
+
target.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { theme } }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
target.dispatchEvent({ type: THEME_CHANGE_EVENT, detail: { theme } });
|
|
88
|
+
}
|
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
typeLabel,
|
|
16
16
|
typeLabelLong,
|
|
17
17
|
priorityLabel,
|
|
18
|
-
priorityKeyFor
|
|
18
|
+
priorityKeyFor,
|
|
19
|
+
renderMarkdown
|
|
19
20
|
} from "./format.js";
|
|
20
21
|
import { renderTypeIcon, renderPriorityPill } from "./kanban.js";
|
|
21
22
|
import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
|
|
@@ -125,7 +126,8 @@ function wireTicketDetailEditors(ticket) {
|
|
|
125
126
|
|
|
126
127
|
const descEl = drawerInner.querySelector('[data-edit-field="description"]');
|
|
127
128
|
if (descEl) {
|
|
128
|
-
descEl.addEventListener("click", () => {
|
|
129
|
+
descEl.addEventListener("click", (event) => {
|
|
130
|
+
event.preventDefault();
|
|
129
131
|
startInlineEdit(descEl, "description", ticket.description || "", ticketId);
|
|
130
132
|
});
|
|
131
133
|
descEl.addEventListener("keydown", (event) => {
|
|
@@ -270,6 +272,7 @@ export async function renderDetail() {
|
|
|
270
272
|
`;
|
|
271
273
|
|
|
272
274
|
renderDrawerShell({
|
|
275
|
+
mode: "ticket",
|
|
273
276
|
title: ticket.title,
|
|
274
277
|
titleAttrs: {
|
|
275
278
|
class: "editable-field",
|
|
@@ -281,7 +284,7 @@ export async function renderDetail() {
|
|
|
281
284
|
body: `
|
|
282
285
|
<div class="detail-head">
|
|
283
286
|
<p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
|
|
284
|
-
<
|
|
287
|
+
<div class="description markdown-body editable-field ${ticket.description ? "" : "is-placeholder"}" data-edit-field="description" tabindex="0" title="Click to edit">${ticket.description ? renderMarkdown(ticket.description) : escapeHtml("No description yet.")}</div>
|
|
285
288
|
<dl class="ticket-meta" id="ticketMetaGrid" data-ticket-id="${escapeHtml(ticket.id)}">
|
|
286
289
|
<div class="ticket-meta-row">
|
|
287
290
|
<dt>State</dt>
|