dsh-in-hand 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +132 -0
  2. package/lib/page.html +373 -158
  3. package/lib/sw.js +14 -4
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -331,6 +331,138 @@ dsh plugin --profile web add <你的绝对路径>/dsh-in-hand
331
331
  PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效;
332
332
  页面文件与插件共用同一份,避免两边漂移。
333
333
 
334
+ ## 局域网直连(同一 Wi-Fi)
335
+
336
+ 上面那条路要域名、要 nginx、要一条 `ssh -R` —— 适合"人在外面也想看"。如果手机和电脑
337
+ 就在同一个屋子里,还有一条更短的路:**让中继直接监听内网地址,手机连同一个 Wi-Fi
338
+ 直连它。**
339
+
340
+ ### 怎么开(中继侧开关)
341
+
342
+ 中继默认只监听回环(`127.0.0.1`),连局域网都到不了,只有隧道能进。开局域网模式就是
343
+ 一个开关:在中继目录里放一个空的 `.lan` 标记文件,或者给它设环境变量 `RELAY_LAN=1`,
344
+ 它就从 `127.0.0.1` 改为绑定 `0.0.0.0`;删掉标记文件即关闭(这个开关是中继**启动时**
345
+ 读的,改完要重启它才生效)。中继自己的
346
+ `/taskwatch/lan` 页面(**不需要令牌,也不含任何凭据**)会列出当前是否已开启、本机可用
347
+ 的内网地址和连接步骤。
348
+
349
+ ### 手机怎么进
350
+
351
+ 1. 在跑中继的电脑上打开 `http://127.0.0.1:3099/taskwatch/pair`,页面上会显示一个
352
+ **6 位配对码**(只有从本机访问才显示)。
353
+ 2. 手机连同一个 Wi-Fi,打开 `http://192.168.1.20:3099/taskwatch`(把 `192.168.1.20`
354
+ 换成电脑的内网地址,以中继 `/taskwatch/lan` 列出的为准)。它会进配对页。
355
+ 3. 输入那 6 位码即可。配对码 **2 分钟有效、最多试 5 次、只能用一次**;换到的 cookie
356
+ 有效期 30 天,并且每次带着它访问都会滑动续期。
357
+
358
+ 页面右上角那个双入口胶囊也可以在这条路上用(见下一节)。
359
+
360
+ ### 这条路的硬限制
361
+
362
+ - **`http://` 不是安全上下文。** 装不成 PWA、没有 service worker 离线外壳,摄像头 /
363
+ 麦克风、剪贴板 API 也用不了。页面本身照常能用 —— 但"添加到主屏幕当一个 App 用"
364
+ 这件事只在 HTTPS 那条路上成立。
365
+ - **IP 会随 DHCP 变。** 路由器重新分配地址之后,手机上存的那条
366
+ `http://192.168.x.x:3099` 就失效了;以中继 `/taskwatch/lan` 当前列出的地址为准。
367
+ - **开启客户端隔离(AP isolation)的 Wi-Fi 连不上。** 那种设置下同一 Wi-Fi 的设备之间
368
+ 互相不可见,手机到不了电脑 —— 这是路由器的行为,不是这条路能绕开的。
369
+ - **出了这个 Wi-Fi 就连不上。** 要外网访问就用上面的隧道那条路。
370
+
371
+ ### 安全提醒
372
+
373
+ **拿到这条地址 + 配对码,等于拿到你机器上的一个终端。** 手机端能驱动 agent,而 DSH
374
+ 默认是 `danger-full-access`。所以:只在自己家、朋友家这类你信得过的网络里开;公司 /
375
+ 公共网络的 Wi-Fi 不要常开;不用的时候就删掉 `.lan` 标记文件,让它退回只监听回环。
376
+
377
+ 配对码这一层是刻意收紧的:它一次性,并且只在**本机 `127.0.0.1`** 的页面上显示 ——
378
+ 能操作这台电脑的人,才能授权一台手机。
379
+
380
+ ## 连接原理(实现路径)
381
+
382
+ ### 两条路
383
+
384
+ 远端(人在外面):
385
+
386
+ ```
387
+ 手机
388
+ └─ HTTPS https://dsh.example.com (示例域名,不是真实部署)
389
+ └─ nginx(服务器) 反向代理:TLS 终止
390
+ └─ SSH 反向隧道 ssh -R 本机主动连出,不需要公网 IP 与端口映射
391
+ └─ 本机中继 127.0.0.1:3099 校验中继令牌,按白名单转发
392
+ └─ DSH 上游 127.0.0.1:3080 /taskwatch/*(读 + /taskwatch/chat/* 写)
393
+ ```
394
+
395
+ 局域网(同一个 Wi-Fi):
396
+
397
+ ```
398
+ 手机
399
+ └─ HTTP http://192.168.1.20:3099/taskwatch 直连中继(中继绑定 0.0.0.0)
400
+ └─ 同一套 6 位配对码 → 30 天 cookie
401
+ └─ DSH 上游 127.0.0.1:3080
402
+ ```
403
+
404
+ ### 为什么用 SSH 反向隧道
405
+
406
+ 家里的机器通常在 NAT 后面:没有固定公网 IP,也没开入站端口。反向隧道是**本机主动连出**
407
+ 到服务器,服务器再顺着这条已经建立的连接把请求送回来 —— 所以不需要公网 IP,也不需要在
408
+ 路由器上做端口映射。服务器那一侧只多一个 nginx 反代负责 TLS 终止。
409
+
410
+ ### 令牌和 6 位配对码各解决什么
411
+
412
+ 两段路,两道锁,解的不是同一个问题:
413
+
414
+ - **中继令牌**(中继侧的 `RELAY_TOKEN`,或它自己生成在 `.token` 里的值)管的是
415
+ 「**服务器 → 本机中继**」这一段:证明这个请求是从被授权的隧道入口进来的。比较是定长
416
+ 的(`timingSafeEqual`),并且令牌不会被转发给上游。
417
+ - **6 位配对码**管的是「**手机 → 中继**」这一段:手机第一次访问时用它换一张 30 天、会
418
+ 滑动续期的 `HttpOnly` cookie。配对码 2 分钟有效、最多试 5 次、只能用一次,并且只在
419
+ **`127.0.0.1`** 的页面上显示。
420
+
421
+ ### 为什么中继看不出手机在哪个网络
422
+
423
+ 隧道那条路上,请求在中继眼里**来源都是 `127.0.0.1`** —— 它看到的对端是隧道,不是手机。
424
+ 所以中继分不清手机是在家里、在 4G,还是在同一个 Wi-Fi 里;同理,HTTPS 页面也没法探测
425
+ `http://` 内网地址(浏览器按混合内容拦掉,IP 主机连"可升级"都不算)。
426
+
427
+ 这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
428
+
429
+ - 胶囊上显示当前走的是哪条路(远端 / 局域网),点一下在 **自动 → 固定局域网 →
430
+ 固定远端** 之间循环,选择记在 `localStorage`。
431
+ - **自动**只用两个信号:这条路刚才通不通、你上次选了哪条。远端路上
432
+ `GET /taskwatch/upstream-health` 体检失败(隧道或 DSH 挂了)就切局域网(若可用);
433
+ 局域网路上请求失败(手机离开了 Wi-Fi)就切回远端。能用的那条路不会被主动换掉。
434
+
435
+ ### 换路为什么不重输配对码
436
+
437
+ 换路等于换了一个**源**(`https://…` ↔ `http://192.168.x.x:3099`),而浏览器的 cookie 是
438
+ **按源隔离**的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
439
+
440
+ 所以页面点切换时,先向当前这条路的中继要一张凭据:
441
+
442
+ 1. `POST /taskwatch/ticket`(带令牌)→ 中继返回另一条路的 URL,末尾带一次性 `?t=…`;
443
+ 2. 手机跳过去,中继兑换这张票:**60 秒有效、兑换即作废、重放无效**,同时给那条路种下
444
+ cookie;
445
+ 3. 于是不用重新输那 6 位配对码。
446
+
447
+ 这三个接口都在中继侧,**中继不在本仓库**:`GET /taskwatch/entries`(入口清单与当前入口)、
448
+ `POST /taskwatch/ticket`(换路凭据)、`GET /taskwatch/upstream-health`(上游体检)。
449
+ 详细的实现与部署文档在中继仓库 **`dsh-in-hand-relay`**(该仓库尚未创建,此处只写仓库名)。
450
+ 中继没升级时,页面上的胶囊不会出现,其余功能不受影响。
451
+
452
+ > 注:上面「在外网 / 手机上访问」一节描述的是只转发读路径的只读中继;实际的中继同时
453
+ > 承载 `/taskwatch/chat/*` 的写入口(见上面的路由表)和本节这三个双入口接口。中继代码
454
+ > 不在本仓库。
455
+
456
+ ### 502 排查
457
+
458
+ 看到 502 先分清它从哪一段来,三种情况的处置完全不同:
459
+
460
+ | 现象 | 断在哪 | 说明 |
461
+ |---|---|---|
462
+ | 服务器 nginx 回的 502 | 隧道那一段 | `ssh -R` 断了,或者本机中继没在跑;重新拉起隧道 / 中继 |
463
+ | 中继回的 502,响应头带 `x-taskwatch-hop: relay` | 中继 → DSH 上游 | 中继自己发的:隧道是通的,但上游 DSH 不可达(回环端口没在监听,或插件没挂载) |
464
+ | 手机上**反复**看到 502 | 手机的 service worker | v1.6.0 之前的 SW 会把那次 502 存成外壳,之后离线导航一直吃它,而服务端早已恢复 —— v1.6.0 起只缓存 2xx,并把 `CACHE` 升到 v13 清掉已存的 502 |
465
+
334
466
  ## 安全边界
335
467
 
336
468
  - **写入口等于一台终端**。一旦放行 `/taskwatch/chat/send`,能打开这个页面的人就能在
package/lib/page.html CHANGED
@@ -51,7 +51,8 @@
51
51
  抄自客户端的原值:
52
52
  蓝调中性 00 #fff · 50 #f9fafb · 60 #f5f6f7 · 75 #f1f3f5 · 100 #ebeef2
53
53
  150 #e9ecf2 · 200 #e1e5ee · 300 #cfd3d6 · 400 #adb2b8 · 500 #979da6
54
- 600 #81858c · 700 #61666b · 750 #43454a · 800 #353638 · 850 #2c2c2e
54
+ 600 #81858c · 650 #6b7076 · 700 #61666b · 750 #43454a · 800 #353638 · 850 #2c2c2e
55
+ (650 是本页新增的一档:600 压在白底上只有 3.71:1,当正文不够,压暗一档到 4.99:1)
55
56
  875 #232324 · 900 #1b1b1c · 950 #151517 · 1000 #0f1115
56
57
  品牌蓝 #4176e6(--dsw-alias-brand-primary-new-color)
57
58
  状态 绿 #22c55e · 琥珀 #f59e0b · 红 #ec1313(暗色用红 400 #f25a5a)
@@ -60,7 +61,8 @@
60
61
  /* ── 客户端静态色阶(原值照抄,一个都没改) ── */
61
62
  --nb-00:#fff; --nb-50:#f9fafb; --nb-60:#f5f6f7; --nb-75:#f1f3f5;
62
63
  --nb-100:#ebeef2; --nb-150:#e9ecf2; --nb-200:#e1e5ee; --nb-300:#cfd3d6;
63
- --nb-400:#adb2b8; --nb-500:#979da6; --nb-600:#81858c; --nb-700:#61666b;
64
+ --nb-400:#adb2b8; --nb-500:#979da6; --nb-600:#81858c; --nb-650:#6b7076;
65
+ --nb-700:#61666b;
64
66
  --nb-750:#43454a; --nb-800:#353638; --nb-850:#2c2c2e; --nb-875:#232324;
65
67
  --nb-900:#1b1b1c; --nb-950:#151517; --nb-1000:#0f1115;
66
68
  --blue-450:#4d93f8; --blue-600:#2563eb;
@@ -72,6 +74,34 @@
72
74
  --dsw-font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
73
75
  --dsw-font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
74
76
 
77
+ /* ── 本页的间距 / 圆角 / 字号尺度(改这个文件时请守这一条) ────────────────
78
+ 界面上只准出现下面这几档,不要再写 5px / 7px / 9px / 11px / 13px 这种
79
+ "看着差不多" 的数 —— 单看一处看不出来,一屏里凑齐五种就是"哪里不对"的来源。
80
+ 间距 4/8/12/16/24 · 圆角沿用客户端那五档 + 胶囊 · 字号五档。
81
+ 全页只有两处例外,各自在原地写了理由:#input / .sheet-search input 的 16px
82
+ (iOS 聚焦不缩放的下限),以及 .md-* 的内容排版(那是"读起来不晕"的尺度)。 */
83
+ --sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px;
84
+ --r-pill:999px;
85
+ --fs-1:11.5px; /* 脚注:时间戳、指标行、徽标 */
86
+ --fs-2:12.5px; /* 次要:卡片副标题、工具参数、抽屉条目说明 */
87
+ --fs-3:13.5px; /* 次级正文:状态条、工具卡、折叠按钮、输入区 */
88
+ --fs-4:15.5px; /* 正文:消息、列表条目、胶囊按钮 */
89
+ --fs-5:17px; /* 标题:抽屉标题、空态标题 */
90
+
91
+ /* ── 「能当文字用」的状态色 ────────────────────────────────────────────────
92
+ --ok / --warn 是**填充色**:绿 #22c55e 压在白底上只有 2.28:1,"完成"两个字
93
+ 和那枚勾都不合格(文字要 4.5:1、有意义图形要 3:1)。下面两个是同色相压暗后
94
+ 的文字版,只给文字和图标用;纯色块(脉搏条、圆点)仍用填充色。 */
95
+ --ok-ink:#15803d; /* 绿,白底 5.02:1 */
96
+ --warn-ink:#b45309; /* 琥珀底,压白字 5.02:1 */
97
+ --warn-ink-fg:#fff;
98
+ --bad-ink:#cc1111; /* 红,白底 5.74:1(--red-600 #ec1313 是 4.50,正卡在门槛线上) */
99
+ /* 焦点环 / 运行中描边:块外不许再出现 rgba(76,141,255,…) 字面量。 */
100
+ --ring-line:rgba(76,141,255,.5);
101
+ --ring-soft:rgba(76,141,255,.3);
102
+ --ring-glow:rgba(76,141,255,.14);
103
+ --ring-none:rgba(76,141,255,0);
104
+
75
105
  /* ── 语义令牌 · 亮色(默认) ── */
76
106
  --dsw-alias-bg-base:var(--nb-00);
77
107
  --dsw-alias-bg-layer-1:var(--nb-00);
@@ -109,7 +139,11 @@
109
139
  --line2:var(--dsw-alias-border-l2);
110
140
  --fg:var(--dsw-alias-label-primary);
111
141
  --dim:var(--dsw-alias-label-secondary);
112
- --faint:var(--dsw-alias-label-tertiary);
142
+ /* 全页最淡的那一档文字(时间戳、指标行、卡片副标题):
143
+ 客户端的三级文字 #81858c 压在白底上只有 3.71:1,当正文不合格,压暗一档到 4.99:1。
144
+ ⚠ 只改这个**别名**,不动上面的 --dsw-alias-label-tertiary —— 那一层必须与
145
+ lib/tokens.css 逐条一致(scripts/check-bundle.mjs 会逐条比)。 */
146
+ --faint:var(--nb-650);
113
147
  --accent:var(--whale-ink);
114
148
  --accent-fg:var(--whale-paper);
115
149
  --link:var(--dsw-alias-brand-primary-new-color);
@@ -120,6 +154,9 @@
120
154
  --bubble-ai:var(--dsw-alias-bg-layer-1);
121
155
  --safe-b:env(safe-area-inset-bottom,0px);
122
156
  --safe-t:env(safe-area-inset-top,0px);
157
+ /* 横屏时挖孔在左右两侧:viewport-fit=cover 之后内容会一路铺到屏幕边缘 */
158
+ --safe-l:env(safe-area-inset-left,0px);
159
+ --safe-r:env(safe-area-inset-right,0px);
123
160
  }
124
161
  /* 暗色:只有这一份。由 <head> 里的小脚本按系统偏好设 data-theme,手动切换覆盖它。 */
125
162
  :root[data-theme='dark']{
@@ -130,6 +167,9 @@
130
167
  --dsw-alias-label-primary:var(--nb-50);
131
168
  --dsw-alias-label-secondary:var(--nb-300);
132
169
  --dsw-alias-label-tertiary:var(--nb-400);
170
+ /* --faint 是页面别名、不随 --dsw-alias-* 走,暗色必须显式给一遍:
171
+ 漏了的话暗色会继承亮色那支 #6b7076,在暗底上只有 2.9:1。 */
172
+ --faint:var(--nb-400);
133
173
  --dsw-alias-border-l1:#ffffff0f;
134
174
  --dsw-alias-border-l2:#ffffff1f;
135
175
  /* 包内暗色对品牌蓝有**真实覆盖** #5686fe(= --dsw-static-deepseek-450),
@@ -141,6 +181,16 @@
141
181
  而 #e6eaf0 正是同一个图标里的另一个颜色,成对使用不会跑味。 */
142
182
  --accent:var(--whale-paper);
143
183
  --accent-fg:var(--whale-ink);
184
+ /* 文字版状态色:暗底上反过来用**亮**的那一支 */
185
+ --ok-ink:var(--green-500);
186
+ --warn-ink:var(--amber-500);
187
+ --warn-ink-fg:#20160a;
188
+ --bad-ink:#ff8080; /* 亮一档的红:#f25a5a 在 --panel2 上只有 4.24 */
189
+ /* 暗色品牌蓝 #5686fe 对应的焦点环 */
190
+ --ring-line:rgba(86,134,254,.55);
191
+ --ring-soft:rgba(86,134,254,.32);
192
+ --ring-glow:rgba(86,134,254,.18);
193
+ --ring-none:rgba(86,134,254,0);
144
194
  }
145
195
  *{box-sizing:border-box}
146
196
  html,body{height:100%;margin:0}
@@ -182,24 +232,26 @@ button:disabled{cursor:default}
182
232
  header{
183
233
  flex:0 0 auto;background:var(--panel);border-bottom:1px solid var(--line);
184
234
  padding-top:var(--safe-t);
235
+ /* 横屏时挖孔在左右两侧,viewport-fit=cover 之后内容会钻到它下面 */
236
+ padding-left:var(--safe-l);padding-right:var(--safe-r);
185
237
  }
186
- .hrow{display:flex;align-items:center;gap:6px;padding:6px 12px 5px}
238
+ .hrow{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-3)}
187
239
  /* 左上角是「对话」入口(豆包的布局):一个带文字的胶囊,而不是光秃秃的图标 ——
188
240
  图标没有文字时,老人和第一次用的人都得先点一下才知道它是干什么的。 */
189
241
  .pillbtn{
190
- flex:0 0 auto;display:flex;align-items:center;gap:6px;min-height:44px;padding:0 12px;
191
- border-radius:22px;background:var(--panel2);border:1px solid var(--line);
192
- font-size:14px;color:var(--fg);
242
+ flex:0 0 auto;display:flex;align-items:center;gap:var(--sp-2);min-height:44px;padding:0 var(--sp-3);
243
+ border-radius:var(--r-pill);background:var(--panel2);border:1px solid var(--line);
244
+ font-size:var(--fs-3);color:var(--fg);
193
245
  }
194
246
  .pillbtn:active{background:var(--panel)}
