@starlove/orbit 1.3.5 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/Dockerfile +2 -1
  2. package/README.md +10 -9
  3. package/SKILL-ORBIT.md +41 -17
  4. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  5. package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  6. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  7. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  8. package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  9. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  10. package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  11. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  12. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  13. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  14. package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
  15. package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  16. package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  17. package/dist/full/fonts/Inter/OFL.txt +93 -0
  18. package/dist/full/fonts/Inter/README.txt +118 -0
  19. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  20. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  21. package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
  22. package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
  23. package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
  24. package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
  25. package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  26. package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  27. package/dist/full/fonts/Open_Sans/README.txt +100 -0
  28. package/dist/full/fonts/Orbitron/OFL.txt +93 -0
  29. package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  30. package/dist/full/fonts/Orbitron/README.txt +68 -0
  31. package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
  32. package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  33. package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  34. package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  35. package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  36. package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  37. package/dist/full/fonts/runescape-uf.ttf +0 -0
  38. package/dist/full/index.html +135 -25
  39. package/dist/full/js/app.4e6fbe20.js +952 -0
  40. package/dist/full/js/app.4e6fbe20.js.map +7 -0
  41. package/dist/full/styles.4e60d824.css +1 -0
  42. package/dist/full/version.json +2 -2
  43. package/docs/AGENT_PROTOCOL.md +20 -13
  44. package/docs/ORBIT_DISPATCH.md +73 -38
  45. package/docs/UPDATING.md +114 -0
  46. package/package.json +5 -7
  47. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  48. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  49. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  50. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  51. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  52. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  53. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  54. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  55. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  56. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  57. package/public/fonts/Chakra_Petch/OFL.txt +93 -0
  58. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  59. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  60. package/public/fonts/Inter/OFL.txt +93 -0
  61. package/public/fonts/Inter/README.txt +118 -0
  62. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  63. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  64. package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
  65. package/public/fonts/JetBrains_Mono/README.txt +79 -0
  66. package/public/fonts/OpenRing-Regular.ttf +0 -0
  67. package/public/fonts/Open_Sans/OFL.txt +93 -0
  68. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  69. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  70. package/public/fonts/Open_Sans/README.txt +100 -0
  71. package/public/fonts/Orbitron/OFL.txt +93 -0
  72. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  73. package/public/fonts/Orbitron/README.txt +68 -0
  74. package/public/fonts/Rajdhani/OFL.txt +93 -0
  75. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  76. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  77. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  78. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  79. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  80. package/public/fonts/runescape-uf.ttf +0 -0
  81. package/public/index.html +133 -23
  82. package/public/js/artifact-drawer.js +79 -0
  83. package/public/js/board-export.js +161 -0
  84. package/public/js/board-menu.js +219 -17
  85. package/public/js/card-actions.js +1 -1
  86. package/public/js/create-card.js +60 -23
  87. package/public/js/drawer.js +164 -4
  88. package/public/js/font-preference.js +230 -0
  89. package/public/js/format.js +0 -10
  90. package/public/js/icons.js +16 -0
  91. package/public/js/local-backend.js +31 -4
  92. package/public/js/main.js +7 -1
  93. package/public/js/modal.js +3 -1
  94. package/public/js/panel.js +58 -0
  95. package/public/js/settings-tabs.js +1 -1
  96. package/public/js/settings.js +606 -275
  97. package/public/js/sse.js +3 -0
  98. package/public/js/state.js +1 -1
  99. package/public/js/text-editing.js +128 -0
  100. package/public/js/ticket-detail.js +377 -148
  101. package/public/js/ticket-typeahead.js +49 -0
  102. package/public/js/toast.js +19 -4
  103. package/public/js/topbar-notes.js +3 -3
  104. package/public/js/typeahead.js +206 -0
  105. package/public/js/update-check.js +58 -0
  106. package/public/styles.css +5447 -3572
  107. package/src/cli/orbit.js +9 -5
  108. package/src/core/agent.js +12 -4
  109. package/src/core/agents-md.js +3 -3
  110. package/src/core/artifacts.js +149 -0
  111. package/src/core/board.js +3 -2
  112. package/src/core/boards.js +0 -1
  113. package/src/core/css-bundle.js +1 -0
  114. package/src/core/db.js +6 -0
  115. package/src/core/dispatch.js +344 -34
  116. package/src/core/http.js +9 -2
  117. package/src/core/paths.js +26 -2
  118. package/src/core/router.js +33 -0
  119. package/src/core/search.js +3 -2
  120. package/src/core/snapshots.js +10 -5
  121. package/src/core/tickets.js +34 -3
  122. package/src/core/util.js +3 -2
  123. package/src/core/version-check.js +154 -0
  124. package/src/mcp/orbit-client.js +18 -2
  125. package/src/mcp-server.js +19 -14
  126. package/src/server.js +28 -3
  127. package/dist/full/js/app.4ae80764.js +0 -742
  128. package/dist/full/js/app.4ae80764.js.map +0 -7
  129. package/dist/full/styles.b9e0c130.css +0 -1
