dsh-mobile-ui 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,11 @@ Fast local iteration: copy to `~/.dsh/profiles/node_modules/dsh-mobile-ui/` and
42
42
 
43
43
  - **CSS layer**: injects `style[data-plugin-css=dsh-mobile-ui]`, all rules wrapped in `@media (max-width:768px)` — desktop loads none of it
44
44
  - **JS layer**: `matchMedia`-driven; on narrow screens the main grid goes single-column, the sidebar is hidden and becomes a fixed overlay drawer with scrim
45
- - **QA card** (`ask_user_question`, Mbwy4a component): JS detects the card and pins it as a floating panel (`position:fixed; top:100px`) — no `:has()` dependency (works in WeChat/X5-style engines); the footer button row wraps when narrow, so the submit button is always visible; conversation stays visible below
45
+ - **QA card** (`ask_user_question`, Mbwy4a component): JS detects the card and turns its composer seat into a full-height floating panel — no `:has()` dependency (works in WeChat/X5-style engines). The card's own `max-height` is lifted and its body stays the single scroll container, so long option lists scroll with a finger on every device; the footer button row wraps when narrow, so the submit button is always visible
46
+ - **Composer action row = two lines** (2026-09-11, settled over two rounds of user feedback): line 1 holds the tools (`+` / attach / access mode) then the context ring and send pushed to its right edge; line 2 is the model trigger alone, spanning the full width. ⚠️ The *first* two-line attempt (tools alone on line 1, model + context + send on line 2) was wrong: it left **183px of dead space** at the end of line 1 (measured at 412px) and the user immediately spotted it as "an empty patch up there". Lifting the context ring and send onto line 1 fills it (183px → 8px). Implemented with `display:contents` on the trailing group so its children become direct flex items of the row, then `order` for visual sequence and `margin-left:auto` to push the last two right — **pure CSS, no DOM moves** (relocating React nodes throws `removeChild` on re-render). Two lines are *required*, not cosmetic: the full model name (`commandcode/deepseek/deepseek-v4.1-flash` = 40 chars ≈ 225px) physically cannot fit on one line inside a 390px viewport — the row has 332px of content width and the tools need 120px, leaving the model trigger ~61px (10/40 chars, measured). Wrapping lets it take 218px at the same viewport (38/40 chars) and the **full name from 400px up**, at the cost of row height 52 → 90px. The access-mode button always shows its shield icon plus its text (`@container (min-width: 170px)`, judged on the row's real width); a clipped model name always uses a real ellipsis.
47
+ - **Composer row visual consistency** (2026-09-11, from the user's "the heights don't match, it looks off" report): upstream ships six different control heights (add 28 / PermissionSelect 28 / model 28 / ContextMeter 28 / primary 34) and our own earlier patches had left a 36/40/44 mix with three font sizes (model 11px / access 13px / context 10px). Everything is now **44px tall with a 12px font** (which also satisfies the iOS HIG minimum touch size — measured: `+`/attach/access were only 36px), the primary button's upstream `translateY(-2px)` is cancelled (a single-line-era optical compensation that pushed its centre 2px below its row), and the access-mode button gets the same round solid background as `+`/attach (upstream renders it as a background-less compact trigger, which looked like a missing piece between two round buttons). **Invariants**: exactly two lines; within a row every button is the same height, the same font size, and their vertical centres spread ≤1.5px; and **no line may end in more than 60px of dead space** (the quantitative guard for that "empty patch"). ⚠️ One more subtlevty: the UA default for `<button>` is **`text-align:center`** — once the model name spans a full line, centring makes the text start at x=69px instead of aligning with the `+` button above, so `text-align:left` must be explicit (the old layout never exposed this because the label's width exactly equalled its text width). ⚠️ This surfaced a fourth word-root leak: the message flow contains **no** `[class*=tools]` at all (only `xzv4MW_actions` / `TS9iAW_actions`), so the old `[class*=scrollBody] [class*=tools] button{min-height:36px}` was really hitting the composer's `uV2eYG_tools` and crushing `+`/attach/access to 36px — the actual cause of "row 1 is 36px while row 2 is 40/44px". It is now scoped to `[class*=flowItem]`, so message-flow buttons stay 36px and composer controls 44px. Never fake a single line with "no-wrap + shrink": upstream group buttons carry a non-compressible `min-width: 44px`, so a narrow row cannot resolve by shrinking and simply **overlaps** (the original bug: at 390px the model trigger covered the context ring by 4.3px and the access-mode button covered it by 5.7px).
48
+ - **Regression probe**: `python3 tests/mobile-layout-probe.py` drives a real headless Chromium (upstream CSS + real DOM nesting + the actual plugin bundle) across viewports, asserting two families of invariants: for the QA card — "option list scrolls / last option reachable after a real touch swipe / submit always visible / the fixed top-right menu button does not overlap the card"; for the composer row — "no two controls overlap / nothing spills past the card's right edge / every control is hit-testable / the access-mode icon *and* its text are always visible / the model name keeps at least 120px so it never degrades back into a sliver / a clipped model name uses a real ellipsis / icon, text and chevron are vertically centred". Exit code 1 on regression.
49
+ - **⚠️ The most dangerous pitfall in this plugin: word-root selectors** (three separate silent breakages on 2026-09-11). Upstream's `_7KE1Ra_triggerLabel / triggerIcon / triggerEffort` all contain **"trigger"**, so a bare `[class*=trigger]` hits them too: with `display:flex` the `text-overflow:ellipsis` is ignored (flex containers ignore it) and the model name is **cut mid-character**; with `min-height:40px` the label is stretched to full row height and its text hugs the top, so the chevron *looks* like it dropped to another line. Always narrow to `button[class*=trigger]`, and keep ellipsis on `display:block`. Likewise `button[aria-label*=访问模式] span { display:none }` hides the **shield icon as well** (it is a span too), leaving a completely blank button — hide only `[class*=triggerLabel]` and keep the icon permanently visible. Two more rules it guards: never override the card body's `overflow` (that is exactly what broke scrolling before), and hide the floating menu button whenever the question card is open (it sits at `top:48px; right:12px`, right on top of the card title). Related: a `min-width` floor on the trailing group's content is harmful — `flex:1 1 0` + `min-width:0` + `justify-content:flex-end` spills children **leftward** over the tools group once the box is narrower than its content minimum (measured: 9px at 365px viewport with `min-width:96px`), so express "the model name should stay readable" some other way.
46
50
 
47
51
  ## Maintenance notes (important)
48
52
 
package/README.zh.md CHANGED
@@ -28,8 +28,55 @@ bash install.sh --uninstall # 卸载
28
28
  ```bash
29
29
  bash scripts/build.sh # 语法 + 契约预检(含 classic-script 校验)
30
30
  bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.tar.gz
31
+ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提问卡片 + 输入区操作行)
31
32
  ```
32
33
 
34
+ **回归探针**(`tests/mobile-layout-probe.py`):用真实上游 CSS + 真实 DOM 嵌套 +
35
+ 真实插件代码,在 headless Chromium 里按视口跑矩阵,回归时退出码 1。两组:
36
+
37
+ **① 提问卡片**(视口 × 选项数):断言「正文可滚 / 真实手指滑动后最后一个选项可见 /
38
+ 提交按钮始终可见 / 右上角菜单按钮不遮挡卡片」。它守的是两条硬纪律——
39
+
40
+ 1. **绝不要覆盖提问卡片正文(`Mbwy4a_body` / `data-question-scroll`)的 overflow**:
41
+ 上游卡片是 `max-height:min(60vh,520px)+overflow:hidden`,正文才是它唯一的滚动容器,
42
+ 把它改成 `visible` 会让放不下的选项被卡片直接裁掉、且再也滚不出来(2026-09-11 修复的真实故障)。
43
+ 2. **浮层打开时必须隐藏右上角菜单按钮**:它固定在 `top:48px; right:12px`,
44
+ 正好压在提问卡片标题右侧(截图实证),所以菜单按钮的显隐要按
45
+ 「抽屉 / 设置面板 / 提问卡片」三态判定,而不是只看抽屉。
46
+
47
+ **② 输入区操作行**(视口 × 会话页/首页 × 长/短模型名):断言「任意两个控件都不重叠 /
48
+ 不越出输入卡右边界 / 都点得到 / 权限图标与文字都常显 / 模型名够宽(≥120px)/
49
+ 被裁模型名走真省略号 / 图标文字箭头垂直居中对齐 / **同行控件等高同字号且中心离散
50
+ ≤1.5px**」。它守的是第三条硬纪律——
51
+
52
+ 3. **绝不能用"禁止换行 + 强行收缩"来硬塞单行**:上游组内按钮有
53
+ `min-width:44px` 不可压缩,`flex-wrap:nowrap` 下宽度不足时 flex 无法收缩化解,
54
+ 只能**溢出重叠**(2026-09-11 修复的真实故障:390px 会话页
55
+ 访问模式↔模型重叠 5.7px、模型↔上下文重叠 4.3px)。正确做法是恢复上游
56
+ `flex-wrap:wrap` 作兜底,并让 **trailing 组吃满余量、只留模型按钮一个可收缩项**。
57
+
58
+ **⚠️ `[class*=trigger]` / `[class*=访问模式] span` 这两类词根选择器是本插件最危险的坑**
59
+ (2026-09-11 一次性踩中三处,全部静默):
60
+
61
+ - 上游 `_7KE1Ra_triggerLabel / triggerIcon / triggerEffort` 类名里都含 **"trigger"**。
62
+ `[class*=trigger]{display:flex}` 会把 `text-overflow:ellipsis` 废掉(flex 容器忽略它)
63
+ → 模型名被**硬切出半个字符**;`[class*=trigger]{min-height:40px}` 还会把 label
64
+ 撑成整行高、文字贴顶 → 视觉上 chevron 像"掉到下一行"。
65
+ ⇒ 一律限定 `button[class*=trigger]`;省略号必须配 `display:block`。
66
+ - `button[aria-label*=访问模式] span{display:none}` 的 `span` 通配会连**盾牌图标**
67
+ (也是 span)一起隐藏 → 整个按钮**纯空白**。⇒ 只隐藏 `[class*=triggerLabel]`,
68
+ 图标必须常显(它是该按钮唯一不变的识别物)。
69
+
70
+ 另外两条同源教训:**trailing 的 `min-width` 不能给小**(`flex:1 1 0` + `min-width:0`
71
+ + `justify-content:flex-end` 时,盒宽小于内容最小宽会把子元素**向左溢出**压住工具组——
72
+ 实测 `min-width:96px` 时 365px 压 9px、410px 压 2px;而"模型名至少多宽"这种业务意图
73
+ 不该用 min-width 表达);**按容器查询而非视口猜**(操作行自带 `container-type:inline-size`,
74
+ `@container (min-width:324px)` 决定权限文字显隐,比按视口宽度判断稳)。
75
+
76
+ 探针已做**双向对照**验证:修复前的版本返回 1(抓到 4 个重叠/越界用例 +
77
+ "权限图标被隐藏"/"推理等级后缀未隐藏"),完整修复版返回 0 —— 即它抓得住这两类缺陷,
78
+ 不是"永远绿灯"的假探针。
79
+
33
80
  本地快速迭代:复制到 `~/.dsh/profiles/node_modules/dsh-mobile-ui/` 后刷新页面
34
81
  (客户端插件有 HMR 通道,改 client.js 后页面自动更新)。
35
82
 
@@ -41,6 +88,52 @@ bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.
41
88
  注入右上角菜单按钮 + 遮罩;菜单把侧边栏临时变为 fixed 抽屉(overlay)。
42
89
  - **hero 标题置顶**:纯 CSS 实现——composerHero 栈在 hero 态撑满视口
43
90
  (flex:1),输入卡 margin-top:auto 沉底,标题/工作区行自然留在顶部。
91
+ - **提问卡片**:JS 检测到卡片后把它的 composerSeat 变成铺满可视高度的浮层,
92
+ 卡片自身的 max-height 放开、正文保持为唯一滚动容器(卡片内 `data-question-scroll`),
93
+ footer 钉在浮层底部。选择器优先用上游稳定属性(`data-question-key` /
94
+ `data-question-scroll` / `data-composer-seat`),hash 类名只作兜底。
95
+ - **操作行 = 两行布局**(2026-09-11 按用户反馈两轮迭代定型):
96
+ - 第一行:工具组(`+` / 附件 / 访问模式)······ 上下文环 发送
97
+ - 第二行:模型选择器(**独占整行**)
98
+
99
+ 为什么必须两行:完整模型名(如 `commandcode/deepseek/deepseek-v4.1-flash`
100
+ = 40 字符 ≈ 225px)在单行里**物理上放不下**——390px 视口整行内容仅 332px、
101
+ 工具组最少 120px,单行最多只能给模型名约 61px(10/40 字符,实测)。
102
+ 模型独占整行后同一视口可达 236px,**340px 以上就完整显示**;
103
+ 代价是行高 52 → 98~102px。
104
+
105
+ ⚠️ **第一版两行排布(工具组独占第一行、模型+上下文+发送在第二行)是错的**:
106
+ 它在第一行右侧留出 **183px 空白**(412px 实测),用户一眼看出"上面空了一块"。
107
+ 正确做法是把上下文环与发送提到第一行右侧填满,模型独占第二行
108
+ (空块 183px → 8px)。实现用 `display:contents` 打散 `trailing`,
109
+ 让其子元素成为 row 的直接 flex 项,再用 `order` 指定视觉顺序、
110
+ `margin-left:auto` 把后两者推到最右 —— **纯 CSS,不移动 DOM**
111
+ (搬 React 节点会在重渲染时 removeChild 抛错)。
112
+ 权限按钮恒显盾牌图标 + 文字(`@container (min-width:170px)` 判据,第一行独占后
113
+ 320px 也放得下「完全权限」);被裁的模型名一律走**真省略号**。
114
+
115
+ - **操作行视觉统一**(2026-09-11 用户反馈"高度不一致、怪怪的"实测定案):
116
+ 上游六种控件高度本不相同(add 28 / PermissionSelect 28 / 模型 28 / ContextMeter 28
117
+ / primary 34,而我们此前的补丁又留下 36/40/44 混排),字号也散落三代
118
+ (模型 11px / 权限 13px / 上下文 10px)。现已全部统一为 **44px 高 + 12px 字号**
119
+ (顺带满足 iOS HIG 最小可点尺寸——实测原来 `+`/附件/权限 只有 36px 可点高度),
120
+ 并取消发送键上游的 `translateY(-2px)`(那是单行时代的视觉补偿,多行下让它的
121
+ 中心比同行低 2px),权限按钮补上与 `+`/附件 相同的圆形实底(上游它是无底色
122
+ 紧凑触发器,夹在两个圆形实底按钮中间显得"缺一块",是"怪"的主要来源)。
123
+ **判据**:恰为两行、同一行内所有按钮等高同字号、垂直中心离散 ≤1.5px、
124
+ **每行右侧空块 ≤60px**(后者正是"空了一块"的量化守卫)。
125
+
126
+ ⚠️ 另一个隐蔽坑:浏览器 UA 默认给 `<button>` 是 **`text-align:center`**。
127
+ 模型名独占整行后,居中会让文字从 69px 处才开始(实测 412px),
128
+ 与上一行的 `+` 按钮不对齐 —— 必须显式 `text-align:left`。
129
+ (旧排布里 label 宽度恰好等于文字宽度、没有富余空间居中,所以一直没暴露。)
130
+
131
+ ⚠️ 这条修复暴露了第四处**词根选择器泄漏**:消息流里**并不存在** `[class*=tools]`
132
+ (实测消息流只有 `xzv4MW_actions` / `TS9iAW_actions`),所以旧规则
133
+ `[class*=scrollBody] [class*=tools] button{min-height:36px}` 唯一命中的对象是
134
+ **输入框的 `uV2eYG_tools`**,把 `+`/附件/权限 压成 36px —— 这正是"第一行 36px、
135
+ 第二行 40/44px"的根源。现已限定到 `[class*=flowItem]` 内,消息流按钮保持
136
+ 36px、输入框控件 44px,两者互不干扰。
44
137
 
45
138
  ## 维护须知(重要)
46
139
 
@@ -54,6 +147,16 @@ bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.
54
147
  pXSMma_root 等):React fiber 树仍记录旧父节点,重渲染时 removeChild 抛
55
148
  NotFoundError,整个会话视图被卸载 → 页面空白。布局需求一律用 CSS
56
149
  (flex/order/:has)表达;hero 标题置顶即纯 CSS 实现。
150
+ - **严禁覆盖提问卡片正文的 overflow**(2026-09-11 故障根因,已由探针看住):
151
+ 上游 `.card{max-height:min(60vh,520px);overflow:hidden}` 且 `.body{overflow-y:auto}`
152
+ ——正文是卡片内唯一滚动容器。改成 `overflow:visible` 会造成
153
+ 「选项看不全 + 手指滑动毫无反应」:卡片把放不下的选项裁掉,正文又不再滚动,
154
+ 浮层里也没有可滚内容(实测 6 选项时 body 内容 1047px / 可见 319px,滑动后几何零变化)。
155
+ 正确做法是**给正文更多高度**(浮层撑满视口 + 卡片 max-height:100%),而不是动它的 overflow。
156
+ - **右上角菜单按钮的显隐必须是三态判定**(抽屉 / 设置面板 / 提问卡片):
157
+ 该按钮固定在 `top:calc(48px + safe-area); right:12px`,提问卡片打开时正好压在
158
+ 卡片标题右侧(2026-09-11 由真实页面截图发现)。旧写法只看 `drawerOpen`,
159
+ 既会在提问卡片上造成遮挡,也会把设置面板打开时隐藏的菜单在下次 sync 又显示回来。
57
160
 
58
161
  ## 回滚
59
162
 
Binary file
Binary file
Binary file
Binary file
package/client.js CHANGED
@@ -50,9 +50,15 @@ window.__ModuleLoader__.load({
50
50
  " body.dsh-mobile-ui [class*=composerSeat],body.dsh-mobile-ui [class*=composerStack]{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px)) !important}",
51
51
  /* 头部紧凑(允许换行,标题不再截断) */
52
52
  " body.dsh-mobile-ui [class*=header]{flex-wrap:wrap;row-gap:4px}",
53
- /* 触摸目标:composer 区按钮/触发器 ≥44px(iOS HIG) */
53
+ /* 触摸目标:composer 区按钮统一 44px(iOS HIG 最小可点尺寸)。
54
+ 上游本身高度不一(add 28 / PermissionSelect 28 / 模型 28 / ContextMeter 28
55
+ / primary 34),下面「操作行统一高度」块会全部拉到 44px,
56
+ 这里只负责给一个所有按钮都能继承的基线。
57
+ ⚠️ 必须限定 button:上游 _7KE1Ra_triggerLabel / triggerIcon / triggerEffort
58
+ 类名里也含 "trigger",裸 [class*=trigger] 会把 min-height 套到这些
59
+ span 上 → label 被撑成整行高、文字贴顶 → 视觉上 chevron 像"掉到下一行"
60
+ (2026-09-11 用户截图实证;与下面 访问模式/选择模型 的选择器收窄同源)。 */
54
61
  " body.dsh-mobile-ui [class*=composerSeat] button,body.dsh-mobile-ui [class*=composerStack] button{min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}",
55
- " body.dsh-mobile-ui [class*=composerSeat] [class*=trigger],body.dsh-mobile-ui [class*=composerStack] [class*=trigger]{min-height:40px}",
56
62
  /* 统计行(LLM 用时/首token)允许换行防截断(分段 nowrap 片段的父容器) */
57
63
  " body.dsh-mobile-ui [class*=composerStack] > *,body.dsh-mobile-ui [class*=composerSeat] > *{white-space:normal !important;overflow-wrap:anywhere !important}",
58
64
  /* 抽屉会话列表项触摸目标 44px */
@@ -71,13 +77,26 @@ window.__ModuleLoader__.load({
71
77
  " body.dsh-mobile-ui [class*=composerHero] > [class*=heroWorkspaceRow]{flex:0 0 auto !important;height:auto !important}",
72
78
  /* 工作区行内按钮豁免 44px 触摸高(保持 28px 原高) */
73
79
  " body.dsh-mobile-ui [class*=heroWorkspaceRow] button{min-height:28px !important;min-width:0 !important;height:auto !important}",
74
- /* QA 问答卡片(Mbwy4a)移动端适配:全屏弹层(与设置面板同构)——
75
- seat 固定铺满视口 + 内部滚动,footer(跳过/提交)sticky 钉屏幕底部。
76
- 任何设备/视口/系统字体下提交按钮都必然可见;QA 关闭后自动恢复 */
77
- " body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa{position:fixed !important;top:100px !important;left:8px !important;right:8px !important;bottom:auto !important;margin:0 !important;z-index:2147482999 !important;padding:12px !important;max-height:calc(100vh - 150px) !important;max-height:calc(100dvh - 150px) !important;overflow-y:auto !important;background:transparent !important}",
78
- " body.dsh-mobile-ui [class*=Mbwy4a_card]{width:100% !important;max-width:none !important}",
79
- " body.dsh-mobile-ui [class*=Mbwy4a_body]{overflow:visible !important}",
80
- " body.dsh-mobile-ui [class*=Mbwy4a_footer]{position:sticky !important;bottom:0 !important;background:var(--dsw-alias-bg-layer-1,rgb(44,44,46)) !important}",
80
+ /* ── QA 问答卡片(ask_user_question)移动端适配 ─────────────────────
81
+ ⚠️ 2026-09-11 复现定案的坑(现象:选项看不全、手指滑动也翻不出来):
82
+ 上游卡片自带 max-height:min(60vh,520px) + overflow:hidden,真正的滚动容器
83
+ 是它内部的 body(上游 overflow-y:auto)。旧规则把 body 的 overflow 覆盖成
84
+ visible → ①超出卡片的选项被卡片直接裁掉;②body 不再是滚动容器;
85
+ ③覆盖层因卡片被 60vh 卡死而没有可滚内容 → 滑动毫无反应、选项永久不可见。
86
+ (实测 6 选项:body scrollHeight 1047 / clientHeight 319,滑动后几何零变化)
87
+ 修法三原则:①绝不覆盖 body 的 overflow(保留上游唯一滚动容器);
88
+ ②覆盖层撑满可视高度、卡片 max-height:100%(不再被 60vh 卡死);
89
+ ③覆盖层自身 overflow:hidden,杜绝双层滚动互抢手势。
90
+ 选择器优先用上游稳定属性 data-question-key(frame)/ data-question-scroll(body)
91
+ / data-composer-seat(seat),hash 类名只作兜底,降低 dsh 升级后的漂移风险。 */
92
+ " body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa,body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa{position:fixed !important;top:calc(12px + env(safe-area-inset-top,0px)) !important;left:8px !important;right:8px !important;bottom:calc(12px + env(safe-area-inset-bottom,0px)) !important;margin:0 !important;z-index:2147482999 !important;padding:0 !important;max-height:none !important;overflow:hidden !important;background:transparent !important;display:flex !important;flex-direction:column !important}",
93
+ " body.dsh-mobile-ui [data-question-key] > section,body.dsh-mobile-ui [class*=Mbwy4a_card]{width:100% !important;max-width:none !important;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14)) !important;box-shadow:0 10px 34px rgba(0,0,0,.45) !important}",
94
+ " body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [data-question-key],body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa [data-question-key],body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [class*=Mbwy4a_frame]{flex:1 1 auto !important;min-height:0 !important;padding:0 !important;align-items:flex-start !important}",
95
+ " body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [data-question-key] > section,body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa [data-question-key] > section,body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [class*=Mbwy4a_card]{max-height:100% !important;min-height:0 !important}",
96
+ /* 正文:唯一滚动容器(覆盖层态与普通流态都生效——JS 标记失败也不会再被裁掉);
97
+ overscroll-behavior 防滑动穿透到会话消息流 */
98
+ " body.dsh-mobile-ui [data-question-scroll],body.dsh-mobile-ui [class*=Mbwy4a_body]{flex:1 1 auto !important;min-height:0 !important;overflow-y:auto !important;-webkit-overflow-scrolling:touch !important;overscroll-behavior:contain !important;touch-action:pan-y !important}",
99
+ " body.dsh-mobile-ui [class*=Mbwy4a_footer]{position:sticky !important;bottom:0 !important;background:var(--dsw-alias-bg-layer-1,rgb(44,44,46)) !important}",
81
100
  /* QA footer 防溢出:内容超宽时换行(跳过/提交换行后仍可见,提交永不被裁) */
82
101
  " body.dsh-mobile-ui [class*=Mbwy4a_footer]{flex-wrap:wrap !important;row-gap:4px !important}",
83
102
  " body.dsh-mobile-ui [class*=Mbwy4a_footer] [class*=footerActions]{flex-wrap:wrap !important;min-width:0 !important}",
@@ -86,18 +105,93 @@ window.__ModuleLoader__.load({
86
105
  " body.dsh-mobile-ui [class*=Mbwy4a_footer] [class*=progress]{flex:0 1 auto !important;min-width:0 !important}",
87
106
  " body.dsh-mobile-ui [class*=Mbwy4a_pager]{flex:0 1 auto !important;min-width:0 !important}",
88
107
  " body.dsh-mobile-ui [class*=Mbwy4a_footer] button[class*=_primary]{flex:0 0 auto !important}",
89
- /* 操作行单行紧凑:hero 4 元素 / 会话页 5 元素(含上下文按钮)一行放下。
108
+ /* 操作行紧凑:hero 4 元素 / 会话页 5 元素(含上下文按钮)一行放下。
90
109
  权限按钮移动端图标模式(44px 隐藏文字防撑宽),上下文紧凑 36px,
91
- 模型选择器 127px 居中显示(轻微截断可接受) */
92
- " body.dsh-mobile-ui [class*=uV2eYG_row]{flex-wrap:nowrap !important;gap:4px !important}",
93
- " body.dsh-mobile-ui [class*=uV2eYG_row] > *{flex:0 1 auto !important}",
94
- " body.dsh-mobile-ui [class*=uV2eYG_tools],body.dsh-mobile-ui [class*=uV2eYG_modes]{padding:0 !important;gap:4px !important}",
95
- " body.dsh-mobile-ui [class*=uV2eYG_row] [class*=trigger]{display:flex !important;align-items:center !important;line-height:1.2 !important}",
96
- " body.dsh-mobile-ui button[aria-label*=选择模型]{display:flex !important;align-items:center !important;justify-content:center !important;flex:0 0 auto !important;max-width:136px !important;font-size:11px !important;padding:0 6px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
97
- " body.dsh-mobile-ui button[aria-label*=访问模式]{width:44px !important;flex:0 0 44px !important;padding:0 !important;justify-content:center !important}",
98
- " body.dsh-mobile-ui button[aria-label*=访问模式] span,body.dsh-mobile-ui button[aria-label*=访问模式] [class*=label]{display:none !important}",
99
- " body.dsh-mobile-ui button[aria-label*=上下文]{width:36px !important;max-width:36px !important;min-width:36px !important;flex:0 0 36px !important;padding:0 !important;font-size:10px !important;justify-content:center !important;overflow:hidden !important}",
110
+ 模型选择器最长 136px、可伸缩、超出省略号截断。
111
+ ⚠️ 2026-09-11 修复「模型/上下文/控制按钮三者在会话页重叠」:
112
+ ① 旧规则 [class*=uV2eYG_row] [class*=trigger] 会误命中上游的
113
+ _7KE1Ra_triggerLabel / triggerIcon / triggerEffort(类名都含 "trigger"),
114
+ 用 display:flex !important 顶掉上游 @container(width<=360px) 的折叠,
115
+ 把模型按钮撑到 136px 且无法收缩 —— 选择器收窄为 button[class*=trigger];
116
+ ② 旧 row 强制 nowrap 且把 tools/trailing 都设成 flex:0 1 auto,
117
+ 但组内按钮有 44px min-width 不可压缩,宽度不足时不是换行而是**溢出**,
118
+ 于是模型按钮压住上下文环、访问模式按钮压住模型按钮(实测重叠 5.7px/4.3px)。
119
+ 改法:恢复上游 flex-wrap:wrap 作兜底(宁可换行绝不重叠),
120
+ trailing 吃满余量并右对齐,全行只留模型按钮一个可收缩项。 */
121
+ " body.dsh-mobile-ui [class*=uV2eYG_row]{flex-wrap:wrap !important;gap:8px !important;row-gap:6px !important}",
122
+ " body.dsh-mobile-ui [class*=uV2eYG_row] > *{flex:0 1 auto !important;min-width:0 !important}",
123
+ /* ── 移动端操作行排布(2026-09-11 v3,按用户反馈"上面空了一块"重排)────
124
+ 两行,但**不是**「工具组独占第一行 + 其余全在第二行」:
125
+ 第一行:工具组(+ / 附件 / 访问模式) ······ 上下文环 发送
126
+ 第二行:模型选择器(独占整行)
127
+ 为什么这样:旧排布(工具组独占第一行、模型+上下文+发送在第二行)
128
+ 实测在 412px 下第一行右侧留出 **183px 空白**——用户一眼看出"上面空了一块"。
129
+ 把上下文环与发送键提到第一行右侧正好填满(空块降到 8px),
130
+ 同时模型独占整行后名字可完整显示(412px 实测 40/40 字符,旧排布 37/40)。
131
+ 实现:display:contents 打散 trailing,让它三个子元素成为 row 的直接
132
+ flex 项,再用 order 指定视觉顺序、margin-left:auto 把后两者推到最右。
133
+ (纯 CSS,不移动 DOM —— 搬 React 节点会在重渲染时 removeChild 抛错。) */
134
+ " body.dsh-mobile-ui [class*=uV2eYG_trailing]{display:contents !important}",
135
+ " body.dsh-mobile-ui [class*=uV2eYG_tools]{order:1 !important;flex:0 0 auto !important;padding:0 !important;gap:8px !important}",
136
+ " body.dsh-mobile-ui [class*=uV2eYG_modes]{padding:0 !important;gap:8px !important}",
137
+ " body.dsh-mobile-ui [class*=JObwrW_root]{order:2 !important;flex:0 0 auto !important;margin-left:auto !important}",
138
+ " body.dsh-mobile-ui [class*=uV2eYG_primary]{order:3 !important;flex:0 0 auto !important}",
139
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button[class*=trigger]{display:flex !important;align-items:center !important;line-height:1.2 !important}",
140
+ /* 模型选择器:独占整行(flex:1 1 100%),故**不设宽度上限**;
141
+ min-width:0 保留(给内容设 min-width 地板会导致窄行时向左溢出压住工具组) */
142
+ " body.dsh-mobile-ui [class*=_7KE1Ra_root]{order:4 !important;flex:1 1 100% !important;min-width:0 !important;max-width:none !important;overflow:hidden !important}",
143
+ /* 模型按钮:内边距 6→4px。
144
+ ⚠️ text-align:left 必须显式写:浏览器的 UA 默认给 <button> 是 text-align:center,
145
+ 模型名独占整行后,居中会让文字从 69px 处开始(实测 412px),
146
+ 左对齐才与上一行的 + 按钮对齐(旧排布里 label 宽度恰好等于文字宽度,
147
+ 没有富余空间居中,所以这个坑一直没暴露)。 */
148
+ " body.dsh-mobile-ui button[aria-label*=选择模型]{display:flex !important;align-items:center !important;justify-content:flex-start !important;text-align:left !important;flex:1 1 auto !important;min-width:0 !important;max-width:100% !important;width:100% !important;font-size:11px !important;padding:0 4px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
149
+ /* 模型名:display:block + overflow:hidden + text-overflow:ellipsis 才有**真省略号**
150
+ (flex 容器忽略 text-overflow,会硬切出半个字符——用户截图里的"半截字母")。
151
+ flex:1 1 auto 让名字吃满、把 chevron 顶到最右(像正常下拉框)。
152
+ 垂直居中由父按钮的 align-items:center 负责。 */
153
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerLabel]{display:block !important;flex:1 1 auto !important;line-height:1.2 !important;min-width:0 !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
154
+ /* 推理等级(Max)移动端不显示:上游给它 flex-shrink:1000(先消失),
155
+ 上轮误改成 flex:0 1 auto 后它与模型名一起被压缩,只剩 8px 宽的
156
+ 半截字符(用户截图实证)。这里直接隐藏,信息仍可在模型菜单里看到。 */
157
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerEffort]{display:none !important}",
158
+ /* 模型图标移动端不显示:两行布局后模型名有整行宽度,文字信息量远大于图标
159
+ (上游在 @container(width<=360px) 时才折叠成图标,我们不再需要那条降级) */
160
+ " body.dsh-mobile-ui button[aria-label*=选择模型] [class*=_7KE1Ra_triggerIcon]{display:none !important}",
161
+ /* 权限(访问模式)按钮:恢复图标 + 文字。
162
+ ⚠️ 旧规则 button[aria-label*=访问模式] span{display:none} 用 span 通配,
163
+ 连上游的 triggerIcon(盾牌图标也是 span)一起隐藏 → 整个按钮纯空白
164
+ (2026-09-11 用户截图实证;此前被控件重叠掩盖)。现在:
165
+ 图标常显(这是按钮唯一不变的识别物);文字只在整行有余量时显示
166
+ (容器查询按行实际宽度判定,比按视口猜可靠),窄屏自动退回图标模式。
167
+ 宽度自适应 + 可收缩,避免任何情况下把行撑溢出。 */
168
+ " body.dsh-mobile-ui button[aria-label*=访问模式]{width:auto !important;min-width:44px !important;max-width:120px !important;flex:0 1 auto !important;padding:0 4px !important;gap:3px !important;justify-content:center !important}",
169
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerIcon]{display:inline-flex !important;flex:0 0 auto !important}",
170
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerLabel]{display:none !important;line-height:1.2 !important;min-width:0 !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
171
+ /* chevron 在移动端省略(该按钮语义已由盾牌图标 + 文字表达,省 18px 留给模型名) */
172
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=chevron]{display:none !important}",
173
+ " body.dsh-mobile-ui button[aria-label*=上下文]{width:44px !important;max-width:44px !important;min-width:44px !important;flex:0 0 44px !important;padding:0 !important;font-size:11px !important;justify-content:center !important;overflow:hidden !important}",
100
174
  " body.dsh-mobile-ui button[aria-label*=上下文] span,body.dsh-mobile-ui button[aria-label*=上下文] [class*=label]{max-width:30px !important;overflow:hidden !important;text-overflow:ellipsis !important;white-space:nowrap !important}",
175
+ /* ── 操作行「视觉统一」四件事(2026-09-11 用户反馈"高度不一致、怪怪的")──
176
+ 实测病灶(412px 会话页):
177
+ 第一行 36px(+ / 附件 / 权限),第二行 40px(模型 / 上下文)+ 44px(发送)
178
+ → 同一行内 36 vs 40 vs 44 三种高度;发送键还带上游 translateY(-2px)
179
+ (单行时代的视觉补偿),多行布局下让它的中心比同行低 2px。
180
+ 修法:全部拉到 44px(顺带满足 iOS HIG 最小可点尺寸,实测原来
181
+ `+`/附件/权限 只有 36px 可点高度、模型/上下文 40px,都偏小),
182
+ 取消发送键的 translateY,字号统一 12px(原模型 11 / 权限 13 / 上下文 10)。*/
183
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button{min-height:44px !important;height:44px !important}",
184
+ " body.dsh-mobile-ui [class*=uV2eYG_add]{width:44px !important;min-width:44px !important;max-width:44px !important}",
185
+ " body.dsh-mobile-ui [class*=_7KE1Ra_root]{height:44px !important}",
186
+ " body.dsh-mobile-ui [class*=JObwrW_root],body.dsh-mobile-ui [class*=JObwrW_trigger]{height:44px !important}",
187
+ " body.dsh-mobile-ui [class*=uV2eYG_primary]{transform:none !important}",
188
+ " body.dsh-mobile-ui [class*=uV2eYG_row] button{font-size:12px !important}",
189
+ " body.dsh-mobile-ui [class*=uV2eYG_row] [class*=triggerLabel]{font-size:12px !important;line-height:1.2 !important}",
190
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerIcon] svg{width:16px !important;height:16px !important}",
191
+ /* 权限按钮与 + / 附件 同一视觉语言(上游它是无底色的紧凑触发器,
192
+ 夹在两个圆形实底按钮中间显得"缺一块",是"怪"的主要来源) */
193
+ " body.dsh-mobile-ui button[aria-label*=访问模式]{background:var(--dsw-specific-selector) !important;border-radius:999px !important}",
194
+ " body.dsh-mobile-ui button[aria-label*=访问模式]:active{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover)) !important}",
101
195
  /* 输入框圆角与内边距(视觉更圆润、输入更舒适;卡左右加宽) */
102
196
  " body.dsh-mobile-ui [class*=input]{border-radius:18px !important}",
103
197
  " body.dsh-mobile-ui [class*=uV2eYG_root]{padding-left:8px !important;padding-right:8px !important}",
@@ -121,16 +215,31 @@ window.__ModuleLoader__.load({
121
215
  " body.dsh-mobile-ui [class*=header] [class*=title],body.dsh-mobile-ui [class*=crumbs]{white-space:normal;overflow-wrap:anywhere}",
122
216
  /* 输入框聚焦反馈 */
123
217
  " body.dsh-mobile-ui textarea:focus,body.dsh-mobile-ui [class*=input]:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-brand-primary,rgba(79,124,255,.45))}",
124
- /* 消息流操作按钮(复制/反馈/分享)触摸目标提升 */
125
- " body.dsh-mobile-ui [class*=scrollBody] [class*=actions] button,body.dsh-mobile-ui [class*=scrollBody] [class*=tools] button{min-width:36px;min-height:36px;display:inline-flex;align-items:center;justify-content:center}",
218
+ /* 消息流操作按钮(复制/反馈/分享)触摸目标提升。
219
+ ⚠️ 必须限定在 [class*=flowItem] 内:消息流里**并不存在** [class*=tools]
220
+ (实测消息流只有 xzv4MW_actions / TS9iAW_actions),所以裸
221
+ `[class*=scrollBody] [class*=tools] button` 唯一命中的对象是**输入框里的
222
+ uV2eYG_tools**,把 + / 附件 / 权限 三个按钮压成 36px ——
223
+ 这正是"第一行 36px、第二行 40/44px 高度不一致"的根源
224
+ (2026-09-11 用户反馈"选项卡和输入按钮高度不一致"实测定案)。 */
225
+ " body.dsh-mobile-ui [class*=scrollBody] [class*=flowItem] [class*=actions] button,body.dsh-mobile-ui [class*=scrollBody] [class*=flowItem] [class*=tools] button{min-width:36px;min-height:36px;display:inline-flex;align-items:center;justify-content:center}",
226
+ /* ── 按「行实际宽度」决定权限按钮是否显示文字(容器查询,不用视口猜)──
227
+ 操作行上游自带 container-type:inline-size,故这里以行宽为判据。
228
+ v3 排布下第一行是「工具组 ····· 上下文 发送」,要保住**两行**,
229
+ 权限文字的宽度预算必须让出来:实测 320px 视口(行宽 278px)带文字
230
+ 会挤成三行,故阈值取 290px —— 行宽不足时权限按钮退回纯图标,
231
+ 上下文+发送就能与工具组同行(该视口模型名反而能完整显示)。 */
232
+ " @container (min-width:290px){",
233
+ " body.dsh-mobile-ui button[aria-label*=访问模式] [class*=triggerLabel]{display:block !important}",
234
+ " }",
126
235
  "}",
127
236
 
128
- /* 320px 小屏: 操作行进一步压缩防重叠 */
237
+ /* ≤360px 小屏:仅压缩间距。
238
+ ⚠️ 这里**不再**把模型按钮降级成图标——两行布局后模型独占第二行,
239
+ 320px 视口也有约 244px 可用(可显示 38/40 字符),降级反而丢信息。
240
+ 权限文字也无需降级:容器查询阈值已降到 170px,窄屏同样显示。 */
129
241
  "@media (max-width:360px){",
130
- " body.dsh-mobile-ui button[aria-label*=选择模型]{max-width:110px !important}",
131
242
  " body.dsh-mobile-ui [class*=uV2eYG_row]{gap:2px !important}",
132
- " body.dsh-mobile-ui [class*=uV2eYG_tools],body.dsh-mobile-ui [class*=uV2eYG_modes]{gap:2px !important}",
133
- " body.dsh-mobile-ui button[aria-label*=上下文]{margin-left:8px !important}",
134
243
  "}",
135
244
  /* 抽屉视觉:右侧圆角 + 更实背景 */
136
245
  "body.dsh-mobile-ui [class*=sidebarCol].dsh-mobile-drawer{border-radius:0 18px 18px 0;padding-bottom:64px !important;box-sizing:border-box !important}",
@@ -145,10 +254,17 @@ window.__ModuleLoader__.load({
145
254
  /* trailing 基础 gap 2px:hero(无上下文)模型右扩吃 gap,左缘保持不变 */
146
255
  "body.dsh-mobile-ui [class*=uV2eYG_trailing]{gap:2px !important}",
147
256
  /* 会话页(有上下文按钮):上下文 margin-left 右移 12px(只扩模型↔上下文间距) */
148
- "body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:12px !important}",
149
- "body.dsh-mobile-ui [class*=VOzbGW_nav]{width:110px !important;flex:0 0 110px !important;min-width:110px !important;overflow:visible !important}",
257
+ "body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:4px !important}",
258
+ "body.dsh-mobile-ui [class*=VOzbGW_nav]{width:68px !important;flex:0 0 68px !important;min-width:68px !important;overflow:visible !important}",
150
259
  "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navList]{flex:1 1 auto !important;height:auto !important;max-height:none !important;overflow-y:auto !important}",
151
- "body.dsh-mobile-ui [class*=VOzbGW_content]{flex:1 1 auto !important;min-width:0 !important;max-width:calc(100% - 110px) !important;padding:0 12px !important;overflow:hidden !important}",
260
+ "body.dsh-mobile-ui [class*=VOzbGW_nav] button,body.dsh-mobile-ui [class*=VOzbGW_nav] [role=button]{white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}",
261
+ /* 选项卡改竖排(icon 上/文字下居中):dsh 默认横排 icon+88px 标签总宽 112px,
262
+ 88px 窄栏放不下会裁掉文字;竖排后 88px 宽容纳 4 字标签 */
263
+ "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navCell]{flex-direction:column !important;justify-content:center !important;align-items:center !important;gap:4px !important;padding:4px 0 !important}",
264
+ "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navCell] svg{width:20px !important;height:20px !important;flex:0 0 auto !important}",
265
+ "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navLabel]{flex:0 0 auto !important;width:auto !important;max-width:100% !important;min-width:0 !important;text-align:center !important;font-size:12px !important;line-height:1.2 !important}",
266
+ "body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navTitle]{width:100% !important;box-sizing:border-box !important;padding:0 8px !important;white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}",
267
+ "body.dsh-mobile-ui [class*=VOzbGW_content]{flex:1 1 auto !important;min-width:0 !important;max-width:calc(100% - 68px) !important;padding:0 12px !important;overflow:hidden !important}",
152
268
  /* 头部防重叠加固:关闭按钮固定右侧 + 打开配置文件允许收缩 */
153
269
  "body.dsh-mobile-ui [class*=VOzbGW_header]{display:flex !important;align-items:center !important;justify-content:flex-end !important;gap:8px !important;padding:0 12px !important}",
154
270
  "body.dsh-mobile-ui [class*=VOzbGW_close]{flex:0 0 28px !important;margin-left:0 !important}",
@@ -161,6 +277,14 @@ window.__ModuleLoader__.load({
161
277
  "body.dsh-mobile-ui [class*=VOzbGW_content] [class*=title],body.dsh-mobile-ui [class*=VOzbGW_content] [class*=rowText]{width:100% !important;max-width:100% !important;white-space:normal !important;overflow-wrap:anywhere !important}",
162
278
  "body.dsh-mobile-ui [class*=VOzbGW_content] [class*=title]{display:block !important;flex:1 1 auto !important;align-self:stretch !important}",
163
279
  "body.dsh-mobile-ui [class*=VOzbGW_content] [class*=rowText]{flex:1 1 auto !important;min-width:0 !important}",
280
+ /* 会话行操作菜单(dsh portal 弹层,原 z-index:1100)提升层级:
281
+ 抽屉(2147482995)/遮罩(2147482990)层级过高会把菜单盖住(归档等操作不可见)。
282
+ 仅移动端提升,菜单 fixed 定位不受抽屉 overflow 裁剪;QA 卡片(2147482999)仍居最上 */
283
+ "body.dsh-mobile-ui [class*=_portal_]{z-index:2147482996 !important}",
284
+ /* dsh 模态弹层容器提升:目录选择(添加工作区)/Modal 的 portal root 是 fixed z-index:1000,
285
+ 会被抽屉(2147482995)/遮罩(2147482990)盖住 → 弹框不可见。
286
+ [role=dialog] 定位容器统一提升到 2996 与菜单同级(< QA 2999) */
287
+ "body.dsh-mobile-ui div:has(> [role=dialog]){z-index:2147482996 !important}",
164
288
  ].join("\n");
165
289
 
166
290
  function injectStyle() {
@@ -189,6 +313,7 @@ window.__ModuleLoader__.load({
189
313
  var scrim = null;
190
314
  var drawerOpen = false;
191
315
  var settingsOpen = false; /* 设置弹层打开标志(弹层关闭时恢复导航栏) */
316
+ var qaOpen = false; /* 提问卡片打开标志(卡片浮层打开时隐藏右上角菜单,防遮挡标题) */
192
317
  var savedGrid = null; /* 窄屏前的 frame 原始 grid(宽屏恢复用) */
193
318
 
194
319
  /* 主布局 frame:display:grid 的顶层容器(CSS 生效前 3 列 / 生效后 1 列均可) */
@@ -300,17 +425,23 @@ window.__ModuleLoader__.load({
300
425
  }
301
426
  }
302
427
  document.body.classList.add("dsh-mobile-ui");
303
- /* QA 弹层标记:JS 检测 Mbwy4a 卡片(不依赖 :has,兼容微信 X5 等旧内核)。
304
- 加 class 到 composerSeat,CSS 据此全屏化;卡片消失即移除 */
428
+ /* QA 弹层标记:JS 检测提问卡片(不依赖 :has,兼容微信 X5 等旧内核)。
429
+ 加 class 到 composerSeat,CSS 据此覆盖层化;卡片消失即移除。
430
+ 定位优先用上游稳定属性 data-question-key / data-composer-seat
431
+ (hash 类名在 dsh 升级后会漂移,只作兜底) */
305
432
  try {
306
- var qaFrame = document.querySelector("[class*=Mbwy4a_frame]");
433
+ var qaFrame = document.querySelector("[data-question-key],[class*=Mbwy4a_frame]");
307
434
  var qaSeat = null;
308
435
  if (qaFrame) {
309
- var qn = qaFrame.parentElement;
310
- while (qn) { if ((typeof qn.className === "string" ? qn.className : "").indexOf("composerSeat") >= 0) { qaSeat = qn; break; } qn = qn.parentElement; }
436
+ if (typeof qaFrame.closest === "function") qaSeat = qaFrame.closest("[data-composer-seat],[class*=composerSeat]");
437
+ if (!qaSeat) {
438
+ var qn = qaFrame.parentElement;
439
+ while (qn) { if ((typeof qn.className === "string" ? qn.className : "").indexOf("composerSeat") >= 0) { qaSeat = qn; break; } qn = qn.parentElement; }
440
+ }
311
441
  }
442
+ qaOpen = !!qaSeat;
312
443
  if (qaSeat) qaSeat.classList.add("dsh-mobile-qa");
313
- else { var qOld = document.querySelector("[class*=composerSeat].dsh-mobile-qa"); if (qOld) qOld.classList.remove("dsh-mobile-qa"); }
444
+ else { var qOld = document.querySelector(".dsh-mobile-qa"); if (qOld) qOld.classList.remove("dsh-mobile-qa"); }
314
445
  } catch (e) { /* QA 标记失败静默 */ }
315
446
  /* hero 标题置顶改由纯 CSS 实现(composerHero 撑满视口 + 输入卡沉底),
316
447
  不再用 insertBefore 搬动 React 节点——移动节点会导致 React 重渲染
@@ -324,9 +455,13 @@ window.__ModuleLoader__.load({
324
455
  /* 抽屉态下 sidebar 保持可见,否则隐藏 */
325
456
  side.style.display = drawerOpen ? "" : "none";
326
457
  ensureChrome();
327
- if (tabbar) tabbar.style.display = drawerOpen ? "none" : "";
458
+ /* 菜单按钮三态判定:抽屉 / 设置面板 / 提问卡片浮层打开时都隐藏。
459
+ (旧写法只看 drawerOpen,会把设置面板打开时隐藏的菜单在下次 sync 又显示回来;
460
+ 提问卡片时则会浮在卡片右上角遮住问题标题) */
461
+ if (tabbar) tabbar.style.display = (drawerOpen || settingsOpen || qaOpen) ? "none" : "";
328
462
  } else {
329
463
  document.body.classList.remove("dsh-mobile-ui");
464
+ qaOpen = false;
330
465
  var frame2 = findFrame();
331
466
  if (frame2) {
332
467
  if (savedGrid) frame2.style.gridTemplateColumns = savedGrid;
package/install.sh CHANGED
@@ -46,6 +46,10 @@ done
46
46
  [ -n "$PLUGIN" ] || { echo "错误: 缺少 --plugin <name>"; exit 1; }
47
47
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
48
48
  [ -n "$SRC" ] || SRC="$HERE"
49
+ # tarball 分发布局:install.sh 在根、插件源码在同名子目录(如 <name>-install/<name>/)
50
+ if [ ! -f "$SRC/package.json" ] && [ -f "$SRC/$PLUGIN/package.json" ]; then
51
+ SRC="$SRC/$PLUGIN"
52
+ fi
49
53
  # 契约预检脚本位置:优先所在仓库(packages/<name>/install.sh → ../../scripts),
50
54
  # 其次环境变量 DSH_PLUGINS_REPO,最后 $HOME/dsh-plugins
51
55
  for CAND in "$HERE/../../scripts" "${DSH_PLUGINS_REPO:-$HOME/dsh-plugins}/scripts"; do
@@ -221,14 +225,48 @@ fi
221
225
 
222
226
  if [ "$MODE" = "restart" ]; then
223
227
  echo "== 4/4 重启 dsh =="
224
- pkill -TERM -f 'node_modules/.bin/dsh web' 2>/dev/null
225
- pkill -TERM -f 'npm exec @deepseek-ai/dsh web' 2>/dev/null
226
- sleep 3
227
- if [ -n "$ROOT" ] && [ -x "$ROOT/node_modules/.bin/dsh" ]; then
228
- cd "$ROOT" || exit 1
229
- setsid nohup ./node_modules/.bin/dsh web >> /tmp/dsh-web.log 2>&1 < /dev/null &
230
- echo " 新进程 PID=$!"
231
- sleep 8
228
+ # 统一重启逻辑:systemd 托管优先,回退 pkill(MR-026)
229
+ if [ -f "$HERE/_dsh-common/dsh-restart.sh" ]; then
230
+ # tarball 内自带共享重启脚本(package.sh 打入 _dsh-common/)
231
+ . "$HERE/_dsh-common/dsh-restart.sh"
232
+ restart_dsh "$ROOT"
233
+ elif [ -n "$COMMON" ] && [ -f "$COMMON/dsh-restart.sh" ]; then
234
+ # 开发环境:dsh-plugins 仓库 scripts/
235
+ . "$COMMON/dsh-restart.sh"
236
+ restart_dsh "$ROOT"
237
+ else
238
+ # 极端场景(无共享脚本):内联完整重启逻辑(systemd → pkill 回退)
239
+ SVC=""
240
+ if command -v systemctl >/dev/null 2>&1; then
241
+ for SVC in dsh.service dsh-web.service; do
242
+ systemctl --user is-active --quiet "$SVC" 2>/dev/null && break
243
+ done
244
+ fi
245
+ if [ -n "$SVC" ]; then
246
+ echo " [systemd] $SVC 托管中 → systemctl --user restart $SVC"
247
+ systemctl --user restart "$SVC"
248
+ sleep 6
249
+ curl -s --noproxy '*' -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪"
250
+ else
251
+ echo " [回退] 无 systemd 托管,pkill + 手动拉起"
252
+ pkill -TERM -f "dsh web" 2>/dev/null
253
+ pkill -TERM -f 'node_modules/.bin/dsh web' 2>/dev/null
254
+ pkill -TERM -f 'npm exec @deepseek-ai/dsh web' 2>/dev/null
255
+ pkill -TERM -f 'sh -c dsh web' 2>/dev/null
256
+ pkill -TERM -f 'dsh/lib/bin.js web' 2>/dev/null
257
+ sleep 3
258
+ if [ -n "$ROOT" ] && [ -x "$ROOT/node_modules/.bin/dsh" ]; then
259
+ cd "$ROOT" || exit 1
260
+ setsid nohup ./node_modules/.bin/dsh web >> /tmp/dsh-web.log 2>&1 < /dev/null &
261
+ echo " 新进程 PID=$!"
262
+ sleep 8
263
+ curl -s --noproxy '*' -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪"
264
+ else
265
+ echo " [警告] 无法定位 dsh 可执行文件,请手动重启 dsh web"
266
+ fi
267
+ fi
268
+ fi
269
+ if [ -x "$ROOT/node_modules/.bin/dsh" ]; then
232
270
  curl -s -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪,请稍后手动刷新"
233
271
  curl -s -o /dev/null -w " $PLUGIN bundle -> %{http_code}\n" "http://127.0.0.1:3080/plugins/$PLUGIN/client.js"
234
272
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
4
  "description": "Mobile UI for the DeepSeek Harness Web GUI: responsive layout, overlay session drawer, 44px touch targets, safe-area support, reading enhancements (≤768px, zero desktop impact)",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env python3
2
+ """dsh-mobile-ui 移动端布局探针:两处可读性/几何回归测试。
3
+
4
+ ## 一、提问卡片(ask_user_question)
5
+
6
+ 提问卡片曾在手机上出现「选项看不全 + 手指滑动也没反应」的故障(2026-09-11 定位修复)。
7
+ 根因不是样式不生效,而是**覆盖了上游唯一的滚动容器**:
8
+
9
+ 上游 .card { max-height: min(60vh,520px); overflow: hidden }
10
+ 上游 .body { overflow-y: auto } ← 卡片内唯一的滚动容器
11
+ 旧插件规则 .body { overflow: visible } ← 把滚动容器废掉
12
+ ⇒ ① 超出卡片的选项被 card 的 overflow:hidden 直接裁掉
13
+ ② body 不再是滚动容器,③ 覆盖层里也没有可滚内容
14
+ ⇒ 手指滑动零反应,后面的选项永久不可见
15
+
16
+ ## 二、输入区操作行(composer row)
17
+
18
+ 操作行曾出现「模型选择器压住上下文环 / 访问模式按钮压住模型选择器」(2026-09-11 定位修复)。
19
+ 根因是**两条插件规则互相配合出的溢出**:
20
+
21
+ 旧插件规则 [class*=uV2eYG_row]{flex-wrap:nowrap} ← 禁止换行
22
+ 旧插件规则 [class*=uV2eYG_row] > *{flex:0 1 auto} ← 组可收缩
23
+ 上游组内按钮 min-width:44px ← 但按钮不可压缩
24
+ ⇒ 行宽不足时 flex 无法通过收缩解决,又禁止换行 ⇒ 各控件**溢出重叠**
25
+ (实测 390px 会话页:访问模式↔模型重叠 5.7px、模型↔上下文重叠 4.3px)
26
+
27
+ 这类故障同样**静默且只在特定宽度/控件数下出现**(hero 页 4 控件正常、会话页
28
+ 多一个上下文按钮就重叠),肉眼审查 CSS 极难穷举,所以固化成几何测量:
29
+ 真实上游 CSS + 真实 DOM 嵌套 + 真实插件代码,断言「任意两个控件都不重叠」。
30
+
31
+ ## 用法
32
+
33
+ python3 tests/mobile-layout-probe.py # 默认测当前安装副本
34
+ python3 tests/mobile-layout-probe.py --plugin ./client.js
35
+ python3 tests/mobile-layout-probe.py --viewport 320x568 --options 12
36
+ python3 tests/mobile-layout-probe.py --only composer # 只跑操作行
37
+ python3 tests/mobile-layout-probe.py --only qa # 只跑提问卡片
38
+
39
+ 依赖:playwright(python)+ 已安装的 dsh(用于抽取上游 CSS)。退出码非 0 表示回归。
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import pathlib
47
+ import re
48
+ import sys
49
+ import tempfile
50
+
51
+ DSH_NODE_MODULES = pathlib.Path(
52
+ os.environ.get(
53
+ "DSH_NODE_MODULES",
54
+ "/home/wbaifan/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai",
55
+ )
56
+ )
57
+ DEFAULT_PLUGIN = pathlib.Path.home() / ".dsh/plugins/dsh-mobile-ui/client.js"
58
+
59
+ CONV_CSS_RE = r'const css\$4 = ("(?:[^"\\]|\\.)*");'
60
+ QQ_CSS_RE = r'const css = ("(?:[^"\\]|\\.)*");'
61
+ # 输入区(InputBar)上游 CSS:uV2eYG 词根
62
+ INPUTBAR_CSS_RE = r'const css\$1 = ("(?:[^"\\]|\\.)*");'
63
+
64
+
65
+ def extract_css(path: pathlib.Path, pattern: str, what: str) -> str:
66
+ """从已安装的 dsh 客户端包里抽出上游 CSS(保证复现页与线上像素级同源)。"""
67
+ if not path.exists():
68
+ raise SystemExit(f"找不到 {path}(用 DSH_NODE_MODULES 指定 dsh 安装位置)")
69
+ m = re.search(pattern, path.read_text(encoding="utf-8"))
70
+ if not m:
71
+ raise SystemExit(f"未能从 {path} 抽取 {what} 的 CSS(上游结构可能已变化)")
72
+ return eval(m.group(1))
73
+
74
+
75
+ def build_page(plugin: pathlib.Path, options: int, detail_paras: int) -> str:
76
+ conv_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", CONV_CSS_RE, "ConversationRoot")
77
+ qq_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-user-questions/lib/client.js", QQ_CSS_RE, "QuestionComposer")
78
+
79
+ detail = "".join(
80
+ f"<p>背景说明段落 {i + 1}:用于模拟真实场景中 detail 字段的长文本,"
81
+ f"它会明显占据垂直空间。</p>"
82
+ for i in range(detail_paras)
83
+ )
84
+ opts = "".join(
85
+ f"""
86
+ <button type="button" class="Mbwy4a_option" role="radio" aria-checked="false" aria-label="选项 {i + 1}">
87
+ <span class="Mbwy4a_number">{i + 1}</span>
88
+ <span class="Mbwy4a_optionCopy"><span class="Mbwy4a_optionLine">
89
+ <span class="Mbwy4a_optionLabel">选项 {i + 1}:第 {i + 1} 个候选方案的简短标题</span>
90
+ <span class="Mbwy4a_description">— 该方案的补充说明,用于占位真实文本长度。</span>
91
+ </span></span>
92
+ </button>"""
93
+ for i in range(options)
94
+ )
95
+
96
+ return f"""<!doctype html>
97
+ <html lang="zh-CN"><head><meta charset="utf-8">
98
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
99
+ <title>mobile-layout-probe</title>
100
+ <style>
101
+ :root{{
102
+ --dsw-alias-bg-base:#16171b; --dsw-alias-bg-layer-1:#1e1f24; --dsw-specific-input-major:#26272d;
103
+ --dsw-alias-label-primary:rgba(255,255,255,.9); --dsw-alias-border-l2:rgba(255,255,255,.14);
104
+ --dsw-alias-scrollbar-bg-l2:rgba(255,255,255,.2); --dsw-alias-scrollbar-hover-l2:rgba(255,255,255,.3);
105
+ --dsh-composer-side-clearance:12px; --dsh-composer-card-max-width:720px;
106
+ --dsh-chat-user-width:clamp(680px, 60vw, 860px); --dsh-scrollbar-width:0px;
107
+ --dsh-conversation-column-width:100%;
108
+ }}
109
+ html,body{{margin:0;padding:0;height:100%;background:var(--dsw-alias-bg-base);color:#eee;
110
+ font-family:-apple-system,"PingFang SC",system-ui,sans-serif}}
111
+ {conv_css}
112
+ {qq_css}
113
+ /* 顶层容器必须带 frame 词根:插件靠 [class*=frame] 找主布局(真实 dsh 同构) */
114
+ #app{{height:100%;display:grid;grid-template-columns:260px minmax(0,1fr) 0px}}
115
+ .sidebarCol{{background:#1a1b1f;border-right:1px solid rgba(255,255,255,.08)}}
116
+ .detailsCol{{background:#1a1b1f}}
117
+ .msg{{padding:10px 14px;margin:8px 12px;border-radius:12px;background:#22232a;line-height:1.5}}
118
+ </style></head>
119
+ <body>
120
+ <div id="app" class="frame">
121
+ <div class="sidebarCol" data-testid="sidebar">sidebar</div>
122
+ <div class="wSkVaW_root" data-phase="active">
123
+ <div class="wSkVaW_header" style="padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.08)">会话标题</div>
124
+ <div class="wSkVaW_body">
125
+ <div class="wSkVaW_scrollBody" data-conversation-scroll>
126
+ <div data-slot="conversation.session"><div class="wSkVaW_viewArea">
127
+ <div class="msg">用户:帮我看下这个方案。</div>
128
+ <div class="msg">助手:好的,我需要你确认几个问题。</div>
129
+ </div></div>
130
+ <div class="wSkVaW_composerSeat" data-composer-seat>
131
+ <div class="Mbwy4a_frame" data-question-key="probe">
132
+ <section class="Mbwy4a_card" aria-labelledby="q1">
133
+ <header class="Mbwy4a_header">
134
+ <div class="Mbwy4a_headingBlock">
135
+ <div class="Mbwy4a_eyebrow">需要你的确认</div>
136
+ <h2 class="Mbwy4a_title" id="q1">这个任务应该按哪种方式继续?请在下面选择一项。</h2>
137
+ </div>
138
+ <div class="Mbwy4a_headerActions">
139
+ <button type="button" class="Mbwy4a_iconButton" aria-label="最小化">⌄</button>
140
+ <button type="button" class="Mbwy4a_iconButton" aria-label="取消">✕</button>
141
+ </div>
142
+ </header>
143
+ <div class="Mbwy4a_body" data-question-scroll>
144
+ <div class="Mbwy4a_detail">{detail}</div>
145
+ <div class="Mbwy4a_options" role="radiogroup">{opts}
146
+ <div class="Mbwy4a_customRow">
147
+ <span class="Mbwy4a_number" aria-hidden="true">+</span>
148
+ <input class="Mbwy4a_fieldInput" placeholder="其他(自定义回答)">
149
+ </div>
150
+ </div>
151
+ </div>
152
+ <div class="Mbwy4a_footer">
153
+ <div class="Mbwy4a_footerActions"><button class="Mbwy4a_iconButton">跳过</button></div>
154
+ <div class="Mbwy4a_footerActions">
155
+ <span class="Mbwy4a_progress">1/1</span>
156
+ <button class="Mbwy4a_primary">提交</button>
157
+ </div>
158
+ </div>
159
+ </section>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ <div class="detailsCol" data-testid="details">details</div>
166
+ </div>
167
+ <script>window.__ModuleLoader__={{mode:"queue",load:function(reg){{window.__reg=reg}}}};</script>
168
+ <script src="file://{plugin}"></script>
169
+ <script>window.__reg.factory(function(){{throw new Error("no require")}});window.__reg.factory;window.__plug=window.__reg;window.__plug.factory(function(){{}});</script>
170
+ <script>try{{window.__plug.factory(function(){{}});}}catch(e){{}}</script>
171
+ </body></html>"""
172
+
173
+
174
+ MEASURE_JS = r"""
175
+ () => {
176
+ const q = (s) => document.querySelector(s);
177
+ const seat = q('[data-composer-seat]');
178
+ const body = q('[data-question-scroll]');
179
+ const opts = [...document.querySelectorAll('button[class*=Mbwy4a_option]')];
180
+ const vis = (el) => {
181
+ if (!el) return {ok:false, why:'missing'};
182
+ const r = el.getBoundingClientRect();
183
+ if (!r.width || !r.height) return {ok:false, why:'zero-size'};
184
+ const cx = r.left + r.width/2, cy = r.top + r.height/2;
185
+ if (cy < 0 || cy > innerHeight) return {ok:false, why:'offscreen'};
186
+ const hit = document.elementFromPoint(cx, cy);
187
+ return {ok: !!hit && (hit === el || el.contains(hit) || hit.contains(el)), why: hit ? String(hit.className||hit.tagName) : 'null'};
188
+ };
189
+ return {
190
+ vp: {w: innerWidth, h: innerHeight},
191
+ seatQa: !!(seat && /dsh-mobile-qa/.test(seat.className)),
192
+ bodyOverflow: body ? getComputedStyle(body).overflowY : null,
193
+ bodyScroll: body ? body.scrollHeight - body.clientHeight : 0,
194
+ last: vis(opts[opts.length-1]),
195
+ submit: vis(q('[class*=Mbwy4a_primary]')),
196
+ // 右上角菜单按钮在提问卡片打开时必须隐藏,否则会浮在卡片右上角遮住问题标题
197
+ menuBtnDisplay: (() => { const m = document.getElementById('dsh-mobile-menu-btn'); return m ? getComputedStyle(m).display : null; })(),
198
+ };
199
+ }
200
+ """
201
+
202
+
203
+ def apply_plugin(page, plugin: pathlib.Path) -> None:
204
+ """在页面里按真实加载路径执行插件:注册 → factory → apply(ctx)。"""
205
+ page.add_script_tag(path=str(plugin))
206
+ page.evaluate(
207
+ """() => {
208
+ const reg = window.__reg;
209
+ window.__plug = reg.factory(function () { throw new Error('no require'); });
210
+ window.__plug.apply({ logger: { info() {} } });
211
+ }"""
212
+ )
213
+
214
+
215
+ def build_composer_page(plugin: pathlib.Path, with_context: bool, with_effort: bool,
216
+ model_label: str = "commandcode/deepseek/deepseek-v4.1-flash") -> str:
217
+ """复现页:真实上游 InputBar CSS + 真实 DOM 嵌套(composerSeat > stack > root > card > row > tools/trailing)。
218
+
219
+ with_context: 会话页有「上下文已用」按钮(hero 页没有)——正是多这一个控件才触发重叠。
220
+ with_effort: 模型名带推理等级后缀(triggerEffort),会额外占宽。
221
+ model_label: 模型显示名。默认用超长 provider/model(最坏情况,考验省略号);
222
+ 短名(目录内模型的友好名)用于对照正常观感。
223
+ """
224
+ conv_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", CONV_CSS_RE, "ConversationRoot")
225
+ input_css = extract_css(
226
+ DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", INPUTBAR_CSS_RE, "InputBar")
227
+
228
+ effort = '<span class="_7KE1Ra_triggerEffort">Max</span>' if with_effort else ""
229
+ ctx_btn = (
230
+ '<span class="JObwrW_root"><button type="button" class="JObwrW_trigger" '
231
+ 'aria-label="上下文已用 18%" aria-haspopup="dialog"><svg viewBox="0 0 14 14" width="14" height="14">'
232
+ '<circle class="JObwrW_track" cx="7" cy="7" r="5"></circle>'
233
+ '<circle class="JObwrW_fill" cx="7" cy="7" r="5" stroke-dasharray="5.6 31.4" '
234
+ 'transform="rotate(-90 7 7)"></circle></svg></button></span>'
235
+ if with_context else ""
236
+ )
237
+
238
+ return f"""<!doctype html>
239
+ <html lang="zh-CN"><head><meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
241
+ <title>composer-row-probe</title>
242
+ <style>
243
+ :root{{
244
+ --dsw-alias-bg-base:#16171b; --dsw-alias-bg-layer-1:#1e1f24; --dsw-specific-input-major:#26272d;
245
+ --dsw-specific-menu:#2a2b31; --dsw-specific-selector:#33343a;
246
+ --dsw-alias-label-primary:rgba(255,255,255,.9); --dsw-alias-label-secondary:rgba(255,255,255,.7);
247
+ --dsw-alias-label-tertiary:rgba(255,255,255,.5); --dsw-alias-label-caption:rgba(255,255,255,.4);
248
+ --dsw-alias-border-l2:rgba(255,255,255,.14); --dsw-alias-border-l3:rgba(255,255,255,.2);
249
+ --dsw-alias-interactive-bg-hover:rgba(255,255,255,.08);
250
+ --dsh-composer-side-clearance:12px; --dsh-composer-card-max-width:720px;
251
+ --dsh-composer-text-max-height:200px; --dsh-content-font-size:14px;
252
+ --dsh-conversation-column-width:100%;
253
+ }}
254
+ html,body{{margin:0;padding:0;height:100%;background:var(--dsw-alias-bg-base);color:#eee;
255
+ font-family:-apple-system,"PingFang SC",system-ui,sans-serif}}
256
+ {conv_css}
257
+ {input_css}
258
+ #app{{height:100%;display:grid;grid-template-columns:260px minmax(0,1fr) 0px}}
259
+ .sidebarCol{{background:#1a1b1f}} .detailsCol{{background:#1a1b1f}}
260
+ </style></head>
261
+ <body>
262
+ <div id="app" class="frame">
263
+ <div class="sidebarCol" data-testid="sidebar">sidebar</div>
264
+ <div class="wSkVaW_root" data-phase="active">
265
+ <div class="wSkVaW_header" style="padding:8px 12px">会话标题</div>
266
+ <div class="wSkVaW_body">
267
+ <div class="wSkVaW_scrollBody" data-conversation-scroll>
268
+ <div data-slot="conversation.session"><div class="wSkVaW_viewArea">&nbsp;</div></div>
269
+ <div class="wSkVaW_composerSeat" data-composer-seat>
270
+ <div class="wSkVaW_composerStack">
271
+ <div class="uV2eYG_root">
272
+ <div class="uV2eYG_card">
273
+ <div class="uV2eYG_scroll"><div class="uV2eYG_grow">
274
+ <div class="uV2eYG_input" contenteditable="true" aria-label="发消息"></div>
275
+ </div></div>
276
+ <div class="uV2eYG_row">
277
+ <div class="uV2eYG_tools">
278
+ <button type="button" class="uV2eYG_add" aria-label="指令">
279
+ <svg viewBox="0 0 16 16" width="14" height="14"><path d="M8 3v10M3 8h10" stroke="currentColor" fill="none"/></svg></button>
280
+ <button type="button" class="uV2eYG_add" aria-label="添加附件">
281
+ <svg viewBox="0 0 16 16" width="14" height="14"><path d="M5 8h6" stroke="currentColor" fill="none"/></svg></button>
282
+ <div class="uV2eYG_modes">
283
+ <button type="button" class="Sh0Q9G_trigger" aria-label="访问模式,当前:完全权限"
284
+ ><span class="Sh0Q9G_triggerIcon"><svg viewBox="0 0 16 16" width="14" height="14"><path d="M8 1l6 3v4c0 4-3 6-6 7-3-1-6-3-6-7V4z" fill="none" stroke="currentColor"/></svg></span><span class="Sh0Q9G_triggerLabel">完全权限</span></button>
285
+ </div>
286
+ </div>
287
+ <div class="uV2eYG_trailing">
288
+ <div class="_7KE1Ra_root">
289
+ <button type="button" class="_7KE1Ra_trigger" aria-label="选择模型,当前 {model_label}" aria-haspopup="menu">
290
+ <svg class="_7KE1Ra_triggerIcon" viewBox="0 0 16 16" width="16" height="16"><path d="M2 8h12" stroke="currentColor" fill="none"/></svg>
291
+ <span class="_7KE1Ra_triggerLabel">{model_label}</span>
292
+ {effort}
293
+ <svg class="_7KE1Ra_chevron" viewBox="0 0 14 14" width="14" height="14"><path d="M4 6l3 3 3-3" stroke="currentColor" fill="none"/></svg>
294
+ </button>
295
+ </div>
296
+ {ctx_btn}
297
+ <button type="button" class="uV2eYG_primary" aria-label="发送消息">
298
+ <svg viewBox="0 0 16 16" width="16" height="16"><path d="M8 13V3M4 7l4-4 4 4" stroke="currentColor" fill="none"/></svg></button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ <div class="detailsCol" data-testid="details">details</div>
309
+ </div>
310
+ <script>window.__ModuleLoader__={{mode:"queue",load:function(reg){{window.__reg=reg}}}};</script>
311
+ <script src="file://{plugin}"></script>
312
+ <script>window.__plug=window.__reg;window.__plug.factory(function(){{}});</script>
313
+ </body></html>"""
314
+
315
+
316
+ COMPOSER_MEASURE_JS = r"""
317
+ () => {
318
+ const row = document.querySelector('[class*=uV2eYG_row]');
319
+ if (!row) return {err: 'no composer row'};
320
+ const btns = [...row.querySelectorAll('button')].map(b => {
321
+ const r = b.getBoundingClientRect();
322
+ return {aria: (b.getAttribute('aria-label')||'').slice(0, 24),
323
+ x: +r.x.toFixed(1), y: +r.y.toFixed(1), right: +r.right.toFixed(1),
324
+ bottom: +r.bottom.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1)};
325
+ });
326
+ // 任意两个控件重叠 = 回归(正是 2026-09-11 的故障形态)
327
+ const overlaps = [];
328
+ for (let i = 0; i < btns.length; i++) for (let j = i + 1; j < btns.length; j++) {
329
+ const a = btns[i], b = btns[j];
330
+ const ox = Math.min(a.right, b.right) - Math.max(a.x, b.x);
331
+ const oy = Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y);
332
+ if (ox > 0.5 && oy > 0.5) overlaps.push({a: a.aria, b: b.aria, ox: +ox.toFixed(1)});
333
+ }
334
+ const card = document.querySelector('[class*=uV2eYG_card]');
335
+ const cardRight = card ? +card.getBoundingClientRect().right.toFixed(1) : null;
336
+ // 控件越出输入卡右边界 = 溢出(窄屏下被裁掉,点不到)
337
+ const spill = cardRight === null ? [] : btns
338
+ .filter(b => b.right > cardRight + 0.5)
339
+ .map(b => ({aria: b.aria, over: +(b.right - cardRight).toFixed(1)}));
340
+ const rowBox = row.getBoundingClientRect();
341
+
342
+ // ── 子元素可见性断言(2026-09-11 两个静默故障的守卫)──
343
+ const shown = (sel) => { const el = document.querySelector(sel);
344
+ return el ? getComputedStyle(el).display !== 'none' : null; };
345
+ const accessIconShown = shown('button[aria-label*=访问模式] [class*=triggerIcon]');
346
+ // 权限文字只在行够宽时出现;出现时必须真的可见(不能是 0 宽空壳)
347
+ const accessLabelEl = document.querySelector('button[aria-label*=访问模式] [class*=triggerLabel]');
348
+ const accessLabelShown = accessLabelEl ? getComputedStyle(accessLabelEl).display !== 'none' : false;
349
+ const accessLabelW = accessLabelEl && accessLabelShown
350
+ ? +accessLabelEl.getBoundingClientRect().width.toFixed(1) : 0;
351
+ // 推理等级后缀移动端必须隐藏(否则被压成 8px 宽的半截字符)
352
+ const effortShown = shown('button[aria-label*=选择模型] [class*=triggerEffort]');
353
+
354
+ // 省略号是否真的生效:被裁的 label 必须 overflow:hidden + text-overflow:ellipsis
355
+ // (flex 容器会忽略 text-overflow → 硬切出半个字符,正是用户看到的"半截字母")
356
+ const label = document.querySelector('button[aria-label*=选择模型] [class*=_7KE1Ra_triggerLabel]');
357
+ let ellipsisOk = true, modelLabelClipped = false, modelLabelW = null;
358
+ if (label) {
359
+ const lcs = getComputedStyle(label);
360
+ const shown = lcs.display !== 'none';
361
+ modelLabelW = shown ? +label.getBoundingClientRect().width.toFixed(1) : 0;
362
+ modelLabelClipped = label.scrollWidth > label.clientWidth + 1;
363
+ if (modelLabelClipped) {
364
+ ellipsisOk = lcs.overflow === 'hidden' && lcs.textOverflow === 'ellipsis'
365
+ && lcs.display !== 'flex';
366
+ }
367
+ }
368
+
369
+ // ── 每行右侧空块宽度(2026-09-11 用户反馈"上面空了一块"的守卫)──
370
+ // 旧排布把工具组独占第一行,右侧留白 183px(412px 实测)→ 一眼看出"空了一块"。
371
+ // 判据:每行最右控件的右缘到卡片右缘的空白不得超过 60px(约一个按钮宽)。
372
+ // ── 视觉一致性(2026-09-11 用户反馈"高度不一致、怪怪的"的守卫)──
373
+ // 同一操作行内所有按钮必须等高、同字号,且行内垂直中心离散 ≤1.5px
374
+ const heights = [...new Set(btns.map(b => Math.round(b.h)))];
375
+ const fonts = [...new Set([...row.querySelectorAll('button')]
376
+ .map(b => getComputedStyle(b).fontSize))];
377
+ // 按 top 分簇(不能用中心 y:控件 44px 高、两行中心仅差 50px,
378
+ // 阈值稍大就会把两行并成一簇。等高后同行 top 必然相同,故对 top 聚类最稳)
379
+ const tops = btns.map(b => b.y).sort((a, b) => a - b);
380
+ const clusters = [];
381
+ for (const ty of tops) {
382
+ if (!clusters.length || ty - clusters[clusters.length - 1][0] > 6) clusters.push([ty]);
383
+ else clusters[clusters.length - 1].push(ty);
384
+ }
385
+ const lineSpreads = clusters.map(c => +(Math.max(...c) - Math.min(...c)).toFixed(1));
386
+ // 每行右侧空块(行最右控件右缘 → 卡片右缘)
387
+ // ⚠️ clusters 的每个元素是「该行 top 的数组」,取 [0] 才是行 top
388
+ //(曾误把整个数组当数值比较,导致空块恒算出 0、守卫静默失效)
389
+ const lineRightGaps = cardRight === null ? [] : clusters.map(cluster => {
390
+ const lineTop = cluster[0];
391
+ const rowBtns = btns.filter(b => Math.abs(b.y - lineTop) <= 6);
392
+ return rowBtns.length ? +(cardRight - Math.max(...rowBtns.map(b => b.right))).toFixed(1) : 0;
393
+ });
394
+
395
+ // 垂直对齐:label / chevron / 图标中心相对按钮中心的偏差 ≤1.5px
396
+ const cy = (el) => { const b = el.getBoundingClientRect(); return b.y + b.height / 2; };
397
+ const misaligned = [];
398
+ for (const [name, btnSel, innerSel] of [
399
+ ['模型名', 'button[aria-label*=选择模型]', '[class*=_7KE1Ra_triggerLabel]'],
400
+ ['模型箭头', 'button[aria-label*=选择模型]', '[class*=_7KE1Ra_chevron]'],
401
+ ['权限图标', 'button[aria-label*=访问模式]', '[class*=triggerIcon]']]) {
402
+ const btn = document.querySelector(btnSel), el = document.querySelector(innerSel);
403
+ if (!btn || !el || getComputedStyle(el).display === 'none') continue;
404
+ const dev = Math.abs(cy(el) - cy(btn));
405
+ if (dev > 1.5) misaligned.push({name, dev: +dev.toFixed(1)});
406
+ }
407
+
408
+ return {
409
+ rowH: +rowBox.height.toFixed(1), rowW: +rowBox.width.toFixed(1),
410
+ count: btns.length,
411
+ minW: btns.length ? Math.min(...btns.map(b => b.w)) : 0,
412
+ overlaps, spill,
413
+ accessIconShown, accessLabelShown, accessLabelW, effortShown,
414
+ modelLabelClipped, ellipsisOk, misaligned, modelLabelW,
415
+ heights, fonts, lineSpreads, lineRightGaps,
416
+ // 控件是否都可点(中心点命中自己)
417
+ unclickable: btns.filter(b => {
418
+ const cx = b.x + b.w / 2, cy2 = b.y + b.h / 2;
419
+ const hit = document.elementFromPoint(cx, cy2);
420
+ const el = [...row.querySelectorAll('button')].find(x => {
421
+ const r = x.getBoundingClientRect();
422
+ return Math.abs(r.x - b.x) < .5 && Math.abs(r.width - b.w) < .5; });
423
+ return !hit || !el || !(hit === el || el.contains(hit) || hit.contains(el));
424
+ }).map(b => b.aria),
425
+ };
426
+ }
427
+ """
428
+
429
+
430
+ def probe_composer(plugin, viewport, with_context, with_effort, workdir,
431
+ model_label="commandcode/deepseek/deepseek-v4.1-flash"):
432
+ """操作行几何探针:断言控件互不重叠、不越界、可点击,且关键子元素该显示的都在。"""
433
+ from playwright.sync_api import sync_playwright
434
+
435
+ w, h = viewport
436
+ html = build_composer_page(plugin, with_context, with_effort, model_label)
437
+ tag = (f"composer-{'sess' if with_context else 'hero'}-{'eff' if with_effort else 'noeff'}"
438
+ f"-{'long' if len(model_label) > 20 else 'short'}-{w}x{h}")
439
+ page_path = workdir / f"{tag}.html"
440
+ page_path.write_text(html, encoding="utf-8")
441
+
442
+ with sync_playwright() as p:
443
+ browser = p.chromium.launch()
444
+ ctx = browser.new_context(
445
+ viewport={"width": w, "height": h}, device_scale_factor=2, is_mobile=True, has_touch=True
446
+ )
447
+ page = ctx.new_page()
448
+ page.goto(page_path.as_uri())
449
+ apply_plugin(page, plugin)
450
+ page.wait_for_timeout(500)
451
+ m = page.evaluate(COMPOSER_MEASURE_JS)
452
+ browser.close()
453
+
454
+ return {
455
+ "viewport": f"{w}x{h}",
456
+ "variant": ("会话页(含上下文)" if with_context else "首页(hero)")
457
+ + ("/长模型名" if len(model_label) > 20 else "/短模型名"),
458
+ "effort": with_effort,
459
+ **m,
460
+ }
461
+
462
+
463
+ def probe(plugin, viewport, options, detail_paras, workdir):
464
+ from playwright.sync_api import sync_playwright
465
+
466
+ w, h = viewport
467
+ html = build_page(plugin, options, detail_paras)
468
+ page_path = workdir / f"probe-{options}-{w}x{h}.html"
469
+ page_path.write_text(html, encoding="utf-8")
470
+
471
+ with sync_playwright() as p:
472
+ browser = p.chromium.launch()
473
+ ctx = browser.new_context(
474
+ viewport={"width": w, "height": h}, device_scale_factor=2, is_mobile=True, has_touch=True
475
+ )
476
+ page = ctx.new_page()
477
+ page.goto(page_path.as_uri())
478
+ apply_plugin(page, plugin)
479
+ page.wait_for_timeout(500)
480
+
481
+ before = page.evaluate(MEASURE_JS)
482
+
483
+ # 真实手指滑动(touch 事件;滚轮不等价:移动端页面常只响应 touch)。
484
+ # 反复滑动直到末项可见(模拟用户持续上滑),最多 8 次;
485
+ # 滚不动的情况下滑多少次都不会可见 —— 因此不会掩盖真问题。
486
+ body = page.query_selector("[data-question-scroll]")
487
+ box = body.bounding_box()
488
+ swipes = 0
489
+ after_swipe = before
490
+ if box:
491
+ cx, y0, y1 = box["x"] + box["width"] / 2, box["y"] + box["height"] - 24, box["y"] + 24
492
+ cdp = ctx.new_cdp_session(page)
493
+ while swipes < 8:
494
+ cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": y0}]})
495
+ for i in range(1, 11):
496
+ cdp.send("Input.dispatchTouchEvent", {
497
+ "type": "touchMove", "touchPoints": [{"x": cx, "y": y0 + (y1 - y0) * i / 10}]})
498
+ page.wait_for_timeout(16)
499
+ cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
500
+ page.wait_for_timeout(200)
501
+ swipes += 1
502
+ after_swipe = page.evaluate(MEASURE_JS)
503
+ if after_swipe["last"]["ok"]:
504
+ break
505
+ browser.close()
506
+
507
+ return {
508
+ "viewport": f"{w}x{h}",
509
+ "options": options,
510
+ "detail_paras": detail_paras,
511
+ "scrollable": (before["bodyScroll"] or 0) > 1 and before["bodyOverflow"] in ("auto", "scroll"),
512
+ "needs_scroll": (before["bodyScroll"] or 0) > 1,
513
+ "last_visible_after_swipe": after_swipe["last"]["ok"],
514
+ "submit_visible": before["submit"]["ok"],
515
+ "menu_btn_hidden_while_qa": before["menuBtnDisplay"] == "none",
516
+ "swipes_to_reach_last": swipes,
517
+ "seat_qa": before["seatQa"],
518
+ "detail": {"before": before, "after_swipe": after_swipe},
519
+ }
520
+
521
+
522
+ def main() -> int:
523
+ ap = argparse.ArgumentParser(description="dsh-mobile-ui 移动端布局探针(提问卡片 + 输入区操作行)")
524
+ ap.add_argument("--plugin", default=str(DEFAULT_PLUGIN), help="被测 client.js 路径")
525
+ ap.add_argument("--viewport", action="append", default=None, help="WxH,可重复(默认覆盖常见机型)")
526
+ ap.add_argument("--options", type=int, action="append", default=None, help="选项数量,可重复")
527
+ ap.add_argument("--detail-paras", type=int, default=2, help="detail 长文本段落数")
528
+ ap.add_argument("--only", choices=["qa", "composer"], default=None, help="只跑其中一组")
529
+ ap.add_argument("--json", action="store_true", help="输出 JSON")
530
+ args = ap.parse_args()
531
+
532
+ plugin = pathlib.Path(args.plugin).expanduser()
533
+ if not plugin.exists():
534
+ raise SystemExit(f"被测插件不存在:{plugin}")
535
+
536
+ viewports = [tuple(int(x) for x in v.lower().split("x")) for v in (args.viewport or ["320x568", "360x640", "390x844", "414x896"])]
537
+ options_list = args.options or [1, 3, 6, 12]
538
+
539
+ qa_results, composer_results = [], []
540
+ with tempfile.TemporaryDirectory(prefix="dsh-mobile-probe-") as tmp:
541
+ wd = pathlib.Path(tmp)
542
+ if args.only in (None, "qa"):
543
+ for vp in viewports:
544
+ for n in options_list:
545
+ qa_results.append(probe(plugin, vp, n, args.detail_paras, wd))
546
+ if args.only in (None, "composer"):
547
+ # 会话页(含上下文按钮)+ 首页 hero;模型名分超长(provider/model 兜底)与短名两种,
548
+ # 都要过:带推理等级后缀更宽,长名考验省略号。
549
+ for vp in viewports:
550
+ for with_ctx in (True, False):
551
+ composer_results.append(probe_composer(plugin, vp, with_ctx, True, wd))
552
+ for vp in viewports[:2]:
553
+ composer_results.append(probe_composer(
554
+ plugin, vp, True, True, wd, model_label="DeepSeek V4.1 Flash"))
555
+
556
+ qa_failures = [
557
+ r for r in qa_results
558
+ if not r["last_visible_after_swipe"] or not r["submit_visible"]
559
+ or not r["menu_btn_hidden_while_qa"] or (r["needs_scroll"] and not r["scrollable"])
560
+ ]
561
+ composer_failures = [
562
+ r for r in composer_results
563
+ if r.get("err") or r.get("overlaps") or r.get("spill") or r.get("unclickable")
564
+ or r.get("count", 0) < 2
565
+ # 权限图标必须常显(曾被 span 通配规则连图标一起隐藏 → 整个按钮空白)
566
+ or not r.get("accessIconShown")
567
+ # 权限按钮:**图标**必须常显(曾因 span 通配规则连图标一起隐藏 → 按钮纯空白);
568
+ # **文字**按行宽自适应:v3 排布下第一行是「工具组 ···· 上下文 发送」,
569
+ # 行宽不足(<290px,如 320px 视口)时主动退回纯图标以保住两行,
570
+ # 故此处只要求"若显示则必须有真实宽度",不强制常显。
571
+ or not r.get("accessIconShown")
572
+ or (r.get("accessLabelShown") and (r.get("accessLabelW") or 0) < 20)
573
+ # 推理等级后缀必须隐藏(否则被压成半截字符)
574
+ or r.get("effortShown")
575
+ # 被裁的模型名必须走真省略号,不能硬切半个字符
576
+ or (r.get("modelLabelClipped") and not r.get("ellipsisOk"))
577
+ # 模型名必须够宽(两行布局后独占一行:320px 约 148px、390px 约 218px,
578
+ # 而单行时代仅 40px → 只能看 7/40 字符)。下限取 120px 防退化成窄条,
579
+ # 又不锁死具体像素(布局微调不该误报)。
580
+ or 0 < (r.get("modelLabelW") or 0) < 120
581
+ # 图标/文字/箭头必须垂直居中对齐
582
+ or r.get("misaligned")
583
+ # 行数必须恰为 2(v3 排布:工具组+上下文+发送 / 模型独占第二行)
584
+ or len(r.get("lineSpreads") or []) != 2
585
+ # 每行右侧不得留出大空块(旧排布第一行空 183px → 用户看出"空了一块")
586
+ or any(g > 60 for g in (r.get("lineRightGaps") or []))
587
+ # 控件必须等高、同字号(曾 36/40/44 三种高度混排 → 视觉"怪")
588
+ or len(r.get("heights") or [1]) != 1
589
+ or len(r.get("fonts") or [1]) != 1
590
+ # 行内垂直中心离散 ≤1.5px(发送键上游 translateY(-2px) 曾致偏移)
591
+ or any(s > 1.5 for s in (r.get("lineSpreads") or [0]))
592
+ ]
593
+
594
+ if args.json:
595
+ print(json.dumps({
596
+ "ok": not qa_failures and not composer_failures,
597
+ "qa": qa_results, "composer": composer_results,
598
+ }, ensure_ascii=False, indent=2))
599
+ return 1 if (qa_failures or composer_failures) else 0
600
+
601
+ print(f"被测插件: {plugin}")
602
+
603
+ if qa_results:
604
+ print()
605
+ print("── 提问卡片(ask_user_question)──")
606
+ print(f"{'视口':<10} {'选项数':<7} {'内容溢出':<9} {'正文可滚':<9} {'滑动后末项可见':<15} {'提交可见':<9} {'菜单不遮挡':<10}")
607
+ print("-" * 78)
608
+ for r in qa_results:
609
+ print(f"{r['viewport']:<10} {r['options']:<8} {'是' if r['needs_scroll'] else '否':<10} "
610
+ f"{'✓' if r['scrollable'] else ('—' if not r['needs_scroll'] else '✗'):<10} "
611
+ f"{'✓' if r['last_visible_after_swipe'] else '✗':<16} {'✓' if r['submit_visible'] else '✗':<10} "
612
+ f"{'✓' if r['menu_btn_hidden_while_qa'] else '✗':<10}")
613
+
614
+ if composer_results:
615
+ print()
616
+ print("── 输入区操作行(composer row)──")
617
+ print(f"{'视口':<10} {'形态':<24} {'控件':<5} {'重叠':<6} {'越界':<6} {'可点':<6} "
618
+ f"{'权限图标':<9} {'权限文字':<9} {'省略号':<8} {'对齐':<6}")
619
+ print("-" * 96)
620
+ for r in composer_results:
621
+ if r.get("err"):
622
+ print(f"{r['viewport']:<10} {r['variant']:<24} ERR {r['err']}")
623
+ continue
624
+ ell = "—" if not r.get("modelLabelClipped") else ("✓" if r.get("ellipsisOk") else "✗")
625
+ perm_txt = ("✓" if r.get("accessLabelShown") else "—")
626
+ print(f"{r['viewport']:<10} {r['variant']:<24} {r['count']:<6} "
627
+ f"{'✓' if not r['overlaps'] else '✗':<7} {'✓' if not r['spill'] else '✗':<7} "
628
+ f"{'✓' if not r['unclickable'] else '✗':<7} "
629
+ f"{'✓' if r.get('accessIconShown') else '✗':<10} {perm_txt:<10} {ell:<9} "
630
+ f"{'✓' if not r.get('misaligned') else '✗':<6}")
631
+
632
+ print()
633
+ if qa_failures or composer_failures:
634
+ if qa_failures:
635
+ print(f"❌ 提问卡片回归:{len(qa_failures)} 个用例不达标(选项被裁 / 滚动失效 / 提交按钮不可见)")
636
+ for r in qa_failures:
637
+ print(f" - {r['viewport']} options={r['options']}: {r['detail']['before']}")
638
+ if composer_failures:
639
+ print(f"❌ 操作行回归:{len(composer_failures)} 个用例不达标")
640
+ for r in composer_failures:
641
+ # 逐条列出真实原因,别只说"重叠/越界"(2026-09-11 曾因此误判)
642
+ why = []
643
+ if r.get("err"):
644
+ why.append(f"err={r['err']}")
645
+ if r.get("overlaps"):
646
+ why.append(f"控件重叠={r['overlaps']}")
647
+ if r.get("spill"):
648
+ why.append(f"越出卡片={r['spill']}")
649
+ if r.get("unclickable"):
650
+ why.append(f"点不到={r['unclickable']}")
651
+ if not r.get("accessIconShown"):
652
+ why.append("权限图标被隐藏(按钮会变空白)")
653
+ if r.get("accessLabelShown") and (r.get("accessLabelW") or 0) < 20:
654
+ why.append(f"权限文字宽度异常={r.get('accessLabelW')}")
655
+ if r.get("effortShown"):
656
+ why.append("推理等级后缀未隐藏(会被压成半截字符)")
657
+ if r.get("modelLabelClipped") and not r.get("ellipsisOk"):
658
+ why.append("模型名被硬切而非省略号")
659
+ mw = r.get("modelLabelW") or 0
660
+ if 0 < mw < 120:
661
+ why.append(f"模型名被压得过窄({mw}px < 120px)")
662
+ if r.get("misaligned"):
663
+ why.append(f"垂直未对齐={r['misaligned']}")
664
+ if len(r.get("heights") or [1]) != 1:
665
+ why.append(f"控件高度不一致={r.get('heights')}")
666
+ if len(r.get("fonts") or [1]) != 1:
667
+ why.append(f"字号不一致={r.get('fonts')}")
668
+ if len(r.get("lineSpreads") or []) != 2:
669
+ why.append(f"行数不为 2(实际 {len(r.get('lineSpreads') or [])})")
670
+ if any(g > 60 for g in (r.get("lineRightGaps") or [])):
671
+ why.append(f"行右侧留有大空块={r.get('lineRightGaps')}px")
672
+ if any(s > 1.5 for s in (r.get("lineSpreads") or [0])):
673
+ why.append(f"行内中心离散过大={r.get('lineSpreads')}")
674
+ if r.get("count", 0) < 2:
675
+ why.append(f"控件数异常={r.get('count')}")
676
+ print(f" - {r['viewport']} {r.get('variant')}: " + ";".join(why or ["未知"]))
677
+ return 1
678
+
679
+ parts = []
680
+ if qa_results:
681
+ parts.append(f"提问卡片 {len(qa_results)} 个(选项可滚可见、提交始终可见、菜单不遮挡)")
682
+ if composer_results:
683
+ parts.append(f"操作行 {len(composer_results)} 个(互不重叠/不越界/可点击、"
684
+ f"权限图标常显、被裁模型名走省略号、图标文字垂直对齐)")
685
+ print("✅ 全部通过:" + ";".join(parts))
686
+ return 0
687
+
688
+
689
+ if __name__ == "__main__":
690
+ sys.exit(main())