Open Claude UI
EN
v4 · Open Claude UI 分析手册(4.4212.0)

第三章:架构重构与对比分析

Open Claude UI 分析手册(Claude Desktop 4.29.0 → 4.4212.0)「第三章:架构重构与对比分析」:每条结论附代码证据与复现命令。

View as Markdown最后更新:

本章聚焦 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 × 90msreveal-end(无级联)
效果逐个错峰呼吸整组同步呼吸
适用大卡片、多块布局密集列表

3.3.4 与分支的差异汇总

项CDS 3.0分支判定
wave 动画cds-skeleton-breathe(呼吸)cds-skeleton-sweep(扫光)❌ 用错动画
lockstep 挂载Group 容器.cds-skeleton[data-motion="lockstep"](每个块)⚠️ 语义不同但效果接近
级联延时reveal-end + i×90msi × 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-inscale(.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 / outcds-pill-in、cds-pill-out入场/退场
popcds-pill-pop-up、cds-pill-pop-downscale(1.32) 过冲
dipcds-pill-dip-up、cds-pill-dip-downscale(.82) 压入
rollcds-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.0v2 文档声称open-claude-ui 现状
实现方式@property + animation-timeline: scroll()「7 停非线性 mask」@property + animation-timeline
渐隐尺寸--cds-scroll-fade-size: 28px未记录已实现
mask-imagelinear-gradient(#000 0 0)(纯色占位,由 @property 变量控制)「7 停 mask」待核对

⚠️ 需注意:本文在 3.6556.2 主样式表中只找到 mask-image:linear-gradient(#000 0 0) 这一处,未找到 v2 文档描述的「7 停非线性 mask」原文。这可能是:

  1. 该实现已重构为 @property 变量驱动
  2. 该实现位于其它 CSS 文件(如 window-shared.css)
  3. v2 文档的「7 停」描述本身是推断而非取证

本文不对「7 停 mask 是否仍然存在」下结论,标注为 unknown,需进一步核对 window-shared.css 与运行时计算样式。


本章证据索引

断言证据
骨架屏双层 data-cdsCSS 选择器 [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; … }

On this page