@monster-orange/den 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  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 +12 -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/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -1,31 +1,72 @@
1
1
  /**
2
- * Shared header logic — service health dots
3
- *
4
- * Both index.html and services.html include this script.
5
- * It expects:
6
- * - a <div id="health"> in the header to render dots into
7
- * - a <select id="env-sel"> whose change event triggers a re-ping
2
+ * The shell's behaviour (shell.js draws the frame: top bar and rail):
3
+ * the health poll that feeds the status chip and drawer (topbar.js), the
4
+ * version pill and changelog, the workspace menu, the env picker (one for
5
+ * the whole shell) and the log badge. Every page imports it. It expects
6
+ * a <select id="env-sel"> (shell.js) whose change event triggers a re-ping.
8
7
  *
9
8
  * Health data is driven entirely by __DEN_CLI__.json manifests — no hardcoded
10
9
  * service names. Each registered service that declares a health.url for
11
- * the current env gets a dot.
10
+ * the current env is counted.
12
11
  */
13
12
 
14
- import {
15
- buildWorkspaceMenuModel,
16
- buildWorkspacePillModel,
17
- } from "./workspace-picker.js";
13
+ import { buildScopeMenuModel } from "./workspace-picker.js";
14
+ import { initAppearance } from "./appearance.js";
15
+ import { versionLabel } from "./version-label.js";
18
16
  import { changelogView } from "./changelog-view.js";
