@dsh-plugin/dsh-ui-experience 1.0.0-dev.43493974985 → 1.0.0-dev.43522015745

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
@@ -143,32 +143,235 @@ M3 那侧的分离要说明白:Material 3 的 `emphasized` 整体是两段首
143
143
 
144
144
  ### 滚动
145
145
 
146
- `scroll-behavior: smooth` 与脚本调用的 `scrollTo({behavior:'smooth'})` 都不受 CSS 曲线控制——时长和曲线由浏览器定,CSS 读不到,JS 也拿不到。勾选「定位滚动使用同一曲线」后,本层接管 `Element.prototype.scrollIntoView`,用脚本滚动把**一次性定位**(轨迹行聚焦、插件卡片居中、提问卡定位)换成本页曲线。
146
+ `scroll-behavior: smooth` 与脚本调用的 `scrollTo({behavior:'smooth'})` 都不受 CSS 曲线控制——时长和曲线由浏览器定,CSS 读不到,JS 也拿不到。勾选「滚动使用同一曲线」后(**默认开启**),本层接管三件事:
147
147
 
148
- #### 为什么只接管 scrollIntoView
148
+ 1. 程序平滑滚动:`scrollTo` / `scrollBy` / `scrollIntoView` 的 `behavior:'smooth'`,改用脚本滚动走本页曲线;
149
+ 2. 大位移瞬时跳转:`behavior:'instant'` 且位移超过 24px 的请求(「回到底部」一类按钮),改为动画落位。小于 24px 的瞬时跳转、以及直接赋值 `scrollTop` 一律保持即时——布局校正代码会在写入后立刻回读位置,延迟它是缺陷而非修饰;
150
+ 3. 滚轮滚动:接管滚轮增量,按指数衰减逐帧逼近落点,连发时累加而不是重来。
149
151
 
