@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
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Appearance: the user menu (R10, ◐ ▾ at the end of the top bar: a .pop
3
+ * menu with Appearance (System / Dark / Light), Theme (the installed
4
+ * themes), a hidden Language slot (dev.23) and All settings), the same
5
+ * choices as ⌘K palette commands (R17), and the active theme's mark at the
6
+ * start of the header.
7
+ *
8
+ * A choice is saved to /api/settings (`appearance`, user.yml) and applied
9
+ * without a reload: data-mode on <html>, /theme.css swapped for a fresh
10
+ * copy (the server renders the theme the settings now name), and the mark
11
+ * redrawn. Nothing else changes: no edge, no services, no restart. Each
12
+ * page's inline pre-paint script sets the first data-mode the same way
13
+ * effectiveMode does.
14
+ *
15
+ * The Theme menu's first entry is "Org default (<name>)": no choice of your
16
+ * own (appearance.theme null), so you follow the org config's brand.theme.
17
+ * Picking the theme that is the org default stores null too (the server
18
+ * does that), so you keep following it.
19
+ *
20
+ * The Settings page (R21) drives the same prefs through setAppearance; any
21
+ * change, from either, fires `den:appearance` on window and both repaint.
22
+ */
23
+
24
+ /**
25
+ * The en strings of this module's pure exports, for where there is no
26
+ * shell (a unit test). In a page every lookup is the shell's t (R19), whose
27
+ * en is the same text (i18n.test.ts holds them equal).
28
+ */
29
+ export const EN_FALLBACK = {
30
+ "um.label": "Appearance",
31
+ "um.title": "Appearance and theme",
32
+ "um.appearance": "Appearance",
33
+ "um.system": "System",
34
+ "um.dark": "Dark",
35
+ "um.light": "Light",
36
+ "um.theme": "Theme",
37
+ "um.language": "Language",
38
+ "um.all": "All settings",
39
+ "cmd.appearance": "Appearance: {mode}",
40
+ "cmd.theme": "Theme: {name}",
41
+ "cmd.language": "Language: {name}",
42
+ "theme.orgDefault": "Org default ({name})",
43
+ "theme.pinned": "{name} (pinned)",
44
+ "theme.notInstalled": "Your theme {id} isn't installed, so {inUse} is in use",
45
+ "theme.invalid": "Your theme {id} isn't valid ({why}), so {inUse} is in use",
46
+ "theme.invalidBare": "Your theme {id} isn't valid, so {inUse} is in use",
47
+ };
48
+ function t(key, vars) {
49
+ const sh = typeof window !== "undefined" ? window.denShell : null;
50
+ if (sh?.t) return sh.t(key, vars);
51
+ return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
52
+ vars && vars[k] != null ? String(vars[k]) : m,
53
+ );
54
+ }
55
+
56
+ /** The mode to draw in: yours, else the system's, else the theme's, else dark. */
57
+ export function effectiveMode(mode, system, defaultMode) {
58
+ if (mode === "dark" || mode === "light") return mode;
59
+ if (system === "dark" || system === "light") return system;
60
+ return defaultMode === "light" ? "light" : "dark";
61
+ }
62
+
63
+ /**
64
+ * What the header's mark is, for the active theme's `mark` (from
65
+ * /api/themes) in a mode: its SVG (the dark variant in dark mode, if it has
66
+ * one), else its PNG or WebP the same way (a raster, drawn in the mark's
67
+ * shape), else text Den draws in the mark's shape, else the theme name's
68
+ * first letter. Pure, for tests.
69
+ */
70
+ export function markView(mark, mode, themeName) {
71
+ const m = mark || {};
72
+ const shape = ["square", "rounded", "circle"].includes(m.shape)
73
+ ? m.shape
74
+ : "rounded";
75
+ if (m.svg)
76
+ return {
77
+ kind: "svg",
78
+ src: mode === "dark" && m.svg_dark ? m.svg_dark : m.svg,
79
+ };
80
+ for (const k of ["png", "webp"])
81
+ if (m[k])
82
+ return {
83
+ kind: "raster",
84
+ src: mode === "dark" && m[`${k}_dark`] ? m[`${k}_dark`] : m[k],
85
+ shape,
86
+ };
87
+ const text = (m.text || (themeName || "").trim().charAt(0) || "·").slice(
88
+ 0,
89
+ 3,
90
+ );
91
+ return { kind: "text", text, shape };
92
+ }
93
+
94
+ /**
95
+ * The Theme menu: "Org default (<name>)" first (value "", stored as null),
96
+ * then every theme that passes validate. Selected: the org default when you
97
+ * have no choice of your own, else your theme, even when it is the org's
98
+ * theme: picking it by name pins it, and it reads "<name> (pinned)" so the
99
+ * menu tells a pin from following the org. A stored choice that is no
100
+ * longer usable is never offered, and nothing is selected: themeNotice says
101
+ * what happened instead, so it never reads as "Org default". Pure, for tests.
102
+ */
103
+ export function themeOptions(themes, prefsTheme, orgDefault) {
104
+ const usable = (themes || []).filter((th) => th.ok);
105
+ const chosen = usable.some((th) => th.id === prefsTheme) ? prefsTheme : null;
106
+ return [
107
+ {
108
+ value: "",
109
+ label: t("theme.orgDefault", { name: orgDefault?.name || "Den" }),
110
+ selected: !prefsTheme,
111
+ },
112
+ ...usable.map((th) => ({
113
+ value: th.id,
114
+ label:
115
+ th.id === chosen && th.id === orgDefault?.id
116
+ ? t("theme.pinned", { name: th.name })
117
+ : th.name,
118
+ selected: th.id === chosen,
119
+ })),
120
+ ];
121
+ }
122
+
123
+ /**
124
+ * One line for the menu when your stored theme isn't usable: "Your theme
125
+ * <id> isn't valid (<first problem>), so <name> is in use", from
126
+ * /api/themes' ok:false entry; null when your choice is fine or you have
127
+ * none. Pure, for tests.
128
+ */
129
+ export function themeNotice(themes, prefsTheme, active) {
130
+ if (!prefsTheme) return null;
131
+ const th = (themes || []).find((x) => x.id === prefsTheme);
132
+ if (th?.ok) return null;
133
+ const inUse = active?.name || active?.id || "Den";
134
+ if (!th) return t("theme.notInstalled", { id: prefsTheme, inUse });
135
+ // The validator's problem is output: it is quoted, not translated.
136
+ const first = String(th.problem || "")
137
+ .split("; ")[0]
138
+ .trim();
139
+ return first
140
+ ? t("theme.invalid", { id: prefsTheme, why: first, inUse })
141
+ : t("theme.invalidBare", { id: prefsTheme, inUse });
142
+ }
143
+
144
+ const st = {
145
+ mode: "system",
146
+ defaultMode: "dark",
147
+ themes: [],
148
+ active: null,
149
+ prefsTheme: null,
150
+ orgDefault: null,
151
+ };
152
+
153
+ function systemMode() {
154
+ const q = (s) =>
155
+ typeof window !== "undefined" &&
156
+ window.matchMedia &&
157
+ window.matchMedia(`(prefers-color-scheme: ${s})`).matches;
158
+ return q("light") ? "light" : q("dark") ? "dark" : null;
159
+ }
160
+
161
+ function currentMode() {
162
+ return effectiveMode(st.mode, systemMode(), st.defaultMode);
163
+ }
164
+
165
+ /**
166
+ * The browser-tab icon for the active theme in a mode: the URL the server
167
+ * gave for that mode (it carries the theme, its version, the mode and a
168
+ * hash of the file, so every switch is a new URL), else null. Pure, for
169
+ * tests.
170
+ */
171
+ export function faviconHref(active, mode) {
172
+ const f = active?.favicon;
173
+ if (!f) return null;
174
+ return (mode === "dark" ? f.dark : f.light) || f.light || null;
175
+ }
176
+
177
+ function applyMode() {
178
+ document.documentElement.dataset.mode = currentMode();
179
+ renderMark();
180
+ const href = faviconHref(st.active, currentMode());
181
+ if (href) window.denShell?.setFavicon?.(href);
182
+ }
183
+
184
+ /** Draw the active theme's mark into #den-mark (each page has one). */
185
+ function renderMark() {
186
+ const host = document.getElementById("den-mark");
187
+ if (!host || !st.active) return;
188
+ const v = markView(st.active.mark, currentMode(), st.active.name);
189
+ const key = JSON.stringify(v);
190
+ if (host.dataset.mark === key) return;
191
+ host.dataset.mark = key;
192
+ host.title = st.active.name || "";
193
+ if (v.kind === "svg" || v.kind === "raster") {
194
+ const img = document.createElement("img");
195
+ img.src = v.src;
196
+ img.alt = "";
197
+ img.width = 18;
198
+ img.height = 18;
199
+ // A raster is a rectangle: the mark's shape clips it.
200
+ host.className =
201
+ v.kind === "svg"
202
+ ? "den-mark den-mark-svg"
203
+ : `den-mark den-mark-raster den-mark-${v.shape}`;
204
+ host.replaceChildren(img);
205
+ } else {
206
+ host.className = `den-mark den-mark-text den-mark-${v.shape}`;
207
+ host.dataset.len = String(v.text.length);
208
+ host.textContent = v.text;
209
+ }
210
+ }
211
+
212
+ /** Load /theme.css again; drop the old sheet once the new one has loaded. */
213
+ function reloadThemeCss() {
214
+ const old = document.getElementById("theme-css");
215
+ const next = document.createElement("link");
216
+ next.rel = "stylesheet";
217
+ next.href = `/theme.css?t=${Date.now()}`;
218
+ const done = () => {
219
+ old?.remove();
220
+ next.id = "theme-css";
221
+ // The new tokens are in: swatches and the like re-read them.
222
+ changed("css");
223
+ };
224
+ next.onload = done;
225
+ next.onerror = done;
226
+ if (old) old.after(next);
227
+ else document.head.appendChild(next);
228
+ }
229
+
230
+ async function loadThemes() {
231
+ const r = await fetch("/api/themes");
232
+ if (!r.ok) throw new Error(`/api/themes: ${r.status}`);
233
+ const data = await r.json();
234
+ st.themes = data.themes || [];
235
+ st.active = data.active || null;
236
+ st.orgDefault = data.org_default || null;
237
+ st.prefsTheme = data.prefs?.theme || null;
238
+ st.mode = data.prefs?.mode || "system";
239
+ st.defaultMode = data.active?.default_mode || "dark";
240
+ return data;
241
+ }
242
+
243
+ /** A copy of what this module knows: themes, your prefs, the org default. */
244
+ export function appearanceState() {
245
+ return {
246
+ mode: st.mode,
247
+ defaultMode: st.defaultMode,
248
+ themes: st.themes.slice(),
249
+ active: st.active,
250
+ prefsTheme: st.prefsTheme,
251
+ orgDefault: st.orgDefault,
252
+ effectiveMode: currentMode(),
253
+ };
254
+ }
255
+
256
+ function changed(what) {
257
+ try {
258
+ window.dispatchEvent(
259
+ new CustomEvent("den:appearance", { detail: { what } }),
260
+ );
261
+ } catch {
262
+ /* no CustomEvent */
263
+ }
264
+ }
265
+
266
+ /** Load the themes and prefs if nothing has yet (the Settings page). */
267
+ export async function ensureAppearance() {
268
+ if (!st.themes.length) await loadThemes();
269
+ return appearanceState();
270
+ }
271
+
272
+ /**
273
+ * Change mode and/or theme ({ mode?, theme? }; theme "" or null is the org
274
+ * default), as the ◐ menu does: save to /api/settings `appearance`, apply
275
+ * without a reload, repaint the menu, fire `den:appearance`. Throws when
276
+ * the server refuses (nothing changes then).
277
+ */
278
+ export async function setAppearance(patch) {
279
+ const prev = st.mode;
280
+ if (patch.mode !== undefined) {
281
+ st.mode = patch.mode;
282
+ applyMode();
283
+ }
284
+ try {
285
+ await save(patch);
286
+ } catch (err) {
287
+ st.mode = prev;
288
+ applyMode();
289
+ syncMenu();
290
+ changed("prefs");
291
+ throw err;
292
+ }
293
+ if (patch.theme !== undefined) {
294
+ await loadThemes();
295
+ applyMode();
296
+ reloadThemeCss();
297
+ }
298
+ syncMenu();
299
+ changed("prefs");
300
+ }
301
+
302
+ /**
303
+ * Repaint after a change elsewhere: the user menu is drawn from the state
304
+ * each time it opens, so only the palette's Theme rows need redoing.
305
+ */
306
+ function syncMenu() {
307
+ registerCommands();
308
+ }
309
+
310
+ async function save(patch) {
311
+ // R26c: the shell's one client path (its error names the server's
312
+ // message and request id); a page without the shell, a plain fetch.
313
+ const api = typeof window !== "undefined" ? window.denShell?.api : null;
314
+ if (api) {
315
+ await api("POST", "/api/settings", { appearance: patch });
316
+ return;
317
+ }
318
+ const r = await fetch("/api/settings", {
319
+ method: "POST",
320
+ headers: { "Content-Type": "application/json" },
321
+ body: JSON.stringify({ appearance: patch }),
322
+ });
323
+ if (!r.ok) throw new Error(await r.text());
324
+ }
325
+
326
+ function injectStyles() {
327
+ if (document.getElementById("appearance-styles")) return;
328
+ const s = document.createElement("style");
329
+ s.id = "appearance-styles";
330
+ s.textContent = `
331
+ .den-mark {
332
+ display: inline-flex; align-items: center; justify-content: center;
333
+ width: 18px; height: 18px; flex-shrink: 0; overflow: hidden;
334
+ }
335
+ .den-mark img { display: block; width: 18px; height: 18px; }
336
+ /* Scaled, never cropped: non-square art keeps its edges. */
337
+ .den-mark-raster img { object-fit: contain; }
338
+ .den-mark-text {
339
+ font: 700 11px/1 var(--display); letter-spacing: -0.02em;
340
+ background: var(--accent-fill); color: var(--on-accent);
341
+ }
342
+ .den-mark-text[data-len="2"] { font-size: 9px; }
343
+ .den-mark-text[data-len="3"] { font-size: 7px; }
344
+ .den-mark-square { border-radius: 2px; }
345
+ .den-mark-rounded { border-radius: 5px; }
346
+ .den-mark-circle { border-radius: 50%; }
347
+ `;
348
+ document.head.appendChild(s);
349
+ }
350
+
351
+ /** Switch the mode: drawn at once, then saved (reverted if the save fails). */
352
+ export async function setMode(mode) {
353
+ const prev = st.mode;
354
+ st.mode = mode;
355
+ applyMode();
356
+ registerCommands();
357
+ try {
358
+ await save({ mode });
359
+ } catch {
360
+ st.mode = prev;
361
+ applyMode();
362
+ registerCommands();
363
+ }
364
+ changed("prefs");
365
+ }
366
+
367
+ /** Switch the theme ("" or null: follow the org default), live. */
368
+ export async function setTheme(value) {
369
+ try {
370
+ await save({ theme: value || null });
371
+ await loadThemes();
372
+ applyMode();
373
+ reloadThemeCss();
374
+ } catch {
375
+ /* keep what was there */
376
+ }
377
+ registerCommands();
378
+ changed("prefs");
379
+ }
380
+
381
+ /** The user menu's model (shell.js openMenu's items). */
382
+ export function userMenu() {
383
+ const opts = themeOptions(st.themes, st.prefsTheme, st.orgDefault);
384
+ const notice = themeNotice(st.themes, st.prefsTheme, st.active);
385
+ // "All settings": the Settings page, at Appearance (R10, R21).
386
+ const settings = "/settings#appearance";
387
+ const sh = window.denShell;
388
+ const here = sh?.lang?.() ?? "en";
389
+ return {
390
+ label: t("um.label"),
391
+ items: [
392
+ { heading: t("um.appearance") },
393
+ ...MODES.map(([v, key]) => ({
394
+ label: t(key),
395
+ checked: st.mode === v,
396
+ onSelect: () => setMode(v),
397
+ })),
398
+ { hr: true },
399
+ { heading: t("um.theme") },
400
+ ...opts.map((o) => ({
401
+ label: o.label,
402
+ checked: o.selected,
403
+ onSelect: () => (o.selected ? null : setTheme(o.value)),
404
+ })),
405
+ ...(notice ? [{ note: notice }] : []),
406
+ // Language (R19, 0.3.0-dev.39): each named in itself; a pick saves
407
+ // the pref and reloads the page in it.
408
+ ...(sh?.languages
409
+ ? [
410
+ { hr: true },
411
+ { heading: t("um.language") },
412
+ ...sh.languages().map((l) => ({
413
+ label: l.name,
414
+ checked: l.id === here,
415
+ onSelect: () => (l.id === here ? null : sh.setLanguage(l.id)),
416
+ })),
417
+ ]
418
+ : []),
419
+ { hr: true },
420
+ { label: t("um.all"), href: settings },
421
+ ],
422
+ };
423
+ }
424
+
425
+ const MODES = [
426
+ ["system", "um.system"],
427
+ ["dark", "um.dark"],
428
+ ["light", "um.light"],
429
+ ];
430
+
431
+ /**
432
+ * The palette's Appearance and Theme rows (R17), registered with the
433
+ * shell's COMMANDS (again after each change: the theme list can change).
434
+ */
435
+ function registerCommands() {
436
+ const sh = window.denShell;
437
+ if (!sh?.registerCommand) return;
438
+ for (const [v, key] of MODES)
439
+ sh.registerCommand({
440
+ id: `appearance-${v}`,
441
+ label: t("cmd.appearance", { mode: t(key) }),
442
+ keywords: ["mode", "appearance", "theme", v],
443
+ kind: "action",
444
+ run: () => setMode(v),
445
+ });
446
+ for (const c of sh.commands?.() ?? [])
447
+ if (c.id.startsWith("theme-") || c.id.startsWith("language-"))
448
+ sh.unregisterCommand(c.id);
449
+ for (const o of themeOptions(st.themes, st.prefsTheme, st.orgDefault))
450
+ sh.registerCommand({
451
+ id: `theme-${o.value || "org-default"}`,
452
+ label: t("cmd.theme", { name: o.label }),
453
+ keywords: ["theme", "brand", "colors", o.value],
454
+ kind: "action",
455
+ run: () => setTheme(o.value),
456
+ });
457
+ // Language (R19, R17): after Appearance and Theme, so the palette's first
458
+ // rows stay as they were.
459
+ for (const l of sh.languages?.() ?? [])
460
+ sh.registerCommand({
461
+ id: `language-${l.id}`,
462
+ label: t("cmd.language", { name: l.name }),
463
+ keywords: ["language", "lang", "locale", l.id, l.name],
464
+ kind: "action",
465
+ run: () => sh.setLanguage(l.id),
466
+ });
467
+ }
468
+
469
+ function render(host) {
470
+ let btn = document.getElementById("appearance-btn");
471
+ if (!btn) {
472
+ btn = document.createElement("button");
473
+ btn.id = "appearance-btn";
474
+ btn.type = "button";
475
+ host.appendChild(btn);
476
+ }
477
+ // The prototype's user menu (R10): ◐ ▾, an .ib with a .pop menu. Its
478
+ // name is "Appearance"; the glyphs are hidden from readers.
479
+ btn.className = "den-ib appearance-btn";
480
+ const glyph = document.createElement("span");
481
+ glyph.setAttribute("aria-hidden", "true");
482
+ glyph.textContent = "◐ ▾";
483
+ btn.replaceChildren(glyph);
484
+ btn.title = t("um.title");
485
+ btn.setAttribute("aria-label", t("um.label"));
486
+ btn.setAttribute("aria-haspopup", "menu");
487
+ btn.setAttribute("aria-expanded", "false");
488
+ if (btn.dataset.wired) return;
489
+ btn.dataset.wired = "1";
490
+ btn.addEventListener("click", () =>
491
+ window.denShell?.openMenu?.(btn, userMenu()),
492
+ );
493
+ btn.addEventListener("keydown", (e) => {
494
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
495
+ e.preventDefault();
496
+ if (btn.getAttribute("aria-expanded") !== "true")
497
+ window.denShell?.openMenu?.(btn, userMenu());
498
+ });
499
+ }
500
+
501
+ /** The bar the control goes in: the shell's user-menu slot (shell.js). */
502
+ function headerHost() {
503
+ return (
504
+ document.getElementById("den-user") ||
505
+ document.querySelector(".den-top") ||
506
+ document.querySelector("header") ||
507
+ document.querySelector(".topbar")
508
+ );
509
+ }
510
+
511
+ let inited = false;
512
+ /** Put the controls in the page header and follow the system's mode. */
513
+ export async function initAppearance() {
514
+ if (inited || typeof document === "undefined") return;
515
+ inited = true;
516
+ const mq = window.matchMedia?.("(prefers-color-scheme: light)");
517
+ mq?.addEventListener?.("change", () => {
518
+ if (st.mode === "system") applyMode();
519
+ });
520
+ try {
521
+ await loadThemes();
522
+ } catch {
523
+ return; // no control without the server; the page keeps its first paint
524
+ }
525
+ injectStyles();
526
+ applyMode();
527
+ registerCommands();
528
+ const host = headerHost();
529
+ if (!host) return;
530
+ render(host);
531
+ }