@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.
- package/CHANGELOG.md +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- 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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
+
}
|