第三章:架构重构与对比分析
Open Claude UI 分析手册(Claude Desktop 4.29.0 → 4.4212.0)「第三章:架构重构与对比分析」:每条结论附代码证据与复现命令。
本章聚焦 CDS 3.0 中被
open-claude-ui误实现或完全未实现的机制。每节先给 CSS 原文,再给结论,最后给与当前实现(feat/issue-25-skeleton-sweep-and-rea-audit分支)的差异。
3.1 骨架屏:双层组件结构,sweep band 是独立子元素
3.1.1 真实的 DOM 层级
CDS 3.0 的骨架屏由两层 data-cds 属性构成:
[data-cds="SkeletonGroup"] ← 组容器,控制 motion / hold
└─ [data-cds="Skeleton"] ← 单个骨架块
└─ .cds-skel-sweep-clip ← 裁剪层(overflow:hidden)
└─ .cds-skel-sweep-band ← 扫光光带(真正动的元素)证据(CSS 原文)
/* 裁剪层 */
.cds-root .cds-skel-sweep-clip {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden
}
/* 光带 */
.cds-root .cds-skel-sweep-band {
background: linear-gradient(105deg, transparent 35%, var(--cds-skeleton-sheen), transparent 65%);
animation: cds-skeleton-sweep 2s linear var(--cds-skel-reveal-end, 0s) infinite backwards;
position: absolute;
inset: 0
}关键点
| 观察 | 说明 |
|---|---|
.cds-skel-sweep-band 是真实 DOM 元素 | 不是 ::after 伪元素 |
.cds-skel-sweep-clip 提供 overflow:hidden | 扫光被裁剪在骨架块内 |
position:absolute; inset:0 属于 band / clip | 不属于骨架块本身 |
骨架块 [data-cds="Skeleton"] 本身没有 position:absolute 规则 | 它保持正常文档流 |
3.1.2 与 issue #25 分支的差异
分支的实现(cds-theme.css):
.cds-skeleton-sweep,
.cds-skeleton[data-motion="wave"]::after,
.cds-skeleton:not([data-motion])::after {
content: "";
position: absolute; /* ← 问题所在 */
inset: 0;
transform: translate(-100%);
…
}三个结构性偏差:
| 项 | CDS 3.0 实际 | 分支实现 | 后果 |
|---|---|---|---|
| 光带载体 | 独立元素 .cds-skel-sweep-band | ::after 伪元素 | 伪元素无法被 JS 查询/替换 |
.cds-skeleton-sweep 作为独立类 | 不存在此语义 | 写在同一条规则的选择器列表里 | 该类的元素自身变成 position:absolute + translate(-100%),被推出视口 |
| 裁剪机制 | 独立 .cds-skel-sweep-clip + overflow:hidden | 依赖骨架块自身 overflow:hidden | 结构上可工作,但缺少显式裁剪层 |
分支缺陷的实测证据(Chrome headless,用 storybook 自己的 CSS):
class="cds-skeleton cds-skeleton-sweep h-4 rounded w-3/4" data-motion="wave"
→ position: absolute, x = -245, 渲染为不可见这正是 3.1.2 表格第二行描述的直接后果。
3.1.3 修复方向
最小改动(保留现有类名,去掉并列的独立类选择器):
/* 只保留伪元素形态,不把 .cds-skeleton-sweep 放进这条规则 */
.cds-skeleton[data-motion="wave"]::after,
.cds-skeleton:not([data-motion])::after { … }完整对齐(推荐,需同步改组件标记):
.cds-skeleton { position: relative; overflow: hidden; }
.cds-skel-sweep-clip { pointer-events:none; position:absolute; inset:0; overflow:hidden; }
.cds-skel-sweep-band {
background: linear-gradient(105deg, transparent 35%, var(--cds-skeleton-sheen), transparent 65%);
animation: cds-skeleton-sweep 2s linear var(--cds-skel-reveal-end, 0s) infinite backwards;
position: absolute; inset: 0;
}3.2 骨架屏:data-hold 三态揭示时序
这是 CDS 3.0 完全新增的机制,v2 文档与 issue #25 分支均未涉及。
3.2.1 三个状态
data-hold 属性(挂在 SkeletonGroup 上)有三个取值:
| 取值 | CSS 行为 | 语义 |
|---|---|---|
| 缺省(无该属性) | 使用 var(--placeholder-reveal-delay, .5s) 的默认揭示时序 | 标准延迟揭示 |
none | --cds-skel-reveal-end: calc(0s - var(--cds-skel-elapsed)) | 立即开始,不等待 |
delayed | 额外挂 cds-placeholder-reveal .15s ease-out …backwards 动画 | 显式延迟揭示 |
3.2.2 CSS 原文
/* 基础(无 data-hold 或 data-hold 无值时) */
.cds-root [data-cds=SkeletonGroup][data-hold] {
--cds-skel-elapsed: 0s;
--cds-skel-i: 0;
--cds-skel-settled: 0s;
--cds-skel-reveal-end: calc(var(--placeholder-reveal-delay, .5s) + .15s - var(--cds-skel-elapsed))
}
/* hold=none:立即 */
.cds-root [data-cds=SkeletonGroup][data-hold=none] {
--cds-skel-reveal-end: calc(0s - var(--cds-skel-elapsed))
}
/* hold=delayed:显式延迟 + 淡入 */
.cds-root [data-cds=SkeletonGroup][data-hold=delayed] {
animation: cds-placeholder-reveal .15s ease-out
calc(var(--placeholder-reveal-delay, .5s) - var(--cds-skel-elapsed) - var(--cds-skel-settled))
backwards
}3.2.3 揭示延迟公式
从上面三段可推出完整的 --cds-skel-reveal-end 取值链:
data-hold 缺省 / 有值但非 none 非 delayed:
reveal-end = placeholder-reveal-delay(.5s) + 0.15s - elapsed
data-hold = none:
reveal-end = 0s - elapsed
data-hold = delayed:
reveal-end = (同上缺省公式)
额外叠加 cds-placeholder-reveal 动画,延时 = reveal-delay - elapsed - settled含义:.5s 是默认的占位揭示延迟,+0.15s 是 cds-placeholder-reveal 动画自身时长(.15s ease-out)。elapsed / settled 由 JS 在运行时写入,用于「组件已经挂载了多久」的补偿——避免骨架屏在已经加载完成后才启动动画。
⚠️ 不可解部分:
--cds-skel-elapsed与--cds-skel-settled的默认值在 CSS 里是0s,但它们在运行时由谁写入、写入什么值,静态分析无法确定(需运行时观测)。本文只记录 CSS 侧的公式。
3.2.4 揭示完成后停止动画
/* 一旦 data-hold 且 data-motion 同时存在(即进入揭示完成态) */
.cds-root [data-cds=SkeletonGroup][data-hold][data-motion],
.cds-root [data-cds=SkeletonGroup][data-hold][data-motion] [data-cds=Skeleton] {
animation-duration: .01ms;
animation-iteration-count: 1
}
/* 并隐藏扫光带 */
.cds-root [data-cds=SkeletonGroup][data-hold][data-motion] .cds-skel-sweep-band {
display: none
}这是 CDS 3.0 的「动画收尾」机制:当骨架内容已揭示完成,把动画压到 .01ms 单次执行(实质是立即结束),并直接 display:none 掉扫光带。
分支完全缺失此机制——分支的骨架屏会无限循环扫光,不会在内容到达后停止。
3.3 骨架屏:双模 wave / lockstep 的真实延时公式
3.3.1 wave 模式(波浪级联)
.cds-root [data-cds=SkeletonGroup][data-motion=wave] [data-cds=Skeleton] {
animation: cds-skeleton-breathe 2s ease-in-out
calc(var(--cds-skel-reveal-end, 0s) + var(--cds-skel-i, 0) * 90ms)
infinite
}关键发现:wave 模式用的是 cds-skeleton-breathe(透明度呼吸),不是扫光 sweep。
动画: cds-skeleton-breathe
时长: 2s
缓动: ease-in-out
延时: reveal-end + (skel-i × 90ms)
迭代: infinite这与分支的实现相反:分支把 wave 模式绑定到 cds-skeleton-sweep(扫光),而实测中用 breathe。
3.3.2 lockstep 模式(同步呼吸)
/* hold=none 时 */
.cds-root [data-cds=SkeletonGroup][data-motion=lockstep][data-hold=none] {
animation: cds-skeleton-breathe 2s ease-in-out var(--cds-skel-reveal-end) infinite
}
/* hold=delayed 时:叠加揭示动画 */
.cds-root [data-cds=SkeletonGroup][data-motion=lockstep][data-hold=delayed] {
animation: cds-skeleton-breathe 2s ease-in-out var(--cds-skel-reveal-end) infinite,
cds-placeholder-reveal .15s ease-out
calc(var(--placeholder-reveal-delay, .5s) - var(--cds-skel-elapsed) - var(--cds-skel-settled))
backwards
}注意:lockstep 的动画挂在 SkeletonGroup 容器上,作用于整个组;wave 的动画挂在每个 Skeleton 子块上,带级联延时。这是两种模式的本质区别。
3.3.3 两种模式的对比表
| 维度 | data-motion="wave" | data-motion="lockstep" |
|---|---|---|
| 动画 | cds-skeleton-breathe 2s ease-in-out | 同左 |
| 挂载位置 | 每个 [data-cds=Skeleton] 子块 | [data-cds=SkeletonGroup] 容器 |
| 延时 | reveal-end + skel-i × 90ms | reveal-end(无级联) |
| 效果 | 逐个错峰呼吸 | 整组同步呼吸 |
| 适用 | 大卡片、多块布局 | 密集列表 |
3.3.4 与分支的差异汇总
| 项 | CDS 3.0 | 分支 | 判定 |
|---|---|---|---|
| wave 动画 | cds-skeleton-breathe(呼吸) | cds-skeleton-sweep(扫光) | ❌ 用错动画 |
| lockstep 挂载 | Group 容器 | .cds-skeleton[data-motion="lockstep"](每个块) | ⚠️ 语义不同但效果接近 |
| 级联延时 | reveal-end + i×90ms | i × 90ms | ⚠️ 缺 reveal-end 项 |
| 扫光触发 | 由 .cds-skel-sweep-band 独立元素承担,与 motion 无关 | 绑定到 data-motion | ❌ 混淆了两种机制 |
核心误解:分支把「扫光」和「呼吸」当成了 wave / lockstep 两种模式。实际是两个正交的机制:
- 扫光(
.cds-skel-sweep-band):独立元素,2s linear,与 data-motion 无关 - 呼吸(
cds-skeleton-breathe):由 data-motion 的 wave/lockstep 驱动,2s ease-in-out
3.4 骨架屏:RTL 语言级适配
3.4.1 CSS 原文
/* 扫光带:RTL 时渐变角度 105° → 255°,并反转动画方向 */
.cds-root .cds-skel-sweep-clip:where(
:root[dir=rtl] :is(
:lang(ae), :lang(ar), :lang(arc), :lang(bcc), :lang(bqi), :lang(ckb),
:lang(dv), :lang(fa), :lang(glk), :lang(he), :lang(ku), :lang(mzn),
:lang(nqo), :lang(pnb), :lang(ps), :lang(sd), :lang(ug), :lang(ur), :lang(yi)
)
) .cds-skel-sweep-band {
background-image: linear-gradient(255deg, transparent 35%, var(--cds-skeleton-sheen), transparent 65%);
animation-direction: reverse
}
/* 骨架块自身的伪元素同样反转 */
.cds-root [data-cds=Skeleton][data-cds=Skeleton]:where(
:root[dir=rtl] :is(…同上 19 个语言…)
):after {
animation-direction: reverse
}3.4.2 三个关键点
| 点 | 说明 |
|---|---|
:lang() 精确语言列表 | 19 个语言代码:ae ar arc bcc bqi ckb dv fa glk he ku mzn nqo pnb ps sd ug ur yi |
animation-direction: reverse | 不只是翻转渐变角度,还要反转动画播放方向,扫光才会从右往左 |
[data-cds=Skeleton][data-cds=Skeleton] 重复属性 | 这是提高选择器特异性的技巧,用于压过其它规则 |
3.4.3 与分支的差异
| 项 | CDS 3.0 | 分支 | 判定 |
|---|---|---|---|
| RTL 判定 | :root[dir=rtl] 且 :lang(<19 个语言之一>) | 仅 :dir(rtl) / [dir="rtl"] | ⚠️ 范围不同 |
| 渐变角度翻转 | ✅ 105° → 255° | ✅ 105° → 255° | ✅ 一致 |
| 动画方向反转 | ✅ animation-direction: reverse | ❌ 缺失 | ❌ 分支的 RTL 仍然从左往右扫 |
分支的 RTL 实测(Chrome,<html dir="rtl">):
LTR: transform = matrix(1,0,0,1,-220,0)
RTL: transform = matrix(1,0,0,1,-220,0) ← 完全相同,未反转3.5 减少动效与残留动效兜底
3.5.1 三层兜底机制
CDS 3.0 对减少动效(reduced motion)有三层处理:
/* 第 1 层:属性开关 —— data-reduce-motion=true 时也要收尾 */
.cds-root:where([data-reduce-motion=true],[data-reduce-motion=true] *)
[data-cds=SkeletonGroup][data-hold][data-motion],
.cds-root:where([data-reduce-motion=true],[data-reduce-motion=true] *)
[data-cds=SkeletonGroup][data-hold][data-motion] [data-cds=Skeleton] {
animation-duration: .01ms;
animation-iteration-count: 1
}
.cds-root:where([data-reduce-motion=true],[data-reduce-motion=true] *)
[data-cds=SkeletonGroup][data-hold][data-motion] .cds-skel-sweep-band {
display: none
}第 2 / 3 层(在其它组件上,抽样证据):
/* chip 进入动画:媒体查询层 */
@media (prefers-reduced-motion: reduce) {
.cds-root .cds-chip-in { animation: none }
}
/* chip 进入动画:属性层 */
.cds-root:where([data-reduce-motion=true],[data-reduce-motion=true] *) .cds-chip-in {
animation: none
}
/* flipbook:媒体查询层 */
@media (prefers-reduced-motion: reduce) {
.cds-root .cds-flipbook { animation: none }
}
/* flipbook:属性层 */
.cds-root:where([data-reduce-motion=true],[data-reduce-motion=true] *) .cds-flipbook {
animation: none
}3.5.2 双轨制
CDS 3.0 的减少动效同时监听两种来源:
| 来源 | 选择器 | 层级 |
|---|---|---|
| 系统偏好 | @media (prefers-reduced-motion: reduce) | 媒体查询 |
| 应用开关 | [data-reduce-motion=true] | 属性 |
两者并列,各自独立生效。这是为 Electron 应用设计的:系统偏好可能不可靠(或用户想单独控制某个应用),所以提供应用级显式开关。
3.5.3 与分支的差异
| 项 | CDS 3.0 | 分支 |
|---|---|---|
@media (prefers-reduced-motion) | ✅ 多处使用 | ⚠️ 仅 1 处,且只针对 [data-cds-anim] |
[data-reduce-motion=true] 属性开关 | ✅ 全组件覆盖 | ❌ 完全缺失 |
| 骨架屏动效兜底 | ✅ 有 | ❌ 无 |
结论:分支的骨架屏动画在任何情况下都无限循环,不响应系统的减少动效设置。
3.6 Pill 胶囊:1.32x 过冲的 9 个关键帧
v2 文档提到「Pill 1.32x 过冲回弹」,但未给出关键帧定义。以下是 3.6556.2 的原文。
3.6.1 四个关键帧
@keyframes cds-pill-in { 0% { transform: scale(.8) } }
@keyframes cds-pill-pop-up { to { transform: scale(1.32) } }
@keyframes cds-pill-pop-down { 0% { transform: scale(1.32) } }
@keyframes cds-pill-dip-up { 0% { transform: scale(.82) } }3.6.2 命名规律与数值
| 关键帧 | 关键值 | 用途推断(基于命名与数值) |
|---|---|---|
cds-pill-in | scale(.8) 起点 | 入场 |
cds-pill-pop-up | 终点 scale(1.32) | 上弹到 1.32x |
cds-pill-pop-down | 起点 scale(1.32) | 从 1.32x 回落 |
cds-pill-dip-up | 起点 scale(.82) | 下压后回升 |
1.32 与 0.82 的对称性:
1.32 - 1.00 = +0.32
1.00 - 0.82 = +0.18不是严格对称。.82 是压入,1.32 是弹出,弹比压更夸张——符合「过冲手感」的意图。
3.6.3 完整的 pill 关键帧家族
3.6556.2 中与 pill 相关的关键帧共 9 个:
cds-pill-dip-down
cds-pill-dip-up
cds-pill-in
cds-pill-out
cds-pill-pop-down
cds-pill-pop-up
cds-pill-roll-in-down
cds-pill-roll-in-up
cds-pill-roll-out分为三个系列:
| 系列 | 成员 | 数值特征 |
|---|---|---|
in / out | cds-pill-in、cds-pill-out | 入场/退场 |
pop | cds-pill-pop-up、cds-pill-pop-down | scale(1.32) 过冲 |
dip | cds-pill-dip-up、cds-pill-dip-down | scale(.82) 压入 |
roll | cds-pill-roll-in-down、cds-pill-roll-in-up、cds-pill-roll-out | 滚动进出 |
证据:grep -o "@keyframes cds-pill-[a-z-]*" MainWindowPage-BjVtjJFx.css | sed 's/@keyframes //' | sort -u | wc -l → 9
分支与 open-claude-ui 均无这 9 个关键帧中的任何一个。 open-claude-ui 的 ClaudeMotionPresets.ts 用 Framer Motion spring 实现类似效果,但数值体系(stiffness/damping/mass)与 CSS keyframes(scale 离散值)不是同一套。
3.7 滚动渐隐:@property 驱动的纯 CSS 实现
3.7.1 @property 注册
CDS 3.0 用 @property 注册了一批可动画的自定义属性。与滚动渐隐相关的 17 个:
@property --cds-scroll-fade-top { … }
@property --cds-scroll-fade-top-on { … }
@property --cds-scroll-fade-top-raw { … }
@property --cds-scroll-fade-bottom { … }
@property --cds-scroll-fade-bottom-on { … }
@property --cds-scroll-fade-bottom-raw { … }
@property --cds-scroll-fade-left { … }
@property --cds-scroll-fade-left-on { … }
@property --cds-scroll-fade-left-raw { … }
@property --cds-scroll-fade-right { … }
@property --cds-scroll-fade-right-on { … }
@property --cds-scroll-fade-right-raw { … }
@property --cds-scroll-fade-size { … }
@property --cds-scroll-fade-x-active { … }
@property --cds-scroll-fade-y-active { … }
@property --cds-scroll-fade-strip-active { … }
@property --cds-scroll-fade-strip-raw { … }证据:grep -o "@property --cds-scroll-fade-[a-zA-Z0-9_-]*" MainWindowPage-BjVtjJFx.css | sort -u | wc -l → 17
另有 3 个与吸边(sticky edge)相关的 @property:--cds-sticky-edge-active / --cds-sticky-edge-end / --cds-sticky-edge-start。
3.7.2 默认值
.cds-root {
--cds-scroll-fade-size: 28px;
--cds-scroll-fade-top: 0px;
--cds-scroll-fade-bottom: 0px;
--cds-scroll-fade-left: 0px;
--cds-scroll-fade-right: 0px;
--cds-scroll-fade-top-raw: 0px;
--cds-scroll-fade-bottom-raw: 0px;
--cds-scroll-fade-left-raw: 0px;
--cds-scroll-fade-right-raw: 0px;
--cds-scroll-fade-y-active: 0;
--cds-scroll-fade-x-active: 0;
--cds-scroll-fade-top-on: 1;
--cds-scroll-fade-bottom-on: 1;
--cds-scroll-fade-left-on: 1;
--cds-scroll-fade-right-on: 1;
--cds-scroll-fade-strip-raw: 0;
--cds-scroll-fade-strip-active: 0
}3.7.3 滚动驱动
animation-timeline: scroll()出现 1 处(用于 [data-cds-title-leaves] 的页面标题动画)。
3.7.4 与 v2 文档 / 分支的差异
| 项 | CDS 3.0 | v2 文档声称 | open-claude-ui 现状 |
|---|---|---|---|
| 实现方式 | @property + animation-timeline: scroll() | 「7 停非线性 mask」 | @property + animation-timeline |
| 渐隐尺寸 | --cds-scroll-fade-size: 28px | 未记录 | 已实现 |
| mask-image | linear-gradient(#000 0 0)(纯色占位,由 @property 变量控制) | 「7 停 mask」 | 待核对 |
⚠️ 需注意:本文在 3.6556.2 主样式表中只找到
mask-image:linear-gradient(#000 0 0)这一处,未找到 v2 文档描述的「7 停非线性 mask」原文。这可能是:
- 该实现已重构为
@property变量驱动- 该实现位于其它 CSS 文件(如
window-shared.css)- v2 文档的「7 停」描述本身是推断而非取证
本文不对「7 停 mask 是否仍然存在」下结论,标注为
unknown,需进一步核对window-shared.css与运行时计算样式。
本章证据索引
| 断言 | 证据 |
|---|---|
骨架屏双层 data-cds | CSS 选择器 [data-cds=SkeletonGroup] / [data-cds=Skeleton] 原文 |
.cds-skel-sweep-band 是真实元素 | CSS .cds-root .cds-skel-sweep-band { … } 无 ::after |
data-hold 三态 | 三段 [data-hold] / [data-hold=none] / [data-hold=delayed] 规则 |
| wave 用 breathe 不用 sweep | .cds-root [data-cds=SkeletonGroup][data-motion=wave] [data-cds=Skeleton] { animation: cds-skeleton-breathe … } |
| lockstep 挂 Group | [data-cds=SkeletonGroup][data-motion=lockstep]… 选择器层级 |
| 90ms 级联 | calc(var(--cds-skel-reveal-end,0s) + var(--cds-skel-i,0) * 90ms) |
| RTL 19 个语言 + reverse | :lang(ae,ar,arc,…) 完整列表 + animation-direction:reverse |
| 减少动效双轨 | @media (prefers-reduced-motion:reduce) 与 [data-reduce-motion=true] 并列出现 |
| pill 1.32x | @keyframes cds-pill-pop-up { to { transform: scale(1.32) } } |
@property 滚动渐隐 17 项 | @property --cds-scroll-fade-* 清单(sort -u | wc -l = 17) |
scroll-fade-size: 28px | .cds-root { --cds-scroll-fade-size: 28px; … } |