17
+ import { buildRow, healthCycle, paintGate, shellHealth } from "./health-fit.js";
18
+ import { initTopbar, paintChecking, paintStatus } from "./topbar.js";
19
+ import { registerDenSettings } from "./settings-registry.js";
20
+
21
+ /** The shell's message lookup (R19; shell.js MESSAGES). */
22
+ const t = (key, vars) =>
23
+ typeof window !== "undefined" && window.denShell?.t
24
+ ? window.denShell.t(key, vars)
25
+ : key;
26
+ const escT = (key, vars) =>
27
+ String(t(key, vars)).replace(
28
+ /[&<>"]/g,
29
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
30
+ );
31
+
32
+ export {
33
+ isUnlocked,
34
+ onLockChange,
35
+ problemCards,
36
+ problemFix,
37
+ requireUnlock,
38
+ setServiceRunner,
39
+ takePendingAction,
40
+ } from "./topbar.js";
41
+ export { buildRow } from "./health-fit.js";
42
+
43
+ // Every page with this header gets the appearance controls (mode, theme)
44
+ // and the top bar's chips, palette and drawer (topbar.js).
45
+ void initAppearance();
46
+ // Den's SETTINGS rows on every page, not only /settings: each is a ⌘K
47
+ // palette row (kind "setting"), so "terminal" finds Default terminal.
48
+ if (typeof window !== "undefined")
49
+ (window.denShellQueue = window.denShellQueue || []).push((sh) => {
50
+ if (sh?.registerSetting) registerDenSettings(sh);
51
+ });
52
+ if (typeof document !== "undefined")
53
+ initTopbar({
54
+ refreshHealth: (env, opts) => refreshHealth(env, opts),
55
+ healthEnv: () => healthEnv(),
56
+ storeOpenLog: (repo, service, env) => storeOpenLog(repo, service, env),
57
+ });
19
58
 
20
59
  // 5s between polls — measured from each poll's COMPLETION (schedulePoll
21
- // re-arms after renderHealthDots resolves), so slow probes can't overlap.
60
+ // re-arms after renderHealth resolves), so slow probes can't overlap.
22
61
  const POLL_INTERVAL_MS = 5_000;
23
62
 
24
63
  // Last painted health results, keyed `repo/service`, so a new poll can seed
25
64
  // from them and update dots in place instead of collapsing the strip and
26
- // regrowing it (which shifted the whole page below — see renderHealthDots).
65
+ // regrowing it (see renderHealth).
27
66
  let _healthAcc = new Map();
28
67
  let _healthAccGen = -1;
68
+ // The poll generation that last painted _healthAcc (healthCycle's `last`).
69
+ let _healthPaintGen = -1;
29
70
  let pollTimer = null;
30
71
  // Generation token: every (re)start of polling bumps this. A stale tick (an
31
72
  // env the user already switched away from, or a slow fetch resolving late)
@@ -34,46 +75,63 @@ let pollTimer = null;
34
75
  // the dots back to the old env.
35
76
  let pollGen = 0;
36
77
 
37
- // ── Editor preference (cached) ─────────────────────────────────────────────────
38
- let _editorKey = null;
39
- let _editorKeyFetched = false;
40
- async function _loadEditorKey() {
41
- if (_editorKeyFetched) return;
42
- _editorKeyFetched = true;
43
- try {
44
- const data = await fetch("/api/settings").then((r) => r.json());
45
- _editorKey = data.editor ?? null;
46
- } catch {
47
- /* settings unavailable */
48
- }
78
+ // ── The env picker: one for the whole shell (0.3.0-dev.14) ─────────────────────
79
+
80
+ const LS_ENV = "__DEN_CLI__-env";
81
+ let _envReady = null;
82
+
83
+ /**
84
+ * Fill the shell's env picker from /api/environments and restore the last
85
+ * choice (one key for every page). Resolves with the env list once the
86
+ * picker holds the right value; pages await it before reading #env-sel.
87
+ * Started as soon as header.js loads.
88
+ */
89
+ export function envReady() {
90
+ if (_envReady) return _envReady;
91
+ _envReady = (async () => {
92
+ const sel = document.getElementById("env-sel");
93
+ const envs = await fetch("/api/environments")
94
+ .then((r) => r.json())
95
+ .then((v) => (Array.isArray(v) ? v : ["local"]))
96
+ .catch(() => ["local"]);
97
+ if (!sel) return envs;
98
+ const esc = (s) =>
99
+ String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
100
+ sel.innerHTML = envs
101
+ .map((e) => `<option value="${esc(e)}">${esc(e)}</option>`)
102
+ .join("");
103
+ let saved = null;
104
+ try {
105
+ saved = localStorage.getItem(LS_ENV);
106
+ } catch {}
107
+ if (saved && envs.includes(saved)) sel.value = saved;
108
+ // The Env ▾ menu (shell.js) shows and sets this select's value.
109
+ window.denShell?.syncEnv?.();
110
+ sel.addEventListener("change", () => {
111
+ try {
112
+ localStorage.setItem(LS_ENV, sel.value);
113
+ } catch {}
114
+ });
115
+ return envs;
116
+ })();
117
+ return _envReady;
49
118
  }
119
+ if (typeof document !== "undefined" && document.getElementById?.("env-sel"))
120
+ void envReady();
50
121
 
51
- function _injectNavStyles() {
52
- if (document.getElementById("__DEN_CLI__-nav-styles")) return;
53
- const s = document.createElement("style");
54
- s.id = "__DEN_CLI__-nav-styles";
55
- s.textContent = `
56
- .nav-open-btn {
57
- background: none; border: none; color: var(--muted, #8b949e);
58
- cursor: pointer; font-size: 11px; padding: 0 3px; line-height: 1;
59
- opacity: 0; transition: opacity .15s, color .1s;
60
- flex-shrink: 0;
61
- }
62
- .health-item:hover .nav-open-btn,
63
- .health-group:hover .nav-open-btn { opacity: 1; }
64
- .nav-open-btn:hover { color: var(--accent, #58a6ff) !important; }
65
- /* Repo dots boxed by application (Org > Application > Repo). */
66
- .health-app {
67
- display: flex; align-items: center; gap: 8px;
68
- padding: 1px 8px 1px 6px; border: 1px solid var(--border, #30363d);
69
- border-radius: 10px;
70
- }
71
- .health-app-label {
72
- font-size: 10px; font-weight: 600; letter-spacing: .03em;
73
- color: var(--text, #c9d1d9); opacity: .7; white-space: nowrap;
74
- }
75
- `;
76
- document.head.appendChild(s);
122
+ /** The env picker's value: what this page shows. */
123
+ export function currentEnv() {
124
+ return document.getElementById("env-sel")?.value ?? "local";
125
+ }
126
+
127
+ /**
128
+ * The env the health row checks: the page's pin (Local Dev and Releases are
129
+ * local by nature: `data-health-env` on #den-shell), else the env picker.
130
+ */
131
+ export function healthEnv() {
132
+ return (
133
+ document.getElementById("den-shell")?.dataset?.healthEnv || currentEnv()
134
+ );
77
135
  }
78
136
 
79
137
  // ── Changelog modal ────────────────────────────────────────────────────────────
@@ -91,19 +149,19 @@ function _injectChangelogStyles() {
91
149
  const style = document.createElement("style");
92
150
  style.textContent = `
93
151
  .v-pill {
94
- font-family: var(--mono, monospace); font-size: 10px;
152
+ font-family: var(--mono); font-size: 10px;
95
153
  padding: 2px 8px; border-radius: 10px;
96
- border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
154
+ border: 1px solid var(--border); color: var(--muted);
97
155
  background: none; cursor: pointer; user-select: none;
98
156
  transition: color .1s, border-color .1s; position: relative;
99
157
  flex-shrink: 0;
100
158
  }
101
- .v-pill:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
159
+ .v-pill:hover { color: var(--accent-text); border-color: var(--accent); }
102
160
  .v-pip {
103
161
  position: absolute; top: -3px; right: -3px;
104
162
  width: 7px; height: 7px; border-radius: 50%;
105
- background: var(--accent, #58a6ff);
106
- border: 1.5px solid var(--bg, #0d1117);
163
+ background: var(--accent);
164
+ border: 1.5px solid var(--bg);
107
165
  display: none; pointer-events: none;
108
166
  }
109
167
  .cl-overlay {
@@ -113,58 +171,58 @@ function _injectChangelogStyles() {
113
171
  }
114
172
  .cl-overlay.open { display: flex; }
115
173
  .cl-box {
116
- background: var(--surface, #161b22);
117
- border: 1px solid var(--border, #30363d);
174
+ background: var(--surface);
175
+ border: 1px solid var(--border);
118
176
  border-radius: 10px; width: min(580px, 92vw); max-height: 78vh;
119
177
  display: flex; flex-direction: column; overflow: hidden;
120
- box-shadow: 0 8px 40px rgba(0,0,0,.65);
178
+ box-shadow: var(--shadow);
121
179
  }
122
180
  .cl-hdr {
123
181
  display: flex; align-items: center; gap: 10px;
124
- padding: 13px 18px; border-bottom: 1px solid var(--border, #30363d); flex-shrink: 0;
182
+ padding: 13px 18px; border-bottom: 1px solid var(--border); flex-shrink: 0;
125
183
  }
126
184
  .cl-hdr h3 {
127
- font-family: var(--mono, monospace); font-size: 13px; font-weight: 600;
128
- color: var(--text, #c9d1d9); flex: 1; margin: 0;
185
+ font-family: var(--mono); font-size: 13px; font-weight: 600;
186
+ color: var(--text); flex: 1; margin: 0;
129
187
  }
130
188
  .cl-close {
131
- background: none; border: none; color: var(--muted, #8b949e);
189
+ background: none; border: none; color: var(--muted);
132
190
  font-size: 18px; cursor: pointer; line-height: 1;
133
191
  padding: 2px 5px; border-radius: 3px; transition: color .1s, background .1s;
134
192
  }
135
- .cl-close:hover { color: var(--text, #c9d1d9); background: var(--border, #30363d); }
193
+ .cl-close:hover { color: var(--text); background: var(--border); }
136
194
  .cl-body { overflow-y: auto; padding: 16px 18px; flex: 1; }
137
195
  .cl-entry { margin-bottom: 18px; }
138
196
  .cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
139
197
  .cl-ver {
140
198
  margin: 0;
141
- font-family: var(--mono, monospace); font-size: 13px; font-weight: 700;
142
- color: var(--accent, #58a6ff);
199
+ font-family: var(--mono); font-size: 13px; font-weight: 700;
200
+ color: var(--accent-text);
143
201
  }
144
- .cl-date { font-size: 11px; color: var(--muted, #8b949e); }
145
- .cl-note { font-size: 12px; color: var(--muted, #8b949e); margin: 0 0 8px; font-style: italic; }
202
+ .cl-date { font-size: 11px; color: var(--muted); }
203
+ .cl-note { font-size: 12px; color: var(--muted); margin: 0 0 8px; font-style: italic; }
146
204
  .cl-sec {
147
205
  font-size: 10px; font-weight: 600; text-transform: uppercase;
148
- letter-spacing: .07em; color: var(--muted, #8b949e); margin: 8px 0 4px;
206
+ letter-spacing: .07em; color: var(--muted); margin: 8px 0 4px;
149
207
  }
150
208
  .cl-items { list-style: none; padding: 0; margin: 0; }
151
209
  .cl-items li {
152
- font-size: 12px; color: var(--text, #c9d1d9);
210
+ font-size: 12px; color: var(--text);
153
211
  padding: 2px 0 2px 14px; position: relative; line-height: 1.5;
154
212
  }
155
- .cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted, #8b949e); }
213
+ .cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted); }
156
214
  .cl-subitems { list-style: none; padding: 0; margin: 2px 0 0 0; }
157
- .cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted, #8b949e); }
215
+ .cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted); }
158
216
  .cl-subitems li::before { content: "–"; position: absolute; left: 2px; }
159
217
  .cl-items code {
160
- font-family: var(--mono, monospace); font-size: 10.5px;
161
- color: var(--accent, #58a6ff); background: rgba(88,166,255,.1);
218
+ font-family: var(--mono); font-size: 10.5px;
219
+ color: var(--accent-text); background: color-mix(in srgb, var(--accent) 10%, transparent);
162
220
  padding: 1px 4px; border-radius: 3px;
163
221
  }
164
- .cl-divider { border: none; border-top: 1px solid var(--border, #30363d); margin: 14px 0; }
222
+ .cl-divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
165
223
  .cl-copy-group { display: flex; gap: 4px; margin-left: auto; flex-shrink: 0; }
166
224
  .cl-copy-btn, .cl-slack-btn {
167
- background: none; border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
225
+ background: none; border: 1px solid var(--border); color: var(--muted);
168
226
  padding: 3px 6px; border-radius: 4px; line-height: 0;
169
227
  cursor: pointer; flex-shrink: 0;
170
228
  display: inline-flex; align-items: center; justify-content: center;
@@ -172,22 +230,22 @@ function _injectChangelogStyles() {
172
230
  }
173
231
  .cl-more-btn {
174
232
  margin-right: auto; padding: 4px 12px;
175
- font: inherit; font-size: 11px; color: var(--muted, #8b949e);
176
- background: none; border: 1px solid var(--border, #30363d); border-radius: 6px;
233
+ font: inherit; font-size: 11px; color: var(--muted);
234
+ background: none; border: 1px solid var(--border); border-radius: 6px;
177
235
  cursor: pointer;
178
236
  }
179
- .cl-more-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
180
- .cl-copy-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
181
- .cl-copy-btn.copied { color: #3fb950; border-color: #3fb950; }
182
- .cl-slack-btn:hover { color: #e01e5a; border-color: #e01e5a; }
183
- .cl-slack-btn.copied { color: #3fb950; border-color: #3fb950; }
237
+ .cl-more-btn:hover { color: var(--accent-text); border-color: var(--accent); }
238
+ .cl-copy-btn:hover { color: var(--accent-text); border-color: var(--accent); }
239
+ .cl-copy-btn.copied { color: var(--ok); border-color: var(--ok); }
240
+ .cl-slack-btn:hover { color: var(--accent-text); border-color: var(--accent); }
241
+ .cl-slack-btn.copied { color: var(--ok); border-color: var(--ok); }
184
242
  .cl-footer {
185
- padding: 10px 18px; border-top: 1px solid var(--border, #30363d);
243
+ padding: 10px 18px; border-top: 1px solid var(--border);
186
244
  display: flex; justify-content: flex-end; flex-shrink: 0;
187
- background: var(--surface, #161b22);
245
+ background: var(--surface);
188
246
  }
189
247
  .cl-got-it {
190
- background: var(--accent, #58a6ff); border: none; color: #000;
248
+ background: var(--accent-fill); border: none; color: var(--on-accent);
191
249
  padding: 5px 16px; border-radius: 5px; font-size: 12px;
192
250
  font-weight: 600; cursor: pointer;
193
251
  }
@@ -201,9 +259,11 @@ function _injectChangelogStyles() {
201
259
  overlay.className = "cl-overlay";
202
260
  overlay.innerHTML =
203
261
  '<div class="cl-box" role="dialog" aria-modal="true" aria-labelledby="cl-title">' +
204
- '<div class="cl-hdr"><h3 id="cl-title" tabindex="-1">What\'s new</h3><button type="button" class="cl-close" id="cl-close-btn" title="Close" aria-label="Close">×</button></div>' +
262
+ `<div class="cl-hdr"><h3 id="cl-title" tabindex="-1">${escT("cl.title")}</h3><button type="button" class="cl-close" id="cl-close-btn" title="${escT("cl.close")}" aria-label="${escT("cl.close")}">×</button></div>` +
205
263
  '<div class="cl-body" id="cl-body"></div>' +
206
- '<div class="cl-footer"><button type="button" class="cl-more-btn" id="cl-more" aria-controls="cl-body" aria-expanded="false" hidden></button><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
264
+ '<div class="cl-footer"><button type="button" class="cl-more-btn" id="cl-more" aria-controls="cl-body" aria-expanded="false" hidden></button><button class="cl-got-it" id="cl-got-it-btn">' +
265
+ escT("cl.gotIt") +
266
+ "</button></div>" +
207
267
  "</div>";
208
268
  document.body.appendChild(overlay);
209
269
  document.getElementById("cl-more")?.addEventListener("click", () => {
@@ -224,50 +284,41 @@ function _injectChangelogStyles() {
224
284
  ?.addEventListener("click", () => window.closeChangelog());
225
285
 
226
286
  document.addEventListener("keydown", (e) => {
227
- if (!_changelogIsOpen()) return;
228
- if (e.key === "Escape") window.closeChangelog();
229
- else if (e.key === "Tab") _trapChangelogTab(e);
287
+ if (!overlay.classList.contains("open")) return;
288
+ if (e.key === "Escape") {
289
+ e.preventDefault();
290
+ window.closeChangelog();
291
+ } else if (e.key === "Tab") _trapChangelogTab(e, overlay);
230
292
  });
231
293
  }
232
294
 
233
- function _changelogIsOpen() {
234
- return !!document.getElementById("cl-overlay")?.classList.contains("open");
235
- }
236
-
237
- /** The dialog's focusable controls, in order (hidden ones excluded). */
238
- function _changelogFocusables() {
239
- const box = document.querySelector("#cl-overlay .cl-box");
240
- if (!box) return [];
241
- return [...box.querySelectorAll("button, a[href], [tabindex]")].filter(
242
- (el) => !el.hidden && el.getAttribute("tabindex") !== "-1",
243
- );
244
- }
245
-
246
- /** Keep Tab inside the open dialog (aria-modal). */
247
- function _trapChangelogTab(e) {
248
- const els = _changelogFocusables();
249
- if (!els.length) return;
250
- const first = els[0];
251
- const last = els[els.length - 1];
252
- const inside = els.includes(document.activeElement);
253
- if (e.shiftKey && (document.activeElement === first || !inside)) {
295
+ /** Tab and Shift+Tab stay inside the open dialog (aria-modal). */
296
+ function _trapChangelogTab(e, overlay) {
297
+ const box = overlay.querySelector(".cl-box");
298
+ const stops = [
299
+ ...box.querySelectorAll("button, a[href], [tabindex]:not([tabindex='-1'])"),
300
+ ].filter((el) => !el.hidden && !el.disabled && el.getClientRects().length);
301
+ if (!stops.length) return;
302
+ const first = stops[0];
303
+ const last = stops[stops.length - 1];
304
+ const at = document.activeElement;
305
+ // Anything not one of the stops (the focused heading, or outside) counts
306
+ // as outside: Shift+Tab from the heading must not leave the dialog.
307
+ const inside = stops.includes(at);
308
+ if (e.shiftKey ? at === first || !inside : at === last) {
254
309
  e.preventDefault();
255
- last.focus();
256
- } else if (!e.shiftKey && (document.activeElement === last || !inside)) {
310
+ (e.shiftKey ? last : first).focus();
311
+ } else if (!inside) {
257
312
  e.preventDefault();
258
313
  first.focus();
259
314
  }
260
315
  }
261
316
 
262
- /** What had focus before the dialog opened; it gets it back on close. */
263
- let _clReturnFocus = null;
264
-
265
317
  function _renderChangelogBody(entries) {
266
318
  const body = document.getElementById("cl-body");
267
319
  if (!body) return;
268
320
  if (!entries.length) {
269
- body.innerHTML =
270
- '<p style="color:var(--muted,#8b949e);font-size:12px">No entries found.</p>';
321
+ body.innerHTML = `<p style="color:var(--muted);font-size:12px">${escT("cl.none")}</p>`;
271
322
  return;
272
323
  }
273
324
  const escHtml = (s) =>
@@ -295,9 +346,11 @@ function _renderChangelogBody(entries) {
295
346
  '<div class="cl-copy-group">' +
296
347
  '<button class="cl-copy-btn" data-cl-version="' +
297
348
  escHtml(entry.version) +
298
- '" title="Copy as Markdown" aria-label="Copy v' +
299
- escHtml(entry.version) +
300
- ' as Markdown">' +
349
+ '" title="' +
350
+ escT("cl.copyMd") +
351
+ '" aria-label="' +
352
+ escT("cl.copyMdX", { v: entry.version }) +
353
+ '">' +
301
354
  '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
302
355
  '<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
303
356
  '<path d="M8.5 9v2.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.5"/>' +
@@ -305,9 +358,11 @@ function _renderChangelogBody(entries) {
305
358
  "</button>" +
306
359
  '<button class="cl-slack-btn" data-cl-version="' +
307
360
  escHtml(entry.version) +
308
- '" title="Copy for Slack" aria-label="Copy v' +
309
- escHtml(entry.version) +
310
- ' for Slack">' +
361
+ '" title="' +
362
+ escT("cl.copySlack") +
363
+ '" aria-label="' +
364
+ escT("cl.copySlackX", { v: entry.version }) +
365
+ '">' +
311
366
  '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
312
367
  '<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
313
368
  "</svg>" +
@@ -352,7 +407,11 @@ function _renderChangelogBody(entries) {
352
407
  if (more) {
353
408
  more.hidden = !view.toggle;
354
409
  if (view.toggle) {
355
- more.textContent = view.toggle.label;
410
+ // changelog-view.js's labels are its en; the button says them in
411
+ // the page's language.
412
+ more.textContent = view.toggle.expanded
413
+ ? t("cl.latest")
414
+ : t("cl.all", { n: entries.length - 1 });
356
415
  more.setAttribute("aria-expanded", String(view.toggle.expanded));
357
416
  }
358
417
  }
@@ -387,8 +446,8 @@ function _renderChangelogBody(entries) {
387
446
  btn.classList.remove("copied");
388
447
  }, 2000);
389
448
  } catch {
390
- btn.title = "Copy failed — try again";
391
- setTimeout(() => (btn.title = "Copy as Markdown"), 2000);
449
+ btn.title = t("cl.copyFailed");
450
+ setTimeout(() => (btn.title = t("cl.copyMd")), 2000);
392
451
  }
393
452
  });
394
453
  });
@@ -410,8 +469,8 @@ function _renderChangelogBody(entries) {
410
469
  btn.classList.remove("copied");
411
470
  }, 2000);
412
471
  } catch {
413
- btn.title = "Copy failed — try again";
414
- setTimeout(() => (btn.title = "Copy for Slack"), 2000);
472
+ btn.title = t("cl.copyFailed");
473
+ setTimeout(() => (btn.title = t("cl.copySlack")), 2000);
415
474
  }
416
475
  });
417
476
  });
@@ -521,23 +580,27 @@ function _buildMarkdown(entry) {
521
580
  return lines.join("\n");
522
581
  }
523
582
 
583
+ // Where focus goes back to when the dialog closes.
584
+ let _clReturnFocus = null;
585
+
524
586
  window.openChangelog = function () {
525
587
  _clShowAll = false;
526
588
  _injectChangelogStyles();
527
589
  _renderChangelogBody(_changelogEntries);
528
590
  const overlay = document.getElementById("cl-overlay");
529
591
  if (!overlay) return;
530
- if (!overlay.classList.contains("open")) {
531
- // A first-visit auto-open has nothing focused: return to the pill.
532
- const active = document.activeElement;
592
+ const wasOpen = overlay.classList.contains("open");
593
+ if (!wasOpen) {
594
+ const at = document.activeElement;
595
+ // Nothing focused (a first-visit auto-open): the version pill.
533
596
  _clReturnFocus =
534
- active && active !== document.body
535
- ? active
597
+ at && at !== document.body && !overlay.contains(at)
598
+ ? at
536
599
  : document.getElementById("version-pill");
537
600
  }
538
601
  overlay.classList.add("open");
539
- // After .open: while the overlay is display:none the body cannot scroll,
540
- // and a reset made then does not stick. Always open at the newest release.
602
+ // Always opens at the newest release. After `.open`: while the overlay is
603
+ // display:none the body has no scroll box, so a reset there doesn't stick.
541
604
  const body = document.getElementById("cl-body");
542
605
  if (body) body.scrollTop = 0;
543
606
  // The heading first, so a screen reader starts at the latest notes.
@@ -578,20 +641,16 @@ export async function initChangelog() {
578
641
 
579
642
  const latest = _changelogEntries[0];
580
643
  const pill = document.getElementById("version-pill");
581
- if (pill && latest?.version) {
582
- pill.querySelector(".v-txt").textContent = "v" + latest.version;
644
+ if (pill) {
645
+ let running = null;
646
+ try {
647
+ running = (await fetch("/api/server-info").then((r) => r.json())).version;
648
+ } catch {
649
+ /* fall back to the changelog's newest version */
650
+ }
651
+ const label = versionLabel(running, _changelogEntries);
652
+ if (label) pill.querySelector(".v-txt").textContent = label;
583
653
  }
584
- // The pill names the running build (0.2.5-dev.2, not the changelog's
585
- // newest heading, 0.2.5), so a prerelease under test says what it is.
586
- fetch("/api/server-info")
587
- .then((r) => r.json())
588
- .then((info) => {
589
- const txt = pill?.querySelector(".v-txt");
590
- if (txt && typeof info?.version === "string" && info.version) {
591
- txt.textContent = "v" + info.version;
592
- }
593
- })
594
- .catch(() => {});
595
654
 
596
655
  let lastSeen = null;
597
656
  try {
@@ -624,7 +683,7 @@ async function getDenConfig() {
624
683
  }
625
684
 
626
685
  /**
627
- * Apply __DEN_CLI__ config to the nav — hide "Local Dev" when local environment is disabled.
686
+ * Apply __DEN_CLI__ config to the nav — hide Workspaces when the local environment is disabled.
628
687
  * Call once on page load from each page's init function.
629
688
  */
630
689
  export async function applyDenConfig() {
@@ -633,7 +692,7 @@ export async function applyDenConfig() {
633
692
  const localEnabled = config.environments?.includes("local") ?? true;
634
693
  if (!localEnabled) {
635
694
  document
636
- .querySelectorAll("nav a[href='/'], nav a[href='/local-dev']")
695
+ .querySelectorAll(".den-rail a[href='/local-dev']")
637
696
  .forEach((el) => {
638
697
  el.style.display = "none";
639
698
  });
@@ -646,18 +705,20 @@ export async function applyDenConfig() {
646
705
  * before the product name: "Globex › __DEN_CLI__".
647
706
  */
648
707
  function _renderOrgCrumb(org) {
649
- const h1 = document.querySelector("header h1");
708
+ // The shell's scope has an org part (the prototype's "/ ORG name").
709
+ if (window.denShell?.setOrg?.(org)) return;
710
+ const h1 = document.querySelector(".den-top .den-title, header h1");
650
711
  if (!h1 || !org?.title || document.getElementById("org-crumb")) return;
651
712
  const crumb = document.createElement("span");
652
713
  crumb.id = "org-crumb";
653
- crumb.title = `Org: ${org.title} (${org.id})`;
714
+ crumb.title = t("org.titleId", { title: org.title, id: org.id });
654
715
  crumb.style.cssText =
655
- "font-size:12px;font-weight:600;color:var(--text,#c9d1d9);" +
716
+ "font-size:12px;font-weight:600;color:var(--text);" +
656
717
  "white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:8px";
657
718
  crumb.textContent = org.title;
658
719
  const sep = document.createElement("span");
659
720
  sep.textContent = "›";
660
- sep.style.cssText = "color:var(--muted,#8b949e);font-weight:400";
721
+ sep.style.cssText = "color:var(--muted);font-weight:400";
661
722
  crumb.appendChild(sep);
662
723
  h1.parentNode.insertBefore(crumb, h1);
663
724
  }
@@ -706,243 +767,127 @@ export function groupReposByApplication(repos, apps) {
706
767
  return out;
707
768
  }
708
769
 
709
- // ── Workspace pill ────────────────────────────────────────────────────────────
710
-
711
- // Set when a page opts into switching (initWorkspacePill's `onSwitch`): the
712
- // pill becomes a dropdown of every known workspace instead of a static label.
713
- let _wsOnSwitch = null;
714
- let _wsMenuOpen = false;
770
+ // ── Workspace: the scope breadcrumb's Workspace ▾ menu (R4) ─────────────────
715
771
 
716
772
  /**
717
- * Render the active workspace name into <span id="ws-pill">.
718
- * Pass the id of the env <select> to show the pill only when env is "local".
719
- * Omit envSelId (or pass null) to always show it (e.g. Local Dev page).
773
+ * Feed the shell's Workspace part (shell.js #ws-pill, a menu): "All active"
774
+ * and each active workspace, the focused one marked. Choosing one sets the
775
+ * view scope (POST /api/scope, a preference) for every page. It replaces
776
+ * dev.17's workspace pill and its focus menu (one control, not two): the
777
+ * focus moves only through the menu's separate "Set <ws> as focus".
720
778
  *
721
- * Pass `onSwitch` to make the pill a workspace picker: choosing an entry POSTs
722
- * `use` (activate + focus, same as `__DEN_CLI__ workspace use`) and then calls
723
- * onSwitch(name) so the page can reload whatever it derives from the focused
724
- * workspace. Pages with their own workspace UI (Local Dev) omit it.
779
+ * The arguments are dev.17's (an env select, an onSwitch callback) and are
780
+ * no longer used: the view applies to every env.
725
781
  */
726
- export async function initWorkspacePill(envSelId = null, { onSwitch } = {}) {
727
- const envSel = envSelId ? document.getElementById(envSelId) : null;
728
- _wsOnSwitch = onSwitch ?? null;
729
- await _renderPill(envSel);
730
- if (envSel) {
731
- envSel.addEventListener("change", () => _renderPill(envSel));
732
- }
782
+ export async function initWorkspacePill() {
783
+ await _renderScopeMenu();
733
784
  }
734
785
 
735
- /** Re-fetch and re-render the workspace pill (call after switching workspaces). */
786
+ /** Re-fetch and repaint the Workspace part (after a workspace change). */
736
787
  export async function refreshWorkspacePill() {
737
- const envSel = document.getElementById("env-sel");
738
- await _renderPill(envSel);
788
+ await _renderScopeMenu();
739
789
  }
740
790
 
741
- async function _renderPill(envSel) {
742
- const el = document.getElementById("ws-pill");
743
- if (!el) return;
744
- const isLocal = !envSel || envSel.value === "local";
745
- if (!isLocal) {
746
- _closeWorkspaceMenu();
747
- _pillHost(el).style.display = "none";
748
- return;
749
- }
791
+ async function _renderScopeMenu() {
792
+ const sh = window.denShell;
793
+ if (!sh?.setWorkspaces) return;
750
794
  try {
751
- const data = await fetch("/api/workspaces").then((r) => r.json());
752
- const { label, title } = buildWorkspacePillModel(data);
753
- if (_wsOnSwitch) {
754
- _renderPickerPill(el, data, label, title);
755
- } else {
756
- el.textContent = label;
757
- el.title = data.active?.length > 1 ? title : "";
758
- }
759
- // The pill starts as inline display:none in every page's markup; clear it
760
- // on the pill AND its picker wrapper, or the wrapper shows an empty box.
761
- el.style.display = "";
762
- _pillHost(el).style.display = "";
795
+ const [data, scope] = await Promise.all([
796
+ fetch("/api/workspaces").then((r) => r.json()),
797
+ fetch("/api/scope")
798
+ .then((r) => (r.ok ? r.json() : null))
799
+ .catch(() => null),
800
+ ]);
801
+ sh.setWorkspaces({
802
+ ...buildScopeMenuModel(pageScope(scope, data), data, t),
803
+ onSelect: _switchScope,
804
+ onFocus: _setFocus,
805
+ });
763
806
  } catch {
764
- _closeWorkspaceMenu();
765
- _pillHost(el).style.display = "none";
807
+ sh.setWorkspaces(null);
766
808
  }
767
809
  }
768
810
 
769
811
  /**
770
- * The element whose visibility tracks the pill's: the wrapper once the picker
771
- * has been built (so the menu is hidden with it), the pill itself before that.
812
+ * The Graph page draws what its ?ws= names (it wins over the stored view,
813
+ * 0.3.0-dev.33), so its pill shows that workspace too: the pill, the URL,
814
+ * the header's line and the graph agree (0.3.0-dev.34, a consumer's
815
+ * finding). Elsewhere, and for a ?ws= that names no active workspace,
816
+ * the stored view.
772
817
  */
773
- function _pillHost(el) {
774
- return el.parentElement?.classList.contains("ws-pill-wrap")
775
- ? el.parentElement
776
- : el;
777
- }
778
-
779
- function _injectWorkspacePickerStyles() {
780
- if (document.getElementById("__DEN_CLI__-ws-picker-styles")) return;
781
- const s = document.createElement("style");
782
- s.id = "__DEN_CLI__-ws-picker-styles";
783
- s.textContent = `
784
- .ws-pill-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
785
- #ws-pill.ws-pill-btn { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 5px; }
786
- #ws-pill.ws-pill-btn:hover, #ws-pill.ws-pill-btn.open { border-color: var(--accent, #58a6ff); }
787
- .ws-pill-caret { font-size: 8px; opacity: .7; }
788
- .ws-menu {
789
- display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 200;
790
- background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);
791
- border-radius: 6px; min-width: 190px; max-height: 280px; overflow-y: auto;
792
- box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 3px 0;
793
- }
794
- .ws-menu.open { display: block; }
795
- .ws-menu-item {
796
- display: flex; align-items: center; gap: 8px; width: 100%;
797
- background: none; border: none; text-align: left;
798
- padding: 6px 12px; cursor: pointer; font-size: 12px;
799
- font-family: var(--mono, monospace); color: var(--text, #c9d1d9); white-space: nowrap;
800
- }
801
- .ws-menu-item:hover { background: var(--border, #30363d); }
802
- .ws-menu-item[disabled] { cursor: default; }
803
- .ws-menu-mark { width: 10px; flex-shrink: 0; color: var(--accent, #58a6ff); }
804
- .ws-menu-tag { margin-left: auto; font-size: 10px; color: var(--muted, #8b949e); }
805
- `;
806
- document.head.appendChild(s);
818
+ function pageScope(scope, data) {
819
+ if (location.pathname !== "/graph") return scope;
820
+ const ws = new URLSearchParams(location.search).get("ws");
821
+ const active = Array.isArray(data?.active)
822
+ ? data.active
823
+ : data?.active
824
+ ? [data.active]
825
+ : [];
826
+ return ws && active.includes(ws) ? { ...scope, scope: ws } : scope;
807
827
  }
808
828
 
809
- /** Turn the static pill into a button + dropdown (idempotent). */
810
- function _renderPickerPill(el, data, label, title) {
811
- _injectWorkspacePickerStyles();
812
-
813
- let wrap = el.parentElement;
814
- if (!wrap?.classList.contains("ws-pill-wrap")) {
815
- wrap = document.createElement("span");
816
- wrap.className = "ws-pill-wrap";
817
- el.replaceWith(wrap);
818
- wrap.appendChild(el);
819
- el.classList.add("ws-pill-btn");
820
- el.setAttribute("role", "button");
821
- el.setAttribute("tabindex", "0");
822
- el.addEventListener("click", () => _toggleWorkspaceMenu());
823
- el.addEventListener("keydown", (e) => {
824
- if (e.key === "Enter" || e.key === " ") {
825
- e.preventDefault();
826
- _toggleWorkspaceMenu();
827
- }
828
- });
829
- }
830
-
831
- el.replaceChildren();
832
- const name = document.createElement("span");
833
- name.textContent = label;
834
- const caret = document.createElement("span");
835
- caret.className = "ws-pill-caret";
836
- caret.textContent = "▾";
837
- el.append(name, caret);
838
- el.title = title;
839
-
840
- let menu = wrap.querySelector(".ws-menu");
841
- if (!menu) {
842
- menu = document.createElement("div");
843
- menu.className = "ws-menu";
844
- wrap.appendChild(menu);
845
- }
846
- _paintWorkspaceMenu(menu, data);
829
+ /**
830
+ * On the Graph page a choice replaces ?ws= (All active drops it, a
831
+ * workspace sets it), so the reload shows the choice rather than the old
832
+ * ?ws=, which would win over it. The history entry is replaced, not
833
+ * pushed: Back does not return to a view the picker has left.
834
+ */
835
+ function followScopeInUrl(value) {
836
+ if (location.pathname !== "/graph") return;
837
+ const u = new URL(location.href);
838
+ if (value === "all") u.searchParams.delete("ws");
839
+ else u.searchParams.set("ws", value);
840
+ history.replaceState(history.state, "", u.pathname + u.search + u.hash);
847
841
  }
848
842
 
849
- function _paintWorkspaceMenu(menu, data) {
850
- const { items } = buildWorkspaceMenuModel(data);
851
- menu.replaceChildren();
852
- if (items.length === 0) {
853
- const empty = document.createElement("button");
854
- empty.className = "ws-menu-item";
855
- empty.disabled = true;
856
- empty.textContent = "No workspaces";
857
- menu.appendChild(empty);
843
+ /**
844
+ * A view filter only: POST /api/scope stores the preference (user.yml
845
+ * `view`), then the page reloads to show the new view whole. No service,
846
+ * container, daemon or route changes, and the focus stays where it is.
847
+ */
848
+ async function _switchScope(value) {
849
+ try {
850
+ await window.denShell.api("POST", "/api/scope", { scope: value });
851
+ } catch (err) {
852
+ if (err?.denApi) {
853
+ err.message = t("ws.viewFailed", { msg: err.message });
854
+ window.denShell.errorToast(err);
855
+ } else window.denShell?.toast?.(t("ws.viewFailed", { msg: err.message }));
858
856
  return;
859
857
  }
860
- for (const item of items) {
861
- const btn = document.createElement("button");
862
- btn.className = "ws-menu-item";
863
- btn.type = "button";
864
- // Selecting an inactive workspace activates it (POST `use`), which only
865
- // rewrites workspaces.json — no services are started.
866
- btn.title = item.focused
867
- ? "Current workspace"
868
- : item.active
869
- ? "Focus this workspace"
870
- : "Activate and focus this workspace";
871
-
872
- const mark = document.createElement("span");
873
- mark.className = "ws-menu-mark";
874
- mark.textContent = item.focused ? "●" : "";
875
- const name = document.createElement("span");
876
- name.textContent = item.name;
877
- btn.append(mark, name);
878
-
879
- if (!item.active) {
880
- const tag = document.createElement("span");
881
- tag.className = "ws-menu-tag";
882
- tag.textContent = "not active";
883
- btn.appendChild(tag);
884
- }
885
-
886
- btn.addEventListener("click", () => _switchWorkspace(item));
887
- menu.appendChild(btn);
888
- }
889
- }
890
-
891
- function _toggleWorkspaceMenu(force = null) {
892
- const menu = document.querySelector(".ws-pill-wrap .ws-menu");
893
- const pill = document.getElementById("ws-pill");
894
- if (!menu) return;
895
- _wsMenuOpen = force ?? !_wsMenuOpen;
896
- menu.classList.toggle("open", _wsMenuOpen);
897
- pill?.classList.toggle("open", _wsMenuOpen);
898
- }
899
-
900
- function _closeWorkspaceMenu() {
901
- if (_wsMenuOpen) _toggleWorkspaceMenu(false);
858
+ followScopeInUrl(value);
859
+ location.reload();
902
860
  }
903
861
 
904
- document.addEventListener("click", (e) => {
905
- if (_wsMenuOpen && !e.target.closest(".ws-pill-wrap")) _closeWorkspaceMenu();
906
- });
907
- document.addEventListener("keydown", (e) => {
908
- if (e.key === "Escape") _closeWorkspaceMenu();
909
- });
910
-
911
- async function _switchWorkspace(item) {
912
- _closeWorkspaceMenu();
913
- if (item.focused) return;
914
- const onSwitch = _wsOnSwitch;
862
+ /**
863
+ * "Set <ws> as focus": Local Dev's focus action (POST /api/workspaces
864
+ * `focus`, which writes workspaces.yml and retargets the CLI). Separate
865
+ * from the view: it is the only thing in the menu that moves the focus.
866
+ */
867
+ async function _setFocus(name) {
915
868
  try {
916
- const res = await fetch("/api/workspaces", {
917
- method: "POST",
918
- headers: { "Content-Type": "application/json" },
919
- // `use` = activate + focus, matching `__DEN_CLI__ workspace use`. Focus alone
920
- // is rejected by the server for a workspace that is not active yet.
921
- body: JSON.stringify({ name: item.name, action: "use" }),
869
+ await window.denShell.api("POST", "/api/workspaces", {
870
+ name,
871
+ action: "focus",
922
872
  });
923
- if (!res.ok) {
924
- const text = await res.text().catch(() => "");
925
- throw new Error(text || `HTTP ${res.status}`);
926
- }
927
873
  } catch (err) {
928
- alert(`Could not switch to workspace "${item.name}": ${err.message}`);
874
+ if (err?.denApi) {
875
+ err.message = t("ws.focusFailed", { msg: err.message });
876
+ window.denShell.errorToast(err);
877
+ } else window.denShell?.toast?.(t("ws.focusFailed", { msg: err.message }));
929
878
  return;
930
879
  }
931
- await refreshWorkspacePill();
932
- try {
933
- await onSwitch?.(item.name);
934
- } catch {
935
- /* the page's own refresh failing must not break the switch */
936
- }
880
+ location.reload();
937
881
  }
938
882
 
939
- // ── Health dots ───────────────────────────────────────────────────────────────
883
+ // ── Health: the status chip and drawer (topbar.js, R6 and R15) ─────────────
940
884
 
941
885
  /** Call once on page load. Does NOT manage env changes — caller handles that. */
942
886
  export async function initHealth() {
943
- const env = document.getElementById("env-sel")?.value ?? "local";
887
+ await envReady();
888
+ const env = healthEnv();
944
889
  const gen = ++pollGen;
945
- await renderHealthDots(env, gen);
890
+ await renderHealth(env, gen);
946
891
  schedulePoll(env, gen);
947
892
  }
948
893
 
@@ -950,120 +895,75 @@ export async function initHealth() {
950
895
  export async function refreshHealth(env, { force = false } = {}) {
951
896
  const gen = ++pollGen;
952
897
  clearTimeout(pollTimer);
953
- setCheckingState(env);
954
- await renderHealthDots(env, gen, force);
898
+ // The chip goes neutral while the new env is checked: never red from
899
+ // the old env's answers, never a partial count.
900
+ if (env !== _healthEnvPainted) paintChecking();
901
+ await renderHealth(env, gen, force);
955
902
  schedulePoll(env, gen);
956
903
  }
957
904
 
958
905
  // Background tabs throttle chained timers (Chrome clamps to ~1/min after a few
959
- // hidden minutes), so the dots freeze while hidden — refresh immediately on
906
+ // hidden minutes), so the chip freezes while hidden — refresh immediately on
960
907
  // return instead of showing pre-hide state for up to another minute.
961
908
  document.addEventListener("visibilitychange", () => {
962
909
  if (!document.hidden && pollGen > 0) {
963
- refreshHealth(document.getElementById("env-sel")?.value ?? "local");
910
+ refreshHealth(healthEnv());
964
911
  }
965
912
  });
966
913
 
967
- /** Immediately show all dots as "checking" so the user can see the env changed. */
968
- function setCheckingState(env) {
969
- const container = document.getElementById("health");
970
- if (!container) return;
971
- // Pulse all dots yellow while we re-ping
972
- container.querySelectorAll(".dot").forEach((d) => {
973
- d.className = "dot running";
974
- });
975
- }
976
-
977
914
  function schedulePoll(env, gen) {
978
915
  if (gen !== pollGen) return; // superseded by an env switch/re-init
979
916
  clearTimeout(pollTimer);
980
917
  pollTimer = setTimeout(async () => {
981
918
  try {
982
- await renderHealthDots(env, gen);
919
+ await renderHealth(env, gen);
983
920
  } finally {
984
921
  // ALWAYS re-arm: a thrown/aborted tick must not silently kill polling
985
- // (that froze the dots at their last state until a page reload).
922
+ // (that froze the chip at its last state until a page reload).
986
923
  schedulePoll(env, gen);
987
924
  }
988
925
  }, POLL_INTERVAL_MS);
989
926
  }
990
927
 
991
928
  /**
992
- * Stream health results, painting each batch as it lands.
993
- *
994
- * The checks run concurrently but the slowest are shell probes that shell out
995
- * to the aws CLI (~0.45s each), so waiting for the whole set made every dot
996
- * resolve at the pace of the worst one. Streaming paints ~75% of them inside
997
- * 200ms. Batched through requestAnimationFrame so 30 results cause a handful of
998
- * repaints, not 30.
929
+ * Stream health results and paint them through the gate (health-fit.js):
930
+ * a first cycle (a page load, an env switch) paints once, complete, so a
931
+ * cold load never shows a partial count or a flash of red; later polls
932
+ * seed from the last one and repaint debounced.
999
933
  */
1000
- async function renderHealthDots(env, gen = pollGen, force = false) {
1001
- const container = document.getElementById("health");
1002
- if (!container) return;
1003
-
1004
- await Promise.all([_loadEditorKey(), _loadApps()]);
1005
- _injectNavStyles();
1006
-
1007
- // Merge into the PREVIOUS poll's results rather than starting empty.
1008
- //
1009
- // Each poll streams results as checks complete, repainting per batch. Painting
1010
- // only what has landed so far made the strip collapse (13 items → 2) and
1011
- // regrow on every 5s poll; because the strip sits above the content, the whole
1012
- // page below it — the dependency graph especially — visibly jumped ~30px each
1013
- // time. Seeding from the last known set keeps the item count stable, so a poll
1014
- // updates dot COLOURS in place instead of rebuilding the row.
1015
- const acc = new Map(gen === _healthAccGen ? _healthAcc : []);
934
+ async function renderHealth(env, gen = pollGen, force = false) {
935
+ await _loadApps();
936
+ const seed = gen === _healthAccGen ? [..._healthAcc] : [];
1016
937
  _healthAccGen = gen;
1017
- const keyOf = (r) => `${r.repo}/${r.service}`;
1018
- let scheduled = false;
1019
- const paint = () => {
1020
- scheduled = false;
1021
- if (gen !== pollGen) return; // a newer poll owns the DOM now
1022
- _healthAcc = acc;
1023
- _paintHealthDots(container, env, _sortHealth([...acc.values()]));
1024
- };
1025
- const schedule = () => {
1026
- if (scheduled) return;
1027
- scheduled = true;
1028
- requestAnimationFrame(paint);
1029
- };
1030
-
1031
- const fresh = new Map();
1032
- const streamed = await _streamHealth(env, force, (r) => {
1033
- acc.set(keyOf(r), r);
1034
- fresh.set(keyOf(r), r);
1035
- schedule();
938
+ await healthCycle({
939
+ seed,
940
+ gate: _healthGate,
941
+ stream: (onResult) => _streamHealth(env, force, onResult),
942
+ // Stream unavailable or broken (older server, a proxy that buffers SSE,
943
+ // a dropped connection): the plain endpoint completes the cycle.
944
+ fetchAll: () =>
945
+ fetch(`/api/health?env=${env}${force ? "&refresh=1" : ""}`, {
946
+ signal: AbortSignal.timeout(10_000),
947
+ })
948
+ .then((r) => r.json())
949
+ .catch(() => null),
950
+ // What the chip shows now, for a paint before this cycle completes.
951
+ last: () => (_healthPaintGen === gen ? _healthAcc : null),
952
+ paint: (m) => {
953
+ _healthAcc = m;
954
+ _healthPaintGen = gen;
955
+ _paintHealth(env, _sortHealth([...m.values()]));
956
+ },
957
+ current: () => gen === pollGen,
1036
958
  });
1037
-
1038
- if (!streamed) {
1039
- // Stream unavailable (older server, proxy that buffers SSE) — fall back to
1040
- // the plain endpoint so the dots still resolve.
1041
- try {
1042
- const rs = await fetch(
1043
- `/api/health?env=${env}${force ? "&refresh=1" : ""}`,
1044
- {
1045
- signal: AbortSignal.timeout(10_000),
1046
- },
1047
- ).then((r) => r.json());
1048
- fresh.clear();
1049
- for (const r of rs) fresh.set(keyOf(r), r);
1050
- } catch {
1051
- /* server unreachable */
1052
- }
1053
- }
1054
- if (gen !== pollGen) return;
1055
- // Settle on exactly what THIS poll returned, so a service removed from the
1056
- // workspace disappears instead of lingering from the seeded set.
1057
- if (fresh.size > 0) {
1058
- _healthAcc = fresh;
1059
- _paintHealthDots(container, env, _sortHealth([...fresh.values()]));
1060
- }
1061
959
  }
960
+ const _healthGate = paintGate();
961
+ let _healthEnvPainted = null;
1062
962
 
1063
963
  /**
1064
964
  * Stable display order. `idx` is the server's graph-ranked position (entry repo
1065
965
  * first … infra-stack last); results arrive in completion order, which differs
1066
- * every poll, so sorting by it is what keeps the dots from reshuffling. Falls
966
+ * every poll, so sorting by it is what keeps the drawer from reshuffling. Falls
1067
967
  * back to repo name when the server doesn't send `idx`.
1068
968
  */
1069
969
  function _sortHealth(results) {
@@ -1077,18 +977,21 @@ function _sortHealth(results) {
1077
977
 
1078
978
  /**
1079
979
  * Read /api/health/stream to completion, calling onResult per check.
1080
- * Resolves true if the stream ran, false if the caller should fall back.
980
+ * Resolves "done" when the server ended the cycle (every check answered),
981
+ * "partial" when it broke after some results, "none" when it gave nothing:
982
+ * only "done" is a whole cycle (healthCycle).
1081
983
  */
1082
984
  function _streamHealth(env, force, onResult) {
1083
985
  return new Promise((resolve) => {
1084
986
  let es;
1085
987
  let got = false;
1086
988
  // Backstop: a hung stream must not stall the poll chain forever.
989
+ const broken = () => (got ? "partial" : "none");
1087
990
  const guard = setTimeout(() => {
1088
991
  try {
1089
992
  es?.close();
1090
993
  } catch {}
1091
- resolve(got);
994
+ resolve(broken());
1092
995
  }, 15_000);
1093
996
  const done = (ok) => {
1094
997
  clearTimeout(guard);
@@ -1105,7 +1008,7 @@ function _streamHealth(env, force, onResult) {
1105
1008
  );
1106
1009
  } catch {
1107
1010
  clearTimeout(guard);
1108
- return resolve(false);
1011
+ return resolve("none");
1109
1012
  }
1110
1013
  es.onmessage = (e) => {
1111
1014
  let msg;
@@ -1118,149 +1021,36 @@ function _streamHealth(env, force, onResult) {
1118
1021
  got = true;
1119
1022
  onResult(msg.result);
1120
1023
  } else if (msg.type === "done") {
1121
- done(true);
1024
+ done("done");
1122
1025
  } else if (msg.type === "error") {
1123
- done(got);
1026
+ done(broken());
1124
1027
  }
1125
1028
  };
1126
- // Errors before any result mean "fall back"; after results, keep what we got.
1127
- es.onerror = () => done(got);
1029
+ // An error ends the stream: before any result, fall back; after some,
1030
+ // the cycle is partial and /api/health completes it.
1031
+ es.onerror = () => done(broken());
1128
1032
  });
1129
1033
  }
1130
1034
 
1131
- /** One repo's dot + label in the health row. */
1132
- function _repoHealthItem(repo, items, escAttr) {
1133
- // Show open-in-editor icon if editor is configured and any item is dev-managed
1134
- const devItem = items.find((i) => i.mode === "dev-managed" && i.repoPath);
1135
- const openIcon =
1136
- _editorKey && devItem
1137
- ? `<button class="nav-open-btn" data-open-path="${escAttr(devItem.repoPath)}" title="Open in editor">↗</button>`
1138
- : "";
1139
-
1140
- const isSingle = items.length === 1 && items[0].service === repo;
1141
- if (isSingle) {
1142
- const { service, ok, status, error } = items[0];
1143
- // What was checked: the URL, or a shell check's command.
1144
- const what = items[0].url ?? items[0].check;
1145
- const state = ok === true ? "ok" : ok === false ? "err" : "";
1146
- const tip =
1147
- ok == null
1148
- ? error
1149
- ? `${what} — ${error}`
1150
- : "no health check"
1151
- : error
1152
- ? `${what} — ${error}`
1153
- : `${what} → ${status}`;
1154
- return `<div class="health-item" title="${escAttr(tip)}">
1155
- <span class="dot ${state}" id="h-${repo}-${service}"></span>
1156
- <span>${repo}</span>
1157
- ${openIcon}
1158
- </div>`;
1159
- }
1160
- // Multi-service repo: ONE dot for the system's main component (the
1161
- // entrypoint), so a down app isn't masked green by its shared-infra
1162
- // sidecars. An entrypoint-less infra provider (infra-stack) falls back
1163
- // to an aggregate across its subsystems.
1164
- const entry = items.find((i) => i.isEntrypoint);
1165
- let overallState, tip;
1166
- if (entry) {
1167
- overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
1168
- tip =
1169
- entry.ok == null
1170
- ? entry.error
1171
- ? `${entry.url ?? entry.check} — ${entry.error}`
1172
- : "no health check"
1173
- : entry.error
1174
- ? `${entry.url ?? entry.check} — ${entry.error}`
1175
- : `${entry.url ?? entry.check} → ${entry.status}`;
1176
- } else {
1177
- // Only a completed, failed check (ok false) is down; ok null (no check,
1178
- // or no answer yet) is unknown and never turns the dot red.
1179
- const allOk = items.every((i) => i.ok === true);
1180
- const anyOk = items.some((i) => i.ok === true);
1181
- const anyDown = items.some((i) => i.ok === false);
1182
- overallState = allOk ? "ok" : anyOk ? "running" : anyDown ? "err" : "";
1183
- tip = `${items.length} services`;
1184
- }
1185
- return `<div class="health-item" title="${escAttr(tip)}">
1186
- <span class="dot ${overallState}"></span>
1187
- <span>${repo}</span>
1188
- ${openIcon}
1189
- </div>`;
1190
- }
1191
-
1192
- function _paintHealthDots(container, env, results) {
1193
- const escAttr = (s) =>
1194
- String(s ?? "")
1195
- .replace(/&/g, "&amp;")
1196
- .replace(/"/g, "&quot;");
1197
-
1198
- if (!results.length) {
1199
- container.innerHTML = `<div class="health-item" style="color:var(--muted);font-size:11px">${env}: no services</div>`;
1200
- } else {
1201
- // Group by repo so multi-service repos don't produce duplicate labels.
1202
- const byRepo = new Map();
1203
- for (const item of results) {
1204
- if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
1205
- byRepo.get(item.repo).push(item);
1206
- }
1207
- const repoHtml = new Map(
1208
- [...byRepo.entries()].map(([repo, items]) => [
1209
- repo,
1210
- _repoHealthItem(repo, items, escAttr),
1211
- ]),
1212
- );
1213
- container.innerHTML = groupReposByApplication([...byRepo.keys()], _apps)
1214
- .map(({ app, repos }) => {
1215
- const inner = repos.map((r) => repoHtml.get(r)).join("");
1216
- if (!app) return inner;
1217
- const tip = [app.title, app.owner, app.description]
1218
- .filter(Boolean)
1219
- .join(" · ");
1220
- return `<div class="health-app" title="${escAttr(tip)}"><span class="health-app-label">${escAttr(app.name)}</span>${inner}</div>`;
1221
- })
1222
- .join("");
1223
-
1224
- // Wire open-in-editor click handlers (data-open-path avoids inline onclick escaping)
1225
- container
1226
- .querySelectorAll(".nav-open-btn[data-open-path]")
1227
- .forEach((btn) => {
1228
- btn.addEventListener("click", (e) => {
1229
- e.stopPropagation();
1230
- fetch("/api/editor/open", {
1231
- method: "POST",
1232
- headers: { "Content-Type": "application/json" },
1233
- body: JSON.stringify({ path: btn.dataset.openPath }),
1234
- }).then((r) => {
1235
- if (!r.ok)
1236
- r.json().then((d) =>
1237
- console.warn("[__DEN_CLI__] open-in-editor:", d.error),
1238
- );
1239
- });
1240
- });
1241
- });
1242
- }
1035
+ /**
1036
+ * One painted cycle: the shared rollup (buildRow, health-rollup.js) feeds
1037
+ * the rail (the Services badge and pinned dots) and the status chip and
1038
+ * drawer alike, so they never disagree (R6, R13): a down service counts
1039
+ * only once its repo's checks have all answered, pending and unchecked
1040
+ * ones never do.
1041
+ */
1042
+ function _paintHealth(env, results) {
1043
+ const repos = [...new Set(results.map((r) => r.repo))];
1044
+ const groups = groupReposByApplication(repos, _apps);
1045
+ const row = buildRow(results, groups);
1046
+ window.denShell?.setState(shellHealth(row));
1047
+ _healthEnvPainted = env;
1048
+ paintStatus({ row, groups, env });
1243
1049
 
1244
1050
  // Broadcast results so other components (e.g. dep graph) can stay in sync
1245
1051
  document.dispatchEvent(
1246
1052
  new CustomEvent("__DEN_CLI__:health-update", { detail: results }),
1247
1053
  );
1248
-
1249
- // Refresh button — shown on every page, no per-page HTML changes needed
1250
- const btn = document.createElement("button");
1251
- btn.title = "Refresh service health";
1252
- btn.textContent = "⟳";
1253
- btn.style.cssText =
1254
- "background:none;border:none;color:var(--muted);cursor:pointer;" +
1255
- "font-size:13px;padding:1px 4px;border-radius:3px;line-height:1;margin-left:4px;" +
1256
- "transition:color .1s;";
1257
- btn.addEventListener("mouseenter", () => (btn.style.color = "var(--text)"));
1258
- btn.addEventListener("mouseleave", () => (btn.style.color = "var(--muted)"));
1259
- btn.addEventListener("click", () => {
1260
- const currentEnv = document.getElementById("env-sel")?.value ?? env;
1261
- refreshHealth(currentEnv); // pulses dots yellow while re-pinging
1262
- });
1263
- container.appendChild(btn);
1264
1054
  }
1265
1055
 
1266
1056
  // ── Log badge (shared across pages) ───────────────────────────────────────────
@@ -1279,16 +1069,14 @@ export function initLogBadge() {
1279
1069
  });
1280
1070
  }
1281
1071
 
1072
+ /** The Logs page's NAV badge (shell.js) reads `openLogs`. */
1282
1073
  function _updateBadgeFromStorage() {
1283
- const el = document.getElementById("log-badge");
1284
- if (!el) return;
1285
1074
  try {
1286
1075
  const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
1287
1076
  const count = Array.isArray(logs) ? logs.length : 0;
1288
- el.textContent = count;
1289
- el.style.display = count > 0 ? "inline" : "none";
1077
+ window.denShell?.setState({ openLogs: count });
1290
1078
  } catch {
1291
- el.style.display = "none";
1079
+ window.denShell?.setState({ openLogs: 0 });
1292
1080
  }
1293
1081
  }
1294
1082
 
@@ -1317,7 +1105,7 @@ export function storeOpenLog(repo, service, env) {
1317
1105
  export function computePrimaryByRepo(svcList) {
1318
1106
  const primary = new Map();
1319
1107
  const rank = new Map();
1320
- for (const s of svcList || []) {
1108
+ for (const s of Array.isArray(svcList) ? svcList : []) {
1321
1109
  const r = s.isEntrypoint ? 2 : s.service === s.repo ? 1 : 0;
1322
1110
  if (!primary.has(s.repo) || r > (rank.get(s.repo) ?? -1)) {
1323
1111
  primary.set(s.repo, s.address);
@@ -1346,31 +1134,31 @@ function _injectStaleStyles() {
1346
1134
  const s = document.createElement("style");
1347
1135
  s.textContent = `
1348
1136
  .stale-pill {
1349
- font-family: var(--mono, monospace); font-size: 10px; font-weight: 600;
1137
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
1350
1138
  padding: 2px 9px; border-radius: 10px; cursor: pointer; user-select: none;
1351
- border: 1px solid var(--yellow, #d29922); color: var(--yellow, #d29922);
1352
- background: rgba(210,153,34,0.12);
1139
+ border: 1px solid var(--warn); color: var(--warn);
1140
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
1353
1141
  display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
1354
1142
  animation: stale-pulse 2s ease-in-out infinite;
1355
1143
  }
1356
- .stale-pill:hover { color: #0d1117; background: var(--yellow, #d29922); }
1144
+ .stale-pill:hover { color: var(--bg); background: var(--warn); }
1357
1145
  .stale-pill b { font-weight: 700; }
1358
1146
  @keyframes stale-pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
1359
1147
  .restart-overlay {
1360
- position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,.82);
1148
+ position: fixed; inset: 0; z-index: 2000; background: color-mix(in srgb, var(--bg) 90%, transparent);
1361
1149
  display: flex; align-items: center; justify-content: center;
1362
1150
  flex-direction: column; gap: 16px;
1363
- color: var(--text, #c9d1d9); font-family: var(--mono, monospace);
1151
+ color: var(--text); font-family: var(--mono);
1364
1152
  }
1365
1153
  .restart-overlay .spinner {
1366
1154
  width: 30px; height: 30px; border-radius: 50%;
1367
- border: 3px solid var(--border, #30363d); border-top-color: var(--accent, #58a6ff);
1155
+ border: 3px solid var(--border); border-top-color: var(--accent);
1368
1156
  animation: stale-spin .8s linear infinite;
1369
1157
  }
1370
1158
  @keyframes stale-spin { to { transform: rotate(360deg); } }
1371
1159
  .restart-overlay .msg { font-size: 13px; }
1372
1160
  .restart-overlay .hint {
1373
- font-size: 11px; color: var(--muted, #8b949e);
1161
+ font-size: 11px; color: var(--muted);
1374
1162
  max-width: 380px; text-align: center; line-height: 1.5;
1375
1163
  }
1376
1164
  `;
@@ -1385,16 +1173,15 @@ function _renderStalePill(info) {
1385
1173
  }
1386
1174
  _injectStaleStyles();
1387
1175
  if (!pill) {
1388
- const header = document.querySelector("header");
1176
+ const header =
1177
+ document.querySelector(".den-top") || document.querySelector("header");
1389
1178
  if (!header) return;
1390
1179
  pill = document.createElement("button");
1391
1180
  pill.id = "stale-pill";
1392
1181
  pill.className = "stale-pill";
1393
- pill.title =
1394
- "This __DEN_CLI__ web server is running code older than your working tree — " +
1395
- "click to restart and pick up your changes. Running deploys continue on " +
1396
- "GitHub; an open deploy log reattaches after the reload.";
1397
- pill.innerHTML = "⚠ stale server · <b>Restart</b>";
1182
+ pill.title = t("stale.title", { cli: "__DEN_CLI__" });
1183
+ pill.setAttribute("aria-label", t("stale.aria"));
1184
+ pill.innerHTML = `<span aria-hidden="true">⚠</span><span class="stale-lbl"> ${escT("stale.label")} · <b>${escT("stale.restart")}</b></span>`;
1398
1185
  pill.addEventListener("click", _requestRestart);
1399
1186
  const vpill = document.getElementById("version-pill");
1400
1187
  if (vpill && vpill.parentNode)
@@ -1420,6 +1207,13 @@ async function _staleTick() {
1420
1207
  if (info && info.pid != null)
1421
1208
  _lastServerInfo = { pid: info.pid, startedAt: info.startedAt };
1422
1209
  _renderStalePill(info);
1210
+ // MD-2: a newer engine on disk than the one this server runs.
1211
+ if (info && "engineStale" in info)
1212
+ window.denShell?.setEngine?.({
1213
+ stale: info.engineStale === true,
1214
+ loaded: info.loaded?.version ?? null,
1215
+ onDisk: info.onDisk?.version ?? null,
1216
+ });
1423
1217
  setTimeout(_staleTick, SERVER_INFO_POLL_MS);
1424
1218
  }
1425
1219
 
@@ -1449,8 +1243,8 @@ function _showRestartOverlay() {
1449
1243
  o.className = "restart-overlay";
1450
1244
  o.innerHTML =
1451
1245
  '<div class="spinner"></div>' +
1452
- '<div class="msg">Restarting __DEN_CLI__ web…</div>' +
1453
- '<div class="hint" id="restart-hint">Reconnecting — this page will reload automatically.</div>';
1246
+ `<div class="msg">${escT("restart.msg", { cli: "__DEN_CLI__" })}</div>` +
1247
+ `<div class="hint" id="restart-hint">${escT("restart.hint")}</div>`;
1454
1248
  document.body.appendChild(o);
1455
1249
  }
1456
1250
 
@@ -1467,8 +1261,10 @@ async function _pollForNewServer(before, startedAt) {
1467
1261
  if (Date.now() - startedAt > 90_000) {
1468
1262
  const hint = document.getElementById("restart-hint");
1469
1263
  if (hint)
1470
- hint.innerHTML =
1471
- "Still restarting… if this persists, run <b>__DEN_CLI__ web restart</b> in a terminal, then reload.";
1264
+ hint.innerHTML = escT("restart.still", { cmd: "\u0000" }).replace(
1265
+ "\u0000",
1266
+ "<b>__DEN_CLI__ web restart</b>",
1267
+ );
1472
1268
  }
1473
1269
  setTimeout(() => _pollForNewServer(before, startedAt), 1500);
1474
1270
  }