Merge pull request #23 from Caho1/fix/cds-token-closure-and-client-spring-audit

fix(cds): close undefined token references and realign motion presets with client 2.42304.2
This commit is contained in:
Caho1 authored and GitHub committed 2026-10-20 22:25:02 +08:00
commit 07caa0d695
6 files changed
+307 -130

No files matched your search

+64 -11
View File
@@ -83,18 +83,71 @@ import {
```
### The 4 Canonical Spring Presets
1. **`SPRING_SNAPPY`** (`stiffness: 600, damping: 30, mass: 0.6`, $\zeta = 0.791$):
- **Overshoot**: `+1.8%`, settle time: `~0.22s`.
- **Where to use**: Primary button clicks, segmented control pill switching (`layoutId`), tabs, micro-interactions.
2. **`SPRING_BOUNCY`** (`stiffness: 400, damping: 25, mass: 0.8`, $\zeta = 0.612$):
- **Overshoot**: `+8.8%`, settle time: `~0.38s`.
- **Where to use**: Toggle switches, badge growth entrances, success confirmation pop-ins.
3. **`SPRING_GENTLE`** (`stiffness: 220, damping: 24, mass: 1.0`, $\zeta = 0.809$):
- **Overshoot**: `+1.5%`, settle time: `~0.48s`.
- **Where to use**: Modals, Thinking Accordion height expansion, message bubble entry, split-pane 50/50 reset.
4. **`SPRING_HEAVY_SNAP`** (`stiffness: 1200, damping: 80, mass: 0.1`, $\zeta = 1.826$, overdamped):
Presets 1, 2 and 4 are observed values extracted from `Claude.app` `ion-dist`.
Preset 3 (`SPRING_GENTLE`) is a project-local addition, not a CDS observation —
no client release checked here contains `stiffness:380` or `damping:18`. Values
below are recomputed from the exported constants, not copied from prose.
`Observed use` lines record call sites found in the shipped bundle, not inferred intent.
> **Provenance and version scope.** `Observed use` and the client inventory in
> this section were verified against **`/Applications/Claude.app` 2.42304.2**.
> The `docs/v1` chapters describe **v1.28** and are deliberately left as the
> historical record of that release; the `docs/v2` chapters describe
> **v2.37337.1** but do not carry a spring table. Constant values are stable
> across these releases; call sites are not.
1. **`SPRING_BOUNCY`** (`stiffness: 600, damping: 30, mass: 1.0`, $\zeta = 0.612$):
- **Overshoot**: `+8.8%`.
- **Observed use**: Microphone button mount/unmount `scale`+`opacity`, voice-bar
`width: 0 → 32` expansion, button padding morphs, and `popLayout` icon swaps.
- **Source**: `ion-dist/assets/v1/shared-8-PHXy9B6B.js`.
2. **`SPRING_SNAPPY`** (`stiffness: 320, damping: 28, mass: 1.0`, $\zeta = 0.783$):
- **Overshoot**: `+1.9%`.
- **Observed use**: Drag-release rebound — paired `animate(dx|dy, target, {...preset, velocity})`
calls that settle a dragged element onto its slot using the release velocity.
Not an entrance animation. Guarded by `prefers-reduced-motion`, which snaps instead.
- **Source**: `ion-dist/assets/v1/c427176b2-DuM09fKk.js`.
3. **`SPRING_GENTLE`** (`stiffness: 380, damping: 18, mass: 1.0`, $\zeta = 0.462$):
- **Overshoot**: `+19.5%`.
- **Observed use**: none — this preset has no client call site.
- **Source**: none. Project-local preset filling the gap between the
near-critically-damped pair and the high-frequency pop; do not present it as a Claude reading.
- **Do not equate it with `duration .32 / bounce .15`.** That real client config
(`shared-9-Co0ph6Xn.js`, dictation↔rest state swap) resolves through framer-motion
`tn()` to `stiffness ≈ 244, damping ≈ 26.6, ζ ≈ 0.85` — `+0.6%` overshoot, not `+19.5%`.
4. **`SPRING_HEAVY_SNAP`** (`stiffness: 1200, damping: 80, mass: 0.1`, $\zeta = 3.652$, overdamped):
- **Overshoot**: `0%` (Zero overshoot).
- **Where to use**: Session item drag-and-drop placeholder collision, magnetic drawer snapping.
- **Observed use**: `file_viewer` panel width animation — `animate(width, 384, preset)`
on open and back to the computed initial width on close. In the same module
`kt = 320` is the minimum width and `At = 480` the default width.
- **Source**: `ion-dist/assets/v1/c8ad300b7-07P9B-v4.js`.
### Other spring presets observed in the client
The four names above are a project convention, not a complete client inventory.
The shipped bundle contains 14 distinct spring configs; these three are missing
from the preset file and are worth knowing about:
| Config | ζ | Overshoot | Observed use | Source |
| :--- | :--- | :--- | :--- | :--- |
| `420 / 28` | 0.683 | `+5.3%` | Draggable capture popover, `scale .25 → 1` entrance with `dragElastic: .05` | `c0447db9a-KKfDTbDr.js` |
| `260 / 16` | 0.496 | `+16.6%` | Capture-selection handle rotation, `-90°` arrow marker; exit `scale .6` | `c0447db9a-KKfDTbDr.js` |
| `300 / 35 / mass 1` | 1.01 | `0%` | Staggered list entrance, `translateY 32 → 0` with `staggerChildren: .15` and a separate `opacity` ease-out | `ccd647019-DrnyijvI.js` |
Two more appear as timing variants rather than reusable presets: `320 / 30`
(`delay: 1`, credits controls row) and `500 / 30 / mass .7`.
### Version caveat
`docs/v1` records the matrix as it appeared in client **v1.28**, labelling
`600 / 30` as "buttons" and `1200 / 80 / .1` as "sidebar drag damping". That
mapping was correct for that release. In **2.42304.2** both constants survive
unchanged but their call sites moved: `600 / 30` now drives the microphone and
voice bar, and `1200 / 80 / .1` drives the `file_viewer` panel width. Neither
`docs/v1` nor `docs/v2` covers 2.42304.2, so the table above is the only
current-version record in this repo. When a scenario label matters, re-derive it
from the bundle rather than trusting either volume.
### 7-Stop Non-Linear Fade Mask
Never use a linear gradient for scroll fading. Use the calibrated 7-stop non-linear mask:
+29 -8
View File
@@ -55,8 +55,11 @@ export function calcDampedHarmonicOscillator(
// ============================================================================
/**
* 预设 1: 按钮点击、开关切换、图标按压 (Button & Small Controls)
* 特征:高频微弹,利落归位无粘滞。超调量 +8.8% (ζ = 0.612),0.22s 极速收敛。
* 预设 1: 麦克风按钮 / 语音状态条 (Mic & Voice Bar)
* 特征:高频微弹,超调量 +8.8% (ζ = 0.612)。
* 来源:`ion-dist/assets/v1/shared-8-PHXy9B6B.js` — `{type:"spring",stiffness:600,damping:30}`
* 实测用途:麦克风按钮 mount/unmount 的 `scale/opacity` 出入场、语音状态
* `width:0→32` 展开、按钮 padding 形变、图形切换的 `popLayout`。
*/
export const SPRING_BOUNCY: Transition = {
type: "spring",
@@ -66,8 +69,12 @@ export const SPRING_BOUNCY: Transition = {
};
/**
* 预设 2: 气泡上浮、卡片展开、浮层 Popover 唤起 (Card / Popover Reveal)
* 特征:沉稳顺滑,微冲后平稳着陆。超调量 +1.8%,0.35s 收敛。
* 预设 2: 手势拖拽回弹 (Drag Release) —— 不是入场动画
* 特征:沉稳顺滑,超调量 +1.9% (ζ = 0.783)。
* 来源:`ion-dist/assets/v1/c427176b2-DuM09fKk.js` — `{type:"spring",stiffness:320,damping:28}`
* 实测用途:`animate(current.dx, target.dx, {...preset, velocity})` 与 `dy` 成对,
* 拖动结束后按松手速度回弹到落位点;带 `prefers-reduced-motion`
* 分支直接跳位(客户端 65 个文件均做此降级)。
*/
export const SPRING_SNAPPY: Transition = {
type: "spring",
@@ -77,8 +84,18 @@ export const SPRING_SNAPPY: Transition = {
};
/**
* 预设 3: 点赞动画、复制成功勾选、新消息微提示 (Bouncy Light Feedback)
* 特征:两周期明显跳跃感,激发积极情绪反馈。超调量 +19.4%,0.58s 收敛。
* 预设 3: **非客户端提取,本项目自建**
* 特征:明显两周期跳跃感,超调量 +19.5% (ζ = 0.462)。
*
* 客户端全量搜索无 `stiffness:380` / `damping:18`,故 `380/18` 不是 CDS 观测值。
* 文档曾把客户端的 `{type:"spring",duration:.32,bounce:.15}`
* (`shared-9-Co0ph6Xn.js`,听写/静息状态切换)标注为「或 `dur 0.32`」的等价写法,
* 但按 framer-motion `tn()` 反解,该配置实际等价于
* `stiffness≈244, damping≈26.6, ζ≈0.85`(超调仅 +0.6%),与 380/18 相差极大。
* 如需还原该切换手感,请直接用 duration/bounce 形式,不要用本预设近似。
*
* 本预设仅用于补足「零过冲」与「高频微弹」之间的中段强弹性档位,
* 借用前请确认符合你的设计意图,不要当作 Claude 观测值引用。
*/
export const SPRING_GENTLE: Transition = {
type: "spring",
@@ -88,8 +105,12 @@ export const SPRING_GENTLE: Transition = {
};
/**
* 预设 4: 抽屉与侧边栏手势吸附、面板分割拖拽 (High Damping Panel Drag)
* 特征:绝对零过冲 (过阻尼 ζ = 3.65),松手瞬间如磁吸般强力咬合。
* 预设 4: 文件查看器面板宽度 (File Viewer Panel Width)
* 特征:绝对零过冲(过阻尼 ζ = 3.652)。
* 来源:`ion-dist/assets/v1/c8ad300b7-07P9B-v4.js` — `{type:"spring",stiffness:1200,damping:80,mass:.1}`
* 实测用途:`file_viewer` 面板展开时 `animate(width, 384, preset)` 将宽度从
* `initial` 弹到 `384`(常量 `jt=384`),关闭时弹回 `Pt().initial`;
* 同一文件中 `kt=320` 是最小宽度、`At=480` 是默认宽度。
*/
export const SPRING_HEAVY_SNAP: Transition = {
type: "spring",
+1 -1
View File
@@ -90,7 +90,7 @@ export function SegmentedControl({
{/* 1. 原生纯几何定位滑动指示器 */}
{!useFramerMotion ? (
<div
className="df-pill-indicator absolute top-[1px] bottom-[1px] rounded-full bg-white dark:bg-[#2c2c2a] pointer-events-none transition-all duration-120 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform"
className="df-pill-indicator absolute top-[1px] bottom-[1px] rounded-full bg-white dark:bg-[#2c2c2a] pointer-events-none transition-all duration-[var(--cds-dur-snap)] ease-[var(--cds-ease-snap)] will-change-transform"
style={{
left: `calc(1px + ${activeIndex} * (100% - 2px) / ${normalizedTabs.length})`,
width: `calc((100% - 2px) / ${normalizedTabs.length})`,
+163 -110
View File
@@ -158,6 +158,18 @@
--radius-card: calc(var(--cds-radius, 8px) + calc(.25rem * var(--cds-rem-scale, 1)));
/* 会话气泡与版心计量核心令牌 (REA v6.5.0 取证自 frame-shell-chrome-CAw-tYzh.js) */
/* 中性档别名 (REA 取证自 .cds-root 作用域, Claude 2.42304.2)
用户气泡背景经 --cds-bg-user-message 间接依赖这两个别名;缺失会使
var() 链在解析期失效、气泡背景回退为透明。 */
--cds-bg-neutral: var(--cds-alpha-1);
--cds-bg-neutral-hover: var(--cds-alpha-2);
/* 强调填充别名链 (REA 取证: .cds-root 作用域, Claude 2.42304.2)
--cds-fill-accent → --cds-role-accent-fill → --cds-role-accent-450 → --cds-blue-450 */
--cds-blue-450: #2a78d6;
--cds-role-accent-450: var(--cds-blue-450);
--cds-role-accent-fill: var(--cds-role-accent-450);
--cds-fill-accent: var(--cds-role-accent-fill);
--cds-bg-user-message: var(--cds-bg-neutral);
--cds-bg-user-message-personal: var(--cds-bg-tint-blue);
--cds-text-user-message-personal: var(--cds-text-tint-blue, #193c5f);
@@ -199,6 +211,15 @@
--cds-ease-pulse: cubic-bezier(.4, 0, .2, 1); /* 思考三点跳动呼吸脉冲 */
--cds-ease-rise: cubic-bezier(.3, .7, .4, 1); /* 状态条平滑抬升 */
--cds-ease-fall: cubic-bezier(0, 0, .2, 1); /* 状态条平滑下落 */
/* 12b. 时间尺度令牌 (REA 取证自 c6a992d55-sXiDGMFQ.css, Claude 2.42304.2)
缺失时下列 var() 引用会让整条 transition 声明被丢弃、计算值退化为 `all 0s`。
--cds-dur-slow 被 .cds-btn-squish、卡片按压与 clip-path 过渡共同依赖。 */
--cds-dur-fast: 60ms; /* 瞬态响应:按压塌陷、Focus 环显现、Checkbox 勾选 */
--cds-dur-snap: .12s; /* 瞬时吸附:分段控件滑块、Tab 切换 */
--cds-dur-base: .2s; /* 标准组件过渡:菜单弹出、折叠展开、卡片入场 */
--cds-dur-sheet: .3s; /* 大面板与抽屉:侧边栏、Artifact 面板滑入 */
--cds-dur-slow: .45s; /* 环境转场与按压回弹:.cds-btn-squish 松手复位的弹簧时长 */
--cds-btn-spring: linear(0, .2459, .6526, .9468, 1.0764, 1.0915, 1.0585, 1.0219, .9993, .9914, .9921, .9957, .9988, 1.0004, 1); /* 15阶物理弹簧回弹 */
/* 13. 字体栈与 CJK 真实回退链 */
@@ -271,12 +292,29 @@
--cds-clay: #d97757;
--cds-clay-hover: #e08264;
/* 强调色链在暗色下不变 (REA 核实: --cds-blue-* 全库仅单值,无 dark 变体;
与 --cds-clay 同属品牌色,此处显式重复以对齐既有约定)。 */
--cds-blue-450: #2a78d6;
--cds-role-accent-450: var(--cds-blue-450);
--cds-role-accent-fill: var(--cds-role-accent-450);
--cds-fill-accent: var(--cds-role-accent-fill);
--cds-alpha-0: hsla(0, 0%, 100%, 0);
--cds-alpha-1: hsla(0, 0%, 100%, 0.05);
--cds-alpha-2: hsla(0, 0%, 100%, 0.09);
--cds-alpha-3: hsla(0, 0%, 100%, 0.18);
--cds-alpha-4: hsla(0, 0%, 100%, 0.30);
/* 会话气泡背景在暗色下的取值。
--cds-bg-user-message 在 :root 处已绑定 var(--cds-bg-neutral),该绑定在
:root 层完成替换;仅改 --cds-alpha-* 不会回溯更新气泡颜色,故此处显式重声明
整条链。客户端对应实现为 hsl(from --cds-neutral-900 ... ) 形式,
其中 --cds-neutral-900 在暗色下取 gray-900(#0b0b0b) → 白 5%。 */
--cds-bg-neutral: var(--cds-alpha-1);
--cds-bg-neutral-hover: var(--cds-alpha-2);
--cds-bg-user-message: var(--cds-bg-neutral);
--cds-border: var(--cds-alpha-2);
--cds-border-strong: var(--cds-alpha-3);
@@ -631,21 +669,6 @@
/* 1. CSS Houdini 自定义变量注册 (CSS Properties & Values API) */
@property --cds-anim { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-anim2 { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-size { syntax: "<length>"; inherits: false; initial-value: 28px; }
@property --cds-scroll-fade-top { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-bottom { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-left { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-right { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-top-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-bottom-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-left-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-right-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-y-active { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-x-active { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-top-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-bottom-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-left-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-right-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --sheet-card-sweep { syntax: "<angle>"; inherits: false; initial-value: 90deg; }
@property --sheet-card-lit { syntax: "<number>"; inherits: false; initial-value: 0.55; }
@property --sheet-card-light-angle { syntax: "<angle>"; inherits: false; initial-value: 165deg; }
@@ -814,118 +837,148 @@
}
/* ==========================================================================
7. Chromium 115+ / Electron 44+ 纯 CSS @property 滚动插值引擎 (Zero-JS Scroll-Fade Engine)
基于 CSS @property 与 animation-timeline: scroll(self block / inline)
7. 滚动边缘渐隐引擎 (Scroll-Edge Fade)
---------------------------------------------------------------------------
REA 取证自 Claude 2.42304.2 `assets/v1/c6a992d55-sXiDGMFQ.css`。
客户端设计要点(与简化版的关键差异):
1. 不使用 `animation-range` 硬编码 48px;由 `animation-timeline: scroll(self)`
驱动 0→100% 全程插值,实际可见宽度由 `--cds-scroll-fade-size` 决定。
2. `*-raw` 只承载「几何进度」(0 → size),不承载「是否启用」;
是否启用由 `*-on`(0/1)与 `*-active`(0/1)相乘控制。三者分离,
因此可以在触顶/触底时把 `-on` 归零而不打断 raw 的插值连续性。
3. `--cds-scroll-fade-{top,bottom,left,right}` 是派生量:
raw × active × on,供 mask 引用。
4. 全部 `@property` 均声明 `inherits: false`,初始值即兜底
——这是它们不出现在 :root 的原因,而非遗漏。
========================================================================== */
@property --cds-scroll-fade-top {
syntax: '<length>';
inherits: false;
initial-value: 0px;
@property --cds-scroll-fade-size { syntax: "<length>"; inherits: false; initial-value: 28px; }
@property --cds-scroll-fade-top-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-bottom-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-left-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-right-raw { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-top-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-bottom-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-left-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-right-on { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --cds-scroll-fade-x-active { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-y-active { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-top { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-bottom { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-left { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-right { syntax: "<length>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-strip-raw { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --cds-scroll-fade-strip-active { syntax: "<number>"; inherits: false; initial-value: 0; }
/* 只推进几何进度,不决定可见性 */
@keyframes cds-scroll-fade-top { 0% { --cds-scroll-fade-top-raw: 0px; } to { --cds-scroll-fade-top-raw: var(--cds-scroll-fade-size); } }
@keyframes cds-scroll-fade-bottom { 0% { --cds-scroll-fade-bottom-raw: var(--cds-scroll-fade-size); } to { --cds-scroll-fade-bottom-raw: 0px; } }
@keyframes cds-scroll-fade-left { 0% { --cds-scroll-fade-left-raw: 0px; } to { --cds-scroll-fade-left-raw: var(--cds-scroll-fade-size); } }
@keyframes cds-scroll-fade-right { 0% { --cds-scroll-fade-right-raw: var(--cds-scroll-fade-size); } to { --cds-scroll-fade-right-raw: 0px; } }
/* 仅用于把 *-active 抬到 1,本身不产生视觉变化 */
@keyframes cds-scroll-fade-y-active { 0%, to { --cds-scroll-fade-y-active: 1; } }
@keyframes cds-scroll-fade-x-active { 0%, to { --cds-scroll-fade-x-active: 1; } }
/* 无滚动时间线支持时的静态兜底:保留 scroll-padding,不做遮罩 */
.scroll-fade-y {
scroll-padding-block:
calc(var(--cds-scroll-fade-size) * var(--cds-scroll-fade-top-on))
calc(var(--cds-scroll-fade-size) * var(--cds-scroll-fade-bottom-on));
}
@property --cds-scroll-fade-bottom {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@property --cds-scroll-fade-left {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@property --cds-scroll-fade-right {
syntax: '<length>';
inherits: false;
initial-value: 0px;
.scroll-fade-x {
scroll-padding-inline:
calc(var(--cds-scroll-fade-size) * var(--cds-scroll-fade-left-on))
calc(var(--cds-scroll-fade-size) * var(--cds-scroll-fade-right-on));
}
@keyframes cds-scroll-fade-top-in {
from { --cds-scroll-fade-top: 0px; }
to { --cds-scroll-fade-top: var(--cds-scroll-fade-size, 28px); }
}
@keyframes cds-scroll-fade-bottom-out {
from { --cds-scroll-fade-bottom: var(--cds-scroll-fade-size, 28px); }
to { --cds-scroll-fade-bottom: 0px; }
}
@keyframes cds-scroll-fade-left-in {
from { --cds-scroll-fade-left: 0px; }
to { --cds-scroll-fade-left: var(--cds-scroll-fade-size, 28px); }
}
@keyframes cds-scroll-fade-right-out {
from { --cds-scroll-fade-right: var(--cds-scroll-fade-size, 28px); }
to { --cds-scroll-fade-right: 0px; }
}
[data-cds-scroll-fade],
.cds-scroll-fade {
--cds-scroll-fade-size: 28px;
overflow: auto;
position: relative;
}
@supports (animation-timeline: scroll(self block)) {
[data-cds-scroll-fade="vertical"],
[data-cds-scroll-fade="both"],
.cds-scroll-fade,
.cds-scroll-fade-vertical,
.cds-scroll-fade-v {
animation-timeline: scroll(self block), scroll(self block);
animation-name: cds-scroll-fade-top-in, cds-scroll-fade-bottom-out;
animation-range: 0 48px, calc(100% - 48px) 100%;
animation-fill-mode: both;
@supports (animation-timeline: scroll()) {
.scroll-fade-y {
--cds-scroll-fade-top: calc(var(--cds-scroll-fade-top-raw) * var(--cds-scroll-fade-y-active) * var(--cds-scroll-fade-top-on));
--cds-scroll-fade-bottom: calc(var(--cds-scroll-fade-bottom-raw) * var(--cds-scroll-fade-y-active) * var(--cds-scroll-fade-bottom-on));
animation-name: cds-scroll-fade-top, cds-scroll-fade-bottom, cds-scroll-fade-y-active;
animation-duration: auto;
animation-timing-function: linear;
mask-image: linear-gradient(to bottom,
transparent,
rgba(0, 0, 0, 0.05) calc(var(--cds-scroll-fade-top) * 0.15),
rgba(0, 0, 0, 0.15) calc(var(--cds-scroll-fade-top) * 0.3),
rgba(0, 0, 0, 0.30) calc(var(--cds-scroll-fade-top) * 0.45),
rgba(0, 0, 0, 0.50) calc(var(--cds-scroll-fade-top) * 0.6),
rgba(0, 0, 0, 0.70) calc(var(--cds-scroll-fade-top) * 0.75),
rgba(0, 0, 0, 0.85) calc(var(--cds-scroll-fade-top) * 0.87),
#000 var(--cds-scroll-fade-top),
#000 calc(100% - var(--cds-scroll-fade-bottom)),
rgba(0, 0, 0, 0.85) calc(100% - var(--cds-scroll-fade-bottom) * 0.87),
rgba(0, 0, 0, 0.70) calc(100% - var(--cds-scroll-fade-bottom) * 0.75),
rgba(0, 0, 0, 0.50) calc(100% - var(--cds-scroll-fade-bottom) * 0.6),
rgba(0, 0, 0, 0.30) calc(100% - var(--cds-scroll-fade-bottom) * 0.45),
rgba(0, 0, 0, 0.15) calc(100% - var(--cds-scroll-fade-bottom) * 0.3),
rgba(0, 0, 0, 0.05) calc(100% - var(--cds-scroll-fade-bottom) * 0.15),
transparent);
animation-fill-mode: both, both, none;
animation-timeline: scroll(self);
/* 15 段遮罩:顶部按 0.15/0.30/0.45/0.60/0.75/0.87 递减,底部对称 */
-webkit-mask-image: linear-gradient(to bottom,
transparent,
rgba(0, 0, 0, 0.05) calc(var(--cds-scroll-fade-top) * 0.15),
rgba(0, 0, 0, 0.15) calc(var(--cds-scroll-fade-top) * 0.3),
rgba(0, 0, 0, 0.30) calc(var(--cds-scroll-fade-top) * 0.45),
rgba(0, 0, 0, 0.50) calc(var(--cds-scroll-fade-top) * 0.6),
rgba(0, 0, 0, 0.70) calc(var(--cds-scroll-fade-top) * 0.75),
rgba(0, 0, 0, 0.85) calc(var(--cds-scroll-fade-top) * 0.87),
#0000000d calc(var(--cds-scroll-fade-top) * .15),
#00000026 calc(var(--cds-scroll-fade-top) * .3),
#0000004d calc(var(--cds-scroll-fade-top) * .45),
#00000080 calc(var(--cds-scroll-fade-top) * .6),
#000000b3 calc(var(--cds-scroll-fade-top) * .75),
#000000d9 calc(var(--cds-scroll-fade-top) * .87),
#000 var(--cds-scroll-fade-top),
#000 calc(100% - var(--cds-scroll-fade-bottom)),
rgba(0, 0, 0, 0.85) calc(100% - var(--cds-scroll-fade-bottom) * 0.87),
rgba(0, 0, 0, 0.70) calc(100% - var(--cds-scroll-fade-bottom) * 0.75),
rgba(0, 0, 0, 0.50) calc(100% - var(--cds-scroll-fade-bottom) * 0.6),
rgba(0, 0, 0, 0.30) calc(100% - var(--cds-scroll-fade-bottom) * 0.45),
rgba(0, 0, 0, 0.15) calc(100% - var(--cds-scroll-fade-bottom) * 0.3),
rgba(0, 0, 0, 0.05) calc(100% - var(--cds-scroll-fade-bottom) * 0.15),
#000000d9 calc(100% - var(--cds-scroll-fade-bottom) * .87),
#000000b3 calc(100% - var(--cds-scroll-fade-bottom) * .75),
#00000080 calc(100% - var(--cds-scroll-fade-bottom) * .6),
#0000004d calc(100% - var(--cds-scroll-fade-bottom) * .45),
#00000026 calc(100% - var(--cds-scroll-fade-bottom) * .3),
#0000000d calc(100% - var(--cds-scroll-fade-bottom) * .15),
transparent);
mask-image: linear-gradient(to bottom,
transparent,
#0000000d calc(var(--cds-scroll-fade-top) * .15),
#00000026 calc(var(--cds-scroll-fade-top) * .3),
#0000004d calc(var(--cds-scroll-fade-top) * .45),
#00000080 calc(var(--cds-scroll-fade-top) * .6),
#000000b3 calc(var(--cds-scroll-fade-top) * .75),
#000000d9 calc(var(--cds-scroll-fade-top) * .87),
#000 var(--cds-scroll-fade-top),
#000 calc(100% - var(--cds-scroll-fade-bottom)),
#000000d9 calc(100% - var(--cds-scroll-fade-bottom) * .87),
#000000b3 calc(100% - var(--cds-scroll-fade-bottom) * .75),
#00000080 calc(100% - var(--cds-scroll-fade-bottom) * .6),
#0000004d calc(100% - var(--cds-scroll-fade-bottom) * .45),
#00000026 calc(100% - var(--cds-scroll-fade-bottom) * .3),
#0000000d calc(100% - var(--cds-scroll-fade-bottom) * .15),
transparent);
}
[data-cds-scroll-fade="horizontal"],
.cds-scroll-fade-horizontal,
.cds-scroll-fade-h {
animation-timeline: scroll(self inline), scroll(self inline);
animation-name: cds-scroll-fade-left-in, cds-scroll-fade-right-out;
animation-range: 0 48px, calc(100% - 48px) 100%;
animation-fill-mode: both;
.scroll-fade-x {
--cds-scroll-fade-left: calc(var(--cds-scroll-fade-left-raw) * var(--cds-scroll-fade-x-active) * var(--cds-scroll-fade-left-on));
--cds-scroll-fade-right: calc(var(--cds-scroll-fade-right-raw) * var(--cds-scroll-fade-x-active) * var(--cds-scroll-fade-right-on));
animation-name: cds-scroll-fade-left, cds-scroll-fade-right, cds-scroll-fade-x-active;
animation-duration: auto;
animation-timing-function: linear;
mask-image: linear-gradient(to right,
transparent,
#000 var(--cds-scroll-fade-left),
#000 calc(100% - var(--cds-scroll-fade-right)),
transparent);
animation-fill-mode: both, both, none;
animation-timeline: scroll(self inline);
-webkit-mask-image: linear-gradient(to right,
transparent,
#0000000d calc(var(--cds-scroll-fade-left) * .15),
#00000026 calc(var(--cds-scroll-fade-left) * .3),
#0000004d calc(var(--cds-scroll-fade-left) * .45),
#00000080 calc(var(--cds-scroll-fade-left) * .6),
#000000b3 calc(var(--cds-scroll-fade-left) * .75),
#000000d9 calc(var(--cds-scroll-fade-left) * .87),
#000 var(--cds-scroll-fade-left),
#000 calc(100% - var(--cds-scroll-fade-right)),
#000000d9 calc(100% - var(--cds-scroll-fade-right) * .87),
#000000b3 calc(100% - var(--cds-scroll-fade-right) * .75),
#00000080 calc(100% - var(--cds-scroll-fade-right) * .6),
#0000004d calc(100% - var(--cds-scroll-fade-right) * .45),
#00000026 calc(100% - var(--cds-scroll-fade-right) * .3),
#0000000d calc(100% - var(--cds-scroll-fade-right) * .15),
transparent);
mask-image: linear-gradient(to right,
transparent,
#0000000d calc(var(--cds-scroll-fade-left) * .15),
#00000026 calc(var(--cds-scroll-fade-left) * .3),
#0000004d calc(var(--cds-scroll-fade-left) * .45),
#00000080 calc(var(--cds-scroll-fade-left) * .6),
#000000b3 calc(var(--cds-scroll-fade-left) * .75),
#000000d9 calc(var(--cds-scroll-fade-left) * .87),
#000 var(--cds-scroll-fade-left),
#000 calc(100% - var(--cds-scroll-fade-right)),
#000000d9 calc(100% - var(--cds-scroll-fade-right) * .87),
#000000b3 calc(100% - var(--cds-scroll-fade-right) * .75),
#00000080 calc(100% - var(--cds-scroll-fade-right) * .6),
#0000004d calc(100% - var(--cds-scroll-fade-right) * .45),
#00000026 calc(100% - var(--cds-scroll-fade-right) * .3),
#0000000d calc(100% - var(--cds-scroll-fade-right) * .15),
transparent);
}
}
+49
View File
@@ -74,3 +74,52 @@ author: "REA 逆向工程实验室"
```
---
### 4.4 Framer Motion 物理弹簧参数矩阵
上一节的时间 Token 描述的是**缓动时长**;本节记录的是**二阶阻尼振子**参数。两者不可互换:前者是固定的时间曲线,后者由 `stiffness` / `damping` / `mass` 共同决定超调量与收敛过程。
> **取证边界**:本表由 `ion-dist/assets/v1/` 打包产物静态提取,验证目标为 `/Applications/Claude.app` **2.42304.2**。`react` 侧 Framer Motion 的默认值是 `{stiffness:100, damping:10, mass:1}`,`mass` 省略即按 `1` 计算。阻尼比与超调量按 `ζ = c / (2√(mk))`、`Mp = exp(−πζ/√(1−ζ²))` 由常量复算得出,不是照抄描述文字。
#### 1. 四个命名预设之外的完整清单
客户端打包产物中共有 **14 组** distinct spring 配置。下表列出其中的可复用项及其真实调用点:
| stiffness / damping / mass | ζ | 超调量 | 客户端真实调用点 | 源文件 |
| :--- | :--- | :--- | :--- | :--- |
| `600` / `30` / `1` | `0.612` | `+8.8%` | 麦克风按钮 `scale`+`opacity` 出入场、语音条 `width 0→32` 展开、按钮 padding 形变、图形 `popLayout` 切换 | `shared-8-PHXy9B6B.js` |
| `320` / `28` / `1` | `0.783` | `+1.9%` | 拖拽松手回弹:成对 `animate(dx|dy, target, {...preset, velocity})` 按松手速度落位。**不是入场动画**;带 `prefers-reduced-motion` 直接跳位分支 | `c427176b2-DuM09fKk.js` |
| `1200` / `80` / `0.1` | `3.652` | `0%` | `file_viewer` 面板宽度动画:展开 `animate(width, 384)`,关闭回到计算初值。同模块 `kt=320` 最小宽、`At=480` 默认宽 | `c8ad300b7-07P9B-v4.js` |
| `420` / `28` / `1` | `0.683` | `+5.3%` | 可拖拽截图浮层,`scale .25→1` 入场,配 `dragElastic:.05` | `c0447db9a-KKfDTbDr.js` |
| `260` / `16` / `1` | `0.496` | `+16.6%` | 截图选区手柄旋转 `rotate→-90°`,退出 `scale .6` | `c0447db9a-KKfDTbDr.js` |
| `300` / `35` / `1` | `1.01` | `0%` | 列表逐项入场 `translateY 32→0`,`staggerChildren:.15`,透明度另用 `easeOut` | `ccd647019-DrnyijvI.js` |
另有 `320` / `30`(`delay:1`,credits 控制行)与 `500` / `30` / `0.7` 属于内联时序变体,未提炼为命名预设。
#### 2. 六阶 `linear()` 弹簧曲线与 `--cds-dur-slow` 的耦合
除 Framer Motion 之外,按钮按压回弹走的是纯 CSS 路径,由 `.cds-btn-squish` 承担:
```css
/* 客户端 c6a992d55-sXiDGMFQ.css 真实写法 */
.cds-btn-squish {
--cds-btn-press-sx: .975;
--cds-btn-press-sy: .975;
transform-origin: 50%;
transition: transform var(--cds-dur-slow) var(--cds-btn-spring);
}
.cds-btn-squish:active {
transform: scale(var(--cds-btn-press-sx), var(--cds-btn-press-sy));
transition: transform var(--cds-dur-fast) ease-out; /* 按下:60ms 快速塌陷 */
}
```
`--cds-btn-spring` 是一条 15 阶采样(14 段)的 `linear()` 曲线,在第 35.7% 时间点冲出至 `1.0915`(**+9.15% 物理过冲**),随后回落至 `0.9914`,并在 `1.0004` 处收敛。
**关键耦合关系**:这条 `transition` 依赖 `--cds-dur-slow` 与 `--cds-dur-fast` 两个时长 Token。二者任一缺失时,整条 `transition` 简写会被浏览器判定为无效并丢弃,计算值退化为 `transition-property: all; transition-duration: 0s`——**按钮按下会跳变,松手完全没有回弹**,而 `--cds-btn-spring` 曲线也随之失效。
这一失效已在本地复现确认:补齐 `--cds-dur-*` 五个 Token 后,`.cds-btn-squish` 的计算值从 `all 0s` 恢复为 `transform 0.45s` 加完整 `linear()` 函数。
> **实现要求**:使用 `.cds-btn-squish` 或任何引用 `var(--cds-dur-*)` 的过渡时,必须同时定义 §4.3 的五个时间 Token。仅复制 `--cds-btn-spring` 而遗漏时长 Token,会得到一条静默失效的过渡。
---
+1
View File
@@ -51,6 +51,7 @@
- 4.1 纯 CSS 动效参数 (47 项 @keyframes cds-* 完整清单)
- 4.2 原生组件命名空间 (40 大 data-cds 原生组件命名空间契约)
- 4.3 响应时间与贝塞尔曲线 (5 阶时间 Token 60ms~450ms 与 3 大精确贝塞尔控制点)
- 4.4 物理弹簧参数矩阵 (14 组 Framer Motion spring 实测调用点、`linear()` 弹簧曲线与时长 Token 的耦合失效)
- [第五章:生产级参考实现代码](05-生产级参考实现代码.md)
- 5.1 模型选择与算力调节器 (`ModelSelectorEffort.tsx`)
- 5.2 输入框底部工具栏 (`ChatComposerChin.tsx`)