dsh-vt-uiux-enhancer 1.0.1 → 1.1.1

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/DESIGN.md CHANGED
@@ -8,9 +8,13 @@
8
8
 
9
9
  - 让 Harness 读起来像一份**干净的白纸界面**:纯白画布、暖调中性文字、极浅的边界。
10
10
  - **默认只做外观**:颜色、圆角形状、阴影、间距、对齐、字重;不改布局结构、不改交互逻辑、不加功能。
11
- - 唯一的越界通道是**用入口换空间**:某个低频功能占着常驻空间时,把它的常驻入口收进已有的信息行、
12
- 把空间还给内容。前提是三条同时成立 —— 入口没有消失(双向可回)、没有新增持久状态、
13
- 关掉插件后原样回来。目前只有「视图切换入口」走这条路。
11
+ - 越界只有**两处可逆的密度处理**,共同前提是三条同时成立 —— 内容没有消失(双向可回)、
12
+ 没有新增持久状态、关掉插件后原样回来:
13
+ 1. **用入口换空间**:某个低频功能占着常驻空间时,把它的常驻入口收进已有的信息行、把空间还给内容。
14
+ 目前只有「视图切换入口」走这条路。
15
+ 2. **用披露换密度**:内容本身没有变,只是默认不一次性铺满。超长用户消息折叠成 20 行,
16
+ 保留「展开」把全文原位放回来。这一条不是"美化",是"读得下去":一条 60 行的粘贴内容会把
17
+ 后面所有轮次推出屏幕,滚动本身就成了任务。
14
18
  - **必须可完全撤销**:插件关闭后 Harness 精确回到出厂外观。因此不写死任何 Harness 文件,也不留下
15
19
  常驻的定时器、监听器或网络请求。
16
20
 
@@ -56,6 +60,7 @@
56
60
  | 输入框「+」 | 去掉常驻灰底,默认裸字形,hover 才出底(与麦克风同一个 token);字形缩到与整行同档;再向审查 chip 拉近 8px(视觉间距 4px) | 一行控件里只有它常驻底色,等于把「可点」画成了「已选」;裸字形之后,原先被底板衬住的 11px 加号成了整行最重的图标;12px 盒间距在「裸字形 + 带内距的 chip」之间会读成约 27px,左簇因此被拉散;拉近到视觉 4px 才读成一簇 |
57
61
  | 视图切换入口 | 顶部标签行整条移除,header 从 76px 收到 51px;「轨迹」改为状态行第一项(列表图标 + 视图名),在轨迹页原地变成「对话」 | 只有两个视图时,常驻 36px 的标签行 + 右侧「…」是在为低频功能付固定成本;状态行本来就是本会话读数所在,宽度成本为零。入口做成双向的,是因为移除标签行后不能再有第二个「对话」入口 —— 但也不该为回程新开一条 40px 的 header 行 |
58
62
  | 队列消息卡片 | 浅灰 `#f5f5f5`(暗色沿用浮层材质) | 它借的是浮层材质上色,浮层改成近白后它和白画布分不开;但它不是浮层 —— 它在画布上叠一层,该用「比画布深一档」而不是「和浮层一样白」 |
63
+ | 长用户消息折叠 | 20 行(19 行正文 + 独占一行的「…」)+ 气泡内底部「展开/收起」;阈值按渲染行数;展开原位 | 参照本机 Codex:省略号单独占一行、按钮留在气泡填充里,气泡才仍读作**一个对象**(按钮放到气泡外会读成第二个对象、第二条消息)。按行数而不是字数,是因为窄窗口下 600 字只排 9 行、宽窗口下能排 30 行 —— 字数阈值在响应式布局里等于没有阈值。按钮文字用 `label-secondary` 而不是应用 disclosure 行的 `label-tertiary`:它坐在气泡浅蓝填充上,13px 下 tertiary 的对比度不足 4:1。展开状态**不跨节点保留**:切会话、热更新重挂后重新折叠,不为了"恢复阅读位置"去持有状态 |
59
64
 
60
65
  ---
61
66
 
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # dsh-vt-uiux-enhancer · VT UI/UX 增强
2
2
 
3
- DeepSeek Harness 的界面改善插件:把外观调成**一张干净的白纸**,只改看得见的样式,
4
- 不改布局结构、不加功能。关闭插件后 Harness 精确回到出厂外观。
3
+ DeepSeek Harness 的界面改善插件:把外观调成**一张干净的白纸**。
4
+ 默认只改看得见的样式;唯一越界的是两处**可逆的密度处理**(低频入口收进状态行、超长用户消息折叠成 20 行),
5
+ 都不新增持久状态,关闭插件后 Harness 精确回到出厂外观。
5
6
 
6
7
  ## 它改了什么
7
8
 
@@ -12,6 +13,9 @@ DeepSeek Harness 的界面改善插件:把外观调成**一张干净的白纸*
12
13
  - **动效**:动画只表达"状态正在变";轮次跳转条静止为短刻度、靠近指针才长出来。
13
14
  - **输入框**:原生 `title` tooltip 换成自绘气泡,沿用应用自己的 tooltip token。
14
15
  - **空间回收**:视图切换标签行收进状态行,header 从 76px 收到 51px;入口双向保留。
16
+ - **长消息折叠**:用户自己的消息超过 20 行时,显示 19 行正文 + 一行「…」,气泡内底部出现
17
+ 「展开/收起」+ 原生 chevron;展开后原位显示全文。阈值是**渲染行数**,窗口变窄或字号变大
18
+ 都不会改变折叠点。
15
19
  - **细节**:会话边缘上下 24px 渐隐、队列消息卡片用"比画布深一档"的底色。
