dsh-mini-remote 0.1.3 → 0.1.5

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/lib/page.html CHANGED
@@ -3,29 +3,90 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <meta name="theme-color" content="#0f1115">
6
+ <meta name="theme-color" content="#0a0f16">
7
7
  <meta name="mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-capable" content="yes">
9
9
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
- <title>DSH mini remote</title>
10
+ <title>极简遥控器</title>
11
11
  <style>
12
+ /* ---------- 设计令牌 ----------
13
+ 规格来自用户 2026-09-27 定的六关键词翻译表,改之前先读这一段:
14
+ · 圆角只有两档:卡片一律 2px,状态点用 999px/50%。**没有中间档**。
15
+ · 阴影只有两档,且都极浅;层次主要靠 --line 这条 1px 细线和间距建立。
16
+ · 强调色分两个角色,加起来**一屏最多两处**:
17
+ --gold 是**当字/点**用的(刻度上"当前这一步");--act 是**当底**用的(主按钮)。
18
+ 深色下两个都是暖金 #c9a55c;浅色下 --gold 压深成 #8a6529(白底才读得清),
19
+ --act 换成宣传图的宝蓝 #3b6fc4——用户 2026-09-27 看过对照图后定的:浅色那套
20
+ 底色本来就是从宣传图取的,冷底上再压一块饱和暖金,比在深色下更像外来物。
21
+ 需要"显眼一点"的地方一律用鲸鱼娘的冰蓝 --ice,别动这两个。
22
+ · --ok/--run/--err 是状态点的语义色,占的是 8px 一个小点,不算强调色。
23
+ · 配色以鲸鱼娘为准心:深海墨蓝打底,她的冰蓝作次级色。
24
+ · 两套主题:这套(默认,深色)和 [data-theme="light"],色调跟宣传图
25
+ (docs/promo.png)统一。**颜色令牌两边必须一一对应**——缺一个,就有元素
26
+ 在某个主题下"隐身",而且只在那个主题下才看得出来。守卫在 test/ui.test.mjs。 */
12
27
  :root {
13
- --bg: #0f1115;
14
- --panel: #171a21;
15
- --panel-2: #1e222b;
16
- --line: #2a2f3a;
17
- --fg: #e8eaed;
18
- --muted: #8b93a1;
19
- --accent: #4c8dff;
20
- --ok: #35c46b;
21
- --warn: #e0a33a;
22
- --err: #e5604d;
23
- --radius: 14px;
28
+ color-scheme: dark;
29
+ --bg: #0a0f16;
30
+ --bg-top: #101a27;
31
+ --bg-deep: #070b11;
32
+ --panel: #121a25;
33
+ --panel-2: #18222f;
34
+ --line: #22303f;
35
+ --fg: #e3ebf4;
36
+ --muted: #7b8a9b;
37
+ --dim: #6c7a8b;
38
+ --ice: #a9c8e8;
39
+ --gold: #c9a55c; /* 强调色当**字/点**用:刻度上"当前这一步" */
40
+ --act: #c9a55c; /* 强调色当**底**用:一屏一个的主按钮 */
41
+ --ok: #4fb477;
42
+ --run: #a9c8e8;
43
+ --err: #d4614f;
44
+ --r: 2px;
45
+ --sh-1: 0 1px 2px rgba(0, 0, 0, .28);
46
+ --sh-2: 0 2px 6px rgba(0, 0, 0, .34);
47
+ --glass: rgba(18, 26, 37, .72);
48
+ --tint: rgba(24, 34, 47, .5); /* 队列那块比面板再深一点的底 */
49
+ --pop: rgba(24, 34, 47, .97); /* 浮起来的小面板(指令菜单等) */
50
+ --scrim: rgba(0, 0, 0, .55); /* 弹出层背后压暗的那层;浅色主题里它得轻得多 */
51
+ --on-act: #10161f; /* 实心主按钮上的字:金底配白字读不清 */
52
+ }
53
+ /* 浅色主题:色调跟宣传图(docs/promo.png)统一——近白的冰蓝底、深宝蓝的字与状态色、
54
+ 白色卡片、极浅的蓝投影。深色那边的规矩一条不改:圆角两档、层次靠 1px 细线。
55
+ 强调色在浅色下分了家:当字/点用的金压深到 #8a6529,当底用的主按钮换成宣传图的宝蓝
56
+ (#3b6fc4 配白字)——用户 2026-09-27 看过对照图后定的。
57
+ 切换入口在设置页「外观」;选择存在 localStorage,首屏之前就定好,不会闪一下。 */
58
+ [data-theme="light"] {
59
+ color-scheme: light;
60
+ --bg: #eaf3fc;
61
+ --bg-top: #f7fbff;
62
+ --bg-deep: #dbe9f8;
63
+ --panel: #ffffff;
64
+ --panel-2: #f2f8ff;
65
+ --line: #d3e2f3;
66
+ --fg: #1b2c49;
67
+ --muted: #6b7f9b;
68
+ --dim: #8496ae;
69
+ --ice: #3b6fc4;
70
+ --gold: #8a6529; /* 白底上当字用,得压深才读得清 */
71
+ --act: #3b6fc4; /* 主按钮底:用宣传图那个宝蓝(用户 2026-09-27 裁决) */
72
+ --ok: #2e9a5f;
73
+ --run: #3b6fc4;
74
+ --err: #c0392b;
75
+ --sh-1: 0 1px 2px rgba(28, 60, 110, .07);
76
+ --sh-2: 0 2px 8px rgba(28, 60, 110, .12);
77
+ --glass: rgba(255, 255, 255, .74);
78
+ --tint: rgba(59, 111, 196, .07);
79
+ --pop: rgba(255, 255, 255, .98);
80
+ --scrim: rgba(23, 52, 95, .30);
81
+ --on-act: #ffffff; /* 宝蓝底上的字 */
24
82
  }
25
83
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
26
84
  html, body {
27
85
  margin: 0; height: 100%; overflow: hidden;
28
- background: var(--bg); color: var(--fg);
86
+ /* 纵深:顶部略亮、底部更深,做出"空间"而不是一整块平色。
87
+ 设计稿参考的是沉浸式暗色流媒体那一套——界面浮在深色基底上,不是被切成几块。 */
88
+ background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg) 55%, var(--bg-deep) 100%);
89
+ color: var(--fg);
29
90
  font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
30
91
  "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
31
92
  overscroll-behavior: none;
@@ -33,24 +94,32 @@
33
94
  body { display: flex; flex-direction: column; }
34
95
 
35
96
  /* ---------- 顶栏 ---------- */
97
+ /* 顶栏和底栏是两块**悬浮毛玻璃**:内容从它们底下淡出,界面像浮着,
98
+ 而不是上下两条实心色带把屏幕切成三块。 */
36
99
  header {
37
100
  flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
38
101
  padding: calc(env(safe-area-inset-top) + 10px) 14px 10px;
39
- background: var(--panel); border-bottom: 1px solid var(--line);
102
+ background: var(--glass); border-bottom: 1px solid var(--line);
103
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
104
+ backdrop-filter: blur(14px) saturate(1.1);
40
105
  }
41
106
  .brand { font-weight: 600; letter-spacing: .3px; }
42
107
  .dot {
43
108
  width: 8px; height: 8px; border-radius: 50%;
44
- background: var(--muted); flex: 0 0 auto; transition: background .2s;
109
+ background: var(--muted); flex: 0 0 auto;
110
+ /* 状态变化是**瞬时**的:数据在变,界面不表演。
111
+ 这里原来挂着一圈 1.2s 的呼吸闪动,2026-09-27 按规格删掉了——
112
+ "正在思考"的脉动圆点是那几年的视觉套话;而"它在动"这件事,下面那块正在执行区
113
+ 里她说的那句话和秒数已经说得更准,也更安静。160ms 是本页唯一保留的过渡节奏。 */
114
+ transition: background .16s;
45
115
  }
46
116
  .dot.ok { background: var(--ok); }
