@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.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -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();