@dsh-plugin/dsh-ui-experience 1.0.0-dev.43485427852 → 1.0.0-dev.43486958683

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,6 +1,6 @@
1
1
  # dsh-ui-experience
2
2
 
3
- DeepSeek Harness 界面体验插件(`@dsh-plugin/dsh-ui-experience`,仓库:<https://github.com/dsh-plugins/dsh-ui-experience>):读取系统 matugen 生成的 Material You 配色,作为叠加层套在 DSH 当前主题上,并统一界面边框宽度。
3
+ DeepSeek Harness 界面体验插件(`@dsh-plugin/dsh-ui-experience`,仓库:<https://github.com/dsh-plugins/dsh-ui-experience>):读取系统 matugen 生成的 Material You 配色,作为叠加层套在 DSH 当前主题上;按 Material 3 曲线改写整个界面的过渡与关键帧动画;并统一界面边框宽度。
4
4
 
5
5
  ## 功能
6
6
 
@@ -8,6 +8,7 @@ DeepSeek Harness 界面体验插件(`@dsh-plugin/dsh-ui-experience`,仓库
8
8
  - 配色是叠加层,不改写原生「浅色 / 深色 / 跟随系统」偏好;开关关闭后回到原生配色。
9
9
  - 同一行可展开 **配色文件路径**,分别编辑 matugen 颜色文件与 DSH 令牌文件的路径,两者都会持久化;开关状态与路径均保存在 profile 配置里,重启应用后自动恢复。
10
10
  - 统一界面边框宽度:插件配置 `borderWidth`(默认 `0.5px`),仅在「Matugen 配色跟随」开启时生效。
11
+ - 「设置 → 通用设置」另起一行 **M3 动效**,可选择 `M3` / `M3E` 两族曲线,并调时长倍率、时长对齐与减弱动效策略。它原地改写页面上已生效的样式规则,覆盖范围包含第三方插件注入的样式。
11
12
  - 配色文件只在本地读取,不上传。
12
13
 
13
14
  ## 使用
@@ -107,6 +108,60 @@ output_path = '~/.local/state/quickshell/user/generated/dsh-tokens.json'
107
108
 
108
109
  token 的值按原样写入 CSS 变量,因此 `color-mix(...)` 这类表达式可以直接写在模板里。改完模板后重新运行 matugen,插件在下一个轮询周期读到文件变化后生效。
109
110
 
111
+ ## M3 动效
112
+
113
+ 「设置 → 通用设置 → M3 动效」把界面的动画曲线换成 Material 3 / Material 3 Expressive 的曲线族。
114
+
115
+ ### 曲线族
116
+
117
+ | 用途 | M3 | M3E |
118
+ | --- | --- | --- |
119
+ | 位移(transform、宽高、内外距、gap、圆角…) | `cubic-bezier(0.2, 0, 0, 1)` | 声明时长 ≤200ms 用 `cubic-bezier(0.42, 1.67, 0.21, 0.9)`,否则用 `cubic-bezier(0.38, 1.21, 0.22, 1)` |
120
+ | 效果(颜色、透明度、阴影、滤镜…) | `cubic-bezier(0.2, 0, 0, 1)` | `cubic-bezier(0.34, 0.8, 0.34, 1)` |
121
+
122
+ M3E 的两条位移曲线带过冲,峰值分别为 1.092 与 1.014;效果曲线不过冲。这正是 M3E 的设计意图:物体移动有惯性,颜色过冲会闪出调色板里不存在的颜色。所以位移与效果的曲线必须分开,关闭「位移与效果使用不同曲线」后一律用效果曲线。
123
+
124
+ M3 族的 standard 与 emphasized 在 CSS 的三次贝塞尔下都退化为同一个值(M3 的 emphasized 是五次贝塞尔,`cubic-bezier()` 表达不了),因此 M3 族整个界面只有一条曲线,这是规范本身的形状,不是实现偷懒。
125
+
126
+ ### 为什么是改写 CSSOM,而不是注入覆盖样式表
127
+
128
+ 注入一张「覆盖表」有两种写法,都不成立:
129
+
130
+ - **按类名覆盖**:需要为每个组件抄一遍选择器并保证优先级不低于原规则。这份抄件正是 DSH 改版时会失效的东西——和「把编译产物里的 hash 类名写死」是同一类错。
131
+ - **`!important` 全覆盖**:会连带压掉刻意保留的例外,例如终端光标的 `step-end`、点阵揭示的 `steps()`。
132
+
133
+ 这里改为遍历 `document.styleSheets` 与 `document.adoptedStyleSheets`,递归进入 `@media` / `@supports` / `@layer` / 嵌套规则,对每条真正声明了 `transition` / `animation` 的规则,**原地改写它自己的长写属性**:
134
+
135
+ - 优先级不变:改的是同一条规则,所以只在原规则生效的地方生效。
136
+ - 例外自动保留:`steps()`、`step-end` 这类离散步进不动;无限循环上的 `linear` 不动(旋转指示器被任何缓动曲线处理都会显出一顿一顿);`0s` 的可见性门控不动。
137
+ - 无需随 DSH 更新维护:新增或改名的组件自动被覆盖。
138
+ - 精确还原:写入前整块记录 `cssText`,关闭时原样写回;若期间被第三方改过则在日志中说明后仍然还原。
139
+
140
+ ### 关键帧感知
141
+
142
+ 动画该用哪条曲线,取决于它的 `@keyframes` 到底在动什么。扫描时先建立「关键帧名 → 它改动的属性集合」,再据此判定:同时含 `transform` 的是位移,纯 `opacity` 的是效果。查不到名字(定义在跨源样式表里)时按效果处理。
143
+
144
+ 无限循环的动画一律按效果处理:旋转、呼吸、骨架屏都是环境性的,不是一次性位移;给它套带过冲的位移曲线会让循环进度条每一轮都冲出轨道。
145
+
146
+ ### 时长
147
+
148
+ - 「时长倍率」按 1× / 1.25× / 1.5× / 2× 缩放**写死的字面时长**;引用 `var(--ds-transition-duration*)` 的时长不动它本身,改为把这些自定义属性按同一倍率缩放,效果一致且不用逐条改。
149
+ - 无限循环的时长不缩放——1 秒的旋转是设计选定的速度,不是待调的过渡。
150
+ - 「时长对齐到 Material 档位」把结果吸附到 50/100/150/200/250/300/350/400/450/500/550/600/700/800/900/1000ms。
151
+
152
+ ### 减弱动效
153
+
154
+ - `跟随系统`(默认):系统开启「减弱动态效果」时,全部时长压到 1ms、动画只跑一轮。
155
+ - `始终减弱动效`:无论系统设置,一律压制。
156
+
157
+ 这一条是整层里唯一使用 `!important` 的地方——它要压掉本层从未写过的声明,逐条改写做不到。压制期间不再改写曲线,因为没有可观察的时长。
158
+
159
+ ### 生效范围
160
+
161
+ - 覆盖页面上的全部样式表,包括其他插件注入的。首次应用时全量扫描,之后用 `MutationObserver` 监视 `head`,新注入的样式表在下一个微任务内被补上。
162
+ - 跨源样式表读不到 `cssRules`,会在 `~/.dsh/ui-experience.log` 记录一行并跳过——这类表的规则无法改写,静默跳过会让部分界面停留在原生曲线而无迹可循。
163
+ - 状态会写在 `<html data-dsh-uix-motion="m3|m3e">` 上,便于区分曲线来源。
164
+
110
165
  ## 边框宽度
111
166
 
112
167
  - 插件配置 `borderWidth` 默认为 `0.5px`,只在「Matugen 配色跟随」开启时生效;关闭开关后边框立即恢复原生宽度。
@@ -118,6 +173,10 @@ token 的值按原样写入 CSS 变量,因此 `color-mix(...)` 这类表达式
118
173
  - 走 `colors.json` 路径时 matugen 只提供一套暗色方案,浅色与深色模式叠加同一张配色表,界面的明暗切换只影响未被覆盖的元素;需要两套配色时改用 `dsh-tokens.json`。
119
174
  - 配色检测周期为 2 秒,文件变化最多延迟约 2 秒生效。
120
175
  - 关闭开关后启动底色叠加立即撤销(由作用域属性控制);注入的样式与脚本元素保留到下次刷新,但不产生视觉效果。
176
+ - M3 动效的「位移 / 效果」判定依据属性名与关键帧内容,不分析属性的实际取值变化;判定不出来的情况(例如只由 `all` 声明的过渡)按效果处理,宁可不给过冲。
177
+ - 跨源样式表里的规则无法改写,只会在日志中记录。
178
+ - 本层只改写曲线与时长,不新增、不改写关键帧本身;DSH 没定义的动效不会因此出现。
179
+ - 浏览器半在刷新页面时重载,宿主半只在进程重启时重载。两者版本不一致时(刚升级插件但没重启 DSH),M3 动效行会保持无操作并在控制台记录一行原因,配色功能不受影响。
121
180
 
122
181
  ## 样式基线
123
182
 
@@ -140,6 +199,8 @@ token 的值按原样写入 CSS 变量,因此 `color-mix(...)` 这类表达式
140
199
  | `.dsh-uix-save` | 配置表单的保存按钮 |
141
200
  | `.dsh-uix-reset` | `Button.module.css` `.outline` |
142
201
  | `.dsh-uix-switch` / `-thumb` | `Switch.module.css` `.switch` / `.thumb` |
202
+ | `.dsh-uix-segment` / `-indicator` / `-tab` | `SegmentedControl.module.css` `.control` / `.indicator` / `.tab` |
203
+ | `.dsh-uix-check` / `-label` / `-hint` | `Checkbox.module.css` `.checkbox` 及其原生输入 |
143
204
 
144
205
  所有颜色、圆角、焦点环都用 `--dsw-*` 主题 token,这些是跨版本稳定的公开契约;只有长度值(内距、尺寸、字号)可能随改版调整。升级 DSH 后,把上表的源文件与 `src/styles.ts` 对照一遍即可。
145
206
 
package/lib/client.js CHANGED
@@ -3,8 +3,411 @@
3
3
  // src/shared.ts
4
4
  var REMOTE_SERVICE = "uiExperience";
5
5
  var BOOT_ATTRIBUTE = "data-dsh-uix-matugen";
6
+ var MOTION_ATTRIBUTE = "data-dsh-uix-motion";
6
7
  var DEFAULT_COLORS_PATH = "~/.local/state/quickshell/user/generated/colors.json";
7
8
  var DEFAULT_TOKENS_PATH = "~/.local/state/quickshell/user/generated/dsh-tokens.json";
9
+ var MOTION_OFF = {
10
+ mode: "off",
11
+ durationScale: 1,
12
+ spatialSplit: true,
13
+ quantize: false,
14
+ reduced: "system"
15
+ };
16
+
17
+ // src/motion-plan.ts
18
+ var SPATIAL_PROPERTIES = /* @__PURE__ */ new Set([
19
+ "transform",
20
+ "translate",
21
+ "rotate",
22
+ "scale",
23
+ "offset-distance",
24
+ "offset-path",
25
+ "left",
26
+ "right",
27
+ "top",
28
+ "bottom",
29
+ "inset",
30
+ "inset-inline",
31
+ "inset-block",
32
+ "inset-inline-start",
33
+ "inset-inline-end",
34
+ "inset-block-start",
35
+ "inset-block-end",
36
+ "width",
37
+ "height",
38
+ "min-width",
39
+ "min-height",
40
+ "max-width",
41
+ "max-height",
42
+ "inline-size",
43
+ "block-size",
44
+ "min-inline-size",
45
+ "min-block-size",
46
+ "max-inline-size",
47
+ "max-block-size",
48
+ "flex-basis",
49
+ "margin",
50
+ "margin-top",
51
+ "margin-right",
52
+ "margin-bottom",
53
+ "margin-left",
54
+ "margin-inline",
55
+ "margin-block",
56
+ "margin-inline-start",
57
+ "margin-inline-end",
58
+ "padding",
59
+ "padding-top",
60
+ "padding-right",
61
+ "padding-bottom",
62
+ "padding-left",
63
+ "padding-inline",
64
+ "padding-block",
65
+ "padding-inline-start",
66
+ "padding-inline-end",
67
+ "gap",
68
+ "row-gap",
69
+ "column-gap",
70
+ "grid-template-columns",
71
+ "grid-template-rows",
72
+ "grid-auto-columns",
73
+ "grid-auto-rows",
74
+ "border-radius",
75
+ "border-top-left-radius",
76
+ "border-top-right-radius",
77
+ "border-bottom-left-radius",
78
+ "border-bottom-right-radius"
79
+ ]);
80
+ var CURVE_M3_SPATIAL = "cubic-bezier(0.2, 0, 0, 1)";
81
+ var CURVE_M3_EFFECTS = "cubic-bezier(0.2, 0, 0, 1)";
82
+ var CURVE_M3E_SPATIAL_FAST = "cubic-bezier(0.42, 1.67, 0.21, 0.9)";
83
+ var CURVE_M3E_SPATIAL = "cubic-bezier(0.38, 1.21, 0.22, 1)";
84
+ var CURVE_M3E_EFFECTS = "cubic-bezier(0.34, 0.8, 0.34, 1)";
85
+ var FAST_SPATIAL_MAX_MS = 200;
86
+ var MATERIAL_DURATIONS_MS = [
87
+ 50,
88
+ 100,
89
+ 150,
90
+ 200,
91
+ 250,
92
+ 300,
93
+ 350,
94
+ 400,
95
+ 450,
96
+ 500,
97
+ 550,
98
+ 600,
99
+ 700,
100
+ 800,
101
+ 900,
102
+ 1e3
103
+ ];
104
+ function splitList(value) {
105
+ if (value.trim() === "") return [];
106
+ const out = [];
107
+ let depth = 0;
108
+ let start = 0;
109
+ for (let index = 0; index < value.length; index += 1) {
110
+ const character = value[index];
111
+ if (character === "(") depth += 1;
112
+ else if (character === ")") depth = Math.max(0, depth - 1);
113
+ else if (character === "," && depth === 0) {
114
+ out.push(value.slice(start, index).trim());
115
+ start = index + 1;
116
+ }
117
+ }
118
+ out.push(value.slice(start).trim());
119
+ return out;
120
+ }
121
+ function entryAt(entries, index) {
122
+ if (entries.length === 0) return void 0;
123
+ return entries[index % entries.length];
124
+ }
125
+ var DURATION = /^([0-9]*\.?[0-9]+)(ms|s)$/;
126
+ function parseDurationMs(value) {
127
+ const match = DURATION.exec(value.trim());
128
+ if (match === null) return void 0;
129
+ const amount = Number(match[1]);
130
+ if (!Number.isFinite(amount) || amount < 0) return void 0;
131
+ return match[2] === "s" ? amount * 1e3 : amount;
132
+ }
133
+ function quantizeMs(ms) {
134
+ let best = MATERIAL_DURATIONS_MS[0] ?? 50;
135
+ let bestDistance = Number.POSITIVE_INFINITY;
136
+ for (const step of MATERIAL_DURATIONS_MS) {
137
+ const distance = Math.abs(step - ms);
138
+ if (distance < bestDistance) {
139
+ bestDistance = distance;
140
+ best = step;
141
+ }
142
+ }
143
+ return best;
144
+ }
145
+ var MAX_DURATION_MS = 1e4;
146
+ function retime(value, scale, quantize) {
147
+ const original = parseDurationMs(value);
148
+ if (original === void 0 || original === 0) return void 0;
149
+ const scaled = Math.round(original * scale);
150
+ const target = Math.min(MAX_DURATION_MS, Math.max(1, quantize ? quantizeMs(scaled) : scaled));
151
+ if (target === original) return void 0;
152
+ return `${target}ms`;
153
+ }
154
+ function isDiscrete(value) {
155
+ return /^(?:steps?\(|step-start$|step-end$)/.test(value.trim());
156
+ }
157
+ function classify(property) {
158
+ const name = property.trim().toLowerCase();
159
+ return SPATIAL_PROPERTIES.has(name) ? "spatial" : "effects";
160
+ }
161
+ function classifyAll(properties) {
162
+ for (const property of properties) {
163
+ if (classify(property) === "spatial") return "spatial";
164
+ }
165
+ return "effects";
166
+ }
167
+ function curveFor(mode, domain, spatialSplit, originalMs) {
168
+ if (mode === "m3") {
169
+ return domain === "spatial" ? CURVE_M3_SPATIAL : CURVE_M3_EFFECTS;
170
+ }
171
+ if (mode === "m3e" && spatialSplit && domain === "spatial") {
172
+ return originalMs !== void 0 && originalMs <= FAST_SPATIAL_MAX_MS ? CURVE_M3E_SPATIAL_FAST : CURVE_M3E_SPATIAL;
173
+ }
174
+ return CURVE_M3E_EFFECTS;
175
+ }
176
+
177
+ // src/motion.ts
178
+ var TRANSITIONS = {
179
+ selector: "transition-property",
180
+ duration: "transition-duration",
181
+ timing: "transition-timing-function"
182
+ };
183
+ var ANIMATIONS = {
184
+ selector: "animation-name",
185
+ duration: "animation-duration",
186
+ timing: "animation-timing-function",
187
+ iterations: "animation-iteration-count"
188
+ };
189
+ var DURATION_TOKENS = [
190
+ ["--ds-transition-duration", 200],
191
+ ["--ds-transition-duration-fast", 100],
192
+ ["--ds-transition-duration-slow", 300]
193
+ ];
194
+ function createMotion() {
195
+ const patches = /* @__PURE__ */ new Map();
196
+ const unreadable = /* @__PURE__ */ new Set();
197
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
198
+ let settings;
199
+ let reducedSheet;
200
+ let reducedActive = false;
201
+ let observer;
202
+ let mediaListener;
203
+ let refreshPending = false;
204
+ function walk(rules, visit) {
205
+ for (let index = 0; index < rules.length; index += 1) {
206
+ const rule = rules[index];
207
+ if (rule === void 0) continue;
208
+ visit(rule);
209
+ if (rule.cssRules !== void 0) walk(rule.cssRules, visit);
210
+ }
211
+ }
212
+ function readableSheets() {
213
+ const out = [];
214
+ const collect = (sheet) => {
215
+ if (sheet === void 0) return;
216
+ try {
217
+ const rules = sheet.cssRules;
218
+ if (rules === void 0) return;
219
+ out.push(sheet);
220
+ } catch (error) {
221
+ const key = sheet.href ?? "(inline)";
222
+ if (unreadable.has(key)) return;
223
+ unreadable.add(key);
224
+ console.error(`ui-experience: motion cannot read stylesheet ${key}: ${error.message}`);
225
+ }
226
+ };
227
+ for (let index = 0; index < document.styleSheets.length; index += 1) {
228
+ collect(document.styleSheets[index]);
229
+ }
230
+ for (const sheet of document.adoptedStyleSheets ?? []) collect(sheet);
231
+ return out;
232
+ }
233
+ function keyframeProperties(sheets) {
234
+ const frames = /* @__PURE__ */ new Map();
235
+ for (const sheet of sheets) {
236
+ walk(sheet.cssRules, (rule) => {
237
+ if (rule.name === void 0 || rule.cssRules === void 0) return;
238
+ const properties = frames.get(rule.name) ?? [];
239
+ walk(rule.cssRules, (frame) => {
240
+ const declaration = frame.style;
241
+ if (declaration === void 0) return;
242
+ for (let index = 0; index < declaration.length; index += 1) {
243
+ const name = declaration.item(index);
244
+ if (name !== "" && !properties.includes(name)) properties.push(name);
245
+ }
246
+ });
247
+ frames.set(rule.name, properties);
248
+ });
249
+ }
250
+ return frames;
251
+ }
252
+ function rewriteFamily(declaration, family, frames, active) {
253
+ const selectors = splitList(declaration.getPropertyValue(family.selector));
254
+ const durations = splitList(declaration.getPropertyValue(family.duration));
255
+ const timings = splitList(declaration.getPropertyValue(family.timing));
256
+ const iterations = family.iterations === void 0 ? [] : splitList(declaration.getPropertyValue(family.iterations));
257
+ if (selectors.length === 0 && durations.length === 0 && timings.length === 0) return false;
258
+ const count = Math.max(selectors.length, durations.length, timings.length, 1);
259
+ const nextTimings = [];
260
+ const nextDurations = [];
261
+ let timingChanged = false;
262
+ let durationChanged = false;
263
+ for (let index = 0; index < count; index += 1) {
264
+ const selector = entryAt(selectors, index) ?? "all";
265
+ const originalTiming = entryAt(timings, index);
266
+ const originalDuration = entryAt(durations, index);
267
+ const originalMs = originalDuration === void 0 ? void 0 : parseDurationMs(originalDuration);
268
+ const infinite = entryAt(iterations, index) === "infinite";
269
+ if (selector === "none") {
270
+ if (originalTiming !== void 0) nextTimings.push(originalTiming);
271
+ if (originalDuration !== void 0) nextDurations.push(originalDuration);
272
+ continue;
273
+ }
274
+ const keepTiming = originalTiming !== void 0 && (isDiscrete(originalTiming) || infinite && originalTiming === "linear" || originalMs === 0);
275
+ const domain = infinite ? "effects" : family.selector === "transition-property" ? classify(selector) : classifyAll(frames.get(selector) ?? ["opacity"]);
276
+ const curve = keepTiming && originalTiming !== void 0 ? originalTiming : curveFor(active.mode, domain, active.spatialSplit, originalMs);
277
+ nextTimings.push(curve);
278
+ if (curve !== originalTiming) timingChanged = true;
279
+ if (originalDuration === void 0) continue;
280
+ const retimed = infinite ? void 0 : retime(originalDuration, active.durationScale, active.quantize);
281
+ nextDurations.push(retimed ?? originalDuration);
282
+ if (retimed !== void 0) durationChanged = true;
283
+ }
284
+ let changed = false;
285
+ if (timingChanged) {
286
+ declaration.setProperty(family.timing, nextTimings.join(", "));
287
+ changed = true;
288
+ }
289
+ if (durationChanged && nextDurations.length === count) {
290
+ declaration.setProperty(family.duration, nextDurations.join(", "));
291
+ changed = true;
292
+ }
293
+ return changed;
294
+ }
295
+ function scan() {
296
+ const active = settings;
297
+ if (active === void 0 || active.mode === "off") return;
298
+ const sheets = readableSheets();
299
+ const frames = keyframeProperties(sheets);
300
+ for (const sheet of sheets) {
301
+ walk(sheet.cssRules, (rule) => {
302
+ const declaration = rule.style;
303
+ if (declaration === void 0 || patches.has(rule)) return;
304
+ const before = declaration.cssText;
305
+ const touched = rewriteFamily(declaration, TRANSITIONS, frames, active) || rewriteFamily(declaration, ANIMATIONS, frames, active);
306
+ if (!touched) return;
307
+ patches.set(rule, { declaration, before, after: declaration.cssText });
308
+ });
309
+ }
310
+ }
311
+ function applyDurationTokens() {
312
+ const active = settings;
313
+ if (active === void 0 || active.durationScale === 1) return;
314
+ for (const [token, base] of DURATION_TOKENS) {
315
+ document.documentElement.style.setProperty(token, `${Math.round(base * active.durationScale)}ms`);
316
+ }
317
+ }
318
+ function clearDurationTokens() {
319
+ for (const [token] of DURATION_TOKENS) document.documentElement.style.removeProperty(token);
320
+ }
321
+ function wantsReduced() {
322
+ const active = settings;
323
+ if (active === void 0) return false;
324
+ if (active.reduced === "always") return true;
325
+ return active.reduced === "system" && media.matches;
326
+ }
327
+ function syncReduced() {
328
+ const want = wantsReduced();
329
+ if (want === reducedActive) return;
330
+ reducedActive = want;
331
+ reducedSheet?.remove();
332
+ reducedSheet = void 0;
333
+ if (!want) return;
334
+ const sheet = document.createElement("style");
335
+ sheet.textContent = "*,*::before,*::after{transition-duration:1ms !important;animation-duration:1ms !important;animation-iteration-count:1 !important;scroll-behavior:auto !important}";
336
+ document.head.appendChild(sheet);
337
+ reducedSheet = sheet;
338
+ }
339
+ function refresh() {
340
+ if (refreshPending) return;
341
+ refreshPending = true;
342
+ queueMicrotask(() => {
343
+ refreshPending = false;
344
+ const active = settings;
345
+ if (active === void 0 || active.mode === "off" || reducedActive) return;
346
+ scan();
347
+ });
348
+ }
349
+ function observeDocument() {
350
+ const instance = new MutationObserver(() => {
351
+ refresh();
352
+ });
353
+ instance.observe(document.head, { childList: true, subtree: true });
354
+ return { disconnect: () => {
355
+ instance.disconnect();
356
+ } };
357
+ }
358
+ function unwatchPreference() {
359
+ if (mediaListener === void 0) return;
360
+ media.removeEventListener?.("change", mediaListener);
361
+ mediaListener = void 0;
362
+ }
363
+ function watchPreference() {
364
+ if (mediaListener !== void 0) return;
365
+ mediaListener = () => {
366
+ syncReduced();
367
+ };
368
+ media.addEventListener?.("change", mediaListener);
369
+ }
370
+ function restore() {
371
+ for (const patch of patches.values()) {
372
+ if (patch.declaration.cssText !== patch.after) {
373
+ console.error("ui-experience: motion restore overwrote a declaration that changed after this layer wrote it");
374
+ }
375
+ patch.declaration.cssText = patch.before;
376
+ }
377
+ patches.clear();
378
+ clearDurationTokens();
379
+ reducedSheet?.remove();
380
+ reducedSheet = void 0;
381
+ reducedActive = false;
382
+ unwatchPreference();
383
+ document.documentElement.removeAttribute(MOTION_ATTRIBUTE);
384
+ }
385
+ return {
386
+ apply(next) {
387
+ restore();
388
+ settings = next;
389
+ if (next.mode === "off") return;
390
+ watchPreference();
391
+ syncReduced();
392
+ if (reducedActive) {
393
+ document.documentElement.setAttribute(MOTION_ATTRIBUTE, next.mode);
394
+ return;
395
+ }
396
+ applyDurationTokens();
397
+ scan();
398
+ observer ??= observeDocument();
399
+ document.documentElement.setAttribute(MOTION_ATTRIBUTE, next.mode);
400
+ },
401
+ refresh,
402
+ restore,
403
+ dispose() {
404
+ restore();
405
+ observer?.disconnect();
406
+ observer = void 0;
407
+ settings = void 0;
408
+ }
409
+ };
410
+ }
8
411
 
9
412
  // src/styles.ts
10
413
  var ROW_CSS = `
@@ -75,9 +478,52 @@
75
478
  .dsh-uix-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease}
76
479
  .dsh-uix-switch[aria-checked=false] .dsh-uix-switch-thumb{background:var(--dsw-alias-switch-thumb)}
77
480
  .dsh-uix-switch[aria-checked=true] .dsh-uix-switch-thumb{transform:translate(16px)}
481
+
482
+ /* A labelled control line: the caption column and the control that follows it,
483
+ mirroring the settings form's label/control pairing. */
484
+ .dsh-uix-choice{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
485
+ .dsh-uix-choice-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
486
+
487
+ /* Segmented control: SegmentedControl.module.css .control / .indicator / .tab \u2014
488
+ a hover-wash track holding one sliding pill. The indicator is placed
489
+ arithmetically from the segment count and index carried as custom properties,
490
+ so nothing measures the DOM. */
491
+ .dsh-uix-segment{position:relative;display:inline-grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:2px;padding:4px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover)}
492
+ .dsh-uix-segment-indicator{position:absolute;top:4px;left:4px;width:calc((100% - 8px - 2px * (var(--dsh-segment-count) - 1)) / var(--dsh-segment-count));height:calc(100% - 8px);border:0;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);transform:translateX(calc(var(--dsh-segment-index) * (100% + 2px)));transition:transform 160ms ease;pointer-events:none}
493
+ .dsh-uix-segment-tab{box-sizing:border-box;position:relative;z-index:1;height:28px;padding:0 16px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;font-weight:500;white-space:nowrap;cursor:pointer;transition:color 120ms ease}
494
+ .dsh-uix-segment-tab[aria-selected=true]{color:var(--dsw-alias-label-primary)}
495
+ .dsh-uix-segment-tab:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
496
+ .dsh-uix-segment-tab:disabled{cursor:default;opacity:.4}
497
+ .dsh-uix-segment-tab:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
498
+
499
+ /* Checkbox: Checkbox.module.css \u2014 a native input so the platform draws and keys
500
+ it, tinted with the brand accent, with the explanation stacked underneath. */
501
+ .dsh-uix-check{display:flex;align-items:flex-start;gap:6px;padding:10px 0;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary);cursor:pointer}
502
+ .dsh-uix-check+.dsh-uix-check{border-top:0.5px solid var(--dsw-alias-border-l2)}
503
+ .dsh-uix-check input{flex:0 0 auto;width:16px;height:16px;margin:2px 0 0;accent-color:var(--dsw-alias-brand-primary);cursor:inherit}
504
+ .dsh-uix-check input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
505
+ .dsh-uix-check:has(input:disabled){cursor:default;opacity:.5}
506
+ .dsh-uix-check-text{display:flex;flex-direction:column;gap:2px;min-width:0}
507
+ .dsh-uix-check-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
508
+ .dsh-uix-check-hint{font-size:12px;line-height:1.5;font-weight:400;color:var(--dsw-alias-label-tertiary)}
78
509
  `;
79
510
 
80
511
  // src/client.ts
512
+ var REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
513
+ function queryReduced() {
514
+ return window.matchMedia(REDUCED_QUERY).matches;
515
+ }
516
+ function watchReduced(report) {
517
+ const media = window.matchMedia(REDUCED_QUERY);
518
+ const listener = () => {
519
+ report(media.matches);
520
+ };
521
+ report(media.matches);
522
+ media.addEventListener?.("change", listener);
523
+ return () => {
524
+ media.removeEventListener?.("change", listener);
525
+ };
526
+ }
81
527
  var SOURCE = "@dsh-plugin/dsh-ui-experience";
82
528
  var POLL_MS = 2e3;
83
529
  var IDLE_TICKS = 15;
@@ -108,7 +554,14 @@
108
554
  descriptors: [
109
555
  descriptor("poll", () => ({ known: zod.string().optional() }), "PollArgs"),
110
556
  descriptor("setActive", () => ({ active: zod.boolean().optional() }), "SetActiveArgs"),
111
- descriptor("setPaths", () => ({ colorsPath: zod.string().optional(), tokensPath: zod.string().optional() }), "SetPathsArgs")
557
+ descriptor("setPaths", () => ({ colorsPath: zod.string().optional(), tokensPath: zod.string().optional() }), "SetPathsArgs"),
558
+ descriptor("setMotion", () => ({
559
+ mode: zod.string().optional(),
560
+ durationScale: zod.number().optional(),
561
+ spatialSplit: zod.boolean().optional(),
562
+ quantize: zod.boolean().optional(),
563
+ reduced: zod.string().optional()
564
+ }), "SetMotionArgs")
112
565
  ]
113
566
  };
114
567
  }
@@ -124,7 +577,24 @@
124
577
  "palette.tokens-path": "DSH \u4EE4\u724C\u6587\u4EF6\uFF08\u5B58\u5728\u65F6\u4F18\u5148\uFF09",
125
578
  "palette.save": "\u4FDD\u5B58",
126
579
  "palette.reset": "\u6062\u590D\u9ED8\u8BA4",
127
- "palette.paths-hint": "\u652F\u6301\u4EE5 ~ \u5F00\u5934\u7684\u8DEF\u5F84\uFF0C\u6216\u7EDD\u5BF9\u8DEF\u5F84\u3002\u989C\u8272\u6587\u4EF6\u63D0\u4F9B\u5B8C\u6574\u6620\u5C04\uFF0C\u4EE4\u724C\u6587\u4EF6\u91CC\u5217\u51FA\u7684 token \u9010\u9879\u8986\u76D6\u3002"
580
+ "palette.paths-hint": "\u652F\u6301\u4EE5 ~ \u5F00\u5934\u7684\u8DEF\u5F84\uFF0C\u6216\u7EDD\u5BF9\u8DEF\u5F84\u3002\u989C\u8272\u6587\u4EF6\u63D0\u4F9B\u5B8C\u6574\u6620\u5C04\uFF0C\u4EE4\u724C\u6587\u4EF6\u91CC\u5217\u51FA\u7684 token \u9010\u9879\u8986\u76D6\u3002",
581
+ "motion.title": "M3 \u52A8\u6548",
582
+ "motion.description": "\u6309 Material 3 \u66F2\u7EBF\u6539\u5199\u6574\u4E2A\u754C\u9762\u7684\u8FC7\u6E21\u4E0E\u5173\u952E\u5E27\u52A8\u753B\uFF0C\u539F\u5730\u6539\u5199\u5165\u5DF2\u751F\u6548\u7684\u6837\u5F0F\u89C4\u5219",
583
+ "motion.mode": "\u66F2\u7EBF\u65CF",
584
+ "motion.mode-off": "\u5173\u95ED",
585
+ "motion.mode-m3": "M3",
586
+ "motion.mode-m3e": "M3E",
587
+ "motion.duration": "\u65F6\u957F\u500D\u7387",
588
+ "motion.options": "\u9AD8\u7EA7\u9009\u9879",
589
+ "motion.spatial-split": "\u4F4D\u79FB\u4E0E\u6548\u679C\u4F7F\u7528\u4E0D\u540C\u66F2\u7EBF",
590
+ "motion.spatial-split-hint": "M3E \u7684\u4F4D\u79FB\u66F2\u7EBF\u5E26\u6709\u7EA6 9% \u7684\u8FC7\u51B2\uFF0C\u6548\u679C\u66F2\u7EBF\u6CA1\u6709\u3002\u5173\u95ED\u540E\u5168\u90E8\u4F7F\u7528\u6548\u679C\u66F2\u7EBF\u3002",
591
+ "motion.quantize": "\u65F6\u957F\u5BF9\u9F50\u5230 Material \u6863\u4F4D",
592
+ "motion.quantize-hint": "\u628A\u6539\u5199\u540E\u7684\u65F6\u957F\u5438\u9644\u5230 50/100/150/200/250/300/350/400/450/500/550/600/700/800/900/1000ms\u3002",
593
+ "motion.reduced": "\u59CB\u7EC8\u51CF\u5F31\u52A8\u6548",
594
+ "motion.reduced-hint": "\u5F00\u542F\u540E\u5FFD\u7565\u7CFB\u7EDF\u7684\u300C\u51CF\u5F31\u52A8\u6001\u6548\u679C\u300D\u504F\u597D\uFF0C\u4E00\u5F8B\u628A\u65F6\u957F\u538B\u5230 1ms\u3002\u5173\u95ED\u65F6\u8DDF\u968F\u7CFB\u7EDF\u3002",
595
+ "motion.system-reduced": "\u7CFB\u7EDF\u5DF2\u8BF7\u6C42\u51CF\u5F31\u52A8\u6548\uFF0C\u672C\u9875\u52A8\u753B\u6B63\u5728\u88AB\u538B\u5236\u3002",
596
+ "motion.save-failed": "\u52A8\u6548\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25",
597
+ "motion.inert": "\u66F2\u7EBF\u65CF\u4E3A\u300C\u5173\u95ED\u300D\uFF0C\u4EE5\u4E0B\u9009\u9879\u4E0D\u4F1A\u751F\u6548\u3002"
128
598
  },
129
599
  en: {
130
600
  "palette.title": "Matugen palette sync",
@@ -137,7 +607,24 @@
137
607
  "palette.tokens-path": "DSH token file (preferred when present)",
138
608
  "palette.save": "Save",
139
609
  "palette.reset": "Restore defaults",
140
- "palette.paths-hint": "Paths may start with ~, or be absolute. The colors file supplies the full mapping; tokens listed in the token file override it item by item."
610
+ "palette.paths-hint": "Paths may start with ~, or be absolute. The colors file supplies the full mapping; tokens listed in the token file override it item by item.",
611
+ "motion.title": "M3 motion",
612
+ "motion.description": "Rewrite every transition and keyframe animation in the interface onto Material 3 curves, in place on the rules already in the cascade",
613
+ "motion.mode": "Curve family",
614
+ "motion.mode-off": "Off",
615
+ "motion.mode-m3": "M3",
616
+ "motion.mode-m3e": "M3E",
617
+ "motion.duration": "Duration scale",
618
+ "motion.options": "Advanced options",
619
+ "motion.spatial-split": "Spatial and effects take different curves",
620
+ "motion.spatial-split-hint": "The M3E spatial curve overshoots by about 9%, the effects curve does not. Turning this off uses the effects curve everywhere.",
621
+ "motion.quantize": "Snap durations to the Material ladder",
622
+ "motion.quantize-hint": "Pull rewritten durations onto 50/100/150/200/250/300/350/400/450/500/550/600/700/800/900/1000 ms.",
623
+ "motion.reduced": "Always reduce motion",
624
+ "motion.reduced-hint": "Ignore the system reduce-motion preference and force every duration to 1 ms. Off follows the system.",
625
+ "motion.system-reduced": "The system asked for reduced motion; this page is being held still.",
626
+ "motion.save-failed": "Failed to save the motion settings",
627
+ "motion.inert": "The curve family is Off, so these options have no effect."
141
628
  }
142
629
  };