150
- `scrollTo` / `scrollBy` 若一并接管,会破坏聊天跟随。跟随控制器先设一个目标,再等 `scrollend` 结算;在 Chrome 156 实测:
152
+ 并同时遮蔽 `scrollend`,让上述三种滚动对消费者呈现「一次手势一次结束」。
153
+
154
+ 默认开启意味着每个用户都会拿到原型补丁与事件遮蔽。关掉即拆掉补丁、监听与遮蔽,在飞的滚动就地停止、兑现其 Promise,并把欠下的一次结束放行。
155
+
156
+ ### 流式文本
157
+
158
+ 勾选「流式文本渐进显现」(**默认开启**)后,正文与思考摘要在生成时逐段显现,而不是随每个分片一次跳出。
159
+
160
+ #### 为什么移动遮罩而不是截断文字
161
+
162
+ 最直接的做法是把文本节点截到前 N 个字符,再让 N 随时间增长。这条路不走,因为文本归 React 所有:它会持续下发完整字符串,两边互相覆盖;而在覆盖的空隙里,选择、复制和读屏拿到的都是被截断的内容。
163
+
164
+ 所以文字一个字都不截。模型产出的每个字符从到达那一刻起就在 DOM 里,只有一条 `mask-image` 的显现线在移动:线上的部分绘制,线下的部分透明。布局在分片到达的瞬间就已经定稿,动画不可能与它不一致。
165
+
166
+ `scroll` 实测:流式过程中整段文本可选中,选区长度与文本长度一致。
167
+
168
+ #### 逐字:靠倾斜遮罩线,不切分文本
169
+
170
+ 水平遮罩线只能整行推进,观感是「一行闪出来」而不是打字。把线倾斜即可:边界每下降一段就横穿整行一次,任何时刻处在部分显现中的是这条斜线跨过的那几行,并且在这几行内从左向右推进。
171
+
172
+ 倾斜角固定在**相对水平 15°–45°** 区间(CSS 里写作 `180° - 倾斜角`,即 `135deg`–`165deg`)。下限由观感定:斜线的倾角越小,越过一行的下落量越小,在 920px 宽的正文上一行只需不到 2°,看上去就是一条横线,逐字推进完全看不出来;取 15° 时同一行会落下约 246px,斜边清晰可见。上限避免窄栏被扫成竖向擦除。
173
+
174
+ 代价是斜线同时跨越多行(15° / 920px 约 10 行),因此不再是「只揭一行」,而是一次揭开一个斜向的带;换行时整行会略微推迟,而不是逐行打字。这里优先的是逐字推进的观感,不是逐行节奏。
175
+
176
+ 渐变轴不再是竖直方向,所以「完全绘制」的位置是**右下角**而不是底边:`sin(倾角) × 宽度 + cos(倾角) × 内容高度 + 淡出带`。少算这一项会在最后一行右端留下永久渐隐,短消息上就是整块可见区域。
177
+
178
+ 不切分文本,是因为文本归 React 所有:切分后它会持续下发完整字符串,两边互相覆盖,而覆盖的空隙里选择、复制与读屏拿到的都是残缺内容。
179
+
180
+ #### 整流:时长随积压量增长,速度随积压量上升
181
+
182
+ 显现进度落后于内容高度 `gap`,时长取 `36.8 × gap^0.62`(钳在 250–1200ms),再乘时长倍率、按需吸附。
183
+
184
+ **下限就是缓存的可见窗口**:一行约 250ms、十行约 1s、整块一次落下封顶 1.2s。正常输出时边界后方始终压着一到两行未读文字,突发输出被吸收成更长的推进而不是被跳过。低于这个量级,每次到达都会在眼睛反应过来之前结束,看起来就是「没有动画」——上一个版本每行只有 90ms 正是如此。
185
+
186
+ 指数小于 1 让速度随积压上升(时长增长比距离慢),所以积压越多追得越快,不会越落越远。遮罩本身是连续推进的,同一时刻处在显现中的是一整行,不是固定一个字。
187
+
188
+ 本层的曲线与时长全部取自同一份配置,不另设一套:
189
+
190
+ | 项 | 来源 |
191
+ | --- | --- |
192
+ | 显现过渡曲线 | 当前曲线族的**效果曲线**(M3E `0.34,0.8,0.34,1`/M3 `0.2,0,0,1`) |
193
+ | 显现时长 | 积压量 ÷ 0.55px/ms × 时长倍率,钳制后按需吸附 |
194
+ | 标签进场曲线 | 同上,作为 `--dsh-uix-ease` 写在根元素 |
195
+ | 标签进场时长 | 260ms × 时长倍率,按需吸附,作为 `--dsh-uix-roll` |
196
+ | 新行进场曲线 | 同上,作为 `--dsh-uix-ease` |
197
+ | 新行进场时长 | 220ms × 时长倍率,按需吸附,作为 `--dsh-uix-enter` |
198
+ | 标签追加时长 | 与显现同一公式,由积压宽度算出 |
199
+
200
+ 输出突发时积压大、追得快;输出缓慢时积压小、走得慢。两者在观感上没有区别,这正是要的效果。
201
+
202
+ 推进由注册过的自定义属性(`@property`)上的 `transition` 完成,因此跑在合成器上,不占主线程。本层只在分片到达时算一次时长。实测流式期间帧时间中位数 16.7ms。
203
+
204
+ #### 覆盖哪些元素
205
+
206
+ 按应用自己的标记识别,不猜结构:
207
+
208
+ | 标记 | 含义 | 处理 |
209
+ | --- | --- | --- |
210
+ | `[data-streaming]` + 多行 | 正文 | 逐字显现 |
211
+ | `[data-streaming]` + 单行(`nowrap` 或高度不足 1.5 行) | 思考摘要、只含一行内容的整条消息 | 横向边缘推进 |
212
+ | `[data-shimmer]` | `TextShimmer` 的活动标签,工作步骤组标题、运行状态等 | 横向边缘推进 |
213
+
214
+ 单行判定必须包含高度:`nowrap` 只覆盖思考摘要,而一条流式消息在只有一行时同样没有可供显现的纵向空间——倾斜的显现线在 1 行高的元素上无处下移,只会把它这一行按固定比例压暗,看起来是一个静止的遮罩。实测该元素的遮罩轴长按 `高度 + 行高` 算,1 行元素上轴长 48px,而过去的静止位置只有 34.8px,于是永久留着一段横跨 300px 以上的渐变。
215
+
216
+ `TextShimmer` 只用于活动中的单行标签,所以它是可靠的「这是一行会变的标签」信号。
217
+
218
+ 嵌套时只标记最外层:正文显现本身会覆盖其内部所有内容,再标一次就是同一行动画两遍。但如果外层只是单行(按上面的规则成为标签),它不显现任何内容,就该让内部真正的标签自己推进——否则整行既没有横向推进,又被外层留下一段静止渐变。所以外层只有在成为多行显现时才吞掉内部标签。
219
+
220
+ #### 标签追加:只显现新增的那几个字
221
+
222
+ 工作步骤的标题是**追加**成长的:`工作中 - A` → `工作中 - A, B` → `工作中 - A, B, C`。把整行滚出去再滚进来,会把读者已经读过的部分重新动一遍,观感是整行反复滑行。
223
+
224
+ 改为横向边缘推进:新文本到来时把遮罩左边缘放在「新旧文本共享前缀」的宽度处、右边缘推到新宽度。旧字符原地不动,只有真正变化的部分从边缘后面显现。
225
+
226
+ 共享前缀按字符算、再乘到当前渲染宽度上,所以两种变化都能覆盖:纯追加(`A` → `A, B` → `A, B, C`)边界正好落在旧文本右边缘;滑动窗口式的明细(只共享一段前缀)边界落在第一个真正改变的字上。
227
+
228
+ 两类标签的处理不同,按应用标记区分:
229
+
230
+ | 元素 | 变化时 |
231
+ | --- | --- |
232
+ | `[data-shimmer]` 单行标签(步骤标题) | **只**横向推进,从不纵向滚动 |
233
+ | `[data-streaming]` + 单行(思考摘要、单行消息) | **只**横向推进 |
234
+
235
+ 标签变长时行本身不动:它的变化方式就是变长,把整行滚出去再滚进来只是让读者重读已经读过的部分。整行被替换时边界回到左端重新推进(并伴随上滑进场,见上一节),所以新的一段仍然是从头揭开,而不是接着旧文字的右边缘继续。
236
+
237
+ 标签自带遮罩时(思考摘要由应用自己淡出被裁剪的尾部),本层在标记的那一刻记下它的遮罩值,静止时原样放回,只在到达时换成推进用的渐变。否则本层那条覆盖整行的规则会把应用的柔和边缘变成硬切。
238
+
239
+ 宽度靠 `Range` 量实际渲染宽度,而不是数字符再换算:DOM 里混着空白文本节点,归一化后的字符偏移量并不对应到调用方以为的那个节点——这个做法试过,量出来的位置会偏(实测 55px,应为 87px)。
240
+
241
+ #### 追加与换行是两种变化
242
+
243
+ 工作步骤标签有两条完全不同的更新路径,动画必须分开:
244
+
245
+ | 变化 | 判定 | 动画 |
246
+ | --- | --- | --- |
247
+ | 文本追加 | 新文本以旧文本开头 | 只有横向边缘推进,行本身**不动** |
248
+ | 换行(整行更换) | 新旧文本无共享开头 | 从下方**上滑**进场,同时从左边揭开 |
249
+ | 首次出现 | 这个槽位此前是空的 | 上滑进场 |
250
+
251
+ 早先的判定只看元素自己记住的上一段文字。问题在于这条标签的 DOM 节点并不稳定:标题一变,进程行就重建,接管的元素是新的,于是每次都判成「首次出现」,**每追加一个词就重放一遍完整进场**——整行淡入并上移,读者看到的就是反复揭幕。
252
+
253
+ 现在按 **DOM 槽位**(父元素 + 子节点序号)记录上一段文字与宽度。元素换了,槽位没换,就仍然认得这是同一行:能续上就只推进新字,接不上才当成换行。实测两种更新方式(复用节点 / 重建节点)结果一致:追加时进场动画数为 0、边缘从旧宽度继续;换行时进场动画播放、边缘从 0 起步。
254
+
255
+ #### 关掉应用自己的流光
256
+
257
+ `TextShimmer` 会在可读副本之外再渲染一份惰性副本,并让一条光带在其中无限扫过——这是应用自带的「流光」。本层改的是可读副本上的遮罩,两者叠加就是一条光带横穿一行正在被揭开的文字,观感是两套动画在打架。
258
+
259
+ 所以本层标记的标签会把那份装饰副本隐藏(它恒为第二个子元素,且只在标签活动时存在,静止标签上这条规则匹配不到任何元素)。流式动画关闭时这条规则也不存在,恢复应用原样。实测标记后装饰层的 `display` 为 `none`、其 `getAnimations()` 为空。
260
+
261
+ #### 进场与追加分属两个时钟
262
+
263
+ 单行标签的两个动作是「整行滑入」(`dsh-uix-arrive`:上移 0.85 行高并淡入,默认 260ms)与「逐字追加」(横向边缘推进)。它们共用同一条元素,但生命周期无关。
264
+
265
+ 早先两者共用一个世代计数,于是**第一个到达的分片就会把进场动画的标记摘掉**:输出快时标签只升到一半就被定格,观感是「上滑进场没了,只剩逐字」。现在进场有独立的计数与定时器,追加不再触碰它,进场一定会跑完。实测 40ms 间隔连发三次文本:透明度 0.31 → 0.60 → 0.87 → 1.00,位移 11.8px → 6.8px → 2.2px → 0,进场完整播完;随后追加的横向推进照常进行。
266
+
267
+ #### 连续到达的推进不会互相打断
268
+
269
+ 每个标签有一个世代计数:每次启动横向推进就递增,清理定时器只在自己仍是最新世代时才生效。没有这条约束时,一次更新留下的定时器会在下一次推进播到一半时把它收尾,观感是推进时断时续。
270
+
271
+ 进场动画用另一个独立计数(见上一节),因为两者的时长无关:进场是 260ms,推进随积压量变化,谁先结束并不固定。
272
+
273
+ #### 新行怎么出现:行自己进场,不动滚动
274
+
275
+ 聊天跟随的写法是「写入滚动上限,然后立刻回读确认落点」(`use-chat-viewport.ts` 的 `write`)。任何让写入延迟到下一帧的做法都会让这次回读拿到旧值,跟随控制器据此判定被内容推离底部,于是停止跟随。所以**滚动本身不做动画**。
276
+
277
+ 新出现的内容由行自己进场:新插入的行淡入并上移 6px。只动 `opacity` 与 `transform`,不碰布局,因此同一 tick 里回读到的位置与写入值完全一致。
278
+
279
+ 只在该行确实是一次「跟随新到达」时触发:
280
+
281
+ - 聊天根节点带有 `data-chat-following-tail`(应用自己表达的「正贴底」),且上一次同样如此;
282
+ - 该行是本次新增的,不是已有行换位置。
283
+
284
+ 读者往上翻时新到的内容不动画;折叠块换位、历史加载插入的行也不动画。
285
+
286
+ 实测 30 次追加:回读误判 0 次、视口滞后始终 0px、30 行全部进场、最终精确贴底;关掉跟随标记后新增行不动画。
287
+
288
+ #### 收尾
289
+
290
+ 流式结束时元素失去 `data-streaming`,本层先把显现线推到遮罩轴末端,等过渡走完再摘掉遮罩——直接摘会让最后一行从半透明瞬变实色。
291
+
292
+ 遮罩轴长按 `内容高度 + 行高 + 淡出带` 算,不是内容高度。倾斜让渐变轴比内容更高:轴要横跨整行宽度就得再落下恰好一个行高,所以静止位置少了这一行,边界就会停在内容里,表现为最后一行右侧永远压着一段渐变。实测 1 行元素用旧公式停在 34.8px,轴长 48px,残影横跨 300px 以上。
293
+
294
+ #### 单行长成多行
295
+
296
+ 一条消息从一行文字长成两行时,处理从横向推进切到纵向显现。切换那一刻把显现线放在新内容的最后一行之上,于是刚到达的这行被揭开一次;直接按「已显示」开局会让它整行瞬间跳出。
297
+
298
+ #### 为什么需要连 scrollend 一起接管
299
+
300
+ 逐帧写 `scrollTop` 是一串**瞬时**滚动,浏览器为每一次都结束一次。同一段 1200px 位移,Chrome 156 实测:
151
301
 