47
- .dot.run { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
117
+ .dot.run { background: var(--run); }
48
118
  .dot.err { background: var(--err); }
49
- @keyframes pulse { 50% { opacity: .25; } }
50
119
  .status { color: var(--muted); font-size: 13px; flex: 1 1 auto;
51
120
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
52
121
  .icon-btn {
53
- flex: 0 0 auto; width: 36px; height: 36px; border-radius: 10px;
122
+ flex: 0 0 auto; width: 36px; height: 36px; border-radius: var(--r);
54
123
  border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
55
124
  font-size: 16px; display: grid; place-items: center; cursor: pointer;
56
125
  }
@@ -64,11 +133,11 @@
64
133
  white-space: pre-wrap; word-break: break-word;
65
134
  font-size: 16px; line-height: 1.75;
66
135
  }
67
- #reply a { color: var(--accent); }
136
+ #reply a { color: var(--ice); }
68
137
  .placeholder { color: var(--muted); font-size: 15px; text-align: center; line-height: 1.9; }
69
138
  .placeholder kbd {
70
139
  background: var(--panel-2); border: 1px solid var(--line);
71
- border-radius: 6px; padding: 1px 6px; font-size: 13px; font-family: inherit;
140
+ border-radius: var(--r); padding: 1px 6px; font-size: 13px; font-family: inherit;
72
141
  }
73
142
 
74
143
  /* ---------- 聊天模式 ---------- */
@@ -79,7 +148,7 @@
79
148
  两种气泡都要,只改一边的话,短回复那边还是会露出大方块,看着像坏了。 */
80
149
  .bubble {
81
150
  width: fit-content;
82
- max-width: 86%; padding: 10px 13px; border-radius: var(--radius);
151
+ max-width: 86%; padding: 10px 13px; border-radius: var(--r);
83
152
  /* 18px,和 .said 的下边距一致。原来是 10px,用户 2026-09-22 实机提「指令气泡和
84
153
  回答正文第一行之间贴得太紧」——10px 是「同一条消息内部」的距离,
85
154
  而这里跨的是「我说的话」和「它答的话」,是更大的一个断点,该给更多。 */
@@ -88,12 +157,12 @@
88
157
  }
89
158
  .bubble.user { margin-left: auto; background: var(--panel-2); border: 1px solid var(--line); }
90
159
  .bubble.assistant { margin-right: auto; background: var(--panel); border: 1px solid var(--line); }
91
- .bubble a { color: var(--accent); }
160
+ .bubble a { color: var(--ice); }
92
161
  .bubble .meta { display: block; margin-top: 6px; font-size: 11px; color: var(--muted); }
93
162
  /* 「已停止」标签:被按停的那一轮,手机上收到的只是**已经吐出来的那半句**。
94
163
  不标出来的话,它看着就像模型答崩了。 */
95
164
  .stopped {
96
- display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px;
165
+ display: inline-block; padding: 1px 7px; border-radius: var(--r); font-size: 11px;
97
166
  color: var(--muted); border: 1px solid var(--line); vertical-align: middle;
98
167
  }
99
168
  .reply-bar .stopped { margin-right: auto; }
@@ -101,6 +170,12 @@
101
170
  /* 外层是 pre-wrap(用户消息和旧内容靠它),markdown 输出的是真块级元素,
102
171
  所以要在这里把 pre-wrap 收掉,否则段落之间会多出一堆空行。 */
103
172
  .md { white-space: normal; }
173
+ /* 回复里的图。地址有两种写法,见下面 mdInline() 里那段注释。
174
+ 宽度只封顶不写死:窄屏上占满可用宽度、按比例缩,圆角沿用全站那 2 像素。 */
175
+ .md-img {
176
+ display: block; max-width: 100%; height: auto; margin: 10px 0;
177
+ border-radius: var(--r); border: 1px solid var(--line); background: var(--panel-2);
178
+ }
104
179
  .md > :first-child { margin-top: 0; }
105
180
  .md > :last-child { margin-bottom: 0; }
106
181
  .md p { margin: 0 0 .7em; }
@@ -113,7 +188,7 @@
113
188
  .md h4, .md h5, .md h6 { font-size: 1em; }
114
189
  .md ul, .md ol { margin: 0 0 .7em; padding-left: 1.4em; }
115
190
  .md li { margin: .18em 0; }
116
- .md a { color: var(--accent); word-break: break-all; }
191
+ .md a { color: var(--ice); word-break: break-all; }
117
192
  .md strong { font-weight: 650; }
118
193
  .md hr { border: none; border-top: 1px solid var(--line); margin: 1em 0; }
119
194
  .md blockquote {
@@ -121,12 +196,12 @@
121
196
  border-left: 3px solid var(--line); color: var(--muted);
122
197
  }
123
198
  .md code {
124
- background: var(--panel-2); border: 1px solid var(--line); border-radius: 5px;
199
+ background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r);
125
200
  padding: .1em .35em; font-size: .88em;
126
201
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
127
202
  }
128
203
  .md .code {
129
- margin: 0 0 .7em; border: 1px solid var(--line); border-radius: 10px;
204
+ margin: 0 0 .7em; border: 1px solid var(--line); border-radius: var(--r);
130
205
  background: var(--panel-2); overflow: hidden;
131
206
  }
132
207
  .md .code-head {
@@ -150,7 +225,7 @@
150
225
  三个地方共用:单帧模式的整篇、聊天模式的气泡、代码块。 */
151
226
  .copy {
152
227
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
153
- width: 28px; height: 28px; padding: 0; border-radius: 8px; cursor: pointer;
228
+ width: 28px; height: 28px; padding: 0; border-radius: var(--r); cursor: pointer;
154
229
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
155
230
  }
156
231
  .copy svg {
@@ -158,7 +233,7 @@
158
233
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
159
234
  }
160
235
  .copy:active { transform: scale(.95); }
161
- .copy.done { color: var(--accent); border-color: var(--accent); }
236
+ .copy.done { color: var(--ice); border-color: var(--ice); }
162
237
  .bubble .meta .copy { margin-left: 8px; vertical-align: middle; }
163
238
  /* 上面那条只放「已停止」标签——它是给正文打的预防针,得在正文之前出现。
164
239
  复制按钮挪到正文下面去了(.reply-foot)。 */
@@ -177,7 +252,7 @@
177
252
  display: flex; align-items: baseline; gap: 8px;
178
253
  width: fit-content; max-width: 100%;
179
254
  margin: 0 0 14px; padding: 4px 10px 4px 9px;
180
- border-left: 2px solid var(--line); border-radius: 0 8px 8px 0;
255
+ border-left: 2px solid var(--line); border-radius: 0 var(--r) var(--r) 0;
181
256
  background: var(--panel-2); font-size: 13px; line-height: 1.5;
182
257
  }
183
258
  /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。 */