143
630
  function registerClient() {
@@ -166,6 +653,7 @@
166
653
  let idleTick = 0;
167
654
  let lastRpcProblem = "";
168
655
  let lastPaletteProblem = "";
656
+ let motionUnsupported = false;
169
657
  let paintedWidth = "";
170
658
  let disposeOverride;
171
659
  let borderSheet;
@@ -173,11 +661,13 @@
173
661
  active: false,
174
662
  borderWidth: "0.5px",
175
663
  paths: { colors: DEFAULT_COLORS_PATH, tokens: DEFAULT_TOKENS_PATH },
664
+ motion: { ...MOTION_OFF },
176
665
  problem: "",
177
666
  notice: "",
178
667
  saving: false
179
668
  };
180
669
  const listeners = /* @__PURE__ */ new Set();
670
+ const motion = createMotion();
181
671
  const publish = (patch) => {
182
672
  view = { ...view, ...patch };
183
673
  for (const listener of listeners) listener();
@@ -190,6 +680,11 @@
190
680
  }
191
681
  return out;
192
682
  };
683
+ const applyMotion = (next) => {
684
+ if (disposed) return;
685
+ motion.apply(next);
686
+ };
687
+ const motionDiffers = (left, right) => left.mode !== right.mode || left.durationScale !== right.durationScale || left.spatialSplit !== right.spatialSplit || left.quantize !== right.quantize || left.reduced !== right.reduced;
193
688
  const clearOverride = () => {
194
689
  disposeOverride?.();
195
690
  disposeOverride = void 0;
@@ -240,13 +735,23 @@
240
735
  return envelope.value;
241
736
  };