152
302
  | 写入方式 | scroll 事件 | scrollend 事件 |
153
303
  | --- | --- | --- |
154
- | 原生 `behavior:'smooth'` 一次 | 89 | **1** |
155
- | 逐帧赋值 `scrollTop`(脚本动画) | 21 | **21** |
304
+ | 原生 `behavior:'smooth'` | 56 | **1** |
305
+ | 逐帧赋值 `scrollTop` | 21 | **21** |
306
+
307
+ 页面上所有「滚动是否停了」的判断都听 `scrollend`:聊天的跟随控制器靠它结算目标,会话检查器的定位高亮靠它决定何时落点。一次手势收到 21 次结束,它们都会在第一帧认定读者中途停下。
156
308
 
157
- 浏览器会为自己发起的每一次瞬时滚动各发一次 `scrollend`,本层无法拦截。控制器把其中第一个读作「读者在中途停下」,于是在动画第一帧就放弃跟随。因此走这条循环的调用一律保持原生。
309
+ 所以本层同时**遮蔽** `scrollend`:在移动某个容器期间,该容器的真实 `scrollend` 在捕获阶段被拦下,等到真正落位(或取消)时补发一次合成事件。消费方看到的序列与原生平滑滚动一致——一串 `scroll`,恰好一次结束。落位后仍保留 120ms 的遮蔽窗口,用来吞掉浏览器为最后一次写入补发的那次结束;此后到来的事件属于下一位滚动者,正常放行。
158
310
 