@@ -202,7 +277,7 @@
202
277
  .live .md > *:last-child::after {
203
278
  content: ''; display: inline-block; width: 2px; height: 1em;
204
279
  margin-left: 2px; vertical-align: -2px;
205
- background: var(--accent, #2563eb);
280
+ background: var(--ice);
206
281
  animation: caret 1s steps(2, start) infinite;
207
282
  }
208
283
  @keyframes caret { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
@@ -221,10 +296,15 @@
221
296
  那个 171 = 10 个全角字 × 14.5px,加左右内边距 24、边框 2。
222
297
  用 max-content 而不是写死 width:将来真加了更长的词条,这行会自己撑开——
223
298
  宁可立绘挪一点,也不能让字顶出屏幕。
224
- min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。 */
299
+ min() 是窄屏退路:屏幕比 299 还窄时,这行跟着屏幕收,别溢出。
300
+ **max-width 那一行是把上面那句话真正落实**(2026-09-27 补):光有
301
+ `max-content` + `min-width` 只有下限没有上限,而气泡的 `max-width:
302
+ calc(100% - 128px)` 里的那个 100% 又是拿这一行自己算的——两头都靠对方,
303
+ 谁也没被真正管住。结果是一句长话(尤其我那几句英文思考)把整行撑到屏幕外,
304
+ 手机上得横着划才能看见(用户截图报的就是这个)。钉死在这一行的宽度上才算数。 */
225
305
  .work-top {
226
- display: flex; align-items: center; gap: 6px;
227
- width: max-content; min-width: min(299px, 100%);
306
+ display: flex; align-items: flex-start; gap: 6px;
307
+ width: max-content; min-width: min(299px, 100%); max-width: 100%;
228
308
  margin: 0 auto;
229
309
  }
230
310
 
@@ -234,10 +314,9 @@
234
314
  .work-stage {
235
315
  position: relative; flex: 0 0 auto;
236
316
  width: 122px; height: 106px;
237
- /* 整块再轻轻浮动一下。它的节奏和翻帧错开,两下叠起来才像活的。 */
238
- animation: workBob 2.4s ease-in-out infinite;
317
+ /* 这里原来还叠着一层 2.4s 的上下浮动(`workBob`),2026-09-27 删掉:
318
+ 她的翻帧本身就是"活着"的证据,再叠一层漂浮就变成界面在表演了。 */
239
319
  }
240
- @keyframes workBob { 50% { transform: translateY(-3px); } }
241
320
  .pose {
242
321
  position: absolute; inset: 0;
243
322
  background-repeat: no-repeat;
@@ -262,37 +341,72 @@
262
341
  它推不动立绘——立绘的位置由上面 .work-top 的固定行宽管着,跟气泡宽窄无关。
263
342
  所以「词条每 3.6 秒换一次」这件事不会让立绘左右晃。
264
343
  max-width 是窄屏退路:立绘 122 + 间隙 6,剩下的都给气泡,宁可折行也别顶出屏幕。
265
- 抬到 flex-start 是让尖角对着她的头,而不是对着身子中间。 */
344
+
345
+ **竖着不限行数**(2026-09-27 用户报的:一句长话被切掉半行)。原来是
346
+ `-webkit-line-clamp: 3` + `overflow: hidden`——第四行连省略号都没有地截断,
347
+ 看着像她话说了一半。现在让气泡自己长高,整页跟着让位(main 本来就能上下滚)。
348
+ 留一道 `max-height` + 内部滚动只为兜病态长文(几屏):正常一两句话够不到它。
349
+ `align-self: flex-start` 让气泡顶到行首、和她的头对齐。 */
266
350
  .work-bubble {
267
351
  position: relative; flex: 0 1 auto;
268
352
  max-width: calc(100% - 128px);
269
353
  align-self: flex-start; margin-top: 6px;
270
354
  background: var(--panel-2); border: 1px solid var(--line);
271
- border-radius: 12px; padding: 9px 12px;
355
+ border-radius: var(--r); padding: 9px 12px;
272
356
  font-size: 14.5px; line-height: 1.5; color: var(--fg);
273
- }
357
+ max-height: 50vh; overflow-y: auto;
358
+ /* 长英文串、URL 不认换行,会把气泡顶宽。中文本来就能任意断行,这条是给它们兜的。 */
359
+ overflow-wrap: anywhere;
360
+ }
361
+ /* hidden 只是 UA 样式表里的一条 display:none,元素自己没写 display 时它才管用——
362
+ 原来这里有个 `display: -webkit-box` 把它盖掉了,于是「词条是空串就把气泡收起来」
363
+ 根本没生效,跑动那张旁边一直挂着一个没字的空药丸(2026-09-27 用户截图报的)。 */
364
+ .work-bubble[hidden] { display: none; }
365
+ /* 尖角固定在气泡**上部**(对准她的头),不跟着气泡高度跑。原来钉在 50%,气泡一长高,
366
+ 尖角就滑到她身子下边去了——长句和短句都得指着头,所以要一个绝对值。 */
274
367
  .work-bubble::before, .work-bubble::after {
275
- content: ''; position: absolute; top: 50%; width: 0; height: 0;
368
+ content: ''; position: absolute; top: 24px; width: 0; height: 0;
276
369
  border: 6px solid transparent; margin-top: -6px;
277
370
  }
278
371
  .work-bubble::before { left: -12px; border-right-color: var(--line); }
279
372
  .work-bubble::after { left: -11px; border-right-color: var(--panel-2); }
373
+ /* 气泡里的话走和回答区同一套行内渲染(`mdInline`):`**加粗**`、`` `代码` ``、链接会变成样式。
374
+ 代码的底色特意不用 --panel-2——那是气泡自己的底色,套上去等于看不见。 */
375
+ .work-bubble strong { font-weight: 650; }
376
+ .work-bubble a { color: var(--ice); word-break: break-all; }
377
+ .work-bubble code {
378
+ background: rgba(127, 127, 127, .18); border: 1px solid var(--line); border-radius: var(--r);
379
+ padding: .1em .35em; font-size: .88em;
380
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
381
+ }
280
382
 
281
- /* 进度条故意做成**不确定进度**的流动条,不是百分比。
383
+ /* 进度条是**不确定进度**,不是百分比。
282
384
  插件按设计只收「指令 + 最终回复」,拿不到 Agent 此刻跑到哪一步,
283
- 所以画一个填到 80% 的假进度条等于撒谎。它只表达「在跑」,不表达「跑到哪了」。 */
385
+ 所以画一个填到 80% 的假进度条等于撒谎。它只表达「在跑」,不表达「跑到哪了」。
386
+
387
+ 这一段前后改过三次,方向来回,写清楚免得再兜圈子:
388
+ ① 最初是一条来回扫的流光(`workSweep`),按 UI 规格删掉——流光属于"看起来在动"
389
+ 的廉价套话;
390
+ ② 删完剩一根**定在 38% 的静态标记**。结果更糟:用户实机一看就问「底部的进度条
391
+ 好像不会动」,而一根停在三成八的线,比一个会动的它**更**像在谎报百分比;
392
+ ③ 现在:宽度写死 38% 永不改变,动的是**位置**——十六格一轮、每格 160ms,
393
+ 也就是全页那一个节奏。推进式(steps)而不是匀速流淌。
394
+ 它仍然不知道跑了多少;它只是不再假装停住。 */
284
395
  .work-progress {
285
- margin-top: 10px; height: 4px; border-radius: 2px;
286
- background: var(--panel-2); overflow: hidden;
396
+ margin-top: 10px; height: 1px;
397
+ background: var(--line); overflow: hidden;
287
398
  }
288
399
  .work-progress i {
289
- display: block; width: 38%; height: 100%; border-radius: 2px;
290
- background: linear-gradient(90deg, transparent, var(--accent) 45%, var(--accent) 55%, transparent);
291
- animation: workSweep 1.5s ease-in-out infinite;
400
+ display: block; width: 38%; height: 100%;
401
+ background: var(--ice);
402
+ animation: barStep 2.56s steps(16, end) infinite;
292
403
  }
293
- @keyframes workSweep {
294
- from { transform: translateX(-105%); }
295
- to { transform: translateX(265%); }
404
+ /* 从左外侧进来、从右外侧出去。38% 宽的块想走完一整条轨道,位移得是自身宽度的
405
+ 263%(100 ÷ 38 ≈ 2.63)。用 steps 而不是匀速:一格一格地推进,
406
+ 跟上面那把刻度尺是同一种秩序感。 */
407
+ @keyframes barStep {
408
+ from { transform: translateX(-100%); }
409
+ to { transform: translateX(263%); }
296
410
  }
297
411
  /* 秒数在左、停止按钮在右。秒数原来是自己一行右对齐,现在跟按钮同一行。 */
298
412
  .work-foot {
@@ -306,7 +420,7 @@
306
420
  按下去之后自己禁用并改文案——cancel() 只是递请求,真正停下要等状态翻,
307
421
  这中间必须让用户看见「已经按到了」,否则会连按好几下。 */
308
422
  .stop {
309
- padding: 5px 14px; border-radius: 999px; font-size: 13px;
423
+ padding: 5px 14px; border-radius: var(--r); font-size: 13px;
310
424
  border: 1px solid var(--line); background: transparent; color: var(--fg);
311
425
  transition: opacity .15s;
312
426
  }
@@ -315,11 +429,44 @@
315
429
  /* 排队中的指令。跑着任务时接着发的那几条排在这儿,等这一轮跑完再跑。
316
430
  单帧模式下你发出去的那句话在别处一个字都看不见(那一模式只显示回复),
317
431
  所以这块是唯一的反馈,不能省。 */
432
+ /* ---------- 工序刻度尺 ----------
433
+ **它代表的是"队列",不是 Agent 的工序。** 插件按设计只收「指令 + 最终回复」,
434
+ 根本看不到 Agent 此刻跑到哪一步(见上面进度条那一段)。所以刻度上每个点都对应
435
+ 一条真实存在的指令:第一个点是**正在跑的那条**(暖金),后面是排着队的(空心)。
436
+ 设计稿里画的是一条理想中的工序进度尺;落到真实实现,只有队列是插件真知道的事,
437
+ 就按队列画——界面上不出现编出来的数据。 */
318
438
  .queue {
319
- margin-top: 12px; padding: 8px 10px; border-radius: 10px;
320
- background: var(--panel-2); border: 1px solid var(--line);
321
- }
322
- .queue .q-head { font-size: 11.5px; color: var(--muted); margin-bottom: 6px; }
439
+ margin-top: 12px; padding: 12px;
440
+ border: 1px solid var(--line); border-radius: var(--r);
441
+ background: var(--tint);
442
+ }
443
+ .queue .q-head { font-size: 11.5px; color: var(--muted); margin-bottom: 10px; }
444
+ .queue .q-scale {
445
+ position: relative; display: flex; align-items: center; height: 24px; margin: 2px 0 10px;
446
+ }
447
+ /* 刻度之间的那条细线:垫在点底下,两端各留 3px,不顶到边。 */
448
+ .queue .q-scale::before {
449
+ content: ''; position: absolute; left: 3px; right: 3px; top: 6px;
450
+ height: 1px; background: var(--line);
451
+ }
452
+ .queue .q-node {
453
+ position: relative; flex: 1 1 0; display: flex; flex-direction: column;
454
+ align-items: center; gap: 5px;
455
+ }
456
+ .queue .q-node i {
457
+ display: block; width: 7px; height: 7px; border-radius: 50%;
458
+ box-sizing: content-box; border: 1px solid var(--line); background: var(--bg);
459
+ /* 全页唯一允许的动效:节点由"待"变"过"时 160ms 颜色过渡。 */
460
+ transition: background .16s ease, border-color .16s ease;
461
+ }
462
+ /* 刻度上"当前这一步"是**当字用**的那个强调色(--gold):浅色主题底下它要压深,
463
+ 所以这里是 --gold 而不是 --act——--act 是"当底用"的,浅色下已经换成宝蓝了。 */
464
+ .queue .q-node.cur i { background: var(--gold); border-color: var(--gold); }
465
+ .queue .q-node b {
466
+ font-weight: 400; font-size: 10.5px; line-height: 1;
467
+ color: var(--muted); font-variant-numeric: tabular-nums;
468
+ }
469
+ .queue .q-node.cur b { color: var(--gold); }
323
470
  .queue .q-row {
324
471
  display: flex; align-items: center; gap: 8px; padding: 3px 0;
325
472
  font-size: 13.5px; line-height: 1.45;
@@ -332,41 +479,51 @@
332
479
  }
333
480
  .queue .q-drop {
334
481
  flex: 0 0 auto; width: 24px; height: 24px; padding: 0; line-height: 1;
335
- border-radius: 999px; font-size: 12px;
482
+ border-radius: var(--r); font-size: 12px;
336
483
  border: 1px solid var(--line); background: transparent; color: var(--muted);
337
484
  }
485
+ .queue .q-drop svg {
486
+ width: 12px; height: 12px; fill: none; stroke: currentColor;
487
+ stroke-width: 1.6; stroke-linecap: round;
488
+ }
338
489
 
339
490
  /* 系统里设了「减少动态效果」的人:不浮动、不翻帧、不轮播、进度条常亮。
340
491
  秒数照常走——那是信息,不是装饰。 */
341
492
  @media (prefers-reduced-motion: reduce) {
342
493
  .work-stage { animation: none; }
343
494
  .pose { animation: none; transition: none; }
344
- .work-progress i { animation: none; width: 100%; opacity: .45; }
495
+ .work-progress i { width: 100%; opacity: .45; animation: none; }
496
+ .queue .q-node i { transition: none; }
497
+ .dot { transition: none; }
345
498
  }
346
499
 
347
500
  /* ---------- 底栏 ---------- */
348
501
  footer {
349
502
  flex: 0 0 auto; display: flex; align-items: flex-end; gap: 8px;
350
503
  padding: 10px 12px calc(env(safe-area-inset-bottom) + 10px);
351
- background: var(--panel); border-top: 1px solid var(--line);
504
+ background: var(--glass); border-top: 1px solid var(--line);
505
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
506
+ backdrop-filter: blur(14px) saturate(1.1);
352
507
  }
353
508
  #input {
354
509
  flex: 1 1 auto; resize: none; min-height: 42px; max-height: 140px;
355
- padding: 10px 12px; border-radius: 12px; border: 1px solid var(--line);
510
+ padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line);
356
511
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
357
512
  line-height: 1.5; outline: none;
358
513
  }
359
- #input:focus { border-color: var(--accent); }
514
+ #input:focus { border-color: var(--ice); }
515
+ /* 发送键是这一屏**唯一的暖金**(另一处是刻度上"正在跑的那一步")。
516
+ 金色底配白字读不清,所以字用近黑的 --on-act。(浅色下 --act 是宝蓝、--on-act 是白字。) */
360
517
  .send {
361
- flex: 0 0 auto; height: 42px; padding: 0 16px; border-radius: 12px;
362
- border: none; background: var(--accent); color: #fff;
518
+ flex: 0 0 auto; height: 42px; padding: 0 16px; border-radius: var(--r);
519
+ border: none; background: var(--act); color: var(--on-act);
363
520
  font-size: 15px; font-weight: 600; cursor: pointer;
364
521
  }
365
522
  .send:disabled { opacity: .4; }
366
523
 
367
524
  /* ---------- 上传附件 ---------- */
368
525
  .attach {
369
- flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border-radius: 12px;
526
+ flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border-radius: var(--r);
370
527
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted);
371
528
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
372
529
  }