@@ -10,6 +10,158 @@ import { escapeHtml } from "./format.js";
10
10
  /** Bumps on each open/close so a stale `transitionend` from an old close cannot clear a newly opened drawer. */
11
11
  let drawerToken = 0;
12
12
 
13
+ /** Aborts scroll/resize observers when tabs are re-rendered or the drawer closes. */
14
+ let drawerTabsScrollAbort = null;
15
+
16
+ /** Toggle edge-fade classes on the tab shell when the strip can scroll further left/right. */
17
+ function syncDrawerTabsScrollState(tabsEl) {
18
+ const shellEl = document.getElementById("drawerTabsShell");
19
+ if (!tabsEl || !shellEl) return;
20
+
21
+ const maxScroll = tabsEl.scrollWidth - tabsEl.clientWidth;
22
+ if (maxScroll <= 1) {
23
+ shellEl.classList.remove("can-scroll-left", "can-scroll-right");
24
+ return;
25
+ }
26
+
27
+ shellEl.classList.toggle("can-scroll-left", tabsEl.scrollLeft > 1);
28
+ shellEl.classList.toggle("can-scroll-right", tabsEl.scrollLeft < maxScroll - 1);
29
+ }
30
+
31
+ /** Keep the active settings tab visible when the strip overflows horizontally. */
32
+ function scrollActiveDrawerTabIntoView(tabsEl) {
33
+ tabsEl
34
+ ?.querySelector(".drawer-tab.is-active")
35
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
36
+ }
37
+
38
+ function teardownDrawerTabsScroll() {
39
+ drawerTabsScrollAbort?.abort();
40
+ drawerTabsScrollAbort = null;
41
+ const shellEl = document.getElementById("drawerTabsShell");
42
+ const tabsEl = document.getElementById("drawerTabs");
43
+ shellEl?.classList.remove("can-scroll-left", "can-scroll-right");
44
+ tabsEl?.classList.remove("is-grabbing");
45
+ }
46
+
47
+ function bindDrawerTabsScroll(tabsEl) {
48
+ teardownDrawerTabsScroll();
49
+ if (!tabsEl) return;
50
+
51
+ const controller = new AbortController();
52
+ drawerTabsScrollAbort = controller;
53
+ const { signal } = controller;
54
+
55
+ const onScrollChange = () => syncDrawerTabsScrollState(tabsEl);
56
+ tabsEl.addEventListener("scroll", onScrollChange, { passive: true, signal });
57
+
58
+ if (typeof ResizeObserver !== "undefined") {
59
+ const observer = new ResizeObserver(onScrollChange);
60
+ observer.observe(tabsEl);
61
+ signal.addEventListener("abort", () => observer.disconnect(), { once: true });
62
+ }
63
+
64
+ // Vertical wheel over the tab strip pans horizontally (no Shift required).
65
+ const LINE_SCROLL_PX = 16;
66
+ const wheelPixels = (value, mode) => {
67
+ if (mode === WheelEvent.DOM_DELTA_LINE) return value * LINE_SCROLL_PX;
68
+ if (mode === WheelEvent.DOM_DELTA_PAGE) return value * Math.max(tabsEl.clientWidth, 1);
69
+ return value;
70
+ };
71
+
72
+ tabsEl.addEventListener(
73
+ "wheel",
74
+ (event) => {
75
+ if (event.defaultPrevented || event.ctrlKey) return;
76
+ if (tabsEl.scrollWidth <= tabsEl.clientWidth) return;
77
+
78
+ const horizontalDelta = event.deltaX || event.deltaY;
79
+ if (!horizontalDelta) return;
80
+
81
+ event.preventDefault();
82
+ tabsEl.scrollLeft += wheelPixels(horizontalDelta, event.deltaMode);
83
+ },
84
+ { passive: false, signal }
85
+ );
86
+
87
+ // Click-and-drag horizontal panning; threshold keeps tab clicks intact.
88
+ const DRAG_THRESHOLD = 4;
89
+ let dragActive = false;
90
+ let dragged = false;
91
+ let startX = 0;
92
+ let startScroll = 0;
93
+ let pointerId = 0;
94
+
95
+ tabsEl.addEventListener(
96
+ "pointerdown",
97
+ (event) => {
98
+ if (event.button !== 0) return;
99
+ if (tabsEl.scrollWidth <= tabsEl.clientWidth) return;
100
+ dragged = false;
101
+ dragActive = true;
102
+ pointerId = event.pointerId;
103
+ startX = event.clientX;
104
+ startScroll = tabsEl.scrollLeft;
105
+ },
106
+ { signal }
107
+ );
108
+
109
+ tabsEl.addEventListener(
110
+ "pointermove",
111
+ (event) => {
112
+ if (!dragActive || event.pointerId !== pointerId) return;
113
+ const delta = event.clientX - startX;
114
+ if (!dragged && Math.abs(delta) > DRAG_THRESHOLD) {
115
+ dragged = true;
116
+ try {
117
+ tabsEl.setPointerCapture(pointerId);
118
+ } catch {
119
+ /* no-op: some browsers can't capture here */
120
+ }
121
+ tabsEl.classList.add("is-grabbing");
122
+ }
123
+ if (dragged) {
124
+ event.preventDefault();
125
+ tabsEl.scrollLeft = startScroll - delta;
126
+ }
127
+ },
128
+ { signal }
129
+ );
130
+
131
+ const endDrag = (event) => {
132
+ if (!dragActive || (event && event.pointerId !== pointerId)) return;
133
+ dragActive = false;
134
+ tabsEl.classList.remove("is-grabbing");
135
+ try {
136
+ tabsEl.releasePointerCapture(pointerId);
137
+ } catch {
138
+ /* no-op */
139
+ }
140
+ };
141
+
142
+ tabsEl.addEventListener("pointerup", endDrag, { signal });
143
+ tabsEl.addEventListener("pointercancel", endDrag, { signal });
144
+ tabsEl.addEventListener("pointerleave", endDrag, { signal });
145
+
146
+ tabsEl.addEventListener(
147
+ "click",
148
+ (event) => {
149
+ if (dragged) {
150
+ event.stopPropagation();
151
+ event.preventDefault();
152
+ dragged = false;
153
+ }
154
+ },
155
+ { capture: true, signal }
156
+ );
157
+
158
+ requestAnimationFrame(() => {
159
+ if (signal.aborted) return;
160
+ onScrollChange();
161
+ scrollActiveDrawerTabIntoView(tabsEl);
162
+ });
163
+ }
164
+
13
165
  /** Slide the right drawer in (ticket detail, project settings, or create form). */