159
- `scrollIntoView` 属于一次性定位:一次手势对应一个终点,没有消费方靠数滚动结束事件推断意图。
311
+ 实测两个真实消费方(Chrome 156,同一段 9000px 定位):
312
+
313
+ | | 关闭(原生) | 开启 |
314
+ | --- | --- | --- |
315
+ | `scroll` / `scrollend` 次数 | 89 / 1 | 29 / 1 |
316
+ | 会话检查器落高亮的时刻 | 1525ms | 486ms |
317
+ | 落高亮时的 `scrollTop` | 8870 | 8870 |
318
+ | 聊天跟随的 `following` | true | true |
319
+
320
+ #### 一次性定位与跟随循环
321
+
322
+ `scrollTo` 既承载一次性定位(虚拟化表格跳行),也承载流式跟随——后者每秒重定向几十次。两者共用一条规则:**同一容器上再次请求平滑滚动时,合并进正在运行的那一次**,从当前位置重新计算剩余行程与时长,所有调用方的 Promise 一起在落位时兑现。
323
+
324
+ 这既符合原生平滑滚动的语义(原生也是重新瞄准而非重来),也避免两个问题:重启会让曲线每帧从头开始,永远播不完;而不合并的话,每帧重定向会让运行永远不结束,依赖「滚动已停」的消费方永远等不到那一次结束。
325
+
326
+ 虚拟化表格会在测量到真实行高后再次以同一目标重发请求,流式跟随会在内容增长时不断抬高目标——两者都能被合并吸收。实测每 45ms 重定向一次的跟随循环:71 次 `scroll`、**1 次** `scrollend`,与原生一致。
327
+
328
+ `scrollIntoView` 的终点由对齐方式、`scroll-margin`、嵌套容器和元素自身盒模型共同决定,自己算就是「滚到错的那一行」的来源。做法是先向浏览器要一次瞬时跳转,读出每个被移动容器的落点,把它们全部放回原位,再动画到读到的落点;跳转与还原在同一个任务内完成,中间位置不会绘制,跳转自身产生的那次结束也一并被遮蔽。
329
+
330
+ #### 滚轮
331
+
332
+ 浏览器的滚轮是逐像素跟手的,没有任何缓动入口。这里在**冒泡阶段**监听 `wheel`,先让更靠近目标的处理器发言,再决定是否接管:
333
+
334
+ - 只在**手势所问的那一轴**上找滚动容器。鼠标下面是代码块、宽表格这类只能横向滚动的元素时,垂直滚轮不能被它挡住——浏览器会把增量链式交给父容器,本层若在这里弃权,父容器就退回原生曲线。
335
+ - 命中**文本输入框**就不接管:读者是在编辑器中滚动自己的文本,缓动会与光标抢位置。
336
+ - `defaultPrevented` 为真就不接管:草稿编辑器在自身到顶/到底时会把滚轮转发给聊天记录,宽度手柄会把滚轮变成横向滚动——这些地方已经用 `passive: false` 的监听认领了事件,本层不能抢。滚轮监听本身也必须 `passive: false`,否则 `preventDefault` 会被忽略(window 上的滚轮监听默认是 passive 的)。
337
+ - `ctrl` / `meta`(缩放)不接管。
338
+ - 粗粒度增量按 Chromium 自己的换算折算成像素:`DOM_DELTA_LINE` 每单位 40px(`cc::kPixelsPerLineStep`),`DOM_DELTA_PAGE` 为 `max(视口×0.875, 视口−40)`(`ScrollUtils::CalculatePageStep`)。物理滚轮上报的往往是行数而不是像素,不折算就会整类被跳过。
339
+ - 容器已经到边界时,夹紧后的落点等于当前位置,不接管,让浏览器照常处理链式滚动(`overscroll-behavior`)。
340
+
341
+ 接管后走与其他滚动相同的曲线族与时长模型:每一格从**当前实际位置**起算到累积目标,时长按这段距离算,曲线取当前设置的效果曲线。这与 Chromium 自身的 `UpdateTarget` 语义一致——重新瞄准,而不是把曲线从已经被走过的起点重新播一遍——所以连续滚动时位置连续、不会跳回。
342
+
343
+ 实测同一段 300px:`M3` 在 40ms 时走到 120px,`M3E` 走到 139px,形状随曲线族变化。任一读者按键、触摸,或本层的程序滚动启动,都会立刻终止滚轮缓动。
344
+
345
+ #### 为什么必须自己接管:浏览器把滚轮曲线写死在编译期
346
+
347
+ `scroll-behavior` 规范上就不管用户滚动——CSSOM View 原文是「导航、CSSOM 滚动 API、非用户发起的 scroll snapping 之外的其他滚动,例如用户执行的滚动,不受该属性影响」。
348
+
349
+ Chromium 内部确实有滚轮滚动动画,但曲线是源码常量(`cc/animation/scroll_offset_animation_curve.cc`):
350
+
351
+ ```cpp
352
+ static constexpr CubicBezierPoints kEaseInOutControlPoints{
353
+ .x1 = 0.42, .y1 = 0, .x2 = 0.58, .y2 = 1, // 就是 CSS 的 ease-in-out
354
+ };
355
+ CubicBezierPoints control_points = kEaseInOutControlPoints;
356
+ if (scroll_type_ == ScrollType::kProgrammatic) {
357
+ control_points = GetCubicBezierPointsForProgrammaticScroll(); // 仅程序化滚动可被 feature flag 覆盖
358
+ }
359
+ ```
360
+
361
+ 用户滚轮走 `kEaseInOutControlPoints`,**连命令行参数都改不了**;能被 `--enable-features` 覆盖的只有程序化滚动那条分支。时长同样是常量算法(`kInverseDelta`,120–480px 映射到 100–200ms,`kDurationDivisor = 60`)。CSS 与 JS 都读不到这些值,也无法覆盖。
160
362
 
