@starlove/orbit 1.3.5 → 1.4.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/Dockerfile +2 -1
- package/README.md +10 -9
- package/SKILL-ORBIT.md +41 -17
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
- package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/dist/full/fonts/Inter/OFL.txt +93 -0
- package/dist/full/fonts/Inter/README.txt +118 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
- package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
- package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
- package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/dist/full/fonts/Open_Sans/README.txt +100 -0
- package/dist/full/fonts/Orbitron/OFL.txt +93 -0
- package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/Orbitron/README.txt +68 -0
- package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/dist/full/fonts/runescape-uf.ttf +0 -0
- package/dist/full/index.html +135 -25
- package/dist/full/js/app.62905fcb.js +952 -0
- package/dist/full/js/app.62905fcb.js.map +7 -0
- package/dist/full/styles.4e60d824.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +20 -13
- package/docs/ORBIT_DISPATCH.md +73 -38
- package/docs/UPDATING.md +114 -0
- package/package.json +5 -7
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +93 -0
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +93 -0
- package/public/fonts/Inter/README.txt +118 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
- package/public/fonts/JetBrains_Mono/README.txt +79 -0
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OFL.txt +93 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +100 -0
- package/public/fonts/Orbitron/OFL.txt +93 -0
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +68 -0
- package/public/fonts/Rajdhani/OFL.txt +93 -0
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +133 -23
- package/public/js/artifact-drawer.js +79 -0
- package/public/js/board-export.js +161 -0
- package/public/js/board-menu.js +219 -17
- package/public/js/card-actions.js +1 -1
- package/public/js/create-card.js +60 -23
- package/public/js/drawer.js +164 -4
- package/public/js/font-preference.js +229 -0
- package/public/js/format.js +0 -10
- package/public/js/icons.js +16 -0
- package/public/js/local-backend.js +31 -4
- package/public/js/main.js +7 -1
- package/public/js/modal.js +3 -1
- package/public/js/panel.js +58 -0
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +606 -275
- package/public/js/sse.js +3 -0
- package/public/js/state.js +1 -1
- package/public/js/text-editing.js +128 -0
- package/public/js/ticket-detail.js +377 -148
- package/public/js/ticket-typeahead.js +49 -0
- package/public/js/toast.js +19 -4
- package/public/js/topbar-notes.js +3 -3
- package/public/js/typeahead.js +206 -0
- package/public/js/update-check.js +58 -0
- package/public/styles.css +5447 -3572
- package/src/cli/orbit.js +9 -5
- package/src/core/agent.js +12 -4
- package/src/core/agents-md.js +3 -3
- package/src/core/artifacts.js +149 -0
- package/src/core/board.js +3 -2
- package/src/core/boards.js +0 -1
- package/src/core/css-bundle.js +1 -0
- package/src/core/db.js +6 -0
- package/src/core/dispatch.js +344 -34
- package/src/core/http.js +9 -2
- package/src/core/paths.js +26 -2
- package/src/core/router.js +33 -0
- package/src/core/search.js +3 -2
- package/src/core/snapshots.js +10 -5
- package/src/core/tickets.js +34 -3
- package/src/core/util.js +3 -2
- package/src/core/version-check.js +154 -0
- package/src/mcp/orbit-client.js +18 -2
- package/src/mcp-server.js +19 -14
- package/src/server.js +28 -3
- package/dist/full/js/app.4ae80764.js +0 -742
- package/dist/full/js/app.4ae80764.js.map +0 -7
- package/dist/full/styles.b9e0c130.css +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Shared ticket typeahead: the styled suggestion dropdown used wherever a
|
|
2
|
+
// ticket is picked by typing (ticket-detail's Related / parent-epic / add-child
|
|
3
|
+
// fields and the New-card flyout's parent-epic field). Rows mirror the topbar
|
|
4
|
+
// search results — bold #number, title, type badge, state badge — and selecting
|
|
5
|
+
// a row fills the input with the canonical "#12 — Title" label so the host
|
|
6
|
+
// form's resolve-on-submit logic keeps working unchanged.
|
|
7
|
+
|
|
8
|
+
import { escapeHtml, ticketLabel, stateClassFor, typeLabel, canonicalTicketType } from "./format.js";
|
|
9
|
+
import { attachTypeahead } from "./typeahead.js";
|
|
10
|
+
|
|
11
|
+
// Canonical "#12 — Title" label the resolve-on-submit logic expects.
|
|
12
|
+
export function ticketCanonicalLabel(t) {
|
|
13
|
+
return `#${t.number} — ${t.title}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A typeahead row for a ticket: bold #number, title, then trailing type and
|
|
17
|
+
// state badges — the same shape as the topbar search results.
|
|
18
|
+
export function renderTicketHitRow(t) {
|
|
19
|
+
const type = canonicalTicketType(t.type);
|
|
20
|
+
return `
|
|
21
|
+
<span class="search-hit-main">
|
|
22
|
+
<strong>${escapeHtml(ticketLabel(t))}</strong>
|
|
23
|
+
<span class="search-hit-title">${escapeHtml(t.title)}</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span class="search-hit-type type-pill-${escapeHtml(type)}">${escapeHtml(typeLabel(t.type))}</span>
|
|
26
|
+
<span class="search-hit-state" data-variant="${escapeHtml(stateClassFor(t))}">${escapeHtml(t.state_name || "State")}</span>
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Matches a ticket against a query by number ("12", "#12") or title substring.
|
|
31
|
+
export function ticketMatchesQuery(t, query) {
|
|
32
|
+
const q = query.replace(/^#/, "");
|
|
33
|
+
return String(t.number).startsWith(q) || t.title.toLowerCase().includes(query);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wire the styled typeahead onto a ticket-picker input. `getCandidates` returns
|
|
37
|
+
// the eligible tickets; `onSelect` (optional) fires after the value is filled,
|
|
38
|
+
// e.g. to record the chosen ticket's id in a hidden field.
|
|
39
|
+
export function attachTicketTypeahead(input, getCandidates, { onSelect } = {}) {
|
|
40
|
+
if (!input) return;
|
|
41
|
+
attachTypeahead(input, {
|
|
42
|
+
getItems: getCandidates,
|
|
43
|
+
match: ticketMatchesQuery,
|
|
44
|
+
renderItem: renderTicketHitRow,
|
|
45
|
+
valueOf: ticketCanonicalLabel,
|
|
46
|
+
onSelect,
|
|
47
|
+
emptyText: "No matching tickets",
|
|
48
|
+
});
|
|
49
|
+
}
|
package/public/js/toast.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Toast notifications + small UI utilities (downloadJson, debounce).
|
|
2
2
|
|
|
3
3
|
import { escapeHtml } from "./format.js";
|
|
4
|
+
import { closeIconSvg, trashIconSvg } from "./icons.js";
|
|
4
5
|
|
|
5
6
|
const toastIcons = {
|
|
6
7
|
info: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>`,
|
|
7
8
|
success: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>`,
|
|
8
9
|
warning: `<svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>`,
|
|
9
|
-
error: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/></svg
|
|
10
|
+
error: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/></svg>`,
|
|
11
|
+
removed: trashIconSvg
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
let toastContainer = null;
|
|
@@ -19,7 +21,7 @@ function ensureToastContainer() {
|
|
|
19
21
|
return toastContainer;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function toast(message, type = "info", duration = 4000) {
|
|
24
|
+
export function toast(message, type = "info", duration = 4000, options = {}) {
|
|
23
25
|
const container = ensureToastContainer();
|
|
24
26
|
|
|
25
27
|
const node = document.createElement("div");
|
|
@@ -27,9 +29,21 @@ export function toast(message, type = "info", duration = 4000) {
|
|
|
27
29
|
node.setAttribute("role", "status");
|
|
28
30
|
|
|
29
31
|
const icon = toastIcons[type];
|
|
30
|
-
node.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close" aria-label="Dismiss"
|
|
32
|
+
node.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close" aria-label="Dismiss">${closeIconSvg}</button>`;
|
|
31
33
|
|
|
32
|
-
node.querySelector(".toast-close").addEventListener("click", () =>
|
|
34
|
+
node.querySelector(".toast-close").addEventListener("click", (event) => {
|
|
35
|
+
event.stopPropagation();
|
|
36
|
+
dismissToast(node);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Optional whole-toast click action (e.g. "Update available → open Settings").
|
|
40
|
+
if (typeof options.onClick === "function") {
|
|
41
|
+
node.classList.add("toast-clickable");
|
|
42
|
+
node.addEventListener("click", () => {
|
|
43
|
+
options.onClick();
|
|
44
|
+
dismissToast(node);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
33
47
|
|
|
34
48
|
container.prepend(node);
|
|
35
49
|
requestAnimationFrame(() => requestAnimationFrame(() => node.classList.add("toast-enter")));
|
|
@@ -46,6 +60,7 @@ toast.info = (msg, dur) => toast(msg, "info", dur);
|
|
|
46
60
|
toast.success = (msg, dur) => toast(msg, "success", dur);
|
|
47
61
|
toast.warning = (msg, dur) => toast(msg, "warning", dur);
|
|
48
62
|
toast.error = (msg, dur) => toast(msg, "error", dur);
|
|
63
|
+
toast.removed = (msg, dur) => toast(msg, "removed", dur);
|
|
49
64
|
|
|
50
65
|
function dismissToast(node) {
|
|
51
66
|
if (node.classList.contains("toast-exit")) return;
|
|
@@ -32,12 +32,12 @@ function renderNotesBody(board) {
|
|
|
32
32
|
const inner = hasNotes ? renderPreservedText(notes) : escapeHtml(NOTES_PLACEHOLDER);
|
|
33
33
|
return `
|
|
34
34
|
<div
|
|
35
|
-
class="inline-md-field-body preserved-text-body editable-field brand-focus-notes-edit${placeholderClass}"
|
|
35
|
+
class="inline-md-field-body preserved-text-body editable-field settings-notes-body brand-focus-notes-edit${placeholderClass}"
|
|
36
36
|
data-edit-field="project_notes"
|
|
37
37
|
tabindex="0"
|
|
38
38
|
title="Click to edit"
|
|
39
39
|
role="button"
|
|
40
|
-
aria-label="Edit
|
|
40
|
+
aria-label="Edit Notes For You"
|
|
41
41
|
>${inner}</div>
|
|
42
42
|
`;
|
|
43
43
|
}
|
|
@@ -86,7 +86,7 @@ async function startBoardNotesEdit(sourceEvent) {
|
|
|
86
86
|
fieldName: "project_notes",
|
|
87
87
|
multiline: true,
|
|
88
88
|
initialValue: lastRenderedBoard.project_notes || "",
|
|
89
|
-
ariaLabel: "Edit
|
|
89
|
+
ariaLabel: "Edit Notes For You",
|
|
90
90
|
sourceEvent,
|
|
91
91
|
rerender: async () => {
|
|
92
92
|
renderTopbarFocus(lastRenderedBoard);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Custom typeahead dropdown for the ticket-detail drawer inputs (Add label,
|
|
2
|
+
// Set parent epic, Add related ticket). Replaces the native <datalist>, which
|
|
3
|
+
// browsers render in their own un-styleable popup, with a results list that
|
|
4
|
+
// reuses the topbar search styling (bold label + title + state badge). The
|
|
5
|
+
// component only fills the input on selection — the surrounding form's Add/Set
|
|
6
|
+
// button and Enter handler still own the actual mutation, so swapping the
|
|
7
|
+
// datalist for this is a pure presentation change.
|
|
8
|
+
|
|
9
|
+
const ACTIVE_CLASS = "is-active";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wire a styled suggestion dropdown onto a text input.
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* - getItems(): returns the full candidate list (re-read on each keystroke).
|
|
16
|
+
* - match(item, q): whether an item matches the lowercased query string.
|
|
17
|
+
* - renderItem(item): inner HTML for the .search-hit row.
|
|
18
|
+
* - valueOf(item): string written into the input when the item is chosen.
|
|
19
|
+
* - onSelect(item): optional hook fired after the value is filled.
|
|
20
|
+
* - emptyText: message shown when a non-empty query matches nothing.
|
|
21
|
+
* - minChars: minimum query length before the list opens (default 0).
|
|
22
|
+
* - maxItems: cap on rendered rows (default 8).
|
|
23
|
+
*
|
|
24
|
+
* Returns a teardown function (rarely needed — the drawer is re-rendered wholesale).
|
|
25
|
+
*/
|
|
26
|
+
export function attachTypeahead(
|
|
27
|
+
input,
|
|
28
|
+
{
|
|
29
|
+
getItems,
|
|
30
|
+
match,
|
|
31
|
+
renderItem,
|
|
32
|
+
valueOf,
|
|
33
|
+
onSelect,
|
|
34
|
+
emptyText = "No matches",
|
|
35
|
+
minChars = 0,
|
|
36
|
+
maxItems = 8,
|
|
37
|
+
},
|
|
38
|
+
) {
|
|
39
|
+
if (!input) return () => {};
|
|
40
|
+
|
|
41
|
+
// Appended to <body> (not the input's wrapper) so the ancestor
|
|
42
|
+
// .settings-card overflow:hidden can't clip it; positioned as fixed from the
|
|
43
|
+
// input's viewport rect and re-anchored on scroll/resize while open.
|
|
44
|
+
const list = document.createElement("div");
|
|
45
|
+
list.className = "search-results typeahead-results";
|
|
46
|
+
list.hidden = true;
|
|
47
|
+
list.setAttribute("role", "listbox");
|
|
48
|
+
list._ownerInput = input;
|
|
49
|
+
document.body.appendChild(list);
|
|
50
|
+
|
|
51
|
+
// The drawer re-renders wholesale, detaching the old inputs but leaving their
|
|
52
|
+
// body-level dropdowns behind. Sweep those orphans (owner no longer in the
|
|
53
|
+
// document) whenever a fresh typeahead mounts.
|
|
54
|
+
for (const el of document.querySelectorAll(".typeahead-results")) {
|
|
55
|
+
if (el !== list && el._ownerInput && !el._ownerInput.isConnected) {
|
|
56
|
+
el.remove();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let visible = [];
|
|
61
|
+
let activeIndex = -1;
|
|
62
|
+
let blurTimer = 0;
|
|
63
|
+
|
|
64
|
+
const position = () => {
|
|
65
|
+
const r = input.getBoundingClientRect();
|
|
66
|
+
list.style.left = `${r.left}px`;
|
|
67
|
+
list.style.width = `${r.width}px`;
|
|
68
|
+
list.style.top = `${r.bottom + 4}px`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const onReposition = () => {
|
|
72
|
+
if (list.hidden) return;
|
|
73
|
+
position();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const close = () => {
|
|
77
|
+
list.hidden = true;
|
|
78
|
+
list.innerHTML = "";
|
|
79
|
+
visible = [];
|
|
80
|
+
activeIndex = -1;
|
|
81
|
+
window.removeEventListener("scroll", onReposition, true);
|
|
82
|
+
window.removeEventListener("resize", onReposition);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const open = () => {
|
|
86
|
+
if (!list.hidden) return;
|
|
87
|
+
list.hidden = false;
|
|
88
|
+
window.addEventListener("scroll", onReposition, true);
|
|
89
|
+
window.addEventListener("resize", onReposition);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const render = () => {
|
|
93
|
+
const query = input.value.trim().toLowerCase();
|
|
94
|
+
if (query.length < minChars) {
|
|
95
|
+
close();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const all = getItems() || [];
|
|
99
|
+
visible = (query ? all.filter((item) => match(item, query)) : all).slice(
|
|
100
|
+
0,
|
|
101
|
+
maxItems,
|
|
102
|
+
);
|
|
103
|
+
if (visible.length === 0) {
|
|
104
|
+
// Only surface the empty state once the user has typed something —
|
|
105
|
+
// an empty focused field just shows the full (capped) list below.
|
|
106
|
+
if (query) {
|
|
107
|
+
open();
|
|
108
|
+
list.innerHTML = `<div class="search-hit typeahead-empty">${emptyText}</div>`;
|
|
109
|
+
position();
|
|
110
|
+
} else {
|
|
111
|
+
close();
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
activeIndex = -1;
|
|
116
|
+
open();
|
|
117
|
+
list.innerHTML = visible
|
|
118
|
+
.map(
|
|
119
|
+
(item, i) =>
|
|
120
|
+
`<div class="search-hit" role="option" data-index="${i}">${renderItem(item)}</div>`,
|
|
121
|
+
)
|
|
122
|
+
.join("");
|
|
123
|
+
position();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const setActive = (next) => {
|
|
127
|
+
const rows = list.querySelectorAll("[data-index]");
|
|
128
|
+
if (!rows.length) return;
|
|
129
|
+
activeIndex = (next + rows.length) % rows.length;
|
|
130
|
+
rows.forEach((row, i) => {
|
|
131
|
+
const on = i === activeIndex;
|
|
132
|
+
row.classList.toggle(ACTIVE_CLASS, on);
|
|
133
|
+
if (on) row.scrollIntoView({ block: "nearest" });
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const choose = (item) => {
|
|
138
|
+
if (!item) return;
|
|
139
|
+
input.value = valueOf(item);
|
|
140
|
+
// Let the host form react (e.g. the clear-button has-value toggle).
|
|
141
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
142
|
+
close();
|
|
143
|
+
input.focus();
|
|
144
|
+
if (onSelect) onSelect(item);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onInput = () => render();
|
|
148
|
+
const onFocus = () => render();
|
|
149
|
+
|
|
150
|
+
const onKeydown = (event) => {
|
|
151
|
+
if (list.hidden) return;
|
|
152
|
+
if (event.key === "ArrowDown") {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
setActive(activeIndex + 1);
|
|
155
|
+
} else if (event.key === "ArrowUp") {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
setActive(activeIndex - 1);
|
|
158
|
+
} else if (event.key === "Enter") {
|
|
159
|
+
// Only swallow Enter when a suggestion is highlighted; otherwise let the
|
|
160
|
+
// form submit the typed value as before.
|
|
161
|
+
if (activeIndex >= 0 && visible[activeIndex]) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
// stopImmediatePropagation so a sibling keydown listener on the same
|
|
164
|
+
// input (e.g. the label field's add-on-Enter) doesn't also fire.
|
|
165
|
+
event.stopImmediatePropagation();
|
|
166
|
+
choose(visible[activeIndex]);
|
|
167
|
+
}
|
|
168
|
+
} else if (event.key === "Escape") {
|
|
169
|
+
if (!list.hidden) {
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onBlur = () => {
|
|
177
|
+
blurTimer = window.setTimeout(close, 120);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// mousedown beats the input's blur, so the row click registers before close().
|
|
181
|
+
const onMouseDown = (event) => {
|
|
182
|
+
const row = event.target.closest("[data-index]");
|
|
183
|
+
if (!row) return;
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
window.clearTimeout(blurTimer);
|
|
186
|
+
choose(visible[Number(row.dataset.index)]);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
input.addEventListener("input", onInput);
|
|
190
|
+
input.addEventListener("focus", onFocus);
|
|
191
|
+
input.addEventListener("keydown", onKeydown);
|
|
192
|
+
input.addEventListener("blur", onBlur);
|
|
193
|
+
list.addEventListener("mousedown", onMouseDown);
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
window.clearTimeout(blurTimer);
|
|
197
|
+
window.removeEventListener("scroll", onReposition, true);
|
|
198
|
+
window.removeEventListener("resize", onReposition);
|
|
199
|
+
input.removeEventListener("input", onInput);
|
|
200
|
+
input.removeEventListener("focus", onFocus);
|
|
201
|
+
input.removeEventListener("keydown", onKeydown);
|
|
202
|
+
input.removeEventListener("blur", onBlur);
|
|
203
|
+
list.removeEventListener("mousedown", onMouseDown);
|
|
204
|
+
list.remove();
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Client-side update awareness. On boot we ask the server (GET /api/version)
|
|
2
|
+
// whether a newer Orbit exists; if so we light a dot on the Settings button and
|
|
3
|
+
// show a one-time toast. The Settings → Data "Version & Updates" card
|
|
4
|
+
// reuses fetchVersionInfo/getUpdateInfo/syncUpdateBadge for "Check now".
|
|
5
|
+
//
|
|
6
|
+
// Only the server-backed "full" edition has /api/version; the preview edition
|
|
7
|
+
// (IndexedDB, no server) and any fetch failure are swallowed silently — same
|
|
8
|
+
// best-effort posture as help-menu.js's version pill.
|
|
9
|
+
|
|
10
|
+
import { toast } from "./toast.js";
|
|
11
|
+
import { navigate } from "./router.js";
|
|
12
|
+
import { state } from "./state.js";
|
|
13
|
+
import { edition } from "./config.js";
|
|
14
|
+
|
|
15
|
+
const TOAST_SEEN_KEY = "orbit:updateToastSeen";
|
|
16
|
+
|
|
17
|
+
let cachedInfo = null;
|
|
18
|
+
|
|
19
|
+
/** Last /api/version payload (or null if not checked / unavailable). */
|
|
20
|
+
export function getUpdateInfo() {
|
|
21
|
+
return cachedInfo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fetch version info. `force` adds ?refresh=1 so the server re-hits GitHub. */
|
|
25
|
+
export async function fetchVersionInfo({ force = false } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`./api/version${force ? "?refresh=1" : ""}`, { cache: "no-store" });
|
|
28
|
+
if (!res.ok) return null;
|
|
29
|
+
cachedInfo = await res.json();
|
|
30
|
+
return cachedInfo;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Toggle the Settings-button dot to match the latest known state. */
|
|
37
|
+
export function syncUpdateBadge() {
|
|
38
|
+
const show = Boolean(cachedInfo?.updateAvailable);
|
|
39
|
+
const btn = document.getElementById("settingsBtn");
|
|
40
|
+
if (!btn) return;
|
|
41
|
+
btn.classList.toggle("has-update", show);
|
|
42
|
+
const dot = btn.querySelector(".topbar-update-dot");
|
|
43
|
+
if (dot) dot.hidden = !show;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run once on boot: check, badge, and toast (deduped per detected version). */
|
|
47
|
+
export async function checkForUpdate() {
|
|
48
|
+
if (edition !== "full") return;
|
|
49
|
+
const info = await fetchVersionInfo();
|
|
50
|
+
syncUpdateBadge();
|
|
51
|
+
if (!info?.updateAvailable || !info.latest) return;
|
|
52
|
+
|
|
53
|
+
if (localStorage.getItem(TOAST_SEEN_KEY) === info.latest) return;
|
|
54
|
+
localStorage.setItem(TOAST_SEEN_KEY, info.latest);
|
|
55
|
+
toast(`Orbit v${info.latest} is available →`, "info", 8000, {
|
|
56
|
+
onClick: () => navigate({ boardId: state.boardId, view: "settings", tab: "data" })
|
|
57
|
+
});
|
|
58
|
+
}
|