@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/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 });
@@ -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();
@@ -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();
@@ -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();
@@ -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
- if (!state.boardId || !boards.some((b) => b.id === state.boardId)) {
34
- state.boardId = boards[0]?.id || "";
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
- <p class="description editable-field ${ticket.description ? "" : "is-placeholder"}" data-edit-field="description" tabindex="0" title="Click to edit">${escapeHtml(ticket.description || "No description yet.")}</p>
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>