@@ -379,12 +536,14 @@
379
536
  /* 附件小条:排在输入框上面,让你发之前看得见自己带了什么。 */
380
537
  .attach-bar {
381
538
  flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 6px;
382
- padding: 8px 12px 0; background: var(--panel); border-top: 1px solid var(--line);
539
+ padding: 8px 12px 0; background: var(--glass); border-top: 1px solid var(--line);
540
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
541
+ backdrop-filter: blur(14px) saturate(1.1);
383
542
  }
384
543
  .attach-bar[hidden] { display: none; }
385
544
  .chip {
386
545
  display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
387
- padding: 5px 6px 5px 10px; border-radius: 999px;
546
+ padding: 5px 6px 5px 10px; border-radius: var(--r);
388
547
  border: 1px solid var(--line); background: var(--panel-2);
389
548
  font-size: 12.5px; color: var(--fg);
390
549
  }
@@ -403,17 +562,19 @@
403
562
  .cmd-menu {
404
563
  flex: 0 0 auto; max-height: 42vh; overflow-y: auto;
405
564
  -webkit-overflow-scrolling: touch;
406
- padding: 6px 8px; background: var(--panel); border-top: 1px solid var(--line);
565
+ padding: 6px 8px; background: var(--glass); border-top: 1px solid var(--line);
566
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
567
+ backdrop-filter: blur(14px) saturate(1.1);
407
568
  }
408
569
  .cmd-menu[hidden] { display: none; }
409
570
  .cmd-menu .cmd-row {
410
571
  display: flex; align-items: baseline; gap: 10px; width: 100%;
411
- padding: 9px 8px; border: none; border-radius: 8px; background: none;
572
+ padding: 9px 8px; border: none; border-radius: var(--r); background: none;
412
573
  color: var(--fg); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer;
413
574
  }
414
575
  .cmd-menu .cmd-row:active { background: var(--panel-2); }
415
576
  .cmd-menu .cmd-name {
416
- flex: 0 0 auto; color: var(--accent);
577
+ flex: 0 0 auto; color: var(--ice);
417
578
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
418
579
  }
419
580
  /* 说明收成一行:这一屏是「扫一眼挑一个」,不是读文档的地方。 */
@@ -428,18 +589,20 @@
428
589
  /* ---------- 左侧导航栏 ---------- */
429
590
  #nav {
430
591
  position: fixed; inset: 0; z-index: 60; display: none;
431
- background: rgba(0, 0, 0, .5);
592
+ background: rgba(4, 7, 11, .62);
432
593
  }
433
594
  #nav.open { display: block; }
434
595
  .nav-panel {
435
596
  position: absolute; top: 0; bottom: 0; left: 0; width: min(86vw, 350px);
436
597
  display: flex; flex-direction: column;
437
- background: var(--panel); border-right: 1px solid var(--line);
598
+ background: var(--glass); border-right: 1px solid var(--line);
438
599
  padding-top: env(safe-area-inset-top);
439
600
  padding-bottom: env(safe-area-inset-bottom);
440
- animation: navIn .18s ease-out;
601
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
602
+ backdrop-filter: blur(14px) saturate(1.1);
603
+ /* 原来抽出来时有一条 0.18s 的滑入(`navIn`),2026-09-27 删掉:
604
+ 状态变化是瞬时的,抽屉不该表演怎么出现。 */
441
605
  }
442
- @keyframes navIn { from { transform: translateX(-100%); } }
443
606
  .nav-head {
444
607
  flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
445
608
  padding: 12px 12px 12px 16px; border-bottom: 1px solid var(--line);
@@ -471,19 +634,23 @@
471
634
  font: inherit; font-size: 14px; text-align: left; cursor: pointer;
472
635
  }
473
636
  .sess:active { background: var(--panel-2); }
474
- .sess.cur { background: var(--panel-2); box-shadow: inset 3px 0 0 var(--accent); }
637
+ /* "当前会话"用左边一条 2px 冰蓝线,不用投影:层次靠细线,不靠阴影。 */
638
+ .sess.cur { background: var(--panel-2); border-left: 2px solid var(--ice); }
475
639
  .sess .s-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
476
640
  .sess .s-meta { flex: 0 0 auto; color: var(--muted); font-size: 11.5px; }
641
+ /* 「正在跑」这个点是**状态**,和主屏顶栏那颗是同一种意思,所以用同一个冰蓝。
642
+ 这里原来跟着暖金走,结果抽屉一屏能出现三处金(两个点 + 「就在这个工作区」),
643
+ 破了"一屏最多两处"。金只留给"该动手的地方"和"当前这一步"。 */
477
644
  .sess .s-dot, .ws-head .s-dot {
478
645
  flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%;
479
- background: var(--warn); animation: pulse 1.2s ease-in-out infinite;
646
+ background: var(--run);
480
647
  }
481
648
  /* 「新建会话」那一行。跟会话条目同宽、同缩进,但用强调色——
482
649
  它不是一个会话,是一个动作。长得跟会话一样会让人以为点进去就是那条会话。 */
483
650
  .ws-new {
484
651
  display: flex; align-items: center; gap: 6px; width: 100%;
485
652
  padding: 10px 14px 10px 30px; background: none; border: none;
486
- color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
653
+ color: var(--ice); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
487
654
  }
488
655
  .ws-new:active { background: var(--panel-2); }
489
656
  .ws-new[disabled] { color: var(--muted); cursor: default; }
@@ -493,7 +660,7 @@
493
660
  .nav-cta {
494
661
  display: flex; align-items: center; gap: 6px; width: 100%;
495
662
  padding: 12px 14px; background: none; border: none; border-bottom: 1px solid var(--line);
496
- color: var(--accent); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
663
+ color: var(--ice); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
497
664
  }
498
665
  .nav-cta:active { background: var(--panel-2); }
499
666
 
@@ -503,8 +670,8 @@
503
670
  overflow-x: auto; white-space: nowrap; border-bottom: 1px solid var(--line);
504
671
  }
505
672
  .crumb button {
506
- background: none; border: none; color: var(--accent); font: inherit;
507
- font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: 6px;
673
+ background: none; border: none; color: var(--ice); font: inherit;
674
+ font-size: 13px; padding: 4px 6px; cursor: pointer; border-radius: var(--r);
508
675
  }
509
676
  .crumb button:active { background: var(--panel-2); }
510
677
  .crumb .sep { color: var(--muted); font-size: 12px; }
@@ -513,8 +680,8 @@
513
680
  /* 「就选这个目录」——这一屏唯一的主按钮,所以给它实心底。 */
514
681
  .pick-here {
515
682
  display: block; width: calc(100% - 24px); margin: 10px 12px;
516
- padding: 11px; border: none; border-radius: var(--radius);
517
- background: var(--accent); color: #fff; font: inherit; font-size: 14px; cursor: pointer;
683
+ padding: 11px; border: none; border-radius: var(--r);
684
+ background: var(--act); color: var(--on-act); font: inherit; font-size: 14px; cursor: pointer;
518
685
  }
519
686
  .pick-here:active { opacity: .85; }
520
687
  .pick-here[disabled] { background: var(--line); color: var(--muted); cursor: default; }
@@ -525,12 +692,12 @@
525
692
  .mkdir-row input {
526
693
  flex: 1 1 auto; min-width: 0; padding: 9px 10px; font: inherit; font-size: 14px;
527
694
  background: var(--panel-2); border: 1px solid var(--line);
528
- border-radius: 10px; color: var(--fg);
695
+ border-radius: var(--r); color: var(--fg);
529
696
  }
