@crewhaus/hangar-ui 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
package/assets/js/dom.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection-safe DOM toolkit. Rendering is 100% createElement/textContent —
|
|
3
|
+
* markup-string assignment is banned app-wide (a unit test scans every
|
|
4
|
+
* embedded module for it) — so server data and agent output can never
|
|
5
|
+
* inject markup or script. SVG goes through createElementNS with attribute
|
|
6
|
+
* strings computed by pure helpers.
|
|
7
|
+
*
|
|
8
|
+
* Only function BODIES touch `document`; importing the module outside a
|
|
9
|
+
* browser is safe (the test suite imports it to assert exports).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseMarkdown } from "./markdown.js";
|
|
13
|
+
import { fmtRelativeTime } from "./util.js";
|
|
14
|
+
|
|
15
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build an element.
|
|
19
|
+
* attrs: { class, text, dataset: {}, style: {}, on<Event>: fn, <attr>: v }
|
|
20
|
+
* children: Node | string | array (nested) | null
|
|
21
|
+
* Strings become text nodes — never markup.
|
|
22
|
+
*/
|
|
23
|
+
export function el(tag, attrs, children) {
|
|
24
|
+
const node = document.createElement(tag);
|
|
25
|
+
if (attrs) {
|
|
26
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
27
|
+
if (v === null || v === undefined || v === false) continue;
|
|
28
|
+
if (k === "class") node.className = v;
|
|
29
|
+
else if (k === "text") node.textContent = v;
|
|
30
|
+
else if (k === "dataset") {
|
|
31
|
+
for (const [dk, dv] of Object.entries(v)) node.dataset[dk] = dv;
|
|
32
|
+
} else if (k === "style" && typeof v === "object") {
|
|
33
|
+
for (const [sk, sv] of Object.entries(v)) {
|
|
34
|
+
// custom properties (e.g. --accent) only apply via setProperty
|
|
35
|
+
if (sk.startsWith("--")) node.style.setProperty(sk, sv);
|
|
36
|
+
else node.style[sk] = sv;
|
|
37
|
+
}
|
|
38
|
+
} else if (k.startsWith("on") && typeof v === "function") {
|
|
39
|
+
node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
40
|
+
} else node.setAttribute(k, v === true ? "" : String(v));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
append(node, children);
|
|
44
|
+
return node;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A plain text node. */
|
|
48
|
+
export function text(value) {
|
|
49
|
+
return document.createTextNode(String(value));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Append Node | string | nested arrays; null/undefined are skipped. */
|
|
53
|
+
export function append(node, children) {
|
|
54
|
+
if (children === null || children === undefined) return node;
|
|
55
|
+
if (Array.isArray(children)) {
|
|
56
|
+
for (const c of children) append(node, c);
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
if (typeof Node !== "undefined" && children instanceof Node) {
|
|
60
|
+
node.appendChild(children);
|
|
61
|
+
return node;
|
|
62
|
+
}
|
|
63
|
+
node.appendChild(text(children));
|
|
64
|
+
return node;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove all children. Returns the node. */
|
|
68
|
+
export function clear(node) {
|
|
69
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An SVG element (children must already be nodes). */
|
|
74
|
+
export function svgEl(tag, attrs, children) {
|
|
75
|
+
const node = document.createElementNS(SVGNS, tag);
|
|
76
|
+
if (attrs) {
|
|
77
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
78
|
+
if (v !== null && v !== undefined && v !== false) node.setAttribute(k, String(v));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(children)) {
|
|
82
|
+
for (const c of children) node.appendChild(c);
|
|
83
|
+
}
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Traffic-light dot — ALWAYS paired with a text label (never color alone).
|
|
89
|
+
* state: "ok" | "warn" | "bad" | "unknown" | "off"
|
|
90
|
+
*/
|
|
91
|
+
export function dot(state, label) {
|
|
92
|
+
return el("span", { class: `dot-pair dot-${state}` }, [
|
|
93
|
+
el("span", { class: "dot", "aria-hidden": "true" }),
|
|
94
|
+
el("span", { class: "dot-label", text: label }),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Standard empty state. "Absence is not an error": a 404/empty payload
|
|
100
|
+
* renders as "nothing yet" and names the CLI verb that creates the data.
|
|
101
|
+
*/
|
|
102
|
+
export function emptyState(message, verb) {
|
|
103
|
+
return el("div", { class: "empty" }, [
|
|
104
|
+
el("div", { class: "empty-msg", text: message }),
|
|
105
|
+
verb
|
|
106
|
+
? el("div", { class: "empty-verb" }, ["Run ", el("code", { text: verb }), " to create it."])
|
|
107
|
+
: null,
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Loading skeleton (opacity pulse only — the one animation in the app). */
|
|
112
|
+
export function skeleton(lines = 3) {
|
|
113
|
+
const wrap = el("div", { class: "skeleton", "aria-label": "loading", role: "status" });
|
|
114
|
+
for (let i = 0; i < lines; i += 1) wrap.appendChild(el("div", { class: "skel-line" }));
|
|
115
|
+
return wrap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** "as of 3m ago" chip for cached figures (honest-latency invariant). */
|
|
119
|
+
export function asOf(cachedAtIso) {
|
|
120
|
+
if (!cachedAtIso) return null;
|
|
121
|
+
return el("span", {
|
|
122
|
+
class: "asof",
|
|
123
|
+
title: cachedAtIso,
|
|
124
|
+
text: `as of ${fmtRelativeTime(cachedAtIso, Date.now())}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Error/notice toast — the "no write ever fails silently" affordance. One
|
|
130
|
+
* toast at a time (a newer message replaces the older); auto-dismisses, and
|
|
131
|
+
* `role="alert"` makes screen readers announce it.
|
|
132
|
+
*/
|
|
133
|
+
export function toast(message, kind = "error") {
|
|
134
|
+
for (const old of document.querySelectorAll(".toast")) old.remove();
|
|
135
|
+
const node = el("div", {
|
|
136
|
+
class: `toast toast-${kind === "error" ? "error" : "info"}`,
|
|
137
|
+
role: "alert",
|
|
138
|
+
text: message,
|
|
139
|
+
});
|
|
140
|
+
document.body.appendChild(node);
|
|
141
|
+
setTimeout(() => node.remove(), 6000);
|
|
142
|
+
return node;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Click-to-copy button (clipboard API; silently no-ops when unavailable). */
|
|
146
|
+
export function copyBtn(value, label = "copy") {
|
|
147
|
+
const btn = el("button", { class: "btn btn-ghost btn-copy", type: "button", text: label });
|
|
148
|
+
btn.addEventListener("click", async () => {
|
|
149
|
+
try {
|
|
150
|
+
await navigator.clipboard.writeText(value);
|
|
151
|
+
btn.textContent = "copied";
|
|
152
|
+
btn.classList.add("ok");
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
btn.textContent = label;
|
|
155
|
+
btn.classList.remove("ok");
|
|
156
|
+
}, 1200);
|
|
157
|
+
} catch {
|
|
158
|
+
btn.textContent = "copy failed";
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return btn;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A native collapsible (<details>) card. */
|
|
165
|
+
export function collapsible(summaryChildren, bodyChildren, open = false) {
|
|
166
|
+
return el("details", { class: "fold", open: open || null }, [
|
|
167
|
+
el("summary", { class: "fold-summary" }, summaryChildren),
|
|
168
|
+
el("div", { class: "fold-body" }, bodyChildren),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Line-numbered read-only code view (one div per line, all textContent). */
|
|
173
|
+
export function numberedCode(source, label = "code") {
|
|
174
|
+
const wrap = el("div", { class: "codeview", role: "region", "aria-label": label });
|
|
175
|
+
const lines = String(source ?? "").split("\n");
|
|
176
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
177
|
+
wrap.appendChild(
|
|
178
|
+
el("div", { class: "codeline" }, [
|
|
179
|
+
el("span", { class: "ln", text: String(i + 1) }),
|
|
180
|
+
el("span", { class: "lc", text: lines[i] === "" ? " " : lines[i] }),
|
|
181
|
+
]),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return wrap;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Pretty-printed JSON in a <pre> (for the Raw toggles). */
|
|
188
|
+
export function jsonPre(value) {
|
|
189
|
+
let body;
|
|
190
|
+
try {
|
|
191
|
+
body = JSON.stringify(value, null, 2);
|
|
192
|
+
} catch {
|
|
193
|
+
body = String(value);
|
|
194
|
+
}
|
|
195
|
+
return el("pre", { class: "rawjson", text: body });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Render parsed markdown tokens (see markdown.js) into a container div. */
|
|
199
|
+
export function mdBlocks(source) {
|
|
200
|
+
const wrap = el("div", { class: "md" });
|
|
201
|
+
for (const block of parseMarkdown(source)) {
|
|
202
|
+
if (block.type === "heading") {
|
|
203
|
+
const level = Math.min(6, Math.max(1, block.level));
|
|
204
|
+
wrap.appendChild(el(`h${level}`, null, spansToNodes(block.spans)));
|
|
205
|
+
} else if (block.type === "para") {
|
|
206
|
+
wrap.appendChild(el("p", null, spansToNodes(block.spans)));
|
|
207
|
+
} else if (block.type === "list") {
|
|
208
|
+
wrap.appendChild(
|
|
209
|
+
el(
|
|
210
|
+
block.ordered ? "ol" : "ul",
|
|
211
|
+
null,
|
|
212
|
+
block.items.map((spans) => el("li", null, spansToNodes(spans))),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
} else if (block.type === "code") {
|
|
216
|
+
wrap.appendChild(el("pre", { class: "mdcode" }, [el("code", { text: block.text })]));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return wrap;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function spansToNodes(spans) {
|
|
223
|
+
return spans.map((s) => {
|
|
224
|
+
if (s.type === "bold") return el("strong", { text: s.text });
|
|
225
|
+
if (s.type === "code") return el("code", { text: s.text });
|
|
226
|
+
return text(s.text);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HM-190 — keyboard triage, at the APP level.
|
|
3
|
+
*
|
|
4
|
+
* The inbox half of this item shipped with M2: `triageKey` in
|
|
5
|
+
* `supervision.js` owns j/k movement, the one-key verdicts, the `.` row menu
|
|
6
|
+
* and the `?` bindings sheet inside the approvals and review lists, and
|
|
7
|
+
* `views/inbox.js` mounts it. M4 adds the layer above that — ⌘K, Escape, and
|
|
8
|
+
* a bindings sheet that works on screens with no inbox on them — and, more
|
|
9
|
+
* importantly, decides WHO owns a key when both layers want it.
|
|
10
|
+
*
|
|
11
|
+
* The whole decision is one pure reducer, {@link globalKey}, so the answer
|
|
12
|
+
* to "does `?` open the global sheet or the inbox's?" is a unit test rather
|
|
13
|
+
* than an argument with a browser.
|
|
14
|
+
*
|
|
15
|
+
* THE OWNERSHIP RULES, in the order they apply:
|
|
16
|
+
*
|
|
17
|
+
* 1. A key pressed inside an INPUT, TEXTAREA or contenteditable belongs to
|
|
18
|
+
* the field — an operator typing a spec edit must never have a modal
|
|
19
|
+
* stolen out from under them by a shortcut. There is exactly ONE
|
|
20
|
+
* exception, and it is the omnibox's own input: the overlay focuses it
|
|
21
|
+
* the instant it opens, so from that instant every key arrives with
|
|
22
|
+
* `inField: true`. Bailing unconditionally would make Escape and the
|
|
23
|
+
* ⌘K toggle unreachable and turn a keyboard feature into a keyboard
|
|
24
|
+
* TRAP — the only way out a mouse click on the backdrop. So while the
|
|
25
|
+
* omnibox is open, the two keys that CLOSE it are claimed even in a
|
|
26
|
+
* field, and nothing else is: typing a query still belongs to the
|
|
27
|
+
* field.
|
|
28
|
+
* 2. ⌘K / Ctrl-K opens the omnibox from anywhere else, and toggles it
|
|
29
|
+
* closed when it is already open.
|
|
30
|
+
* 3. While the omnibox is OPEN it owns every key: the list beneath it must
|
|
31
|
+
* not scroll while a query is being typed.
|
|
32
|
+
* 4. Escape closes the topmost overlay — omnibox first, then the sheet.
|
|
33
|
+
* 5. `?` toggles the bindings sheet, but ONLY when no inbox is mounted;
|
|
34
|
+
* an inbox already binds `?` for its own sheet, and two sheets opening
|
|
35
|
+
* on one keypress is the bug this rule exists to prevent.
|
|
36
|
+
*
|
|
37
|
+
* Nothing here touches `document`; the caller normalizes a KeyboardEvent
|
|
38
|
+
* with {@link keyShape} and applies the returned effect.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The global bindings, as data — the sheet renders from this list. */
|
|
42
|
+
export const GLOBAL_BINDINGS = [
|
|
43
|
+
{ keys: "⌘K / Ctrl-K", does: "open the omnibox (harnesses, sessions, wiki, runs, actions)" },
|
|
44
|
+
{ keys: "?", does: "show or hide this bindings sheet" },
|
|
45
|
+
{ keys: "Esc", does: "close the omnibox, then this sheet" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** The inbox bindings, repeated in the global sheet so one overlay answers
|
|
49
|
+
* "what can I press here?" wherever it is opened. */
|
|
50
|
+
export const INBOX_BINDINGS = [
|
|
51
|
+
{ keys: "j / ↓", does: "move down a row" },
|
|
52
|
+
{ keys: "k / ↑", does: "move up a row" },
|
|
53
|
+
{ keys: "g / d", does: "grant or deny (approvals inbox)" },
|
|
54
|
+
{ keys: "u / d / p / f", does: "up, down, pass or fail (review queue)" },
|
|
55
|
+
{ keys: ".", does: "open the row menu" },
|
|
56
|
+
{ keys: "?", does: "show the inbox's own bindings" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Fields that own their keystrokes. */
|
|
60
|
+
const FIELD_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
|
61
|
+
|
|
62
|
+
/** The omnibox chord, in both flavours. */
|
|
63
|
+
function isOmniChord(k) {
|
|
64
|
+
return (k.meta === true || k.ctrl === true) && (k.key === "k" || k.key === "K");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalize a KeyboardEvent into the plain record the reducer takes. Pure
|
|
69
|
+
* apart from reading the event; kept here so tests describe key presses as
|
|
70
|
+
* data rather than constructing DOM events.
|
|
71
|
+
*/
|
|
72
|
+
export function keyShape(event) {
|
|
73
|
+
const target = event?.target ?? null;
|
|
74
|
+
const tag = target && typeof target.tagName === "string" ? target.tagName : "";
|
|
75
|
+
return {
|
|
76
|
+
key: typeof event?.key === "string" ? event.key : "",
|
|
77
|
+
meta: event?.metaKey === true,
|
|
78
|
+
ctrl: event?.ctrlKey === true,
|
|
79
|
+
alt: event?.altKey === true,
|
|
80
|
+
shift: event?.shiftKey === true,
|
|
81
|
+
inField: FIELD_TAGS.has(tag) || target?.isContentEditable === true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The initial overlay state. */
|
|
86
|
+
export function initialKeyState() {
|
|
87
|
+
return { omniboxOpen: false, sheetOpen: false, inboxMounted: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The reducer. Returns `{ state, effect, claimed }`:
|
|
92
|
+
*
|
|
93
|
+
* - `state` — the next overlay state;
|
|
94
|
+
* - `effect` — `"open-omnibox" | "close-omnibox" | "toggle-sheet" |
|
|
95
|
+
* "close-sheet" | null`;
|
|
96
|
+
* - `claimed` — true when the app handled the key and the caller should
|
|
97
|
+
* `preventDefault()`. A key the app did NOT claim must fall
|
|
98
|
+
* through untouched, which is what lets the inbox reducer
|
|
99
|
+
* keep working on the same screen.
|
|
100
|
+
*/
|
|
101
|
+
export function globalKey(state, shape) {
|
|
102
|
+
const s = { ...initialKeyState(), ...(state && typeof state === "object" ? state : {}) };
|
|
103
|
+
const k = { key: "", meta: false, ctrl: false, alt: false, inField: false, ...(shape ?? {}) };
|
|
104
|
+
const keep = { state: s, effect: null, claimed: false };
|
|
105
|
+
|
|
106
|
+
// 1. A field owns its keys — including ⌘K — except the two that close an
|
|
107
|
+
// open omnibox. The overlay focuses its own input, so without this
|
|
108
|
+
// exception it would swallow both of its own exits.
|
|
109
|
+
const closesOmnibox = s.omniboxOpen && (k.key === "Escape" || isOmniChord(k));
|
|
110
|
+
if (k.inField && !closesOmnibox) return keep;
|
|
111
|
+
|
|
112
|
+
// 2. ⌘K / Ctrl-K toggles the omnibox.
|
|
113
|
+
if (isOmniChord(k)) {
|
|
114
|
+
return s.omniboxOpen
|
|
115
|
+
? { state: { ...s, omniboxOpen: false }, effect: "close-omnibox", claimed: true }
|
|
116
|
+
: {
|
|
117
|
+
state: { ...s, omniboxOpen: true, sheetOpen: false },
|
|
118
|
+
effect: "open-omnibox",
|
|
119
|
+
claimed: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Any other modifier chord belongs to the browser (⌘R, ⌘L, ⌘T…).
|
|
123
|
+
if (k.meta || k.ctrl || k.alt) return keep;
|
|
124
|
+
|
|
125
|
+
// 3. While the omnibox is open it owns everything except Escape, which
|
|
126
|
+
// rule 4 handles.
|
|
127
|
+
if (s.omniboxOpen) {
|
|
128
|
+
if (k.key === "Escape") {
|
|
129
|
+
return { state: { ...s, omniboxOpen: false }, effect: "close-omnibox", claimed: true };
|
|
130
|
+
}
|
|
131
|
+
return { state: s, effect: null, claimed: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. Escape closes the sheet when it is the topmost overlay.
|
|
135
|
+
if (k.key === "Escape") {
|
|
136
|
+
return s.sheetOpen
|
|
137
|
+
? { state: { ...s, sheetOpen: false }, effect: "close-sheet", claimed: true }
|
|
138
|
+
: keep;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 5. `?` is the inbox's when an inbox is mounted.
|
|
142
|
+
if (k.key === "?") {
|
|
143
|
+
if (s.inboxMounted) return keep;
|
|
144
|
+
return { state: { ...s, sheetOpen: !s.sheetOpen }, effect: "toggle-sheet", claimed: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return keep;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Every binding the sheet shows, grouped. Pure — the overlay renders it. */
|
|
151
|
+
export function bindingSheet(inboxMounted = false) {
|
|
152
|
+
return [
|
|
153
|
+
{ section: "Anywhere", bindings: GLOBAL_BINDINGS },
|
|
154
|
+
{
|
|
155
|
+
section: inboxMounted ? "This inbox" : "Inboxes (approvals, review)",
|
|
156
|
+
bindings: INBOX_BINDINGS,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SAFE markdown parser for the wiki reader: headings, bold, inline
|
|
3
|
+
* code, unordered/ordered lists, and fenced code blocks — nothing else, and
|
|
4
|
+
* explicitly NO raw-HTML passthrough. The parser emits a token tree; the DOM
|
|
5
|
+
* layer renders every token via `textContent`, so `<script>` in an article
|
|
6
|
+
* body stays literal text by construction.
|
|
7
|
+
*
|
|
8
|
+
* DOM-free and pure so it runs identically in the browser and under
|
|
9
|
+
* `bun test`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Parse inline spans: `**bold**` and `` `code` ``; the rest is plain text. */
|
|
13
|
+
export function parseInline(src) {
|
|
14
|
+
const s = String(src ?? "");
|
|
15
|
+
const spans = [];
|
|
16
|
+
let buf = "";
|
|
17
|
+
let i = 0;
|
|
18
|
+
const flush = () => {
|
|
19
|
+
if (buf !== "") {
|
|
20
|
+
spans.push({ type: "text", text: buf });
|
|
21
|
+
buf = "";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
while (i < s.length) {
|
|
25
|
+
if (s.startsWith("**", i)) {
|
|
26
|
+
const end = s.indexOf("**", i + 2);
|
|
27
|
+
if (end > i + 1) {
|
|
28
|
+
flush();
|
|
29
|
+
spans.push({ type: "bold", text: s.slice(i + 2, end) });
|
|
30
|
+
i = end + 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (s[i] === "`") {
|
|
35
|
+
const end = s.indexOf("`", i + 1);
|
|
36
|
+
if (end > i) {
|
|
37
|
+
flush();
|
|
38
|
+
spans.push({ type: "code", text: s.slice(i + 1, end) });
|
|
39
|
+
i = end + 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
buf += s[i];
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
flush();
|
|
47
|
+
return spans;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
51
|
+
const UL_ITEM_RE = /^\s*[-*]\s+(.*)$/;
|
|
52
|
+
const OL_ITEM_RE = /^\s*\d+[.)]\s+(.*)$/;
|
|
53
|
+
const FENCE_RE = /^```/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse a markdown source string into a flat list of block tokens:
|
|
57
|
+
* { type: "heading", level, spans }
|
|
58
|
+
* { type: "para", spans }
|
|
59
|
+
* { type: "list", ordered, items: spans[][] }
|
|
60
|
+
* { type: "code", text }
|
|
61
|
+
* Unrecognized syntax degrades to paragraph text; raw HTML is never parsed.
|
|
62
|
+
*/
|
|
63
|
+
export function parseMarkdown(src) {
|
|
64
|
+
const lines = String(src ?? "").split("\n");
|
|
65
|
+
const blocks = [];
|
|
66
|
+
let para = [];
|
|
67
|
+
let list = null;
|
|
68
|
+
const flushPara = () => {
|
|
69
|
+
if (para.length > 0) {
|
|
70
|
+
blocks.push({ type: "para", spans: parseInline(para.join(" ")) });
|
|
71
|
+
para = [];
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const flushList = () => {
|
|
75
|
+
if (list !== null) {
|
|
76
|
+
blocks.push(list);
|
|
77
|
+
list = null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < lines.length) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
if (FENCE_RE.test(line.trim())) {
|
|
84
|
+
flushPara();
|
|
85
|
+
flushList();
|
|
86
|
+
const body = [];
|
|
87
|
+
i += 1;
|
|
88
|
+
while (i < lines.length && !FENCE_RE.test(lines[i].trim())) {
|
|
89
|
+
body.push(lines[i]);
|
|
90
|
+
i += 1;
|
|
91
|
+
}
|
|
92
|
+
i += 1; // skip the closing fence (or run off the end on an unclosed one)
|
|
93
|
+
blocks.push({ type: "code", text: body.join("\n") });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const heading = line.match(HEADING_RE);
|
|
97
|
+
if (heading) {
|
|
98
|
+
flushPara();
|
|
99
|
+
flushList();
|
|
100
|
+
blocks.push({ type: "heading", level: heading[1].length, spans: parseInline(heading[2]) });
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const ul = line.match(UL_ITEM_RE);
|
|
105
|
+
const ol = ul === null ? line.match(OL_ITEM_RE) : null;
|
|
106
|
+
if (ul || ol) {
|
|
107
|
+
flushPara();
|
|
108
|
+
const ordered = ol !== null;
|
|
109
|
+
const item = parseInline((ul ?? ol)[1]);
|
|
110
|
+
if (list === null || list.ordered !== ordered) {
|
|
111
|
+
flushList();
|
|
112
|
+
list = { type: "list", ordered, items: [] };
|
|
113
|
+
}
|
|
114
|
+
list.items.push(item);
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (line.trim() === "") {
|
|
119
|
+
flushPara();
|
|
120
|
+
flushList();
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
flushList();
|
|
125
|
+
para.push(line.trim());
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
flushPara();
|
|
129
|
+
flushList();
|
|
130
|
+
return blocks;
|
|
131
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HM-183's console half — the ONE notification poll.
|
|
3
|
+
*
|
|
4
|
+
* The manager runs no timer of its own: the rules are evaluated when the
|
|
5
|
+
* console asks for the notification state, and `GET /api/notifications` IS
|
|
6
|
+
* that evaluation. Which makes an ordinary-looking read a MUTATION in one
|
|
7
|
+
* respect — every delivery it returns is added to the server's dedupe set
|
|
8
|
+
* and will never be returned again — and that turns two innocent facts into
|
|
9
|
+
* a bug:
|
|
10
|
+
*
|
|
11
|
+
* - a console that only asks at boot never notifies anybody, however long
|
|
12
|
+
* it is left open, which is exactly the case the one default-on rule
|
|
13
|
+
* (a parked approval) exists for; and
|
|
14
|
+
* - a second caller — the Settings screen drawing its rules table — can
|
|
15
|
+
* CONSUME a delivery that nothing renders, so the toast for it can
|
|
16
|
+
* never appear again in that manager process.
|
|
17
|
+
*
|
|
18
|
+
* So the console asks in exactly one place: here. The loop below is the only
|
|
19
|
+
* caller of `api.notifications()` in the asset tree (a unit test pins that),
|
|
20
|
+
* a view that needs the state {@link NotificationPoll.subscribe|subscribes}
|
|
21
|
+
* or asks for the shared {@link NotificationPoll.current|snapshot} rather
|
|
22
|
+
* than issuing its own GET, and a payload a mutating route already
|
|
23
|
+
* evaluated — the PUT that saves the rules answers with the same view the
|
|
24
|
+
* GET does, deliveries and all — is folded back in through
|
|
25
|
+
* {@link NotificationPoll.accept}. One rule holds it together: nothing
|
|
26
|
+
* consumes a delivery without showing it.
|
|
27
|
+
*
|
|
28
|
+
* The factory is exported, with its loader and its timer injectable, so the
|
|
29
|
+
* loop can be driven to completion in a unit test with no browser and no
|
|
30
|
+
* clock.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { api } from "./api.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How often an open console re-evaluates the rules. Each poll is a
|
|
37
|
+
* fleet-wide signal scan on the server, so this is a BADGE cadence, not a
|
|
38
|
+
* live feed: often enough that a parked approval surfaces while the
|
|
39
|
+
* operator is still at the keyboard, rare enough that a hundred-harness
|
|
40
|
+
* fleet is not rescanned every second.
|
|
41
|
+
*/
|
|
42
|
+
export const NOTIFICATION_POLL_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/** The deliveries in a payload, defensively. */
|
|
45
|
+
function deliveredOf(payload) {
|
|
46
|
+
return Array.isArray(payload?.delivered) ? payload.delivered : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a poll. `load` defaults to the real route; `schedule`/`cancel`
|
|
51
|
+
* default to the browser timers. Returns:
|
|
52
|
+
*
|
|
53
|
+
* - `start()` — poll now, then every `intervalMs` (idempotent);
|
|
54
|
+
* - `stop()` — stop the loop, keeping the last snapshot;
|
|
55
|
+
* - `subscribe(fn)` — `fn(payload, delivered)` on every snapshot seen,
|
|
56
|
+
* polled or folded in; returns an unsubscribe;
|
|
57
|
+
* - `snapshot()` — the last payload (or null) without a request;
|
|
58
|
+
* - `current()` — the state a view should render, awaiting the poll in
|
|
59
|
+
* flight or reusing the snapshot before it will start
|
|
60
|
+
* one;
|
|
61
|
+
* - `refresh()` — force one evaluation now, coalesced;
|
|
62
|
+
* - `accept(payload)` — fold in a payload a mutating route returned.
|
|
63
|
+
*/
|
|
64
|
+
export function createNotificationPoll(options = {}) {
|
|
65
|
+
const load = typeof options.load === "function" ? options.load : () => api.notifications();
|
|
66
|
+
const intervalMs = Number.isFinite(options.intervalMs)
|
|
67
|
+
? Number(options.intervalMs)
|
|
68
|
+
: NOTIFICATION_POLL_MS;
|
|
69
|
+
const schedule =
|
|
70
|
+
typeof options.schedule === "function" ? options.schedule : (fn, ms) => setTimeout(fn, ms);
|
|
71
|
+
const cancel =
|
|
72
|
+
typeof options.cancel === "function" ? options.cancel : (handle) => clearTimeout(handle);
|
|
73
|
+
|
|
74
|
+
const listeners = new Set();
|
|
75
|
+
let snapshot = null;
|
|
76
|
+
let inFlight = null;
|
|
77
|
+
let timer = null;
|
|
78
|
+
let running = false;
|
|
79
|
+
|
|
80
|
+
const emit = (payload, delivered) => {
|
|
81
|
+
for (const listener of [...listeners]) {
|
|
82
|
+
try {
|
|
83
|
+
listener(payload, delivered);
|
|
84
|
+
} catch {
|
|
85
|
+
// One listener throwing must never stop the loop that feeds the
|
|
86
|
+
// others — a broken toast cannot be allowed to break the badge.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Record a payload and tell everyone. The ONLY place `snapshot` moves. */
|
|
92
|
+
const fold = (payload) => {
|
|
93
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
94
|
+
snapshot = payload;
|
|
95
|
+
emit(payload, deliveredOf(payload));
|
|
96
|
+
return payload;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const poll = () => {
|
|
100
|
+
// Coalesce: two callers asking at once must never become two evaluating
|
|
101
|
+
// GETs, because the second would find the first one's deliveries already
|
|
102
|
+
// deduped away and would show nothing.
|
|
103
|
+
if (inFlight !== null) return inFlight;
|
|
104
|
+
const request = Promise.resolve()
|
|
105
|
+
.then(load)
|
|
106
|
+
.then(fold, () => null) // a failed poll is not a reason to stop polling
|
|
107
|
+
.then((result) => {
|
|
108
|
+
inFlight = null;
|
|
109
|
+
return result;
|
|
110
|
+
});
|
|
111
|
+
inFlight = request;
|
|
112
|
+
return request;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const tick = () => {
|
|
116
|
+
timer = null;
|
|
117
|
+
poll().then(() => {
|
|
118
|
+
if (running && timer === null) timer = schedule(tick, intervalMs);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
start() {
|
|
124
|
+
if (running) return;
|
|
125
|
+
running = true;
|
|
126
|
+
tick();
|
|
127
|
+
},
|
|
128
|
+
stop() {
|
|
129
|
+
running = false;
|
|
130
|
+
if (timer !== null) {
|
|
131
|
+
cancel(timer);
|
|
132
|
+
timer = null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
subscribe(listener) {
|
|
136
|
+
listeners.add(listener);
|
|
137
|
+
return () => listeners.delete(listener);
|
|
138
|
+
},
|
|
139
|
+
snapshot: () => snapshot,
|
|
140
|
+
current() {
|
|
141
|
+
if (inFlight !== null) return inFlight;
|
|
142
|
+
if (snapshot !== null) return Promise.resolve(snapshot);
|
|
143
|
+
return poll();
|
|
144
|
+
},
|
|
145
|
+
refresh: () => poll(),
|
|
146
|
+
accept: (payload) => fold(payload),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The console's one poll. Views import THIS; the factory above exists so a
|
|
151
|
+
* test can drive the same loop with its own clock. */
|
|
152
|
+
export const notifications = createNotificationPoll();
|