@dsh-plugin/dsh-ui-experience 0.0.0-stage → 0.0.1

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/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-ui-experience
2
+
3
+ DeepSeek Harness 界面体验插件(`@dsh-plugin/dsh-ui-experience`,仓库:<https://github.com/dsh-plugins/dsh-ui-experience>):读取系统 matugen 生成的 Material You 配色,作为叠加层套在 DSH 当前主题上,并统一界面边框宽度。
4
+
5
+ ## 功能
6
+
7
+ - 「设置 → 通用设置」新增一行 **Matugen 配色跟随**,由开关控制启用 / 停用。开启后界面立即叠加 matugen 当前输出的配色;matugen 重新生成颜色(例如更换壁纸)后数秒内自动跟随,无需刷新页面。
8
+ - 配色是叠加层,不改写原生「浅色 / 深色 / 跟随系统」偏好;开关关闭后回到原生配色。
9
+ - 同一行可展开 **配色文件路径**,分别编辑 matugen 颜色文件与 DSH 令牌文件的路径,两者都会持久化;开关状态与路径均保存在 profile 配置里,重启应用后自动恢复。
10
+ - 统一界面边框宽度:插件配置 `borderWidth`(默认 `0.5px`),仅在「Matugen 配色跟随」开启时生效。
11
+ - 配色文件只在本地读取,不上传。
12
+
13
+ ## 使用
14
+
15
+ 1. 安装并启用插件后刷新页面。
16
+ 2. 打开「设置 → 通用设置」,开启 **Matugen 配色跟随**。
17
+ 3. 界面配色随配色文件内容变化自动更新,检测周期 2 秒。
18
+ 4. 关闭开关:配色叠加层、边框覆盖与启动底色叠加同时立即撤销——注入样式以 `<html data-dsh-uix-matugen>` 为作用域,关闭时该属性被移除,规则立刻失效;注入的 `<style>` / `<script>` 元素本身保留到下次刷新。此后插件停止 2 秒轮询,改为约 30 秒一次的低频检查。
19
+
20
+ ## 配置路径
21
+
22
+ 「Matugen 配色跟随」所在行可展开 **配色文件路径**,编辑两个路径并保存:
23
+
24
+ | 文件 | 默认路径 | 用途 |
25
+ | --- | --- | --- |
26
+ | 颜色文件 | `~/.local/state/quickshell/user/generated/colors.json` | matugen 原始输出;插件用内置映射把它转换为界面配色 |
27
+ | 令牌文件 | `~/.local/state/quickshell/user/generated/dsh-tokens.json` | 由自定义 matugen 模板产出的、已映射好的 DSH token 表;**存在时优先**使用 |
28
+
29
+ - 令牌文件优先:它存在时忽略颜色文件;删除它才回落到内置映射。
30
+ - 路径必须是绝对路径,或以 `~/` 开头(`~` 按当前用户主目录展开)。
31
+ - 「恢复默认」把两个路径重置为上表默认值。
32
+ - 两个文件都不存在时插件不上色,界面保持原生配色。
33
+
34
+ ## 表面层级
35
+
36
+ 内置映射把 Material You 的六档表面按明度排成阶梯:
37
+
38
+ `surface_container_lowest` → `surface_container_low` → `surface_container` → `surface_container_high` → `surface_container_highest` → `surface_bright`
39
+
40
+ - 侧栏取最暗一档,主面板从第二档起算,因此侧栏比主面板更暗。
41
+ - 菜单、卡片、输入区、代码块等其余表面按原有相对层级整体上移一档,各层之间的相对明暗顺序不变。
42
+ - Material 相邻档的色差本身较小,输入区、卡片与主面板之间只有微弱的层次区分;需要更强的分区感时应单独调整对应 token,而不是继续依赖相邻档。
43
+ - `overlay` 原本就位于阶梯顶端,没有更高的档位可移,因此在最亮一档上用 `color-mix(in srgb, …, white 8%)` 提亮,保持它高于其余浮层。
44
+ - 遮罩类 token 用 `color-mix(…, transparent 50%)` 保留透明度,避免不透明的纯 `scrim` 盖住下层界面。
45
+
46
+ ## 颜色来源与优先级
47
+
48
+ 1. 令牌文件 `dsh-tokens.json`:`{ "light": { … }, "dark": { … } }` 两张 DSH token 表,按当前模式分别取用,浅色与深色可以使用不同配色。
49
+ 2. 颜色文件 `colors.json`:matugen 的 Material You 颜色表,由插件内置映射转换。matugen 只给出一套暗色方案,因此浅色与深色两种模式叠加的是同一张表。
50
+ 3. 两者都不存在:不叠加配色。
51
+
52
+ token 名必须以 `--dsw-` 开头;未列出的 token 保持 DSH 内置值。
53
+
54
+ ## matugen 模板接入(可选)
55
+
56
+ 默认的 `colors.json` 只含一套暗色方案。若要让浅色与深色各自成表,或自行控制 DSH 全部 token 的取值,可在 `~/.config/matugen/config.toml` 增加一个模板:
57
+
58
+ ```toml
59
+ [templates.dsh]
60
+ input_path = '~/.config/matugen/templates/dsh-tokens.json'
61
+ output_path = '~/.local/state/quickshell/user/generated/dsh-tokens.json'
62
+ ```
63
+
64
+ 模板文件写成一个合法 JSON 对象,`light` 与 `dark` 各一张表;matugen 把 `{{colors.<角色>.<light|dark>.hex}}` 替换为对应方案的颜色值:
65
+
66
+ ```json
67
+ {
68
+ "light": {
69
+ "--dsw-alias-bg-base": "{{colors.surface.light.hex}}",
70
+ "--dsw-alias-bg-layer-1": "{{colors.surface_container_low.light.hex}}",
71
+ "--dsw-alias-bg-layer-2": "{{colors.surface_container.light.hex}}",
72
+ "--dsw-alias-bg-layer-3": "{{colors.surface_container_high.light.hex}}",
73
+ "--dsw-alias-bg-layer-4": "{{colors.surface_container_highest.light.hex}}",
74
+ "--dsw-alias-bg-overlay": "color-mix(in srgb, {{colors.surface_bright.light.hex}}, white 8%)",
75
+ "--dsw-alias-bg-mask-1": "color-mix(in srgb, {{colors.scrim.light.hex}}, transparent 50%)",
76
+ "--dsw-alias-border-l1": "{{colors.outline_variant.light.hex}}",
77
+ "--dsw-alias-border-l2": "{{colors.outline.light.hex}}",
78
+ "--dsw-alias-brand-primary": "{{colors.primary.light.hex}}",
79
+ "--dsw-alias-label-primary": "{{colors.on_surface.light.hex}}",
80
+ "--dsw-alias-label-secondary": "{{colors.on_surface_variant.light.hex}}",
81
+ "--dsw-alias-state-error-primary": "{{colors.error.light.hex}}",
82
+ "--dsw-alias-state-success-primary": "{{colors.tertiary.light.hex}}",
83
+ "--dsw-alias-state-warn-primary": "{{colors.secondary.light.hex}}",
84
+ "--dsw-specific-sidebar-fill": "{{colors.surface_container_lowest.light.hex}}"
85
+ },
86
+ "dark": {
87
+ "--dsw-alias-bg-base": "{{colors.surface.dark.hex}}",
88
+ "--dsw-alias-bg-layer-1": "{{colors.surface_container_low.dark.hex}}",
89
+ "--dsw-alias-bg-layer-2": "{{colors.surface_container.dark.hex}}",
90
+ "--dsw-alias-bg-layer-3": "{{colors.surface_container_high.dark.hex}}",
91
+ "--dsw-alias-bg-layer-4": "{{colors.surface_container_highest.dark.hex}}",
92
+ "--dsw-alias-bg-overlay": "color-mix(in srgb, {{colors.surface_bright.dark.hex}}, white 8%)",
93
+ "--dsw-alias-bg-mask-1": "color-mix(in srgb, {{colors.scrim.dark.hex}}, transparent 50%)",
94
+ "--dsw-alias-border-l1": "{{colors.outline_variant.dark.hex}}",
95
+ "--dsw-alias-border-l2": "{{colors.outline.dark.hex}}",
96
+ "--dsw-alias-brand-primary": "{{colors.primary.dark.hex}}",
97
+ "--dsw-alias-label-primary": "{{colors.on_surface.dark.hex}}",
98
+ "--dsw-alias-label-secondary": "{{colors.on_surface_variant.dark.hex}}",
99
+ "--dsw-alias-state-error-primary": "{{colors.error.dark.hex}}",
100
+ "--dsw-alias-state-success-primary": "{{colors.tertiary.dark.hex}}",
101
+ "--dsw-alias-state-warn-primary": "{{colors.secondary.dark.hex}}",
102
+ "--dsw-specific-sidebar-fill": "{{colors.surface_container_lowest.dark.hex}}"
103
+ }
104
+ }
105
+ ```
106
+
107
+ token 的值按原样写入 CSS 变量,因此 `color-mix(...)` 这类表达式可以直接写在模板里。改完模板后重新运行 matugen,插件在下一个轮询周期读到文件变化后生效。
108
+
109
+ ## 边框宽度
110
+
111
+ - 插件配置 `borderWidth` 默认为 `0.5px`,只在「Matugen 配色跟随」开启时生效;关闭开关后边框立即恢复原生宽度。
112
+ - 实现方式是全局覆盖所有元素的边框宽度,因此会同时压窄正文引用块等刻意加粗的装饰性边框。
113
+ - 给元素加 `data-dsh-uix-keep-border` 属性可让该元素不被覆盖。
114
+
115
+ ## 限制
116
+
117
+ - 走 `colors.json` 路径时 matugen 只提供一套暗色方案,浅色与深色模式叠加同一张配色表,界面的明暗切换只影响未被覆盖的元素;需要两套配色时改用 `dsh-tokens.json`。
118
+ - 配色检测周期为 2 秒,文件变化最多延迟约 2 秒生效。
119
+ - 关闭开关后启动底色叠加立即撤销(由作用域属性控制);注入的样式与脚本元素保留到下次刷新,但不产生视觉效果。
120
+
121
+ ## 开发
122
+
123
+ ```bash
124
+ pnpm install
125
+ pnpm typecheck
126
+ pnpm build
127
+ ```
128
+
129
+ 源码在 `src/`(`index.ts` 为宿主半,`client.ts` 为浏览器半,`shared.ts` 为两端共享的协议类型),esbuild 产物输出到 `lib/`。`lib/` 会提交入库,以便其他设备直接从 git 安装而无需构建环境;改动源码后务必重新构建并一并提交产物。
130
+
131
+ ## 安装
132
+
133
+ 其他设备通过「插件管理器 → 添加插件 → 安装第三方插件」安装,粘贴 Git 仓库地址:
134
+
135
+ ```
136
+ git+https://github.com/dsh-plugins/dsh-ui-experience.git
137
+ ```
138
+
139
+ 注意:
140
+
141
+ - 地址必须以 `git+` 开头;裸 `https://` 不会被识别为 Git 仓库。
142
+ - 目标设备需要能访问该仓库。
143
+ - 构建产物 `lib/` 已入库,无需在目标设备上构建;安装后按提示重启 DSH。
144
+ - 不要手动编辑 profile 的 `package.json`:包名以仓库 `package.json` 的 `name` 为准(`@dsh-plugin/dsh-ui-experience`),别名与仓库名不一致时需显式声明。
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: ui-experience
3
+ name: '@dsh-plugin/dsh-ui-experience'
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
2
+ <defs>
3
+ <linearGradient id="dsh-uix-icon-ring" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#f6bc70"/>
5
+ <stop offset="0.5" stop-color="#dec2a2"/>
6
+ <stop offset="1" stop-color="#bacd9f"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <circle cx="18" cy="18" r="13.2" fill="none" stroke="url(#dsh-uix-icon-ring)" stroke-width="3.2"/>
10
+ <circle cx="18" cy="18" r="6.1" fill="#251e17"/>
11
+ <circle cx="18" cy="18" r="2.55" fill="#f6bc70"/>
12
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,467 @@
1
+ "use strict";
2
+ (() => {
3
+ // src/shared.ts
4
+ var REMOTE_SERVICE = "uiExperience";
5
+ var BOOT_ATTRIBUTE = "data-dsh-uix-matugen";
6
+ var DEFAULT_COLORS_PATH = "~/.local/state/quickshell/user/generated/colors.json";
7
+ var DEFAULT_TOKENS_PATH = "~/.local/state/quickshell/user/generated/dsh-tokens.json";
8
+
9
+ // src/client.ts
10
+ var SOURCE = "@dsh-plugin/dsh-ui-experience";
11
+ var POLL_MS = 2e3;
12
+ var IDLE_TICKS = 15;
13
+ var BORDER_WIDTH = /^(?:0|[0-9]+(?:\.[0-9]+)?(?:px|rem|em))$/;
14
+ function uiExperienceRemoteContribution(zod) {
15
+ const codec = (name, schema) => ({
16
+ mode: "strict",
17
+ typeSymbol: `${SOURCE}/shared#${name}`,
18
+ create: schema
19
+ });
20
+ const descriptor = (method, shape, typeName) => ({
21
+ id: `${SOURCE}#${REMOTE_SERVICE}/${method}`,
22
+ service: REMOTE_SERVICE,
23
+ namespace: REMOTE_SERVICE,
24
+ method,
25
+ invocation: { kind: "direct" },
26
+ parameters: [{
27
+ name: "args",
28
+ wire: "args",
29
+ source: "json",
30
+ codec: codec(typeName, () => zod.object(shape())),
31
+ strictCodec: codec(typeName, () => zod.strictObject(shape()))
32
+ }],
33
+ result: codec(`${method}Result`, () => zod.object({}))
34
+ });
35
+ return {
36
+ package: SOURCE,
37
+ descriptors: [
38
+ descriptor("poll", () => ({ known: zod.string().optional() }), "PollArgs"),
39
+ descriptor("setActive", () => ({ active: zod.boolean().optional() }), "SetActiveArgs"),
40
+ descriptor("setPaths", () => ({ colorsPath: zod.string().optional(), tokensPath: zod.string().optional() }), "SetPathsArgs")
41
+ ]
42
+ };
43
+ }
44
+ var DICTS = {
45
+ zh: {
46
+ "palette.title": "Matugen \u914D\u8272\u8DDF\u968F",
47
+ "palette.description": "\u5C06\u7CFB\u7EDF matugen \u751F\u6210\u7684 Material You \u914D\u8272\u53E0\u52A0\u5728\u5F53\u524D\u4E3B\u9898\u4E0A\uFF0C\u914D\u8272\u6587\u4EF6\u91CD\u65B0\u751F\u6210\u540E\u81EA\u52A8\u8DDF\u968F",
48
+ "palette.persist-failed": "\u5F00\u5173\u72B6\u6001\u4FDD\u5B58\u5931\u8D25",
49
+ "palette.save-failed": "\u8DEF\u5F84\u4FDD\u5B58\u5931\u8D25",
50
+ "palette.saved": "\u5DF2\u4FDD\u5B58",
51
+ "palette.paths": "\u914D\u8272\u6587\u4EF6\u8DEF\u5F84",
52
+ "palette.colors-path": "matugen \u989C\u8272\u6587\u4EF6",
53
+ "palette.tokens-path": "DSH \u4EE4\u724C\u6587\u4EF6\uFF08\u5B58\u5728\u65F6\u4F18\u5148\uFF09",
54
+ "palette.save": "\u4FDD\u5B58",
55
+ "palette.reset": "\u6062\u590D\u9ED8\u8BA4",
56
+ "palette.paths-hint": "\u652F\u6301\u4EE5 ~ \u5F00\u5934\u7684\u8DEF\u5F84\uFF0C\u6216\u7EDD\u5BF9\u8DEF\u5F84\u3002\u4EE4\u724C\u6587\u4EF6\u5B58\u5728\u65F6\u4F18\u5148\u4F7F\u7528\uFF0C\u5426\u5219\u8BFB\u53D6\u989C\u8272\u6587\u4EF6\u5E76\u6620\u5C04\u3002"
57
+ },
58
+ en: {
59
+ "palette.title": "Matugen palette sync",
60
+ "palette.description": "Overlay the system matugen Material You palette on the current theme; follows the file when matugen regenerates it",
61
+ "palette.persist-failed": "Failed to save the switch state",
62
+ "palette.save-failed": "Failed to save the paths",
63
+ "palette.saved": "Saved",
64
+ "palette.paths": "Palette file paths",
65
+ "palette.colors-path": "matugen colors file",
66
+ "palette.tokens-path": "DSH token file (preferred when present)",
67
+ "palette.save": "Save",
68
+ "palette.reset": "Restore defaults",
69
+ "palette.paths-hint": "Paths may start with ~, or be absolute. The token file wins when it exists; otherwise the colors file is read and mapped."
70
+ }
71
+ };
72
+ var CSS = `
73
+ .dsh-uix-row{display:block;padding:16px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2)}
74
+ .dsh-uix-head{display:flex;align-items:center;justify-content:space-between;gap:24px}
75
+ .dsh-uix-text{min-width:0}
76
+ .dsh-uix-title{font-size:14px;line-height:22px}
77
+ .dsh-uix-desc{margin-top:4px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
78
+ .dsh-uix-error{margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}
79
+ .dsh-uix-notice{margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-state-success-primary)}
80
+ .dsh-uix-disclosure{margin-top:8px;padding:0;border:0;background:none;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer}
81
+ .dsh-uix-disclosure:hover{color:var(--dsw-alias-label-primary)}
82
+ .dsh-uix-disclosure:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-alias-state-business-primary);outline-offset:2px;border-radius:4px}
83
+ .dsh-uix-paths{margin-top:10px;display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
84
+ .dsh-uix-field{display:flex;flex-direction:column;gap:4px}
85
+ .dsh-uix-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
86
+ .dsh-uix-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,monospace);font-size:12px;outline:none}
87
+ .dsh-uix-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:var(--dsw-focus-ring-width) solid var(--dsw-alias-state-business-primary);outline-offset:1px}
88
+ .dsh-uix-actions{display:flex;gap:8px}
89
+ .dsh-uix-button{height:28px;padding:0 12px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;cursor:pointer}
90
+ .dsh-uix-button:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}
91
+ .dsh-uix-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-alias-state-business-primary);outline-offset:2px}
92
+ .dsh-uix-button-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-on-color)}
93
+ .dsh-uix-button:disabled{opacity:.5;cursor:default}
94
+ .dsh-uix-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
95
+ .dsh-uix-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3);cursor:pointer}
96
+ .dsh-uix-switch[aria-checked=true]{background:var(--dsw-alias-brand-primary)}
97
+ .dsh-uix-switch:disabled{cursor:default;opacity:.5}
98
+ .dsh-uix-switch:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-alias-state-business-primary);outline-offset:2px}
99
+ .dsh-uix-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .12s ease}
100
+ .dsh-uix-switch[aria-checked=false] .dsh-uix-switch-thumb{background:var(--dsw-alias-switch-thumb)}
101
+ .dsh-uix-switch[aria-checked=true] .dsh-uix-switch-thumb{transform:translate(16px)}
102
+ `;
103
+ function registerClient() {
104
+ window.__ModuleLoader__.load({
105
+ id: SOURCE,
106
+ factory(require2) {
107
+ const React = require2("react");
108
+ const zod = require2("zod");
109
+ const h = React.createElement;
110
+ function run(registry, ctx, gateway) {
111
+ const t = ctx.locale.bind("ui-experience");
112
+ ctx.effect(() => ctx.locale.register("ui-experience", DICTS), "ui-experience: dictionaries");
113
+ ctx.effect(() => {
114
+ const sheet = document.createElement("style");
115
+ sheet.textContent = CSS;
116
+ document.head.appendChild(sheet);
117
+ return () => {
118
+ sheet.remove();
119
+ };
120
+ }, "ui-experience: row styles");
121
+ let disposed = false;
122
+ let known;
123
+ let bootstrapped = false;
124
+ let writing = false;
125
+ let inflight = false;
126
+ let idleTick = 0;
127
+ let lastRpcProblem = "";
128
+ let lastPaletteProblem = "";
129
+ let paintedWidth = "";
130
+ let disposeOverride;
131
+ let borderSheet;
132
+ let view = {
133
+ active: false,
134
+ borderWidth: "0.5px",
135
+ paths: { colors: DEFAULT_COLORS_PATH, tokens: DEFAULT_TOKENS_PATH },
136
+ problem: "",
137
+ notice: "",
138
+ saving: false
139
+ };
140
+ const listeners = /* @__PURE__ */ new Set();
141
+ const publish = (patch) => {
142
+ view = { ...view, ...patch };
143
+ for (const listener of listeners) listener();
144
+ };
145
+ const toOverrides = (modes) => {
146
+ const out = {};
147
+ for (const [token, dark] of Object.entries(modes.dark)) {
148
+ const light = modes.light[token] ?? dark;
149
+ if (typeof light === "string" && light !== "") out[token] = { light, dark };
150
+ }
151
+ return out;
152
+ };
153
+ const clearOverride = () => {
154
+ disposeOverride?.();
155
+ disposeOverride = void 0;
156
+ };
157
+ const applyPalette = (modes) => {
158
+ if (disposed) return;
159
+ clearOverride();
160
+ try {
161
+ disposeOverride = registry.overrideTokens(SOURCE, toOverrides(modes));
162
+ } catch (error) {
163
+ const message = `theme override rejected: ${error.message}`;
164
+ console.error(`ui-experience: ${message}`);
165
+ publish({ problem: message });
166
+ }
167
+ };
168
+ const applyBorder = (width) => {
169
+ if (disposed) return;
170
+ if (!BORDER_WIDTH.test(width)) {
171
+ const message = `borderWidth ${JSON.stringify(width)} is not a CSS length; borders left untouched`;
172
+ console.error(`ui-experience: ${message}`);
173
+ publish({ problem: message });
174
+ return;
175
+ }
176
+ if (width === paintedWidth) return;
177
+ paintedWidth = width;
178
+ borderSheet?.remove();
179
+ borderSheet = document.createElement("style");
180
+ borderSheet.textContent = `body :where(*:not([data-dsh-uix-keep-border])){border-width:${width} !important}`;
181
+ document.head.appendChild(borderSheet);
182
+ };
183
+ const clearBorder = () => {
184
+ borderSheet?.remove();
185
+ borderSheet = void 0;
186
+ paintedWidth = "";
187
+ };
188
+ const setBootAttribute = (on) => {
189
+ if (on) document.documentElement.setAttribute(BOOT_ATTRIBUTE, "");
190
+ else document.documentElement.removeAttribute(BOOT_ATTRIBUTE);
191
+ };
192
+ const teardown = () => {
193
+ clearOverride();
194
+ clearBorder();
195
+ setBootAttribute(false);
196
+ };
197
+ const unwrap = (raw) => {
198
+ const envelope = raw;
199
+ if (envelope.ok === false) throw new Error(envelope.error?.message ?? "remote call failed");
200
+ return envelope.value;
201
+ };
202
+ const adopt = (answer) => {
203
+ if (answer.paths.colors !== view.paths.colors || answer.paths.tokens !== view.paths.tokens || answer.borderWidth !== view.borderWidth) {
204
+ publish({ paths: answer.paths, borderWidth: answer.borderWidth });
205
+ }
206
+ if (!bootstrapped) {
207
+ bootstrapped = true;
208
+ setBootAttribute(answer.active);
209
+ publish({ active: answer.active });
210
+ } else if (!writing && view.active !== answer.active) {
211
+ publish({ active: answer.active });
212
+ if (answer.active) {
213
+ setBootAttribute(true);
214
+ if (answer.status === "ok" && answer.tokens !== void 0) applyPalette(answer.tokens);
215
+ applyBorder(answer.borderWidth);
216
+ } else {
217
+ teardown();
218
+ }
219
+ }
220
+ if (answer.status !== "ok") {
221
+ if (answer.message !== lastPaletteProblem) {
222
+ lastPaletteProblem = answer.message;
223
+ console.error(`ui-experience: palette unavailable: ${answer.message}`);
224
+ }
225
+ publish({ problem: answer.message });
226
+ return;
227
+ }
228
+ if (lastPaletteProblem !== "") {
229
+ lastPaletteProblem = "";
230
+ publish({ problem: "" });
231
+ }
232
+ if (answer.changed) {
233
+ known = answer.version;
234
+ if (view.active && answer.tokens !== void 0) applyPalette(answer.tokens);
235
+ }
236
+ if (view.active) applyBorder(answer.borderWidth);
237
+ };
238
+ const poll = async () => {
239
+ if (disposed || inflight) return;
240
+ if (bootstrapped && !view.active) {
241
+ idleTick = (idleTick + 1) % IDLE_TICKS;
242
+ if (idleTick !== 0) return;
243
+ } else {
244
+ idleTick = 0;
245
+ }
246
+ inflight = true;
247
+ let answer;
248
+ try {
249
+ answer = unwrap(await gateway.poll({ known }));
250
+ } catch (error) {
251
+ const message = error.message;
252
+ if (message !== lastRpcProblem) {
253
+ lastRpcProblem = message;
254
+ console.error(`ui-experience: poll RPC failed: ${message}`);
255
+ }
256
+ return;
257
+ } finally {
258
+ inflight = false;
259
+ }
260
+ if (disposed) return;
261
+ lastRpcProblem = "";
262
+ adopt(answer);
263
+ };
264
+ const setActive = async (next) => {
265
+ if (disposed || writing) return;
266
+ writing = true;
267
+ publish({ saving: true, problem: "", notice: "" });
268
+ try {
269
+ if (!next) {
270
+ await gateway.setActive({ active: false });
271
+ if (disposed) return;
272
+ publish({ active: false });
273
+ teardown();
274
+ return;
275
+ }
276
+ const answer = unwrap(await gateway.poll({}));
277
+ if (disposed) return;
278
+ if (answer.status !== "ok") {
279
+ publish({ problem: answer.message });
280
+ return;
281
+ }
282
+ const tokens = answer.tokens;
283
+ if (tokens === void 0) throw new Error("poll returned no tokens for an activation request");
284
+ await gateway.setActive({ active: true });
285
+ if (disposed) return;
286
+ known = answer.version;
287
+ bootstrapped = true;
288
+ setBootAttribute(true);
289
+ publish({ active: true, paths: answer.paths, notice: t("palette.saved") });
290
+ applyPalette(tokens);
291
+ applyBorder(answer.borderWidth);
292
+ } catch (error) {
293
+ const message = error.message;
294
+ console.error(`ui-experience: failed to save the switch state: ${message}`);
295
+ publish({ problem: `${t("palette.persist-failed")}: ${message}` });
296
+ } finally {
297
+ writing = false;
298
+ publish({ saving: false });
299
+ }
300
+ };
301
+ const savePaths = async (colors, tokens) => {
302
+ if (disposed || writing) return;
303
+ writing = true;
304
+ publish({ saving: true, problem: "", notice: "" });
305
+ try {
306
+ const saved = unwrap(await gateway.setPaths({ colorsPath: colors, tokensPath: tokens }));
307
+ if (disposed) return;
308
+ const probe = unwrap(await gateway.poll({}));
309
+ if (disposed) return;
310
+ known = probe.status === "ok" ? probe.version : void 0;
311
+ publish({
312
+ paths: saved.paths,
313
+ notice: probe.status === "ok" ? t("palette.saved") : "",
314
+ problem: probe.status === "ok" ? "" : probe.message
315
+ });
316
+ if (probe.status === "ok" && view.active && probe.tokens !== void 0) applyPalette(probe.tokens);
317
+ } catch (error) {
318
+ const message = error.message;
319
+ console.error(`ui-experience: failed to save the palette paths: ${message}`);
320
+ publish({ problem: `${t("palette.save-failed")}: ${message}` });
321
+ } finally {
322
+ writing = false;
323
+ publish({ saving: false });
324
+ }
325
+ };
326
+ function PaletteRow(props) {
327
+ const tr = props.t ?? t;
328
+ const [state, setState] = React.useState(view);
329
+ const [open, setOpen] = React.useState(false);
330
+ const [colors, setColors] = React.useState(view.paths.colors);
331
+ const [tokens, setTokens] = React.useState(view.paths.tokens);
332
+ React.useEffect(() => {
333
+ const listener = () => {
334
+ setState(view);
335
+ };
336
+ listeners.add(listener);
337
+ return () => {
338
+ listeners.delete(listener);
339
+ };
340
+ }, []);
341
+ React.useEffect(() => {
342
+ setColors(state.paths.colors);
343
+ setTokens(state.paths.tokens);
344
+ }, [state.paths]);
345
+ const status = state.problem !== "" ? h("div", { className: "dsh-uix-error", role: "alert" }, state.problem) : state.notice !== "" ? h("div", { className: "dsh-uix-notice" }, state.notice) : null;
346
+ const field = (label, value, placeholder, set) => h(
347
+ "label",
348
+ { className: "dsh-uix-field" },
349
+ h("span", { className: "dsh-uix-label" }, label),
350
+ h("input", {
351
+ className: "dsh-uix-input",
352
+ type: "text",
353
+ value,
354
+ placeholder,
355
+ spellCheck: false,
356
+ onChange: (event) => {
357
+ set(event.target.value);
358
+ }
359
+ })
360
+ );
361
+ const editor = open ? h(
362
+ "div",
363
+ { className: "dsh-uix-paths" },
364
+ field(tr("palette.colors-path"), colors, DEFAULT_COLORS_PATH, setColors),
365
+ field(tr("palette.tokens-path"), tokens, DEFAULT_TOKENS_PATH, setTokens),
366
+ h(
367
+ "div",
368
+ { className: "dsh-uix-actions" },
369
+ h("button", {
370
+ type: "button",
371
+ className: "dsh-uix-button dsh-uix-button-primary",
372
+ disabled: state.saving,
373
+ onClick: () => {
374
+ void savePaths(colors.trim(), tokens.trim());
375
+ }
376
+ }, tr("palette.save")),
377
+ h("button", {
378
+ type: "button",
379
+ className: "dsh-uix-button",
380
+ disabled: state.saving,
381
+ onClick: () => {
382
+ void savePaths(DEFAULT_COLORS_PATH, DEFAULT_TOKENS_PATH);
383
+ }
384
+ }, tr("palette.reset"))
385
+ ),
386
+ h("div", { className: "dsh-uix-hint" }, tr("palette.paths-hint"))
387
+ ) : null;
388
+ return h(
389
+ "div",
390
+ { className: "dsh-uix-row" },
391
+ h(
392
+ "div",
393
+ { className: "dsh-uix-head" },
394
+ h(
395
+ "div",
396
+ { className: "dsh-uix-text" },
397
+ h("div", { className: "dsh-uix-title" }, tr("palette.title")),
398
+ h("div", { className: "dsh-uix-desc" }, tr("palette.description"))
399
+ ),
400
+ h("button", {
401
+ type: "button",
402
+ role: "switch",
403
+ "aria-checked": state.active,
404
+ "aria-label": tr("palette.title"),
405
+ disabled: state.saving,
406
+ className: "dsh-uix-switch",
407
+ onClick: () => {
408
+ void setActive(!state.active);
409
+ }
410
+ }, h("span", { className: "dsh-uix-switch-thumb", "aria-hidden": true }))
411
+ ),
412
+ status,
413
+ h("button", {
414
+ type: "button",
415
+ className: "dsh-uix-disclosure",
416
+ "aria-expanded": open,
417
+ onClick: () => {
418
+ setOpen(!open);
419
+ }
420
+ }, `${open ? "\u25BE" : "\u25B8"} ${tr("palette.paths")}`),
421
+ editor
422
+ );
423
+ }
424
+ ctx.effect(() => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
425
+ name: "settings.general.item",
426
+ id: "ui-experience.palette",
427
+ order: 10.5,
428
+ locale: "ui-experience"
429
+ }, PaletteRow)), "ui-experience: settings row");
430
+ ctx.effect(() => ctx.timer.interval(() => {
431
+ void poll();
432
+ }, POLL_MS), "ui-experience: palette polling");
433
+ ctx.effect(() => {
434
+ return () => {
435
+ disposed = true;
436
+ teardown();
437
+ };
438
+ }, "ui-experience: palette override");
439
+ void poll();
440
+ }
441
+ return {
442
+ inject: ["slots", "locale", "timer", "theme", "remote"],
443
+ async apply(ctx) {
444
+ const theme = ctx.get("theme");
445
+ if (theme === void 0) {
446
+ console.error("ui-experience: ctx.theme is unavailable; the bundle cannot apply palettes");
447
+ return;
448
+ }
449
+ const unmount = await ctx.remote.$mount(uiExperienceRemoteContribution(zod));
450
+ ctx.effect(() => () => {
451
+ unmount().catch((error) => {
452
+ console.error(`ui-experience: remote unmount failed: ${error.message}`);
453
+ });
454
+ }, "ui-experience: remote namespace");
455
+ const gateway = ctx.get(`remote.${REMOTE_SERVICE}`);
456
+ if (gateway === void 0) {
457
+ console.error("ui-experience: the remote namespace was not installed after mounting the contribution");
458
+ return;
459
+ }
460
+ run(theme, ctx, gateway);
461
+ }
462
+ };
463
+ }
464
+ });
465
+ }
466
+ registerClient();
467
+ })();
package/lib/index.js ADDED
@@ -0,0 +1,531 @@
1
+ // src/index.ts
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { readFile as readFileAsync, stat as statAsync } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
+ import z from "@deepseek-ai/schemastery";
7
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
8
+
9
+ // src/shared.ts
10
+ var REMOTE_SERVICE = "uiExperience";
11
+ var BOOT_ATTRIBUTE = "data-dsh-uix-matugen";
12
+ var DEFAULT_COLORS_PATH = "~/.local/state/quickshell/user/generated/colors.json";
13
+ var DEFAULT_TOKENS_PATH = "~/.local/state/quickshell/user/generated/dsh-tokens.json";
14
+
15
+ // src/index.ts
16
+ var LOG_FILE = join(homedir(), ".dsh", "ui-experience.log");
17
+ var LOG_MAX_BYTES = 512 * 1024;
18
+ function logLine(message) {
19
+ try {
20
+ const dir = dirname(LOG_FILE);
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > LOG_MAX_BYTES) writeFileSync(LOG_FILE, "");
23
+ appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
24
+ `);
25
+ } catch (error) {
26
+ console.error(`[ui-experience] failed to write ${LOG_FILE}: ${error.message}`);
27
+ }
28
+ }
29
+ var BORDER_WIDTH = /^(?:0|[0-9]+(?:\.[0-9]+)?(?:px|rem|em))$/;
30
+ var PATH_PATTERN = /^(?:~$|~\/|\/)/;
31
+ var Config = z.object({
32
+ active: z.boolean().default(false).volatile(),
33
+ borderWidth: z.string().pattern(BORDER_WIDTH).default("0.5px").volatile(),
34
+ colorsPath: z.string().pattern(PATH_PATTERN).default(DEFAULT_COLORS_PATH).volatile(),
35
+ tokensPath: z.string().pattern(PATH_PATTERN).default(DEFAULT_TOKENS_PATH).volatile()
36
+ });
37
+ var REQUIRED_COLOR_KEYS = [
38
+ "surface_container_lowest",
39
+ "surface_container_low",
40
+ "surface_container",
41
+ "surface_container_high",
42
+ "surface_container_highest",
43
+ "surface_bright",
44
+ "on_surface",
45
+ "on_surface_variant",
46
+ "outline",
47
+ "outline_variant",
48
+ "primary",
49
+ "primary_container",
50
+ "on_primary",
51
+ "secondary",
52
+ "secondary_container",
53
+ "tertiary",
54
+ "tertiary_container",
55
+ "error",
56
+ "error_container",
57
+ "inverse_surface",
58
+ "inverse_on_surface",
59
+ "scrim",
60
+ "shadow"
61
+ ];
62
+ var HEX_COLOR = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
63
+ var CSS_VALUE_FORBIDDEN = /[{};<>\n\r]|\/\*|\*\//;
64
+ var CSS_VALUE_MAX_LENGTH = 256;
65
+ function isSafeTokenValue(value) {
66
+ return value.length > 0 && value.length <= CSS_VALUE_MAX_LENGTH && !CSS_VALUE_FORBIDDEN.test(value);
67
+ }
68
+ var REMOTE_METHOD_DESCRIPTOR = "@deepseek-ai/dsh-typert-protocol/remote-methods";
69
+ function mapColors(colors) {
70
+ const surface = [
71
+ colors.surface_container_lowest,
72
+ colors.surface_container_low,
73
+ colors.surface_container,
74
+ colors.surface_container_high,
75
+ colors.surface_container_highest,
76
+ colors.surface_bright
77
+ ];
78
+ return {
79
+ // ── surfaces ──────────────────────────────────────────────
80
+ "--dsw-alias-bg-base": surface[1],
81
+ "--dsw-alias-bg-layer-1": surface[2],
82
+ "--dsw-alias-bg-layer-2": surface[3],
83
+ "--dsw-alias-bg-layer-3": surface[4],
84
+ "--dsw-alias-bg-layer-4": surface[5],
85
+ "--dsw-alias-bg-overlay": `color-mix(in srgb, ${surface[5]}, white 8%)`,
86
+ // 内置主题的遮罩是半透明黑,直接取 scrim 会得到不透明纯黑,盖住下层界面。
87
+ "--dsw-alias-bg-mask-1": `color-mix(in srgb, ${colors.scrim}, transparent 50%)`,
88
+ "--dsw-alias-bg-module-platform": surface[4],
89
+ "--dsw-alias-bg-skeleton": surface[4],
90
+ // ── borders — uniform outline_variant to avoid visual fragmentation ──
91
+ "--dsw-alias-border-l1": colors.outline_variant,
92
+ "--dsw-alias-border-l2": colors.outline_variant,
93
+ "--dsw-alias-border-l2-darkmode-thin": colors.outline_variant,
94
+ "--dsw-alias-border-l3": colors.outline_variant,
95
+ "--dsw-alias-border-l4": colors.outline_variant,
96
+ // ── brand & labels ────────────────────────────────────────
97
+ "--dsw-alias-brand-primary": colors.primary,
98
+ "--dsw-alias-brand-primary-new-colorprimary-new-color": colors.primary,
99
+ "--dsw-alias-label-primary": colors.on_surface,
100
+ "--dsw-alias-label-secondary": colors.on_surface_variant,
101
+ "--dsw-alias-label-tertiary": colors.on_surface_variant,
102
+ "--dsw-alias-label-caption": colors.on_surface_variant,
103
+ "--dsw-alias-label-dimmed": colors.outline,
104
+ "--dsw-alias-label-error": colors.error,
105
+ "--dsw-alias-label-primary-foreground": colors.on_primary,
106
+ "--dsw-alias-label-shimmer": colors.on_surface_variant,
107
+ "--dsw-alias-link": colors.primary,
108
+ // ── interactive ───────────────────────────────────────────
109
+ "--dsw-alias-interactive-bg-hover": surface[4],
110
+ "--dsw-alias-interactive-bg-active": surface[5],
111
+ "--dsw-alias-interactive-bg-hover-danger": colors.error_container,
112
+ // ── buttons ───────────────────────────────────────────────
113
+ "--dsw-alias-button-primary-fill": colors.primary,
114
+ "--dsw-alias-button-primary-hover": colors.primary,
115
+ "--dsw-alias-button-info-fill": colors.primary,
116
+ "--dsw-alias-button-ghost-active-fill": surface[4],
117
+ "--dsw-alias-button-ghost-active-border": colors.outline,
118
+ "--dsw-alias-button-tool-bar-fill": surface[4],
119
+ "--dsw-alias-button-tool-bar-hover": surface[5],
120
+ "--dsw-alias-label-on-color": colors.on_primary,
121
+ // ── states ────────────────────────────────────────────────
122
+ "--dsw-alias-state-error-primary": colors.error,
123
+ "--dsw-alias-state-idle-primary": colors.outline,
124
+ "--dsw-alias-state-success-primary": colors.tertiary,
125
+ "--dsw-alias-state-success-tertiary": colors.tertiary_container,
126
+ "--dsw-alias-state-warn-primary": colors.secondary,
127
+ "--dsw-alias-state-warn-label": colors.secondary,
128
+ "--dsw-alias-state-warn-tertiary": colors.secondary_container,
129
+ "--dsw-alias-state-business-primary": colors.primary,
130
+ // ── switch ────────────────────────────────────────────────
131
+ "--dsw-alias-switch-thumb": colors.primary,
132
+ // ── menu & overlay ────────────────────────────────────────
133
+ "--dsw-alias-menu-icon": colors.on_surface_variant,
134
+ "--dsw-alias-menu-group-header-fill": surface[4],
135
+ "--dsw-alias-hovercard-bg": surface[4],
136
+ "--dsw-alias-hovercard-label-primary": colors.on_surface,
137
+ // 官方契约里 tooltip 与 toast 始终是深色气泡配浅色文字,key 块是气泡色
138
+ // 混 18% 白。Material 的 inverse_* 在深色调色板下方向相反(浅底深字),
139
+ // 会让继承 label 色的键位提示变成深底深字,故这里改用容器面与前景色。
140
+ "--dsw-alias-tooltip-bg": surface[4],
141
+ "--dsw-alias-tooltip-label": colors.on_surface,
142
+ "--dsw-alias-tooltip-key-bg": `color-mix(in srgb, ${surface[4]}, white 18%)`,
143
+ "--dsw-alias-toast-bg": surface[4],
144
+ "--dsw-alias-toast-label": colors.on_surface,
145
+ "--dsw-alias-toast-action": colors.primary,
146
+ // ── scrollbar ─────────────────────────────────────────────
147
+ "--dsw-alias-scrollbar-bg-l2": surface[2],
148
+ "--dsw-alias-scrollbar-hover-l2": surface[4],
149
+ // ── markdown & code ───────────────────────────────────────
150
+ "--dsw-alias-markdown-code-block": surface[2],
151
+ "--dsw-alias-markdown-code-block-banner": surface[3],
152
+ "--dsw-alias-markdown-inline-code": surface[4],
153
+ "--dsw-alias-markdown-tag": surface[4],
154
+ // ── JSON tree ─────────────────────────────────────────────
155
+ "--dsw-alias-json-tree-hover": surface[4],
156
+ "--dsw-alias-json-tree-icon": colors.on_surface_variant,
157
+ "--dsw-alias-json-tree-keyword": colors.primary,
158
+ "--dsw-alias-json-tree-number": colors.tertiary,
159
+ "--dsw-alias-json-tree-property": colors.on_surface,
160
+ "--dsw-alias-json-tree-punctuation": colors.on_surface_variant,
161
+ "--dsw-alias-json-tree-string": colors.secondary,
162
+ // ── diff ──────────────────────────────────────────────────
163
+ "--dsw-alias-code-diff-added": surface[4],
164
+ "--dsw-alias-code-diff-deleted": surface[4],
165
+ "--dsw-alias-file-diff-added-bg": surface[2],
166
+ "--dsw-alias-file-diff-added-marker": colors.tertiary,
167
+ "--dsw-alias-file-diff-deleted-bg": surface[2],
168
+ "--dsw-alias-file-diff-deleted-marker": colors.error,
169
+ // ── app-specific ──────────────────────────────────────────
170
+ "--dsw-specific-sidebar-fill": surface[0],
171
+ "--dsw-specific-input-major": surface[2],
172
+ "--dsw-specific-bubble": surface[2],
173
+ "--dsw-specific-menu": surface[3],
174
+ "--dsw-menu-surface-fill": surface[3],
175
+ "--dsw-focus-ring-color": colors.primary,
176
+ // ── plugin-side surfaces & fills ──────────────────────────
177
+ "--dsw-alias-bg-document-preview": surface[2],
178
+ "--dsw-alias-bg-l1": surface[2],
179
+ "--dsw-alias-bg-mask-drop": `color-mix(in srgb, ${colors.scrim}, transparent 30%)`,
180
+ "--dsw-alias-card-fill": surface[2],
181
+ "--dsw-alias-card-fill-hover": surface[3],
182
+ "--dsw-alias-card-tile-fill": surface[2],
183
+ "--dsw-alias-fill-l1": surface[2],
184
+ "--dsw-alias-fill-l2": surface[4],
185
+ "--dsw-alias-fill-tsp-secondary": surface[3],
186
+ "--dsw-alias-settings-card-fill": surface[2],
187
+ "--dsw-alias-settings-card-stroke": colors.outline_variant,
188
+ "--dsw-alias-illustration-background": surface[1],
189
+ "--dsw-alias-illustration-backing": surface[2],
190
+ "--dsw-alias-illustration-detail": surface[3],
191
+ "--dsw-alias-illustration-inset": surface[0],
192
+ "--dsw-alias-illustration-shadow": colors.shadow,
193
+ "--dsw-alias-illustration-surface": surface[4],
194
+ // ── plugin-side borders & separators ──────────────────────
195
+ "--dsw-alias-border-inverted": colors.inverse_surface,
196
+ "--dsw-alias-border-selected": colors.primary,
197
+ "--dsw-alias-separator-primary": colors.outline_variant,
198
+ // ── plugin-side buttons ───────────────────────────────────
199
+ "--dsw-alias-button-contrast-fill": colors.inverse_surface,
200
+ "--dsw-alias-button-elevated-fill": surface[4],
201
+ "--dsw-alias-button-floating-fill": surface[4],
202
+ "--dsw-alias-button-floating-hover": surface[5],
203
+ "--dsw-alias-button-info-hover": colors.primary_container,
204
+ // ── plugin-side labels ────────────────────────────────────
205
+ "--dsw-alias-label-deep-diving": colors.primary,
206
+ "--dsw-alias-label-deep-diving-shimmer": colors.secondary,
207
+ "--dsw-alias-label-primary-bluish": colors.on_surface,
208
+ "--dsw-alias-label-primary-dimmed": colors.on_surface_variant,
209
+ "--dsw-alias-label-primary-inverted": colors.inverse_on_surface,
210
+ "--dsw-alias-label-quaternary": colors.outline,
211
+ "--dsw-alias-hovercard-label-secondary": colors.on_surface_variant,
212
+ "--dsw-alias-hovercard-label-tertiary": colors.on_surface_variant,
213
+ // ── plugin-side interactive & states ──────────────────────
214
+ "--dsw-alias-interactive-bg-hover-accent": colors.primary_container,
215
+ "--dsw-alias-interactive-bg-hover-solid": surface[5],
216
+ "--dsw-alias-state-business-tertiary": colors.primary_container,
217
+ "--dsw-alias-state-error-secondary": colors.error_container,
218
+ "--dsw-alias-state-success-secondary": colors.tertiary_container,
219
+ "--dsw-alias-state-warn-secondary": colors.secondary_container,
220
+ "--dsw-alias-trajectory-accent": colors.primary,
221
+ // ── charts, citations, onboarding, turn triggers ──────────
222
+ "--dsw-alias-chart-messages": colors.primary,
223
+ "--dsw-alias-chart-system": colors.secondary,
224
+ "--dsw-alias-chart-tools": colors.tertiary,
225
+ "--dsw-alias-markdown-citation": surface[4],
226
+ "--dsw-alias-onboarding-accent": colors.primary,
227
+ "--dsw-alias-onboarding-card-fill": surface[2],
228
+ "--dsw-alias-onboarding-checkbox-border": colors.outline,
229
+ "--dsw-alias-onboarding-secondary-fill": surface[4],
230
+ "--dsw-alias-turn-trigger-bg": surface[4],
231
+ "--dsw-alias-turn-trigger-bg-hover": surface[5],
232
+ // ── plugin-side scrollbars ────────────────────────────────
233
+ "--dsw-alias-scrollbar-bg-l1": surface[0],
234
+ "--dsw-alias-scrollbar-hover-l1": surface[2],
235
+ // ── diff gutters ──────────────────────────────────────────
236
+ "--dsw-alias-file-diff-added-gutter": colors.tertiary_container,
237
+ "--dsw-alias-file-diff-deleted-gutter": colors.error_container,
238
+ // ── sidebar & misc specifics ──────────────────────────────
239
+ // 侧栏玻璃按钮是侧栏内层,取比侧栏底色高一级,避免与底色重合。tint 与
240
+ // wash 只在 macOS 上作为透明渐变叠加在侧栏之上,这里保持与底色同值。
241
+ "--dsw-specific-sidebar-glass-fill": surface[1],
242
+ "--dsw-specific-sidebar-glass-hover": surface[2],
243
+ "--dsw-specific-sidebar-glass-collapsed-fill": surface[1],
244
+ "--dsw-specific-sidebar-glass-collapsed-hover": surface[2],
245
+ "--dsw-specific-sidebar-nav-item-active": surface[4],
246
+ // 该 token 也作为「推荐」徽标等元素的文字底色,文字色是 button-info-fill
247
+ // (映射为 primary),因此这里必须用容器色保证对比。
248
+ "--dsw-specific-sidebar-nav-item-active-accent": colors.primary_container,
249
+ "--dsw-specific-sidebar-right-flash": surface[3],
250
+ "--dsw-specific-sidebar-tint": surface[0],
251
+ "--dsw-specific-sidebar-wash-bottom": surface[0],
252
+ "--dsw-specific-sidebar-wash-bottom-clear": surface[0],
253
+ "--dsw-specific-sidebar-wash-top": surface[0],
254
+ "--dsw-specific-sidebar-wash-top-clear": surface[0],
255
+ "--dsw-specific-file-upload-track": surface[4],
256
+ "--dsw-specific-guide-hero": colors.primary,
257
+ "--dsw-specific-selector": surface[4]
258
+ };
259
+ }
260
+ function validateColors(value) {
261
+ if (typeof value !== "object" || value === null) {
262
+ throw new Error("matugen colors file is not a JSON object");
263
+ }
264
+ const colors = value;
265
+ for (const key of REQUIRED_COLOR_KEYS) {
266
+ const color = colors[key];
267
+ if (typeof color !== "string" || !HEX_COLOR.test(color)) {
268
+ throw new Error(`matugen colors file: role "${key}" is missing or not a hex color`);
269
+ }
270
+ }
271
+ return colors;
272
+ }
273
+ function validateTokenDocument(value) {
274
+ if (typeof value !== "object" || value === null) {
275
+ throw new Error('dsh-tokens file must be { "light": {...}, "dark": {...} } token tables');
276
+ }
277
+ const document = value;
278
+ for (const mode of ["light", "dark"]) {
279
+ const table = document[mode];
280
+ if (typeof table !== "object" || table === null) {
281
+ throw new Error(`dsh-tokens file: missing "${mode}" token table`);
282
+ }
283
+ for (const [name, color] of Object.entries(table)) {
284
+ if (!name.startsWith("--dsw-")) {
285
+ throw new Error(`dsh-tokens file: invalid token name ${JSON.stringify(name)}`);
286
+ }
287
+ if (typeof color !== "string" || !isSafeTokenValue(color)) {
288
+ throw new Error(`dsh-tokens file: token "${name}" must be a CSS value without braces, semicolons, or angle brackets, got ${JSON.stringify(color)}`);
289
+ }
290
+ }
291
+ }
292
+ return document;
293
+ }
294
+ function resolvePath(raw, label) {
295
+ const trimmed = raw.trim();
296
+ if (trimmed === "") throw new Error(`ui-experience: ${label} is empty; set it in the plugin configuration`);
297
+ const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
298
+ if (!isAbsolute(expanded)) {
299
+ throw new Error(`ui-experience: ${label} must be absolute or start with "~/": ${raw}`);
300
+ }
301
+ return expanded;
302
+ }
303
+ function effectivePaths(config) {
304
+ return { colors: config.colorsPath.get(), tokens: config.tokensPath.get() };
305
+ }
306
+ var paletteCache;
307
+ var PALETTE_MAX_BYTES = 4 * 1024 * 1024;
308
+ var readFileText = (path) => readFileAsync(path, "utf8");
309
+ var statFile = (path) => statAsync(path);
310
+ function resolveSources(config) {
311
+ const paths = effectivePaths(config);
312
+ let tokensFile;
313
+ let colorsFile;
314
+ try {
315
+ tokensFile = resolvePath(paths.tokens, "tokensPath");
316
+ colorsFile = resolvePath(paths.colors, "colorsPath");
317
+ } catch (error) {
318
+ return { ok: false, paths, status: "error", message: error.message };
319
+ }
320
+ const source = existsSync(tokensFile) ? tokensFile : existsSync(colorsFile) ? colorsFile : void 0;
321
+ return { ok: true, sources: { paths, tokensFile, colorsFile }, source };
322
+ }
323
+ function parsePalette(source, sources, text) {
324
+ if (source === sources.tokensFile) return validateTokenDocument(JSON.parse(text));
325
+ const table = mapColors(validateColors(JSON.parse(text)));
326
+ return { light: table, dark: table };
327
+ }
328
+ function readPaletteSync(config) {
329
+ const resolved = resolveSources(config);
330
+ if (!resolved.ok) return resolved;
331
+ const { sources, source } = resolved;
332
+ if (source === void 0) {
333
+ return {
334
+ ok: false,
335
+ paths: sources.paths,
336
+ status: "missing",
337
+ message: `no palette file at tokensPath (${sources.tokensFile}) or colorsPath (${sources.colorsFile})`
338
+ };
339
+ }
340
+ try {
341
+ const stat = statSync(source);
342
+ if (stat.size > PALETTE_MAX_BYTES) {
343
+ throw new Error(`${source} is ${stat.size} bytes, over the ${PALETTE_MAX_BYTES} byte limit`);
344
+ }
345
+ const version = `${stat.mtimeMs}:${stat.size}`;
346
+ if (paletteCache !== void 0 && paletteCache.source === source && paletteCache.version === version) {
347
+ return { ok: true, paths: sources.paths, palette: paletteCache };
348
+ }
349
+ const modes = parsePalette(source, sources, readFileSync(source, "utf8"));
350
+ paletteCache = { source, version, tokens: modes };
351
+ return { ok: true, paths: sources.paths, palette: paletteCache };
352
+ } catch (error) {
353
+ return { ok: false, paths: sources.paths, status: "error", message: `cannot read ${source}: ${error.message}` };
354
+ }
355
+ }
356
+ async function readPalette(config) {
357
+ const resolved = resolveSources(config);
358
+ if (!resolved.ok) return resolved;
359
+ const { sources, source } = resolved;
360
+ if (source === void 0) {
361
+ return {
362
+ ok: false,
363
+ paths: sources.paths,
364
+ status: "missing",
365
+ message: `no palette file at tokensPath (${sources.tokensFile}) or colorsPath (${sources.colorsFile})`
366
+ };
367
+ }
368
+ try {
369
+ const stat = await statFile(source);
370
+ if (stat.size > PALETTE_MAX_BYTES) {
371
+ throw new Error(`${source} is ${stat.size} bytes, over the ${PALETTE_MAX_BYTES} byte limit`);
372
+ }
373
+ const version = `${stat.mtimeMs}:${stat.size}`;
374
+ if (paletteCache !== void 0 && paletteCache.source === source && paletteCache.version === version) {
375
+ return { ok: true, paths: sources.paths, palette: paletteCache };
376
+ }
377
+ const modes = parsePalette(source, sources, await readFileText(source));
378
+ paletteCache = { source, version, tokens: modes };
379
+ return { ok: true, paths: sources.paths, palette: paletteCache };
380
+ } catch (error) {
381
+ return { ok: false, paths: sources.paths, status: "error", message: `cannot read ${source}: ${error.message}` };
382
+ }
383
+ }
384
+ function bootColor(value, mode) {
385
+ if (value !== void 0 && HEX_COLOR.test(value)) return value;
386
+ const fallback = mode === "light" ? "#fff" : "#151517";
387
+ logLine(`boot paint: ${mode} base color ${JSON.stringify(value)} is not a hex color; using ${fallback}`);
388
+ return fallback;
389
+ }
390
+ function bootRows(palette) {
391
+ const light = bootColor(palette.tokens.light["--dsw-alias-bg-base"], "light");
392
+ const dark = bootColor(palette.tokens.dark["--dsw-alias-bg-base"], "dark");
393
+ const scope = `html[${BOOT_ATTRIBUTE}]`;
394
+ const text = `${scope} body[data-ds-dark-theme]{background-color:${dark};--dsh-boot-bg:${dark}}${scope} body:not([data-ds-dark-theme]){background-color:${light};--dsh-boot-bg:${light}}`;
395
+ return [
396
+ { kind: "style", text },
397
+ {
398
+ kind: "script",
399
+ placement: "body",
400
+ text: `document.documentElement.toggleAttribute(${JSON.stringify(BOOT_ATTRIBUTE)}, true)`
401
+ }
402
+ ];
403
+ }
404
+ var UiExperienceService = class extends TypertRemoteService {
405
+ constructor(ctx, config, entry, editor) {
406
+ super(ctx, REMOTE_SERVICE);
407
+ this.config = config;
408
+ this.entry = entry;
409
+ this.editor = editor;
410
+ }
411
+ config;
412
+ entry;
413
+ editor;
414
+ /**
415
+ * Remote poll endpoint: report the palette when it changed since the caller's
416
+ * known version, and always report the live switch, border width, and paths.
417
+ * @param args - the caller's last seen version.
418
+ * @returns the poll payload.
419
+ */
420
+ async poll(args) {
421
+ const read = await readPalette(this.config);
422
+ const active = this.config.active.get();
423
+ const borderWidth = this.config.borderWidth.get();
424
+ if (!read.ok) {
425
+ return { status: read.status, message: read.message, active, borderWidth, paths: read.paths };
426
+ }
427
+ const { palette, paths } = read;
428
+ if (args?.known === palette.version) {
429
+ return { status: "ok", changed: false, version: palette.version, source: palette.source, active, borderWidth, paths };
430
+ }
431
+ return {
432
+ status: "ok",
433
+ changed: true,
434
+ version: palette.version,
435
+ tokens: palette.tokens,
436
+ source: palette.source,
437
+ active,
438
+ borderWidth,
439
+ paths
440
+ };
441
+ }
442
+ /**
443
+ * Remote endpoint persisting the Matugen mode switch.
444
+ * @param args - the requested switch state.
445
+ * @returns the persisted state.
446
+ */
447
+ async setActive(args) {
448
+ const next = args?.active;
449
+ if (typeof next !== "boolean") throw new Error("ui-experience: setActive requires a boolean `active`");
450
+ await this.persist((current) => ({ ...current, active: next }));
451
+ logLine(`setActive(${next}) persisted`);
452
+ return { active: next };
453
+ }
454
+ /**
455
+ * Remote endpoint persisting the palette paths.
456
+ * @param args - the paths to write; an omitted field keeps its value.
457
+ * @returns the paths now in effect.
458
+ */
459
+ async setPaths(args) {
460
+ const colors = typeof args?.colorsPath === "string" ? args.colorsPath.trim() : void 0;
461
+ const tokens = typeof args?.tokensPath === "string" ? args.tokensPath.trim() : void 0;
462
+ if (colors === void 0 && tokens === void 0) {
463
+ throw new Error("ui-experience: setPaths requires `colorsPath` or `tokensPath`");
464
+ }
465
+ if (colors !== void 0) resolvePath(colors, "colorsPath");
466
+ if (tokens !== void 0) resolvePath(tokens, "tokensPath");
467
+ await this.persist((current) => ({
468
+ ...current,
469
+ ...colors === void 0 ? {} : { colorsPath: colors },
470
+ ...tokens === void 0 ? {} : { tokensPath: tokens }
471
+ }));
472
+ const paths = {
473
+ colors: colors ?? this.config.colorsPath.get(),
474
+ tokens: tokens ?? this.config.tokensPath.get()
475
+ };
476
+ logLine(`setPaths: colors=${paths.colors} tokens=${paths.tokens}`);
477
+ return { paths };
478
+ }
479
+ /**
480
+ * Write one configuration change through the config editor, logging the
481
+ * failure before it reaches the caller.
482
+ * @param change - derives the next raw config from the current one.
483
+ */
484
+ async persist(change) {
485
+ const { entry, editor } = this;
486
+ if (entry === void 0 || editor === void 0) {
487
+ throw new Error("ui-experience: the plugin entry or the config editor is unavailable; configuration cannot be saved in this profile");
488
+ }
489
+ try {
490
+ await editor.edit(entry, change);
491
+ } catch (error) {
492
+ logLine(`config edit FAILED: ${error.stack ?? error.message}`);
493
+ throw error;
494
+ }
495
+ }
496
+ };
497
+ Object.defineProperty(UiExperienceService.prototype, REMOTE_METHOD_DESCRIPTOR, {
498
+ configurable: true,
499
+ value: Object.freeze({
500
+ version: 1,
501
+ methods: Object.freeze([
502
+ Object.freeze({ method: "poll", invocation: Object.freeze({ kind: "direct" }) }),
503
+ Object.freeze({ method: "setActive", invocation: Object.freeze({ kind: "direct" }) }),
504
+ Object.freeze({ method: "setPaths", invocation: Object.freeze({ kind: "direct" }) })
505
+ ])
506
+ })
507
+ });
508
+ var inject = ["configEditor"];
509
+ function apply(ctx, config) {
510
+ logLine(`apply: active=${String(config.active.get())} borderWidth=${config.borderWidth.get()} colorsPath=${config.colorsPath.get()} tokensPath=${config.tokensPath.get()}`);
511
+ try {
512
+ new UiExperienceService(ctx, config, ctx.fiber.entry, ctx.configEditor);
513
+ } catch (error) {
514
+ console.error(`[ui-experience] failed to register host service: ${error.message}`);
515
+ throw error;
516
+ }
517
+ ctx.on("webserver/index-inject", (table) => {
518
+ if (!config.active.get()) return;
519
+ const read = readPaletteSync(config);
520
+ if (!read.ok) {
521
+ logLine(`boot paint skipped: ${read.message}`);
522
+ return;
523
+ }
524
+ table.push(...bootRows(read.palette));
525
+ });
526
+ }
527
+ export {
528
+ Config,
529
+ apply,
530
+ inject
531
+ };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Interface experience",
4
+ "description": "Make the DSH interface follow the Material You palette matugen generates on the system. After enabling Settings → General → Matugen palette sync, the palette is layered over the current theme and follows the file whenever matugen regenerates it. Expand Palette file paths on the same row to rewrite the matugen colors file or the DSH token file path (both default under ~/.local/state/quickshell/user/generated/, the token file wins when present). Palette files are read locally and never uploaded; turning the switch off returns the native colors. Also unifies the interface border width (config option borderWidth, default 0.5px); that override applies only while the switch is on and narrows decorative borders such as markdown quote rules."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "界面体验",
4
+ "description": "让 DSH 界面跟随系统 matugen 生成的 Material You 配色:在「设置 → 通用设置 → Matugen 配色跟随」开启后,界面配色作为叠加层套在当前主题上,并随配色文件重新生成自动更新。可在同一行展开「配色文件路径」,改写 matugen 颜色文件或 DSH 令牌文件的路径(默认 ~/.local/state/quickshell/user/generated/ 下,令牌文件优先)。配色只在本地读取,不上传。关闭开关后回到原生配色。另统一界面边框宽度(配置项 borderWidth,默认 0.5px)。该覆盖仅在开关开启时生效,且会同时压窄正文引用块等装饰性边框。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
- {
2
- "name": "@dsh-plugin/dsh-ui-experience",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@dsh-plugin/dsh-ui-experience",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "UI experience enhancements for DeepSeek Harness: follow the system matugen Material You palette as the interface theme.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dsh-plugins/dsh-ui-experience.git"
10
+ },
11
+ "homepage": "https://github.com/dsh-plugins/dsh-ui-experience",
12
+ "bugs": {
13
+ "url": "https://github.com/dsh-plugins/dsh-ui-experience/issues"
14
+ },
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg"
21
+ },
22
+ "files": [
23
+ "lib/index.js",
24
+ "lib/client.js",
25
+ "cordis.patch.yml",
26
+ "locale/*.json",
27
+ "icon.svg"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json && node build.mjs",
31
+ "typecheck": "tsc -p tsconfig.json"
32
+ },
33
+ "dependencies": {
34
+ "@deepseek-ai/dsh-typert-protocol": "^0.2.2-rc.2",
35
+ "@deepseek-ai/schemastery": "^3.19.0"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "immediately": true,
44
+ "inject": [
45
+ "@deepseek-ai/dsh-api-remotes",
46
+ "@deepseek-ai/dsh-client-ui-theme"
47
+ ]
48
+ }
49
+ },
50
+ "engines": {
51
+ "dsh": ">=0.2.0-0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^26.6.6",
55
+ "esbuild": "^0.28.2",
56
+ "typescript": "^5.9.3"
57
+ }
58
+ }