dsh-shell-themes 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 往事似风如雾
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # dsh-shell-themes
2
+
3
+ dsh-desktop 内建**主题宿主**插件(host-only):一个插件承载全部内建数据主题,经官方 `webserver/index-inject` 通道注入活动主题的前端 CSS。
4
+
5
+ 出厂数据主题:**原生界面(native,默认,注入空样式)** 与 **液态玻璃(aurora-glass,可选)**;主题清单同时供桌面壳复用(窗口底色 / 启动屏),随包内容变化自动重新暂存并刷新 profile 安装。
6
+
7
+ ## 快速开始
8
+
9
+ 本插件随桌面壳内置并自动安装(`ensureBuiltinPlugins` 标准链),一般无需手动挂载。开发调试:
10
+
11
+ ```powershell
12
+ cd packages/dsh-shell-themes
13
+ # 在插件目录内运行,file:$PWD 自动展开为当前绝对路径(必须 file: 而非 link:):
14
+ dsh plugin --profile web add file:$PWD
15
+ ```
16
+
17
+ 安装后**重启 dsh web 进程**生效;切换主题在托盘「界面主题」中选择。
18
+
19
+ ## 能力
20
+
21
+ | 项目 | 说明 |
22
+ | --- | --- |
23
+ | 多主题单插件 | `themes/<id>/`(`theme.json` 清单 + `content.css`)目录即主题,壳按内容哈希检测变化自动重装 |
24
+ | 注入通道 | 监听官方 `webserver/index-inject`,每次 index 渲染把活动主题 `<style>` 注入 `<head>`;`native` 刻意注入零规则 |
25
+ | 状态文件 | 三态选择(未选 / 显式关闭 / 指定 id),「关闭主题」不会在下次启动被默认值静默覆盖 |
26
+ | 壳复用 | 主题清单同时供桌面壳取窗口底色与启动屏配色,单一来源 |
27
+ | 自愈 | profile 被重装/清空后,壳下次启动自动重新暂存并安装 |
28
+
29
+ ## 配置
30
+
31
+ | 环境变量 | 默认 | 说明 |
32
+ | --- | --- | --- |
33
+ | `DSH_SHELL_THEME` | (未设) | 指定主题 id(如 `aurora-glass`);`0`/`off`/`false` 强制无主题;未知名回退默认并告警 |
34
+ | `DSH_GLASS_THEME` | (未设) | 旧名兼容别名,语义同上 |
35
+ | `DSH_SHELL_THEME_STATE` | `<DSH_HOME>/...` | 主题选择状态文件路径(托盘切换写入) |
36
+
37
+ ## 原理一句话
38
+
39
+ 启动时扫描 `themes/` 目录读入各 `theme.json` 清单(损坏目录只告警不拖垮插件树),按 环境变量 > 状态文件 > manifest 默认 的优先级解析活动主题 id,并在每次 index 渲染时把该主题 CSS 经 `webserver/index-inject` 注入——只改主题,不碰插件与前端。实现见 `src/index.mjs` 与 `src/themes.mjs`。
40
+
41
+ ## 文档
42
+
43
+ | 文档 | 内容 |
44
+ | --- | --- |
45
+ | [../../docs/THEMES.md](../../docs/THEMES.md) | 完整文档:切换方式、新增主题、CSS 写法约定、类名轮检流程、注入顺序取舍、排障 |
46
+ | [../../docs/CHANGELOG.md](../../docs/CHANGELOG.md) | v12–v16 修复史(实测驱动) |
47
+
48
+ ## 测试
49
+
50
+ ```powershell
51
+ node test/shell-themes.test.js # 清单/契约/出厂主题(9 条)
52
+ node test/theme-runtime.test.js # 主题运行时(17 条)
53
+ node test/theme-surfaces.test.js # 壳侧窗口底色/启动屏复用(12 条)
54
+ node test/shell-themes-classes.test.js # 类名轮检:哈希类基线守卫(2 条)
55
+ node test/legacy-frontend-theme.test.js # legacy 前端补丁清理(14 条)
56
+ ```
57
+
58
+ ## 版本与兼容
59
+
60
+ 包名 **dsh-shell-themes**(原内置插件 `dsh-liquid-glass-theme` 已重命名并入)· MIT · 目标 DSH `0.1.5-rc.2`(实测基准)。
61
+
62
+ **npm 首发 0.1.0**(新包版本从 0.1.0 起;主题内容的内部修复史以 aurora-glass 的内容版本 v12–v16 记录,不占 npm 版本线):
63
+
64
+ - 本版包含:v16 修复——右栏把手选择器改同特异性覆盖(`[data-dsh-part="resize-handle"]` 在安装版前端不存在)、清理 5 个腐烂哈希类、legacy 清理补 PATH 缺口/多链接 `/g`/首行归属、未知主题 id 告警、类名轮检测试落地;
65
+ - 此前内部积累(一并随本版发布):v12–v15——对比度、三栏/气泡/把手对齐新版前端、树不误涂、轨迹表与审批空态玻璃化;
66
+ - 出厂默认主题为 `native`(液态玻璃转为可选)。
67
+
68
+ 元信息与文档结构遵循 [docs/PACKAGE-TEMPLATE.md](../../docs/PACKAGE-TEMPLATE.md)。
@@ -0,0 +1,19 @@
1
+ # dsh-shell-themes bundle patch: inserts the theme-host row into the web profile
2
+ # roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
3
+ # field) over dsh-base. Install with `dsh plugin --profile web add <dir>`.
4
+ #
5
+ # ONE row hosts EVERY built-in theme — adding a theme is adding a folder, never a
6
+ # new roster row:
7
+ # themes/<id>/theme.json declares the theme (id, default, window colours, …)
8
+ # themes/<id>/content.css the theme's frontend CSS
9
+ #
10
+ # apply() resolves the active theme id per index render —
11
+ # DSH_SHELL_THEME (explicit id, dev/test force) > the state file named by
12
+ # DSH_SHELL_THEME_STATE (the shell's persisted user choice) > the manifest
13
+ # default — and pushes exactly one `{ kind: "style" }` row, so the active
14
+ # theme's CSS lands in the served index.html <head> (first frame themed, no
15
+ # dist rewrite). CSS is re-read per render, so editing a theme shows up on
16
+ # reload without touching the plugin.
17
+ - insert:
18
+ - id: dsh-shell-themes
19
+ name: 'dsh-shell-themes'
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "dsh-shell-themes",
3
+ "version": "0.1.0",
4
+ "description": "dsh-desktop 内建主题宿主:一个插件承载多个数据主题(themes/<id>/),经 webserver/index-inject 注入活动主题的前端 CSS;主题清单同时供壳复用(窗口底色 / 启动屏)",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "dsh": {
8
+ "bundle": {
9
+ "patch": "./cordis.patch.yml"
10
+ }
11
+ },
12
+ "engines": {
13
+ "node": ">=22"
14
+ },
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "themes",
20
+ "theme",
21
+ "cordis"
22
+ ],
23
+ "files": [
24
+ "src",
25
+ "themes",
26
+ "cordis.patch.yml",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "registry": "https://registry.npmjs.org"
32
+ },
33
+ "license": "MIT",
34
+ "author": "往事似风如雾",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://gitee.com/past-events-sifenruwu/deepseek-harness-electron.git"
38
+ }
39
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // dsh-shell-themes — cordis host plugin and theme host for the dsh-desktop shell.
2
+ //
3
+ // ONE plugin hosts EVERY built-in theme: a theme is a data folder
4
+ // (themes/<id>/theme.json + the CSS it names), never a new plugin row. Adding a
5
+ // theme is adding a folder.
6
+ //
7
+ // Mechanism (verified against dsh-host-webserver 0.1.5-rc.x):
8
+ // - apply() subscribes to `webserver/index-inject`. The webserver emits once
9
+ // per index.html render with a fresh row table; each subscriber pushes rows
10
+ // (dsh-host-webserver: collectIndexInjections -> ctx.emit(..., table)).
11
+ // - A `{ kind: "style", text }` row renders as `<style>…</style>` in the
12
+ // document <head> (renderRow: `case "style"` -> placement "head"), before
13
+ // the app mounts — so the first paint is already themed and the shell never
14
+ // rewrites the frontend dist.
15
+ // - The served index.html really passes through it:
16
+ // dsh-host-frontend-static calls `ctx.webServer.renderIndex(...)`.
17
+ //
18
+ // Active-theme precedence (resolved per render, so a switch needs no restart):
19
+ // DSH_SHELL_THEME (explicit id — dev/test force)
20
+ // > the state file named by DSH_SHELL_THEME_STATE (shell-persisted choice)
21
+ // > the manifest default
22
+ // CSS is re-read on every render, so editing a theme shows up on a reload.
23
+ //
24
+ // Kill switches: DSH_SHELL_THEME=0/off/false, or the legacy DSH_GLASS_THEME
25
+ // when the former is unset.
26
+ //
27
+ // Best effort: any failure only logs a warning — the backend must boot
28
+ // regardless.
29
+
30
+ "use strict";
31
+
32
+ import { dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ import {
36
+ findTheme,
37
+ listThemes,
38
+ readThemeCss,
39
+ readThemeState,
40
+ resolveActiveThemeId,
41
+ themesDisabled,
42
+ } from "./themes.mjs";
43
+
44
+ const PLUGIN_NAME = "dsh-shell-themes";
45
+
46
+ const THIS_DIR = dirname(fileURLToPath(import.meta.url));
47
+ /** Shipped themes: <package>/themes/<id>/. */
48
+ const THEMES_ROOT = join(THIS_DIR, "..", "themes");
49
+
50
+ /**
51
+ * Build the injection rows for the currently active theme.
52
+ *
53
+ * Pure w.r.t. cordis so node:test can drive it directly. `io` is forwarded to
54
+ * the themes module's filesystem seam (defaults to node:fs there).
55
+ *
56
+ * @param {{ themesRoot?: string, env?: object, io?: object }} [options]
57
+ * @returns {{ rows: object[], activeId: string|null, problems: string[], disabled: boolean }}
58
+ */
59
+ export function buildThemeRows(options = {}) {
60
+ const themesRoot = options.themesRoot || THEMES_ROOT;
61
+ const env = options.env || process.env;
62
+ const io = options.io;
63
+
64
+ const { themes, problems } = listThemes(themesRoot, io);
65
+
66
+ if (themesDisabled(env)) {
67
+ return { rows: [], activeId: null, problems, disabled: true };
68
+ }
69
+
70
+ const activeId = resolveActiveThemeId(themes, {
71
+ envValue: env.DSH_SHELL_THEME,
72
+ stateValue: readThemeState(env.DSH_SHELL_THEME_STATE, io),
73
+ problems,
74
+ });
75
+ if (!activeId) return { rows: [], activeId: null, problems, disabled: false };
76
+
77
+ const theme = findTheme(themes, activeId);
78
+ const css = readThemeCss(theme, io);
79
+ const rows = css ? [{ kind: "style", text: css }] : [];
80
+ return { rows, activeId, problems, disabled: false };
81
+ }
82
+
83
+ const plugin = {
84
+ name: PLUGIN_NAME,
85
+ inject: [],
86
+ apply(ctx) {
87
+ const log = ctx.logger(PLUGIN_NAME);
88
+
89
+ if (themesDisabled(process.env)) {
90
+ log.info("theme injection disabled via DSH_SHELL_THEME/DSH_GLASS_THEME");
91
+ return;
92
+ }
93
+
94
+ ctx.on("webserver/index-inject", (table) => {
95
+ try {
96
+ const { rows, activeId, problems } = buildThemeRows({ themesRoot: THEMES_ROOT });
97
+ for (const problem of problems) log.warn("theme: %s", problem);
98
+ if (rows.length === 0) {
99
+ // Distinguish "no theme selected" from "the selected theme carries no
100
+ // styling" (e.g. an empty content.css): both inject nothing, but only
101
+ // the first one means the user is running unthemed.
102
+ if (activeId) log.info("active theme '%s' contributes no CSS — nothing injected", String(activeId));
103
+ else log.info("no active theme — nothing injected");
104
+ return;
105
+ }
106
+ for (const row of rows) table.push(row);
107
+ log.info("injected theme '%s' (%d bytes)", String(activeId), rows[0].text.length);
108
+ } catch (err) {
109
+ log.warn(
110
+ "theme injection failed: %s — theme skipped, backend continues",
111
+ (err && err.message) || String(err)
112
+ );
113
+ }
114
+ });
115
+
116
+ const known = listThemes(THEMES_ROOT).themes.length;
117
+ log.info("attached; theme host ready (%d theme(s) under %s)", known, THEMES_ROOT);
118
+ },
119
+ };
120
+
121
+ export default plugin;
122
+ export { plugin, THEMES_ROOT, PLUGIN_NAME };
package/src/themes.mjs ADDED
@@ -0,0 +1,263 @@
1
+ // dsh-shell-themes — pure theme discovery / validation / resolution.
2
+ //
3
+ // No cordis and no implicit filesystem: every fs touch goes through the injected
4
+ // `io` (defaults to node:fs + node:path). That keeps the whole selection policy
5
+ // drivable from plain node:test, and lets the shell-side CommonJS runtime
6
+ // (lib/theme-runtime.js) be cross-checked against this module over the same
7
+ // themes directory.
8
+ //
9
+ // Contract of one theme folder: themes/<dir>/theme.json + the CSS it names.
10
+ // {
11
+ // "id": "native", // required, lowercase [a-z0-9._-]
12
+ // "name": "原生界面", // optional display name (falls back to id)
13
+ // "description": "…", // optional
14
+ // "contract": 1, // optional, defaults to THEME_CONTRACT
15
+ // "default": true, // optional; at most one theme should set it
16
+ // "palettes": ["light","dark"], // optional; dsh owns the light/dark mode
17
+ // "content": "content.css", // optional, defaults to content.css
18
+ // "boot": "boot.css", // optional: shell loading-screen override
19
+ // "window": { // optional: shell window chrome colours
20
+ // "background": { "light": "#edf1fa", "dark": "#0c101e" },
21
+ // "updater": { "background": "#0b0f14" }
22
+ // }
23
+ // }
24
+
25
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ /** Theme manifest contract version this host understands. */
29
+ export const THEME_CONTRACT = 1;
30
+
31
+ /** Per-theme manifest filename. */
32
+ export const THEME_MANIFEST = "theme.json";
33
+
34
+ /** Default CSS filename inside a theme folder. */
35
+ export const DEFAULT_CONTENT_CSS = "content.css";
36
+
37
+ const defaultIo = { existsSync, readdirSync, readFileSync, join };
38
+
39
+ // Values that mean "no theme". An unset / empty value is NOT off — only an
40
+ // explicit switch does that, so a missing env var stays harmless.
41
+ const OFF_VALUES = new Set(["0", "false", "off", "no", "none"]);
42
+
43
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
44
+ const COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
45
+
46
+ /** Trim + lowercase a switch/id candidate; absent values become "". */
47
+ export function normalizeSwitch(value) {
48
+ if (value === undefined || value === null) return "";
49
+ return String(value).trim().toLowerCase();
50
+ }
51
+
52
+ /** True when `value` is an explicit off switch (0 / false / off / no / none). */
53
+ export function isOff(value) {
54
+ if (value === undefined || value === null) return false;
55
+ return OFF_VALUES.has(normalizeSwitch(value));
56
+ }
57
+
58
+ /**
59
+ * Theme kill switches. `DSH_SHELL_THEME=0/off/…` is the generic switch; the
60
+ * legacy `DSH_GLASS_THEME=0/off/…` is still honoured when the new one is unset,
61
+ * so existing installs and docs keep working.
62
+ */
63
+ export function themesDisabled(env = {}) {
64
+ if (isOff(env.DSH_SHELL_THEME)) return true;
65
+ if (env.DSH_SHELL_THEME === undefined && isOff(env.DSH_GLASS_THEME)) return true;
66
+ return false;
67
+ }
68
+
69
+ function normalizeWindow(raw) {
70
+ const out = { background: {}, updater: {} };
71
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out;
72
+ const bg = raw.background;
73
+ if (typeof bg === "string") {
74
+ const color = bg.trim();
75
+ if (COLOR_RE.test(color)) {
76
+ out.background.light = color;
77
+ out.background.dark = color;
78
+ }
79
+ } else if (bg !== null && typeof bg === "object" && !Array.isArray(bg)) {
80
+ for (const mode of ["light", "dark"]) {
81
+ const color = typeof bg[mode] === "string" ? bg[mode].trim() : "";
82
+ if (COLOR_RE.test(color)) out.background[mode] = color;
83
+ }
84
+ }
85
+ const updater = raw.updater;
86
+ if (updater !== null && typeof updater === "object" && !Array.isArray(updater)) {
87
+ const color = typeof updater.background === "string" ? updater.background.trim() : "";
88
+ if (COLOR_RE.test(color)) out.updater.background = color;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Validate one parsed theme.json.
95
+ * @returns {{ theme: object|null, errors: string[] }} theme is null when any
96
+ * error was found; errors are always human-readable and prefixed with `dir`.
97
+ */
98
+ export function validateTheme(raw, dir = "") {
99
+ const errors = [];
100
+ const fail = (message) => errors.push(dir ? `${dir}: ${message}` : message);
101
+
102
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
103
+ return { theme: null, errors: [dir ? `${dir}: manifest must be an object` : "manifest must be an object"] };
104
+ }
105
+
106
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
107
+ if (!id) fail("id is required");
108
+ else if (!ID_RE.test(id)) fail(`id ${JSON.stringify(id)} must match ${String(ID_RE)}`);
109
+
110
+ const contract = raw.contract === undefined || raw.contract === null ? THEME_CONTRACT : raw.contract;
111
+ if (!Number.isInteger(contract) || contract < 1) fail("contract must be a positive integer");
112
+ else if (contract > THEME_CONTRACT) fail(`contract ${contract} is newer than this host supports (${THEME_CONTRACT})`);
113
+
114
+ const content = typeof raw.content === "string" && raw.content.trim() ? raw.content.trim() : DEFAULT_CONTENT_CSS;
115
+ const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : id;
116
+
117
+ const theme = {
118
+ id,
119
+ name,
120
+ contract: Number.isInteger(contract) ? contract : THEME_CONTRACT,
121
+ description: typeof raw.description === "string" ? raw.description.trim() : "",
122
+ default: raw.default === true,
123
+ palettes: Array.isArray(raw.palettes)
124
+ ? raw.palettes.filter((p) => p === "light" || p === "dark")
125
+ : ["light", "dark"],
126
+ content,
127
+ boot: typeof raw.boot === "string" && raw.boot.trim() ? raw.boot.trim() : null,
128
+ window: normalizeWindow(raw.window),
129
+ };
130
+
131
+ return { theme: errors.length ? null : theme, errors };
132
+ }
133
+
134
+ /**
135
+ * Discover every valid theme under `themesRoot`.
136
+ * @returns {{ themes: object[], problems: string[] }} themes sorted by id, each
137
+ * carrying its absolute `dir`; problems are non-fatal diagnostics (an invalid
138
+ * theme is skipped, never fatal).
139
+ */
140
+ export function listThemes(themesRoot, io = defaultIo) {
141
+ const themes = [];
142
+ const problems = [];
143
+ let entries = [];
144
+ try {
145
+ entries = io.readdirSync(themesRoot, { withFileTypes: true });
146
+ } catch (_) {
147
+ return { themes, problems }; // missing themes dir -> simply no themes
148
+ }
149
+
150
+ const byId = new Map();
151
+ for (const entry of entries) {
152
+ if (!entry || typeof entry.isDirectory !== "function" || !entry.isDirectory()) continue;
153
+ const dirName = String(entry.name);
154
+ if (!dirName || dirName.startsWith(".") || dirName.startsWith("_")) continue;
155
+
156
+ const dir = io.join(themesRoot, dirName);
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(io.readFileSync(io.join(dir, THEME_MANIFEST), "utf8"));
160
+ } catch (err) {
161
+ problems.push(`${dirName}: unreadable ${THEME_MANIFEST} (${(err && err.message) || err})`);
162
+ continue;
163
+ }
164
+
165
+ const { theme, errors } = validateTheme(raw, dirName);
166
+ if (errors.length) {
167
+ problems.push(...errors);
168
+ continue;
169
+ }
170
+ if (byId.has(theme.id)) {
171
+ problems.push(`duplicate theme id ${JSON.stringify(theme.id)} in ${byId.get(theme.id)} and ${dirName}`);
172
+ continue;
173
+ }
174
+ byId.set(theme.id, dirName);
175
+ themes.push({ ...theme, dir: dir });
176
+ }
177
+
178
+ themes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
179
+
180
+ const defaults = themes.filter((t) => t.default);
181
+ if (defaults.length > 1) {
182
+ problems.push(`multiple themes declare default: ${defaults.map((t) => t.id).join(", ")} — the first by id wins`);
183
+ }
184
+ return { themes, problems };
185
+ }
186
+
187
+ /** Read one theme's content CSS (throwing propagates to the caller's guard). */
188
+ export function readThemeCss(theme, io = defaultIo) {
189
+ if (!theme || !theme.content) return "";
190
+ return io.readFileSync(io.join(theme.dir, theme.content), "utf8");
191
+ }
192
+
193
+ /** Read an optional per-theme boot CSS; returns null when the theme has none. */
194
+ export function readThemeBootCss(theme, io = defaultIo) {
195
+ if (!theme || !theme.boot) return null;
196
+ return io.readFileSync(io.join(theme.dir, theme.boot), "utf8");
197
+ }
198
+
199
+ /**
200
+ * Read the shell-written state file.
201
+ *
202
+ * Three-way ON PURPOSE — "no theme" must stay distinguishable from "no choice
203
+ * yet", or selecting 关闭 would silently revert to the manifest default on the
204
+ * next boot:
205
+ * undefined = no usable state (missing / unreadable / unknown shape)
206
+ * null = explicitly "no theme"
207
+ * string = a theme id
208
+ * Any damage degrades to `undefined` rather than throwing.
209
+ */
210
+ export function readThemeState(statePath, io = defaultIo) {
211
+ if (!statePath) return undefined;
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(io.readFileSync(statePath, "utf8"));
215
+ } catch (_) {
216
+ return undefined;
217
+ }
218
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
219
+ const theme = parsed.theme;
220
+ if (theme === null || theme === "") return null; // explicit "no theme"
221
+ if (typeof theme === "string" && theme.trim()) return theme.trim();
222
+ return undefined;
223
+ }
224
+
225
+ /**
226
+ * Resolve the active theme id.
227
+ * Precedence: explicit env id > persisted state > manifest default.
228
+ * An explicitly requested id that names no known theme falls back to the
229
+ * default and, when `options.problems` is provided, records a problem there —
230
+ * a typo'd `DSH_SHELL_THEME` must not look like "the default is active".
231
+ * @returns {string|null} null means "no theme" (render nothing).
232
+ */
233
+ export function resolveActiveThemeId(themes, options = {}) {
234
+ if (options.disabled === true) return null;
235
+ const list = Array.isArray(themes) ? themes : [];
236
+ const has = (id) => list.some((t) => t.id === id);
237
+
238
+ // An explicit off value (0/off/false) means NO theme, whatever is persisted.
239
+ if (isOff(options.envValue)) return null;
240
+
241
+ const fromEnv = normalizeSwitch(options.envValue);
242
+ if (fromEnv) {
243
+ if (has(fromEnv)) return fromEnv;
244
+ options.problems?.push(`theme id "${fromEnv}" from DSH_SHELL_THEME is unknown — falling back to the default`);
245
+ }
246
+
247
+ // undefined = never chosen (fall through to the default); null = "no theme".
248
+ if (options.stateValue === null) return null;
249
+ const fromState = normalizeSwitch(options.stateValue);
250
+ if (fromState) {
251
+ if (has(fromState)) return fromState;
252
+ options.problems?.push(`theme id "${fromState}" from the theme state file is unknown — falling back to the default`);
253
+ }
254
+
255
+ const fallback = list.find((t) => t.default);
256
+ return fallback ? fallback.id : null;
257
+ }
258
+
259
+ /** Resolve a theme object by id. */
260
+ export function findTheme(themes, id) {
261
+ if (!id || !Array.isArray(themes)) return null;
262
+ return themes.find((t) => t.id === id) || null;
263
+ }