195
247
  .pillbtn svg{width:18px;height:18px;display:block;stroke:currentColor;fill:none;
196
248
  stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
197
249
  .sess{
198
250
  flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:1px;
199
- text-align:center;padding:2px 2px;border-radius:9px;
251
+ text-align:center;padding:2px;border-radius:var(--dsw-radius-sm);
200
252
  }
201
- .sess .t{font-size:15px;font-weight:600;max-width:52vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:-.01em}
202
- .sess .s{font-size:11.5px;color:var(--faint)}
253
+ .sess .t{font-size:var(--fs-4);font-weight:600;max-width:52vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:-.01em}
254
+ .sess .s{font-size:var(--fs-1);color:var(--faint)}
203
255
  /* 触碰区 ≥44×44 —— iOS/Android 的硬性下限,检索库里标为 Critical。
204
256
  图标本身 20px,靠按钮本身撑到 44;相邻按钮之间留够间距。 */
205
257
  .iconbtn{
@@ -213,12 +265,12 @@ header{
213
265
 
214
266
  /* 状态条:一句话说清现在什么情况 */
215
267
  #status{
216
- display:flex;align-items:center;gap:8px;
217
- margin:0 12px 8px;padding:7px 10px;
218
- background:var(--panel2);border:1px solid var(--line);border-radius:999px;
219
- font-size:13px;color:var(--dim);text-align:left;width:calc(100% - 24px);
268
+ display:flex;align-items:center;gap:var(--sp-2);
269
+ margin:0 var(--sp-3) var(--sp-2);padding:0 var(--sp-3);min-height:44px;
270
+ background:var(--panel2);border:1px solid var(--line);border-radius:var(--r-pill);
271
+ font-size:var(--fs-3);color:var(--dim);text-align:left;width:calc(100% - var(--sp-5));
220
272
  }
221
- #status .srow{display:flex;align-items:center;gap:8px;min-width:0;flex:1}
273
+ #status .srow{display:flex;align-items:center;gap:var(--sp-2);min-width:0;flex:1}
222
274
 
223
275
  /* 脉搏条 —— 这一页的签名元素。
224
276
  一格 = 一个会话:在跑的呼吸、空闲的暗;只要「有事等你」整条转琥珀。
@@ -232,20 +284,24 @@ header{
232
284
  .pulserow .cell:nth-child(3n){animation-delay:.8s}
233
285
  #status.warn .pulserow .cell{background:var(--warn);animation:none}
234
286
  #status.bad .pulserow .cell{background:var(--bad);animation:none}
235
- .pulselegend{margin-left:auto;flex:0 0 auto;color:var(--faint);font-size:11px;max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
287
+ .pulselegend{margin-left:auto;flex:0 0 auto;color:var(--faint);font-size:var(--fs-1);max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
236
288
  #status .dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:var(--ok)}
237
289
  #status.busy .dot{background:var(--accent);animation:pulse 1.1s ease-in-out infinite}
238
290
  #status.warn .dot{background:var(--warn)}
239
291
  #status.bad .dot{background:var(--bad)}
240
292
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
241
293
  #status .txt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
242
- #status .chev{flex:0 0 auto;color:var(--faint);font-size:12px}
294
+ #status .chev{flex:0 0 auto;color:var(--faint);font-size:var(--fs-2)}
243
295
 
244
296
  /* 工作区与模型是官方 Harness 工作台语义的核心,但在手机上只占一行。 */
245
- #contextLine{display:flex;justify-content:center;padding:0 12px 8px}
246
- .modelchip{display:inline-flex;align-items:center;gap:6px;min-height:28px;max-width:100%;padding:0 10px;
247
- border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--dim);font-size:11.5px;
248
- overflow:hidden}
297
+ #contextLine{display:flex;justify-content:center;padding:0 var(--sp-3) var(--sp-2)}
298
+ /* 命中区 ≥44×44:胶囊里的字很小,靠 min-height 把**元素本身**撑到 44(不是靠
299
+ 伪元素偷偷放大 —— 那样 getBoundingClientRect 量出来还是小的)。状态条、模型
300
+ 胶囊、右上角的路由胶囊都是这一条。 */
301
+ .modelchip{display:inline-flex;align-items:center;justify-content:center;gap:var(--sp-2);min-height:44px;
302
+ max-width:100%;padding:0 var(--sp-3);
303
+ border:1px solid var(--line);border-radius:var(--r-pill);background:transparent;color:var(--dim);
304
+ font-size:var(--fs-2);overflow:hidden}
249
305
  .modelchip:active{background:var(--panel2);color:var(--fg)}
250
306
  .modelchip .ws,.modelchip .model{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
251
307
  .modelchip .ws{color:var(--faint);max-width:36vw}
@@ -256,8 +312,9 @@ header{
256
312
  /* ── 消息区 ─────────────────────────────────────────────────────────── */
257
313
  main{
258
314
  flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;
259
- -webkit-overflow-scrolling:touch;padding:14px 12px 6px;
260
- display:flex;flex-direction:column;gap:10px;
315
+ -webkit-overflow-scrolling:touch;
316
+ padding:var(--sp-3) calc(var(--sp-3) + var(--safe-r)) var(--sp-2) calc(var(--sp-3) + var(--safe-l));
317
+ display:flex;flex-direction:column;gap:var(--sp-3);
261
318
  }
262
319
  .row{display:flex;flex-direction:column;max-width:100%}
263
320
  .row.me{align-items:flex-end}
@@ -270,8 +327,8 @@ main{
270
327
  所以 ai 一侧放开到整宽,层级改由字重与留白承担 —— 和客户端会话页一致。 */
271
328
  .ai .bubwrap{max-width:100%}
272
329
  .bub{
273
- max-width:100%;padding:10px 13px;border-radius:var(--dsw-radius-lg);white-space:pre-wrap;
274
- word-break:break-word;font-size:15.5px;overflow-wrap:anywhere;
330
+ max-width:100%;padding:var(--sp-3);border-radius:var(--dsw-radius-lg);white-space:pre-wrap;
331
+ word-break:break-word;font-size:var(--fs-4);overflow-wrap:anywhere;
275
332
  }
276
333
  /* 用户消息保留轻气泡(要和正文区分开),但不再是整块高饱和蓝:
277
334
  底色取强调色的 15% 混合、正文用前景色 —— 对比度比原来的"白字压蓝"更高。
@@ -283,76 +340,81 @@ main{
283
340
  border-radius:var(--dsw-radius-lg) var(--dsw-radius-lg) var(--dsw-radius-xs) var(--dsw-radius-lg);
284
341
  }
285
342
  .ai .bub{background:transparent;border:0;padding:0 1px;border-radius:0}
286
- .meta{font-size:11px;color:var(--faint);margin:3px 4px 0;font-variant-numeric:tabular-nums;line-height:1.35}
343
+ .meta{font-size:var(--fs-1);color:var(--faint);margin:var(--sp-1) var(--sp-1) 0;font-variant-numeric:tabular-nums;line-height:1.35}
287
344
 
288
345
  /* 工具卡片:把内部工具名翻成人话,这是"看得懂进度"的关键 */
289
346
  .tool{
290
- max-width:90%;margin-top:5px;background:var(--panel);border:1px solid var(--line);
291
- border-radius:12px;padding:9px 12px;font-size:13.5px;
347
+ /* 整宽:助手的正文本来就是整宽,工具卡再收 10% 就成了两套边距 */
348
+ max-width:100%;margin-top:var(--sp-1);background:var(--panel);border:1px solid var(--line);
349
+ border-radius:var(--dsw-radius-md);padding:var(--sp-2) var(--sp-3);font-size:var(--fs-3);
292
350
  }
293
- .tool .h{display:flex;align-items:center;gap:8px}
351
+ .tool .h{display:flex;align-items:center;gap:var(--sp-2)}
294
352
  .tool .h .n{font-weight:600}
295
- .tool .h .st{margin-left:auto;font-size:12px;color:var(--faint);flex:0 0 auto}
296
- .tool.run{border-color:rgba(76,141,255,.5)}
353
+ .tool .h .st{margin-left:auto;font-size:var(--fs-2);color:var(--faint);flex:0 0 auto}
354
+ .tool.run{border-color:var(--ring-line)}
297
355
  .tool.run .sp{
298
356
  flex:0 0 auto;width:12px;height:12px;border-radius:50%;
299
- border:2px solid rgba(76,141,255,.3);
357
+ border:2px solid var(--ring-soft);
300
358
  border-top-color:var(--accent);animation:spin .7s linear infinite;
301
359
  }
302
360
  @keyframes spin{to{transform:rotate(360deg)}}
303
- .tool.ok .h .st{color:var(--ok)}
304
- .tool.no .h .st{color:var(--bad)}
361
+ /* 「完成」既是文字也是那枚勾:--ok 那个绿压在白底上只有 2.28:1,两处都得换成文字版 */
362
+ .tool.ok .h .st{color:var(--ok-ink)}
363
+ .tool.no .h .st{color:var(--bad-ink)}
305
364
  /* 状态图标:同一套描边风格、同一个尺寸令牌,filled/outline 不混用。 */
306
- .tool .ti{flex:0 0 auto;display:grid;place-items:center;color:var(--ok)}
307
- .tool.no .ti{color:var(--bad)}
365
+ .tool .ti{flex:0 0 auto;display:grid;place-items:center;color:var(--ok-ink)}
366
+ .tool.no .ti{color:var(--bad-ink)}
308
367
  .tool .ti svg{width:16px;height:16px;display:block;stroke:currentColor;fill:none;
309
368
  stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
310
- .tool .a{margin-top:4px;color:var(--dim);font-size:12.5px;font-family:ui-monospace,Menlo,Consolas,monospace;
369
+ .tool .a{margin-top:var(--sp-1);color:var(--dim);font-size:var(--fs-2);font-family:var(--dsw-font-mono);
311
370
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
312
371
 
313
372
  /* 空状态:第一眼必须知道"这是干什么的" */
314
- .empty{margin:auto 0;padding:20px 6px;text-align:center}
315
- .empty .brand{width:64px;height:64px;border-radius:16px;margin:0 auto 14px;display:block}
316
- .empty h2{margin:0 0 8px;font-size:19px}
317
- .empty p{margin:0 0 18px;color:var(--dim);font-size:14.5px;line-height:1.65}
318
- .chips{display:flex;flex-direction:column;gap:9px}
373
+ .empty{margin:auto 0;padding:var(--sp-5) var(--sp-2);text-align:center}
374
+ .empty .brand{width:64px;height:64px;border-radius:var(--dsw-radius-lg);margin:0 auto var(--sp-4);display:block}
375
+ .empty h2{margin:0 0 var(--sp-2);font-size:var(--fs-5)}
376
+ .empty p{margin:0 0 var(--sp-4);color:var(--dim);font-size:var(--fs-4);line-height:1.65}
377
+ .chips{display:flex;flex-direction:column;gap:var(--sp-2)}
319
378
  .chip{
320
- background:var(--panel);border:1px solid var(--line);border-radius:13px;
321
- padding:12px 14px;font-size:15px;text-align:left;
379
+ background:var(--panel);border:1px solid var(--line);border-radius:var(--dsw-radius-md);
380
+ padding:var(--sp-3) var(--sp-4);font-size:var(--fs-4);text-align:left;
322
381
  }
323
382
  .chip:active{background:var(--panel2)}
324
383
 
325
- .note{font-size:12.5px;color:var(--faint);text-align:center;padding:6px 16px}
384
+ .note{font-size:var(--fs-2);color:var(--faint);text-align:center;padding:var(--sp-2) var(--sp-4)}
326
385
 
327
386
  /* ── 输入区 ─────────────────────────────────────────────────────────── */
328
387
  footer{
329
388
  flex:0 0 auto;background:var(--panel);border-top:1px solid var(--line);
330
- padding:9px 12px calc(9px + var(--safe-b));
389
+ padding:var(--sp-2) calc(var(--sp-3) + var(--safe-r)) calc(var(--sp-2) + var(--safe-b)) calc(var(--sp-3) + var(--safe-l));
331
390
  }
332
- .attachment-tray{display:flex;gap:7px;overflow-x:auto;padding:0 2px 8px;
391
+ .attachment-tray{display:flex;gap:var(--sp-2);overflow-x:auto;padding:0 var(--sp-1) var(--sp-2);
333
392
  -webkit-overflow-scrolling:touch}
334
393
  .attachment-tray[hidden]{display:none}
335
- .attach-chip{position:relative;display:flex;align-items:center;gap:7px;flex:0 0 auto;min-width:0;
336
- max-width:240px;min-height:48px;padding:5px 46px 5px 7px;border:1px solid var(--line);
337
- border-radius:12px;background:var(--panel2);color:var(--fg)}
338
- .attach-chip .thumb{width:32px;height:32px;flex:0 0 32px;object-fit:cover;border-radius:7px;background:var(--panel)}
339
- .attach-chip .kind{width:32px;height:32px;flex:0 0 32px;display:grid;place-items:center;border-radius:7px;
394
+ .attach-chip{position:relative;display:flex;align-items:center;gap:var(--sp-2);flex:0 0 auto;min-width:0;
395
+ max-width:240px;min-height:48px;padding:var(--sp-1) 48px var(--sp-1) var(--sp-2);
396
+ border:1px solid var(--line);border-radius:var(--dsw-radius-md);background:var(--panel2);color:var(--fg)}
397
+ .attach-chip .thumb{width:32px;height:32px;flex:0 0 32px;object-fit:cover;border-radius:var(--dsw-radius-sm);background:var(--panel)}
398
+ /* 格式徽标(PNG / JSON / WEBP…)是**图形标签**不是正文:32px 的方框塞不下 11.5px
399
+ 的五字母组合,所以这两个徽标字号不参与上面的界面尺度。 */
400
+ .attach-chip .kind{width:32px;height:32px;flex:0 0 32px;display:grid;place-items:center;border-radius:var(--dsw-radius-sm);
340
401
  background:var(--panel);color:var(--dim);font-size:10px;font-weight:700}
341
- .attach-chip .name{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px}
342
- .attach-chip .state{display:block;margin-top:2px;color:var(--faint);font-size:10.5px}
343
- .attach-chip .remove{position:absolute;right:0;top:1px;width:44px;height:44px;display:grid;place-items:center;
344
- border-radius:8px;color:var(--dim)}
402
+ .attach-chip .name{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--fs-2)}
403
+ .attach-chip .state{display:block;margin-top:2px;color:var(--faint);font-size:var(--fs-1)}
404
+ .attach-chip .remove{position:absolute;right:0;top:0;width:44px;height:44px;display:grid;place-items:center;
405
+ border-radius:var(--dsw-radius-sm);color:var(--dim)}
345
406
  .attach-chip .remove svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round}
346
407
  .attach-chip.bad{border-color:var(--bad)}
347
- .attach-chip .retry{min-height:32px;color:var(--accent);font-size:12px;text-align:left}
348
- .attachment-notice{padding:0 3px 8px;font-size:12px;line-height:1.5;color:var(--dim)}
408
+ /* 重试也是可点元素,44 是底线(原来是 32) */
409
+ .attach-chip .retry{min-height:44px;color:var(--accent);font-size:var(--fs-2);text-align:left}
410
+ .attachment-notice{padding:0 var(--sp-1) var(--sp-2);font-size:var(--fs-2);line-height:1.5;color:var(--dim)}
349
411
  .attachment-notice:empty{display:none}
350
- .attach-menu{display:flex;gap:8px;padding:0 16px 8px}
351
- .attach-menu button{flex:1;min-height:76px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);
352
- text-align:left;padding:12px 14px;font-weight:600}
353
- .attach-menu small{display:block;margin-top:5px;font-size:12px;font-weight:400;color:var(--dim)}
412
+ .attach-menu{display:flex;gap:var(--sp-2);padding:0 var(--sp-4) var(--sp-2)}
413
+ .attach-menu button{flex:1;min-height:76px;border:1px solid var(--line);border-radius:var(--dsw-radius-md);
414
+ background:var(--panel2);text-align:left;padding:var(--sp-3) var(--sp-4);font-weight:600}
415
+ .attach-menu small{display:block;margin-top:var(--sp-1);font-size:var(--fs-2);font-weight:400;color:var(--dim)}
354
416
  #input{min-width:0}
355
- .composer{display:flex;align-items:flex-end;gap:8px}
417
+ .composer{display:flex;align-items:flex-end;gap:var(--sp-2)}
356
418
  #attach{flex:0 0 auto;width:44px;height:44px;border:1px solid var(--line);border-radius:var(--dsw-radius-md);
357
419
  background:var(--panel2);color:var(--dim);display:grid;place-items:center}
358
420
  #attach svg{width:20px;height:20px;display:block;stroke:currentColor;fill:none;stroke-width:1.9;
@@ -360,15 +422,17 @@ footer{
360
422
  #attach:active{background:var(--panel)}
361
423
  /* 指标行:数字用等宽数字对齐,跳数时不会左右抖。 */
362
424
  .stats{
363
- display:flex;flex-wrap:wrap;gap:3px 12px;padding:0 3px 9px;
364
- font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums;
425
+ display:flex;flex-wrap:wrap;gap:var(--sp-1) var(--sp-3);padding:0 var(--sp-1) var(--sp-2);
426
+ font-size:var(--fs-1);color:var(--faint);font-variant-numeric:tabular-nums;
365
427
  }
366
428
  #input{
367
429
  flex:1;min-height:44px;max-height:132px;resize:none;overflow-y:auto;
368
430
  background:var(--panel2);color:var(--fg);border:1px solid var(--line);
369
- border-radius:14px;padding:11px 13px;font-size:16px;line-height:1.5;
431
+ border-radius:var(--dsw-radius-md);padding:var(--sp-2) var(--sp-3);
432
+ /* 16px 不是随便定的:iOS 上聚焦时小于 16px 会自动把整页放大一下,改小就抖 */
433
+ font-size:16px;line-height:1.5;
370
434
  }
371
- #input:focus{outline:none;border-color:rgba(76,141,255,.55)}
435
+ #input:focus{outline:none;border-color:var(--ring-line)}
372
436
  #send{
373
437
  flex:0 0 auto;width:44px;height:44px;border-radius:var(--dsw-radius-md);background:var(--accent);
374
438
  color:var(--accent-fg);display:grid;place-items:center;
@@ -388,63 +452,74 @@ footer{
388
452
  #sheet[hidden]{display:none}
389
453
  .card{
390
454
  width:100%;max-height:82vh;overflow-y:auto;background:var(--panel);
391
- border-radius:18px 18px 0 0;padding:8px 0 calc(12px + var(--safe-b));
455
+ border-radius:var(--dsw-radius-lg) var(--dsw-radius-lg) 0 0;
456
+ padding:var(--sp-2) 0 calc(var(--sp-3) + var(--safe-b));
392
457
  border-top:1px solid var(--line);
393
458
  }
394
- .card .grab{width:38px;height:4px;border-radius:99px;background:var(--line);margin:6px auto 10px}
395
- .sheet-head{display:flex;align-items:center;gap:10px;margin:2px 16px 10px}
396
- .sheet-head h3{flex:1;margin:0;font-size:16px;color:var(--fg);font-weight:600;letter-spacing:0}
397
- .sheet-head .count{margin-left:5px;font-size:12px;color:var(--faint);font-weight:400}
398
- .sheet-close{display:grid;place-items:center;width:36px;height:36px;flex:0 0 36px;border:1px solid var(--line);
459
+ .card .grab{width:38px;height:4px;border-radius:var(--r-pill);background:var(--line);margin:var(--sp-2) auto var(--sp-3)}
460
+ .sheet-head{display:flex;align-items:center;gap:var(--sp-3);margin:var(--sp-1) var(--sp-4) var(--sp-3)}
461
+ .sheet-head h3{flex:1;margin:0;font-size:var(--fs-5);color:var(--fg);font-weight:600;letter-spacing:0}
462
+ .sheet-head .count{margin-left:var(--sp-1);font-size:var(--fs-2);color:var(--faint);font-weight:400}
463
+ /* 关闭键 36 → 44:抽屉里最该一次点中的就是它 */
464
+ .sheet-close{display:grid;place-items:center;width:44px;height:44px;flex:0 0 44px;border:1px solid var(--line);
399
465
  border-radius:50%;color:var(--dim);background:transparent}
400
466
  .sheet-close:active{background:var(--panel2)}
401
467
  .sheet-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round}
402
- .sheet-search{display:flex;align-items:center;gap:8px;margin:0 16px 12px;padding:0 11px;min-height:42px;
403
- border:1px solid var(--line);border-radius:12px;background:var(--panel2);color:var(--faint)}
468
+ .sheet-search{display:flex;align-items:center;gap:var(--sp-2);margin:0 var(--sp-4) var(--sp-3);
469
+ padding:0 var(--sp-3);min-height:44px;
470
+ border:1px solid var(--line);border-radius:var(--dsw-radius-md);background:var(--panel2);color:var(--faint)}
404
471
  .sheet-search svg{width:16px;height:16px;flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round}
405
- .sheet-search input{width:100%;min-width:0;background:transparent;color:var(--fg);font:inherit;font-size:14px;outline:0}
472
+ /* 同样 16px:这是输入框,iOS 聚焦时小于 16px 会放大整页。
473
+ 还要 min-height:44:外层 44 的命中区不算数 —— 点在外层空白上并不会聚焦 */
474
+ .sheet-search input{width:100%;min-width:0;min-height:44px;background:transparent;color:var(--fg);font:inherit;font-size:16px;outline:0}
406
475
  .sheet-search input::placeholder{color:var(--faint)}
407
- .session-group{margin:0 0 10px}
408
- .session-group .group-label{margin:0 16px 4px;font-size:11.5px;color:var(--faint);font-weight:600;letter-spacing:.04em}
409
- .card h3{margin:14px 16px 8px;font-size:12px;color:var(--faint);font-weight:600;letter-spacing:.04em}
476
+ .session-group{margin:0 0 var(--sp-3)}
477
+ .session-group .group-label{margin:0 var(--sp-4) var(--sp-1);font-size:var(--fs-1);color:var(--faint);font-weight:600;letter-spacing:.04em}
478
+ .card h3{margin:var(--sp-4) var(--sp-4) var(--sp-2);font-size:var(--fs-2);color:var(--faint);font-weight:600;letter-spacing:.04em}
410
479
  .item{
411
- display:flex;align-items:center;gap:10px;padding:12px 16px;font-size:15px;
480
+ display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);font-size:var(--fs-4);
412
481
  text-align:left;width:100%;
413
482
  }
414
483
  .item:active{background:var(--panel2)}
415
484
  .item .g{flex:1;min-width:0}
416
485
  .item .g .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
417
- .item .g .s{font-size:12px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
418
- .item .r{flex:0 0 auto;font-size:12px;color:var(--faint)}
486
+ .item .g .s{font-size:var(--fs-2);color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
487
+ .item .r{flex:0 0 auto;font-size:var(--fs-2);color:var(--faint)}
419
488
  .item.on .g .t{color:var(--accent);font-weight:600}
420
- .kv{display:flex;gap:10px;padding:9px 16px;font-size:14px;border-top:1px solid var(--line)}
489
+ .kv{display:flex;gap:var(--sp-3);padding:var(--sp-2) var(--sp-4);font-size:var(--fs-3);border-top:1px solid var(--line)}
421
490
  .kv .k{flex:0 0 88px;color:var(--dim)}
422
491
  .kv .v{flex:1;min-width:0;word-break:break-word}
423
- .detail-section{border-top:1px solid var(--line);margin-top:12px}
424
- .detail-section summary{min-height:48px;padding:14px 16px;font-size:13px;font-weight:600;color:var(--dim);cursor:pointer}
425
- .detail-section .kv{font-size:13px}
492
+ .detail-section{border-top:1px solid var(--line);margin-top:var(--sp-3)}
493
+ .detail-section summary{min-height:48px;padding:var(--sp-4);font-size:var(--fs-3);font-weight:600;color:var(--dim);cursor:pointer}
494
+ .detail-section .kv{font-size:var(--fs-2)}
426
495
  .detail-section .v{white-space:pre-wrap}
427
- .pill{display:flex;align-items:center;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:12px;margin:0 16px 8px;font-size:15px;width:calc(100% - 32px);text-align:left}
496
+ .pill{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-3) var(--sp-4);border:1px solid var(--line);
497
+ border-radius:var(--dsw-radius-md);margin:0 var(--sp-4) var(--sp-2);font-size:var(--fs-4);
498
+ width:calc(100% - var(--sp-4) * 2);text-align:left}
428
499
  .pill:active{background:var(--panel2)}
429
500
  .pill .label{flex:1;min-width:0}
430
- .pill .hint{flex:0 0 auto;color:var(--faint);font-size:12px;white-space:nowrap}
431
- .pending-card{display:flex;gap:10px;margin:0 16px 8px;padding:11px 12px;border:1px solid var(--line);
432
- border-radius:12px;background:var(--panel2)}
433
- .pending-card .badge{flex:0 0 auto;align-self:flex-start;padding:3px 7px;border-radius:999px;background:var(--warn);
434
- color:var(--accent-fg);font-size:11px;line-height:1.2}
501
+ .pill .hint{flex:0 0 auto;color:var(--faint);font-size:var(--fs-2);white-space:nowrap}
502
+ .pending-card{display:flex;gap:var(--sp-3);margin:0 var(--sp-4) var(--sp-2);padding:var(--sp-3);
503
+ border:1px solid var(--line);border-radius:var(--dsw-radius-md);background:var(--panel2)}
504
+ /* 待批准徽标:原来琥珀底 + 近白字只有 2.2:1,换成压暗的琥珀底 + 白字(5.0:1) */
505
+ .pending-card .badge{flex:0 0 auto;align-self:flex-start;padding:var(--sp-1) var(--sp-2);
506
+ border-radius:var(--r-pill);background:var(--warn-ink);color:var(--warn-ink-fg);
507
+ font-size:var(--fs-1);line-height:1.2}
435
508
  .pending-card .body{flex:1;min-width:0}
436
- .pending-card .title{display:block;font-size:14px;font-weight:600;line-height:1.45;overflow-wrap:anywhere}
437
- .pending-card .summary{display:block;margin-top:3px;font-size:12.5px;color:var(--dim);line-height:1.5;overflow-wrap:anywhere}
509
+ .pending-card .title{display:block;font-size:var(--fs-4);font-weight:600;line-height:1.45;overflow-wrap:anywhere}
510
+ .pending-card .summary{display:block;margin-top:var(--sp-1);font-size:var(--fs-2);color:var(--dim);line-height:1.5;overflow-wrap:anywhere}
438
511
 
439
512
  /* 小屏把次要主题控制移进会话抽屉,给标题和任务状态留下可读宽度。 */
440
513
  @media (max-width:380px){
514
+ /* 小屏再收一档:字号回到 --fs-1/--fs-2,内边距回到 --sp-1/--sp-2;
515
+ min-height 仍然是 44,小屏也不许把命中区一起收掉。 */
441
516
  #themeBtn{display:none}
442
- .sess .t{font-size:14px}
443
- .sess .s{font-size:10.5px}
444
- #status{gap:6px;padding:6px 8px;font-size:12px}
517
+ .sess .t{font-size:var(--fs-3)}
518
+ .sess .s{font-size:var(--fs-1)}
519
+ #status{gap:var(--sp-1);padding:0 var(--sp-2);font-size:var(--fs-2)}
445
520
  .pulserow{width:30px;flex-basis:30px;gap:2px}
446
521
  #status .pulselegend{display:none}
447
- #status .chev{font-size:11px}
522
+ #status .chev{font-size:var(--fs-1)}
448
523
  }
449
524
 
450
525
  /* ══ 溢出与折叠(内容放不下时的八条策略) ══════════════════════════════
@@ -458,8 +533,8 @@ footer{
458
533
  -webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 44px),transparent);
459
534
  mask-image:linear-gradient(180deg,#000 calc(100% - 44px),transparent)}
460
535
  .bub.fold.open{max-height:none;-webkit-mask-image:none;mask-image:none}
461
- .foldbtn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;
462
- min-height:44px;margin-top:2px;color:var(--accent);font-size:13.5px;
536
+ .foldbtn{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);width:100%;
537
+ min-height:44px;margin-top:var(--sp-1);color:var(--accent);font-size:var(--fs-3);
463
538
  font-weight:500;border-radius:var(--dsw-radius-sm)}
464
539
  .foldbtn:active{background:var(--panel2)}
465
540
  .foldbtn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;
@@ -467,29 +542,32 @@ footer{
467
542
  .foldbtn[aria-expanded='true'] svg{transform:rotate(180deg)}
468
543
 
469
544
  /* 2. 代码块:内部自己横竖滚,绝不撑破气泡;右上角可复制。 */
470
- .code{margin:4px 0;border:1px solid var(--line);border-radius:var(--dsw-radius-md);
545
+ .code{margin:var(--sp-1) 0;border:1px solid var(--line);border-radius:var(--dsw-radius-md);
471
546
  overflow:hidden;background:var(--panel2)}
472
- .codebar{display:flex;align-items:center;gap:8px;padding:4px 6px 4px 10px;
473
- border-bottom:1px solid var(--line);font-size:12px;color:var(--faint)}
547
+ .codebar{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);
548
+ border-bottom:1px solid var(--line);font-size:var(--fs-2);color:var(--faint)}
474
549
  .codebar .lang{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
475
- .codebar .scrollhint{flex:0 0 auto;color:var(--faint);font-size:11px;letter-spacing:.02em}
476
- .codebar button{display:flex;align-items:center;gap:5px;min-height:36px;padding:0 8px;
477
- color:var(--dim);border-radius:var(--dsw-radius-sm);font-size:12px}
550
+ .codebar .scrollhint{flex:0 0 auto;color:var(--faint);font-size:var(--fs-1);letter-spacing:.02em}
551
+ /* 代码块右上角的复制键原来是 40×36,撑到 44×44 */
552
+ .codebar button{display:flex;align-items:center;justify-content:center;gap:var(--sp-1);
553
+ min-height:44px;min-width:44px;padding:0 var(--sp-3);
554
+ color:var(--dim);border-radius:var(--dsw-radius-sm);font-size:var(--fs-2)}
478
555
  .codebar button:active{background:var(--panel)}
479
556
  .codebar svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.9;
480
557
  stroke-linecap:round;stroke-linejoin:round}
481
- .code pre{margin:0;padding:10px 12px;max-height:20rem;overflow:auto;
482
- font:12.5px/1.6 var(--dsw-font-mono);color:var(--fg);white-space:pre;
558
+ .code pre{margin:0;padding:var(--sp-3);max-height:20rem;overflow:auto;
559
+ font:var(--fs-2)/1.6 var(--dsw-font-mono);color:var(--fg);white-space:pre;
483
560
  -webkit-overflow-scrolling:touch}
484
561
 
485
562
  /* 3. 工具卡里的长命令:原来是单行省略号,长命令等于看不见。
486
563
  改成默认收 3 行、点开看全、并给复制按钮。 */
487
564
  .tool .a{white-space:pre-wrap;overflow-wrap:anywhere;max-height:4.2rem;overflow:hidden}
488
565
  .tool .a.open{max-height:none}
489
- .tool .a code{font:12.5px/1.6 var(--dsw-font-mono);white-space:pre-wrap;overflow-wrap:anywhere}
490
- .tool .acts{display:flex;align-items:center;gap:18px;margin-top:2px}
491
- .tool .more{min-height:32px;color:var(--accent);font-size:12.5px;
492
- display:flex;align-items:center;gap:5px}
566
+ .tool .a code{font:var(--fs-2)/1.6 var(--dsw-font-mono);white-space:pre-wrap;overflow-wrap:anywhere}
567
+ .tool .acts{display:flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-1)}
568
+ /* 「复制 / 展开完整内容」原来只有 25×32 —— 手机上是纯误点区,撑到 44×44 */
569
+ .tool .more{min-height:44px;min-width:44px;justify-content:center;color:var(--accent);
570
+ font-size:var(--fs-2);display:flex;align-items:center;gap:var(--sp-1)}
493
571
  .tool .more:active{opacity:.7}
494
572
 
495
573
  /* 4. 流式输出中:尾部渐隐,表示"还在往下长"(客户端也是用 mask-image 做的)。 */
@@ -501,10 +579,10 @@ footer{
501
579
  原先是写死的 78px,后果是浮标出现时正好压住指标行中间那几个字;而指标行折成
502
580
  一行还是两行随内容变,写死就一定会在某些屏/某些数据下压住。拿不到 --foot-h 时
503
581
  (JS 没跑)回落成旧值,位置与以前一致,不会更差。 */
504
- #tobottom{position:fixed;right:14px;bottom:calc(var(--foot-h) + 10px);z-index:30;
505
- display:none;align-items:center;gap:6px;min-height:44px;padding:0 14px;
506
- border-radius:999px;background:var(--panel);border:1px solid var(--line2);
507
- color:var(--fg);font-size:13.5px;box-shadow:0 6px 20px rgba(0,0,0,.18);
582
+ #tobottom{position:fixed;right:calc(14px + var(--safe-r));bottom:calc(var(--foot-h) + var(--sp-3));z-index:30;
583
+ display:none;align-items:center;gap:var(--sp-2);min-height:44px;padding:0 var(--sp-4);
584
+ border-radius:var(--r-pill);background:var(--panel);border:1px solid var(--line2);
585
+ color:var(--fg);font-size:var(--fs-3);box-shadow:0 6px 20px rgba(0,0,0,.18);
508
586
  transition:bottom var(--mo-base) var(--mo-ease)}
509
587
  #tobottom.on{display:flex}
510
588
  #tobottom svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;
@@ -524,8 +602,8 @@ html,body{overflow-x:clip}
524
602
  点开才逐条看。运行中默认展开(要看得见进度),全部结束自动收起来。 */
525
603
  .toolgroup{width:100%;min-width:0;border:1px solid var(--line);
526
604
  border-radius:var(--dsw-radius-md);background:var(--panel2);overflow:hidden}
527
- .toolgroup .gh{display:flex;align-items:center;gap:8px;width:100%;min-height:44px;
528
- padding:0 12px;text-align:left;color:var(--dim);font-size:13.5px}
605
+ .toolgroup .gh{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:44px;
606
+ padding:0 var(--sp-3);text-align:left;color:var(--dim);font-size:var(--fs-3)}
529
607
  .toolgroup .gh:active{background:var(--panel)}
530
608
  .toolgroup .gh .sum{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
531
609
  .toolgroup .gh .sum b{color:var(--fg);font-weight:600}
@@ -536,8 +614,8 @@ html,body{overflow-x:clip}
536
614
  stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s ease-out}
537
615
  .toolgroup .gh svg.ch{transition:transform .15s ease-out}
538
616
  .toolgroup .gh[aria-expanded='true'] svg.ch{transform:rotate(180deg)}
539
- .toolgroup .gb{padding:0 8px 8px}
540
- .toolgroup .gb .tool{margin-top:6px}
617
+ .toolgroup .gb{padding:0 var(--sp-2) var(--sp-2)}
618
+ .toolgroup .gb .tool{margin-top:var(--sp-2)}
541
619
  .toolgroup .gb[hidden]{display:none}
542
620
 
543
621
  /* 8. 轻量 markdown:标题 / 粗体 / 行内代码 / 列表 / 引用 / 分隔线 / 空行留白。
@@ -551,7 +629,9 @@ html,body{overflow-x:clip}
551
629
  · 标题上 16 / 下 7 —— 原来 3/2,标题贴在正文上,层次看不出来;
552
630
  · 列表项 5px —— 原来 1px,多行列表挤成一片;
553
631
  · line-break:strict —— 别在中文标点前后断行。
554
- 这些数字改小很容易;改小之前先想清楚为什么在这儿。 */
632
+ 这些数字改小很容易;改小之前先想清楚为什么在这儿。
633
+ ⚠ 另外:这一节的字号**不参与**界面那五档尺度(--fs-1…--fs-5)——
634
+ 内容排版和界面层级是两件事,混在一起改一定有一头会坏。 */
555
635
  .md-h{font-weight:600;font-size:15.5px;line-height:1.4;margin:16px 0 7px;
556
636
  text-wrap:balance;line-break:strict}
557
637
  .md-h[data-lv='1']{font-size:17px}
@@ -565,8 +645,8 @@ html,body{overflow-x:clip}
565
645
  color:var(--dim);line-height:1.7;line-break:strict}
566
646
  .md-hr{height:1px;margin:16px 0;background:var(--line)}
567
647
  .bub strong{font-weight:600}
568
- .bub code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
569
- font-size:.88em;padding:1px 4px;border-radius:4px;
648
+ .bub code{font-family:var(--dsw-font-mono);
649
+ font-size:.88em;padding:1px var(--sp-1);border-radius:var(--dsw-radius-xs);
570
650
  background:var(--panel2);border:1px solid var(--line);overflow-wrap:anywhere}
571
651
  .bub .md-p:first-child,.bub .md-h:first-child{margin-top:0}
572
652
  .bub .md-p:last-child,.bub .md-h:last-child{margin-bottom:0}
@@ -575,33 +655,38 @@ html,body{overflow-x:clip}
575
655
  手机上「看结果」主要就是看图、看文档:图铺满气泡宽度、点开能放大;
576
656
  文档没法内嵌预览,至少把名字 / 大小 / 类型说清楚,再给一个能点开的入口。
577
657
  点击目标都不小于 44px —— 这是手机页既定的底线。 */
578
- .md-imgwrap{display:block;width:100%;max-width:100%;padding:0;margin:6px 0;border:0;
579
- background:none;text-align:left;border-radius:var(--dsw-radius-md);overflow:hidden}
658
+ .md-imgwrap{display:block;width:100%;max-width:100%;padding:0;margin:var(--sp-2) 0;border:0;
659
+ min-height:44px;background:none;text-align:left;border-radius:var(--dsw-radius-md);overflow:hidden}
580
660
  .md-img{display:block;width:100%;height:auto;border:1px solid var(--line);
581
661
  border-radius:var(--dsw-radius-md);background:var(--panel2)}
582
662
  .md-imgwrap:active .md-img{opacity:.86}
583
- .filecard{display:flex;align-items:center;gap:10px;width:100%;min-height:52px;margin:6px 0;
584
- padding:8px 12px;border:1px solid var(--line);border-radius:var(--dsw-radius-md);
663
+ .filecard{display:flex;align-items:center;gap:var(--sp-3);width:100%;min-height:52px;margin:var(--sp-2) 0;
664
+ padding:var(--sp-2) var(--sp-3);border:1px solid var(--line);border-radius:var(--dsw-radius-md);
585
665
  background:var(--panel2);color:var(--fg);text-align:left;text-decoration:none}
586
666
  .filecard:active{background:var(--panel)}
587
- .filecard .fi{flex:0 0 auto;width:36px;height:36px;border-radius:9px;display:flex;
667
+ /* 同 .attach-chip .kind:36px 方框里的格式徽标,字号不参与界面尺度 */
668
+ .filecard .fi{flex:0 0 auto;width:36px;height:36px;border-radius:var(--dsw-radius-sm);display:flex;
588
669
  align-items:center;justify-content:center;background:var(--panel);border:1px solid var(--line);
589
670
  font-size:10.5px;font-weight:700;letter-spacing:.02em;color:var(--dim)}
590
671
  .filecard .fm{flex:1;min-width:0}
591
- .filecard .fn{display:block;font-size:14px;font-weight:600;overflow:hidden;
672
+ .filecard .fn{display:block;font-size:var(--fs-3);font-weight:600;overflow:hidden;
592
673
  text-overflow:ellipsis;white-space:nowrap}
593
- .filecard .fs{display:block;font-size:12px;color:var(--dim);margin-top:2px;overflow:hidden;
674
+ .filecard .fs{display:block;font-size:var(--fs-2);color:var(--dim);margin-top:var(--sp-1);overflow:hidden;
594
675
  text-overflow:ellipsis;white-space:nowrap}
595
- .message-attachments{display:flex;flex-direction:column;gap:2px;width:100%;margin:0 0 5px}
676
+ .message-attachments{display:flex;flex-direction:column;gap:var(--sp-1);width:100%;margin:0 0 var(--sp-1)}
596
677
  .row.me .message-attachments{align-items:flex-end}
597
678
  .row.me .message-attachments .md-imgwrap{width:min(78vw,280px)}
598
679
  .row.me .message-attachments .filecard{width:min(82vw,320px)}
599
- .md-a{color:inherit;text-decoration:underline;overflow-wrap:anywhere}
680
+ /* 正文里的行内链接**不往 44px 上撑**:WCAG 2.5.8 把"句子里/段落里的行内链接"
681
+ 明确列为尺寸下限的例外。在 15.5px/1.72 的段落里把链接撑到 44px 行盒,只会把
682
+ 行距撑破、还让相邻两行的可点区互相压住,越改越容易误点。实测这行 21px 高。
683
+ 做按钮的那一类全部 ≥44(见各条注释)。 */
684
+ .md-a{color:inherit;text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere}
600
685
  #lb{position:fixed;inset:0;z-index:60;display:none;align-items:center;justify-content:center;
601
- padding:10px;background:rgba(6,8,12,.94)}
686
+ padding:var(--sp-3);background:rgba(6,8,12,.94)}
602
687
  #lb.on{display:flex}
603
- #lb img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px}
604
- #lb .x{position:absolute;top:calc(8px + env(safe-area-inset-top));right:10px;width:44px;height:44px;
688
+ #lb img{max-width:100%;max-height:100%;object-fit:contain;border-radius:var(--dsw-radius-sm)}
689
+ #lb .x{position:absolute;top:calc(var(--sp-2) + var(--safe-t));right:calc(var(--sp-2) + var(--safe-r));width:44px;height:44px;
605
690
  border-radius:50%;border:1px solid rgba(255,255,255,.34);background:rgba(255,255,255,.12);
606
691
  color:#fff;font-size:18px;line-height:1}
607
692
  /* 工作区选择:新建对话前先定在哪个目录开。
@@ -612,24 +697,25 @@ html,body{overflow-x:clip}
612
697
  background:rgba(8,10,14,.42)}
613
698
  #wsp.on{display:flex}
614
699
  #wsp .box{width:100%;max-height:78vh;overflow-y:auto;background:var(--panel);
615
- border-top:1px solid var(--line);border-radius:16px 16px 0 0;
616
- padding:8px 12px calc(16px + var(--safe-b));
700
+ border-top:1px solid var(--line);
701
+ border-radius:var(--dsw-radius-lg) var(--dsw-radius-lg) 0 0;
702
+ padding:var(--sp-2) var(--sp-3) calc(var(--sp-4) + var(--safe-b));
617
703
  transform:translateY(16px);opacity:0;
618
704
  transition:transform var(--mo-base) var(--mo-ease),opacity var(--mo-base) var(--mo-ease)}
619
705
  #wsp.on .box{transform:none;opacity:1}
620
- #wsp h4{margin:8px 6px 10px;font-size:13px;font-weight:600;color:var(--dim)}
621
- #wsp .ws-head{display:flex;align-items:center;gap:8px;margin:2px 6px 10px}
622
- #wsp .ws-head h4{flex:1;margin:0;color:var(--fg);font-size:16px}
623
- #wsp .it{display:flex;align-items:center;gap:10px;width:100%;min-height:52px;margin:6px 0;
624
- padding:9px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);
625
- color:var(--fg);text-align:left;font:inherit;font-size:15px;cursor:pointer}
706
+ #wsp h4{margin:var(--sp-2) var(--sp-2) var(--sp-3);font-size:var(--fs-3);font-weight:600;color:var(--dim)}
707
+ #wsp .ws-head{display:flex;align-items:center;gap:var(--sp-2);margin:var(--sp-1) var(--sp-2) var(--sp-3)}
708
+ #wsp .ws-head h4{flex:1;margin:0;color:var(--fg);font-size:var(--fs-5)}
709
+ #wsp .it{display:flex;align-items:center;gap:var(--sp-3);width:100%;min-height:52px;margin:var(--sp-2) 0;
710
+ padding:var(--sp-2) var(--sp-3);border:1px solid var(--line);border-radius:var(--dsw-radius-md);
711
+ background:var(--panel2);color:var(--fg);text-align:left;font:inherit;font-size:var(--fs-4);cursor:pointer}
626
712
  #wsp .it:active{background:var(--panel)}
627
713
  #wsp .it .txt{flex:1;min-width:0}
628
714
  #wsp .it .nm{display:block;font-weight:600}
629
- #wsp .it .pt{display:block;font-size:12px;color:var(--dim);margin-top:2px;overflow:hidden;
715
+ #wsp .it .pt{display:block;font-size:var(--fs-2);color:var(--dim);margin-top:var(--sp-1);overflow:hidden;
630
716
  text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
631
- #wsp .it .tag{flex:0 0 auto;font-size:11px;color:var(--faint);border:1px solid var(--line);
632
- border-radius:999px;padding:2px 8px}
717
+ #wsp .it .tag{flex:0 0 auto;font-size:var(--fs-1);color:var(--faint);border:1px solid var(--line);
718
+ border-radius:var(--r-pill);padding:var(--sp-1) var(--sp-2)}
633
719
  @media (prefers-reduced-motion: reduce){
634
720
  #wsp .box{transition:none;transform:none;opacity:1}
635
721
  }
@@ -679,8 +765,8 @@ html,body{overflow-x:clip}
679
765
  #send:active{transform:scale(.94)}
680
766
 
681
767
  /* 输入框里有字 → 发送键"醒过来":外圈浮一层光晕(JS 加 .awake) */
682
- #send{box-shadow:0 0 0 0 rgba(76,141,255,0)}
683
- #send.awake{box-shadow:0 0 0 4px rgba(76,141,255,.14)}
768
+ #send{box-shadow:0 0 0 0 var(--ring-none)}
769
+ #send.awake{box-shadow:0 0 0 4px var(--ring-glow)}
684
770
 
685
771
  /* 流式生成:.bub.streaming 本来就用尾部渐隐表示"还在往下长"(既有代码切换的类,
686
772
  所以这里不需要新 JS)。再加一枚呼吸的墨点,像笔尖停在纸上。 */
@@ -768,18 +854,18 @@ html,body{overflow-x:clip}
768
854
  #poker{position:fixed;inset:0;z-index:70;display:none;flex-direction:column;
769
855
  background:#0d1512;color:#eaf5ee;font-size:14px;overflow:hidden}
770
856
  #poker.on{display:flex}
771
- .pk-top{display:flex;align-items:center;gap:10px;padding:10px 12px;
857
+ .pk-top{display:flex;align-items:center;gap:var(--sp-3);padding:calc(10px + var(--safe-t)) var(--sp-3) 10px;
772
858
  border-bottom:1px solid rgba(255,255,255,.08);flex:0 0 auto}
773
859
  .pk-top b{font-size:15px;font-weight:600}
774
860
  .pk-top .pk-meta{margin-left:auto;font-size:12px;opacity:.7;text-align:right;line-height:1.35}
775
- .pk-x{width:34px;height:34px;border:0;border-radius:9px;background:rgba(255,255,255,.09);
861
+ .pk-x{width:44px;height:44px;border:0;border-radius:var(--dsw-radius-sm);background:rgba(255,255,255,.09);
776
862
  color:inherit;font-size:17px;line-height:1;cursor:pointer}
777
863
  .pk-felt{position:relative;flex:1 1 auto;margin:10px 12px;border-radius:46% / 30%;
778
864
  background:radial-gradient(ellipse at 50% 45%,#1c5f45 0%,#14453422 60%,#0d1512 100%),
779
865
  linear-gradient(#17553f,#0f3a2b);border:1px solid rgba(255,255,255,.10);
780
866
  box-shadow:inset 0 0 40px rgba(0,0,0,.35);min-height:0}
781
867
  .pk-seat{position:absolute;transform:translate(-50%,-50%);width:88px;padding:5px 6px;
782
- border-radius:10px;background:rgba(0,0,0,.42);border:1px solid rgba(255,255,255,.10);
868
+ border-radius:var(--dsw-radius-sm);background:rgba(0,0,0,.42);border:1px solid rgba(255,255,255,.10);
783
869
  text-align:center;line-height:1.25;overflow:hidden}
784
870
  .pk-seat .nm{font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
785
871
  .pk-seat .st{font-size:11px;opacity:.78;font-variant-numeric:tabular-nums}
@@ -794,28 +880,29 @@ html,body{overflow-x:clip}
794
880
  text-align:center}
795
881
  .pk-pot{font-size:12px;opacity:.85;margin-bottom:6px}
796
882
  .pk-board{display:flex;justify-content:center;gap:5px;flex-wrap:wrap}
797
- .pk-card{min-width:34px;height:46px;border-radius:6px;background:#f4f7f5;color:#16221c;
883
+ .pk-card{min-width:34px;height:46px;border-radius:var(--dsw-radius-xs);background:#f4f7f5;color:#16221c;
798
884
  font-size:15px;font-weight:600;line-height:46px;text-align:center;padding:0 4px}
799
885
  .pk-card.red{color:#c0392b}
800
886
  .pk-card.back{background:repeating-linear-gradient(45deg,#2a6b52,#2a6b52 4px,#215741 4px,#215741 8px);
801
887
  color:transparent}
802
888
  .pk-log{flex:0 0 auto;max-height:52px;overflow-y:auto;padding:0 14px;font-size:11.5px;
803
889
  line-height:1.5;opacity:.72;overflow-wrap:anywhere}
804
- .pk-act{flex:0 0 auto;padding:10px 12px 14px;border-top:1px solid rgba(255,255,255,.08);
890
+ .pk-act{flex:0 0 auto;padding:10px var(--sp-3) calc(14px + var(--safe-b));border-top:1px solid rgba(255,255,255,.08);
805
891
  background:#0b120f}
806
892
  .pk-row{display:flex;gap:8px}
807
- .pk-btn{flex:1 1 0;min-width:0;min-height:52px;border:0;border-radius:12px;
893
+ .pk-btn{flex:1 1 0;min-width:0;min-height:52px;border:0;border-radius:var(--dsw-radius-md);
808
894
  background:rgba(255,255,255,.12);color:inherit;font-size:15px;font-weight:600;cursor:pointer}
809
895
  .pk-btn.p{background:#2fbf7f;color:#06231a}
810
896
  .pk-btn.r{background:#e0574f;color:#fff}
811
897
  .pk-btn:disabled{opacity:.34;cursor:default}
812
- .pk-rate{display:flex;align-items:center;gap:10px;margin-top:9px}
813
- .pk-rate input{flex:1 1 auto;min-width:0;accent-color:#2fbf7f}
898
+ .pk-rate{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-2)}
899
+ /* 滑杆也是可点元素:轨道本身很细,靠 min-height 把命中区撑到 44 */
900
+ .pk-rate input{flex:1 1 auto;min-width:0;min-height:44px;accent-color:#2fbf7f}
814
901
  .pk-rate .amt{font-size:12px;font-variant-numeric:tabular-nums;min-width:52px;text-align:right;
815
902
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
816
903
  .pk-hint{margin-top:8px;font-size:12px;opacity:.72;text-align:center;overflow-wrap:anywhere}
817
- .pk-toast{position:fixed;left:50%;top:62px;transform:translateX(-50%);z-index:80;
818
- max-width:78vw;padding:8px 12px;border-radius:10px;background:rgba(0,0,0,.82);
904
+ .pk-toast{position:fixed;left:50%;top:calc(62px + var(--safe-t));transform:translateX(-50%);z-index:80;
905
+ max-width:78vw;padding:var(--sp-2) var(--sp-3);border-radius:var(--dsw-radius-sm);background:rgba(0,0,0,.82);
819
906
  font-size:12.5px;opacity:0;transition:opacity .18s;pointer-events:none;overflow-wrap:anywhere}
820
907
  .pk-toast.on{opacity:1}
821
908
  @media (prefers-reduced-motion:reduce){.pk-toast{transition:none}}
@@ -3352,5 +3439,133 @@ if ('serviceWorker' in navigator) {
3352
3439
  .catch(function(e){ console.warn('taskwatch: service worker 注册失败', e) })
3353
3440
  }
3354
3441
  </script>
3442
+ <script>
3443
+ /* ── 远端 / 局域网 双入口切换(2026-10-12)─────────────────────────────────
3444
+ 为什么"自动"只能这么写:浏览器禁止 https 页面探测 http:// 的局域网地址(混合内容,
3445
+ IP 主机连"可升级"都不算),而中继经 SSH 反向隧道看到的来源永远是 127.0.0.1,
3446
+ 也判断不出手机在哪个网络。所以能用的信号只有两个:**这条路刚才通不通**,以及
3447
+ **你上次选了哪条**。据此:
3448
+ · 在远端这条路上,上游体检失败(隧道或 DSH 挂了)→ 自动切到局域网(若可用)
3449
+ · 在局域网这条路上,请求自己失败(手机离开 Wi-Fi)→ 自动切回远端
3450
+ · 点一下按钮可在 自动 → 固定局域网 → 固定远端 之间循环,选择会被记住
3451
+ 换路不需要重输 6 位配对码:向中继要一张 60 秒、一次性、换完即作废的凭据。
3452
+ 整段是独立 IIFE,任何异常只让它自己不显示,不影响页面其它功能。 */
3453
+ (function () {
3454
+ try {
3455
+ var PREF_KEY = 'tw-route-pref'
3456
+ var ORIGIN = 'unknown'
3457
+ var REMOTE = ''
3458
+ var LAN = []
3459
+ var failed = false
3460
+ var upstreamOk = true
3461
+ var busy = false
3462
+
3463
+ /* ROUTE-LOGIC-BEGIN */
3464
+ function decideRoute(c) {
3465
+ if (!c.hasRemote && !c.hasLan) return 'stay'
3466
+ if (c.pref === 'remote') return c.origin === 'remote' ? 'stay' : (c.hasRemote ? 'toRemote' : 'stay')
3467
+ if (c.pref === 'lan') return c.origin === 'lan' ? 'stay' : (c.hasLan ? 'toLan' : 'stay')
3468
+ // auto:只在"这条路确实不行"时才动,绝不把能用的页面换成可能打不开的地址
3469
+ if (c.origin === 'lan' && c.failed) return c.hasRemote ? 'toRemote' : 'stay'
3470
+ if (c.origin === 'remote' && c.upstreamFailed) return c.hasLan ? 'toLan' : 'stay'
3471
+ return 'stay'
3472
+ }
3473
+ /* ROUTE-LOGIC-END */
3474
+
3475
+ function pref() {
3476
+ try { var v = localStorage.getItem(PREF_KEY); return (v === 'lan' || v === 'remote') ? v : 'auto' }
3477
+ catch (e) { return 'auto' }
3478
+ }
3479
+ function setPref(v) { try { localStorage.setItem(PREF_KEY, v) } catch (e) {} }
3480
+
3481
+ function mount() {
3482
+ var host = document.querySelector('header') || document.body
3483
+ var el = document.getElementById('tw-route-pill')
3484
+ if (!el) {
3485
+ el = document.createElement('button')
3486
+ el.id = 'tw-route-pill'
3487
+ el.type = 'button'
3488
+ el.setAttribute('aria-label', '切换远端 / 局域网连接')
3489
+ el.addEventListener('click', function () {
3490
+ var p = pref()
3491
+ setPref(p === 'auto' ? 'lan' : (p === 'lan' ? 'remote' : 'auto'))
3492
+ apply()
3493
+ })
3494
+ }
3495
+ if (el.parentNode !== host) host.appendChild(el)
3496
+ return el
3497
+ }
3498
+
3499
+ function label() {
3500
+ if (ORIGIN !== 'lan' && ORIGIN !== 'remote') return ''
3501
+ var p = pref()
3502
+ var here = ORIGIN === 'lan' ? '局域网' : '远端'
3503
+ if (p === 'lan' && ORIGIN !== 'lan') return '去局域网'
3504
+ if (p === 'remote' && ORIGIN !== 'remote') return '去远端'
3505
+ return here + ' · ' + (p === 'auto' ? '自动' : '固定')
3506
+ }
3507
+
3508
+ function apply() {
3509
+ var el = mount()
3510
+ var text = label()
3511
+ if (!text) { el.style.display = 'none'; return }
3512
+ el.textContent = text
3513
+ el.style.display = 'inline-flex'
3514
+ var decision = decideRoute({
3515
+ origin: ORIGIN, pref: pref(), failed: failed, upstreamFailed: !upstreamOk,
3516
+ hasLan: LAN.length > 0, hasRemote: !!REMOTE,
3517
+ })
3518
+ if (decision === 'stay' || busy) return
3519
+ busy = true
3520
+ fetch('/taskwatch/ticket', {
3521
+ method: 'POST',
3522
+ headers: { 'content-type': 'application/json' },
3523
+ body: JSON.stringify({ to: decision === 'toLan' ? 'lan' : 'remote' }),
3524
+ }).then(function (r) { return r.json() }).then(function (j) {
3525
+ if (j && j.ok && j.url) location.href = j.url
3526
+ else busy = false
3527
+ }).catch(function () { busy = false })
3528
+ }
3529
+
3530
+ function probe() {
3531
+ fetch('/taskwatch/upstream-health', { cache: 'no-store' })
3532
+ .then(function (r) { return r.json() })
3533
+ .then(function (j) { failed = false; upstreamOk = !!(j && j.ok); apply() })
3534
+ .catch(function () { failed = true; apply() })
3535
+ }
3536
+
3537
+ // 样式随手挂上,不依赖页面里已有的 class(两份外壳的样式表可能不同)
3538
+ var css = document.createElement('style')
3539
+ /* 跟页面里其它胶囊同一套尺度:44 的命中区、999 的圆角、12.5px 的字、
3540
+ 12 的内边距;颜色仍然只用 currentColor 混合(不依赖页面 class,
3541
+ 万一被别的外壳复用,var() 取不到也不会塌成一块白底)。
3542
+ 这里用 display:flex!important + margin-left:auto 让它**自成一行、靠右**:
3543
+ apply() 会写内联的 display:inline-flex,内联样式优先级高于样式表,
3544
+ 而这颗胶囊是挂在 header 末尾的、不是 .hrow 的子节点,只能这么对齐。 */
3545
+ css.textContent = '#tw-route-pill{display:none;align-items:center;gap:8px;'
3546
+ + 'width:fit-content;margin:0 calc(12px + env(safe-area-inset-right,0px)) 4px auto;'
3547
+ + 'min-height:44px;padding:0 12px;border-radius:999px;'
3548
+ + 'font:600 12.5px/1.25 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",system-ui,sans-serif;'
3549
+ + 'color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);'
3550
+ + 'border:1px solid color-mix(in srgb,currentColor 24%,transparent);cursor:pointer;white-space:nowrap}'
3551
+ + '#tw-route-pill[style*="inline-flex"]{display:flex!important}'
3552
+ + '#tw-route-pill:active{transform:scale(.96)}'
3553
+ document.head.appendChild(css)
3554
+
3555
+ fetch('/taskwatch/entries', { cache: 'no-store' })
3556
+ .then(function (r) { return r.ok ? r.json() : null })
3557
+ .then(function (j) {
3558
+ if (!j || !j.ok || (j.current !== 'lan' && j.current !== 'remote')) return
3559
+ ORIGIN = j.current
3560
+ REMOTE = j.remote || ''
3561
+ LAN = j.lan || []
3562
+ apply()
3563
+ probe()
3564
+ setInterval(function () { apply(); probe() }, 12000)
3565
+ })
3566
+ .catch(function () { /* 插件侧没有这些路由(例如从 DSH GUI 打开)→ 安静地不显示 */ })
3567
+ } catch (e) { console.warn('taskwatch: 双入口切换未启用', e) }
3568
+ })()
3569
+ </script>
3355
3570
  </body>
3356
3571
  </html>
package/lib/sw.js CHANGED
@@ -44,7 +44,7 @@ const SHELL = ['/taskwatch', '/taskwatch/icon-192.png', '/taskwatch/icon-512.png
44
44
  // 那次重载是 cache-first 时代的补丁(旧外壳永远不会自己变新)。现在页面已经是
45
45
  // **网络优先**(见下面 fetch 里的注释),每次导航本来就取新版,这个补丁既没必要、
46
46
  // 又足以把整站打死。别再加回来。
47
- const CACHE = 'taskwatch-v12'
47
+ const CACHE = 'taskwatch-v13'
48
48
 
49
49
  const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
50
50
 
@@ -118,12 +118,22 @@ self.addEventListener('fetch', (e) => {
118
118
  // 吃旧页面,而服务端一切正常,从外面完全查不出来。页面只是几十 KB 的本地
119
119
  // 文件,多一次往返可以忽略,换来的是"改了就生效"。
120
120
  if (u.pathname === '/taskwatch') {
121
+ // 2026-10-12:只把**成功**响应写进外壳缓存。
122
+ //
123
+ // 以前是"拿到什么就存什么",于是中继或上游抖一下的那次 502 会被存成外壳。
124
+ // 之后手机只要离线导航,就吃出那张 502 页面 —— 看着像"又断了",其实服务端
125
+ // 早就好了,而且从服务端完全查不出来(缓存里的旧响应)。
126
+ // 现在非 2xx 时不写缓存,并改用缓存里的外壳兜底:界面照常渲染,数据接口
127
+ // 自己会报它该报的错。CACHE 版本号跟着升到 v13,把已经存进去的 502 清掉。
121
128
  e.respondWith(
122
129
  fetch(e.request)
123
130
  .then((res) => {
124
- const copy = res.clone()
125
- caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {})
126
- return res
131
+ if (res && res.ok) {
132
+ const copy = res.clone()
133
+ caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {})
134
+ return res
135
+ }
136
+ return caches.match(e.request).then((hit) => hit || res)
127
137
  })
128
138
  .catch(() => caches.match(e.request).then((hit) => hit || Response.error()))
129
139
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-in-hand",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "掌上 DSH:DeepSeek Harness 的手机端,不用装 App——会话、实时对话、交付文件、后台任务与待批准。 / DSH in hand: the phone client for DeepSeek Harness with no app to install.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",