242
737
  const adopt = (answer) => {
738
+ const motionAnswer = answer.motion ?? MOTION_OFF;
739
+ if (answer.motion === void 0 && !motionUnsupported) {
740
+ motionUnsupported = true;
741
+ console.error("ui-experience: this host build predates the motion settings; the M3 motion row stays inert until DSH restarts");
742
+ }
243
743
  if (answer.paths.colors !== view.paths.colors || answer.paths.tokens !== view.paths.tokens || answer.borderWidth !== view.borderWidth) {
244
744
  publish({ paths: answer.paths, borderWidth: answer.borderWidth });
245
745
  }
746
+ if (motionDiffers(view.motion, motionAnswer)) {
747
+ publish({ motion: motionAnswer });
748
+ if (bootstrapped) applyMotion(motionAnswer);
749
+ }
246
750
  if (!bootstrapped) {
247
751
  bootstrapped = true;
248
752
  setBootAttribute(answer.active);
249
753
  publish({ active: answer.active });
754
+ applyMotion(motionAnswer);
250
755
  } else if (!writing && view.active !== answer.active) {
251
756
  publish({ active: answer.active });
252
757
  if (answer.active) {
@@ -277,7 +782,7 @@
277
782
  };
278
783
  const poll = async () => {
279
784
  if (disposed || inflight) return;
280
- if (bootstrapped && !view.active) {
785
+ if (bootstrapped && !view.active && view.motion.mode === "off") {
281
786
  idleTick = (idleTick + 1) % IDLE_TICKS;
282
787
  if (idleTick !== 0) return;
283
788
  } else {
@@ -301,6 +806,24 @@
301
806
  lastRpcProblem = "";
302
807
  adopt(answer);
303
808
  };
809
+ const saveMotion = async (change) => {
810
+ if (disposed || writing) return;
811
+ writing = true;
812
+ publish({ saving: true, problem: "", notice: "" });
813
+ try {
814
+ const applied = unwrap(await gateway.setMotion(change));
815
+ if (disposed) return;
816
+ publish({ motion: applied, notice: t("palette.saved") });
817
+ applyMotion(applied);
818
+ } catch (error) {
819
+ const message = error.message;
820
+ console.error(`ui-experience: failed to save the motion settings: ${message}`);
821
+ publish({ problem: `${t("motion.save-failed")}: ${message}` });
822
+ } finally {
823
+ writing = false;
824
+ publish({ saving: false });
825
+ }
826
+ };
304
827
  const setActive = async (next) => {
305
828
  if (disposed || writing) return;
306
829
  writing = true;
@@ -363,6 +886,97 @@
363
886
  publish({ saving: false });
364
887
  }
365
888
  };
889
+ const disclosure = (open, toggle, label) => h(
890
+ "button",
891
+ {
892
+ type: "button",
893
+ className: "dsh-uix-disclosure",
894
+ "aria-expanded": open,
895
+ onClick: toggle
896
+ },
897
+ h(
898
+ "span",
899
+ { className: "dsh-uix-disclosure-icon" },
900
+ h("svg", {
901
+ viewBox: "0 0 16 16",
902
+ width: 14,
903
+ height: 14,
904
+ fill: "none",
905
+ stroke: "currentColor",
906
+ strokeWidth: 1,
907
+ "aria-hidden": true
908
+ }, h("path", { d: "M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6" }))
909
+ ),
910
+ h("span", null, label)
911
+ );
912
+ const WALK_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"];
913
+ const segmented = (id, value, segments, select, label, disabled) => {
914
+ const selected = Math.max(0, segments.findIndex((segment) => segment.value === value));
915
+ const walk = (key) => {
916
+ if (segments.length === 0) return;
917
+ const current = segments[selected];
918
+ if (current === void 0) return;
919
+ let next;
920
+ if (key === "Home") next = 0;
921
+ else if (key === "End") next = segments.length - 1;
922
+ else {
923
+ const step = key === "ArrowRight" || key === "ArrowDown" ? 1 : -1;
924
+ next = ((selected + step) % segments.length + segments.length) % segments.length;
925
+ }
926
+ const target = segments[next];
927
+ if (target !== void 0 && target.value !== value) select(target.value);
928
+ };
929
+ return h(
930
+ "div",
931
+ {
932
+ role: "tablist",
933
+ "aria-label": label,
934
+ className: "dsh-uix-segment",
935
+ style: {
936
+ "--dsh-segment-count": String(segments.length),
937
+ "--dsh-segment-index": String(selected)
938
+ }
939
+ },
940
+ h("span", { "aria-hidden": true, className: "dsh-uix-segment-indicator" }),
941
+ ...segments.map((segment) => h("button", {
942
+ key: segment.value,
943
+ id: `${id}-${segment.value}`,
944
+ type: "button",
945
+ role: "tab",
946
+ "aria-selected": segment.value === value,
947
+ tabIndex: segment.value === value ? 0 : -1,
948
+ disabled,
949
+ className: "dsh-uix-segment-tab",
950
+ onClick: () => {
951
+ if (segment.value !== value) select(segment.value);
952
+ },
953
+ onKeyDown: (event) => {
954
+ if (!WALK_KEYS.includes(event.key)) return;
955
+ event.preventDefault();
956
+ walk(event.key);
957
+ }
958
+ }, segment.label))
959
+ );
960
+ };
961
+ const checkbox = (id, checked, set, label, hint, disabled) => h(
962
+ "label",
963
+ { className: "dsh-uix-check", htmlFor: id },
964
+ h("input", {
965
+ id,
966
+ type: "checkbox",
967
+ checked,
968
+ disabled,
969
+ onChange: (event) => {
970
+ set(event.target.checked);
971
+ }
972
+ }),
973
+ h(
974
+ "span",
975
+ { className: "dsh-uix-check-text" },
976
+ h("span", { className: "dsh-uix-check-label" }, label),
977
+ hint === void 0 ? null : h("span", { className: "dsh-uix-check-hint" }, hint)
978
+ )
979
+ );
366
980
  function PaletteRow(props) {
367
981
  const tr = props.t ?? t;
368
982
  const [state, setState] = React.useState(view);
@@ -461,32 +1075,125 @@
461
1075
  }, h("span", { className: "dsh-uix-switch-thumb", "aria-hidden": true }))
462
1076
  ),
463
1077
  status,
464
- h(
465
- "button",
466
- {
467
- type: "button",
468
- className: "dsh-uix-disclosure",
469
- "aria-expanded": open,
470
- onClick: () => {
471
- setOpen(!open);
472
- }
1078
+ disclosure(open, () => {
1079
+ setOpen(!open);
1080
+ }, tr("palette.paths")),
1081
+ editor
1082
+ );
1083
+ }
1084
+ function MotionRow(props) {
1085
+ const tr = props.t ?? t;
1086
+ const [state, setState] = React.useState(view);
1087
+ const [open, setOpen] = React.useState(false);
1088
+ const [systemReduced, setSystemReduced] = React.useState(queryReduced());
1089
+ React.useEffect(() => {
1090
+ const listener = () => {
1091
+ setState(view);
1092
+ };
1093
+ listeners.add(listener);
1094
+ return () => {
1095
+ listeners.delete(listener);
1096
+ };
1097
+ }, []);
1098
+ React.useEffect(() => watchReduced(setSystemReduced), []);
1099
+ const current = state.motion;
1100
+ const locked = state.saving;
1101
+ const status = state.problem !== "" ? h("p", { className: "dsh-uix-problem", role: "alert" }, state.problem) : state.notice !== "" ? h("p", { className: "dsh-uix-notice" }, state.notice) : null;
1102
+ const modes = [
1103
+ { value: "off", label: tr("motion.mode-off") },
1104
+ { value: "m3", label: tr("motion.mode-m3") },
1105
+ { value: "m3e", label: tr("motion.mode-m3e") }
1106
+ ];
1107
+ const scales = [
1108
+ { value: "1", label: "1\xD7" },
1109
+ { value: "1.25", label: "1.25\xD7" },
1110
+ { value: "1.5", label: "1.5\xD7" },
1111
+ { value: "2", label: "2\xD7" }
1112
+ ];
1113
+ const policy = open ? h(
1114
+ "div",
1115
+ { className: "dsh-uix-form" },
1116
+ checkbox(
1117
+ "dsh-uix-motion-split",
1118
+ current.spatialSplit,
1119
+ (next) => {
1120
+ void saveMotion({ spatialSplit: next });
1121
+ },
1122
+ tr("motion.spatial-split"),
1123
+ tr("motion.spatial-split-hint"),
1124
+ locked
1125
+ ),
1126
+ checkbox(
1127
+ "dsh-uix-motion-quantize",
1128
+ current.quantize,
1129
+ (next) => {
1130
+ void saveMotion({ quantize: next });
1131
+ },
1132
+ tr("motion.quantize"),
1133
+ tr("motion.quantize-hint"),
1134
+ locked
1135
+ ),
1136
+ checkbox(
1137
+ "dsh-uix-motion-reduced",
1138
+ current.reduced === "always",
1139
+ (next) => {
1140
+ void saveMotion({ reduced: next ? "always" : "system" });
473
1141
  },
1142
+ tr("motion.reduced"),
1143
+ tr("motion.reduced-hint"),
1144
+ locked
1145
+ )
1146
+ ) : null;
1147
+ return h(
1148
+ "div",
1149
+ { className: "dsh-uix-row" },
1150
+ h(
1151
+ "div",
1152
+ { className: "dsh-uix-head" },
474
1153
  h(
475
- "span",
476
- { className: "dsh-uix-disclosure-icon" },
477
- h("svg", {
478
- viewBox: "0 0 16 16",
479
- width: 14,
480
- height: 14,
481
- fill: "none",
482
- stroke: "currentColor",
483
- strokeWidth: 1,
484
- "aria-hidden": true
485
- }, h("path", { d: "M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6" }))
486
- ),
487
- h("span", null, tr("palette.paths"))
1154
+ "div",
1155
+ { className: "dsh-uix-text" },
1156
+ h("div", { className: "dsh-uix-title" }, tr("motion.title")),
1157
+ h("div", { className: "dsh-uix-desc" }, tr("motion.description"))
1158
+ )
488
1159
  ),
489
- editor
1160
+ status,
1161
+ h(
1162
+ "div",
1163
+ { className: "dsh-uix-choice" },
1164
+ h("span", { className: "dsh-uix-choice-label" }, tr("motion.mode")),
1165
+ segmented(
1166
+ "dsh-uix-motion-mode",
1167
+ current.mode,
1168
+ modes,
1169
+ (next) => {
1170
+ void saveMotion({ mode: next });
1171
+ },
1172
+ tr("motion.mode"),
1173
+ locked
1174
+ )
1175
+ ),
1176
+ current.mode === "off" ? null : h(
1177
+ "div",
1178
+ { className: "dsh-uix-choice" },
1179
+ h("span", { className: "dsh-uix-choice-label" }, tr("motion.duration")),
1180
+ segmented(
1181
+ "dsh-uix-motion-scale",
1182
+ String(current.durationScale),
1183
+ scales,
1184
+ (next) => {
1185
+ void saveMotion({ durationScale: Number(next) });
1186
+ },
1187
+ tr("motion.duration"),
1188
+ locked
1189
+ )
1190
+ ),
1191
+ current.mode !== "off" && systemReduced && current.reduced === "system" ? h("p", { className: "dsh-uix-hint" }, tr("motion.system-reduced")) : null,
1192
+ current.mode === "off" ? h("p", { className: "dsh-uix-hint" }, tr("motion.inert")) : null,
1193
+ disclosure(open, () => {
1194
+ setOpen(!open);
1195
+ }, tr("motion.options")),
1196
+ policy
490
1197
  );
491
1198
  }