530
697
  .mkdir-row button {
531
698
  flex: 0 0 auto; padding: 9px 12px; font: inherit; font-size: 13px; cursor: pointer;
532
699
  background: var(--panel-2); border: 1px solid var(--line);
533
- border-radius: 10px; color: var(--fg);
700
+ border-radius: var(--r); color: var(--fg);
534
701
  }
535
702
  .mkdir-row button:active { background: var(--line); }
536
703
 
@@ -541,20 +708,22 @@
541
708
  /* 「这不是全部」:垫在聊天内容最上面,往上翻到顶才看得见,不抢内容。 */
542
709
  .history-note {
543
710
  margin: 0 0 14px; padding: 8px 12px; border: 1px dashed var(--line);
544
- border-radius: 10px; color: var(--muted); font-size: 12.5px; line-height: 1.6;
711
+ border-radius: var(--r); color: var(--muted); font-size: 12.5px; line-height: 1.6;
545
712
  }
546
713
 
547
714
  /* ---------- 设置抽屉 ---------- */
548
715
  #settings {
549
- position: fixed; inset: 0; background: rgba(0,0,0,.55);
716
+ position: fixed; inset: 0; background: var(--scrim);
550
717
  display: none; align-items: flex-end; z-index: 50;
551
718
  }
552
719
  #settings.open { display: flex; }
553
720
  .sheet {
554
721
  width: 100%; max-height: 86vh; overflow-y: auto;
555
- background: var(--panel); border-radius: 18px 18px 0 0;
722
+ background: var(--glass); border-radius: var(--r) var(--r) 0 0;
556
723
  padding: 18px 16px calc(env(safe-area-inset-bottom) + 18px);
557
724
  border-top: 1px solid var(--line);
725
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
726
+ backdrop-filter: blur(14px) saturate(1.1);
558
727
  }
559
728
  .sheet h2 { margin: 0 0 14px; font-size: 16px; }
560
729
  .row {
@@ -567,18 +736,18 @@
567
736
  /* flex-shrink: 0 不能省。它是弹性项,左边的说明文字一长就会被压缩;
568
737
  而它自己带着 overflow: hidden,所以被压掉的那一段不是跑到屏幕外,是**被裁掉**——
569
738
  2026-09-25 用户截图里那个「关」字就是这么没的。 */
570
- .seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
739
+ .seg { display: flex; flex-shrink: 0; border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
571
740
  .seg button {
572
741
  padding: 7px 12px; border: none; background: var(--panel-2); color: var(--muted);
573
742
  font-size: 13px; cursor: pointer;
574
743
  }
575
- .seg button.active { background: var(--accent); color: #fff; }
744
+ .seg button.active { background: var(--ice); color: var(--on-act); }
576
745
  select, .btn {
577
- padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line);
746
+ padding: 8px 12px; border-radius: var(--r); border: 1px solid var(--line);
578
747
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 14px;
579
748
  }
580
749
  .btn { cursor: pointer; }
581
- .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
750
+ .btn.primary { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
582
751
  .sheet .foot { margin-top: 18px; display: flex; gap: 10px; }
583
752
  .sheet .foot .btn { flex: 1 1 0; text-align: center; }
584
753
 
@@ -590,19 +759,20 @@
590
759
  #gate h1 { font-size: 20px; margin: 0; }
591
760
  #gate p { color: var(--muted); font-size: 14px; margin: 0; }
592
761
  #gate input {
593
- padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line);
762
+ padding: 12px 14px; border-radius: var(--r); border: 1px solid var(--line);
594
763
  background: var(--panel-2); color: var(--fg); font: inherit; font-size: 16px;
595
764
  }
596
765
  #gate button {
597
- padding: 13px; border-radius: 12px; border: none; background: var(--accent);
598
- color: #fff; font-size: 16px; font-weight: 600;
766
+ padding: 13px; border-radius: var(--r); border: none; background: var(--act);
767
+ color: var(--on-act); font-size: 16px; font-weight: 600;
599
768
  }
600
769
  #gate .err { color: var(--err); font-size: 13px; min-height: 18px; }
601
770
 
602
771
  #toast {
603
772
  position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%) translateY(8px);
604
- background: rgba(40,44,54,.97); color: var(--fg); padding: 9px 16px;
605
- border-radius: 20px; font-size: 13.5px; opacity: 0; pointer-events: none;
773
+ background: var(--pop); color: var(--fg); padding: 9px 16px;
774
+ border-radius: var(--r); font-size: 13.5px; opacity: 0; pointer-events: none;
775
+ border: 1px solid var(--line);
606
776
  transition: opacity .2s, transform .2s; z-index: 200; max-width: 86vw; text-align: center;
607
777
  }
608
778
  #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -611,25 +781,48 @@
611
781
  整屏遮住聊天区:手机上一次只专心答一题,答完自动消失。
612
782
  之所以遮全屏而不是弹个小条:非技术用户看到"电脑在等你回答"时,
613
783
  最怕的是不知道该点哪里;整屏只剩问题本身和按钮,没有第二个选择。 */
614
- #askCard { position: fixed; inset: 0; z-index: 300; background: #0e1116;
784
+ #askCard { position: fixed; inset: 0; z-index: 300; background: var(--bg);
615
785
  display: none; flex-direction: column; padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); overflow-y: auto; }
616
786
  #askCard.on { display: flex; }
617
- #askCard .qhead { font-size: 13px; opacity: .6; margin-bottom: 12px; }
787
+ #askCard .qhead { font-size: 13px; color: var(--muted); margin-bottom: 12px; }
618
788
  #askCard .qtext { font-size: 18px; line-height: 1.6; margin-bottom: 10px; }
619
- #askCard .qdetail { font-size: 14px; line-height: 1.65; opacity: .75; margin-bottom: 16px; white-space: pre-wrap; }
789
+ #askCard .qdetail { font-size: 14px; line-height: 1.65; color: var(--muted); margin-bottom: 16px; white-space: pre-wrap; }
620
790
  #askCard .opt { display: block; width: 100%; text-align: left; margin: 0 0 10px; padding: 15px 16px;
621
- font-size: 16px; line-height: 1.5; border-radius: 12px; border: 1px solid #2a3140;
622
- background: #161b23; color: inherit; font-family: inherit; }
623
- #askCard .opt.sel { border-color: #3b82f6; background: #17243a; }
624
- #askCard .opt small { display: block; opacity: .7; font-size: 13px; margin-top: 5px; }
791
+ font-size: 16px; line-height: 1.5; border-radius: var(--r); border: 1px solid var(--line);
792
+ background: var(--panel-2); color: inherit; font-family: inherit; }
793
+ /* 选中的那一条用冰蓝描边:它是"你选的",不是"你要按的主按钮"——主按钮是暖金。 */
794
+ #askCard .opt.sel { border-color: var(--ice); background: rgba(169, 200, 232, .1); }
795
+ #askCard .opt small { display: block; color: var(--muted); font-size: 13px; margin-top: 5px; }
625
796
  #askCard textarea { width: 100%; box-sizing: border-box; font-size: 16px; line-height: 1.6;
626
- border-radius: 12px; border: 1px solid #2a3140; background: #161b23; color: inherit;
797
+ border-radius: var(--r); border: 1px solid var(--line); background: var(--panel-2); color: inherit;
627
798
  padding: 12px; font-family: inherit; resize: vertical; }
628
799
  #askCard .qfoot { margin-top: auto; display: flex; gap: 10px; padding-top: 16px; }
629
- #askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: 12px;
630
- border: 1px solid #2a3140; background: #161b23; color: inherit; font-family: inherit; }
631
- #askCard .qfoot button#askNext { background: #2563eb; border-color: #2563eb; color: #fff; }
800
+ #askCard .qfoot button { flex: 1; padding: 15px 8px; font-size: 16px; border-radius: var(--r);
801
+ border: 1px solid var(--line); background: var(--panel-2); color: inherit; font-family: inherit; }
802
+ /* 这一屏唯一的主按钮,暖金。 */
803
+ #askCard .qfoot button#askNext { background: var(--act); border-color: var(--act); color: var(--on-act); font-weight: 600; }
804
+ /* 没有毛玻璃的浏览器:退回实色面板。别让界面变成一片糊住的半透明。 */
805
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
806
+ header, footer, .attach-bar, .cmd-menu, .nav-panel, .sheet { background: var(--panel); }
807
+ }
808
+ /* 细线图标:和回形针、复制按钮那套同一副笔法(1.5px 描边、圆头圆角)。 */
809
+ .icon-btn svg {
810
+ width: 18px; height: 18px; fill: none; stroke: currentColor;
811
+ stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
812
+ }
632
813
  </style>
814
+ <script>
815
+ // 主题必须在**首屏画出来之前**定下来,否则浅色手机打开会先闪一下深色底。
816
+ // 所以这一段留在 <head> 里、样式表后面,**别挪到页面底部那段脚本区去**。
817
+ (function () {
818
+ try {
819
+ var pick = localStorage.getItem('dshMiniTheme') || 'dark';
820
+ var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
821
+ document.documentElement.setAttribute('data-theme',
822
+ pick === 'auto' ? (dark ? 'dark' : 'light') : pick);
823
+ } catch (e) { /* 无痕模式下 localStorage 会抛;按默认深色走,不影响页面 */ }
824
+ })();
825
+ </script>
633
826
  </head>
634
827
  <body>
635
828
 
@@ -650,11 +843,11 @@
650
843
  </div>
651
844
 
652
845
  <header>