14
166
  export function openDrawer() {
15
167
  drawerToken += 1;
@@ -38,8 +190,11 @@ export function closeDrawer() {
38
190
  drawerInner.innerHTML = "";
39
191
  const titleBlockEl = document.getElementById("drawerTitleBlock");
40
192
  const tabsEl = document.getElementById("drawerTabs");
193
+ const tabsShellEl = document.getElementById("drawerTabsShell");
41
194
  if (titleBlockEl) titleBlockEl.innerHTML = "";
42
- if (tabsEl) { tabsEl.innerHTML = ""; tabsEl.hidden = true; }
195
+ if (tabsEl) tabsEl.innerHTML = "";
196
+ if (tabsShellEl) tabsShellEl.hidden = true;
197
+ teardownDrawerTabsScroll();
43
198
  drawer.classList.remove("is-wide");
44
199
  };
45
200
 
@@ -69,6 +224,7 @@ export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, ta
69
224
  drawer.classList.toggle("is-wide", mode === "ticket" || mode === "settings");
70
225
  const titleBlockEl = document.getElementById("drawerTitleBlock");
71
226
  const tabsEl = document.getElementById("drawerTabs");
227
+ const tabsShellEl = document.getElementById("drawerTabsShell");
72
228
 
73
229
  // Rebuild the eyebrow / title / subtitle wholesale each render. Like
74
230
  // drawerInner.innerHTML below, this means any listeners the caller attached
@@ -82,12 +238,14 @@ export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, ta
82
238
  }
83
239
  const segments = [];
84
240
  if (eyebrow) segments.push(`<span class="drawer-eyebrow">${escapeHtml(eyebrow)}</span>`);
85
- segments.push(`<h2${titleAttrHtml}>${escapeHtml(title ?? "")}</h2>`);
241
+ if (title != null && String(title).trim() !== "") {
242
+ segments.push(`<h2${titleAttrHtml}>${escapeHtml(title)}</h2>`);
243
+ }
86
244
  if (subtitleHtml) segments.push(`<div class="drawer-subtitle">${subtitleHtml}</div>`);
87
245
  titleBlockEl.innerHTML = segments.join("");
88
246
 
89
247
  if (tabs && tabs.length) {
90
- tabsEl.hidden = false;
248
+ if (tabsShellEl) tabsShellEl.hidden = false;
91
249
  tabsEl.innerHTML = tabs
92
250
  .map(
93
251
  (t) =>
@@ -101,9 +259,11 @@ export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, ta
101
259
  btn.addEventListener("click", () => onTabSelect(btn.dataset.drawerTab));
102
260
  });
103
261
  }
262
+ bindDrawerTabsScroll(tabsEl);
104
263
  } else {
105
- tabsEl.hidden = true;
264
+ if (tabsShellEl) tabsShellEl.hidden = true;
106
265
  tabsEl.innerHTML = "";
266
+ teardownDrawerTabsScroll();
107
267
  }