492
1199
  ctx.effect(() => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
@@ -495,6 +1202,12 @@
495
1202
  order: 10.5,
496
1203
  locale: "ui-experience"
497
1204
  }, PaletteRow)), "ui-experience: settings row");
1205
+ ctx.effect(() => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
1206
+ name: "settings.general.item",
1207
+ id: "ui-experience.motion",
1208
+ order: 10.6,
1209
+ locale: "ui-experience"
1210
+ }, MotionRow)), "ui-experience: motion row");
498
1211
  ctx.effect(() => ctx.timer.interval(() => {
499
1212
  void poll();
500
1213
  }, POLL_MS), "ui-experience: palette polling");
@@ -502,8 +1215,9 @@
502
1215
  return () => {
503
1216
  disposed = true;
504
1217
  teardown();
1218
+ motion.dispose();
505
1219
  };
506
- }, "ui-experience: palette override");
1220
+ }, "ui-experience: page-level layers");
507
1221
  void poll();
508
1222
  }
509
1223
  return {
package/lib/index.js CHANGED
@@ -28,11 +28,18 @@ function logLine(message) {
28
28
  }
29
29
  var BORDER_WIDTH = /^(?:0|[0-9]+(?:\.[0-9]+)?(?:px|rem|em))$/;
30
30
  var PATH_PATTERN = /^(?:~$|~\/|\/)/;
31
+ var MOTION_MODE = /^(?:off|m3|m3e)$/;
32
+ var MOTION_REDUCED = /^(?:system|always)$/;
31
33
  var Config = z.object({
32
34
  active: z.boolean().default(false).volatile(),
33
35
  borderWidth: z.string().pattern(BORDER_WIDTH).default("0.5px").volatile(),
34
36
  colorsPath: z.string().pattern(PATH_PATTERN).default(DEFAULT_COLORS_PATH).volatile(),
35
- tokensPath: z.string().pattern(PATH_PATTERN).default(DEFAULT_TOKENS_PATH).volatile()
37
+ tokensPath: z.string().pattern(PATH_PATTERN).default(DEFAULT_TOKENS_PATH).volatile(),
38
+ motion: z.string().pattern(MOTION_MODE).default("off").volatile(),
39
+ motionDurationScale: z.number().min(0.5).max(2).default(1).volatile(),
40
+ motionSpatialSplit: z.boolean().default(true).volatile(),
41
+ motionQuantize: z.boolean().default(false).volatile(),
42
+ motionReduced: z.string().pattern(MOTION_REDUCED).default("system").volatile()
36
43
  });
37
44
  var REQUIRED_COLOR_KEYS = [
38
45
  "surface_container_lowest",
@@ -306,6 +313,15 @@ function resolvePath(raw, label) {
306
313
  function effectivePaths(config) {
307
314
  return { colors: config.colorsPath.get(), tokens: config.tokensPath.get() };
308
315
  }
316
+ function effectiveMotion(config) {
317
+ return {
318
+ mode: config.motion.get(),
319
+ durationScale: config.motionDurationScale.get(),
320
+ spatialSplit: config.motionSpatialSplit.get(),
321
+ quantize: config.motionQuantize.get(),
322
+ reduced: config.motionReduced.get()
323
+ };
324
+ }
309
325
  var paletteCache;
310
326
  var PALETTE_MAX_BYTES = 4 * 1024 * 1024;
311
327
  var readFileText = (path) => readFileAsync(path, "utf8");
@@ -466,7 +482,8 @@ var UiExperienceService = class extends TypertRemoteService {
466
482
  editor;
467
483
  /**
468
484
  * Remote poll endpoint: report the palette when it changed since the caller's
469
- * known version, and always report the live switch, border width, and paths.
485
+ * known version, and always report the live switch, border width, paths, and
486
+ * motion settings.
470
487
  * @param args - the caller's last seen version.
471
488
  * @returns the poll payload.
472
489
  */
@@ -474,12 +491,22 @@ var UiExperienceService = class extends TypertRemoteService {
474
491
  const read = await readPalette(this.config);
475
492
  const active = this.config.active.get();
476
493
  const borderWidth = this.config.borderWidth.get();
494
+ const motion = effectiveMotion(this.config);
477
495
  if (!read.ok) {
478
- return { status: read.status, message: read.message, active, borderWidth, paths: read.paths };
496
+ return { status: read.status, message: read.message, active, borderWidth, paths: read.paths, motion };
479
497
  }
480
498
  const { palette, paths } = read;
481
499
  if (args?.known === palette.version) {
482
- return { status: "ok", changed: false, version: palette.version, source: palette.source, active, borderWidth, paths };
500
+ return {
501
+ status: "ok",
502
+ changed: false,
503
+ version: palette.version,
504
+ source: palette.source,
505
+ active,
506
+ borderWidth,
507
+ paths,
508
+ motion
509
+ };
483
510
  }
484
511
  return {
485
512
  status: "ok",
@@ -489,7 +516,8 @@ var UiExperienceService = class extends TypertRemoteService {
489
516
  source: palette.source,
490
517
  active,
491
518
  borderWidth,
492
- paths
519
+ paths,
520
+ motion
493
521
  };
494
522
  }
495
523
  /**
@@ -529,6 +557,43 @@ var UiExperienceService = class extends TypertRemoteService {
529
557
  logLine(`setPaths: colors=${paths.colors} tokens=${paths.tokens}`);
530
558
  return { paths };
531
559
  }
560
+ /**
561
+ * Remote endpoint persisting the motion layer settings. Every field is
562
+ * optional, and an omitted field keeps its stored value so one control can be
563
+ * changed without resending the rest.
564
+ * @param args - the fields to write.
565
+ * @returns the settings now in effect.
566
+ */
567
+ async setMotion(args) {
568
+ const mode = args?.mode;
569
+ const reduced = args?.reduced;
570
+ const scale = args?.durationScale;
571
+ const spatialSplit = args?.spatialSplit;
572
+ const quantize = args?.quantize;
573
+ if (mode === void 0 && reduced === void 0 && scale === void 0 && spatialSplit === void 0 && quantize === void 0) {
574
+ throw new Error("ui-experience: setMotion requires at least one field");
575
+ }
576
+ if (mode !== void 0 && !MOTION_MODE.test(mode)) {
577
+ throw new Error(`ui-experience: unknown motion mode ${JSON.stringify(mode)}`);
578
+ }
579
+ if (reduced !== void 0 && !MOTION_REDUCED.test(reduced)) {
580
+ throw new Error(`ui-experience: unknown reduced-motion policy ${JSON.stringify(reduced)}`);
581
+ }
582
+ if (scale !== void 0 && (!Number.isFinite(scale) || scale < 0.5 || scale > 2)) {
583
+ throw new Error(`ui-experience: durationScale ${String(scale)} is outside 0.5\u20132`);
584
+ }
585
+ await this.persist((current) => ({
586
+ ...current,
587
+ ...mode === void 0 ? {} : { motion: mode },
588
+ ...reduced === void 0 ? {} : { motionReduced: reduced },
589
+ ...scale === void 0 ? {} : { motionDurationScale: scale },
590
+ ...spatialSplit === void 0 ? {} : { motionSpatialSplit: spatialSplit },
591
+ ...quantize === void 0 ? {} : { motionQuantize: quantize }
592
+ }));
593
+ const applied = effectiveMotion(this.config);
594
+ logLine(`setMotion: mode=${applied.mode} durationScale=${String(applied.durationScale)} spatialSplit=${String(applied.spatialSplit)} quantize=${String(applied.quantize)} reduced=${applied.reduced}`);
595
+ return applied;
596
+ }
532
597
  /**
533
598
  * Write one configuration change through the config editor, logging the
534
599
  * failure before it reaches the caller.
@@ -554,13 +619,14 @@ Object.defineProperty(UiExperienceService.prototype, REMOTE_METHOD_DESCRIPTOR, {
554
619
  methods: Object.freeze([
555
620
  Object.freeze({ method: "poll", invocation: Object.freeze({ kind: "direct" }) }),
556
621
  Object.freeze({ method: "setActive", invocation: Object.freeze({ kind: "direct" }) }),
557
- Object.freeze({ method: "setPaths", invocation: Object.freeze({ kind: "direct" }) })
622
+ Object.freeze({ method: "setPaths", invocation: Object.freeze({ kind: "direct" }) }),
623
+ Object.freeze({ method: "setMotion", invocation: Object.freeze({ kind: "direct" }) })
558
624
  ])
559
625
  })
560
626
  });
561
627
  var inject = ["configEditor"];
562
628
  function apply(ctx, config) {
563
- logLine(`apply: active=${String(config.active.get())} borderWidth=${config.borderWidth.get()} colorsPath=${config.colorsPath.get()} tokensPath=${config.tokensPath.get()}`);
629
+ logLine(`apply: active=${String(config.active.get())} borderWidth=${config.borderWidth.get()} colorsPath=${config.colorsPath.get()} tokensPath=${config.tokensPath.get()} motion=${config.motion.get()} durationScale=${String(config.motionDurationScale.get())}`);
564
630
  try {
565
631
  new UiExperienceService(ctx, config, ctx.fiber.entry, ctx.configEditor);
566
632
  } catch (error) {
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
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."
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. A separate M3 motion row rewrites every transition and keyframe animation in the interface onto Material 3 / Material 3 Expressive curves, in place, with its own curve family, duration scale, duration snapping, and reduced-motion policy; the rewrite reaches styles injected by other plugins and restores everything exactly when switched off."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "界面体验",
4
- "description": "让 DSH 界面跟随系统 matugen 生成的 Material You 配色:在「设置 → 通用设置 → Matugen 配色跟随」开启后,界面配色作为叠加层套在当前主题上,并随配色文件重新生成自动更新。可在同一行展开「配色文件路径」,改写 matugen 颜色文件或 DSH 令牌文件的路径(默认 ~/.local/state/quickshell/user/generated/ 下,令牌文件优先)。配色只在本地读取,不上传。关闭开关后回到原生配色。另统一界面边框宽度(配置项 borderWidth,默认 0.5px)。该覆盖仅在开关开启时生效,且会同时压窄正文引用块等装饰性边框。"
4
+ "description": "让 DSH 界面跟随系统 matugen 生成的 Material You 配色:在「设置 → 通用设置 → Matugen 配色跟随」开启后,界面配色作为叠加层套在当前主题上,并随配色文件重新生成自动更新。可在同一行展开「配色文件路径」,改写 matugen 颜色文件或 DSH 令牌文件的路径(默认 ~/.local/state/quickshell/user/generated/ 下,令牌文件优先)。配色只在本地读取,不上传。关闭开关后回到原生配色。另统一界面边框宽度(配置项 borderWidth,默认 0.5px)。该覆盖仅在开关开启时生效,且会同时压窄正文引用块等装饰性边框。还有一行「M3 动效」,按 Material 3 / Material 3 Expressive 曲线原地改写整个界面的过渡与关键帧动画,可调曲线族、时长倍率、时长对齐与减弱动效策略;改写覆盖其他插件注入的样式,关闭后原样还原。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@dsh-plugin/dsh-ui-experience",
3
- "version": "1.0.0-dev.43485427852",
3
+ "version": "1.0.0-dev.43486958683",
4
4
  "type": "module",
5
- "description": "UI experience enhancements for DeepSeek Harness: follow the system matugen Material You palette as the interface theme.",
5
+ "description": "UI experience enhancements for DeepSeek Harness: follow the system matugen Material You palette as the interface theme, and rewrite interface motion onto Material 3 / Material 3 Expressive curves.",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",