653
- <button class="icon-btn" id="btnNav" aria-label="工作区与会话">☰</button>
846
+ <button class="icon-btn" id="btnNav" aria-label="工作区与会话"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
654
847
  <span class="dot" id="dot"></span>
655
- <span class="brand">DSH mini remote</span>
848
+ <span class="brand">极简遥控器</span>
656
849
  <span class="status" id="status">连接中…</span>
657
- <button class="icon-btn" id="btnSettings" aria-label="设置">⚙</button>
850
+ <button class="icon-btn" id="btnSettings" aria-label="设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h8M16 7h4M4 17h4M12 17h8"/><circle cx="14" cy="7" r="2.2"/><circle cx="10" cy="17" r="2.2"/></svg></button>
658
851
  </header>
659
852
 
660
853
  <!-- 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
@@ -718,8 +911,8 @@
718
911
  <aside class="nav-panel">
719
912
  <div class="nav-head">
720
913
  <span>工作区与会话</span>
721
- <button class="icon-btn" id="btnNavRefresh" aria-label="刷新">⟳</button>
722
- <button class="icon-btn" id="btnNavClose" aria-label="关闭">✕</button>
914
+ <button class="icon-btn" id="btnNavRefresh" aria-label="刷新"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 5v6h-6"/></svg></button>
915
+ <button class="icon-btn" id="btnNavClose" aria-label="关闭"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
723
916
  </div>
724
917
  <div class="nav-body" id="navBody"></div>
725
918
  </aside>
@@ -740,6 +933,20 @@
740
933
  </div>
741
934
  </div>
742
935
 
936
+ <!-- 外观。深色是原来的样子;浅色的色调跟宣传图(docs/promo.png)统一:
937
+ 近白冰蓝的底、深宝蓝的字与状态色、白色卡片。选择记在这台设备上。 -->
938
+ <div class="row">
939
+ <div>
940
+ <div class="label">外观</div>
941
+ <div class="hint">浅色的色调跟宣传图一致</div>
942
+ </div>
943
+ <div class="seg" id="segTheme">
944
+ <button data-theme="dark">深色</button>
945
+ <button data-theme="light">浅色</button>
946
+ <button data-theme="auto">跟随系统</button>
947
+ </div>
948
+ </div>
949
+
743
950
  <!-- 权限档位。整块由脚本填:这台电脑有哪些档位是**运行时读**的,一个都不写死。
744
951
  服务拿不到时整块不显示——它是附加能力,缺了遥控本身照常。 -->
745
952
  <div id="permBlock" hidden>
@@ -852,6 +1059,9 @@
852
1059
  var state = {
853
1060
  token: params.get('token') || localStorage.getItem('dshMiniToken') || '',
854
1061
  mode: localStorage.getItem('dshMiniMode') || DEFAULT_MODE || 'minimal',
1062
+ // 外观:'dark' | 'light' | 'auto'。默认深色——不给老用户换掉现在的样子;
1063
+ // 要浅色或跟着系统走,去设置页「外观」点一下。
1064
+ theme: localStorage.getItem('dshMiniTheme') || 'dark',
855
1065
  sound: localStorage.getItem('dshMiniSound') !== 'off',
856
1066
  // 锁屏也能提醒(系统通知)。默认关着——它要用户主动点一下才会申请权限,
857
1067
  // 不能替他决定。明文连接下这一项永远是空转的,见下面「锁屏也能提醒」那一段。
@@ -869,6 +1079,14 @@
869
1079
  uploads: [],
870
1080
  // 正在流出来的那一段。空字符串 = 这会儿没有东西要显示。
871
1081
  live: '',
1082
+ /**
1083
+ * 模型此刻的自言自语(鲸鱼娘的台词):{ text, at } 或 null。
1084
+ *
1085
+ * **和 live 严格分开**:live 非空时整块 #work 会让位(那是「回答正在往外冒」的
1086
+ * 规矩),台词要是塞进 live,会出现「该说话的时候鲸鱼娘不见了」。
1087
+ * 它也不进 #reply——2026-09-22 用户否掉的就是「过程的文字跑进回答区」。
1088
+ */
1089
+ thought: null,
872
1090
  // 服务端正去读这个会话的完整记录(点开一个插件没看过的会话时会有这一下)。
873
1091
  historyLoading: false,
874
1092
  // 「这份历史只到最近一段」(服务端读历史有上限时会给这一句)。
@@ -929,6 +1147,18 @@
929
1147
  return CODE_SLOT + (codes.length - 1) + CODE_SLOT;
930
1148
  });
931
1149
  out = out
1150
+ // 图片:![说明](地址)。地址两种写法——
1151
+ // ① 完整 http(s) 链接:原样用,手机直接去那个网站取图;
1152
+ // ② 一个名字:走本机的图片通道 /mini/art/<名字>.webp,跟立绘同一条路、
1153
+ // 同一样要密码,也只认 lib/art/ 下「小写字母数字连字符 + .webp」的文件名。
1154
+ // 想让手机看到电脑上的图,把图放进 lib/art/、名字以 out- 开头即可。
1155
+ // 必须排在下面那条链接规则前面:否则 `![x](y)` 里的 `[x](y)` 会先被链接规则
1156
+ // 吃掉,只剩下一个光秃秃的感叹号。
1157
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (_, alt, src) {
1158
+ var url = /^https?:\/\//.test(src) ? src : artUrl(src.replace(/\.webp$/, ''));
1159
+ return '<img class="md-img" src="' + url + '" alt="' + alt + '"'
1160
+ + ' loading="lazy" referrerpolicy="no-referrer">';
1161
+ })
932
1162
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
933
1163
  .replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>')
934
1164
  .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
@@ -1115,7 +1345,13 @@
1115
1345
  opts.headers = Object.assign(authHeaders(!raw), opts.headers || {});
1116
1346
  return fetch(path, opts).then(function (r) {
1117
1347
  return r.json().catch(function () { return {}; }).then(function (body) {
1118
- if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status));
1348
+ if (!r.ok) {
1349
+ var err = new Error(body.error || ('HTTP ' + r.status));
1350
+ // 状态码挂在错上带出去:调用方要区分「服务端说这题已经结束了」(409)和
1351
+ // 「压根没连上」(fetch 直接抛)——靠中文字面去猜太脆。
1352
+ err.status = r.status;
1353
+ throw err;
1354
+ }
1119
1355
  return body;
1120
1356
  });
1121
1357
  });
@@ -1212,10 +1448,50 @@
1212
1448
  var GAP_MS = 3600;
1213
1449
  var SLOW_MS = 60000;
1214
1450
 
1451
+ /**
1452
+ * 说话那张立绘。**它不进 POSES。**
1453
+ *
1454
+ * POSES 是轮播的节目单,进去就要占一格(还要连带改 posePool 那套按位置认「还在忙」
1455
+ * 的写法)。这张是**按需插播**的:只在宿主侧推来台词时出现,说完就回原来的顺序。
1456
+ * 文件名里的 9 同样只是个名字,跟顺序无关。
1457
+ */
1458
+ var SPEAK_POSE = { file: 'work-9-talking', flip: 1.1 };
1459
+ /**
1460
+ * 一句台词占多久:**按字数算**,短的有起步时长,长的封顶。
1461
+ *
1462
+ * 用户 2026-09-26 选的是「一直说到它结束(最多 10 秒)」;2026-09-27 他觉得太短,
1463
+ * 问能不能再久一点。原来是一律 10 秒——短句够用,可长句在 2026-09-27 起会**完整显示**
1464
+ * (不再截断),一句 200 字的话念 10 秒根本念不完,她就已经回去干别的了。
1465
+ *
1466
+ * 所以:起步 15 秒,每多一个字多留 0.15 秒(≈ 一分钟 400 字,手机上舒服的默读速度),
1467
+ * 30 秒封顶——封顶是为了防「某一轮她再没说话、画面却一直卡在说话那张」。
1468
+ * 宿主侧只拿得到**整段**(一步一步来的,不是逐字),所以「结束」得自己收尾:
1469
+ * 下一句台词来了就把表往后推,一直没有新的或这一轮结束了,就到点放回轮播。
1470
+ */
1471
+ var SPEAK_MIN_MS = 15000;
1472
+ var SPEAK_PER_CHAR_MS = 150;
1473
+ var SPEAK_MAX_MS = 30000;
1474
+
1475
+ /** 这句话该占多久(见上面三条)。 */
1476
+ function speakDuration(text) {
1477
+ var n = String(text || '').length;
1478
+ return Math.min(SPEAK_MAX_MS, Math.max(SPEAK_MIN_MS, n * SPEAK_PER_CHAR_MS));
1479
+ }
1480
+
1215
1481
  var reducedMotion = false;