108
268
 
109
269
  const archiveBtn = document.getElementById("drawerArchiveBtn");
@@ -0,0 +1,230 @@
1
+ export const FONT_STORAGE_KEY = "orbit_font_preferences";
2
+ export const FONT_CHANGE_EVENT = "orbit:font-preference-change";
3
+
4
+ // Inter is the default site font and for most categories; headings and badges
5
+ // default to Orbitron. The site font drives global font metrics such as size,
6
+ // spacing, and line-height.
7
+ export const DEFAULT_SITE_FONT = "inter";
8
+ export const DEFAULT_HEADING_FONT = "orbitron";
9
+ export const DEFAULT_BADGE_FONT = "orbitron";
10
+ export const DEFAULT_USER_SCALE = 1;
11
+ export const USER_SCALE_MIN = 0.85;
12
+ export const USER_SCALE_MAX = 1.3;
13
+ export const USER_SCALE_STEP = 0.05;
14
+
15
+ export const SITE_FONT_OPTIONS = Object.freeze([
16
+ "inter",
17
+ "openSans",
18
+ "jetBrainsMono",
19
+ "chakraPetch",
20
+ "orbitron",
21
+ "rajdhani",
22
+ "runescapeUF",
23
+ "openRing"
24
+ ]);
25
+
26
+ export const FONT_OPTIONS = Object.freeze({
27
+ inter: {
28
+ label: "Inter",
29
+ stack: "\"Orbit Inter\", \"Inter\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
30
+ className: "font-inter"
31
+ },
32
+ openSans: {
33
+ label: "Open Sans",
34
+ stack: "\"Orbit Open Sans\", \"Open Sans\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
35
+ className: "font-open-sans"
36
+ },
37
+ jetBrainsMono: {
38
+ label: "JetBrains Mono",
39
+ stack: "\"Orbit JetBrains Mono\", \"JetBrains Mono\", \"Cascadia Mono\", \"SFMono-Regular\", Consolas, monospace",
40
+ className: "font-jetbrains-mono"
41
+ },
42
+ chakraPetch: {
43
+ label: "Chakra Petch",
44
+ stack: "\"Orbit Chakra Petch\", \"Chakra Petch\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
45
+ className: "font-chakra-petch"
46
+ },
47
+ orbitron: {
48
+ label: "Orbitron",
49
+ stack: "\"Orbit Orbitron\", \"Orbitron\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
50
+ className: "font-orbitron"
51
+ },
52
+ rajdhani: {
53
+ label: "Rajdhani",
54
+ stack: "\"Orbit Rajdhani\", \"Rajdhani\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
55
+ className: "font-rajdhani"
56
+ },
57
+ runescapeUF: {
58
+ label: "Runescape UF",
59
+ stack: "\"Orbit Runescape UF\", \"Cascadia Mono\", monospace",
60
+ className: "font-runescape-uf"
61
+ },
62
+ openRing: {
63
+ label: "Open Ring",
64
+ stack: "\"Orbit Open Ring\", \"Segoe UI Variable\", \"Segoe UI\", Arial, sans-serif",
65
+ className: "font-open-ring"
66
+ }
67
+ });
68
+
69
+ // Per-category overrides shown in the Appearance grid. Body text is not a
70
+ // target: it always follows the primary font (`site`), so it drives
71
+ // --orbit-font-body directly in applyFontPreferences instead.
72
+ export const FONT_TARGETS = Object.freeze({
73
+ heading: {
74
+ label: "Headings",
75
+ cssVar: "--orbit-font-heading",
76
+ dataAttr: "fontHeading"
77
+ },
78
+ control: {
79
+ label: "Controls",
80
+ cssVar: "--orbit-font-control",
81
+ dataAttr: "fontControl"
82
+ },
83
+ label: {
84
+ label: "Labels",
85
+ cssVar: "--orbit-font-label",
86
+ dataAttr: "fontLabel"
87
+ },
88
+ badge: {
89
+ label: "Badges",
90
+ cssVar: "--orbit-font-badge",
91
+ dataAttr: "fontBadge"
92
+ }
93
+ });
94
+
95
+ export const LEGACY_FONT_TARGET_ALIASES = Object.freeze({
96
+ ui: "control"
97
+ });
98
+
99
+ export const DEFAULT_FONT_TARGET_PREFERENCES = Object.freeze({
100
+ control: DEFAULT_SITE_FONT,
101
+ heading: DEFAULT_HEADING_FONT,
102
+ label: DEFAULT_SITE_FONT,
103
+ badge: DEFAULT_BADGE_FONT
104
+ });
105
+
106
+ export const DEFAULT_FONT_PREFERENCES = Object.freeze({
107
+ ...DEFAULT_FONT_TARGET_PREFERENCES,
108
+ // Primary font: drives body text and the default for every override category.
109
+ site: DEFAULT_SITE_FONT,
110
+ // User size slider multiplier (drives --type-user-scale).
111
+ userScale: DEFAULT_USER_SCALE
112
+ });
113
+
114
+ function hasOwn(object, key) {
115
+ return Object.prototype.hasOwnProperty.call(object, key);
116
+ }
117
+
118
+ export function clampUserScale(value) {
119
+ const number = Number(value);
120
+ if (!Number.isFinite(number)) return DEFAULT_USER_SCALE;
121
+ return Math.min(USER_SCALE_MAX, Math.max(USER_SCALE_MIN, number));
122
+ }
123
+
124
+ export function normalizeFontPreferences(value) {
125
+ const input = value && typeof value === "object" ? value : {};
126
+ const normalized = { ...DEFAULT_FONT_PREFERENCES };
127
+ const explicitTargets = new Set();
128
+ for (const key of Object.keys(FONT_TARGETS)) {
129
+ if (typeof input[key] === "string" && hasOwn(FONT_OPTIONS, input[key])) {
130
+ normalized[key] = input[key];
131
+ explicitTargets.add(key);
132
+ }
133
+ }
134
+ for (const [legacyKey, target] of Object.entries(LEGACY_FONT_TARGET_ALIASES)) {
135
+ if (!explicitTargets.has(target) && typeof input[legacyKey] === "string" && hasOwn(FONT_OPTIONS, input[legacyKey])) {
136
+ normalized[target] = input[legacyKey];
137
+ }
138
+ }
139
+ if (typeof input.site === "string" && hasOwn(FONT_OPTIONS, input.site)) {
140
+ normalized.site = input.site;
141
+ }
142
+ if (input.userScale !== undefined) {
143
+ normalized.userScale = clampUserScale(input.userScale);
144
+ }
145
+ // Before the split, one "label" target drove both categories. Preserve a
146
+ // customized label choice for badges unless badge was saved separately.
147
+ if (!hasOwn(input, "badge") && hasOwn(input, "label") && input.label !== DEFAULT_SITE_FONT) {
148
+ normalized.badge = normalized.label;
149
+ }
150
+ return normalized;
151
+ }
152
+
153
+ export function storedFontPreferences(storage = globalThis.localStorage) {
154
+ if (!storage) return { ...DEFAULT_FONT_PREFERENCES };
155
+ try {
156
+ const raw = storage.getItem(FONT_STORAGE_KEY);
157
+ if (!raw) return { ...DEFAULT_FONT_PREFERENCES };
158
+ return normalizeFontPreferences(JSON.parse(raw));
159
+ } catch {
160
+ return { ...DEFAULT_FONT_PREFERENCES };
161
+ }
162
+ }
163
+
164
+ export function fontStackFor(optionId) {
165
+ return FONT_OPTIONS[optionId]?.stack || FONT_OPTIONS[DEFAULT_SITE_FONT].stack;
166
+ }
167
+
168
+ export function applyFontPreferences(preferences = storedFontPreferences(), { doc = globalThis.document } = {}) {
169
+ const normalized = normalizeFontPreferences(preferences);
170
+ const root = doc?.documentElement;
171
+ if (!root) return normalized;
172
+
173
+ for (const [target, config] of Object.entries(FONT_TARGETS)) {
174
+ const optionId = normalized[target];
175
+ root.style?.setProperty?.(config.cssVar, fontStackFor(optionId));
176
+ root.setAttribute?.(`data-${config.dataAttr.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`, optionId);
177
+ }
178
+
179
+ // Body text (paragraphs, card titles, long-form prose, editors) always tracks
180
+ // the primary font rather than being an independent category.
181
+ root.style?.setProperty?.("--orbit-font-body", fontStackFor(normalized.site));
182
+ // The only size lever is the Appearance slider; font family no longer changes size.
183
+ root.style?.setProperty?.("--type-user-scale", String(normalized.userScale));
184
+ root.setAttribute?.("data-font-site", normalized.site);
185
+ return normalized;
186
+ }
187
+
188
+ export function setFontPreferences(preferences, {
189
+ doc = globalThis.document,
190
+ storage = globalThis.localStorage,
191
+ eventTarget = globalThis.window
192
+ } = {}) {
193
+ const normalized = normalizeFontPreferences(preferences);
194
+ storage?.setItem?.(FONT_STORAGE_KEY, JSON.stringify(normalized));
195
+ applyFontPreferences(normalized, { doc });
196
+ eventTarget?.dispatchEvent?.(new CustomEvent(FONT_CHANGE_EVENT, { detail: { preferences: normalized } }));
197
+ return normalized;
198
+ }
199
+
200
+ export function resetFontPreferences(options = {}) {
201
+ options.storage?.removeItem?.(FONT_STORAGE_KEY);
202
+ return setFontPreferences(DEFAULT_FONT_PREFERENCES, options);
203
+ }
204
+
205
+ export function setFontPreference(target, optionId, options = {}) {
206
+ if (!hasOwn(FONT_TARGETS, target)) throw new Error(`Unknown font target: ${target}`);
207
+ if (!hasOwn(FONT_OPTIONS, optionId)) throw new Error(`Unknown font option: ${optionId}`);
208
+ return setFontPreferences({ ...storedFontPreferences(options.storage), [target]: optionId }, options);
209
+ }
210
+
211
+ // Swap the whole site to a primary font: sets every target plus the `site`
212
+ // driver. Per-target overrides can be applied afterward via setFontPreference.
213
+ export function setSiteFont(optionId, options = {}) {
214
+ if (!hasOwn(FONT_OPTIONS, optionId)) throw new Error(`Unknown font option: ${optionId}`);
215
+ const next = { ...storedFontPreferences(options.storage) };
216
+ for (const target of Object.keys(FONT_TARGETS)) next[target] = optionId;
217
+ next.site = optionId;
218
+ return setFontPreferences(next, options);
219
+ }
220
+
221
+ export function setUserScale(value, options = {}) {
222
+ return setFontPreferences(
223
+ { ...storedFontPreferences(options.storage), userScale: clampUserScale(value) },
224
+ options
225
+ );
226
+ }
227
+
228
+ export function applyStoredFontPreferences(options = {}) {
229
+ return applyFontPreferences(storedFontPreferences(options.storage), options);
230
+ }
@@ -431,13 +431,3 @@ export function renderLabels(labels = []) {
431
431
  </div>
432
432
  `;
433
433
  }
434
-
435
- export function repoLabelFromUrl(repoUrl) {
436
- const raw = String(repoUrl || "").trim();
437
- if (!raw) return "Not set";
438
- const githubHttps = raw.match(/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
439
- if (githubHttps) return `${githubHttps[1]}/${githubHttps[2]}`;
440
- const githubSsh = raw.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
441
- if (githubSsh) return `${githubSsh[1]}/${githubSsh[2]}`;
442
- return raw;
443
- }
@@ -0,0 +1,16 @@
1
+ export const closeIconSvg = `
2
+ <svg class="ui-icon ui-icon-x" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
3
+ <path d="M6 6l12 12M18 6 6 18" />
4
+ </svg>
5
+ `;
6
+
7
+ /** Trash bin — archive / permanent delete actions (drawer header, card archive controls, toasts). */
8
+ export const trashIconSvg = `
9
+ <svg class="ui-icon ui-icon-trash" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M3 6h18" />
11
+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
12
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
13
+ <path d="M10 11v6" />
14
+ <path d="M14 11v6" />
15
+ </svg>
16
+ `;
@@ -17,7 +17,7 @@ const STORES = [
17
17
  "labels", // {id, board_id, name, color, created_at}
18
18
  "tickets", // full ticket row (no joins)
19
19
  "ticket_labels", // {ticket_id, label_id} composite key as `${ticket_id}|${label_id}`
20
- "comments", // {id, ticket_id, author, kind, body, created_at}
20
+ "comments", // {id, ticket_id, author, kind, body, omitted_for_ai, created_at}
21
21
  "relations", // {id, source_ticket_id, target_ticket_id, kind, created_at, ...}
22
22
  "board_entries", // {id, board_id, type, title, body, ticket_id, struck_at, created_at, updated_at}
23
23
  "events", // {id, board_id, ticket_id, type, actor, body_json, created_at}
@@ -166,7 +166,7 @@ async function seedIfEmpty(db) {
166
166
  system_path: "",
167
167
  default_branch: "main",
168
168
  project_notes:
169
- "This preview lives entirely in your browser (IndexedDB). Use it to try the flow; export the snapshot from Settings → Repository when you want to move to a real Orbit install.",
169
+ "This preview lives entirely in your browser (IndexedDB). Use it to try the flow; export the snapshot from Settings → Data when you want to move to a real Orbit install.",
170
170
  agent_instructions:
171
171
  "Preview edition has no agent or MCP integration. The full Orbit install (npm i -g starscape-orbit && orbit init) provides those.",
172
172
  ai_enabled: 0,
@@ -210,7 +210,7 @@ async function seedIfEmpty(db) {
210
210
  description:
211
211
  "This onboarding card is for the in-browser preview. Drag it across lanes, edit fields, add comments — it all stays in this browser only.",
212
212
  type: "epic",
213
- aiPlan: "1. Drag this card to In Progress.\n2. Add a comment.\n3. Export the snapshot from Settings → Repository.",
213
+ aiPlan: "1. Drag this card to In Progress.\n2. Add a comment.\n3. Export the snapshot from Settings → Data.",
214
214
  stateId: stateByName.Todo.id,
215
215
  priority: 4,
216
216
  labels: [labelByName["onboarding"].id, labelByName["needs-decomposition"].id]
@@ -238,7 +238,7 @@ async function seedIfEmpty(db) {
238
238
 
239
239
  addTicket(4, {
240
240
  title: "Export your snapshot when ready",
241
- description: "Settings → Repository → Export downloads a JSON snapshot you can import into a real Orbit install.",
241
+ description: "Settings → Data → Export downloads a JSON snapshot you can import into a real Orbit install.",
242
242
  type: "task",
243
243
  stateId: stateByName.Backlog.id,
244
244
  priority: 1,
@@ -251,6 +251,7 @@ async function seedIfEmpty(db) {
251
251
  author: "system",
252
252
  kind: "note",
253
253
  body: "Welcome to the Orbit preview. Everything lives in your browser.",
254
+ omitted_for_ai: 0,
254
255
  created_at: t
255
256
  });
256
257
 
@@ -618,7 +619,9 @@ async function handleTicketCreate(body) {
618
619
  type: body.type || (body.parent_ticket_id ? "feature" : "task"),
619
620
  parent_ticket_id: body.parent_ticket_id || null,
620
621
  ai_plan: body.ai_plan || "",
622
+ plan_artifact_path: body.plan_artifact_path || "",
621
623
  implementation_summary: body.implementation_summary || "",
624
+ implementation_artifact_path: body.implementation_artifact_path || "",
622
625
  implementation_updates: body.implementation_updates || "",
623
626
  state_id: stateId,
624
627
  priority: body.priority ?? 0,
@@ -651,7 +654,9 @@ async function handleTicketPatch(ticketId, body) {
651
654
  "type",
652
655
  "parent_ticket_id",
653
656
  "ai_plan",
657
+ "plan_artifact_path",
654
658
  "implementation_summary",
659
+ "implementation_artifact_path",
655
660
  "implementation_updates",
656
661
  "priority"
657
662
  ];
@@ -840,6 +845,7 @@ async function handleCommentCreate(ticketId, body) {
840
845
  author: "you",
841
846
  kind: body.kind || "note",
842
847
  body: body.body || "",
848
+ omitted_for_ai: 0,
843
849
  created_at: time
844
850
  };
845
851
  stores.comments.put(comment);
@@ -851,6 +857,26 @@ async function handleCommentCreate(ticketId, body) {
851
857
  });
852
858
  }
853
859
 
860
+ async function handleCommentPatch(ticketId, commentId, body) {
861
+ const db = await openDb();
862
+ return putAndEvent(db, async (stores) => {
863
+ const ticket = await reqPromise(stores.tickets.get(ticketId));
864
+ if (!ticket) throw err(404, "ticket_not_found");
865
+ const comment = await reqPromise(stores.comments.get(commentId));
866
+ if (!comment || comment.ticket_id !== ticketId) throw err(404, "comment_not_found");
867
+ const omittedForAi = body.omitted_for_ai ?? body.omittedForAi;
868
+ if (typeof omittedForAi !== "boolean") throw err(400, "omitted_for_ai_required");
869
+ const time = nowIso();
870
+ comment.omitted_for_ai = omittedForAi ? 1 : 0;
871
+ ticket.updated_at = time;
872
+ stores.comments.put(comment);
873
+ stores.tickets.put(ticket);
874
+ recordEvent(stores, "comment_ai_omission_changed", ticketId, { comment_id: commentId, omitted_for_ai: omittedForAi });
875
+ await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, time);
876
+ return comment;
877
+ });
878
+ }
879
+
854
880
  async function handleRelationCreate(body) {
855
881
  const db = await openDb();
856
882
  return putAndEvent(db, async (stores) => {
@@ -970,6 +996,7 @@ const ROUTES = [
970
996
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
971
997
  { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
972
998
  { m: "POST", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m, u, body) => handleCommentCreate(decodeURIComponent(m[1]), body) },
999
+ { m: "PATCH", re: /^\/api\/tickets\/([^/]+)\/comments\/([^/]+)$/, fn: (m, u, body) => handleCommentPatch(decodeURIComponent(m[1]), decodeURIComponent(m[2]), body) },
973
1000
  { m: "POST", re: /^\/api\/relations$/, fn: (m, u, body) => handleRelationCreate(body) },
974
1001
  { m: "DELETE", re: /^\/api\/relations\/([^/]+)$/, fn: (m) => handleRelationDelete(decodeURIComponent(m[1])) },
975
1002
  { m: "GET", re: /^\/api\/search$/, fn: (m, u) => handleSearch(u.searchParams.get("q")) },
package/public/js/main.js CHANGED
@@ -43,9 +43,12 @@ import { edition } from "./config.js";
43
43
  import { openModal, wireModal } from "./modal.js";
44
44
  import { wireTopbarFocus } from "./topbar-notes.js";
45
45
  import { wireHelpMenu } from "./help-menu.js";
46
+ import { checkForUpdate } from "./update-check.js";
47
+ import { applyStoredFontPreferences } from "./font-preference.js";
46
48
 
47
49
  async function init() {
48
50
  const welcomeDialog = document.getElementById("welcomeDialog");
51
+ applyStoredFontPreferences();
49
52
  wireModal(welcomeDialog);
50
53
  wireTopbarFocus();
51
54
  wireHelpMenu();
@@ -97,7 +100,7 @@ async function init() {
97
100
  const ticket = state.data?.tickets?.find((t) => t.id === ticketId);
98
101
  const titleForToast = ticket?.title || "card";
99
102
  await api(withBoardQuery(`/api/tickets/${encodeURIComponent(ticketId)}/archive`), { method: "POST" });
100
- toast.error(`Archived: ${titleForToast}`);
103
+ toast.removed(`Archived: ${titleForToast}`);
101
104
  await load();
102
105
  navigate({ boardId: state.boardId, view: "board" });
103
106
  });
@@ -150,6 +153,9 @@ async function init() {
150
153
  } else {
151
154
  syncUrlFromState({ replace: true });
152
155
  }
156
+
157
+ // Best-effort, non-blocking: nudge if a newer Orbit is available.
158
+ checkForUpdate();
153
159
  }
154
160
 
155
161
  init();
@@ -8,7 +8,9 @@
8
8
  // Escape key is handled natively by the browser; no extra wiring needed.
9
9
 
10
10
  export function openModal(dialogEl) {
11
- dialogEl?.showModal();
11
+ if (!dialogEl) return;
12
+ dialogEl.showModal();
13
+ dialogEl.querySelector("[data-modal-initial-focus]")?.focus({ preventScroll: true });
12
14
  }
13
15
 
14
16
  export function closeModal(dialogEl) {
@@ -0,0 +1,58 @@
1
+ // Reusable settings-style "panel" primitives shared by the project settings
2
+ // drawer and the ticket detail drawer. A panel is a bordered card with a
3
+ // tinted header strip (small-caps title) and a padded body — the same visual
4
+ // language used across the Appearance / AI / Data settings tabs. The
5
+ // collapsible variant turns the header into a <details>/<summary> toggle with a
6
+ // chevron that flips when open, so any surface can drop in a togglable section
7
+ // without re-deriving the markup. These intentionally emit the .settings-card
8
+ // class family (documented in styles.css as a reusable @starlove/ui primitive).
9
+
10
+ import { escapeHtml } from "./format.js";
11
+
12
+ const panelChevronSvg = `<svg class="settings-card-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>`;
13
+
14
+ /**
15
+ * Static panel: bordered card with a header title and a padded body.
16
+ *
17
+ * Options:
18
+ * - title: heading text shown in the small-caps header strip.
19
+ * - body: raw HTML for the card body (already escaped by the caller).
20
+ * - className: extra classes appended to the .settings-card element.
21
+ * - attrs: extra attributes string placed on the <section> (e.g. data-*).
22
+ * - headExtra: raw HTML appended after the title inside the header (actions).
23
+ */
24
+ export function renderPanel({ title, body = "", className = "", attrs = "", headExtra = "" } = {}) {
25
+ const classAttr = className ? ` ${className}` : "";
26
+ const extraAttrs = attrs ? ` ${attrs}` : "";
27
+ return `
28
+ <section class="settings-card${classAttr}"${extraAttrs}>
29
+ <header class="settings-card-head">
30
+ <h3 class="settings-card-title">${escapeHtml(title)}</h3>
31
+ ${headExtra}
32
+ </header>
33
+ <div class="settings-card-body">${body}</div>
34
+ </section>
35
+ `;
36
+ }
37
+
38
+ /**
39
+ * Collapsible panel: same chrome as renderPanel but the header doubles as a
40
+ * <details>/<summary> toggle with a flipping chevron.
41
+ *
42
+ * Options mirror renderPanel, plus:
43
+ * - open: whether the section starts expanded.
44
+ */
45
+ export function renderTogglePanel({ title, body = "", open = false, className = "", attrs = "", headExtra = "" } = {}) {
46
+ const classAttr = className ? ` ${className}` : "";
47
+ const extraAttrs = attrs ? ` ${attrs}` : "";
48
+ return `
49
+ <details class="settings-card settings-card--collapsible${classAttr}"${open ? " open" : ""}${extraAttrs}>
50
+ <summary class="settings-card-head">
51
+ <h3 class="settings-card-title">${escapeHtml(title)}</h3>
52
+ ${headExtra}
53
+ ${panelChevronSvg}
54
+ </summary>
55
+ <div class="settings-card-body">${body}</div>
56
+ </details>
57
+ `;
58
+ }