dsh-mobile-ui 0.1.2 → 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 +4 -1
- package/README.zh.md +80 -7
- package/client.js +115 -19
- package/package.json +1 -1
- package/tests/mobile-layout-probe.py +402 -24
package/README.md
CHANGED
|
@@ -43,7 +43,10 @@ Fast local iteration: copy to `~/.dsh/profiles/node_modules/dsh-mobile-ui/` and
|
|
|
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
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
|
-
- **
|
|
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.
|
|
47
50
|
|
|
48
51
|
## Maintenance notes (important)
|
|
49
52
|
|
package/README.zh.md
CHANGED
|
@@ -28,13 +28,14 @@ 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
|
+
python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提问卡片 + 输入区操作行)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
真实插件代码,在 headless Chromium
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
**回归探针**(`tests/mobile-layout-probe.py`):用真实上游 CSS + 真实 DOM 嵌套 +
|
|
35
|
+
真实插件代码,在 headless Chromium 里按视口跑矩阵,回归时退出码 1。两组:
|
|
36
|
+
|
|
37
|
+
**① 提问卡片**(视口 × 选项数):断言「正文可滚 / 真实手指滑动后最后一个选项可见 /
|
|
38
|
+
提交按钮始终可见 / 右上角菜单按钮不遮挡卡片」。它守的是两条硬纪律——
|
|
38
39
|
|
|
39
40
|
1. **绝不要覆盖提问卡片正文(`Mbwy4a_body` / `data-question-scroll`)的 overflow**:
|
|
40
41
|
上游卡片是 `max-height:min(60vh,520px)+overflow:hidden`,正文才是它唯一的滚动容器,
|
|
@@ -43,8 +44,38 @@ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提
|
|
|
43
44
|
正好压在提问卡片标题右侧(截图实证),所以菜单按钮的显隐要按
|
|
44
45
|
「抽屉 / 设置面板 / 提问卡片」三态判定,而不是只看抽屉。
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
不是"永远绿灯"的假探针。
|
|
48
79
|
|
|
49
80
|
本地快速迭代:复制到 `~/.dsh/profiles/node_modules/dsh-mobile-ui/` 后刷新页面
|
|
50
81
|
(客户端插件有 HMR 通道,改 client.js 后页面自动更新)。
|
|
@@ -61,6 +92,48 @@ python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提
|
|
|
61
92
|
卡片自身的 max-height 放开、正文保持为唯一滚动容器(卡片内 `data-question-scroll`),
|
|
62
93
|
footer 钉在浮层底部。选择器优先用上游稳定属性(`data-question-key` /
|
|
63
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,两者互不干扰。
|
|
64
137
|
|
|
65
138
|
## 维护须知(重要)
|
|
66
139
|
|
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
|
|
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 */
|
|
@@ -99,18 +105,93 @@ window.__ModuleLoader__.load({
|
|
|
99
105
|
" body.dsh-mobile-ui [class*=Mbwy4a_footer] [class*=progress]{flex:0 1 auto !important;min-width:0 !important}",
|
|
100
106
|
" body.dsh-mobile-ui [class*=Mbwy4a_pager]{flex:0 1 auto !important;min-width:0 !important}",
|
|
101
107
|
" body.dsh-mobile-ui [class*=Mbwy4a_footer] button[class*=_primary]{flex:0 0 auto !important}",
|
|
102
|
-
/*
|
|
108
|
+
/* 操作行紧凑:hero 4 元素 / 会话页 5 元素(含上下文按钮)一行放下。
|
|
103
109
|
权限按钮移动端图标模式(44px 隐藏文字防撑宽),上下文紧凑 36px,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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}",
|
|
113
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}",
|
|
114
195
|
/* 输入框圆角与内边距(视觉更圆润、输入更舒适;卡左右加宽) */
|
|
115
196
|
" body.dsh-mobile-ui [class*=input]{border-radius:18px !important}",
|
|
116
197
|
" body.dsh-mobile-ui [class*=uV2eYG_root]{padding-left:8px !important;padding-right:8px !important}",
|
|
@@ -134,16 +215,31 @@ window.__ModuleLoader__.load({
|
|
|
134
215
|
" body.dsh-mobile-ui [class*=header] [class*=title],body.dsh-mobile-ui [class*=crumbs]{white-space:normal;overflow-wrap:anywhere}",
|
|
135
216
|
/* 输入框聚焦反馈 */
|
|
136
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))}",
|
|
137
|
-
/*
|
|
138
|
-
|
|
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
|
+
" }",
|
|
139
235
|
"}",
|
|
140
236
|
|
|
141
|
-
/*
|
|
237
|
+
/* ≤360px 小屏:仅压缩间距。
|
|
238
|
+
⚠️ 这里**不再**把模型按钮降级成图标——两行布局后模型独占第二行,
|
|
239
|
+
320px 视口也有约 244px 可用(可显示 38/40 字符),降级反而丢信息。
|
|
240
|
+
权限文字也无需降级:容器查询阈值已降到 170px,窄屏同样显示。 */
|
|
142
241
|
"@media (max-width:360px){",
|
|
143
|
-
" body.dsh-mobile-ui button[aria-label*=选择模型]{max-width:110px !important}",
|
|
144
242
|
" body.dsh-mobile-ui [class*=uV2eYG_row]{gap:2px !important}",
|
|
145
|
-
" body.dsh-mobile-ui [class*=uV2eYG_tools],body.dsh-mobile-ui [class*=uV2eYG_modes]{gap:2px !important}",
|
|
146
|
-
" body.dsh-mobile-ui button[aria-label*=上下文]{margin-left:8px !important}",
|
|
147
243
|
"}",
|
|
148
244
|
/* 抽屉视觉:右侧圆角 + 更实背景 */
|
|
149
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}",
|
|
@@ -158,7 +254,7 @@ window.__ModuleLoader__.load({
|
|
|
158
254
|
/* trailing 基础 gap 2px:hero(无上下文)模型右扩吃 gap,左缘保持不变 */
|
|
159
255
|
"body.dsh-mobile-ui [class*=uV2eYG_trailing]{gap:2px !important}",
|
|
160
256
|
/* 会话页(有上下文按钮):上下文 margin-left 右移 12px(只扩模型↔上下文间距) */
|
|
161
|
-
"body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:
|
|
257
|
+
"body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:4px !important}",
|
|
162
258
|
"body.dsh-mobile-ui [class*=VOzbGW_nav]{width:68px !important;flex:0 0 68px !important;min-width:68px !important;overflow:visible !important}",
|
|
163
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}",
|
|
164
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}",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-ui",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""dsh-mobile-ui
|
|
2
|
+
"""dsh-mobile-ui 移动端布局探针:两处可读性/几何回归测试。
|
|
3
3
|
|
|
4
|
-
##
|
|
4
|
+
## 一、提问卡片(ask_user_question)
|
|
5
5
|
|
|
6
6
|
提问卡片曾在手机上出现「选项看不全 + 手指滑动也没反应」的故障(2026-09-11 定位修复)。
|
|
7
7
|
根因不是样式不生效,而是**覆盖了上游唯一的滚动容器**:
|
|
@@ -13,15 +13,28 @@
|
|
|
13
13
|
② body 不再是滚动容器,③ 覆盖层里也没有可滚内容
|
|
14
14
|
⇒ 手指滑动零反应,后面的选项永久不可见
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 嵌套 + 真实插件代码,断言「任意两个控件都不重叠」。
|
|
19
30
|
|
|
20
31
|
## 用法
|
|
21
32
|
|
|
22
33
|
python3 tests/mobile-layout-probe.py # 默认测当前安装副本
|
|
23
34
|
python3 tests/mobile-layout-probe.py --plugin ./client.js
|
|
24
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 # 只跑提问卡片
|
|
25
38
|
|
|
26
39
|
依赖:playwright(python)+ 已安装的 dsh(用于抽取上游 CSS)。退出码非 0 表示回归。
|
|
27
40
|
"""
|
|
@@ -45,6 +58,8 @@ DEFAULT_PLUGIN = pathlib.Path.home() / ".dsh/plugins/dsh-mobile-ui/client.js"
|
|
|
45
58
|
|
|
46
59
|
CONV_CSS_RE = r'const css\$4 = ("(?:[^"\\]|\\.)*");'
|
|
47
60
|
QQ_CSS_RE = r'const css = ("(?:[^"\\]|\\.)*");'
|
|
61
|
+
# 输入区(InputBar)上游 CSS:uV2eYG 词根
|
|
62
|
+
INPUTBAR_CSS_RE = r'const css\$1 = ("(?:[^"\\]|\\.)*");'
|
|
48
63
|
|
|
49
64
|
|
|
50
65
|
def extract_css(path: pathlib.Path, pattern: str, what: str) -> str:
|
|
@@ -197,6 +212,254 @@ def apply_plugin(page, plugin: pathlib.Path) -> None:
|
|
|
197
212
|
)
|
|
198
213
|
|
|
199
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"> </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
|
+
|
|
200
463
|
def probe(plugin, viewport, options, detail_paras, workdir):
|
|
201
464
|
from playwright.sync_api import sync_playwright
|
|
202
465
|
|
|
@@ -257,11 +520,12 @@ def probe(plugin, viewport, options, detail_paras, workdir):
|
|
|
257
520
|
|
|
258
521
|
|
|
259
522
|
def main() -> int:
|
|
260
|
-
ap = argparse.ArgumentParser(description="dsh-mobile-ui
|
|
523
|
+
ap = argparse.ArgumentParser(description="dsh-mobile-ui 移动端布局探针(提问卡片 + 输入区操作行)")
|
|
261
524
|
ap.add_argument("--plugin", default=str(DEFAULT_PLUGIN), help="被测 client.js 路径")
|
|
262
525
|
ap.add_argument("--viewport", action="append", default=None, help="WxH,可重复(默认覆盖常见机型)")
|
|
263
526
|
ap.add_argument("--options", type=int, action="append", default=None, help="选项数量,可重复")
|
|
264
527
|
ap.add_argument("--detail-paras", type=int, default=2, help="detail 长文本段落数")
|
|
528
|
+
ap.add_argument("--only", choices=["qa", "composer"], default=None, help="只跑其中一组")
|
|
265
529
|
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
|
266
530
|
args = ap.parse_args()
|
|
267
531
|
|
|
@@ -272,39 +536,153 @@ def main() -> int:
|
|
|
272
536
|
viewports = [tuple(int(x) for x in v.lower().split("x")) for v in (args.viewport or ["320x568", "360x640", "390x844", "414x896"])]
|
|
273
537
|
options_list = args.options or [1, 3, 6, 12]
|
|
274
538
|
|
|
275
|
-
|
|
539
|
+
qa_results, composer_results = [], []
|
|
276
540
|
with tempfile.TemporaryDirectory(prefix="dsh-mobile-probe-") as tmp:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
283
558
|
if not r["last_visible_after_swipe"] or not r["submit_visible"]
|
|
284
559
|
or not r["menu_btn_hidden_while_qa"] or (r["needs_scroll"] and not r["scrollable"])
|
|
285
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
|
+
]
|
|
286
593
|
|
|
287
594
|
if args.json:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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)──")
|
|
293
606
|
print(f"{'视口':<10} {'选项数':<7} {'内容溢出':<9} {'正文可滚':<9} {'滑动后末项可见':<15} {'提交可见':<9} {'菜单不遮挡':<10}")
|
|
294
607
|
print("-" * 78)
|
|
295
|
-
for r in
|
|
608
|
+
for r in qa_results:
|
|
296
609
|
print(f"{r['viewport']:<10} {r['options']:<8} {'是' if r['needs_scroll'] else '否':<10} "
|
|
297
610
|
f"{'✓' if r['scrollable'] else ('—' if not r['needs_scroll'] else '✗'):<10} "
|
|
298
611
|
f"{'✓' if r['last_visible_after_swipe'] else '✗':<16} {'✓' if r['submit_visible'] else '✗':<10} "
|
|
299
612
|
f"{'✓' if r['menu_btn_hidden_while_qa'] else '✗':<10}")
|
|
300
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
|
+
|
|
301
632
|
print()
|
|
302
|
-
if
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 ["未知"]))
|
|
306
677
|
return 1
|
|
307
|
-
|
|
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))
|
|
308
686
|
return 0
|
|
309
687
|
|
|
310
688
|
|