1216
1482
  try { reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
1217
1483
 
1218
- var work = { startedAt: 0, pose: 0, poseTimer: null, tickTimer: null, layers: [] };
1484
+ var work = {
1485
+ startedAt: 0, pose: 0, poseTimer: null, tickTimer: null, layers: [],
1486
+ // 说话那张立绘单独一层(不在 POSES 里,见 SPEAK_POSE)。
1487
+ speakLayer: null,
1488
+ // 正在说的那句台词,以及它最晚说到什么时候。
1489
+ speakText: '',
1490
+ speakUntil: 0,
1491
+ // 已经念过的那句话的身份(宿主侧的 at)。快照来得很密,不认这个的话
1492
+ // 同一句话会被反复「重新开始说」,动画一直从头播。
1493
+ speakAt: null,
1494
+ };
1219
1495
 
1220
1496
  /** 现在该用哪几条姿态:跑够一分钟才把「还在忙」放进来。 */
1221
1497
  function posePool() {
@@ -1254,16 +1530,65 @@
1254
1530
  + '&v=' + encodeURIComponent(BUILD);
1255
1531
  }
1256
1532
 
1533
+ /**
1534
+ * 把一句话写进气泡。
1535
+ *
1536
+ * 走的是和回答区同一个行内渲染(`mdInline`,见上面那段 Markdown 渲染)。它的顺序是
1537
+ * **先转义、再上标签**:模型原话里的尖括号仍然只是字面上的尖括号,不会被当成标签;
1538
+ * 只有它自己写的 `**粗体**`、`` `代码` ``、链接才会变成样式。留一个口子给"以后不加"。
1539
+ *
1540
+ * 之前这里是 `textContent`(一句纯文本),于是 `**重启把连接掐断**` 连星号一起端到了
1541
+ * 用户面前(2026-09-27 用户报的)。词条那几种是我们自己写的、不含 markdown,也一起走
1542
+ * 这个入口——两条路各写各的,早晚会分叉。
1543
+ */
1544
+ function paintBubble(line) {
1545
+ var text = line || '';
1546
+ $('workBubble').hidden = !text;
1547
+ $('workBubble').innerHTML = mdInline(text);
1548
+ }
1549
+
1257
1550
  function paintPose(index) {
1258
1551
  for (var i = 0; i < work.layers.length; i += 1) {
1259
1552
  work.layers[i].classList.toggle('on', i === index);
1260
1553
  }
1554
+ // 轮播回来时把说话那张收掉——它在 POSES 之外,不归上面那个循环管。
1555
+ if (work.speakLayer) work.speakLayer.classList.toggle('on', false);
1261
1556
  // 有的姿势不带词条(比如那张冲刺):词条是空串就把整个气泡收起来。
1262
1557
  // 留一个空泡泡在那儿比不放气泡更怪——那个尖角还指着她的头,像话没说出来。
1263
1558
  // 收起气泡不会让立绘挪位:.work-top 有 min-width 兜着,行宽不变。
1264
- var line = POSES[index].line || '';
1265
- $('workBubble').hidden = !line;
1266
- $('workBubble').textContent = line;
1559
+ paintBubble(POSES[index].line);
1560
+ }
1561
+
1562
+ /**
1563
+ * 换成说话那张,把台词写进气泡。
1564
+ *
1565
+ * 台词是**模型的原话**(宿主机只从旁白里取,模型自己想的那些不上手机),
1566
+ * 所以这里只做一件事:原样写出来,不加不减——渲染的那一步交给 `paintBubble`。
1567
+ */
1568
+ function paintSpeak(text) {
1569
+ paintBubble(text);
1570
+ // 说话那张没加载出来时(预热那次报错翻过这个开关):台词照说,**但不动图层**——
1571
+ // 把正常那几张全关掉、换上一张空的,她会当场从屏幕上消失(2026-09-27 立的闸)。
1572
+ if (work.speakReady === false) return;
1573
+ for (var i = 0; i < work.layers.length; i += 1) work.layers[i].classList.toggle('on', false);
1574
+ if (work.speakLayer) work.speakLayer.classList.toggle('on', true);
1575
+ }
1576
+
1577
+ /**
1578
+ * 宿主侧送来的新台词要不要现在就说。
1579
+ *
1580
+ * 判据是 at(那句话的身份):**同一句只说一次**。快照广播来得很密,每来一条就重说
1581
+ * 一遍的话,说话这张立绘会一直从头播,看着像卡住不动。
1582
+ */
1583
+ function maybeSpeak() {
1584
+ var t = state.thought;
1585
+ if (!t || !t.text) return;
1586
+ if (t.at === work.speakAt) return;
1587
+ work.speakAt = t.at;
1588
+ work.speakText = t.text;
1589
+ // 每来一句新的就从这一刻重新算:时长按这句话有多长来(见 speakDuration)。
1590
+ work.speakUntil = Date.now() + speakDuration(t.text);
1591
+ paintSpeak(t.text);
1267
1592
  }
1268
1593
 
1269
1594
  function elapsedLabel(ms) {
@@ -1293,6 +1618,19 @@
1293
1618
  var warm = document.createElement('img');
1294
1619
  warm.src = artUrl(POSES[i].file);
1295
1620
  }
1621
+ // 说话那张一起建好、一起预热:等台词来了再新建图层,会说第一个字之前先闪一下空白。
1622
+ var speak = document.createElement('div');
1623
+ speak.className = 'pose';
1624
+ speak.style.backgroundImage = 'url("' + artUrl(SPEAK_POSE.file) + '")';
1625
+ speak.style.animationDuration = SPEAK_POSE.flip + 's';
1626
+ stage.appendChild(speak);
1627
+ work.speakLayer = speak;
1628
+ var warmSpeak = document.createElement('img');
1629
+ // 说话那张要是加载不出来(图还没画好、或者网络半路断了):**别把舞台清空**。
1630
+ // 默认按「图在」处理,只有真报错才翻成 false——正常路径一个字都不变;
1631
+ // 翻成 false 之后就退成「台词照说、人照轮播」,而不是她整个消失(见 paintSpeak)。
1632
+ warmSpeak.onerror = function () { work.speakReady = false; };
1633
+ warmSpeak.src = artUrl(SPEAK_POSE.file);
1296
1634
  paintPose(0);
1297
1635
  }
1298
1636
 
@@ -1304,6 +1642,20 @@
1304
1642
  * 停止时 stopWork 会把待排的那一次清掉,不会漏跑。
1305
1643
  */