16
20
 
17
21
  每条补丁的取值理由写在 `client.js` 的注释里;跨补丁的取舍记在 `DESIGN.md`。
@@ -32,16 +36,16 @@ https://vontean.github.io/vt-dsh-plugin/index.json
32
36
  **npm**
33
37
 
34
38
  ```bash
35
- dsh plugin --profile <profile> add dsh-vt-uiux-enhancer@1.0.1
39
+ dsh plugin --profile <profile> add dsh-vt-uiux-enhancer@1.1.1
36
40
  ```
37
41
 
38
42
  **Release tarball**
39
43
 
40
44
  到 [Releases](https://github.com/Vontean/vt-dsh-plugin/releases) 下载
41
- `dsh-vt-uiux-enhancer-1.0.1.tgz`,然后:
45
+ `dsh-vt-uiux-enhancer-1.1.1.tgz`,然后:
42
46
 
43
47
  ```bash
44
- dsh plugin --profile <profile> add /绝对路径/dsh-vt-uiux-enhancer-1.0.1.tgz
48
+ dsh plugin --profile <profile> add /绝对路径/dsh-vt-uiux-enhancer-1.1.1.tgz
45
49
  ```
46
50
 
47
51
  ## 支持的 DSH 版本
package/client.js CHANGED
@@ -43,7 +43,7 @@ window.__ModuleLoader__.load({
43
43
  'use strict';
44
44
 
45
45
  const PLUGIN_ID = 'dsh-vt-uiux-enhancer';
46
- const VERSION = '1.0.1';
46
+ const VERSION = '1.1.1';
47
47
 
48
48
  /**
49
49
  * DSH ships `:root { --dsw-corner-shape: superellipse(1.5) }` and applies it
@@ -292,6 +292,53 @@ window.__ModuleLoader__.load({
292
292
  toConversation: ['M10 4L6.70711 7.29289C6.31658 7.68342 6.31658 8.31658 6.70711 8.70711L10 12'],
293
293
  };
294
294
 
295
+ /**
296
+ * Long user messages in the transcript.
297
+ *
298
+ * A pasted stack trace turns one turn into four screenfuls and pushes every
299
+ * later exchange out of reach. Codex clamps the message and offers a
300
+ * disclosure; this copies that, with one difference that matters for a
301
+ * responsive layout: the threshold is RENDERED ROWS, never characters.
302
+ * Twenty rows means twenty rows at any column width and any content font
303
+ * size, because the count is measured from the bubble the browser laid out;
304
+ * a character budget would show 9 lines in a narrow window and 40 in a wide
305
+ * one, which is exactly the scrolling problem this is meant to solve.
306
+ *
307
+ * The reference this was modelled on (Codex) spends all twenty rows: the
308
+ * nineteenth is the last line of text, the twentieth is a lone "…" that says
309
+ * "there is more", and the disclosure row sits under it inside the same
310
+ * fill. Expanded, the text runs to the bottom and only the disclosure row is
311
+ * left.
312
+ *
313
+ * Geometry is expressed against the bubble's own line box
314
+ * (`line-height: calc(22px + var(--dsh-content-font-delta))` — the second
315
+ * term is the Appearance content-size preference), so the clamp follows the
316
+ * font setting through CSS `calc()` and the line count never has to be
317
+ * recomputed. `padding` mirrors the shipped bubble (`10px 16px`) and
318
+ * `stackGap` the shipped `.userStack` gap; the two extra rows are a SIBLING
319
+ * of the bubble, because `overflow: hidden` clips at the padding box
320
+ * (see trap 10 in DEVELOPMENT.md).
321
+ */
322
+ const MESSAGE_CLAMP = {
323
+ /**
324
+ * Rows shown while clamped, the "…" marker included. Twenty is where a
325
+ * message stops reading as a message and starts reading as a document;
326
+ * below it nothing changes at all.
327
+ */
328
+ rows: 20,
329
+ /** Space between the "…" row and the disclosure row. */
330
+ rowGap: 8,
331
+ /** The shipped bubble's bottom/side padding, and the stack gap it sits in. */
332
+ padding: { bottom: 10, x: 16 },
333
+ stackGap: 8,
334
+ /** Gap between the last text line and the disclosure block when expanded. */
335
+ textGap: 4,
336
+ /** Chevron: the icon set's own default box; regular stroke is 1 at 16. */
337
+ glyph: { box: 14, stroke: 1 },
338
+ /** Icon-to-label gap, the value the app's disclosure titles use. */
339
+ iconGap: 6,
340
+ };
341
+
295
342
  /** Codex's spring: linear() with a 0.8% overshoot instead of a bezier. */
296
343
  const RAIL_SPRING = 'linear(0, .398 10%, .682 20%, .843 30%, .925 40%, .972 50%, 1.004 60%, 1.008 70%, 1.003 80%, 1)';
297
344
 
@@ -1029,6 +1076,106 @@ ${responseLine} {
1029
1076
  fallback: '._6DJyyG_panel',
1030
1077
  rules: (selector) => `[data-queue-dock] ${selector}::before { background: var(--vt-queue-fill) !important; }`,
1031
1078
  },
1079
+ {
1080
+ kind: 'component',
1081
+ id: 'user-bubble',
1082
+ /**
1083
+ * The bubble the user's own message is painted on — the only surface in
1084
+ * the app filled with `--dsw-specific-bubble`, which makes the signature
1085
+ * unambiguous without naming a hashed class.
1086
+ *
1087
+ * This patch owns the clamp, the "…" row and the disclosure block,
1088
+ * because all three are placed against the bubble's own line box.
1089
+ * `data-vt-clamp` is written by the `message-clamp` behaviour.
1090
+ *
1091
+ * The two extra rows are a SIBLING of the bubble, not rows inside it:
1092
+ * `overflow: hidden` clips at the PADDING box, so a reserved
1093
+ * `padding-bottom` would still have text painted over it (trap 10). The
1094
+ * clamp therefore zeroes the bottom padding, which puts the clip edge
1095
+ * exactly on the 19th line's bottom edge, and the block below carries the
1096
+ * same fill plus squared top corners, so both boxes read as one bubble.
1097
+ */
1098
+ signature: ['background:var(--dsw-specific-bubble)'],
1099
+ selectorFilter: '_bubble$',
1100
+ fallback: '.AkCqbG_bubble',
1101
+ rules: (selector) => {
1102
+ const { rows, rowGap, padding, stackGap, textGap, glyph, iconGap } = MESSAGE_CLAMP;
1103
+ return `
1104
+ ${selector}[data-vt-clamp="clamped"] {
1105
+ max-height: calc(${rows - 1} * (22px + var(--dsh-content-font-delta, 0px))) !important;
1106
+ overflow: hidden !important;
1107
+ padding-bottom: 0 !important;
1108
+ border-bottom-left-radius: 0 !important;
1109
+ border-bottom-right-radius: 0 !important;
1110
+ }
1111
+ ${selector}[data-vt-clamp="expanded"] {
1112
+ border-bottom-left-radius: 0 !important;
1113
+ border-bottom-right-radius: 0 !important;
1114
+ }
1115
+ .vt-msg-clamp-tail {
1116
+ align-self: stretch;
1117
+ display: flex;
1118
+ flex-direction: column;
1119
+ gap: ${rowGap}px;
1120
+ /* Sibling inside .userStack, a flex column with an 8px gap: cancel it so the
1121
+ two filled boxes touch, and restore the bottom corners here. */
1122
+ margin-top: -${stackGap}px;
1123
+ padding: 0 ${padding.x}px ${padding.bottom}px;
1124
+ background: var(--dsw-specific-bubble);
1125
+ border-radius: 0 0 var(--dsw-radius-xl) var(--dsw-radius-xl);
1126
+ }
1127
+ ${selector}[data-vt-clamp="expanded"] + .vt-msg-clamp-tail {
1128
+ padding-top: ${textGap}px;
1129
+ }
1130
+ .vt-msg-clamp-more {
1131
+ height: calc(22px + var(--dsh-content-font-delta, 0px));
1132
+ line-height: calc(22px + var(--dsh-content-font-delta, 0px));
1133
+ color: var(--dsw-alias-label-primary);
1134
+ user-select: none;
1135
+ }
1136
+ ${selector}[data-vt-clamp="expanded"] + .vt-msg-clamp-tail .vt-msg-clamp-more {
1137
+ display: none !important;
1138
+ }
1139
+ .vt-msg-clamp-toggle {
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: ${iconGap}px;
1143
+ height: calc(22px + var(--dsh-content-font-delta, 0px));
1144
+ margin: 0;
1145
+ padding: 0;
1146
+ border: 0;
1147
+ background: none;
1148
+ font: inherit;
1149
+ font-size: var(--dsh-content-font-size-secondary, 13px);
1150
+ line-height: 1;
1151
+ /* label-secondary, not the disclosure rows' label-tertiary: those sit on the
1152
+ white canvas, this one on the tinted fill where tertiary drops under 4:1. */
1153
+ color: var(--dsw-alias-label-secondary);
1154
+ cursor: pointer;
1155
+ transition: color 0.1s;
1156
+ }
1157
+ .vt-msg-clamp-toggle:hover {
1158
+ color: var(--dsw-alias-label-primary);
1159
+ }
1160
+ .vt-msg-clamp-toggle:focus-visible {
1161
+ outline: 1.5px solid var(--dsw-alias-state-business-primary);
1162
+ outline-offset: 2px;
1163
+ border-radius: var(--dsw-radius-sm);
1164
+ }
1165
+ .vt-msg-clamp-icon {
1166
+ display: inline-flex;
1167
+ flex: none;
1168
+ }
1169
+ .vt-msg-clamp-icon svg {
1170
+ display: block;
1171
+ width: ${glyph.box}px;
1172
+ height: ${glyph.box}px;
1173
+ }
1174
+ @media (prefers-reduced-motion: reduce) {
1175
+ .vt-msg-clamp-toggle { transition: none; }
1176
+ }`;
1177
+ },
1178
+ },
1032
1179
  {
1033
1180
  kind: 'behavior',
1034
1181
  id: 'view-switch',
@@ -1049,6 +1196,23 @@ ${responseLine} {
1049
1196
  behavior: 'view-switch',
1050
1197
  css: () => '',
1051
1198
  },
1199
+ {
1200
+ kind: 'behavior',
1201
+ id: 'message-clamp',
1202
+ /**
1203
+ * A long user message is clamped to 20 rendered rows, with a disclosure
1204
+ * row inside its own bubble. A behaviour, not a component patch: the
1205
+ * decision ("is this message longer than 20 rows?") needs a layout
1206
+ * measurement, and the toggle has to remember one bit of state per
1207
+ * message. CSS can do neither.
1208
+ *
1209
+ * The style half lives in `user-bubble`, because the geometry has to key
1210
+ * off the discovered bubble class. This entry owns the DOM half: the
1211
+ * state attribute, the sibling disclosure block, the ellipsis row.
1212
+ */
1213
+ behavior: 'message-clamp',
1214
+ css: () => '',
1215
+ },
1052
1216
  ];
1053
1217
 
1054
1218
  // ═══════════════════════════════════════════════════════════════════════
@@ -1122,6 +1286,13 @@ ${responseLine} {
1122
1286
  const disposers = [];
1123
1287
  /** Why a behaviour failed to install; surfaced through diagnostics. */
1124
1288
  const behaviorErrors = [];
1289
+ /**
1290
+ * Measurements a behaviour publishes into the report channel. Behaviours do
1291
+ * not produce `--vt-report-<patch>` tokens (they resolve no selectors), so
1292
+ * without this a behaviour could install successfully and still be
1293
+ * invisible to the one verification channel that works without DevTools.
1294
+ */
1295
+ const behaviorMetrics = {};
1125
1296
  /** Created on first sync, so a document-less host never touches the DOM. */
1126
1297
  let sheet = null;
1127
1298
  const ensureSheet = () => sheet || (sheet = createStyleHost(ctx));
@@ -1273,7 +1444,13 @@ ${responseLine} {
1273
1444
  report[`${id}-radius`] = style.borderRadius;
1274
1445
  report[`${id}-size`] = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
1275
1446
  if (id.includes('row')) report[`${id}-pad`] = style.padding;
1447
+ // The two numbers the long-message clamp derives from: the line box it
1448
+ // counts in, and the box model its max-height is measured against.
1449
+ if (id === 'user-bubble') report[`${id}-box`] = `${style.boxSizing}-${style.lineHeight}`;
1276
1450
  }
1451
+ // Behaviour readings: the only way to see a behaviour work without
1452
+ // DevTools, since behaviours publish no selector report of their own.
1453
+ for (const [key, value] of Object.entries(behaviorMetrics)) report[key] = value;
1277
1454
 
1278
1455
  const tokens = {};
1279
1456
  for (const [key, value] of Object.entries(report)) {
@@ -1759,6 +1936,300 @@ ${responseLine} {
1759
1936
  }, ViewSwitch));
1760
1937
  }
1761
1938
 
1939
+ /**
1940
+ * Clamp a long user message to 20 rendered rows with a disclosure block.
1941
+ *
1942
+ * Three constraints shape this more than the feature itself:
1943
+ *
1944
+ * 1. React owns the bubble's children. Appending leaf nodes is safe as long
1945
+ * as a re-render that drops them is noticed and they are re-added — that
1946
+ * is what the sweep does on every structural mutation. Reparenting or
1947
+ * wrapping React's content would not be safe, so the text is never
1948
+ * moved: the clamp is `max-height` on the bubble, and the disclosure is
1949
+ * a SIBLING of it (trap 10 is why it cannot be inside the clipped box).
1950
+ * 2. The decision needs layout, so it is measured, never guessed. An
1951
+ * unclamped bubble's `scrollHeight` is `paddingTop + rows × lineHeight +
1952
+ * paddingBottom`, which answers "longer than 20 rows?" in one read. Then
1953
+ * the clamped bubble is checked against its own overflow: if it does not
1954
+ * actually hide anything (an inline chip inflated a line, or the estimate
1955
+ * rounded up), the clamp is released. A measured post-condition beats a
1956
+ * clever formula.
1957
+ * 3. Expansion lives on the node and nowhere else. Session switches,
1958
+ * compaction and hot reloads re-mount rows, and a re-mounted message
1959
+ * comes back folded — a message the reader opened in the previous
1960
+ * session is not a reading position worth restoring, and keeping a
1961
+ * memory for it would be state this plugin has no business holding.
1962
+ *
1963
+ * Failure surface: a missing chevron icon module or a missing
1964
+ * `react-dom/client` throws into `installBehaviors`, which reports it as a
1965
+ * failed behaviour instead of leaving half a feature wired.
1966
+ */
1967
+ function installMessageClamp(readSelectors) {
1968
+ const ROW_SELECTOR = '[data-chat-flow-kind="user"],[data-chat-flow-kind="steering"]';
1969
+ const STATE = 'data-vt-clamp';
1970
+ const TOGGLE = 'vt-msg-clamp-toggle';
1971
+ const MORE = 'vt-msg-clamp-more';
1972
+ const TAIL = 'vt-msg-clamp-tail';
1973
+ /** Settle time: the transcript mutates continuously while a turn streams. */
1974
+ const SETTLE_MS = 120;
1975
+
1976
+ const React = require('react');
1977
+ const reactDom = require('react-dom/client');
1978
+ const icons = require('@deepseek-ai/dsh-client-ui-primitives');
1979
+ const ChevronDown = icons.IconChevronDownOutlineRegular;
1980
+ const ChevronUp = icons.IconChevronUpOutlineRegular;
1981
+ if (typeof ChevronDown !== 'function' || typeof ChevronUp !== 'function') {
1982
+ throw new Error('chevron icons unavailable in @deepseek-ai/dsh-client-ui-primitives');
1983
+ }
1984
+
1985
+ /** Labels follow the app language instead of shipping an English-only row. */
1986
+ const locale = ctx.get('locale');
1987
+ const translate = (zh, en) => (
1988
+ locale && typeof locale.resolveText === 'function' ? locale.resolveText({ zh, en }) : en
1989
+ );
1990
+ const label = { open: translate('展开', 'Show more'), close: translate('收起', 'Show less') };
1991
+
1992
+ /** Live disclosure entries, so dispose removes exactly what we added. */
1993
+ const entries = new Set();
1994
+ /** bubble -> entry; a WeakMap keeps dropped rows collectable. */
1995
+ const wired = new WeakMap();
1996
+ let timer = 0;
1997
+ let published = '';
1998
+ let revoke = null;
1999
+
2000
+ /** The bubble class resolved by the `user-bubble` patch, else its own fallback. */
2001
+ const bubblePatch = patches.components.find((patch) => patch.id === 'user-bubble');
2002
+ const bubbleSelector = () => {
2003
+ const map = (typeof readSelectors === 'function' && readSelectors()) || {};
2004
+ const list = map['user-bubble'];
2005
+ if (list && list.length) return list.join(',');
2006
+ return (bubblePatch && bubblePatch.fallback) || '';
2007
+ };
2008
+
2009
+ /**
2010
+ * Rendered row count of an *unclamped* bubble. `scrollHeight` includes
2011
+ * the padding, so the padding is subtracted before dividing by the line
2012
+ * box — the result is the number of line boxes the browser actually laid
2013
+ * out, which is the unit the 20-row rule is defined in.
2014
+ */
2015
+ const lineCount = (bubble) => {
2016
+ const style = getComputedStyle(bubble);
2017
+ const lineHeight = parseFloat(style.lineHeight);
2018
+ if (!isFinite(lineHeight) || lineHeight <= 0) return 0;
2019
+ const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
2020
+ return Math.round((bubble.scrollHeight - padding) / lineHeight);
2021
+ };
2022
+
2023
+ /** Does the applied clamp actually hide text? */
2024
+ const overflows = (bubble) => bubble.clientHeight > 0 && bubble.scrollHeight > bubble.clientHeight + 1;
2025
+
2026
+ /** One chevron, rendered from the app's own icon component. */
2027
+ const chevron = (expanded) => {
2028
+ const host = document.createElement('span');
2029
+ host.className = 'vt-msg-clamp-icon';
2030
+ const root = reactDom.createRoot(host);
2031
+ const paint = (isOpen) => root.render(React.createElement(isOpen ? ChevronUp : ChevronDown, {
2032
+ size: MESSAGE_CLAMP.glyph.box,
2033
+ strokeWidth: MESSAGE_CLAMP.glyph.stroke,
2034
+ }));
2035
+ paint(expanded);
2036
+ return { host, paint, dispose: () => root.unmount() };
2037
+ };
2038
+
2039
+ /**
2040
+ * The clamp's verification channel. Published here rather than only
2041
+ * through the engine's report: the engine republishes on body mutations
2042
+ * alone, so on an idle page a reading taken right after install would
2043
+ * never reach the token list.
2044
+ */
2045
+ const publish = (rows, bubbles, clamped, expandedCount, first, scan) => {
2046
+ const fields = {
2047
+ messages: `rows-${rows} bubbles-${bubbles} clamped-${clamped} expanded-${expandedCount}`,
2048
+ };
2049
+ if (scan && scan.length) fields['messages-scan'] = scan.join(' ');
2050
+ if (first) {
2051
+ const style = getComputedStyle(first.bubble);
2052
+ fields['messages-first'] = [
2053
+ `lines-${first.lines}`,
2054
+ `max-${style.maxHeight || 'none'}`,
2055
+ `h-${first.bubble.clientHeight}`,
2056
+ `scroll-${first.bubble.scrollHeight}`,
2057
+ `pb-${style.paddingBottom}`,
2058
+ `lh-${style.lineHeight}`,
2059
+ `box-${style.boxSizing}`,
2060
+ ].join('-');
2061
+ }
2062
+ const signature = JSON.stringify(fields);
2063
+ if (signature === published) return;
2064
+ published = signature;
2065
+ const tokens = {};
2066
+ for (const [key, value] of Object.entries(fields)) {
2067
+ tokens[`--vt-report-${sanitize(key)}-${sanitize(value)}`] = { light: '', dark: '' };
2068
+ }
2069
+ try {
2070
+ revoke = ctx.theme.overrideTokens('vt-interface:messages', tokens);
2071
+ } catch (err) { /* diagnostics must never break the visual layer */ }
2072
+ };
2073
+
2074
+ const unwire = (bubble, entry) => {
2075
+ entries.delete(entry);
2076
+ wired.delete(bubble);
2077
+ try { entry.icon.dispose(); } catch (err) { /* already gone */ }
2078
+ entry.tail.remove();
2079
+ bubble.removeAttribute(STATE);
2080
+ bubble.removeAttribute('data-vt-clamp-lines');
2081
+ };
2082
+
2083
+ const wire = (bubble, lines) => {
2084
+ const more = document.createElement('span');
2085
+ more.className = MORE;
2086
+ more.setAttribute('aria-hidden', 'true');
2087
+ more.textContent = '…';
2088
+
2089
+ const toggle = document.createElement('button');
2090
+ toggle.type = 'button';
2091
+ toggle.className = TOGGLE;
2092
+ const caption = document.createElement('span');
2093
+ caption.className = 'vt-msg-clamp-label';
2094
+ const icon = chevron(false);
2095
+ toggle.appendChild(caption);
2096
+ toggle.appendChild(icon.host);
2097
+
2098
+ /*
2099
+ * The disclosure is a SIBLING of the bubble, never a child: the clamp
2100
+ * uses `overflow: hidden`, which clips at the padding box, so any row
2101
+ * reserved inside the bubble would still be painted over by the text
2102
+ * that overflows the content box.
2103
+ */
2104
+ const tail = document.createElement('div');
2105
+ tail.className = TAIL;
2106
+ tail.appendChild(more);
2107
+ tail.appendChild(toggle);
2108
+
2109
+ const entry = { bubble, tail, toggle, more, icon, caption, lines, expanded: false };
2110
+
2111
+ const paint = () => {
2112
+ bubble.setAttribute(STATE, entry.expanded ? 'expanded' : 'clamped');
2113
+ bubble.setAttribute('data-vt-clamp-lines', String(entry.lines));
2114
+ toggle.setAttribute('aria-expanded', String(entry.expanded));
2115
+ caption.textContent = entry.expanded ? label.close : label.open;
2116
+ icon.paint(entry.expanded);
2117
+ };
2118
+
2119
+ toggle.addEventListener('click', (event) => {
2120
+ // The transcript row has its own click affordances; a disclosure is
2121
+ // not one of them.
2122
+ event.preventDefault();
2123
+ event.stopPropagation();
2124
+ entry.expanded = !entry.expanded;
2125
+ paint();
2126
+ schedule();
2127
+ });
2128
+
2129
+ bubble.after(tail);
2130
+ entries.add(entry);
2131
+ wired.set(bubble, entry);
2132
+ paint();
2133
+ return entry;
2134
+ };
2135
+
2136
+ const sweep = () => {
2137
+ timer = 0;
2138
+ // A row that left the document must release its nodes and its React
2139
+ // root instead of waiting for dispose.
2140
+ for (const entry of [...entries]) {
2141
+ if (entry.bubble.isConnected) continue;
2142
+ entries.delete(entry);
2143
+ try { entry.icon.dispose(); } catch (err) { /* already gone */ }
2144
+ entry.tail.remove();
2145
+ }
2146
+
2147
+ const rows = document.querySelectorAll(ROW_SELECTOR);
2148
+ const bubbles = [];
2149
+ for (const row of rows) {
2150
+ let found;
2151
+ try { found = row.querySelectorAll(bubbleSelector()); } catch (err) { found = []; }
2152
+ for (const bubble of found) bubbles.push(bubble);
2153
+ }
2154
+
2155
+ let clamped = 0;
2156
+ let expandedCount = 0;
2157
+ let first = null;
2158
+ const scan = [];
2159
+
2160
+ for (const bubble of bubbles) {
2161
+ let entry = wired.get(bubble) || null;
2162
+ // A re-render can drop our nodes while keeping the bubble: rebuild.
2163
+ if (entry && !entry.tail.isConnected) {
2164
+ unwire(bubble, entry);
2165
+ entry = null;
2166
+ }
2167
+ if (!entry) {
2168
+ if (bubble.getAttribute(STATE) === 'none') continue;
2169
+ const lines = lineCount(bubble);
2170
+ if (scan.length < 4) {
2171
+ scan.push(`${lines}L/${bubble.clientHeight}h/${bubble.scrollHeight}s/${bubble.getAttribute(STATE) || '-'}`);
2172
+ }
2173
+ if (lines <= 0) continue;
2174
+ if (lines <= MESSAGE_CLAMP.rows) {
2175
+ bubble.setAttribute(STATE, 'none');
2176
+ continue;
2177
+ }
2178
+ entry = wire(bubble, lines);
2179
+ // Measured post-condition: keep the clamp only if it hides text.
2180
+ if (!entry.expanded && !overflows(bubble)) {
2181
+ unwire(bubble, entry);
2182
+ bubble.setAttribute(STATE, 'none');
2183
+ continue;
2184
+ }
2185
+ }
2186
+ if (entry.expanded) expandedCount += 1;
2187
+ else {
2188
+ clamped += 1;
2189
+ if (!first) first = entry;
2190
+ }
2191
+ }
2192
+
2193
+ publish(rows.length, bubbles.length, clamped, expandedCount, first, scan);
2194
+ };
2195
+
2196
+ const schedule = () => {
2197
+ if (timer) return;
2198
+ timer = setTimeout(sweep, SETTLE_MS);
2199
+ };
2200
+
2201
+ // A language switch relabels every visible disclosure row.
2202
+ const relabel = () => {
2203
+ label.open = translate('展开', 'Show more');
2204
+ label.close = translate('收起', 'Show less');
2205
+ for (const entry of entries) entry.caption.textContent = entry.expanded ? label.close : label.open;
2206
+ };
2207
+ const unsubscribe = locale && typeof locale.subscribe === 'function' ? locale.subscribe(relabel) : null;
2208
+
2209
+ const observer = typeof MutationObserver === 'undefined'
2210
+ ? null
2211
+ : new MutationObserver(schedule);
2212
+ if (observer) observer.observe(document.body, { childList: true, subtree: true });
2213
+
2214
+ sweep();
2215
+
2216
+ return () => {
2217
+ clearTimeout(timer);
2218
+ if (observer) observer.disconnect();
2219
+ if (typeof unsubscribe === 'function') unsubscribe();
2220
+ for (const entry of [...entries]) {
2221
+ entries.delete(entry);
2222
+ try { entry.icon.dispose(); } catch (err) { /* already gone */ }
2223
+ entry.tail.remove();
2224
+ }
2225
+ for (const bubble of document.querySelectorAll(`[${STATE}]`)) bubble.removeAttribute(STATE);
2226
+ for (const bubble of document.querySelectorAll('[data-vt-clamp-lines]')) {
2227
+ bubble.removeAttribute('data-vt-clamp-lines');
2228
+ }
2229
+ if (typeof revoke === 'function') safeCall(revoke);
2230
+ };
2231
+ }
2232
+
1762
2233
  /**
1763
2234
  * Behaviour implementations, keyed by the name a patch declares.
1764
2235
  *
@@ -1771,6 +2242,7 @@ ${responseLine} {
1771
2242
  'native-tooltip': (readSelectors) => installNativeTitleTakeover(readSelectors),
1772
2243
  'scroll-edges': () => installScrollEdgeFade(),
1773
2244
  'view-switch': () => installViewSwitch(ctx),
2245
+ 'message-clamp': (readSelectors) => installMessageClamp(readSelectors),
1774
2246
  };
1775
2247
 
1776
2248
  return {
@@ -1809,7 +2281,7 @@ ${responseLine} {
1809
2281
  try {
1810
2282
  const factory = BEHAVIORS[patch.behavior];
1811
2283
  if (typeof factory !== 'function') throw new Error(`unknown behaviour "${patch.behavior}"`);
1812
- disposers.push(factory(readSelectors));
2284
+ disposers.push(factory(readSelectors, behaviorMetrics));
1813
2285
  installed += 1;
1814
2286
  } catch (err) {
1815
2287
  // A behaviour that throws must not take the visual layer down, but
@@ -1817,6 +2289,10 @@ ${responseLine} {
1817
2289
  behaviorErrors.push(`${patch.id}: ${err && err.message ? err.message : String(err)}`);
1818
2290
  }
1819
2291
  }
2292
+ // Encoded into report tokens, so a failed install is also readable
2293
+ // without DevTools (see the `behaviors` field in docs/VERIFY.md).
2294
+ behaviorMetrics.behaviors = `${installed}-of-${patches.behaviors.length}`;
2295
+ if (behaviorErrors.length > 0) behaviorMetrics['behaviour-errors'] = behaviorErrors.join(' | ');
1820
2296
  return { installed, total: patches.behaviors.length, errors: behaviorErrors };
1821
2297
  },
1822
2298
 
@@ -1825,6 +2301,11 @@ ${responseLine} {
1825
2301
  return [...behaviorErrors];
1826
2302
  },
1827
2303
 
2304
+ /** Live readings a behaviour publishes for the report channel. */
2305
+ behaviorMetrics() {
2306
+ return { ...behaviorMetrics };
2307
+ },
2308
+
1828
2309
  dispose() {
1829
2310
  if (sheet) sheet.dispose();
1830
2311
  for (const d of disposers.reverse()) safeCall(d);
@@ -2218,7 +2699,7 @@ ${responseLine} {
2218
2699
  .filter((node) => String(node.dataset.pluginCss || '').startsWith(`${PLUGIN_ID}/`))
2219
2700
  .map((node) => node.textContent || '')
2220
2701
  .join('\n')
2221
- .slice(0, 12000);
2702
+ .slice(0, 16000);
2222
2703
  }
2223
2704
  // Resolved selectors and patch states, handed in by the engine: the ground
2224
2705
  // truth for "which element did this patch actually land on".
@@ -2487,6 +2968,9 @@ ${responseLine} {
2487
2968
 
2488
2969
  sync();
2489
2970
  const behaviors = ui.installBehaviors(() => lastSelectors);
2971
+ // The install count is recorded while behaviours install; one more publish
2972
+ // makes it visible without waiting for the next body mutation.
2973
+ sync();
2490
2974
 
2491
2975
  // Plugin stylesheets are injected asynchronously during boot, so the first
2492
2976
  // discovery pass may legitimately find nothing. Re-resolve when style
@@ -2556,6 +3040,14 @@ ${responseLine} {
2556
3040
  behaviors() {
2557
3041
  return { ...behaviors, errors: ui.behaviorErrors() };
2558
3042
  },
3043
+ /**
3044
+ * Live readings published by behaviours: how many user rows the clamp
3045
+ * found, how many are clamped or expanded, and the first clamped
3046
+ * bubble's measured geometry. Same values as its report tokens.
3047
+ */
3048
+ messages() {
3049
+ return ui.behaviorMetrics();
3050
+ },
2559
3051
  /** Resolved selectors per patch, for auditing what each patch targets. */
2560
3052
  selectors() {
2561
3053
  return { ...lastSelectors };
package/docs/VERIFY.md CHANGED
@@ -41,8 +41,16 @@ cordis_inspect_query(platform="client", provider="Theme", method="listTokens")
41
41
  扫 `--vt-report-` 前缀即可。**完成判据**:每个 `component` 补丁都有 `discovered-N`(`fallback` 表示仍在
42
42
  兜底工作,可接受但要记录原因),且 `build` 与代码一致。
43
43
 
44
- `behavior` 补丁在这里**不会出现** —— 它不解析选择器。它的判据是
45
- `__vtInterface.behaviors()` 返回 `installed === total` 且 `errors` 为空,见下一条通道。
44
+ `behavior` 补丁不解析选择器,所以没有 `discovered` 这类 token。它自己额外发两组:
45
+
46
+ ```
47
+ --vt-report-behaviors-<installed>-of-<total> 四个行为是否都装上了;失败时会多一条
48
+ --vt-report-behaviour-errors-<原因> 同 `__vtInterface.behaviors().errors`
49
+ --vt-report-messages-rows-<n>-bubbles-<n>-clamped-<n>-expanded-<n> 长消息折叠的读数
50
+ ```
51
+
52
+ `messages-*` 由 `message-clamp` 自己写入独立主题层(`vt-interface:messages`),不等引擎的重发周期 ——
53
+ 页面静止时引擎不会再上报,那个读数就永远不出现(这条踩过)。
46
54
 
47
55
  这个通道每次 body 变动后 600ms 重发一次,只测「解析到没有 + 首个元素的尺寸/圆角」,
48
56
  刻意保持廉价。
@@ -52,6 +60,27 @@ cordis_inspect_query(platform="client", provider="Theme", method="listTokens")
52
60
 
53
61
  ---
54
62
 
63
+ ## 长消息折叠:怎么判断生效
64
+
65
+ 折叠只在**超过 20 行的用户消息出现在屏幕上**时才工作,所以先让那条消息进入会话视口,再读 token。
66
+
67
+ | token | 期望 | 说明 |
68
+ |---|---|---|
69
+ | `--vt-report-user-bubble-discovered-1` | 有 | 气泡类名解析到了(签名是 `--dsw-specific-bubble` 填充,全仓唯一) |
70
+ | `--vt-report-user-bubble-box-content-box-22px` | 有 | 两个前提:`content-box`(Harness 没有全局 `border-box`)+ 行高 22px |
71
+ | `--vt-report-messages-rows-<n>-…-clamped-1` | `clamped` ≥ 1 | 长消息已折叠;`bubbles` 应与 `rows` 同量级 |
72
+ | `--vt-report-messages-first-lines-<n>-max-418px-h-428-scroll-<n>-pb-0px-lh-22px-box-content-box` | 见左 | 14px 字号下:19 行 ≈ 418px 内容盒,气泡实测 428px(+10 上内距),`pb-0px` 是折叠态(内距归零,裁切边缘才对得上第 19 行的底边) |
73
+
74
+ `clamped-0` 而屏幕上确实有一条长消息时,先看 `--vt-report-messages-scan-<n>L/<高>h/<滚动高>s/<状态>`:
75
+ 它是前四个气泡的「行数/高度/滚动高度/状态」快照。
76
+
77
+ - 行数是 0:气泡还没布局(在隐藏的容器里),等它可见后一次 sweep 会补上。
78
+ - 行数 ≤ 20:判定为「不用折叠」,气泡被标记成 `none` 且不会再量第二次。
79
+ - 行数 > 20 但 `clamped-0`:`overflows` 后置检查把它退了(估算法失手时守法,例如某一行被行内 chip 撑高)。
80
+
81
+ 展开后 token 变成 `clamped-0 expanded-1`,再点一次回来。这两个数值变化只证明**状态**变了;
82
+ 「看得见的变化」仍然要肉眼确认一次,因为 `max-height` 与兄弟块的拼接没有计算值可读。
83
+
55
84
  ## 通道 B:探针(按需开启)
56
85
 
57
86
  回答「这个元素**现在**的计算样式是什么」。默认关闭 —— 发布态不该带定时器和网络请求。
@@ -136,8 +165,9 @@ tail -f /tmp/vt-dump.jsonl | jq -c 'select(.kind=="hover") | .hovered'
136
165
 
137
166
  ## 复核作用面
138
167
 
139
- 探针只打印带 `!important` 的规则,而 Harness 上游不这么写 —— 所以打印出来的就是本插件实际发出的
140
- **全部** CSS 规则。新增补丁后跑一次 `static` dump,确认规则数量和选择器都在预期内,就能证明没有
141
- 波及到输入框以外的组件。
168
+ `myCss` 打印的是本插件自己那张 `<style>` 的**全文**(按 `data-plugin-css="<插件 id>/…"` 过滤)。
169
+ Harness 上游不写 `!important`,所以带 `!important` 的那部分就是会覆盖上游的作用面;不带
170
+ `!important` 的规则全部落在本插件自己的 `vt-` 类名上(`vt-interface-tip`、`vt-edge-fade`、
171
+ `vt-msg-clamp-*`),作用面同样一眼可数。新增补丁后跑一次 `static` dump,确认规则数与选择器都在预期内。
142
172
 
143
- `myCss` 截断在 12000 字符(当前约 8.7k)。余量过半时先抬高截断值,否则上面那句「全部」就不成立了。
173
+ `myCss` 截断在 16000 字符(当前约 10.5k)。余量过半时先抬高截断值,否则「全文」这个前提就不成立了。
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "VT UI/UX Enhancer",
4
- "description": "UI/UX refinements for DeepSeek Harness: palette, elevation, control shapes and motion unified, plus space reclaimed from low-frequency features. Restores the original interface completely when disabled."
4
+ "description": "UI/UX refinements for DeepSeek Harness: palette, elevation, control shapes and motion unified, long user messages folded to 20 lines, plus space reclaimed from low-frequency features. Restores the original interface completely when disabled."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "VT UI/UX 增强",
4
- "description": "Harness 的 UI/UX 改善层:配色、层级、控件形状与动效的统一微调,并回收低频功能占用的界面空间。禁用后完全恢复原始界面。"
4
+ "description": "Harness 的 UI/UX 改善层:配色、层级、控件形状与动效的统一微调,超长用户消息折叠成 20 行,并回收低频功能占用的界面空间。禁用后完全恢复原始界面。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vt-uiux-enhancer",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "VT UI/UX Enhancer: reversible UI/UX refinements for DeepSeek Harness — palette, elevation, control shapes and motion shipped as patches over DSH's own tokens, slots and styles. Disabling the plugin restores the shipped interface exactly.",
6
6
  "license": "MIT",