@monster-orange/den 0.2.8 → 0.3.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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -21,13 +21,20 @@ import {
21
21
  buildModel,
22
22
  edgeModel,
23
23
  entryOf,
24
+ findHit,
24
25
  healthLine,
25
26
  isCollapsed,
26
27
  neighbours,
27
28
  nodeFor,
28
29
  orgOf,
30
+ packGroups,
29
31
  placementText,
30
32
  repoPlacement,
33
+ repoRollup,
34
+ repoStatus,
35
+ repoStatusWord,
36
+ routeCross,
37
+ splitTopLevel,
31
38
  status,
32
39
  statusWord,
33
40
  } from "/graph-model.js";
@@ -40,6 +47,7 @@ import {
40
47
  renderPanel,
41
48
  runAction,
42
49
  } from "/panel.js";
50
+ import { ownersLabel } from "/workspace-picker.js";
43
51
 
44
52
  const LS = "__DEN_CLI__-graph:";
45
53
  const store = {
@@ -90,9 +98,26 @@ let edges = [];
90
98
  let elk = null;
91
99
  let renderSeq = 0;
92
100
  let prefsLoaded = false;
101
+ // The floor of the last fit while the view is still the automatic one; null
102
+ // once the user pans or zooms. A viewport that changes size (Local Dev's
103
+ // output panel opening below it) re-fits an automatic view, so cards never
104
+ // sit under the panel; a view the user moved is left where they put it.
105
+ let autoFit = null;
106
+ let vpObserver = null;
93
107
 
94
108
  const $ = (s, el = host) => el?.querySelector(s);
95
109
  const zoomFloor = 0.35;
110
+ /**
111
+ * The automatic fit (first draw, a new set of cards, a layout change, the
112
+ * viewport resizing) shows the whole graph, as Fit does (0.3.0-dev.34).
113
+ * Until dev.33 a graph that needed less than 25% opened on the top of it
114
+ * at 60%, and a 31-card graph across 10 org groups opened with 17 of 31
115
+ * cards outside the stage (a consumer's finding); the groups now wrap to
116
+ * the stage (packGroups), so a graph that big fits at a readable zoom.
117
+ */
118
+ const AUTO_FIT = "auto";
119
+ /** How far Fit (and zooming out to it) goes for a very big graph. */
120
+ const FIT_MIN = 0.1;
96
121
 
97
122
  bindPanel({
98
123
  model: () => M,
@@ -142,7 +167,7 @@ function loadServerPrefs() {
142
167
  store.set("prefs", prefs());
143
168
  if (JSON.stringify(prefs()) !== before && host?.isConnected) {
144
169
  syncControls();
145
- render().then(() => fit(0.6));
170
+ render().then((d) => d && fit(AUTO_FIT));
146
171
  }
147
172
  })
148
173
  .catch(() => {});
@@ -165,6 +190,25 @@ function loadElk() {
165
190
  return elkLoading;
166
191
  }
167
192
 
193
+ // ── maximize (R23, Ward QA 2026-11-05) ────────────────────────────────────
194
+ // The page owns the layout: hooks.maximize = { isOn(), toggle() } gives the
195
+ // zoom group a Maximize button (F on the page; Esc restores). Without it,
196
+ // no button.
197
+ function maxButton() {
198
+ const m = hooks.maximize;
199
+ if (!m) return "";
200
+ const on = !!m.isOn?.();
201
+ return `<button data-z="max" aria-pressed="${on}" aria-keyshortcuts="F" aria-label="Maximize graph" title="${on ? "Restore (Esc)" : "Maximize graph (F)"}">${on ? "⤡" : "⤢"}</button>`;
202
+ }
203
+ /** Repaint the Maximize button after the page changed the layout. */
204
+ export function graphMaximized(on) {
205
+ const b = $('[data-z="max"]');
206
+ if (!b) return;
207
+ b.setAttribute("aria-pressed", String(!!on));
208
+ b.title = on ? "Restore (Esc)" : "Maximize graph (F)";
209
+ b.textContent = on ? "⤡" : "⤢";
210
+ }
211
+
168
212
  // ── mount ────────────────────────────────────────────────────────────────
169
213
  export function mountGraph(el, data, pageHooks) {
170
214
  hooks = pageHooks || {};
@@ -191,7 +235,7 @@ export function mountGraph(el, data, pageHooks) {
191
235
  <div class="dg-vp" tabindex="0" aria-label="Service graph. Drag to pan, scroll to move, Ctrl or Cmd + scroll to zoom.">
192
236
  <div class="dg-world"><svg aria-hidden="true"></svg></div>
193
237
  <div class="dg-legend"></div>
194
- <div class="dg-zoom" role="group" aria-label="Zoom"><button data-z="out" aria-label="Zoom out">−</button><button data-z="100" aria-label="Reset zoom to 100%">100%</button><button data-z="in" aria-label="Zoom in">+</button><button data-z="fit">Fit</button></div>
238
+ <div class="dg-zoom" role="group" aria-label="Zoom"><button data-z="out" aria-label="Zoom out">−</button><button data-z="100" aria-label="Reset zoom to 100%">100%</button><button data-z="in" aria-label="Zoom in">+</button><button data-z="fit">Fit</button>${maxButton()}</div>
195
239
  </div>
196
240
  <aside class="dg-panel" hidden aria-label="Details"></aside>
197
241
  </div>
@@ -199,9 +243,16 @@ export function mountGraph(el, data, pageHooks) {
199
243
  wire();
200
244
  syncControls();
201
245
  loadServerPrefs();
202
- render().then(() => {
203
- if (!st.fitted) {
204
- fit(0.6);
246
+ // Fit once the layout that is drawn is known: a render a newer one
247
+ // overtook draws nothing and fits nothing (All active, and the org
248
+ // frames landing after the first draw, mount twice in quick succession).
249
+ // A new set of cards (the orgs landing, the picker, a workspace
250
+ // activated) re-fits while the view is the automatic one; a view the
251
+ // user zoomed or panned stays where they put it.
252
+ render().then((drawn) => {
253
+ if (!drawn) return;
254
+ if (!st.fitted || autoFit != null) {
255
+ fit(AUTO_FIT);
205
256
  st.fitted = true;
206
257
  } else applyView();
207
258
  if (st.sel) openDetail(st.sel, true);
@@ -213,10 +264,18 @@ export function graphHealth(results) {
213
264
  if (!M || !host?.isConnected) return;
214
265
  applyHealth(M, results);
215
266
  for (const c of host.querySelectorAll(".dg-card")) {
216
- if (c.dataset.id && M.byId.has(c.dataset.id))
217
- c.innerHTML = cardInner(M.byId.get(c.dataset.id));
218
- else if (c.dataset.repo && M.byRepo.has(c.dataset.repo))
267
+ if (c.dataset.id && M.byId.has(c.dataset.id)) {
268
+ const s = M.byId.get(c.dataset.id);
269
+ c.innerHTML = cardInner(s);
270
+ // A service card's border follows its health too (0.3.0-dev.40,
271
+ // DOC-1: it stayed red after a recovery; only repo cards toggled).
272
+ c.classList.toggle("down", s.health.ok === false);
273
+ } else if (c.dataset.repo && M.byRepo.has(c.dataset.repo)) {
219
274
  c.innerHTML = repoCardInner(c.dataset.repo);
275
+ const s = repoRollup(M, c.dataset.repo).state;
276
+ c.classList.toggle("down", s === "down");
277
+ c.classList.toggle("degraded", s === "degraded");
278
+ }
220
279
  }
221
280
  for (const g of host.querySelectorAll(".dg-grp.dg-org"))
222
281
  g.querySelector(".roll").textContent = rollup(groupServices(g.dataset.key));
@@ -255,8 +314,21 @@ async function render() {
255
314
  for (const e of n.edges || []) meta.set(e.id, e._m);
256
315
  (n.children || []).forEach(walk);
257
316
  })(root);
258
- const res = await elk.layout(root);
259
- if (seq !== renderSeq || !world.isConnected) return; // a newer render won
317
+ // Each top-level group on its own, then wrapped to the stage
318
+ // (graph-model.js splitTopLevel / packGroups, 0.3.0-dev.34).
319
+ const { parts, cross, groupOf } = splitTopLevel(root);
320
+ const results = await Promise.all(parts.map((p) => elk.layout(p)));
321
+ if (seq !== renderSeq || !world.isConnected) return false; // a newer render won
322
+ const area = fitArea();
323
+ const tops = results.map((r) => r.children[0]);
324
+ const pack = packGroups(
325
+ tops.map((g) => ({ w: g.width, h: g.height })),
326
+ {
327
+ direction: st.dir,
328
+ // The world adds 30px to the groups' extent (padding and margin).
329
+ ...(area ? { width: area.w - 30, height: area.h - 30 } : {}),
330
+ },
331
+ );
260
332
  world.querySelectorAll(".dg-grp,.dg-card,.dg-msg").forEach((n) => n.remove());
261
333
  edges = edgeModel(M, st.collapsed);
262
334
  layoutAbs = {};
@@ -264,7 +336,7 @@ async function render() {
264
336
  const paths = [];
265
337
  let maxX = 0,
266
338
  maxY = 0;
267
- (function walk(n, ox, oy) {
339
+ function walk(n, ox, oy) {
268
340
  const x = ox + (n.x || 0),
269
341
  y = oy + (n.y || 0);
270
342
  if (n.id !== "root") {
@@ -297,35 +369,69 @@ async function render() {
297
369
  }
298
370
  }
299
371
  for (const c of n.children || []) walk(c, x, y);
300
- })(res, 0, 0);
372
+ }
373
+ // The root's padding (10) around the wrapped groups.
374
+ const PAD = 10;
375
+ const bands = new Map();
376
+ results.forEach((r, i) => {
377
+ const g = tops[i],
378
+ at = pack.pos[i];
379
+ walk(r, PAD + at.x - g.x, PAD + at.y - g.y);
380
+ const down = st.dir !== "right";
381
+ const from = PAD + (down ? at.y : at.x);
382
+ const b = bands.get(at.band) || { band: at.band, from, to: from };
383
+ b.from = Math.min(b.from, from);
384
+ b.to = Math.max(b.to, from + (down ? g.height : g.width));
385
+ bands.set(at.band, b);
386
+ });
387
+ const bandOf = new Map(
388
+ tops.map((g, i) => [g.id, bands.get(pack.pos[i].band)]),
389
+ );
390
+ // Edges between groups: routed through the gaps between the wrap's rows.
391
+ for (const e of cross) {
392
+ const a = layoutAbs[e.sources[0]],
393
+ b = layoutAbs[e.targets[0]];
394
+ const ba = bandOf.get(groupOf.get(e.sources[0])),
395
+ bb = bandOf.get(groupOf.get(e.targets[0]));
396
+ if (!a || !b || !ba || !bb) continue;
397
+ paths.push({ d: roundPath(routeCross(a, b, ba, bb, st.dir)), m: e._m });
398
+ }
301
399
  world.appendChild(frag);
302
400
  const svg = $(".dg-world > svg");
303
401
  svg.setAttribute("width", maxX + 20);
304
402
  svg.setAttribute("height", maxY + 20);
305
403
  const MK = {
306
- intra: "#8b949e",
307
- hard: "#58a6ff",
308
- soft: "#8b949e",
309
- event: "#bc8cff",
310
- cross: "#39c5cf",
404
+ intra: "var(--muted)",
405
+ hard: "var(--accent)",
406
+ soft: "var(--muted)",
407
+ event: "var(--dg-event)",
408
+ cross: "var(--remote)",
311
409
  };
312
410
  svg.innerHTML =
313
411
  `<defs>${Object.entries(MK)
314
412
  .map(
315
413
  ([k, c]) =>
316
- `<marker id="dg-ah-${k}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="${c}"/></marker>`,
414
+ `<marker id="dg-ah-${k}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" style="fill:${c}"/></marker>`,
317
415
  )
318
416
  .join("")}</defs>` +
319
417
  paths
320
418
  .map(({ d, m }) => {
321
419
  const k = m.cross ? "cross" : m.kind;
322
- return `<path class="dg-e ${k}" data-f="${esc(m.from)}" data-t="${esc(m.to)}" d="${d}" marker-end="url(#dg-ah-${k})"/>`;
420
+ // R22b-2: each edge's flow overlay, drawn on its base path (same
421
+ // d, same kind, so the same colour); it shows and marches from
422
+ // data-f to data-t only while the edge is highlighted.
423
+ const ends = `data-f="${esc(m.from)}" data-t="${esc(m.to)}" d="${d}"`;
424
+ return (
425
+ `<path class="dg-e ${k}" ${ends} marker-end="url(#dg-ah-${k})"/>` +
426
+ `<path class="dg-flow ${k}" ${ends}/>`
427
+ );
323
428
  })
324
429
  .join("");
325
430
  world.style.width = maxX + 20 + "px";
326
431
  world.style.height = maxY + 20 + "px";
327
432
  legend();
328
433
  applyFocus();
434
+ return true;
329
435
  }
330
436
 
331
437
  function roundPath(pts) {
@@ -399,7 +505,7 @@ function cardInner(s) {
399
505
  const showPlace = s.runs === "remote" || M.mixed;
400
506
  const acts = actionsFor(s);
401
507
  return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}</div>
402
- <div class="r2" title="${esc(s.id)}">${esc(s.repo)}</div>
508
+ <div class="r2" title="${esc(s.id)}${s.inWorkspaces.length > 1 ? `: in ${esc(s.inWorkspaces.join(", "))}` : ""}">${esc(s.repo + ownersLabel(s.inWorkspaces))}</div>
403
509
  <div class="r3">${s.entry ? '<span class="dg-tag entry">entry</span>' : ""}${s.infra ? '<span class="dg-tag">infra</span>' : ""}${s.missing.length ? `<span class="dg-tag warn" title="Missing: ${esc(s.missing.join(", "))}">⚠ missing</span>` : ""}${!s.cloned ? '<span class="dg-tag warn">not cloned</span>' : ""}<span class="h ${status(s)}">${esc(healthLine(s))}</span></div>
404
510
  <div class="dg-acts">${[acts.primary, ...acts.common]
405
511
  .filter(Boolean)
@@ -423,8 +529,9 @@ function card(s, x, y, w, h) {
423
529
  function repoCardInner(r) {
424
530
  const svcs = M.byRepo.get(r),
425
531
  ok = svcs.filter((s) => s.health.ok).length,
426
- pl = repoPlacement(M, r);
427
- return `<div class="r1"><span class="dg-dot ${ok === svcs.length ? "ok" : svcs.some((s) => s.health.ok === false) ? "bad" : ""}"></span><span class="nm">${esc(r)}</span>${pl !== "local" || M.mixed ? `<span class="dg-badge ${pl}">${pl === "hybrid" ? "⇄ hybrid" : pl === "remote" ? "☁ remote" : "⌂ local"}</span>` : ""}</div>
532
+ pl = repoPlacement(M, r),
533
+ roll = repoRollup(M, r);
534
+ return `<div class="r1"><span class="dg-dot ${repoStatus(roll)}" title="${esc(repoStatusWord(roll))}"></span><span class="nm">${esc(r)}</span>${pl !== "local" || M.mixed ? `<span class="dg-badge ${pl}">${pl === "hybrid" ? "⇄ hybrid" : pl === "remote" ? "☁ remote" : "⌂ local"}</span>` : ""}</div>
428
535
  <div class="r2">${svcs.length} services · ${ok}/${svcs.length} healthy</div>
429
536
  <div class="r3 sum">${svcs.map((s) => `<span class="dg-dot ${status(s)}" title="${esc(s.name)}: ${statusWord(s)}"></span>`).join("")}</div>
430
537
  <div class="dg-acts"><button class="dg-btn sm" data-expand="${esc(r)}">Expand</button></div>`;
@@ -432,13 +539,14 @@ function repoCardInner(r) {
432
539
  function repoCard(r, x, y, w, h) {
433
540
  const svcs = M.byRepo.get(r);
434
541
  const el = document.createElement("div");
435
- el.className = `dg-card ${st.density === "compact" ? "compact" : ""} ${repoPlacement(M, r) === "remote" ? "remote" : ""} ${svcs.some((s) => s.health.ok === false) ? "down" : ""}`;
542
+ const roll = repoRollup(M, r);
543
+ el.className = `dg-card ${st.density === "compact" ? "compact" : ""} ${repoPlacement(M, r) === "remote" ? "remote" : ""} ${roll.state === "down" ? "down" : roll.state === "degraded" ? "degraded" : ""}`;
436
544
  el.dataset.repo = r;
437
545
  el.tabIndex = 0;
438
546
  el.setAttribute("role", "button");
439
547
  el.setAttribute(
440
548
  "aria-label",
441
- `${r}, ${svcs.length} services, ${svcs.filter((s) => s.health.ok).length} healthy. Open details.`,
549
+ `${r}, ${repoStatusWord(roll)}, ${svcs.length} services, ${svcs.filter((s) => s.health.ok).length} healthy. Open details.`,
442
550
  );
443
551
  el.innerHTML = repoCardInner(r);
444
552
  return place(el, x, y, w, h);
@@ -448,9 +556,9 @@ function legend() {
448
556
  const kinds = new Set(edges.map((e) => (e.cross ? "cross" : e.kind)));
449
557
  const items = [
450
558
  ["hard", "var(--accent)", "solid", "hard dependency"],
451
- ["soft", "#8b949e", "dashed", "soft"],
452
- ["event", "#bc8cff", "dotted", "event"],
453
- ["intra", "#8b949e", "solid", "within a repo"],
559
+ ["soft", "var(--muted)", "dashed", "soft"],
560
+ ["event", "var(--dg-event)", "dotted", "event"],
561
+ ["intra", "var(--muted)", "solid", "within a repo"],
454
562
  ["cross", "var(--dg-remote)", "dashed", "local → remote"],
455
563
  ].filter(([k]) => kinds.has(k));
456
564
  $(".dg-legend").innerHTML =
@@ -488,7 +596,7 @@ function applyFocus() {
488
596
  const q = st.q.trim().toLowerCase();
489
597
  host.querySelectorAll(".dg-card").forEach((c) => {
490
598
  const k = c.dataset.id || "repo:" + c.dataset.repo;
491
- const hit = !!q && k.toLowerCase().includes(q);
599
+ const hit = findHit(k, q);
492
600
  c.classList.toggle("sel", key != null && k === key);
493
601
  c.classList.toggle("hit", hit);
494
602
  c.classList.toggle("dim", (focus && !focus.has(k)) || (!!q && !hit));
@@ -502,6 +610,9 @@ function applyFocus() {
502
610
  : p.dataset.f === key || p.dataset.t === key);
503
611
  p.classList.toggle("hl", on);
504
612
  p.classList.toggle("dim", (!!focus && !on) || !!q);
613
+ // Its flow overlay (the next sibling, R22b-2) runs only while lit.
614
+ const flow = p.nextElementSibling;
615
+ if (flow?.classList.contains("dg-flow")) flow.classList.toggle("on", on);
505
616
  });
506
617
  }
507
618
  function focusOn(sel) {
@@ -604,24 +715,64 @@ function zoomAt(f, cx, cy) {
604
715
  const vp = $(".dg-vp").getBoundingClientRect();
605
716
  cx ??= vp.width / 2;
606
717
  cy ??= vp.height / 2;
607
- const nz = Math.min(2, Math.max(zoomFloor, st.z * f));
718
+ autoFit = null;
719
+ // Out to 35%, or further when that is what the whole graph needs.
720
+ const lo = Math.min(zoomFloor, Math.max(FIT_MIN, fitNeed() ?? zoomFloor));
721
+ const nz = Math.min(2, Math.max(lo, st.z * f));
608
722
  st.tx = cx - ((cx - st.tx) * nz) / st.z;
609
723
  st.ty = cy - ((cy - st.ty) * nz) / st.z;
610
724
  st.z = nz;
611
725
  applyView();
612
726
  }
613
- /** Fit the graph; automatic fits pass floor 0.6, the Fit button 0.35. */
614
- function fit(floor = zoomFloor) {
727
+ /** The fitted view's top inset (st.ty) and the gap kept above the overlays. */
728
+ const FIT_TOP = 16;
729
+ const FIT_GAP = 8;
730
+ /**
731
+ * The height the overlays at the viewport's foot take (the legend and the
732
+ * zoom group, from the bottom edge to the higher one's top, plus a gap):
733
+ * Fit keeps the graph above them (0.3.0-dev.33, a consumer's finding: a
734
+ * legend on two lines covered the last group's bottom 10px). Measured,
735
+ * so a wrapped legend reserves its real height.
736
+ */
737
+ function bottomReserve(vp) {
738
+ let top = vp.bottom;
739
+ for (const el of [$(".dg-legend"), $(".dg-zoom")]) {
740
+ const r = el?.getBoundingClientRect();
741
+ if (r && r.height > 0) top = Math.min(top, r.top);
742
+ }
743
+ return vp.bottom - top + FIT_GAP;
744
+ }
745
+ /** The area a fitted graph may use in the viewport now, or null. */
746
+ function fitArea() {
747
+ const vp = $(".dg-vp")?.getBoundingClientRect();
748
+ if (!vp || vp.width < 1 || vp.height < 1) return null;
749
+ return { w: vp.width - 32, h: vp.height - FIT_TOP - bottomReserve(vp) };
750
+ }
751
+ /** The zoom that shows the whole graph in the viewport now (≤ 1), or null. */
752
+ function fitNeed() {
753
+ const area = fitArea();
754
+ const world = $(".dg-world");
755
+ if (!area || !world) return null;
756
+ const w = parseFloat(world.style.width) || 1,
757
+ h = parseFloat(world.style.height) || 1;
758
+ return Math.min(1, area.w / w, area.h / h);
759
+ }
760
+ /**
761
+ * Fit the graph; automatic fits pass AUTO_FIT, the Fit button FIT_MIN.
762
+ * Fit fits: the whole graph, bound by the width and the height (R23), down
763
+ * to FIT_MIN. A 35% floor here made Fit read 35% for a big graph whether
764
+ * the graph was maximized or not (a consumer's dev.30 finding). The fitted
765
+ * view refits when the viewport changes size (maximize, restore, the dock).
766
+ */
767
+ function fit(floor = FIT_MIN) {
615
768
  const vp = $(".dg-vp")?.getBoundingClientRect();
616
769
  if (!vp) return;
617
- const w = parseFloat($(".dg-world").style.width) || 1,
618
- h = parseFloat($(".dg-world").style.height) || 1;
619
- st.z = Math.max(
620
- floor,
621
- Math.min(1, (vp.width - 32) / w, (vp.height - 70) / h),
622
- );
770
+ const w = parseFloat($(".dg-world").style.width) || 1;
771
+ const need = fitNeed();
772
+ st.z = Math.max(floor === AUTO_FIT ? FIT_MIN : floor, need);
623
773
  st.tx = Math.max(16, (vp.width - w * st.z) / 2);
624
- st.ty = 16;
774
+ st.ty = FIT_TOP;
775
+ autoFit = floor;
625
776
  applyView();
626
777
  }
627
778
 
@@ -759,6 +910,14 @@ function onClick(e) {
759
910
  openDetail(st.sel, true);
760
911
  return;
761
912
  }
913
+ const pinT = t.closest("[data-pin-toggle]");
914
+ if (pinT && window.denShell) {
915
+ const id = pinT.dataset.pinToggle;
916
+ if (window.denShell.isPinned(id)) window.denShell.unpin(id);
917
+ else window.denShell.pin(id);
918
+ openDetail(st.sel, true);
919
+ return;
920
+ }
762
921
  if (t.closest("[data-popout]")) {
763
922
  st.popped = !st.popped;
764
923
  openDetail(st.sel, true);
@@ -831,7 +990,7 @@ function wire() {
831
990
  syncControls();
832
991
  savePrefs();
833
992
  render().then(() => {
834
- if (el.dataset.seg !== "density") fit(0.6);
993
+ if (el.dataset.seg !== "density") fit(AUTO_FIT);
835
994
  if (st.sel) keepVisible(st.sel);
836
995
  });
837
996
  }),
@@ -847,7 +1006,7 @@ function wire() {
847
1006
  [...M.byRepo.keys()].filter((r) => M.byRepo.get(r).length > 1),
848
1007
  );
849
1008
  store.set("collapsed", [...st.collapsed]);
850
- render().then(() => fit(0.6));
1009
+ render().then((d) => d && fit(AUTO_FIT));
851
1010
  });
852
1011
  const find = $("[data-find]");
853
1012
  find.addEventListener("input", () => {
@@ -858,7 +1017,7 @@ function wire() {
858
1017
  if (e.key !== "Enter") return;
859
1018
  const q = st.q.trim().toLowerCase();
860
1019
  if (!q) return;
861
- const s = M.services.find((x) => x.id.toLowerCase().includes(q));
1020
+ const s = M.services.find((x) => findHit(x.id, q));
862
1021
  if (s)
863
1022
  focusOn(
864
1023
  isCollapsed(M, st.collapsed, s.repo) ? { repo: s.repo } : { id: s.id },
@@ -866,6 +1025,16 @@ function wire() {
866
1025
  });
867
1026
  // pan: drag the background; scroll moves; Ctrl/⌘ + scroll zooms
868
1027
  const vp = $(".dg-vp");
1028
+ vpObserver?.disconnect();
1029
+ if (typeof ResizeObserver === "function") {
1030
+ let size = null;
1031
+ vpObserver = new ResizeObserver(([e]) => {
1032
+ const next = `${Math.round(e.contentRect.width)}x${Math.round(e.contentRect.height)}`;
1033
+ if (size && next !== size && autoFit != null) fit(autoFit);
1034
+ size = next;
1035
+ });
1036
+ vpObserver.observe(vp);
1037
+ }
869
1038
  let drag = null;
870
1039
  vp.addEventListener("pointerdown", (e) => {
871
1040
  if (e.button !== 0 || e.target.closest(".dg-card,button,.dg-zoom")) return;
@@ -877,7 +1046,10 @@ function wire() {
877
1046
  if (!drag) return;
878
1047
  const dx = e.clientX - drag.x,
879
1048
  dy = e.clientY - drag.y;
880
- if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
1049
+ if (Math.abs(dx) + Math.abs(dy) > 3) {
1050
+ drag.moved = true;
1051
+ autoFit = null;
1052
+ }
881
1053
  st.tx = drag.tx + dx;
882
1054
  st.ty = drag.ty + dy;
883
1055
  applyView();
@@ -905,6 +1077,7 @@ function wire() {
905
1077
  e.clientY - r.top,
906
1078
  );
907
1079
  else {
1080
+ autoFit = null;
908
1081
  st.tx -= e.deltaX;
909
1082
  st.ty -= e.deltaY;
910
1083
  applyView();
@@ -917,12 +1090,14 @@ function wire() {
917
1090
  if (z === "in") zoomAt(1.2);
918
1091
  if (z === "out") zoomAt(1 / 1.2);
919
1092
  if (z === "100") {
1093
+ autoFit = null;
920
1094
  st.z = 1;
921
1095
  st.tx = 24;
922
1096
  st.ty = 24;
923
1097
  applyView();
924
1098
  }
925
- if (z === "fit") fit(zoomFloor);
1099
+ if (z === "fit") fit(FIT_MIN);
1100
+ if (z === "max") hooks.maximize?.toggle?.();
926
1101
  });
927
1102
  // panel resize (360–760px, remembered)
928
1103
  let rs = null;
@@ -956,9 +1131,13 @@ document.addEventListener("keydown", (e) => {
956
1131
  const more = menuOwner.get(m) ?? m.previousElementSibling;
957
1132
  closeMenus();
958
1133
  more?.focus();
1134
+ e.preventDefault(); // handled: the page's Esc (restore) waits
959
1135
  return;
960
1136
  }
961
- if (st.sel) closeDetail();
1137
+ if (st.sel) {
1138
+ closeDetail();
1139
+ e.preventDefault();
1140
+ }
962
1141
  return;
963
1142
  }
964
1143
  if (