@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,734 @@
1
+ /**
2
+ * Hangar console entry. Boot order: token bootstrap (read `#t=<token>`
3
+ * from the URL fragment into sessionStorage and strip it via replaceState),
4
+ * theme, shell render, router start, version footer. Booting is guarded on
5
+ * `document` existing so the module can be imported by tests.
6
+ *
7
+ * M2 adds the fleet-wide nav (Runs, Approvals, Review, Activity) with live
8
+ * counts, and gives the harness header its supervision pill, bundle
9
+ * freshness badge, control availability and — when the spawn plan cannot be
10
+ * built — the remedy as a BUTTON rather than an error.
11
+ *
12
+ * M3 adds the detail tabs (Datasets, Feedback, Credentials, Channels,
13
+ * Security, Thredz, Inspect, Dev & MCP), stacks an M3 half beneath the
14
+ * existing Spec, Evals and Memory panels, and puts the fleet credential
15
+ * matrix, feedback rollup and Thredz explorer in the rail.
16
+ *
17
+ * The tab strip is where the shape × panel matrix is applied: the target
18
+ * shape decides which tabs are live and which render disabled with the
19
+ * reason (`tabAvailability` in router.js owns the table). Gating stops at
20
+ * the strip — a deep link to an off-matrix tab still resolves and the
21
+ * screen still answers, so no URL an operator saved goes dead.
22
+ */
23
+
24
+ import { ApiError, api, onUnauthorized, setToken } from "./api.js";
25
+ import { clear, copyBtn, dot, el, skeleton, toast } from "./dom.js";
26
+ import { bindingSheet, globalKey, initialKeyState, keyShape } from "./keys.js";
27
+ import { notifications } from "./notify.js";
28
+ import { openOmnibox } from "./omnibox.js";
29
+ import {
30
+ hrefGlobal,
31
+ hrefHarness,
32
+ hrefLibrary,
33
+ parseRoute,
34
+ startRouter,
35
+ tabAvailability,
36
+ } from "./router.js";
37
+ import { shapeAccent, shapeLabel } from "./shapes.js";
38
+ import { procRow } from "./supervision.js";
39
+ import { renderActivity } from "./views/activity.js";
40
+ import { renderApprovals } from "./views/approvals.js";
41
+ import { renderChannels } from "./views/channels.js";
42
+ import { renderCosts } from "./views/costs.js";
43
+ import { renderCredentialsMatrix, renderCreds } from "./views/creds.js";
44
+ import { renderData } from "./views/data.js";
45
+ import { renderDeploy } from "./views/deploy.js";
46
+ import { renderEvalsLab } from "./views/evals-lab.js";
47
+ import { renderEvals } from "./views/evals.js";
48
+ import { renderFeedback, renderFeedbackBoard } from "./views/feedback.js";
49
+ import { renderHealthBoard, renderHealthCard } from "./views/health.js";
50
+ import { renderInspect } from "./views/inspect.js";
51
+ import { renderLibrary } from "./views/library.js";
52
+ import { renderMemoryFabric } from "./views/memory-fabric.js";
53
+ import { renderMemory } from "./views/memory.js";
54
+ import { renderOverview } from "./views/overview.js";
55
+ import { renderPanes } from "./views/panes.js";
56
+ import { renderReview } from "./views/review.js";
57
+ import { renderRuns, renderRunsBoard } from "./views/runs.js";
58
+ import { renderRuntime } from "./views/runtime.js";
59
+ import { renderSchedulers } from "./views/schedulers.js";
60
+ import { renderSecurity } from "./views/security.js";
61
+ import { renderSessions } from "./views/sessions.js";
62
+ import { renderSettings } from "./views/settings.js";
63
+ import { renderSpecEdit } from "./views/spec-edit.js";
64
+ import { renderSpec } from "./views/spec.js";
65
+ import { renderThredz, renderThredzGlobal } from "./views/thredz.js";
66
+ import { renderTokenScreen } from "./views/token.js";
67
+
68
+ const THEME_KEY = "hangar.theme";
69
+ const TAB_LABELS = {
70
+ overview: "Overview",
71
+ spec: "Spec",
72
+ runs: "Runs",
73
+ schedulers: "Schedulers",
74
+ sessions: "Sessions",
75
+ evals: "Evals",
76
+ memory: "Memory",
77
+ data: "Datasets",
78
+ feedback: "Feedback",
79
+ costs: "Costs",
80
+ creds: "Credentials",
81
+ channels: "Channels",
82
+ security: "Security",
83
+ thredz: "Thredz",
84
+ deploy: "Deployments",
85
+ inspect: "Inspect",
86
+ dev: "Dev & MCP",
87
+ panes: "Plugin panes",
88
+ };
89
+
90
+ /** The fleet-wide screens in the header rail, with their count sources. */
91
+ const NAV = [
92
+ { view: "runs", label: "Runs & daemons" },
93
+ { view: "approvals", label: "Approvals" },
94
+ { view: "review", label: "Review" },
95
+ { view: "activity", label: "Activity" },
96
+ { view: "health", label: "Health" },
97
+ { view: "credentials", label: "Credentials" },
98
+ { view: "feedback", label: "Feedback" },
99
+ { view: "thredz", label: "Thredz" },
100
+ { view: "settings", label: "Settings" },
101
+ ];
102
+
103
+ let viewRoot = null;
104
+ let footRoot = null;
105
+ let navRoot = null;
106
+ let bannerRoot = null;
107
+ let dispatchSeq = 0;
108
+ let navCounts = {};
109
+ // M4 (HM-190): overlay ownership. `inboxMounted` is set per navigation so
110
+ // `?` goes to the inbox's own sheet on the two screens that bind it, and to
111
+ // the app's sheet everywhere else.
112
+ let keyState = initialKeyState();
113
+ let omnibox = null;
114
+ let sheetNode = null;
115
+ let notificationsStarted = false;
116
+
117
+ /** Move a `#t=<token>` fragment into sessionStorage and off the URL. */
118
+ function bootstrapToken() {
119
+ const m = window.location.hash.match(/^#t=(.+)$/);
120
+ if (m === null) return;
121
+ let token = m[1];
122
+ try {
123
+ token = decodeURIComponent(token);
124
+ } catch {
125
+ // keep the raw fragment
126
+ }
127
+ setToken(token);
128
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#/`);
129
+ }
130
+
131
+ function applyTheme(theme) {
132
+ document.documentElement.dataset.theme = theme === "light" ? "light" : "dark";
133
+ }
134
+
135
+ function initTheme() {
136
+ let stored = null;
137
+ try {
138
+ stored = localStorage.getItem(THEME_KEY);
139
+ } catch {
140
+ // storage unavailable — default applies
141
+ }
142
+ applyTheme(stored === "light" ? "light" : "dark");
143
+ }
144
+
145
+ function toggleTheme() {
146
+ const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
147
+ applyTheme(next);
148
+ try {
149
+ localStorage.setItem(THEME_KEY, next);
150
+ } catch {
151
+ // fine — the toggle just won't persist
152
+ }
153
+ }
154
+
155
+ function renderShell(appRoot) {
156
+ clear(appRoot);
157
+ const themeBtn = el("button", {
158
+ class: "btn btn-ghost",
159
+ type: "button",
160
+ text: "Theme",
161
+ title: "Toggle light/dark",
162
+ onClick: toggleTheme,
163
+ });
164
+ navRoot = el("nav", { class: "app-nav", "aria-label": "fleet screens" });
165
+ // A visible affordance for ⌘K: a keyboard-only feature is a feature only
166
+ // the person who built it knows about.
167
+ const searchBtn = el("button", {
168
+ class: "btn btn-ghost omni-open",
169
+ type: "button",
170
+ title: "Search the fleet (⌘K)",
171
+ onClick: () => openOverlay(),
172
+ });
173
+ searchBtn.appendChild(el("span", { text: "Search" }));
174
+ searchBtn.appendChild(el("kbd", { class: "mono", text: "⌘K" }));
175
+ const header = el("header", { class: "app-header" }, [
176
+ el("a", { class: "brand", href: hrefLibrary() }, [
177
+ el("span", { class: "logo", "aria-hidden": "true", text: "H" }),
178
+ el("span", { class: "brand-name", text: "Hangar" }),
179
+ el("span", { class: "brand-sub", text: "CrewHaus harness manager" }),
180
+ ]),
181
+ navRoot,
182
+ el("span", { class: "spacer" }),
183
+ searchBtn,
184
+ themeBtn,
185
+ ]);
186
+ bannerRoot = el("div", { class: "banner-slot", id: "banners" });
187
+ viewRoot = el("main", { class: "view", id: "view" });
188
+ footRoot = el("footer", { class: "app-foot", id: "foot" }, [
189
+ el("span", { class: "muted", text: "every action shows the CLI command it runs" }),
190
+ ]);
191
+ appRoot.appendChild(header);
192
+ appRoot.appendChild(bannerRoot);
193
+ appRoot.appendChild(viewRoot);
194
+ appRoot.appendChild(footRoot);
195
+ drawNav(parseRoute(window.location.hash));
196
+ loadVersion();
197
+ loadNavCounts();
198
+ startNotifications();
199
+ loadReadOnlyBanner();
200
+ installKeyboard();
201
+ }
202
+
203
+ /**
204
+ * HM-187 — the read-only banner. The mode is enforced SERVER-side; this
205
+ * banner exists so an operator is not left guessing why a button just
206
+ * answered 403. It states the mode, and it links to the toggle.
207
+ */
208
+ async function loadReadOnlyBanner() {
209
+ if (bannerRoot === null) return;
210
+ let state = null;
211
+ try {
212
+ state = await api.readOnly();
213
+ } catch {
214
+ return; // an old server without the route: no banner, no error
215
+ }
216
+ clear(bannerRoot);
217
+ if (state?.enabled !== true) return;
218
+ bannerRoot.appendChild(
219
+ el("div", { class: "banner banner-warn", role: "status" }, [
220
+ dot("warn", "read-only mode"),
221
+ el("span", {
222
+ text: "every mutating request is refused by the server — this manager is in demo/screen-share mode.",
223
+ }),
224
+ el("a", { class: "banner-link", href: hrefGlobal("settings"), text: "Settings →" }),
225
+ state.locked === true
226
+ ? el("span", { class: "chip chip-warn", text: "locked at startup" })
227
+ : null,
228
+ ]),
229
+ );
230
+ }
231
+
232
+ /** The nav, with the active screen marked. Counts arrive separately. */
233
+ function drawNav(route, counts = {}) {
234
+ if (navRoot === null) return;
235
+ clear(navRoot);
236
+ navRoot.appendChild(
237
+ el("a", {
238
+ class: `navlink${route.view === "library" ? " active" : ""}`,
239
+ href: hrefLibrary(),
240
+ text: "Library",
241
+ }),
242
+ );
243
+ for (const item of NAV) {
244
+ const count = counts[item.view];
245
+ const link = el("a", {
246
+ class: `navlink${route.view === item.view ? " active" : ""}`,
247
+ href: hrefGlobal(item.view),
248
+ });
249
+ link.appendChild(el("span", { text: item.label }));
250
+ if (typeof count === "number" && count > 0) {
251
+ link.appendChild(el("span", { class: "nav-badge", text: String(count) }));
252
+ }
253
+ navRoot.appendChild(link);
254
+ }
255
+ }
256
+
257
+ /** Merge counts into the nav and redraw. The three numbers arrive from two
258
+ * sources — the inbox reads here, the notification badge from the shared
259
+ * poll — so they are merged rather than replaced, and neither can wipe the
260
+ * other's number on the way past. */
261
+ function setNavCounts(patch) {
262
+ navCounts = { ...navCounts, ...patch };
263
+ drawNav(parseRoute(window.location.hash), navCounts);
264
+ }
265
+
266
+ /** Pending approvals and open review items — the two inbox numbers. The
267
+ * notification badge is the shared poll's (see {@link startNotifications}). */
268
+ async function loadNavCounts() {
269
+ const [approvals, review] = await Promise.allSettled([api.approvals(), api.review()]);
270
+ const counts = {};
271
+ if (approvals.status === "fulfilled" && approvals.value && typeof approvals.value === "object") {
272
+ counts.approvals = approvals.value.pending;
273
+ }
274
+ if (review.status === "fulfilled" && review.value && typeof review.value === "object") {
275
+ counts.review = review.value.open;
276
+ }
277
+ setNavCounts(counts);
278
+ }
279
+
280
+ /**
281
+ * HM-183 — the notification loop.
282
+ *
283
+ * The manager runs no timer of its own: `GET /api/notifications` IS the
284
+ * rules evaluation, so a console that asks only at boot never notifies
285
+ * anybody however long it is left open — which is precisely the case the
286
+ * one default-on rule (a parked approval) exists for. The console asks on
287
+ * an interval instead, through the single shared poll in `notify.js`, so
288
+ * that no OTHER screen can consume a delivery this listener would have
289
+ * toasted. Every snapshot the poll sees lands here: the badge follows it,
290
+ * and anything delivered on that pass is said out loud.
291
+ */
292
+ function startNotifications() {
293
+ if (notificationsStarted) return;
294
+ notificationsStarted = true;
295
+ notifications.subscribe((payload, delivered) => {
296
+ setNavCounts({ settings: Number(payload?.badge ?? 0) });
297
+ if (delivered.length === 1) toast(String(delivered[0]?.label ?? "notification"), "info");
298
+ else if (delivered.length > 1) toast(`${delivered.length} new notifications`, "info");
299
+ });
300
+ notifications.start();
301
+ // A hidden tab is nobody's console: stop the fleet-wide scan each poll
302
+ // costs the server, and take a fresh reading the moment it comes back.
303
+ document.addEventListener("visibilitychange", () => {
304
+ if (document.hidden === true) notifications.stop();
305
+ else notifications.start();
306
+ });
307
+ }
308
+
309
+ async function loadVersion() {
310
+ for (const old of footRoot.querySelectorAll(".foot-version")) old.remove();
311
+ let node;
312
+ try {
313
+ const v = await api.version();
314
+ if (!v || typeof v !== "object") return;
315
+ // The version route reports { hangar, protocolV }.
316
+ node = el("span", {
317
+ class: "muted mono foot-version",
318
+ text: `hangar ${String(v.hangar ?? "?")} · protocol v${String(v.protocolV ?? "?")}`,
319
+ });
320
+ } catch {
321
+ node = el("span", { class: "muted foot-version", text: "version unavailable" });
322
+ }
323
+ footRoot.appendChild(node);
324
+ }
325
+
326
+ async function dispatch(route) {
327
+ const seq = ++dispatchSeq;
328
+ drawNav(route, navCounts);
329
+ // The two screens that bind `?` for their own inbox sheet. Tracked here
330
+ // rather than sniffed from the DOM so the ownership rule stays a pure
331
+ // input to the `keys.js` reducer.
332
+ keyState = { ...keyState, inboxMounted: route.view === "approvals" || route.view === "review" };
333
+ clear(viewRoot).appendChild(skeleton(6));
334
+ try {
335
+ if (route.view === "library") {
336
+ await renderLibrary(viewRoot);
337
+ } else if (route.view === "harness") {
338
+ await renderHarnessPage(viewRoot, route);
339
+ } else if (route.view === "runs") {
340
+ await renderRunsBoard(viewRoot);
341
+ } else if (route.view === "approvals") {
342
+ await renderApprovals(viewRoot);
343
+ } else if (route.view === "review") {
344
+ await renderReview(viewRoot);
345
+ } else if (route.view === "activity") {
346
+ await renderActivity(viewRoot);
347
+ } else if (route.view === "credentials") {
348
+ await renderCredentialsMatrix(viewRoot);
349
+ } else if (route.view === "feedback") {
350
+ await renderFeedbackBoard(viewRoot);
351
+ } else if (route.view === "thredz") {
352
+ await renderThredzGlobal(viewRoot);
353
+ } else if (route.view === "health") {
354
+ await renderHealthBoard(viewRoot);
355
+ } else if (route.view === "settings") {
356
+ await renderSettings(viewRoot);
357
+ } else {
358
+ renderNotFound(viewRoot, route);
359
+ }
360
+ } catch (err) {
361
+ if (seq !== dispatchSeq) return; // superseded by a newer navigation
362
+ if (err instanceof ApiError && err.status === 401) return; // token screen owns the view
363
+ renderError(viewRoot, err);
364
+ }
365
+ }
366
+
367
+ async function renderHarnessPage(root, route) {
368
+ // The process picture rides along with the detail so the header can show
369
+ // supervision without a second paint; a failure leaves the M1 header.
370
+ const [detailRes, procRes] = await Promise.allSettled([
371
+ api.harness(route.id),
372
+ api.proc(route.id),
373
+ ]);
374
+ if (detailRes.status === "rejected") throw detailRes.reason;
375
+ const detail = detailRes.value;
376
+ const proc = procRes.status === "fulfilled" ? procRes.value : null;
377
+ clear(root);
378
+ if (detail === null) {
379
+ root.appendChild(
380
+ el("div", { class: "card" }, [
381
+ el("h2", { text: "Not registered" }),
382
+ el("p", { class: "muted", text: "No harness with that id is in the registry." }),
383
+ el("a", { href: hrefLibrary(), text: "← back to Library" }),
384
+ ]),
385
+ );
386
+ return;
387
+ }
388
+ const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
389
+ const ctx = {
390
+ id: route.id,
391
+ detail,
392
+ route,
393
+ proc,
394
+ dir: typeof entry.dir === "string" ? entry.dir : "",
395
+ reload: () => renderHarnessPage(root, route),
396
+ };
397
+ root.appendChild(harnessHeader(detail, route, ctx));
398
+ // The shape × panel matrix, applied where the strip is drawn: a tab this
399
+ // target's schema cannot declare renders as a disabled label carrying the
400
+ // reason, not as a link into a screen whose only content is "not
401
+ // applicable". The deep link still resolves, so a shared URL still opens.
402
+ const availability = tabAvailability(targetOf(detail));
403
+ root.appendChild(
404
+ el(
405
+ "nav",
406
+ { class: "tabs", "aria-label": "harness tabs" },
407
+ availability.map((row) => tabNode(route, row)),
408
+ ),
409
+ );
410
+ const current = availability.find((row) => row.tab === route.tab);
411
+ if (current !== undefined && !current.on) {
412
+ root.appendChild(
413
+ el("div", { class: "rollup" }, [
414
+ dot("off", "off this shape's panel matrix"),
415
+ el("span", { class: "muted", text: current.reason }),
416
+ ]),
417
+ );
418
+ }
419
+ const tabRoot = el("div", { class: "tab-body" });
420
+ root.appendChild(tabRoot);
421
+ // The three tabs that gained an M3 half render the M1/M2 surface first and
422
+ // the new one beneath it — one screen, not two competing ones.
423
+ if (route.tab === "spec") {
424
+ await renderSpec(tabRoot, ctx);
425
+ await renderSpecEdit(section(tabRoot), ctx);
426
+ } else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
427
+ else if (route.tab === "schedulers") await renderSchedulers(tabRoot, ctx);
428
+ else if (route.tab === "sessions") await renderSessions(tabRoot, ctx);
429
+ else if (route.tab === "evals") {
430
+ await renderEvals(tabRoot, ctx);
431
+ await renderEvalsLab(section(tabRoot), ctx);
432
+ } else if (route.tab === "memory") {
433
+ await renderMemory(tabRoot, ctx);
434
+ await renderMemoryFabric(section(tabRoot), ctx);
435
+ } else if (route.tab === "data") await renderData(tabRoot, ctx);
436
+ else if (route.tab === "feedback") await renderFeedback(tabRoot, ctx);
437
+ else if (route.tab === "costs") await renderCosts(tabRoot, ctx);
438
+ else if (route.tab === "creds") await renderCreds(tabRoot, ctx);
439
+ else if (route.tab === "channels") await renderChannels(tabRoot, ctx);
440
+ else if (route.tab === "security") await renderSecurity(tabRoot, ctx);
441
+ else if (route.tab === "thredz") await renderThredz(tabRoot, ctx);
442
+ else if (route.tab === "deploy") await renderDeploy(tabRoot, ctx);
443
+ else if (route.tab === "inspect") await renderInspect(tabRoot, ctx);
444
+ else if (route.tab === "dev") await renderRuntime(tabRoot, ctx);
445
+ else if (route.tab === "panes") await renderPanes(tabRoot, ctx);
446
+ else {
447
+ await renderOverview(tabRoot, ctx);
448
+ // The explained score (HM-11) loads on its own: it runs a preflight, and
449
+ // the Overview's first paint must not wait for one.
450
+ await renderHealthCard(section(tabRoot), ctx);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * One tab in the strip. An on-matrix tab is a link; an off-matrix one is a
456
+ * disabled label whose accessible name carries the reason as TEXT — the
457
+ * dimming is never the whole message.
458
+ */
459
+ function tabNode(route, row) {
460
+ const label = TAB_LABELS[row.tab] ?? row.tab;
461
+ const active = route.tab === row.tab;
462
+ if (row.on) {
463
+ return el("a", {
464
+ class: `tab${active ? " active" : ""}`,
465
+ "aria-current": active ? "page" : null,
466
+ href: hrefHarness(route.id, row.tab),
467
+ text: label,
468
+ });
469
+ }
470
+ return el("span", {
471
+ class: `tab tab-off muted${active ? " active" : ""}`,
472
+ // The console ships one stylesheet and this state is the only new one on
473
+ // the strip, so it dims inline rather than growing the CSS a rule that
474
+ // one span uses. The reason still travels as text, in both the tooltip
475
+ // and the accessible name — the dimming is a hint, never the message.
476
+ style: { opacity: "0.45", cursor: "not-allowed" },
477
+ "aria-disabled": "true",
478
+ "aria-current": active ? "page" : null,
479
+ "aria-label": `${label} — not part of this shape: ${row.reason}`,
480
+ title: row.reason,
481
+ text: label,
482
+ });
483
+ }
484
+
485
+ /** The target shape string, read from the same places the header reads it
486
+ * (the inventory's parsed header first, the registry row as the fallback).
487
+ * One reader, so the strip and the badge can never disagree. */
488
+ function targetOf(detail) {
489
+ const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
490
+ const inv = detail.inventory && typeof detail.inventory === "object" ? detail.inventory : {};
491
+ const header = inv.header && typeof inv.header === "object" ? inv.header : {};
492
+ const explicit = header.target ?? entry.target;
493
+ return typeof explicit === "string" ? explicit : "";
494
+ }
495
+
496
+ /** Append a fresh sub-container so a second render into the same tab does
497
+ * not clear the first (every view clears the root it is handed). */
498
+ function section(root) {
499
+ const node = el("div", { class: "tab-section" });
500
+ root.appendChild(node);
501
+ return node;
502
+ }
503
+
504
+ /**
505
+ * Detail header. The detail payload nests all identity fields under
506
+ * `entry` (the registry row) with richer names on `inventory`; the header
507
+ * reads those, never invented top-level fields. Capability chips come from
508
+ * the server's `badges` (the lenient spec scan) — the M1 posture line — and
509
+ * M2 adds the supervision strip beneath it.
510
+ */
511
+ function harnessHeader(detail, route, ctx) {
512
+ const entry = detail.entry && typeof detail.entry === "object" ? detail.entry : {};
513
+ const inv = detail.inventory && typeof detail.inventory === "object" ? detail.inventory : {};
514
+ const name = String(inv.specName ?? entry.specName ?? route.id);
515
+ const target = targetOf(detail);
516
+ const dir = String(entry.dir ?? "");
517
+ const missing = detail.missing === true || typeof entry.missingSince === "string";
518
+ const head = el("div", { class: "h-head", style: { "--accent": shapeAccent(target) } }, [
519
+ el("div", { class: "h-title" }, [
520
+ el("span", { class: "shape-badge", text: shapeLabel(target) }),
521
+ el("h2", { text: name }),
522
+ missing ? dot("bad", "directory missing") : null,
523
+ ]),
524
+ dir !== ""
525
+ ? el("div", { class: "h-dir" }, [
526
+ el("span", { class: "mono muted", title: dir, text: dir }),
527
+ copyBtn(dir, "copy"),
528
+ ])
529
+ : null,
530
+ badgeStrip(detail.badges),
531
+ supervisionStrip(ctx),
532
+ ]);
533
+ return head;
534
+ }
535
+
536
+ /** Supervision pill + bundle freshness + control availability + remedy. */
537
+ function supervisionStrip(ctx) {
538
+ if (ctx.proc === null) return null;
539
+ const row = procRow(ctx.proc, Date.now());
540
+ const strip = el("div", { class: "sup-strip" }, [
541
+ dot(row.pill.dot, row.pill.label),
542
+ row.adopted ? el("span", { class: "chip", text: "adopted" }) : null,
543
+ row.draining ? el("span", { class: "chip chip-warn", text: "draining" }) : null,
544
+ row.runId !== null
545
+ ? el("a", {
546
+ class: "chip",
547
+ href: hrefHarness(ctx.id, "runs", row.runId),
548
+ text: "watch this run",
549
+ })
550
+ : null,
551
+ // The exact/approximate distinction is the badge's whole point — but
552
+ // only where there IS a verdict to qualify.
553
+ el("span", {
554
+ class: `chip${row.bundle.dot === "warn" ? " chip-warn" : ""}`,
555
+ title: row.bundle.precision,
556
+ text: row.bundle.present
557
+ ? `${row.bundle.label} · ${row.bundle.exact ? "exact" : "approximate"}`
558
+ : row.bundle.label,
559
+ }),
560
+ dot(row.control.dot, row.control.label),
561
+ ]);
562
+ const planError = row.launch.error;
563
+ if (planError !== null) {
564
+ strip.appendChild(el("span", { class: "muted gated-why", text: planError.message }));
565
+ if (planError.action !== null) {
566
+ const btn = el("button", {
567
+ class: "btn",
568
+ type: "button",
569
+ text: planError.action.label,
570
+ title: planError.action.hint,
571
+ });
572
+ if (planError.action.jobKind === null) {
573
+ btn.disabled = true;
574
+ } else {
575
+ btn.addEventListener("click", async () => {
576
+ btn.disabled = true;
577
+ try {
578
+ await api.submitJob(ctx.id, planError.action.jobKind);
579
+ toast(`Queued ${planError.action.jobKind}`, "info");
580
+ ctx.reload();
581
+ } catch (err) {
582
+ btn.disabled = false;
583
+ toast(`Could not queue: ${err instanceof Error ? err.message : String(err)}`);
584
+ }
585
+ });
586
+ }
587
+ strip.appendChild(btn);
588
+ }
589
+ }
590
+ return strip;
591
+ }
592
+
593
+ /** Capability-badge chips from the detail payload's `badges` booleans. */
594
+ function badgeStrip(badges) {
595
+ if (!badges || typeof badges !== "object") return null;
596
+ const active = Object.keys(badges).filter((k) => badges[k] === true);
597
+ if (active.length === 0) return null;
598
+ return el(
599
+ "div",
600
+ { class: "safety-strip", "aria-label": "capabilities" },
601
+ active.map((k) => el("span", { class: "chip", text: k })),
602
+ );
603
+ }
604
+
605
+ function renderNotFound(root, route) {
606
+ clear(root).appendChild(
607
+ el("div", { class: "card" }, [
608
+ el("h2", { text: "Nothing at this address" }),
609
+ el("p", { class: "muted mono", text: String(route.hash ?? "") }),
610
+ el("a", { href: hrefLibrary(), text: "← back to Library" }),
611
+ ]),
612
+ );
613
+ }
614
+
615
+ function renderError(root, err) {
616
+ const message = err instanceof Error ? err.message : String(err);
617
+ clear(root).appendChild(
618
+ el("div", { class: "card error-card" }, [
619
+ el("h2", null, [dot("bad", "request failed")]),
620
+ el("p", { class: "muted", text: message }),
621
+ el("button", {
622
+ class: "btn",
623
+ type: "button",
624
+ text: "Retry",
625
+ onClick: () => dispatch(parseRoute(window.location.hash)),
626
+ }),
627
+ ]),
628
+ );
629
+ }
630
+
631
+ /**
632
+ * HM-190 — the app-level keyboard layer.
633
+ *
634
+ * One listener, one pure reducer (`keys.js`). It claims ⌘K, Escape and — on
635
+ * screens with no inbox — `?`, and it claims NOTHING else, so the inbox
636
+ * triage reducer that shipped with M2 keeps receiving j/k/g/d untouched on
637
+ * the two screens that mount it.
638
+ */
639
+ function installKeyboard() {
640
+ document.addEventListener("keydown", (event) => {
641
+ const { state, effect, claimed } = globalKey(keyState, keyShape(event));
642
+ keyState = state;
643
+ if (claimed) event.preventDefault();
644
+ if (effect === "open-omnibox") openOverlay();
645
+ else if (effect === "close-omnibox") closeOverlay();
646
+ else if (effect === "toggle-sheet") drawSheet();
647
+ else if (effect === "close-sheet") drawSheet();
648
+ });
649
+ }
650
+
651
+ function openOverlay() {
652
+ if (omnibox !== null) return;
653
+ keyState = { ...keyState, omniboxOpen: true, sheetOpen: false };
654
+ drawSheet();
655
+ omnibox = openOmnibox(document.body, {
656
+ onClose: () => {
657
+ omnibox = null;
658
+ keyState = { ...keyState, omniboxOpen: false };
659
+ },
660
+ });
661
+ }
662
+
663
+ function closeOverlay() {
664
+ if (omnibox === null) return;
665
+ omnibox.close();
666
+ omnibox = null;
667
+ }
668
+
669
+ /** The `?` bindings overlay. Rendered from `bindingSheet()` so the list and
670
+ * the reducer can never disagree about what a key does. */
671
+ function drawSheet() {
672
+ if (sheetNode !== null) {
673
+ sheetNode.remove();
674
+ sheetNode = null;
675
+ }
676
+ if (!keyState.sheetOpen) return;
677
+ sheetNode = el(
678
+ "div",
679
+ {
680
+ class: "sheet-backdrop",
681
+ role: "dialog",
682
+ "aria-modal": "true",
683
+ "aria-label": "keyboard bindings",
684
+ },
685
+ [
686
+ el("div", { class: "card sheet" }, [
687
+ el("h3", { class: "card-title", text: "Keyboard" }),
688
+ ...bindingSheet(keyState.inboxMounted).flatMap((group) => [
689
+ el("h4", { class: "sheet-section", text: group.section }),
690
+ el(
691
+ "ul",
692
+ { class: "check-list" },
693
+ group.bindings.map((b) =>
694
+ el("li", null, [
695
+ el("kbd", { class: "mono", text: b.keys }),
696
+ el("span", { text: ` — ${b.does}` }),
697
+ ]),
698
+ ),
699
+ ),
700
+ ]),
701
+ el("p", { class: "muted small", text: "Esc closes." }),
702
+ ]),
703
+ ],
704
+ );
705
+ sheetNode.addEventListener("click", (event) => {
706
+ if (event.target === sheetNode) {
707
+ keyState = { ...keyState, sheetOpen: false };
708
+ drawSheet();
709
+ }
710
+ });
711
+ document.body.appendChild(sheetNode);
712
+ }
713
+
714
+ function boot() {
715
+ bootstrapToken();
716
+ initTheme();
717
+ const appRoot = document.getElementById("app");
718
+ renderShell(appRoot);
719
+ onUnauthorized(() => {
720
+ renderTokenScreen(viewRoot, () => {
721
+ loadVersion();
722
+ loadNavCounts();
723
+ // The polls that ran while the token was missing answered 401 and
724
+ // returned nothing; take a reading now rather than at the next tick.
725
+ notifications.refresh();
726
+ dispatch(parseRoute(window.location.hash));
727
+ });
728
+ });
729
+ startRouter((route) => {
730
+ dispatch(route);
731
+ });
732
+ }
733
+
734
+ if (typeof document !== "undefined") boot();