@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,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
|
+
}
|