dsh-entry-transition
Entry Transition —— 打开工作台时的一次性入场动画:标识逐字擦出,退场时反色色块扫过全屏。深浅主题自动镜像,只动合成层。装上即用,零配置。
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 6, 2026
- Updated
- Oct 7, 2026
Introduction
工作台皮肤(工业风过渡画面 + 深色令牌皮肤)
给 DSH Web 界面加一层"进入工作台"的过渡画面,并把整套配色/字体换成简约的深色工业风。
参考了用户提供的官网进入过渡画面的手感,但所有文案与图形都是中性的工程词汇,
不含任何游戏相关字眼(见 DESIGN-SPEC.md §3.3 白名单)。
进入界面时先安静地把外壳加载好,再一笔一画写出 DEEPSEEK HARNESS,最后划出色块:
- 静默加载:过渡层立刻铺满屏幕并停在一帧完整静止的画面上(只有
00 / 00与底色), 此时外壳在它后面加载应用。写字动画用animation-play-state: paused按住,由外壳的"就绪"信号 ([data-dsh-boot]被移除)放行;放行前还会等主线程持续安静 700ms(任何一帧超过 20ms 都重新计时, 上限 3s,最后再交给浏览器的空闲回调)——见下方"开场卡顿的根因"; - 一笔一画写字:放行后,名字不是文字,而是一套手写的单线笔画骨架(9 个字母的骨架拼出 15 个字母、27 条笔画)。
每条笔画用
stroke-dashoffset从起点画到终点,延迟按"第几个字母 + 字母内第几笔"拉开, 于是笔尖从左往右走、每个字母的笔画依次跟上。最后一个字母的最后一笔落在1.51s,这是下面所有等待的基准; - 色块出现:名字画完后等到
2.6s,左边缘出现 2px 色块细条(0.22s); - 色块横扫:细条展开成整幅高度的色块,向右划过整个视口(
0.9s); - 铺满 + 淡出:补满剩余区域(
0.32s),整层淡出(0.46s)。整段离场1.9s。
卡顿:实测数据(2026-10-04,真实时钟 + 真实合成器)
之前所有优化都是猜的(沙箱里 rAF 只触发一次、performance.now() 被虚拟化)。改成真实环境后
harness/measure-frames.mjs 量出的实际数字:
| variant | 帧时间中位数 | p95 | 超 20ms 帧 | 推进一帧动画的主线程成本 |
|---|---|---|---|---|
| isolated(只有过渡层) | 16.7ms | 16.7ms | 0 | 0.023ms |
| baseline(+合成负载) | 16.7ms | 16.8ms | 0 | 0.023ms |
| heavy(5400 节点重负载) | 16.7ms | 16.8ms | 0 | 0.035ms |
两个结论:
- 写字动画本身几乎不花钱 —— 推进全部 27 条笔画动画只要 0.02–0.035ms,占 60Hz 帧预算的 0.2%。 所以"减笔画"、"换揭示机制"这类优化全是白费力气。
- 真正的问题是 GPU 显存。这台机器是
AMD Radeon 610M(512MB 专用显存)+RTX 5060 Laptop, 屏幕 2560×1600 @165Hz。而我为了"防卡"给三个整屏元素加了合成层:
| 合成层 | 物理像素(DPR 1.5) | 常驻显存 |
|---|---|---|
.hsx-boot(translateZ(0) + will-change) | 3840×2400×4B | 37 MB |
__bar::after(will-change: transform) | 3840×2400×4B | 37 MB |
__sweep(will-change: transform) | 3840×2400×4B | 37 MB |
合计约 111MB 常驻贴图,占核显专用显存的 22%,还要和正在启动的应用自己的层抢。 这三个提升已经全部撤掉(见下方根因 3):实测撤掉后主线程成本只从 0.022ms 变成 0.023ms, 却释放了全部 111MB —— 这笔账不划算的部分是我当初加错了。 这就是"动画很轻但仍然卡"的原因 —— 卡在显存/带宽,不在主线程。
修法:把这三个合成层全部撤掉(本轮)。实测代价:主线程成本 0.022 → 0.023ms(可忽略);
收益:释放约 111MB 显存。check-markup.mjs 现在反过来守卫这一点:
no viewport-sized element is promoted to a compositor layer、the layer holds no resident GPU layer。
contain: strict + overflow: hidden 仍然保留 —— 那才是把失效范围限制住、不让周围页面重绘的手段。
为什么之前误判成"写字动画卡"
机制上确实有区别(下表),但在这台机器上它不是瓶颈:
| 动画属性 | 谁来执行 | |
|---|---|---|
| 离场色块 | translateX | 合成器线程搬运图层 |
| 写字 | stroke-dashoffset | 主线程重新光栅化矢量 |
stroke-dashoffset 确实无法交给合成器,这点没错;错在我据此认定它是瓶颈。
先量再改 —— 这是这轮最大的教训。
3. 每帧的 CSS 计算全部预先算好:dash 几何与逐笔延迟原来写成 var(--hsx-len) 和四变量 calc() 表达式,
浏览器要为 27 条笔画逐元素、逐帧重新求值。现在状态机在放行前把它们算成固定数值写进内联样式,
每帧只剩光栅化,没有样式数学。CSS 里已不留 hsx-len / hsx-part;
现在的遮块延迟是一条 calc()(--hsx-i × --hsx-cells),不是 27 条内联值;
4. 把字形 SVG 的失效范围收进元素盒子:原来用 overflow: visible,Chromium 无法把光栅化限制在元素边界内,
每帧的失效区域被放大。现在把描边余量放进 viewBox(-4 -4 108 118),overflow 保持默认;
5. 撤掉全部合成层提升(本轮,见上一节的显存账):三个整屏元素各 37MB 常驻贴图 → 0。
实测代价只有 +0.001ms 主线程。
两条设计决定,都是为了让开场不卡: ① 写字等就绪信号才开始(否则笔尖跑在外壳加载最重的那一段上); ② 离场等名字写完才开始,所以"划出去"这一下落在外壳最忙的时间之后。 兜底:外壳一直不就绪时,
1.2s后强制放行写字;readyTimeoutMs到点强制退场,不会永久冻住画面。不变量(踩过五次坑,现在都有测试守着): ① 任何退场路径都必须先放行笔,并且等名字写完才允许动。 第一次:外壳"卡死探测"比写字兜底更早触发,而退场没有放行笔 —— 画面从静止帧直接划走,字根本没出现。 第二次:就绪信号早到时
exit()立刻离场 —— 名字刚起笔就被色块盖掉。 ② "名字写完"必须按最后一笔的真实落点算,不是名义时长。 第三次:按write + hold只等到 2.4s,而末字最后一笔落到 2.07s + 停顿才真正结束 —— 色块在最后一笔收笔前就动了。现在nameMs由运行时按笔画错开公式算,check-clock.mjs用它跟 CSS 里色块的起手时刻直接比对(the last stroke lands before the block may move)。 ③ 生成到浏览器的脚本里不能出现 Node 侧的标识符。 第四次:nameMs的计算里引用了模块常量WORDMARK,而那段代码运行在浏览器里 ——Uncaught ReferenceError,整个状态机当场死掉:页面正常显示,但什么都不动。check-markup.mjs现在会扫描生成的脚本体,禁止任何模块标识符和 host-only 全局泄漏进去。 ④ 卡死探测按「真实时间」判定,不能按检查次数。 第五次:mark()同时被 16ms 轮询和 MutationObserver 调用,而过渡层自己会改 DOM, 所以"150 次检查"实际只过了约 1.2 秒而不是 2.4 秒 —— 会砍掉小一半写字动画。 现在用now() - stableSince >= 2400ms。兜底:外壳一直不就绪时,
1.2s后强制放行写字;readyTimeoutMs到点强制退场,不会永久冻住画面。为什么不用文字 + 动画:系统字体的字形是封闭轮廓,用 dash 描它只会得到"描边空心字"而且处处断口, 那是"字体在描边"而不是"人在写字"。所以要真正的一笔一画,只能自己提供笔画路径。
画布永远取色块的相反色,这样白块 / 黑块划过去才看得见:
| 画布 | 大字 / 小字 | 划出的色块 | |
|---|---|---|---|
| 深色主题 | 浅 #f4f5f6 | 黑 #0c0e10 | 黑 #000000 |
| 浅色主题 | 深 #0b0d0f | 白 #ffffff | 白 #ffffff |
两组互为镜像:深色主题是"浅底黑字黑块",浅色主题是"深底白字白块"(后者也是参考站那种深底浅字的观感)。
界面字体为圆润而正式的 Segoe UI Variable(Display + Text),代码用 Cascadia Code。
lib/theme.css 令牌皮肤:深/浅两套 --dsw-* 取值、圆角形状、外壳启动占位握手
lib/boot.css 过渡画面样式:笔画写字、色块时间轴与主题调色板
lib/index.js 宿主半:把上面三样 + 过渡画面 DOM 注入每一次 index 渲染;按字符拆分标题并判定主题
lib/client.js 客户端半:交接信号、清理自持样式
harness/ 不依赖真实 GUI 的验证页与脚本(详见 §验证)
preview/ 独立走查页(file:// 双击即可打开,无构建、无网络)
verify/ 独立核验报告(参考站证据 + 本机外壳契约)
DESIGN-SPEC.md 设计与集成规范(单一事实源)
scripts/sync.mjs 把 lib/ 同步到运行时路径
安装
运行时路径必须是纯 ASCII,所以 lib/ 会被同步到 C:\Users\29316\.dsh\plugins\dsh-entry-transition:
node scripts/sync.mjs
该 profile 的 patch 层(C:\Users\29316\.dsh\profiles\desktop\cordis.patch.yml)末尾已插入:
- insert:
- id: dsh-entry-transition
name: "file:///C:/Users/29316/.dsh/plugins/dsh-entry-transition/lib/index.js"
config:
enabled: true
overlay: true
theme: true
minVisibleMs: 2200
readyTimeoutMs: 30000
playOncePerSession: true
全屏不在皮肤里配:窗口尺寸只能由原生侧改,见下面的「全屏」一节。
改完 lib/ 后执行 node scripts/sync.mjs,然后刷新页面;若启动器只在启动时读 patch,则重启桌面应用。
原 patch 备份在 cordis.patch.yml.bak-hsx。
分发(打包成可下载插件)
一条命令产出可分享的插件包(打包器自检:文件清单 / 逐字节一致 / manifest 契约 / bundle patch 形态 / 禁词 / 语法 / 解包回读,任一失败退出码 1):
node tools/package-plugin.mjs
产物(dist/):
| 文件 | 给谁 | 怎么装 |
|---|---|---|
dsh-entry-transition-1.0.1.zip | 任意用户 | 解压 → node install.mjs → 重启应用(离线安装,先备份 patch,幂等) |
dsh-entry-transition-1.0.1.tgz | npm/pnpm 生态 | profile 目录里 pnpm add + 把包名加进 dsh.profile.bundles(包内 cordis.patch.yml 作为 bundle 层自动接入) |
安装细节、不跑脚本的手动三步、卸载,见包内 INSTALL.md。
node verify-all.mjs 的第 9 项会重新构建并验收这两个产物。
关闭 / 卸载
| 想要的效果 | 做法 |
|---|---|
| 临时关掉过渡画面,保留配色 | config.overlay: false |
| 临时关掉配色,保留过渡画面 | config.theme: false |
| 全部关掉 | config.enabled: false |
| 完全卸载 | 删除 patch 里那段 - insert:,或用 cordis.patch.yml.bak-hsx 还原 |
关掉后 GUI 恢复原样:皮肤只通过 index 注入的 <style> 与一个 html 上的 data-hsx-* 属性生效。
调试开关(URL 参数)
| 参数 | 效果 |
|---|---|
?hsx=off | 跳过过渡画面(并在本次会话记住)——全屏仍然生效 |
?hsx=only | 过渡画面常驻,按 ESC 退场 |
?hsx=slow | 进/离场时长 ×3 |
?hsx=drawing | 定格在笔尖停在半途(截图用) |
?hsx=write | 定格在前半段名字已写完(截图用) |
?hsx=still | 定格在名字全部写完、色块细条 62%(截图用) |
?hsx=sweep | 定格在色块横扫 46%(截图用) |
?hsx=flood | 定格在整层铺满(截图用) |
?hsx=debug | 调试模式(探针现在默认就在写字阶段采样,不依赖它) |
其他可用入口:window.__HSX__.dismiss() 手动退场,window.__HSX__.state 读状态;document.querySelector('[data-dsh-boot]') 是外壳启动占位。
全屏(窗口级,由启动器负责,不是皮肤负责)
皮肤不碰全屏。 早期版本用网页 Fullscreen API,结果是"先弹出小窗 → 写字 → 中途变大",
而且退出方式别扭 —— 已删除,并加了守卫断言(the skin never drives the fullscreen api)。
现在由启动器在校外完成:
- 桌面外壳用
BrowserWindow({ width: 1280, height: 820, show: false, titleBarStyle: 'hidden' })开窗, 并且只把dsh-desktop:window-fullscreen做成main → renderer单向通道; 渲染层是sandbox: true + contextIsolation: true + nodeIntegration: false。 也就是说窗口尺寸只能从原生侧改,页面里没有任何受支持的途径。 - 所以做法是:快捷方式先拉起程序,然后一个小工具轮询等待窗口出现(每 40ms,最多 30s),
在它还没被画出来之前就最大化。外壳要等渲染层加载完才
show(),所以尺寸在你看到第一帧时就已经是对的。
装/卸:
node tools/install-fullscreen-launcher.mjs # 编译工具 + 改快捷方式(自动备份)
node tools/install-fullscreen-launcher.mjs --remove # 还原快捷方式 + 删除落地的两个文件
它做了什么、以及怎么反悔:
| 项 | 说明 |
|---|---|
| 改了哪个快捷方式 | %APPDATA%\Microsoft\Windows\Start Menu\Programs\DeepSeek Harness.lnk |
| 改成什么 | wscript.exe "G:\DSH\dsh-fullscreen.vbs"(窗口样式 7,点它不会闪黑框) |
| 备份在哪 | 同目录 DeepSeek Harness.lnk.orig-hsx,--remove 会自动还原 |
| 落地文件 | G:\DSH\harness-fullscreen.exe(8KB,源码 tools/harness-fullscreen.cs)、G:\DSH\dsh-fullscreen.vbs、以及诊断日志 G:\DSH\dsh-fullscreen.log |
| 启动器铁律 | 任何情况下都不弹对话框(找不到文件就静默退出),安装脚本会断言这一点 |
| 启动器第二铁律 | 只用 WSH 5.8 一定存在的 API。快捷方式用样式 7 隐藏运行,脚本里的运行时报错会被完全吞掉:不弹框、看不到退出码,程序看起来就像"没走启动器"。踩过的坑:WshShell.CurrentEnvironment 在 WSH 5.8 里并不存在,一调用就抛错,表现就是"启动器毫无反应、日志也不写"。安装脚本现在会断言代码里没有 CurrentEnvironment / Environment() / Exec() 这类 API |
| 为什么要"盯一会儿" | 外壳是在窗口隐藏时创建的,随后 show() 会重新施加自己的 1280×820,把先前的最大化覆盖掉。所以工具会:① 在窗口第一次被绘制前先最大化(你看不到跳变);② 之后 8 秒内每 60ms 检查一次,发现被还原就立刻再最大化(--settle 8) |
| 诊断日志 | G:\DSH\dsh-fullscreen.log(UTF-16):记录找到窗口时的状态、首次最大化、每次"再最大化"、最终状态。启动异常时先看它 |
| 手动还原 | 双击 DeepSeek Harness.lnk.orig-hsx 覆盖回去,或 --remove |
想自己按快捷键:Win + ↓ 退出最大化,F11/双击标题栏 再最大化。
被最大化的只是窗口,任务栏还在,所以随时能切走 —— 这也是你选的那种"全屏"。
验证
node verify-all.mjs
按顺序做这些事,任一步失败即非零退出:
- 同步检查 —
lib/与运行时副本一致。 - 宿主半注入契约 — 直接调用
apply(),断言它请求webServer、只注册webserver/index-inject、并推送style / style / html:body / script:body四行。 - 真实 index 渲染 — 用发行版
dist/index.html与renderIndexInjections,断言令牌样式落在</head>之前、过渡层 DOM 落在<body>之后且位于#div#root之前、脚本在 DOM 之后。 - 注入脚本语法 — 用
new Function解析内联脚本,确认没有</script>/</style>提前闭合。 - 令牌级联 — 无头 Edge 打开
harness/cascade.html:皮肤样式表排在主题插件样式表之前(比真实顺序更苛刻),断言深/浅两种模式下body与其后代的 64 项计算值全部命中皮肤取值。 - 过渡层几何 — 无头 Edge 打开
harness/probe.html,断言 12 项版式关系(标题/分隔线/说明的先后、读数与右上信息对齐到同一内边距、单位与数字基线一致、底部进度条贴底等)。 - 状态机 — 无头 Edge 跑
harness/index.html:正常路径下过渡层最终从 DOM 移除;?stuck=1(模拟插件激活失败)下也会退场并释放html[data-hsx-boot]。 - 无游戏字眼 — 全仓扫描禁用词表。
单条命令也可以单独跑:
node harness/build-cascade.mjs # 重建级联验收页
node harness/build-probe.mjs # 重建几何/调色板探针页
node harness/build.mjs # 重建注入真值页(状态机 + 外壳模拟)
node harness/build-iso.mjs [--hsx-mode=drawing|write|still|sweep|flood] [--theme=dark|light]
node harness/render-index.mjs # 只跑真实 index 渲染
node harness/inspect-wordmark.mjs # 只看骨架:几个字母、几条笔画、多宽
node harness/measure-wordmark.mjs # 浏览器里量每个字母框的真实尺寸
node harness/check-markup.mjs # 只跑 DOM/脚本/样式契约
node harness/check-layout.mjs # 只跑深/浅两种模式的几何与调色板
node harness/check-clock.mjs # 只跑"时钟真的生效了吗"(读浏览器解析后的秒数)
node harness/check-phases.mjs # 只跑写字分段与离场时间轴
node harness/build-seq.mjs # 重建时序里程碑页(真值页 + 里程碑记录)
node harness/check-sequence.mjs # 只跑"先放行笔、再退场"的时序断言(4 种交班场景)
node harness/measure-frames.mjs # 【真实环境】量帧时间 + 推进一帧动画的主线程成本(对比 5 种变体)
> 旧的 `harness/build-phase.mjs` 已被 `build-iso.mjs --hsx-mode=…` 取代。
设计要点(实现时踩过的坑)
- 令牌必须写在
body上:发行版的--dsw-*别名声明在body/body[data-ds-dark-theme], 写在html上的值对<body>内部是不可见的(元素自身声明优先于继承)。皮肤因此用同样的两个选择器 +!important。 - 为什么需要
!important:主题插件是在客户端阶段把样式表追加到<head>的,位置在宿主注入的<style>之后; 同优先级下后者胜,所以只能靠!important保证皮肤权威。 --dsw-elevation-*不覆盖:插件把它重新声明在body, body *上,继承值会被每个后代覆盖,因此皮肤不管它。- 圆角靠
corner-shape: round而不是改半径:设计系统默认superellipse(1.5),在 Chromium 里圆角看着像被削方; 自带组件的圆角尺寸本来就不大(2–12px),所以把--dsw-corner-shape改成正圆就是"小圆角框"。 过渡层反向声明corner-shape: square(仪表不该是面板)。 - 字体从窄体换成圆润体:
Bahnschrift(窄体、工业)在第一轮用着合适,但用户要求"圆润一点但正式", 于是换成 Windows 的人本无衬线Segoe UI Variable(Display 带头部、Text 带正文),代码用Cascadia Code。 换字体时记得同步--dsw-font-family、--ds-font-family-code以及过渡层自己的font-family三处。 - "写字"必须用笔画路径,不能拿字体凑:试过三种做法——整行
mask-image横扫(读起来是"整块被揭开")、 每个字符独立淡入(只是"逐字出现",不是"一笔一画")、对系统字体做stroke-dashoffset描边 (字形是封闭轮廓,描出来是空心字而且 E/N/A 处处断口)。 最终做法:自己提供STROKE_GLYPHS单线骨架,一条<path>就是一笔,dash 沿笔画走。 样式里刻意不出现任何mask-*,check-markup.mjs会断言这一点。 - 离场是四段式:① 一笔一画写字(1500ms + 700ms 停顿);② 左边缘出现 2px 色块细条(220ms);
③ 细条展开成整幅高度的色块向右划过视口(900ms);④ 色块铺满(320ms)后整层淡出(460ms)。
划出去的就是色块本身(
--hsx-block,即该主题的底色),不是"先划一条线再补色"。 所有时长都必须写成calc(<base>ms * var(--hsx-k)):这一个变量同时承担?hsx=slow(×3)与 reduce(×0.45)。曾经踩过的坑是变量声明了却没被任何calc()引用,结果慢放/减弱动效对离场完全无效; 现在harness/check-markup.mjs静态断言每个阶段都缩放了,harness/check-clock.mjs再从浏览器读取 解析后的秒数复核(4.1s / 12.3s / 1.845s),只靠静态检查抓不到这类问题。 - 退场时间轴锚定在
.hsx-boot--leaving:CSS 的各个延迟与 JS 的收尾定时器都从加这个类的同一刻起算, 两边不可能漂移;色块细条的延迟也是那时才开始算(提前跑会让它在外壳还没就绪时就填满)。 - 静帧不能靠动画:headless 的
--virtual-time-budget会把动画直接跳到终态, 所以定格一律用显式属性(stroke-dashoffset: calc(var(--hsx-len) * 0.52)、transform: scaleX(.46)), 见harness/build-iso.mjs;?hsx=drawing就是靠这个才拍到了"笔停在半途"的那一帧。 - 作者
!important会顶掉 CSS 动画:色块细条原来写成height: 100% !important+hsx-rail, 结果那条!important让动画彻底失效(作者 important 声明优先级高于动画),220ms 的生长根本不播。 已删掉!important,靠forwards保持终态;check-markup.mjs加了守卫断言。 - 笔画没写到时会留下小圆点:Chromium 会给被 offset 藏起来的 dash 也画上圆头端点,
所以"看不见"不能只靠
stroke-dashoffset,必须让整条路径在轮到自己之前opacity: 0。 现在整套虚线机制已被遮块滑走取代(hsx-slide纯transform),这个坑自然消失。 - 错开是"逐字母"而不是"逐笔画":早期版本把一个字母里的笔画按 120ms 拉开(
--hsx-part), 字母是"分几次"冒出来的。27 个遮块 → 15 个同时动的元素,且字母一次成型 —— 这是 5 个变体 在真机上逐一对比后选定的。CSS 用--hsx-i(字母序号,标记内联)×--hsx-cells(词长,容器上一次写入)算延迟;harness/check-stagger.mjs守卫这一点。 - 宿主脚本是模板字符串:
lib/index.js里回传给浏览器的脚本本身写在反引号模板里, 所以脚本正文里不能出现反引号(哪怕在注释里)——一旦出现就会提前闭合模板, 报错却是莫名其妙的Unexpected identifier 'timeline'。harness/check-markup.mjs有一条专门的守卫断言。 - 就绪信号:外壳在挂载应用的同一个 commit 里移除启动占位
[data-dsh-boot],这是最可靠的交接点。 插件激活失败时占位不会消失,所以皮肤还有"占位内容 2.4s 不变即强制退场"的兜底, 避免把失败卡片盖在过渡层下面。 - 无头截图不可靠:headless Edge 的
--virtual-time-budget只推进定时器、不推进合成器动画, 直接截图多半拍到初始帧。验证因此以"计算样式 / 几何测量 / DOM 断言"为准; 阶段静帧用harness/build-iso.mjs把动画钉死在终态再截。 - 开场卡顿的根因(每一条都由实测或用户反馈定位,并加了静态守卫):
0. 动画和外壳启动抢主线程 —— 这是最主要的一条。原先写字动画从页面加载就开始,
正好压在外壳加载应用最重的那一段上。现在写字等就绪信号 + 持续安静 700ms 才放行,离场等名字写完才开始,
于是整段动画都跑在安静的主线程上。
- 放笔兜底定时器抢跑了就绪信号 —— 真机实测外壳就绪在 ~2.1s,而兜底 1.2s 就开枪,笔撞上
挂载期一次 237ms 的大帧(用户能"感觉到"的就是它)。兜底改 4s,且所有非就绪放笔路径
都走安静门(
releaseWhenIdle)。 - 就绪被处理了不止一次 —— 占位观察器在挂载期一直触发
mark(),每次重入onReady: 又开一个安静等待、又把就绪时间戳往后覆盖(这就是quietWait量出来恒为负的元凶)。 现在onReady有if (ready || dismissed) return守卫,只处理一次。 - "6 帧安静"太弱 —— 165Hz 下 6 帧只有 ~36ms,挂载期爆发帧轻松藏在后面。现在是 持续 700ms 干净帧(超 20ms 重新计时,上限 3s)+ 浏览器空闲回调。
- 细条用
height做动画 —— 每帧重新布局整个过渡层。现在改成transform: scaleY(),纯合成。 - 离场用
scaleX铺满整屏 —— 视口大小的元素每帧都要重新光栅化。现在改成translateX滑动一张已经画好的整屏缓冲(-100% → 0),合成器直接搬运图层、不重绘。 - 给三个整屏元素加
will-change提升合成层 —— 2560×1600 + 512MB 核显上每个贴图 ~37MB, 常驻 111MB 等于自造显存争抢(我为"防卡"加的,反而有害)。现已全部撤掉。 stroke-dashoffset逐笔画 —— 27 条矢量每帧重光栅化。改成遮块揭示(纯transform), 错开从"逐笔画"改成"逐字母"(5 个变体实机对比后用户选定的最优:字母一次成型、同时动的元素减半)。- ★ 遮块是 SVG 子元素,所以动画不在合成器上 —— 最后、也最隐蔽的一条。
帧数据说明问题不在动画本身(中位 6ms,165Hz 原生),而在应用启动期主线程的几次 ~170ms 阻塞。
Chromium 会把 HTML 元素的
transform动画交给合成器(主线程被堵也继续滑),SVG 子元素的不交 —— 所以主线程一堵,遮块就冻住。用户的原话"字底下的小光标连贯、字卡"正是这条机制差别(光标是 HTML 元素)。 现在遮块是 HTML 元素 +will-change: transform,一个字母一块、scaleX(1→0)收缩揭示; 因为只在自己格子里收缩,27 个 rect + 54 个 clipPath 全部删掉。 证据(真实 trace):Paint从 168 次/4s(≈42 次/秒) 降到 1.6 次/秒,同时AnimationHost::TickAnimations持续 tick —— 动画确实跑在合成器上。 守卫在harness/check-markup.mjs:the layer holds no resident GPU layer、no viewport-sized element is promoted to a compositor layer、the cover is promoted to its own compositor layer、the reveal animates a compositor transform、the pen waits for sustained idleness, with a backstop、readiness is handled once、the fallback kick is later than the shell and goes through the quiet gate; 另有check-release.mjs(放行与暂停配对)、check-stagger.mjs(逐字母错开)、check-clock.mjs(时序)、check-composited.mjs(抓 trace 断言"没有逐帧重绘",把遮块挪回 SVG 会立刻失败)。
- 放笔兜底定时器抢跑了就绪信号 —— 真机实测外壳就绪在 ~2.1s,而兜底 1.2s 就开枪,笔撞上
挂载期一次 237ms 的大帧(用户能"感觉到"的就是它)。兜底改 4s,且所有非就绪放笔路径
都走安静门(
- 为什么这里量不出帧率(两次尝试都撞墙,记录以免重走):
--virtual-time-budget下requestAnimationFrame只触发 1 次(实测raf=1,而setInterval跑了 126 次), 所以按帧采样的脚本永远测不到数据;- 同一模式下
performance.now()/Date.now()也被虚拟化,量到的"耗时"恒为 0; --dump-dom在 load 就返回,拿不到动画结束后的结果;loopback HTTP 被沙箱挡住,页面无法回传。 结论:沙箱内没有可用的真实时钟(rAF 只触发 1 次、performance.now()恒为 0、--dump-dom在 load 就返回), 所以仓库里不留假装能测的脚本。真机帧率靠有界探针:只在写字阶段采样 ≤3s 自动停,结果写 localStorage(退出不丢,主通道) +window.__HSX__.perf,收集器在线时还会 beacon 过来。 读取命令:node harness/read-storage.mjs(应用运行中也能读,共享读已验证)。 实测基线:中位 6ms(165Hz 原生帧);修复前问题是worst=237ms的单次大顿挫 +quietWait为负。
已知取舍
- 皮肤不做"完全体主题包":只覆盖用户实际会看到的语义令牌(背景/层级/描边/文字/状态色/气泡/侧栏/按钮/滚动条),
没有碰几百个
--dsw-static-*调色板原子值。 - 浅色模式是"不破坏"级别的兜底(可读、协调),主推深色。
- 过渡画面每次浏览器会话只播一次(
sessionStorage);想要每次都播,把playOncePerSession设为false。 - 圆角只改"形状"不改半径:各组件沿用设计系统自己的半径(气泡 8–12px、菜单 6px 等)。
preview/下的独立走查页是评审伴生件,真实行为以lib/+ 实机为准。