Skip to content

About

DeepSeek Harness interface experience plugin: overlay the system matugen Material You palette as the theme. DSH 界面体验插件:跟随系统 matugen 生成的 Material You 配色。

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

dsh-ui-experience

DeepSeek Harness 界面体验插件(@dsh-plugin/dsh-ui-experience,仓库:https://github.com/dsh-plugins/dsh-ui-experience):读取系统 matugen 生成的 Material You 配色,作为叠加层套在 DSH 当前主题上;按 Material 3 曲线改写整个界面的过渡与关键帧动画;并统一界面边框宽度。

功能

  • 「设置 → 通用设置」新增一行 Matugen 配色跟随,由开关控制启用 / 停用。开启后界面立即叠加 matugen 当前输出的配色;matugen 重新生成颜色(例如更换壁纸)后数秒内自动跟随,无需刷新页面。
  • 配色是叠加层,不改写原生「浅色 / 深色 / 跟随系统」偏好;开关关闭后回到原生配色。
  • 同一行可展开 配色文件路径,分别编辑 matugen 颜色文件与 DSH 令牌文件的路径,两者都会持久化;开关状态与路径均保存在 profile 配置里,重启应用后自动恢复。
  • 统一界面边框宽度:插件配置 borderWidth(默认 0.5px),仅在「Matugen 配色跟随」开启时生效。
  • 「设置 → 通用设置」另起一行 M3 动效,可选择 M3 / M3E 两族曲线,并调时长倍率、时长对齐与减弱动效策略。它原地改写页面上已生效的样式规则,覆盖范围包含第三方插件注入的样式。
  • 配色文件只在本地读取,不上传。

使用

  1. 安装并启用插件后刷新页面。
  2. 打开「设置 → 通用设置」,开启 Matugen 配色跟随。
  3. 界面配色随配色文件内容变化自动更新,检测周期 2 秒。
  4. 关闭开关:配色叠加层、边框覆盖与启动底色叠加同时立即撤销——注入样式以 <html data-dsh-uix-matugen> 为作用域,关闭时该属性被移除,规则立刻失效;注入的 <style> / <script> 元素本身保留到下次刷新。此后插件停止 2 秒轮询,改为约 30 秒一次的低频检查。

配置路径

「Matugen 配色跟随」所在行可展开 配色文件路径,编辑两个路径并保存:

文件 默认路径 用途
颜色文件 ~/.local/state/quickshell/user/generated/colors.json matugen 原始输出;插件用内置映射把它转换为界面配色
令牌文件 ~/.local/state/quickshell/user/generated/dsh-tokens.json 由自定义 matugen 模板产出的 DSH token 表,逐 token 叠加在内置映射之上
  • 两个文件是叠加关系,不是替代关系:颜色文件提供完整的内置映射,令牌文件里列出的 token 覆盖对应项,未列出的继续用映射值。只写需要的几项也能生效,不会让其余界面退回原生配色。
  • 只存在令牌文件、没有颜色文件时,未列出的 token 只能保持 DSH 内置值;插件会在日志中说明这一情况。
  • 路径必须是绝对路径,或以 ~/ 开头(~ 按当前用户主目录展开)。
  • 「恢复默认」把两个路径重置为上表默认值。
  • 两个文件都不存在时插件不上色,界面保持原生配色。

表面层级

内置映射把 Material You 的六档表面按明度排成阶梯:

surface_container_lowest → surface_container_low → surface_container → surface_container_high → surface_container_highest → surface_bright

  • 侧栏取最暗一档,主面板从第二档起算,因此侧栏比主面板更暗。
  • 菜单、卡片、输入区、代码块等其余表面按原有相对层级整体上移一档,各层之间的相对明暗顺序不变。
  • Material 相邻档的色差本身较小,输入区、卡片与主面板之间只有微弱的层次区分;需要更强的分区感时应单独调整对应 token,而不是继续依赖相邻档。
  • overlay 原本就位于阶梯顶端,没有更高的档位可移,因此在最亮一档上用 color-mix(in srgb, …, white 8%) 提亮,保持它高于其余浮层。
  • 遮罩类 token 用 color-mix(…, transparent 50%) 保留透明度,避免不透明的纯 scrim 盖住下层界面。

颜色来源与合成顺序

  1. 颜色文件 colors.json:matugen 的 Material You 颜色表,由插件内置映射转换为全部界面 token(148 项)。matugen 只给出一套暗色方案,因此浅色与深色两种模式得到同一张表。
  2. 令牌文件 dsh-tokens.json(可选):叠加层。文件里列出的 token 按模式覆盖上一步的结果,未列出的保持映射值。因此可以只写关心的少数几项。
  3. 两者都不存在:不叠加配色。

token 名必须以 --dsw- 开头;两边都没给的 token 保持 DSH 内置值。

matugen 模板接入(可选)

默认的 colors.json 只含一套暗色方案。若要让浅色与深色各自成表,或自行控制 DSH 全部 token 的取值,可在 ~/.config/matugen/config.toml 增加一个模板:

[templates.dsh]
input_path = '~/.config/matugen/templates/dsh-tokens.json'
output_path = '~/.local/state/quickshell/user/generated/dsh-tokens.json'

模板文件写成一个合法 JSON 对象,light 与 dark 各一张表;matugen 把 {{colors.<角色>.<light|dark>.hex}} 替换为对应方案的颜色值:

{
  "light": {
    "--dsw-alias-bg-base": "{{colors.surface.light.hex}}",
    "--dsw-alias-bg-layer-1": "{{colors.surface_container_low.light.hex}}",
    "--dsw-alias-bg-layer-2": "{{colors.surface_container.light.hex}}",
    "--dsw-alias-bg-layer-3": "{{colors.surface_container_high.light.hex}}",
    "--dsw-alias-bg-layer-4": "{{colors.surface_container_highest.light.hex}}",
    "--dsw-alias-bg-overlay": "color-mix(in srgb, {{colors.surface_bright.light.hex}}, white 8%)",
    "--dsw-alias-bg-mask-1": "color-mix(in srgb, {{colors.scrim.light.hex}}, transparent 50%)",
    "--dsw-alias-border-l1": "{{colors.outline_variant.light.hex}}",
    "--dsw-alias-border-l2": "{{colors.outline.light.hex}}",
    "--dsw-alias-brand-primary": "{{colors.primary.light.hex}}",
    "--dsw-alias-label-primary": "{{colors.on_surface.light.hex}}",
    "--dsw-alias-label-secondary": "{{colors.on_surface_variant.light.hex}}",
    "--dsw-alias-state-error-primary": "{{colors.error.light.hex}}",
    "--dsw-alias-state-success-primary": "{{colors.tertiary.light.hex}}",
    "--dsw-alias-state-warn-primary": "{{colors.secondary.light.hex}}",
    "--dsw-specific-sidebar-fill": "{{colors.surface_container_lowest.light.hex}}"
  },
  "dark": {
    "--dsw-alias-bg-base": "{{colors.surface.dark.hex}}",
    "--dsw-alias-bg-layer-1": "{{colors.surface_container_low.dark.hex}}",
    "--dsw-alias-bg-layer-2": "{{colors.surface_container.dark.hex}}",
    "--dsw-alias-bg-layer-3": "{{colors.surface_container_high.dark.hex}}",
    "--dsw-alias-bg-layer-4": "{{colors.surface_container_highest.dark.hex}}",
    "--dsw-alias-bg-overlay": "color-mix(in srgb, {{colors.surface_bright.dark.hex}}, white 8%)",
    "--dsw-alias-bg-mask-1": "color-mix(in srgb, {{colors.scrim.dark.hex}}, transparent 50%)",
    "--dsw-alias-border-l1": "{{colors.outline_variant.dark.hex}}",
    "--dsw-alias-border-l2": "{{colors.outline.dark.hex}}",
    "--dsw-alias-brand-primary": "{{colors.primary.dark.hex}}",
    "--dsw-alias-label-primary": "{{colors.on_surface.dark.hex}}",
    "--dsw-alias-label-secondary": "{{colors.on_surface_variant.dark.hex}}",
    "--dsw-alias-state-error-primary": "{{colors.error.dark.hex}}",
    "--dsw-alias-state-success-primary": "{{colors.tertiary.dark.hex}}",
    "--dsw-alias-state-warn-primary": "{{colors.secondary.dark.hex}}",
    "--dsw-specific-sidebar-fill": "{{colors.surface_container_lowest.dark.hex}}"
  }
}

token 的值按原样写入 CSS 变量,因此 color-mix(...) 这类表达式可以直接写在模板里。改完模板后重新运行 matugen,插件在下一个轮询周期读到文件变化后生效。

M3 动效

「设置 → 通用设置 → M3 动效」把界面的动画曲线换成 Material 3 / Material 3 Expressive 的曲线族。

曲线族

用途 M3 M3E
位移(transform、宽高、内外距、gap、圆角…) cubic-bezier(0.05, 0.7, 0.1, 1)(emphasized decelerate) 声明时长 ≤200ms 用 cubic-bezier(0.42, 1.67, 0.21, 0.9),否则用 cubic-bezier(0.38, 1.21, 0.22, 1)
效果(颜色、透明度、阴影、滤镜…) cubic-bezier(0.2, 0, 0, 1)(standard) cubic-bezier(0.34, 0.8, 0.34, 1)

两族都把「移动」和「效果」分开,原因在 M3E 上最直观:它的两条位移曲线带过冲,峰值分别为 1.092 与 1.014,效果曲线不过冲。物体移动有惯性,颜色过冲则会闪出调色板里不存在的颜色。

M3 那侧的分离要说明白:Material 3 的 emphasized 整体是两段首尾相接的三次贝塞尔(pathInterpolator(M 0,0 C 0.05,0,0.133333,0.06,0.166666,0.4 C 0.208333,0.82,0.25,1,1,1)),cubic-bezier() 只能表达一段,所以 Material 官方的 Web token 表把 emphasized 标为不可用、回退到 standard。这里改用官方单列出来的 emphasized-decelerate(cubic-bezier(0.05, 0.7, 0.1, 1))承担位移,效果侧用 standard——两个都是 Material 自己发布的 token,不是拼出来的近似。

本层无法区分入场与出场,因此位移一律用 decelerate;Material 对出场建议用对应的 accelerate。

关闭「位移与效果使用不同曲线」后,两族都退化为只用效果曲线。

为什么是改写 CSSOM,而不是注入覆盖样式表

注入一张「覆盖表」有两种写法,都不成立:

  • 按类名覆盖:需要为每个组件抄一遍选择器并保证优先级不低于原规则。这份抄件正是 DSH 改版时会失效的东西——和「把编译产物里的 hash 类名写死」是同一类错。
  • !important 全覆盖:会连带压掉刻意保留的例外,例如终端光标的 step-end、点阵揭示的 steps()。

这里改为遍历 document.styleSheets 与 document.adoptedStyleSheets,递归进入 @media / @supports / @layer / 嵌套规则,对每条真正声明了 transition / animation 的规则,原地改写它自己的长写属性:

  • 优先级不变:改的是同一条规则,所以只在原规则生效的地方生效。
  • 例外自动保留:steps()、step-end 这类离散步进不动;无限循环上的 linear 不动(旋转指示器被任何缓动曲线处理都会显出一顿一顿);0s 的可见性门控不动。
  • 无需随 DSH 更新维护:新增或改名的组件自动被覆盖。
  • 精确还原:写入前整块记录 cssText,关闭时原样写回;若期间被第三方改过则在日志中说明后仍然还原。

滚动

scroll-behavior: smooth 与脚本调用的 scrollTo({behavior:'smooth'}) 都不受 CSS 曲线控制——时长和曲线由浏览器定,CSS 读不到,JS 也拿不到。勾选「滚动使用同一曲线」后(默认开启),本层接管三件事:

  1. 程序平滑滚动:scrollTo / scrollBy / scrollIntoView 的 behavior:'smooth',改用脚本滚动走本页曲线;
  2. 大位移瞬时跳转:behavior:'instant' 且位移超过 24px 的请求(「回到底部」一类按钮),改为动画落位。小于 24px 的瞬时跳转、以及直接赋值 scrollTop 一律保持即时——布局校正代码会在写入后立刻回读位置,延迟它是缺陷而非修饰;
  3. 滚轮滚动:接管滚轮增量,按指数衰减逐帧逼近落点,连发时累加而不是重来。

并同时遮蔽 scrollend,让上述三种滚动对消费者呈现「一次手势一次结束」。

默认开启意味着每个用户都会拿到原型补丁与事件遮蔽。关掉即拆掉补丁、监听与遮蔽,在飞的滚动就地停止、兑现其 Promise,并把欠下的一次结束放行。

流式文本

勾选「流式文本渐进显现」(默认开启)后,正文与思考摘要在生成时逐段显现,而不是随每个分片一次跳出。

为什么移动遮罩而不是截断文字

最直接的做法是把文本节点截到前 N 个字符,再让 N 随时间增长。这条路不走,因为文本归 React 所有:它会持续下发完整字符串,两边互相覆盖;而在覆盖的空隙里,选择、复制和读屏拿到的都是被截断的内容。

所以文字一个字都不截。模型产出的每个字符从到达那一刻起就在 DOM 里,只有一条 mask-image 的显现线在移动:线上的部分绘制,线下的部分透明。布局在分片到达的瞬间就已经定稿,动画不可能与它不一致。

scroll 实测:流式过程中整段文本可选中,选区长度与文本长度一致。

逐字:靠倾斜遮罩线,不切分文本

水平遮罩线只能整行推进,观感是「一行闪出来」而不是打字。把线倾斜即可:边界每下降一段就横穿整行一次,任何时刻处在部分显现中的是这条斜线跨过的那几行,并且在这几行内从左向右推进。

倾斜角固定在相对水平 15°–45° 区间(CSS 里写作 180° - 倾斜角,即 135deg–165deg)。下限由观感定:斜线的倾角越小,越过一行的下落量越小,在 920px 宽的正文上一行只需不到 2°,看上去就是一条横线,逐字推进完全看不出来;取 15° 时同一行会落下约 246px,斜边清晰可见。上限避免窄栏被扫成竖向擦除。

代价是斜线同时跨越多行(15° / 920px 约 10 行),因此不再是「只揭一行」,而是一次揭开一个斜向的带;换行时整行会略微推迟,而不是逐行打字。这里优先的是逐字推进的观感,不是逐行节奏。

渐变轴不再是竖直方向,所以「完全绘制」的位置是右下角而不是底边:sin(倾角) × 宽度 + cos(倾角) × 内容高度 + 淡出带。少算这一项会在最后一行右端留下永久渐隐,短消息上就是整块可见区域。

不切分文本,是因为文本归 React 所有:切分后它会持续下发完整字符串,两边互相覆盖,而覆盖的空隙里选择、复制与读屏拿到的都是残缺内容。

整流:时长随积压量增长,速度随积压量上升

显现进度落后于内容高度 gap,时长取 36.8 × gap^0.62(钳在 250–1200ms),再乘时长倍率、按需吸附。

下限就是缓存的可见窗口:一行约 250ms、十行约 1s、整块一次落下封顶 1.2s。正常输出时边界后方始终压着一到两行未读文字,突发输出被吸收成更长的推进而不是被跳过。低于这个量级,每次到达都会在眼睛反应过来之前结束,看起来就是「没有动画」——上一个版本每行只有 90ms 正是如此。

指数小于 1 让速度随积压上升(时长增长比距离慢),所以积压越多追得越快,不会越落越远。遮罩本身是连续推进的,同一时刻处在显现中的是一整行,不是固定一个字。

本层的曲线与时长全部取自同一份配置,不另设一套:

项 来源
显现过渡曲线 当前曲线族的效果曲线(M3E 0.34,0.8,0.34,1/M3 0.2,0,0,1)
显现时长 积压量 ÷ 0.55px/ms × 时长倍率,钳制后按需吸附
标签进场曲线 同上,作为 --dsh-uix-ease 写在根元素
标签进场时长 260ms × 时长倍率,按需吸附,作为 --dsh-uix-roll
新行进场曲线 同上,作为 --dsh-uix-ease
新行进场时长 220ms × 时长倍率,按需吸附,作为 --dsh-uix-enter
标签追加时长 与显现同一公式,由积压宽度算出

输出突发时积压大、追得快;输出缓慢时积压小、走得慢。两者在观感上没有区别,这正是要的效果。

推进由注册过的自定义属性(@property)上的 transition 完成,因此跑在合成器上,不占主线程。本层只在分片到达时算一次时长。实测流式期间帧时间中位数 16.7ms。

覆盖哪些元素

按应用自己的标记识别,不猜结构:

标记 含义 处理
[data-streaming] + 多行 正文 逐字显现
[data-streaming] + 单行(nowrap 或高度不足 1.5 行) 思考摘要、只含一行内容的整条消息 横向边缘推进
[data-shimmer] TextShimmer 的活动标签,工作步骤组标题、运行状态等 横向边缘推进

单行判定必须包含高度:nowrap 只覆盖思考摘要,而一条流式消息在只有一行时同样没有可供显现的纵向空间——倾斜的显现线在 1 行高的元素上无处下移,只会把它这一行按固定比例压暗,看起来是一个静止的遮罩。实测该元素的遮罩轴长按 高度 + 行高 算,1 行元素上轴长 48px,而过去的静止位置只有 34.8px,于是永久留着一段横跨 300px 以上的渐变。

TextShimmer 只用于活动中的单行标签,所以它是可靠的「这是一行会变的标签」信号。

嵌套时只标记最外层:正文显现本身会覆盖其内部所有内容,再标一次就是同一行动画两遍。但如果外层只是单行(按上面的规则成为标签),它不显现任何内容,就该让内部真正的标签自己推进——否则整行既没有横向推进,又被外层留下一段静止渐变。所以外层只有在成为多行显现时才吞掉内部标签。

标签追加:只显现新增的那几个字

工作步骤的标题是追加成长的:工作中 - A → 工作中 - A, B → 工作中 - A, B, C。把整行滚出去再滚进来,会把读者已经读过的部分重新动一遍,观感是整行反复滑行。

改为横向边缘推进:新文本到来时把遮罩左边缘放在「新旧文本共享前缀」的宽度处、右边缘推到新宽度。旧字符原地不动,只有真正变化的部分从边缘后面显现。

共享前缀按字符算、再乘到当前渲染宽度上,所以两种变化都能覆盖:纯追加(A → A, B → A, B, C)边界正好落在旧文本右边缘;滑动窗口式的明细(只共享一段前缀)边界落在第一个真正改变的字上。

两类标签的处理不同,按应用标记区分:

元素 变化时
[data-shimmer] 单行标签(步骤标题) 只横向推进,从不纵向滚动
[data-streaming] + 单行(思考摘要、单行消息) 只横向推进

标签变长时行本身不动:它的变化方式就是变长,把整行滚出去再滚进来只是让读者重读已经读过的部分。整行被替换时边界回到左端重新推进(并伴随上滑进场,见上一节),所以新的一段仍然是从头揭开,而不是接着旧文字的右边缘继续。

标签自带遮罩时(思考摘要由应用自己淡出被裁剪的尾部),本层在标记的那一刻记下它的遮罩值,静止时原样放回,只在到达时换成推进用的渐变。否则本层那条覆盖整行的规则会把应用的柔和边缘变成硬切。

宽度靠 Range 量实际渲染宽度,而不是数字符再换算:DOM 里混着空白文本节点,归一化后的字符偏移量并不对应到调用方以为的那个节点——这个做法试过,量出来的位置会偏(实测 55px,应为 87px)。

追加与换行是两种变化

工作步骤标签有两条完全不同的更新路径,动画必须分开:

变化 判定 动画
文本追加 新文本以旧文本开头 只有横向边缘推进,行本身不动
换行(整行更换) 新旧文本无共享开头 从下方上滑进场,同时从左边揭开
首次出现 这个槽位此前是空的 上滑进场

早先的判定只看元素自己记住的上一段文字。问题在于这条标签的 DOM 节点并不稳定:标题一变,进程行就重建,接管的元素是新的,于是每次都判成「首次出现」,每追加一个词就重放一遍完整进场——整行淡入并上移,读者看到的就是反复揭幕。

现在按 槽位键记录上一段文字与宽度。键是一段字符串,由两部分拼成:

  • 从标签往上数,每层的子节点序号(附带该层的 data-chat-flow-kind / data-process-activity 作为限定);
  • 遇到应用自己命名的行(data-chat-anchor-key / data-chat-flow-key / data-chat-group-key)就停在那里,只用这个值当键根。

停止爬升是关键:那些属性是值而不是节点,行被重建、被上方新加载的历史往下挤、甚至整段子树重建,值都不变,槽位因此仍然对得上。而行的位置本身不参与键——历史加载把行往下推几个位置,不该让这行看起来是新的一行。

键是字符串,不会被节点回收带走,所以超过 512 条时按「30 秒未被占用」清理,再按插入顺序截断。丢掉一个键的代价只是未变化时重放一次进场,也就是这个槽位机制出现之前的常态。

实测四种刷新方式,进场动画数全部为 0:

刷新方式 是否重放进场
同一元素被重新赋同样的文字 否
标签元素被重建、文字不变 否
整行子树被重建、文字不变 否
上方插入历史导致行索引平移 否

同时确认没有误伤:文字追加(跨重建)进场数为 0、边缘从旧宽度续推;换到下一步(文字无共享开头)进场播放、边缘从 0 起步;另一行即使标题完全相同,也照常独立进场。

关掉应用自己的流光

TextShimmer 会在可读副本之外再渲染一份惰性副本,并让一条光带在其中无限扫过——这是应用自带的「流光」。本层改的是可读副本上的遮罩,两者叠加就是一条光带横穿一行正在被揭开的文字,观感是两套动画在打架。

所以本层标记的标签会把那份装饰副本隐藏(它恒为第二个子元素,且只在标签活动时存在,静止标签上这条规则匹配不到任何元素)。流式动画关闭时这条规则也不存在,恢复应用原样。实测标记后装饰层的 display 为 none、其 getAnimations() 为空。

进场与追加分属两个时钟

单行标签的两个动作是「整行滑入」(dsh-uix-arrive:上移 0.85 行高并淡入,默认 260ms)与「逐字追加」(横向边缘推进)。它们共用同一条元素,但生命周期无关。

早先两者共用一个世代计数,于是第一个到达的分片就会把进场动画的标记摘掉:输出快时标签只升到一半就被定格,观感是「上滑进场没了,只剩逐字」。现在进场有独立的计数与定时器,追加不再触碰它,进场一定会跑完。实测 40ms 间隔连发三次文本:透明度 0.31 → 0.60 → 0.87 → 1.00,位移 11.8px → 6.8px → 2.2px → 0,进场完整播完;随后追加的横向推进照常进行。

连续到达的推进不会互相打断

每个标签有一个世代计数:每次启动横向推进就递增,清理定时器只在自己仍是最新世代时才生效。没有这条约束时,一次更新留下的定时器会在下一次推进播到一半时把它收尾,观感是推进时断时续。

进场动画用另一个独立计数(见上一节),因为两者的时长无关:进场是 260ms,推进随积压量变化,谁先结束并不固定。

新行怎么出现:行自己进场,不动滚动

聊天跟随的写法是「写入滚动上限,然后立刻回读确认落点」(use-chat-viewport.ts 的 write)。任何让写入延迟到下一帧的做法都会让这次回读拿到旧值,跟随控制器据此判定被内容推离底部,于是停止跟随。所以滚动本身不做动画。

新出现的内容由行自己进场:新插入的行淡入并上移 6px。只动 opacity 与 transform,不碰布局,因此同一 tick 里回读到的位置与写入值完全一致。

只在该行确实是一次「跟随新到达」时触发:

  • 聊天根节点带有 data-chat-following-tail(应用自己表达的「正贴底」),且上一次同样如此;
  • 该行是本次新增的,不是已有行换位置。

读者往上翻时新到的内容不动画;折叠块换位、历史加载插入的行也不动画。

实测 30 次追加:回读误判 0 次、视口滞后始终 0px、30 行全部进场、最终精确贴底;关掉跟随标记后新增行不动画。

收尾

流式结束时元素失去 data-streaming,本层先把显现线推到遮罩轴末端,等过渡走完再摘掉遮罩——直接摘会让最后一行从半透明瞬变实色。

遮罩轴长按 内容高度 + 行高 + 淡出带 算,不是内容高度。倾斜让渐变轴比内容更高:轴要横跨整行宽度就得再落下恰好一个行高,所以静止位置少了这一行,边界就会停在内容里,表现为最后一行右侧永远压着一段渐变。实测 1 行元素用旧公式停在 34.8px,轴长 48px,残影横跨 300px 以上。

单行长成多行

一条消息从一行文字长成两行时,处理从横向推进切到纵向显现。切换那一刻把显现线放在新内容的最后一行之上,于是刚到达的这行被揭开一次;直接按「已显示」开局会让它整行瞬间跳出。

为什么需要连 scrollend 一起接管

逐帧写 scrollTop 是一串瞬时滚动,浏览器为每一次都结束一次。同一段 1200px 位移,Chrome 156 实测:

写入方式 scroll 事件 scrollend 事件
原生 behavior:'smooth' 56 1
逐帧赋值 scrollTop 21 21

页面上所有「滚动是否停了」的判断都听 scrollend:聊天的跟随控制器靠它结算目标,会话检查器的定位高亮靠它决定何时落点。一次手势收到 21 次结束,它们都会在第一帧认定读者中途停下。

所以本层同时遮蔽 scrollend:在移动某个容器期间,该容器的真实 scrollend 在捕获阶段被拦下,等到真正落位(或取消)时补发一次合成事件。消费方看到的序列与原生平滑滚动一致——一串 scroll,恰好一次结束。落位后仍保留 120ms 的遮蔽窗口,用来吞掉浏览器为最后一次写入补发的那次结束;此后到来的事件属于下一位滚动者,正常放行。

实测两个真实消费方(Chrome 156,同一段 9000px 定位):

关闭(原生) 开启
scroll / scrollend 次数 89 / 1 29 / 1
会话检查器落高亮的时刻 1525ms 486ms
落高亮时的 scrollTop 8870 8870
聊天跟随的 following true true

一次性定位与跟随循环

scrollTo 既承载一次性定位(虚拟化表格跳行),也承载流式跟随——后者每秒重定向几十次。两者共用一条规则:同一容器上再次请求平滑滚动时,合并进正在运行的那一次,从当前位置重新计算剩余行程与时长,所有调用方的 Promise 一起在落位时兑现。

这既符合原生平滑滚动的语义(原生也是重新瞄准而非重来),也避免两个问题:重启会让曲线每帧从头开始,永远播不完;而不合并的话,每帧重定向会让运行永远不结束,依赖「滚动已停」的消费方永远等不到那一次结束。

虚拟化表格会在测量到真实行高后再次以同一目标重发请求,流式跟随会在内容增长时不断抬高目标——两者都能被合并吸收。实测每 45ms 重定向一次的跟随循环:71 次 scroll、1 次 scrollend,与原生一致。

scrollIntoView 的终点由对齐方式、scroll-margin、嵌套容器和元素自身盒模型共同决定,自己算就是「滚到错的那一行」的来源。做法是先向浏览器要一次瞬时跳转,读出每个被移动容器的落点,把它们全部放回原位,再动画到读到的落点;跳转与还原在同一个任务内完成,中间位置不会绘制,跳转自身产生的那次结束也一并被遮蔽。

滚轮

浏览器的滚轮是逐像素跟手的,没有任何缓动入口。这里在冒泡阶段监听 wheel,先让更靠近目标的处理器发言,再决定是否接管:

  • 只在手势所问的那一轴上找滚动容器。鼠标下面是代码块、宽表格这类只能横向滚动的元素时,垂直滚轮不能被它挡住——浏览器会把增量链式交给父容器,本层若在这里弃权,父容器就退回原生曲线。
  • 命中文本输入框就不接管:读者是在编辑器中滚动自己的文本,缓动会与光标抢位置。
  • defaultPrevented 为真就不接管:草稿编辑器在自身到顶/到底时会把滚轮转发给聊天记录,宽度手柄会把滚轮变成横向滚动——这些地方已经用 passive: false 的监听认领了事件,本层不能抢。滚轮监听本身也必须 passive: false,否则 preventDefault 会被忽略(window 上的滚轮监听默认是 passive 的)。
  • ctrl / meta(缩放)不接管。
  • 粗粒度增量按 Chromium 自己的换算折算成像素:DOM_DELTA_LINE 每单位 40px(cc::kPixelsPerLineStep),DOM_DELTA_PAGE 为 max(视口×0.875, 视口−40)(ScrollUtils::CalculatePageStep)。物理滚轮上报的往往是行数而不是像素,不折算就会整类被跳过。
  • 容器已经到边界时,夹紧后的落点等于当前位置,不接管,让浏览器照常处理链式滚动(overscroll-behavior)。

接管后走与其他滚动相同的曲线族与时长模型:每一格从当前实际位置起算到累积目标,时长按这段距离算,曲线取当前设置的效果曲线。这与 Chromium 自身的 UpdateTarget 语义一致——重新瞄准,而不是把曲线从已经被走过的起点重新播一遍——所以连续滚动时位置连续、不会跳回。

实测同一段 300px:M3 在 40ms 时走到 120px,M3E 走到 139px,形状随曲线族变化。任一读者按键、触摸,或本层的程序滚动启动,都会立刻终止滚轮缓动。

为什么必须自己接管:浏览器把滚轮曲线写死在编译期

scroll-behavior 规范上就不管用户滚动——CSSOM View 原文是「导航、CSSOM 滚动 API、非用户发起的 scroll snapping 之外的其他滚动,例如用户执行的滚动,不受该属性影响」。

Chromium 内部确实有滚轮滚动动画,但曲线是源码常量(cc/animation/scroll_offset_animation_curve.cc):

static constexpr CubicBezierPoints kEaseInOutControlPoints{
    .x1 = 0.42, .y1 = 0, .x2 = 0.58, .y2 = 1,   // 就是 CSS 的 ease-in-out
};
CubicBezierPoints control_points = kEaseInOutControlPoints;
if (scroll_type_ == ScrollType::kProgrammatic) {
  control_points = GetCubicBezierPointsForProgrammaticScroll();   // 仅程序化滚动可被 feature flag 覆盖
}

用户滚轮走 kEaseInOutControlPoints,连命令行参数都改不了;能被 --enable-features 覆盖的只有程序化滚动那条分支。时长同样是常量算法(kInverseDelta,120–480px 映射到 100–200ms,kDurationDivisor = 60)。CSS 与 JS 都读不到这些值,也无法覆盖。

固定的两条规则

  1. 读者优先。 在正在滚动的容器上滚轮或触摸、或在文本输入框之外按滚动键,脚本滚动立即取消,不混合、不恢复;任何本层没写过的位置变化(拖滚动条、辅助技术滚动、容器在动画中重排)也会在下一帧取消。滚轮落在别的面板上不会打断——那不是这个容器的滚动。
  2. 用效果曲线,不用位移曲线。 位移曲线带过冲,滚动过冲意味着内容冲过目标位置再退回来,终点错两次。曲线族照样生效,带过冲的那半永远不用于滚动。

时长与落点

  • 240px 以内固定 180ms,之后每像素加 0.2ms,封顶 460ms;再乘时长倍率,开了「时长对齐」时同样吸附到 Material 档位。
  • 返回值与原生一致:脚本路径返回一个在落点写入(或被取消)后兑现的 Promise,元素本来就在目标位置时返回已兑现的 Promise;非平滑与减弱动效路径直接透传原生返回值。
  • 「始终减弱动效」或系统的减弱动效偏好生效时,平滑滚动与大位移跳转、滚轮缓动全部退回即时/原生。
  • 滚轮缓动同样受「读者优先」约束:任何时刻本层只持有一个滚轮目标,程序滚动接管时立即释放。
  • 只认显式写出的 behavior:'smooth'。behavior:'auto' 落进带 CSS scroll-behavior: smooth 的容器时仍是原生平滑滚动。

滚动驱动动画不动

scroll-timeline / animation-timeline 驱动的动画,进度来自滚动容器而不是时钟。这类动画的 animation-timing-function 不是「一条随时间推进的曲线」,而是把滚动位置映射到迭代进度——给它套缓动,等于让遮罩在滚动前段就淡完然后定住,改的是效果本身而非手感。因此只要某条规则的 animation-timeline 不是 auto / none,本层就整条跳过。

DSH 目前只有一处用到(提问卡片标题的溢出遮罩 --question-copy)。

关键帧感知

动画该用哪条曲线,取决于它的 @keyframes 到底在动什么。扫描时先建立「关键帧名 → 它改动的属性集合」,再据此判定:同时含 transform 的是位移,纯 opacity 的是效果。查不到名字(定义在跨源样式表里)时按效果处理。

无限循环的动画一律按效果处理:旋转、呼吸、骨架屏都是环境性的,不是一次性位移;给它套带过冲的位移曲线会让循环进度条每一轮都冲出轨道。

时长

  • 「时长倍率」在界面上提供 0.5× / 0.75× / 1× / 1.25× / 1.5× / 2×。配置里手工写入的其它值(schema 允许 0.5–2)会插入档位表并选中,不会被悄悄折回 1×。
  • 倍率作用于写死的字面时长;引用 var(--ds-transition-duration*) 的时长不动它本身,改为把这三个自定义属性按同一倍率缩放,效果一致且不用逐条改。缩放前会读取页面当前的计算值,DSH 改了基值也按新基值算。
  • 1ms 及以下的时长视为「立即」,既不放缩也不吸附——DSH 的减弱动效规则里写的就是 .01ms,把它当成待调的时长去放大,本来即时完成的过渡会变成可见的一跳。
  • 无限循环的时长不缩放——1 秒的旋转是设计选定的速度,不是待调的过渡。
  • 「时长对齐到 Material 档位」把结果吸附到 50/100/150/200/250/300/350/400/450/500/550/600/700/800/900/1000ms。

减弱动效

  • 跟随系统(默认):系统开启「减弱动态效果」时,全部时长压到 1ms、动画只跑一轮。
  • 始终减弱动效:无论系统设置,一律压制。

这一条是整层里唯一使用 !important 的地方——它要压掉本层从未写过的声明,逐条改写做不到。进入压制时会先撤掉已改写的曲线并停掉观察者:时长已被压到 1ms,改写既看不见,还会在压制表插入时被自己的观察者重新触发一次扫描。

生效范围

  • 覆盖页面上的全部样式表,包括其他插件注入的。首次应用时全量扫描,之后用 MutationObserver 观察:head 带子树(样式通常注入在那里),body 只看直接子节点。只有新增节点本身是 <style> / <link> 才触发补扫,不搜子树——实测对一个 1500 节点的子树跑 querySelectorAll('style,link') 要 17.7µs,流式输出每秒会插入多次这样的容器,此时观察者的开销会超过它想触发的重扫。
  • 通过 document.adoptedStyleSheets 挂载的构造式样式表不是 DOM 变更,观察者看不到它,只能在下一次全量扫描(切换曲线族、解除减弱动效、重新应用设置)时被读到。
  • 同理,对已挂载 <style> 再改 textContent、或在它的 sheet 上 insertRule/replaceSync,都不产生新增节点,也要等下一次全量扫描。DSH 自身与插件加载器都是「建 <style> → 设文本 → 挂载」,不受影响。
  • 跨源样式表读不到 cssRules,会在浏览器控制台记录一行并跳过——这类表的规则无法改写,静默跳过会让部分界面停留在原生曲线而无迹可循。(这条诊断走控制台而非 ~/.dsh/ui-experience.log:后者由宿主半写入,浏览器半够不到文件系统。)
  • 状态会写在 <html data-dsh-uix-motion="m3|m3e"> 上,便于区分曲线来源。

边框宽度

  • 插件配置 borderWidth 默认为 0.5px,只在「Matugen 配色跟随」开启时生效;关闭开关后边框立即恢复原生宽度。
  • 实现方式是全局覆盖所有元素的边框宽度,因此会同时压窄正文引用块等刻意加粗的装饰性边框。
  • 给元素加 data-dsh-uix-keep-border 属性可让该元素不被覆盖。

限制

  • 走 colors.json 路径时 matugen 只提供一套暗色方案,浅色与深色模式叠加同一张配色表,界面的明暗切换只影响未被覆盖的元素;需要两套配色时改用 dsh-tokens.json。
  • 配色检测周期为 2 秒,文件变化最多延迟约 2 秒生效。
  • 关闭开关后启动底色叠加立即撤销(由作用域属性控制);注入的样式与脚本元素保留到下次刷新,但不产生视觉效果。
  • M3 动效的「位移 / 效果」判定依据属性名与关键帧内容,不分析属性的实际取值变化;判定不出来的情况(例如只由 all 声明的过渡)按效果处理,宁可不给过冲。
  • 跨源样式表里的规则无法改写,只会在日志中记录。
  • 本层只改写曲线与时长,不新增、不改写关键帧本身;DSH 没定义的动效不会因此出现。
  • CSS 的 scroll-behavior: smooth(全仓一处,dock 标签条)不是 transition/animation,本层不碰;只有减弱动效时随压制表置为 auto。
  • 流式文本层不截断任何文字,只移动遮罩;选择、复制、读屏与搜索都按完整文本工作。
  • 渐进显现按遮罩线推进,粒度为「行内字符」:斜线跨过的那几行同时处于部分显现中,行内从左向右推进。它不解析分片边界,因此显现节奏跟随内容高度而非字符数;斜线取 15°–45°,换行时整行会有短暂推迟,而不是严格的逐行打字。
  • 滚动本身永远不做动画:跟随控制器在每次写入后立即回读位置,延迟的写入会让它误判并停止跟随。新内容的动效由行自己承担,只动 opacity 与 transform。这也意味着跟随是瞬时的,读者看到的是文字逐段显现与行进场,而不是视口滑动。
  • 新行进场只在根节点带 data-chat-following-tail 且上一轮同样贴底时触发;读者上翻时新到达的内容直接出现。
  • 脚本滚动由「滚动使用同一曲线」单独控制,与曲线族无关(关掉曲线族也一并停止接管)。关掉开关会立即拆掉补丁与监听,在飞的脚本滚动就地停止并兑现其 Promise,不残留。
  • 遮蔽期内的合成 scrollend 其 isTrusted 为 false。DSH 侧没有检查该属性的代码;第三方插件若据此区分真实事件,会看到差异。
  • 持续重定向的跟随循环会被判为「控制器追目标」而交还浏览器,此时滚动恢复原生曲线——这是有意的:被每帧重启的脚本动画播不出曲线。
  • 滚动驱动动画(animation-timeline)不属于时间轴动画,本层显式跳过,见上一节。
  • 浏览器半在刷新页面时重载,宿主半只在进程重启时重载。两者版本不一致时(刚升级插件但没重启 DSH):
    • 宿主还不认识 motion 整组设置:动效行保持无操作,控制台记录一行原因。
    • 宿主认识 motion 但缺少后加的 stream 字段:流式文本层照常工作(它完全在浏览器半),但对应开关隐藏——旧宿主的配置结构里没有这个键,写入会被拒绝。控制台记录一行原因。
    • 其余功能不受影响。重启 DSH 后开关恢复。

样式基线

设置行的控件样式来自宿主既有实现,而不是外部包依赖。原因有两条,都不是偏好问题:

  1. DSH 明确禁止:「不要 require('@deepseek-ai/dsh-client-ui-primitives') 或把任何 Harness Client 包当模块加载……这些包会无通知地变更,且抛错的组件会让整个 slot entry 变空白。请自行实现控件:从原语的 src/*.tsx 与 *.module.css 复制标记、样式与行为。」(dsh-doc 技能 references/practices.md)
  2. 技术上也不成立:这些包不在 dsh 的 node_modules 里,官方包自己也只把原语写在 devDependencies。第三方插件能 require 到它,全靠 Web 外壳硬编码的模块表。一旦上游改名、拆包或移除条目,require 直接抛错——因为异常发生在 slot 组件内部,整行设置会消失,而不是样式退化。

两种失效的性质不同:复制样式最坏是某次改版后尺寸或配色略有偏差,功能完好;依赖内部包则是整行消失。所以这里选择复制,并把漂移控制在可核查的范围内。

当前基线为 DSH 0.2.2-rc.2,全部规则集中在 src/styles.ts,每条规则上方标注了对应的宿主源文件与选择器:

本插件类 对应宿主来源
.dsh-uix-row AppearanceRow.module.css .group
.dsh-uix-title / .dsh-uix-desc FontSizeRow.module.css .title / .desc
.dsh-uix-field / -label ConfigField.module.css .field / .head label
.dsh-uix-input-wrap / -input Input.module.css .wrap / .input(同页字体字段用的就是这个原语)
.dsh-uix-disclosure / -icon FontSizeRow.module.css .expander,箭头复用其 chevron 路径
.dsh-uix-save 配置表单的保存按钮
.dsh-uix-reset Button.module.css .outline
.dsh-uix-switch / -thumb Switch.module.css .switch / .thumb
.dsh-uix-segment / -indicator / -tab SegmentedControl.module.css .control / .indicator / .tab
.dsh-uix-option / -text / -title / -desc DeveloperToolsRow.module.css .row / .title / .description

所有颜色、圆角、焦点环都用 --dsw-* 主题 token,这些是跨版本稳定的公开契约;只有长度值(内距、尺寸、字号)可能随改版调整。升级 DSH 后,把上表的源文件与 src/styles.ts 对照一遍即可。

布尔项一律用与上方行相同的开关(Switch),而不是原生复选框:设置页里「开发工具」一类布尔行就是「标题与说明左置、开关右对齐」,同一页混用两种控件会显得割裂。选项行的标题 13px/500、说明 12px 次级色,行间共用一条 0.5px 分隔线。

开发

pnpm install
pnpm typecheck
pnpm build
pnpm test

源码在 src/:

文件 职责
index.ts 宿主半:配色解析与映射、Remote 服务、配置文件注入
client.ts 浏览器半:设置行、页面级图层接线
motion.ts CSSOM 遍历与曲线改写
motion-plan.ts 纯函数:值列表切分、时长解析与重定时、属性分类、曲线选择与求值
scroll.ts 脚本平滑滚动、滚轮缓动、大位移跳转与 scrollend 遮蔽
stream.ts 流式文本渐进显现与思考摘要滚动
styles.ts 设置行样式表
shared.ts 两端共享的协议类型

esbuild 产物输出到 lib/。lib/ 会提交入库,以便其他设备直接从 git 安装而无需构建环境;改动源码后务必重新构建并一并提交产物。

pnpm test 只覆盖 motion-plan.ts 的纯规则(值列表切分、时长解析与重定时、属性分类、曲线选择、贝塞尔求值)。CSSOM 改写与脚本滚动属于 DOM 编排,不在单元测试范围内,用真实浏览器验证:把 lib/client.js 注入页面、按插件的方式挂载,再断言改写后的声明与滚动落点。

安装

其他设备通过「插件管理器 → 添加插件 → 安装第三方插件」安装,粘贴 Git 仓库地址:

git+https://github.com/dsh-plugins/dsh-ui-experience.git

注意:

  • 地址必须以 git+ 开头;裸 https:// 不会被识别为 Git 仓库。
  • 目标设备需要能访问该仓库。
  • 构建产物 lib/ 已入库,无需在目标设备上构建;安装后按提示重启 DSH。
  • 不要手动编辑 profile 的 package.json:包名以仓库 package.json 的 name 为准(@dsh-plugin/dsh-ui-experience),别名与仓库名不一致时需显式声明。

About

DeepSeek Harness interface experience plugin: overlay the system matugen Material You palette as the theme. DSH 界面体验插件:跟随系统 matugen 生成的 Material You 配色。

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages