@monster-orange/den 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,759 @@
1
+ /**
2
+ * The Settings page (R21, 0.3.0-dev.19): /settings, inside the shell. The
3
+ * prototype's look (den-shell.html: page header, sections, rows, segmented
4
+ * buttons, swatches, the navigation list) applied to every row of the
5
+ * SETTINGS registry (shell.js), with:
6
+ *
7
+ * - categories on the left (a nav, the current one aria-current) and
8
+ * their rows on the right, one section per category;
9
+ * - a search box (/ focuses it) that filters rows by label, description
10
+ * and keywords: a match stays under its category's heading, empty
11
+ * categories hide, and "No settings match" shows when nothing does;
12
+ * the count is announced (aria-live polite);
13
+ * - the URL keeps #<category> and ?q=, so a setting can be linked to,
14
+ * and reload and back/forward work.
15
+ *
16
+ * Nothing writes except an explicit change: loading, searching, moving
17
+ * between categories and changing the hash or query read only. A control
18
+ * writes on its change (the per-workspace editor on Save, as before), and
19
+ * says "✓ Saved" or "✗ Save failed".
20
+ *
21
+ * The pure parts (matching, filtering, the URL) are exported for tests.
22
+ */
23
+
24
+ /** The search's terms: lower-case words, every one of which must match. */
25
+ export function terms(q) {
26
+ return String(q || "")
27
+ .toLowerCase()
28
+ .split(/\s+/)
29
+ .filter(Boolean);
30
+ }
31
+
32
+ /** Whether a row matches the query (its label, description and keywords). */
33
+ export function rowMatches(row, q) {
34
+ const ts = terms(q);
35
+ if (!ts.length) return true;
36
+ const hay = [row.label, row.description, ...(row.keywords || [])]
37
+ .join(" ")
38
+ .toLowerCase();
39
+ return ts.every((t) => hay.includes(t));
40
+ }
41
+
42
+ /**
43
+ * The sections to draw: each category (by order) with its matching rows
44
+ * (by order, then registration), empty ones left out. Rows whose category
45
+ * isn't registered wait for it.
46
+ */
47
+ export function sections(categories, rows, q) {
48
+ const out = [];
49
+ const ordered = rows
50
+ .map((r, i) => ({ r, i }))
51
+ .sort((a, b) => (a.r.order ?? 100) - (b.r.order ?? 100) || a.i - b.i)
52
+ .map((x) => x.r);
53
+ for (const c of categories) {
54
+ const rs = ordered.filter((r) => r.category === c.id && rowMatches(r, q));
55
+ if (rs.length) out.push({ category: c, rows: rs });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** { category, q } from a location (its hash and ?q=). */
61
+ export function readLocation(loc) {
62
+ const params = new URLSearchParams(loc.search || "");
63
+ const hash = decodeURIComponent(String(loc.hash || "").replace(/^#/, ""));
64
+ return { category: hash || null, q: params.get("q") || "" };
65
+ }
66
+
67
+ /** The URL for a category and query, keeping the other params. */
68
+ export function settingsUrl(loc, { category, q }) {
69
+ const params = new URLSearchParams(loc.search || "");
70
+ if (q) params.set("q", q);
71
+ else params.delete("q");
72
+ const search = params.toString();
73
+ return (
74
+ (loc.pathname || "/settings") +
75
+ (search ? "?" + search : "") +
76
+ (category ? "#" + encodeURIComponent(category) : "")
77
+ );
78
+ }
79
+
80
+ /** "3 settings match" / "1 setting matches" / "No settings match". */
81
+ export function countText(n) {
82
+ if (!n) return "No settings match";
83
+ return n === 1 ? "1 setting matches" : `${n} settings match`;
84
+ }
85
+
86
+ /** The scope tag's text: User, Workspace · <ws>, Distribution · read-only. */
87
+ export function scopeText(row, workspace) {
88
+ if (row.scope === "distribution") return "Distribution · read-only";
89
+ if (row.scope === "workspace")
90
+ return workspace ? `Workspace · ${workspace}` : "Workspace";
91
+ return "User";
92
+ }
93
+
94
+ // ── the page ──────────────────────────────────────────────────────────────
95
+
96
+ const esc = (s) =>
97
+ String(s ?? "").replace(
98
+ /[&<>"']/g,
99
+ (c) =>
100
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
101
+ c
102
+ ],
103
+ );
104
+ const slug = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, "-");
105
+ const SWATCHES = ["accent", "ok", "warn", "bad", "bg", "surface", "text"];
106
+
107
+ /**
108
+ * Mount the page into `root` (#den-content's settings markup). `deps`:
109
+ * { shell, appearance: { ensureAppearance, setAppearance } }.
110
+ */
111
+ export async function mountSettings(deps) {
112
+ const shell = deps.shell;
113
+ const $ = (id) => document.getElementById(id);
114
+ const body = $("set-body");
115
+ const navEl = $("set-nav");
116
+ const search = $("set-q");
117
+ const count = $("set-count");
118
+ const scroller = $("den-content");
119
+
120
+ const ctx = {
121
+ data: {},
122
+ appearance: {},
123
+ scope: null,
124
+ workspace: null,
125
+ workspaces: [],
126
+ wsSettings: null,
127
+ shell,
128
+ post,
129
+ setAppearance: async (patch) => {
130
+ await deps.appearance.setAppearance(patch);
131
+ ctx.appearance = deps.appearance.appearanceState();
132
+ return true;
133
+ },
134
+ saveWorkspace,
135
+ };
136
+
137
+ async function getJson(url) {
138
+ const r = await fetch(url);
139
+ if (!r.ok) throw new Error(`${url}: ${r.status}`);
140
+ return r.json();
141
+ }
142
+ /** POST /api/settings; merges the reply. true when saved. */
143
+ async function post(patch) {
144
+ const r = await fetch("/api/settings", {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify(patch),
148
+ });
149
+ if (!r.ok) return false;
150
+ const reply = await r.json().catch(() => ({}));
151
+ for (const k of [
152
+ "editor",
153
+ "terminal",
154
+ "editor_overrides",
155
+ "graph",
156
+ "appearance",
157
+ "shell",
158
+ ])
159
+ if (reply[k] !== undefined) ctx.data[k] = reply[k];
160
+ return true;
161
+ }
162
+ async function saveWorkspace(patch) {
163
+ if (!ctx.workspace) return false;
164
+ const r = await fetch("/api/workspaces/settings", {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({ workspace: ctx.workspace, ...patch }),
168
+ });
169
+ if (!r.ok) return false;
170
+ const reply = await r.json().catch(() => ({}));
171
+ ctx.wsSettings = { editor: reply.editor ?? null };
172
+ return true;
173
+ }
174
+ async function loadWorkspaceSettings() {
175
+ ctx.wsSettings = ctx.workspace
176
+ ? await getJson(
177
+ `/api/workspaces/settings?workspace=${encodeURIComponent(ctx.workspace)}`,
178
+ ).catch(() => null)
179
+ : null;
180
+ }
181
+
182
+ // Everything the rows read, read once (GET only).
183
+ const [data, appearance, scope] = await Promise.all([
184
+ getJson("/api/settings").catch(() => null),
185
+ deps.appearance.ensureAppearance().catch(() => ({})),
186
+ getJson("/api/scope").catch(() => null),
187
+ ]);
188
+ if (!data) {
189
+ body.innerHTML =
190
+ '<div class="empty"><b>Settings did not load.</b> Reload to try again.</div>';
191
+ return;
192
+ }
193
+ ctx.data = data;
194
+ ctx.appearance = appearance;
195
+ ctx.scope = scope;
196
+ // The Workspace rows follow the view; with All active, they ask which.
197
+ ctx.workspaces = scope?.inView || [];
198
+ const askWs = !scope || scope.scope === "all";
199
+ const wanted = new URLSearchParams(location.search).get("ws");
200
+ ctx.workspace = !askWs
201
+ ? scope.scope
202
+ : ctx.workspaces.includes(wanted)
203
+ ? wanted
204
+ : ctx.workspaces.includes(scope?.focus)
205
+ ? scope.focus
206
+ : (ctx.workspaces[0] ?? null);
207
+ ctx.askWorkspace = askWs && ctx.workspaces.length > 0;
208
+ await loadWorkspaceSettings();
209
+
210
+ let state = readLocation(location);
211
+ search.value = state.q;
212
+
213
+ // ── drawing ─────────────────────────────────────────────────────────
214
+ function tag(from) {
215
+ return from
216
+ ? ` <span class="den-tagx" title="from ${esc(from)}">${esc(from)}</span>`
217
+ : "";
218
+ }
219
+ function currentCategory(list) {
220
+ const ids = list.map((s) => s.category.id);
221
+ return ids.includes(state.category) ? state.category : (ids[0] ?? null);
222
+ }
223
+ function drawNav(list) {
224
+ const cur = currentCategory(list);
225
+ navEl.innerHTML = list
226
+ .map(
227
+ ({ category: c, rows }) =>
228
+ `<a class="set-ni" href="#${esc(c.id)}" data-cat="${esc(c.id)}"${c.id === cur ? ' aria-current="true"' : ""}>` +
229
+ `<span class="set-ni-l">${esc(c.label)}</span>${tag(c.from)}` +
230
+ (state.q
231
+ ? `<span class="den-bdg" aria-label="${rows.length} matching">${rows.length}</span>`
232
+ : "") +
233
+ "</a>",
234
+ )
235
+ .join("");
236
+ }
237
+ function draw() {
238
+ const list = sections(
239
+ shell.settingsCategories(),
240
+ shell.settings(),
241
+ state.q,
242
+ );
243
+ const total = list.reduce((n, s) => n + s.rows.length, 0);
244
+ count.textContent = state.q ? countText(total) : "";
245
+ drawNav(list);
246
+ const keep = document.activeElement?.dataset?.focusKey || null;
247
+ body.innerHTML = list.length
248
+ ? list.map(drawSection).join("")
249
+ : `<div class="empty" id="set-empty"><b>No settings match “${esc(state.q)}”.</b>` +
250
+ ` <button type="button" class="btn sm" data-clear>Clear search</button></div>`;
251
+ for (const { rows } of list) for (const r of rows) wireRow(r);
252
+ if (keep) body.querySelector(`[data-focus-key="${keep}"]`)?.focus();
253
+ }
254
+ function drawSection({ category: c, rows }) {
255
+ const ws =
256
+ rows.some((r) => r.scope === "workspace") && ctx.askWorkspace
257
+ ? `<div class="row set-ask"><label class="l" for="set-ws">Workspace</label>` +
258
+ `<select id="set-ws" data-focus-key="set-ws">${ctx.workspaces
259
+ .map(
260
+ (w) =>
261
+ `<option value="${esc(w)}"${w === ctx.workspace ? " selected" : ""}>${esc(w)}</option>`,
262
+ )
263
+ .join("")}</select>` +
264
+ `<span class="set-note">You view All active: pick the workspace these are for.</span></div>`
265
+ : "";
266
+ const none =
267
+ rows.some((r) => r.scope === "workspace") && !ctx.workspace
268
+ ? '<p class="set-note">No active workspaces.</p>'
269
+ : "";
270
+ return (
271
+ `<section id="cat-${esc(c.id)}" data-cat="${esc(c.id)}" aria-labelledby="h-${esc(c.id)}">` +
272
+ `<h3 id="h-${esc(c.id)}">${esc(c.label)}${tag(c.from)}</h3>` +
273
+ (c.description ? `<p>${esc(c.description)}</p>` : "") +
274
+ ws +
275
+ none +
276
+ rows.map(drawRow).join("") +
277
+ "</section>"
278
+ );
279
+ }
280
+ function value(r) {
281
+ try {
282
+ return r.read(ctx);
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+ function opts(o) {
288
+ const v = typeof o === "function" ? o(ctx) : o;
289
+ return Array.isArray(v) ? v : [];
290
+ }
291
+ function drawRow(r) {
292
+ const id = slug(r.id);
293
+ const lid = `l-${id}`;
294
+ const cid = `c-${id}`;
295
+ const did = `d-${id}`;
296
+ const v = value(r);
297
+ const t = r.control?.type || "readonly";
298
+ const ro = !r.write;
299
+ const single = ["number", "select", "toggle", "text"].includes(t);
300
+ const label = single
301
+ ? `<label class="l" id="${lid}" for="${cid}">${esc(r.label)}</label>`
302
+ : `<span class="l" id="${lid}">${esc(r.label)}</span>`;
303
+ const desc = `aria-describedby="${did}"`;
304
+ let ctl = "";
305
+ if (ro || t === "readonly") {
306
+ ctl = `<output class="set-ro" id="${cid}" aria-labelledby="${lid}" ${desc}>${esc(v ?? "")}</output>`;
307
+ } else if (t === "seg" || t === "theme") {
308
+ ctl =
309
+ `<div class="seg" role="group" aria-labelledby="${lid}" ${desc}>` +
310
+ opts(r.control.options)
311
+ .map(
312
+ (o) =>
313
+ `<button type="button" data-v="${esc(o.value)}" data-focus-key="${id}:${esc(o.value)}" aria-pressed="${String(o.value) === String(v) || !!o.selected}">${esc(o.label)}</button>`,
314
+ )
315
+ .join("") +
316
+ "</div>";
317
+ if (t === "theme") {
318
+ const notice = r.control.notice?.(ctx);
319
+ ctl +=
320
+ (notice
321
+ ? `<p class="set-warn" role="status">${esc(notice)}</p>`
322
+ : "") +
323
+ `<div class="swatches" aria-label="The theme's colors" role="img">${swatches()}</div>`;
324
+ }
325
+ } else if (t === "radio") {
326
+ ctl =
327
+ `<div class="set-radios" role="radiogroup" aria-labelledby="${lid}" ${desc}>` +
328
+ opts(r.control.options)
329
+ .map(
330
+ (o) =>
331
+ `<label class="set-radio${o.disabled ? " off" : ""}"><input type="radio" name="${cid}" value="${esc(o.value)}" data-focus-key="${id}:${esc(o.value)}"${String(o.value) === String(v) ? " checked" : ""}${o.disabled ? " disabled" : ""}>` +
332
+ `<span>${esc(o.label)}</span>${(o.tags || [])
333
+ .filter(Boolean)
334
+ .map((x) => `<span class="set-tag">${esc(x)}</span>`)
335
+ .join("")}</label>`,
336
+ )
337
+ .join("") +
338
+ "</div>";
339
+ } else if (t === "select") {
340
+ ctl =
341
+ `<select id="${cid}" data-focus-key="${id}" ${desc}>` +
342
+ opts(r.control.options)
343
+ .map(
344
+ (o) =>
345
+ `<option value="${esc(o.value)}"${String(o.value) === String(v) ? " selected" : ""}>${esc(o.label)}</option>`,
346
+ )
347
+ .join("") +
348
+ "</select>";
349
+ } else if (t === "number") {
350
+ const c = r.control;
351
+ ctl =
352
+ `<input type="number" id="${cid}" data-focus-key="${id}" value="${esc(v)}"` +
353
+ (c.min != null ? ` min="${c.min}"` : "") +
354
+ (c.max != null ? ` max="${c.max}"` : "") +
355
+ (c.step != null ? ` step="${c.step}"` : "") +
356
+ ` ${desc}>` +
357
+ (c.unit
358
+ ? `<span class="set-unit" aria-hidden="true">${esc(c.unit)}</span>`
359
+ : "");
360
+ } else if (t === "text") {
361
+ ctl = `<input type="text" id="${cid}" data-focus-key="${id}" value="${esc(v)}" ${desc}>`;
362
+ } else if (t === "toggle") {
363
+ ctl = `<input type="checkbox" class="set-check" id="${cid}" data-focus-key="${id}"${v ? " checked" : ""} ${desc}>`;
364
+ } else if (t === "langmap") {
365
+ const map = v || {};
366
+ const eds = opts(r.control.options);
367
+ ctl =
368
+ `<div class="set-langs" role="group" aria-labelledby="${lid}" ${desc}>` +
369
+ opts(r.control.languages)
370
+ .map((l) => {
371
+ const sid = `${cid}-${slug(l.key)}`;
372
+ return (
373
+ `<div class="set-lang"><label for="${sid}">${esc(l.name)}</label>` +
374
+ `<select id="${sid}" data-lang="${esc(l.key)}" data-focus-key="${sid}">` +
375
+ `<option value="">Default (inherit)</option>` +
376
+ eds
377
+ .map(
378
+ (e) =>
379
+ `<option value="${esc(e.value)}"${map[l.key] === e.value ? " selected" : ""}>${esc(e.label)}</option>`,
380
+ )
381
+ .join("") +
382
+ "</select></div>"
383
+ );
384
+ })
385
+ .join("") +
386
+ "</div>";
387
+ } else if (t === "navlist") {
388
+ ctl = navlist(r, v, lid, desc);
389
+ } else if (t === "pins") {
390
+ const pins = v || [];
391
+ ctl = pins.length
392
+ ? `<div class="navlist" role="list" aria-labelledby="${lid}" ${desc}>` +
393
+ pins
394
+ .map(
395
+ (p, i) =>
396
+ `<div class="it" role="listitem"><span class="nm den-mono">${esc(p)}</span>` +
397
+ `<button type="button" class="btn sm" data-pin-move="${esc(p)}" data-d="-1" data-focus-key="pin-up-${esc(p)}"${i === 0 ? " disabled" : ""} aria-label="Move ${esc(p)} up">↑</button>` +
398
+ `<button type="button" class="btn sm" data-pin-move="${esc(p)}" data-d="1" data-focus-key="pin-down-${esc(p)}"${i === pins.length - 1 ? " disabled" : ""} aria-label="Move ${esc(p)} down">↓</button>` +
399
+ `<button type="button" class="btn sm" data-pin-remove="${esc(p)}" data-focus-key="pin-x-${esc(p)}" aria-label="Unpin ${esc(p)}">Unpin</button></div>`,
400
+ )
401
+ .join("") +
402
+ "</div>"
403
+ : `<output class="set-ro" aria-labelledby="${lid}" ${desc}>Nothing pinned</output>`;
404
+ } else if (t === "workspace-editor") {
405
+ ctl = wsEditor(v, lid, cid, desc);
406
+ } else if (t === "custom") {
407
+ ctl = `<div class="set-custom" id="${cid}" role="group" aria-labelledby="${lid}" ${desc}></div>`;
408
+ }
409
+ const dist = r.scope === "distribution";
410
+ const note = dist
411
+ ? ` Set by the distribution in ${esc(ctx.data.distribution?.file || "its config")}.`
412
+ : "";
413
+ return (
414
+ `<div class="set-row" id="setting-${id}" data-setting="${esc(r.id)}" data-scope="${esc(r.scope)}">` +
415
+ `<div class="row">${label}<div class="ctl">${ctl}</div>` +
416
+ `<span class="set-scope" data-scope="${esc(r.scope)}">${esc(scopeText(r, ctx.workspace))}</span></div>` +
417
+ `<div class="d" id="${did}">${esc(r.description)}${note}${tag(r.from)}</div>` +
418
+ `<span class="set-saved" role="status" id="s-${id}"></span>` +
419
+ "</div>"
420
+ );
421
+ }
422
+ function navlist(r, v, lid, desc) {
423
+ const hidden = (v && v.hidden) || [];
424
+ const items = shell.orderedNav();
425
+ const groups = shell.groups();
426
+ const gl = (id) => {
427
+ const g = groups.find((x) => x.id === id);
428
+ return g ? shell.t(g.label) : id;
429
+ };
430
+ return (
431
+ `<div class="navlist" role="list" aria-labelledby="${lid}" ${desc}>` +
432
+ items
433
+ .map((n, i) => {
434
+ const off = hidden.includes(n.id);
435
+ const name = shell.t(n.label);
436
+ const sameGroup = (j) => items[j] && items[j].group === n.group;
437
+ return (
438
+ `<div class="it${off ? " off" : ""}" role="listitem"><span class="nm">${esc(name)} <span class="set-grp">· ${esc(gl(n.group))}</span>${tag(n.from)}</span>` +
439
+ `<button type="button" class="btn sm" data-move="${esc(n.id)}" data-d="-1" data-focus-key="nav-up-${esc(n.id)}"${sameGroup(i - 1) ? "" : " disabled"} aria-label="Move ${esc(name)} up">↑</button>` +
440
+ `<button type="button" class="btn sm" data-move="${esc(n.id)}" data-d="1" data-focus-key="nav-down-${esc(n.id)}"${sameGroup(i + 1) ? "" : " disabled"} aria-label="Move ${esc(name)} down">↓</button>` +
441
+ `<button type="button" class="btn sm" data-hide="${esc(n.id)}" data-focus-key="nav-hide-${esc(n.id)}" aria-pressed="${off}" aria-label="${off ? "Show" : "Hide"} ${esc(name)}">${off ? "Show" : "Hide"}</button></div>`
442
+ );
443
+ })
444
+ .join("") +
445
+ "</div>"
446
+ );
447
+ }
448
+ function wsEditor(v, lid, cid, desc) {
449
+ if (!ctx.workspace)
450
+ return `<output class="set-ro" aria-labelledby="${lid}" ${desc}>No workspace</output>`;
451
+ const eds = ctx.data.editors || [];
452
+ const def =
453
+ eds.find(
454
+ (e) => e.key === (ctx.data.editor ?? eds.find((x) => x.available)?.key),
455
+ )?.name || "none";
456
+ const radio = (val, name, tagText) =>
457
+ `<label class="set-radio"><input type="radio" name="${cid}" value="${esc(val)}" data-focus-key="ws-ed-${esc(val)}"${(v || "") === val ? " checked" : ""}>` +
458
+ `<span>${esc(name)}</span>${tagText ? `<span class="set-tag">${esc(tagText)}</span>` : ""}</label>`;
459
+ return (
460
+ `<div class="set-radios" role="radiogroup" aria-labelledby="${lid}" ${desc}>` +
461
+ radio("", "Default", def) +
462
+ eds
463
+ .filter((e) => e.available)
464
+ .map((e) => radio(e.key, e.name, e.terminal ? "terminal" : null))
465
+ .join("") +
466
+ "</div>"
467
+ );
468
+ }
469
+ function swatches() {
470
+ const cs = getComputedStyle(document.documentElement);
471
+ return SWATCHES.map((n) => {
472
+ const c = cs.getPropertyValue(`--${n}`).trim();
473
+ return `<div class="sw" style="background:${esc(c)}" title="--${n} ${esc(c)}"><span>${esc(n)}</span></div>`;
474
+ }).join("");
475
+ }
476
+
477
+ // ── writing: only on an explicit change ────────────────────────────────
478
+ async function save(r, v) {
479
+ const el = $(`s-${slug(r.id)}`);
480
+ let ok = false;
481
+ try {
482
+ ok = (await r.write(v, ctx)) !== false;
483
+ } catch {
484
+ ok = false;
485
+ }
486
+ draw();
487
+ const again = $(`s-${slug(r.id)}`) || el;
488
+ if (!again) return;
489
+ again.className = "set-saved " + (ok ? "ok" : "bad");
490
+ again.textContent = ok ? "✓ Saved" : "✗ Save failed";
491
+ if (ok)
492
+ setTimeout(() => {
493
+ if (again.isConnected) again.textContent = "";
494
+ }, 2000);
495
+ }
496
+ function wireRow(r) {
497
+ if (!r.write) return;
498
+ const row = $(`setting-${slug(r.id)}`);
499
+ if (!row) return;
500
+ const t = r.control?.type;
501
+ if (t === "seg" || t === "theme")
502
+ row.querySelectorAll(".seg button").forEach((b) =>
503
+ b.addEventListener("click", () => {
504
+ if (b.getAttribute("aria-pressed") === "true") return;
505
+ save(r, b.dataset.v);
506
+ }),
507
+ );
508
+ else if (t === "radio")
509
+ row
510
+ .querySelectorAll('input[type="radio"]')
511
+ .forEach((i) => i.addEventListener("change", () => save(r, i.value)));
512
+ else if (t === "select" || t === "text")
513
+ row
514
+ .querySelector(".ctl select, .ctl input")
515
+ ?.addEventListener("change", (e) => save(r, e.target.value));
516
+ else if (t === "number")
517
+ row.querySelector(".ctl input")?.addEventListener("change", (e) => {
518
+ const n = Number(e.target.value);
519
+ if (!Number.isFinite(n)) return;
520
+ const c = r.control;
521
+ if ((c.min != null && n < c.min) || (c.max != null && n > c.max)) {
522
+ e.target.reportValidity?.();
523
+ return;
524
+ }
525
+ save(r, n);
526
+ });
527
+ else if (t === "toggle")
528
+ row
529
+ .querySelector(".ctl input")
530
+ ?.addEventListener("change", (e) => save(r, e.target.checked));
531
+ else if (t === "langmap")
532
+ row.querySelectorAll("select[data-lang]").forEach((s) =>
533
+ s.addEventListener("change", () => {
534
+ const map = {};
535
+ row.querySelectorAll("select[data-lang]").forEach((x) => {
536
+ if (x.value) map[x.dataset.lang] = x.value;
537
+ });
538
+ save(r, map);
539
+ }),
540
+ );
541
+ else if (t === "navlist")
542
+ row.addEventListener("click", (e) => {
543
+ const b = e.target.closest("button");
544
+ if (!b || b.disabled) return;
545
+ const cur = value(r) || { hidden: [], order: [] };
546
+ if (b.dataset.hide) {
547
+ const id = b.dataset.hide;
548
+ const hidden = cur.hidden.includes(id)
549
+ ? cur.hidden.filter((x) => x !== id)
550
+ : [...cur.hidden, id];
551
+ save(r, { hidden });
552
+ } else if (b.dataset.move) {
553
+ const ids = shell.orderedNav().map((n) => n.id);
554
+ const i = ids.indexOf(b.dataset.move);
555
+ const j = i + Number(b.dataset.d);
556
+ if (i < 0 || j < 0 || j >= ids.length) return;
557
+ [ids[i], ids[j]] = [ids[j], ids[i]];
558
+ save(r, { order: ids });
559
+ }
560
+ });
561
+ else if (t === "pins")
562
+ row.addEventListener("click", (e) => {
563
+ const b = e.target.closest("button");
564
+ if (!b || b.disabled) return;
565
+ const pins = (value(r) || []).slice();
566
+ if (b.dataset.pinRemove)
567
+ save(
568
+ r,
569
+ pins.filter((p) => p !== b.dataset.pinRemove),
570
+ );
571
+ else if (b.dataset.pinMove) {
572
+ const i = pins.indexOf(b.dataset.pinMove);
573
+ const j = i + Number(b.dataset.d);
574
+ if (i < 0 || j < 0 || j >= pins.length) return;
575
+ [pins[i], pins[j]] = [pins[j], pins[i]];
576
+ save(r, pins);
577
+ }
578
+ });
579
+ else if (t === "workspace-editor")
580
+ // Saves on change, like every other row (SA's dev.20 run: a
581
+ // separate Save gave no sign that a choice was not saved yet).
582
+ row.addEventListener("change", (e) => {
583
+ if (e.target.type === "radio" && e.target.checked)
584
+ save(r, e.target.value);
585
+ });
586
+ else if (t === "custom" && typeof r.control.render === "function") {
587
+ const host = row.querySelector(".set-custom");
588
+ r.control.render(host, ctx, {
589
+ value: value(r),
590
+ save: (v) => save(r, v),
591
+ });
592
+ }
593
+ }
594
+
595
+ // ── search, categories, the URL (all read-only) ────────────────────────
596
+ function go({ category, q }, how) {
597
+ state = { category: category ?? state.category, q: q ?? state.q };
598
+ const url = settingsUrl(location, state);
599
+ if (how === "push") history.pushState(null, "", url);
600
+ else if (how === "replace") history.replaceState(null, "", url);
601
+ }
602
+ function scrollToCategory(id, smooth) {
603
+ const el = id && $(`cat-${id}`);
604
+ if (!el) return;
605
+ el.scrollIntoView({ block: "start", behavior: smooth ? "smooth" : "auto" });
606
+ }
607
+ function markCurrent(id) {
608
+ navEl.querySelectorAll(".set-ni").forEach((a) => {
609
+ if (a.dataset.cat === id) a.setAttribute("aria-current", "true");
610
+ else a.removeAttribute("aria-current");
611
+ });
612
+ }
613
+ search.addEventListener("input", () => {
614
+ go({ q: search.value.trim() }, "replace");
615
+ draw();
616
+ });
617
+ body.addEventListener("click", (e) => {
618
+ if (e.target.closest("[data-clear]")) {
619
+ search.value = "";
620
+ go({ q: "" }, "replace");
621
+ draw();
622
+ search.focus();
623
+ }
624
+ });
625
+ body.addEventListener("change", async (e) => {
626
+ if (e.target.id !== "set-ws") return;
627
+ // Which workspace the rows are about: a read, never a write.
628
+ ctx.workspace = e.target.value;
629
+ const params = new URLSearchParams(location.search);
630
+ params.set("ws", ctx.workspace);
631
+ history.replaceState(
632
+ null,
633
+ "",
634
+ location.pathname + "?" + params.toString() + location.hash,
635
+ );
636
+ await loadWorkspaceSettings();
637
+ draw();
638
+ });
639
+ navEl.addEventListener("click", (e) => {
640
+ const a = e.target.closest(".set-ni");
641
+ if (!a) return;
642
+ e.preventDefault();
643
+ go({ category: a.dataset.cat }, "push");
644
+ markCurrent(a.dataset.cat);
645
+ lockCurrent(a.dataset.cat);
646
+ scrollToCategory(a.dataset.cat, true);
647
+ focusHeading(a.dataset.cat);
648
+ });
649
+ // Arriving at a category (a link, the hash, back/forward) puts focus on
650
+ // its heading, so a screen reader starts there. A search in the URL
651
+ // keeps focus where it is.
652
+ function focusHeading(id) {
653
+ const h = id && $(`h-${id}`);
654
+ if (!h) return;
655
+ h.setAttribute("tabindex", "-1");
656
+ h.focus({ preventScroll: true });
657
+ }
658
+ const arrive = () => {
659
+ const cur = currentCategory(
660
+ sections(shell.settingsCategories(), shell.settings(), state.q),
661
+ );
662
+ markCurrent(cur);
663
+ // Plain /settings stays at the top: the title and the search box are
664
+ // what you land on. Only a URL that names a category moves you to it.
665
+ if (!state.category) return;
666
+ scrollToCategory(cur);
667
+ if (!state.q) focusHeading(cur);
668
+ };
669
+ const fromUrl = () => {
670
+ state = readLocation(location);
671
+ if (search.value !== state.q) search.value = state.q;
672
+ draw();
673
+ arrive();
674
+ };
675
+ window.addEventListener("popstate", fromUrl);
676
+ window.addEventListener("hashchange", fromUrl);
677
+ // `/` focuses the search, unless you are typing somewhere.
678
+ document.addEventListener("keydown", (e) => {
679
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
680
+ const tg = e.target;
681
+ if (
682
+ tg &&
683
+ (tg.isContentEditable ||
684
+ /^(INPUT|TEXTAREA|SELECT)$/.test(tg.tagName || ""))
685
+ )
686
+ return;
687
+ e.preventDefault();
688
+ search.focus();
689
+ search.select();
690
+ });
691
+ // Your pick holds while the smooth scroll runs (SA's dev.21 run: the spy
692
+ // walked aria-current through every category it passed). It lets go on
693
+ // scrollend, or, where there is none, once the scroll has been still for
694
+ // a while, at most ~1s after the last move; then the spy looks again.
695
+ let locked = null;
696
+ let lockTimer = 0;
697
+ const hasScrollEnd = scroller && "onscrollend" in scroller;
698
+ function unlock() {
699
+ if (!locked) return;
700
+ locked = null;
701
+ clearTimeout(lockTimer);
702
+ spy();
703
+ }
704
+ function lockCurrent(id) {
705
+ locked = id;
706
+ clearTimeout(lockTimer);
707
+ // No scroll may come at all (already there): let go after ~1s.
708
+ lockTimer = setTimeout(unlock, 1000);
709
+ }
710
+ scroller?.addEventListener("scrollend", unlock);
711
+ // The section in view is the current one (the URL keeps your pick).
712
+ scroller?.addEventListener("scroll", () => {
713
+ if (locked) {
714
+ markCurrent(locked);
715
+ // Still moving: the fallback waits for the scroll to settle.
716
+ clearTimeout(lockTimer);
717
+ lockTimer = setTimeout(unlock, hasScrollEnd ? 1000 : 150);
718
+ return;
719
+ }
720
+ spy();
721
+ });
722
+ function spy() {
723
+ const box = scroller.getBoundingClientRect();
724
+ // Below the sticky header (R21a): a section is current once it reaches
725
+ // the header's foot, where a category click scrolls it to.
726
+ const under =
727
+ parseFloat(getComputedStyle(scroller).getPropertyValue("--set-top")) || 0;
728
+ const top = box.top + under + 24;
729
+ const all = [...body.querySelectorAll("section[data-cat]")];
730
+ let cur = null;
731
+ all.forEach((s) => {
732
+ if (s.getBoundingClientRect().top <= top) cur = s.dataset.cat;
733
+ });
734
+ // At the bottom, the last short sections can never reach the line:
735
+ // your pick (a click, the hash) wins if it is in view, else the last.
736
+ const atEnd =
737
+ scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
738
+ if (atEnd && all.length) {
739
+ const picked = all.find((s) => s.dataset.cat === state.category);
740
+ const r = picked?.getBoundingClientRect();
741
+ cur =
742
+ r && r.top < box.bottom && r.bottom > box.top
743
+ ? picked.dataset.cat
744
+ : all[all.length - 1].dataset.cat;
745
+ }
746
+ if (cur) markCurrent(cur);
747
+ }
748
+ // The registry or the appearance changed (a plugin, the ◐ menu): repaint.
749
+ window.addEventListener("den:settings", () => draw());
750
+ window.addEventListener("den:appearance", () => {
751
+ ctx.appearance = deps.appearance.appearanceState();
752
+ draw();
753
+ });
754
+ window.addEventListener("den:pins", () => draw());
755
+
756
+ draw();
757
+ arrive();
758
+ return ctx;
759
+ }