161
363
  #### 固定的两条规则
162
364
 
163
- 1. **读者优先。** 在正在滚动的容器上滚轮或触摸、或在文本框之外按滚动键,脚本滚动立即取消,不混合、不恢复;任何本层没写过的位置变化(拖滚动条、辅助技术滚动、容器在动画中重排)也会在下一帧取消。滚轮落在别的面板上不会打断——那不是这个容器的滚动。
365
+ 1. **读者优先。** 在正在滚动的容器上滚轮或触摸、或在文本输入框之外按滚动键,脚本滚动立即取消,不混合、不恢复;任何本层没写过的位置变化(拖滚动条、辅助技术滚动、容器在动画中重排)也会在下一帧取消。滚轮落在别的面板上不会打断——那不是这个容器的滚动。
164
366
  2. **用效果曲线,不用位移曲线。** 位移曲线带过冲,滚动过冲意味着内容冲过目标位置再退回来,终点错两次。曲线族照样生效,带过冲的那半永远不用于滚动。
165
367
 
166
368
  #### 时长与落点
167
369
 
168
370
  - 240px 以内固定 180ms,之后每像素加 0.2ms,封顶 460ms;再乘时长倍率,开了「时长对齐」时同样吸附到 Material 档位。
169
- - 终点由对齐方式、`scroll-margin`、嵌套容器和元素自身盒模型共同决定,自己算就是「滚到错的那一行」的来源。做法是先向浏览器要一次瞬时跳转,读出每个被移动容器的落点,把它们全部放回原位,再动画到读到的落点;跳转与还原在同一个任务内完成,中间位置不会绘制。
170
- - 返回值:脚本路径返回一个在落点写入(或被取消)后兑现的 Promise,与原生 `scrollTo` 一致。
171
- - 「始终减弱动效」或系统的减弱动效偏好生效时,平滑定位直接变成瞬时跳转。
371
+ - 返回值与原生一致:脚本路径返回一个在落点写入(或被取消)后兑现的 Promise,元素本来就在目标位置时返回已兑现的 Promise;非平滑与减弱动效路径直接透传原生返回值。
372
+ - 「始终减弱动效」或系统的减弱动效偏好生效时,平滑滚动与大位移跳转、滚轮缓动全部退回即时/原生。
373
+ - 滚轮缓动同样受「读者优先」约束:任何时刻本层只持有一个滚轮目标,程序滚动接管时立即释放。
374
+ - 只认显式写出的 `behavior:'smooth'`。`behavior:'auto'` 落进带 CSS `scroll-behavior: smooth` 的容器时仍是原生平滑滚动。
172
375
 