1306
1644
  function nextPose() {
1645
+ // 正在说话:这一格让给她,轮播停在这儿等(work.pose 不动,所以恢复时接着的那一格
1646
+ // 就是被打断的那一格,而不是从头来)。到点或来了新台词都由 maybeSpeak 管。
1647
+ if (work.speakUntil) {
1648
+ var left = work.speakUntil - Date.now();
1649
+ if (left > 0) {
1650
+ paintSpeak(work.speakText);
1651
+ // 睡到「该收尾的那一刻」为止,不多不少:按 3.6 秒一格睡的话,
1652
+ // 这个上限会被拖到整格之后,最后那句台词要多挂将近一秒。
1653
+ work.poseTimer = setTimeout(nextPose, Math.min(POSE_MS, left));
1654
+ return;
1655
+ }
1656
+ work.speakUntil = 0;
1657
+ work.speakText = '';
1658
+ }
1307
1659
  var seq = poseSequence();
1308
1660
  work.pose = (work.pose + 1) % seq.length;
1309
1661
  var index = seq[work.pose];
@@ -1328,6 +1680,11 @@
1328
1680
 
1329
1681
  function stopWork() {
1330
1682
  work.startedAt = 0;
1683
+ // 说话状态跟着收掉:这一轮的话已经说完了,下一轮不该接着上一句。
1684
+ // **但 speakAt 留着**——那是对「哪句话已经念过」的记忆,清掉的话,万一宿主侧
1685
+ // 还留着上一轮的台词(广播没到),新一轮一开场它就会被当成新话说一遍。
1686
+ work.speakUntil = 0;
1687
+ work.speakText = '';
1331
1688
  // 轮播现在是自己排下一次的 setTimeout(见 nextPose),用 clearInterval 清
1332
1689
  // setTimeout 在浏览器里也行,但写 clearTimeout 更不容易让人误会。
1333
1690
  if (work.poseTimer) { clearTimeout(work.poseTimer); work.poseTimer = null; }
@@ -1373,6 +1730,30 @@
1373
1730
 
1374
1731
  $('btnStop').addEventListener('click', requestStop);
1375
1732
 
1733
+ /**
1734
+ * 把手机上答的题交回电脑,回一句实话。
1735
+ *
1736
+ * 回的是三种结果,不是一个「成没成」——因为两种失败要说不同的话:
1737
+ * 'ok' 电脑收下了;
1738
+ * 'late' 这题已经交回电脑了(插件回 409),得让用户去电脑上答;
1739
+ * 'failed' 压根没发出去(连不上),卡片留着,手一按还能再试。
1740
+ *
1741
+ * 原来那一段是按下就先把卡片收掉、发失败也咽着不说(当时的理由是「电脑那边等不到
1742
+ * 会自己还给浏览器」)。可电脑那边还在等着,手机上却静悄悄——用户看到的只有
1743
+ * 「我点了,没反应」(2026-09-27 用户报的正是这一幕)。所以这里只做一件事:
1744
+ * 把实情如实报出来,怎么显示由上面那张卡片决定。
1745
+ */
1746
+ function sendAnswer(id, answers) {
1747
+ return api('/mini/api/answer', {
1748
+ method: 'POST',
1749
+ body: JSON.stringify({ id: id, answers: answers }),
1750
+ })
1751
+ .then(function () { return 'ok'; })
1752
+ .catch(function (err) {
1753
+ return err && err.status === 409 ? 'late' : 'failed';
1754
+ });
1755
+ }
1756
+
1376
1757
  // ---------------- 排队中的指令 ----------------
1377
1758
  //
1378
1759
  // 跑着任务时接着发的那几条不会丢,而是排进 agent 的收件箱(`followup()` 的语义
@@ -1384,16 +1765,31 @@
1384
1765
  function paintQueue() {
1385
1766
  var el = $('queue');
1386
1767
  var list = state.queued || [];
1768
+ // 刻度尺上第一个点永远是**正在跑的那条**,后面每个点对应一条真排在队里的指令。
1769
+ //
1770
+ // 2026-09-27 用户裁决:**队列是空的就整块收起来,不显示。**
1771
+ // 上一版这里是「只要在跑就留着」,理由是一个金点表示"你这一轮正是第 1 步";
1772
+ // 用户实际看到的是「排队中 0 条」配一个孤零零的点,判定为噪音。撤销。
1773
+ // (上面那段注释里"哪怕只有一格,秩序也在"的说法同样作废。)
1387
1774
  if (!list.length) { el.hidden = true; el.innerHTML = ''; return; }
1388
1775
  el.hidden = false;
1776
+ // 最多画 12 个点:再长这条尺子就成噪点了,超出缀一个省略号。
1777
+ var nodes = '<div class="q-node cur"><i></i><b>1</b></div>';
1778
+ var shown = Math.min(list.length, 11);
1779
+ for (var n = 0; n < shown; n += 1) {
1780
+ nodes += '<div class="q-node"><i></i><b>' + (n + 2) + '</b></div>';
1781
+ }
1782
+ if (list.length > shown) nodes += '<div class="q-node"><i></i><b>…</b></div>';
1389
1783
  var rows = '';
1390
1784
  for (var i = 0; i < list.length; i += 1) {
1391
1785
  rows += '<div class="q-row">'
1392
1786
  + '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
1393
1787
  + '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
1394
- + ' aria-label="撤掉这条">✕</button></div>';
1788
+ + ' aria-label="撤掉这条"><svg viewBox="0 0 24 24" aria-hidden="true">'
1789
+ + '<path d="M6 6l12 12M18 6L6 18"/></svg></button></div>';
1395
1790
  }
1396
- el.innerHTML = '<div class="q-head">排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们</div>' + rows;
1791
+ el.innerHTML = '<div class="q-head">排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们</div>'
1792
+ + '<div class="q-scale">' + nodes + '</div>' + rows;
1397
1793
  }
1398
1794
 
1399
1795
  /**
@@ -1431,7 +1827,12 @@
1431
1827
  // (比如正在调工具)时,鲸鱼娘还是要留着,那是它唯一该出现的时候。
1432
1828
  var busy = (state.running || queued > 0) && !state.live;
1433
1829
  el.hidden = !busy;
1434
- if (busy) startWork(); else stopWork();
1830
+ if (busy) {
1831
+ startWork();
1832
+ // 台词每次随快照重算一遍。**必须放在 startWork 之后**:startWork 会先画第 0 张,
1833
+ // 反过来的话它会把这句台词盖掉,用户看到的是「收到,这就去办」而不是她的自说自话。
1834
+ maybeSpeak();
1835
+ } else stopWork();
1435
1836
  paintQueue();
1436
1837
  }
1437
1838
 
@@ -1788,6 +2189,8 @@
1788
2189
  // 正在流出来的那一段。服务端只在自己决定要显示之后才写它,所以这里是空字符串
1789
2190
  // 不代表模型没在写,只代表「还不到显示的时候」。
1790
2191
  if ('live' in snap) state.live = snap.live || '';
2192
+ // 模型此刻的自言自语(鲸鱼娘的台词)。null = 这会儿没话说,气泡回到该姿势自己的词条。
2193
+ if ('thought' in snap) state.thought = snap.thought || null;
1791
2194
  if (snap.history) state.history = snap.history;
1792
2195
  // 「服务端正去读这个会话的完整记录」。点开一个没看过的会话时,那一串要读一会儿
1793
2196
  // 才补上来(最大的那条日志 20 MB、约半秒),这期间不能装作「这个会话是空的」。
@@ -2366,12 +2769,17 @@
2366
2769
  if ((one.custom || '').trim()) answer.custom = one.custom.trim()
2367
2770
  return answer
2368
2771
  })
2369
- const id = ask.id
2370
- ask = null
2371
- $('askCard').classList.remove('on')
2372
- // 发失败也不弹窗打扰:电脑那边等不到答案会自己还给浏览器,用户不会卡住。
2373
- api('/mini/api/answer', { method: 'POST', body: JSON.stringify({ id: id, answers: answers }) })
2374
- .catch(function () {})
2772
+ // 三种结果说三句不同的话。**没成事就不收卡片**:收掉了就再也没地方说这句话,
2773
+ // 用户看到的只会是「点了没反应」。
2774
+ sendAnswer(ask.id, answers).then(function (verdict) {
2775
+ if (verdict === 'failed') {
2776
+ toast('没送出去,看一眼和电脑的连接,再点一次')
2777
+ return
2778
+ }
2779
+ ask = null
2780
+ $('askCard').classList.remove('on')
2781
+ if (verdict === 'late') toast('这道题已经交回电脑了,去电脑上答')
2782
+ })
2375
2783
  })
2376
2784
  es.addEventListener('question', function (e) {
2377
2785
  let payload = null
@@ -2911,6 +3319,39 @@
2911
3319
  syncSeg();
2912
3320
  });
2913
3321
  });
3322
+
3323
+ // ---------------- 外观(深色 / 浅色 / 跟随系统) ----------------
3324
+ // 只改 <html> 上的 data-theme,样式表里两套令牌各管各的。这里不碰任何具体颜色,
3325
+ // 免得以后加第三套主题还要回来改这一段。
3326
+ // 首屏那次由 <head> 里那一小段负责(不然会闪);这一段管切换和"跟着系统变"。
3327
+ function resolvedTheme() {
3328
+ if (state.theme !== 'auto') return state.theme;
3329
+ var dark = !window.matchMedia || window.matchMedia('(prefers-color-scheme: dark)').matches;
3330
+ return dark ? 'dark' : 'light';
3331
+ }
3332
+ function applyTheme() {
3333
+ var use = resolvedTheme() === 'light' ? 'light' : 'dark';
3334
+ document.documentElement.setAttribute('data-theme', use);
3335
+ // 手机状态栏那块颜色也跟着走,否则浅色页配一条深色状态栏,像没画完。
3336
+ var meta = document.querySelector('meta[name="theme-color"]');
3337
+ if (meta) meta.setAttribute('content', use === 'light' ? '#eaf3fc' : '#0a0f16');
3338
+ }
3339
+ Array.prototype.forEach.call($('segTheme').children, function (btn) {
3340
+ btn.addEventListener('click', function () {
3341
+ state.theme = btn.dataset.theme;
3342
+ localStorage.setItem('dshMiniTheme', state.theme);
3343
+ applyTheme();
3344
+ syncSeg();
3345
+ });
3346
+ });
3347
+ if (window.matchMedia) {
3348
+ var themeMq = window.matchMedia('(prefers-color-scheme: dark)');
3349
+ var onThemeMq = function () { if (state.theme === 'auto') applyTheme(); };
3350
+ if (themeMq.addEventListener) themeMq.addEventListener('change', onThemeMq);
3351
+ else if (themeMq.addListener) themeMq.addListener(onThemeMq);
3352
+ }
3353
+ applyTheme(); // 补上首屏那次没做的一件事:把状态栏颜色对齐
3354
+
2914
3355
  function syncSeg() {
2915
3356
  Array.prototype.forEach.call($('segMode').children, function (b) {
2916
3357
  b.classList.toggle('active', b.dataset.mode === state.mode);
@@ -2918,6 +3359,9 @@
2918
3359
  Array.prototype.forEach.call($('segSound').children, function (b) {
2919
3360
  b.classList.toggle('active', (b.dataset.sound === 'on') === state.sound);
2920
3361
  });
3362
+ Array.prototype.forEach.call($('segTheme').children, function (b) {
3363
+ b.classList.toggle('active', b.dataset.theme === state.theme);
3364
+ });
2921
3365
  }
2922
3366
 
2923
3367
  // 会话切换挪到左侧导航栏了(原来这里是设置里的下拉框)。
@@ -3211,14 +3655,14 @@
3211
3655
  + 'color:inherit;padding:11px 2px;border-bottom:1px solid var(--line,#333)'
3212
3656
  var HEAD = 'padding:14px 2px 4px;font-size:12px;color:var(--dim,#888)'
3213
3657
  var CHIP = 'margin:0 6px 6px 0;padding:5px 13px;border:1px solid var(--line,#444);'
3214
- + 'border-radius:999px;background:none;font:inherit;color:inherit;font-size:13px'
3658
+ + 'border-radius:var(--r,2px);background:none;font:inherit;color:inherit;font-size:13px'
3215
3659
 
3216
3660
  // 面板用代码建出来、挂在 body 末尾,而不是写进页面结构里——这样这一整块的
3217
3661
  // 改动全部落在脚本内部,不用动 HTML,也就不会碰到别的部分。
3218
- var mask = el('div', 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:69;display:none')
3662
+ var mask = el('div', 'position:fixed;inset:0;background:var(--scrim);z-index:69;display:none')
3219
3663
  var sheet = el('div', 'position:fixed;left:0;right:0;bottom:0;z-index:70;display:none;'
3220
3664
  + 'max-height:72vh;overflow-y:auto;background:var(--bg,#1b1b1b);'
3221
- + 'border-top:1px solid var(--line,#444);border-radius:14px 14px 0 0;'
3665
+ + 'border-top:1px solid var(--line,#444);border-radius:var(--r,2px) var(--r,2px) 0 0;'
3222
3666
  + 'padding:10px 14px calc(16px + env(safe-area-inset-bottom))')
3223
3667
  document.body.appendChild(mask)
3224
3668
  document.body.appendChild(sheet)