173
376
  ### 滚动驱动动画不动
174
377
 
@@ -199,7 +402,7 @@ DSH 目前只有一处用到(提问卡片标题的溢出遮罩 `--question-cop
199
402
 
200
403
  ### 生效范围
201
404
 
202
- - 覆盖页面上的全部样式表,包括其他插件注入的。首次应用时全量扫描,之后用 `MutationObserver` 监视 `head` 与 `body`;只有当新增节点本身是 `<style>` / `<link>`,或子树里含有时才补扫——聊天流式输出每帧都在改 DOM,无条件补扫会把一次性开销变成每帧开销。
405
+ - 覆盖页面上的全部样式表,包括其他插件注入的。首次应用时全量扫描,之后用 `MutationObserver` 观察:`head` 带子树(样式通常注入在那里),`body` 只看直接子节点。只有新增节点本身是 `<style>` / `<link>` 才触发补扫,**不搜子树**——实测对一个 1500 节点的子树跑 `querySelectorAll('style,link')` 要 17.7µs,流式输出每秒会插入多次这样的容器,此时观察者的开销会超过它想触发的重扫。
203
406
  - 通过 `document.adoptedStyleSheets` 挂载的构造式样式表不是 DOM 变更,观察者看不到它,只能在下一次全量扫描(切换曲线族、解除减弱动效、重新应用设置)时被读到。
204
407
  - 同理,对已挂载 `<style>` 再改 `textContent`、或在它的 `sheet` 上 `insertRule`/`replaceSync`,都不产生新增节点,也要等下一次全量扫描。DSH 自身与插件加载器都是「建 `<style>` → 设文本 → 挂载」,不受影响。
205
408
  - 跨源样式表读不到 `cssRules`,会在浏览器控制台记录一行并跳过——这类表的规则无法改写,静默跳过会让部分界面停留在原生曲线而无迹可循。(这条诊断走控制台而非 `~/.dsh/ui-experience.log`:后者由宿主半写入,浏览器半够不到文件系统。)
@@ -220,10 +423,18 @@ DSH 目前只有一处用到(提问卡片标题的溢出遮罩 `--question-cop
220
423
  - 跨源样式表里的规则无法改写,只会在日志中记录。
221
424
  - 本层只改写曲线与时长,不新增、不改写关键帧本身;DSH 没定义的动效不会因此出现。
222
425
  - CSS 的 `scroll-behavior: smooth`(全仓一处,dock 标签条)不是 transition/animation,本层不碰;只有减弱动效时随压制表置为 `auto`。
223
- - 脚本滚动只覆盖 `scrollIntoView`,由「定位滚动使用同一曲线」单独控制,与曲线族无关(关掉曲线族就一并停止接管)。聊天跟随、附件横向滚动、虚拟列表定位都走 `scrollTo`/`scrollBy`,保持原生时序。
224
- - 接管的定位滚动会按帧产生多次 `scrollend`(实测一次约 28 次)。目前没有消费方在这一族调用上依赖单次 `scrollend`;若将来 DSH 给轨迹面板加上类似的跟随控制器,需要重新评估。
426
+ - 流式文本层不截断任何文字,只移动遮罩;选择、复制、读屏与搜索都按完整文本工作。
427
+ - 渐进显现按遮罩线推进,粒度为「行内字符」:斜线跨过的那几行同时处于部分显现中,行内从左向右推进。它不解析分片边界,因此显现节奏跟随内容高度而非字符数;斜线取 15°–45°,换行时整行会有短暂推迟,而不是严格的逐行打字。
428
+ - 滚动本身永远不做动画:跟随控制器在每次写入后立即回读位置,延迟的写入会让它误判并停止跟随。新内容的动效由行自己承担,只动 `opacity` 与 `transform`。这也意味着跟随是瞬时的,读者看到的是文字逐段显现与行进场,而不是视口滑动。
429
+ - 新行进场只在根节点带 `data-chat-following-tail` 且上一轮同样贴底时触发;读者上翻时新到达的内容直接出现。
430
+ - 脚本滚动由「滚动使用同一曲线」单独控制,与曲线族无关(关掉曲线族也一并停止接管)。关掉开关会立即拆掉补丁与监听,在飞的脚本滚动就地停止并兑现其 Promise,不残留。
431
+ - 遮蔽期内的合成 `scrollend` 其 `isTrusted` 为 `false`。DSH 侧没有检查该属性的代码;第三方插件若据此区分真实事件,会看到差异。
432
+ - 持续重定向的跟随循环会被判为「控制器追目标」而交还浏览器,此时滚动恢复原生曲线——这是有意的:被每帧重启的脚本动画播不出曲线。
225
433
  - 滚动驱动动画(`animation-timeline`)不属于时间轴动画,本层显式跳过,见上一节。
226
- - 浏览器半在刷新页面时重载,宿主半只在进程重启时重载。两者版本不一致时(刚升级插件但没重启 DSH),M3 动效行会保持无操作并在控制台记录一行原因,配色功能不受影响。
434
+ - 浏览器半在刷新页面时重载,宿主半只在进程重启时重载。两者版本不一致时(刚升级插件但没重启 DSH):
435
+ - 宿主还不认识 `motion` 整组设置:动效行保持无操作,控制台记录一行原因。
436
+ - 宿主认识 `motion` 但缺少后加的 `stream` 字段:流式文本层照常工作(它完全在浏览器半),但对应开关隐藏——旧宿主的配置结构里没有这个键,写入会被拒绝。控制台记录一行原因。
437
+ - 其余功能不受影响。重启 DSH 后开关恢复。
227
438
 
228
439
  ## 样式基线
229
440
 
@@ -247,19 +458,37 @@ DSH 目前只有一处用到(提问卡片标题的溢出遮罩 `--question-cop
247
458
  | `.dsh-uix-reset` | `Button.module.css` `.outline` |
248
459
  | `.dsh-uix-switch` / `-thumb` | `Switch.module.css` `.switch` / `.thumb` |
249
460
  | `.dsh-uix-segment` / `-indicator` / `-tab` | `SegmentedControl.module.css` `.control` / `.indicator` / `.tab` |
250
- | `.dsh-uix-check` / `-label` / `-hint` | `Checkbox.module.css` `.checkbox` 及其原生输入 |
461
+ | `.dsh-uix-option` / `-text` / `-title` / `-desc` | `DeveloperToolsRow.module.css` `.row` / `.title` / `.description` |
251
462
 
252
463
  所有颜色、圆角、焦点环都用 `--dsw-*` 主题 token,这些是跨版本稳定的公开契约;只有长度值(内距、尺寸、字号)可能随改版调整。升级 DSH 后,把上表的源文件与 `src/styles.ts` 对照一遍即可。
253
464
 
465
+ 布尔项一律用与上方行相同的开关(`Switch`),而不是原生复选框:设置页里「开发工具」一类布尔行就是「标题与说明左置、开关右对齐」,同一页混用两种控件会显得割裂。选项行的标题 13px/500、说明 12px 次级色,行间共用一条 0.5px 分隔线。
466
+
254
467
  ## 开发
255
468
 
256
469
  ```bash
257
470
  pnpm install
258
471
  pnpm typecheck
259
472
  pnpm build
473
+ pnpm test
260
474
  ```
261
475
 
262
- 源码在 `src/`(`index.ts` 为宿主半,`client.ts` 为浏览器半,`shared.ts` 为两端共享的协议类型),esbuild 产物输出到 `lib/`。`lib/` 会提交入库,以便其他设备直接从 git 安装而无需构建环境;改动源码后务必重新构建并一并提交产物。
476
+ 源码在 `src/`:
477
+
478
+ | 文件 | 职责 |
479
+ | --- | --- |
480
+ | `index.ts` | 宿主半:配色解析与映射、Remote 服务、配置文件注入 |
481
+ | `client.ts` | 浏览器半:设置行、页面级图层接线 |
482
+ | `motion.ts` | CSSOM 遍历与曲线改写 |
483
+ | `motion-plan.ts` | 纯函数:值列表切分、时长解析与重定时、属性分类、曲线选择与求值 |
484
+ | `scroll.ts` | 脚本平滑滚动、滚轮缓动、大位移跳转与 `scrollend` 遮蔽 |
485
+ | `stream.ts` | 流式文本渐进显现与思考摘要滚动 |
486
+ | `styles.ts` | 设置行样式表 |
487
+ | `shared.ts` | 两端共享的协议类型 |
488
+
489
+ esbuild 产物输出到 `lib/`。`lib/` 会提交入库,以便其他设备直接从 git 安装而无需构建环境;改动源码后务必重新构建并一并提交产物。
490
+
491
+ `pnpm test` 只覆盖 `motion-plan.ts` 的纯规则(值列表切分、时长解析与重定时、属性分类、曲线选择、贝塞尔求值)。CSSOM 改写与脚本滚动属于 DOM 编排,不在单元测试范围内,用真实浏览器验证:把 `lib/client.js` 注入页面、按插件的方式挂载,再断言改写后的声明与滚动落点。
263
492
 
264
493
  ## 安装
265
494