Skip to content

Commit d90b89d

Browse files
committed
feat(motion): 落相变凝固签名动效
1 parent 17e96fe commit d90b89d

8 files changed

Lines changed: 719 additions & 34 deletions

File tree

‎app/src/components/Waveform.test.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* (非削波时也染状态戳色 ⇒ W3b 红)。
1414
* · W4 零颜色字面量:两件生产件剥注释后 hex 0 命中 ∧ 渲染出的每个着色面都是 `var(--ed-*)`
1515
* ⇒ 变异体 = 写 `backgroundColor: "#0d9488"`。
16+
* · (T29 追加)W5 收束:`freeze=1` ⇒ 条高**集合大小 1**、`freeze=0` ⇒ 两组;**被改的内联属性恰
17+
* `transform`**(`height` 在场但不变 ⇒ 收束没动布局属性)⇒ 变异体 = 收束只改 `opacity` / 改用 `height`。
1618
*
1719
* 副作用:无(只读磁盘 + 只挂载 jsdom 容器)。边界:jsdom **无排版引擎** ⇒ 本文件判得到
1820
* 「条数 / 次序 / 色值 / 属性」,**判不到**「波形看起来像波形」(观感面进报告 `## 诚实边界`)。
@@ -22,10 +24,12 @@ import { readFileSync } from "node:fs";
2224
import { dirname, join } from "node:path";
2325
import { fileURLToPath } from "node:url";
2426
import { afterEach, describe, expect, it } from "vitest";
27+
import { animatedProps } from "../test/motionHarness";
2528
import Waveform, {
2629
WAVEFORM_BARS,
2730
WAVEFORM_CLIP_BARS,
2831
WAVEFORM_FLOOR_DB,
32+
WAVEFORM_GROOVE_SCALE,
2933
inClipZone,
3034
litBars,
3135
} from "./Waveform";
@@ -185,3 +189,42 @@ describe("W4 · 零颜色字面量(色值只在 ui/tokens.css 与 token 生成
185189
expect(colors.length, "读到 0 个着色面 ⇒ 本条是空真").toBe(6);
186190
});
187191
});
192+
193+
/** 逐条条高(`scaleY` 是几何的唯一通道 ⇒ 也是唯一的读数口)。 */
194+
const barScales = (el: HTMLElement): number[] =>
195+
barsOf(el).map((b) => Number.parseFloat(/scaleY\(([^)]+)\)/.exec(b.style.transform)?.[1] ?? "NaN"));
196+
197+
describe("W5 · 收束成直线(T29:受控 `freeze` 0..1)", () => {
198+
it("freeze=0 ⇒ 逐序两组条高(点亮 5 条满高 / 其余底槽)· freeze=1 ⇒ 集合大小恰 1", () => {
199+
render(<Waveform rms={0.1} clipping={false} bars={8} freeze={0} />);
200+
expect(barScales(root()), "采集态的波形:点亮段与底槽段两组高度").toEqual([
201+
1, 1, 1, 1, 1, WAVEFORM_GROOVE_SCALE, WAVEFORM_GROOVE_SCALE, WAVEFORM_GROOVE_SCALE,
202+
]);
203+
cleanup();
204+
render(<Waveform rms={0.1} clipping={false} bars={8} freeze={1} />);
205+
const settled = barScales(root());
206+
expect(settled, "凝固 = 每条都落到**同一**高度(上沿是一条水平线)").toEqual(
207+
Array.from({ length: 8 }, () => WAVEFORM_GROOVE_SCALE),
208+
);
209+
expect(new Set(settled).size, "集合大小必须是 1").toBe(1);
210+
cleanup();
211+
render(<Waveform rms={0.1} clipping={false} bars={8} />);
212+
expect(barScales(root()), "缺省 freeze=0 ⇒ 既有调用点的渲染逐字不变").toEqual([
213+
1, 1, 1, 1, 1, WAVEFORM_GROOVE_SCALE, WAVEFORM_GROOVE_SCALE, WAVEFORM_GROOVE_SCALE,
214+
]);
215+
});
216+
217+
it("收束**只改 `transform`**:freeze 0 → 0.5 的内联属性**增量** ⊆ 白名单,`height` 在场但逐字不变", () => {
218+
const inlineOf = (el: HTMLElement): Record<string, string> =>
219+
Object.fromEntries(animatedProps(el).map((p) => [p, el.style.getPropertyValue(p)]));
220+
const view = render(<Waveform rms={0.1} clipping={false} bars={8} freeze={0} />);
221+
const before = barsOf(root()).map(inlineOf);
222+
view.rerender(<Waveform rms={0.1} clipping={false} bars={8} freeze={0.5} />);
223+
const after = barsOf(root()).map(inlineOf);
224+
const changed = [...new Set(after.flatMap((props, i) => Object.keys(props).filter((p) => props[p] !== before[i][p])))];
225+
expect(changed, "收束改了 `height`(或别的非合成属性)⇒ 会触发排版").toEqual(["transform"]);
226+
expect(Object.keys(after[0]), "`height` 是静态几何基准(在场但不在增量里)").toContain("height");
227+
expect(barScales(root())[0], "中途值落在两端之间(不是瞬切)").toBeGreaterThan(WAVEFORM_GROOVE_SCALE);
228+
expect(barScales(root())[0]).toBeLessThan(1);
229+
});
230+
});

‎app/src/components/Waveform.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
* 计算);③ 颜色**零字面量**,一律 `var(--ed-*)`(与 `views/session/SessionCardFlowView.tsx` 同一
1818
* 纪律:色值只在 `ui/tokens.css` 与生成器里);④ 动效接缝(ADR-033 §4 允许的用途)只声明
1919
* `background` 的 micro 档 token 过渡 —— **几何不做 transition**(§8.4「宽度本身不做 transition」)。
20+
* @ai-context T29 追加(规格 §8.6 第 3 行「波形收束成直线」):受控 prop `freeze`(0..1)+ 唯一映射
21+
* `barScale()` —— **几何只经 `scaleY` 表达**(静态 `height: 100%` 只是几何基准,不入动画属性集合)。
22+
* 本件**不自建时间线**:编排层(`shell/usePhaseFreeze.ts`,唯一 `startControllable` 调用点)把持有
23+
* 的 `freeze` 传进来。缺省 `freeze = 0` ⇒ 既有调用点的渲染逐字不变(T18 的六条判据零改动)。
2024
*/
2125
import type { CSSProperties, ReactElement } from "react";
2226

@@ -42,6 +46,14 @@ export interface WaveformProps {
4246
readonly bars?: number;
4347
readonly height?: number;
4448
readonly testId?: string;
49+
/**
50+
* 相变**凝固进度**(批 6 T29 · 规格 §8.6 第 3 行「波形收束成直线」):`0` = 采集态的活波形
51+
* (点亮条满高、未点亮条留底槽 ⇒ 两组高度 ⇒ 有波形),`1` = **所有条同一高度**(= 底槽档 ⇒ 上沿
52+
* 是一条水平线)。缺省 `0` ⇒ 既有调用点的渲染与 T18 落库时**逐字相同**。
53+
* 🔴 只动 `transform` 的 `scaleY`(+ 静态 `height: 100%` 作几何基准)—— **不许动 `height` 的值**
54+
* (R8.4 的属性集合审计 / §8.4「不 animate height」)。
55+
*/
56+
readonly freeze?: number;
4557
}
4658

4759
/**
@@ -60,6 +72,25 @@ export function inClipZone(index: number, bars: number): boolean {
6072
return index >= bars - WAVEFORM_CLIP_BARS;
6173
}
6274

75+
/**
76+
* 底槽档:未点亮条的静态高度比(相对满高)。**收束的终态与它共用同一个数**(收束 = 一律落到这个
77+
* 高度 ⇒ 上沿成一条水平线),所以「波形收束」只引入**一个**几何新数字(R27.1:数字只有一个落点)。
78+
*/
79+
export const WAVEFORM_GROOVE_SCALE = 0.28;
80+
81+
/**
82+
* 条高(`scaleY`)的**唯一映射**(T29):
83+
* · `freeze = 0` ⇒ 点亮条 `1` / 未点亮条 `WAVEFORM_GROOVE_SCALE`(两个不同值 ⇒ 波形有起伏);
84+
* · `freeze ∈ (0,1)` ⇒ 各自向终态线性收拢(对 `freeze` 单调);
85+
* · `freeze = 1` ⇒ **一律** `WAVEFORM_GROOVE_SCALE`(集合大小 1 ⇒ 收束成直线)。
86+
* 全定义域安全:`freeze` 非有限值按 `0`(永不抛、永不把 NaN 漏进 `style`)。
87+
*/
88+
export function barScale(lit: boolean, freeze: number): number {
89+
const base = lit ? 1 : WAVEFORM_GROOVE_SCALE;
90+
const f = Number.isFinite(freeze) ? Math.min(1, Math.max(0, freeze)) : 0;
91+
return base + (WAVEFORM_GROOVE_SCALE - base) * f;
92+
}
93+
6394
/** 条的公共几何 + 动效接缝(色值逐条不同,故在渲染处合并)。 */
6495
const BAR_STYLE: CSSProperties = {
6596
flex: 1,
@@ -80,6 +111,7 @@ export default function Waveform({
80111
bars = WAVEFORM_BARS,
81112
height = WAVEFORM_HEIGHT,
82113
testId = "waveform",
114+
freeze = 0,
83115
}: WaveformProps): ReactElement {
84116
// 条数先归一到非负整数:`Array.from({length:-1})` 本就是空数组,但显式归零让 `data-bars`
85117
// 与真实条数永远一致(负值 / 小数不会漏进属性)。
@@ -108,6 +140,11 @@ export default function Waveform({
108140
data-clip={hot ? "true" : "false"}
109141
style={{
110142
...BAR_STYLE,
143+
// 几何基准(静态,不是被动画的属性):满高由 `height` prop 给定的根盒子高度决定,
144+
// 条高本身一律交给 `scaleY` 表达 ⇒ 相变收束**不动** `height`(R8.4 / §8.4)。
145+
height: "100%",
146+
transformOrigin: "bottom",
147+
transform: `scaleY(${barScale(on, freeze)})`,
111148
// 用**长写** `backgroundColor` 而不是 `background` 简写:jsdom 30 的 CSSStyleDeclaration
112149
// 会把简写里的 `var(...)` 整条丢掉(实测 `style.background === ""`),长写则逐字保真
113150
// ⇒ 判据读得到真实产出的色值(本批「判据读不到产出 = 假绿」的家族)。

‎app/src/shell/LiveBar.tsx‎

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
* 棘轮因**新文件**当场红;且播放/暂停控制在 WAI-ARIA 的惯用做法本就是**换可访问名**而非按下态
2626
* (按下态适用于静音一类**模式**开关)。琥珀徽标 = R4.5 逐字点名的「暂停徽标」,也是 T29
2727
* 「琥珀退去」要作用的那个元素。
28+
* @ai-context T29 追加(#3 相变凝固 · R5.3 / R4.5):本件挂 `usePhaseFreeze(phase)` —— 相位经
29+
* `LiveBarHost` 注入(**可选**槽,缺省 `"capture"` = 本件既有渲染,冻结进度 0)。三处新结构:
30+
* ① 受控 prop `phase`(唯一新槽;`LiveBarProps` 的既有五项一字未动);
31+
* ② **墨度层** `data-testid="live-ink"`(唯一 GSAP 目标 = 「琥珀退去」的载体;R4.5 点名的 LIVE 仪表与
32+
* 暂停徽标都在它里面 —— 徽标是条件渲染,单独作载体在未暂停时会落空);
33+
* ③ `kind="warn"`:LIVE 仪表本身走 `--ed-due` 族(R4.5 逐字「采集态 LIVE 仪表/暂停徽标的 `--ed-due`
34+
* 族元素」)—— 退去只改**墨度**、不改色值。⚠️ 缺省相位下 hook **不建时间线**(相位未变)⇒ 本件
35+
* 既有六条判据零改动。
2836
*/
2937
import { useEffect, useState } from "react";
3038
import type { ReactElement } from "react";
@@ -33,6 +41,8 @@ import { Button, StatusLine, Text } from "../ui/primitives";
3341
import { Icon } from "../ui/icons";
3442
import { fmtMs } from "../utils/fmt";
3543
import Waveform from "../components/Waveform";
44+
import { usePhaseFreeze } from "./usePhaseFreeze";
45+
import type { ShellPhase } from "./shellPhase";
3646

3747
/**
3848
* 采集态顶栏高度:**消费 T17 落真的新 token**(R4.1)。兜底 58px 与真源同值
@@ -57,6 +67,8 @@ export interface LiveBarProps {
5767
readonly onTogglePause: () => void;
5868
readonly onMark: () => void;
5969
readonly onStop: () => void;
70+
/** 当前相位(T29 追加的**可选**槽;缺省 `"capture"` = 本件既有渲染:冻结进度 0、无编排动效)。 */
71+
readonly phase?: ShellPhase;
6072
}
6173

6274
/**
@@ -71,8 +83,11 @@ export default function LiveBar({
7183
onTogglePause,
7284
onMark,
7385
onStop,
86+
phase = "capture",
7487
}: LiveBarProps): ReactElement {
7588
const [level, setLevel] = useState<AudioLevelPayload>({ rms: 0, clipping: false });
89+
// T29:相变凝固的编排(波形按 `barsFreeze` 收束成直线;墨度层由 hook 的 GSAP 目标退到常态档)
90+
const { inkRef, barsFreeze } = usePhaseFreeze(phase);
7691

7792
useEffect(() => {
7893
const unlisten = listen<AudioLevelPayload>("live:audio-level", (e) => {
@@ -100,39 +115,55 @@ export default function LiveBar({
100115
overflow: "hidden",
101116
}}
102117
>
103-
{/* 语义(`role="status"`)与基类由 `StatusLine` 原语承载;仪表本体走它的 `action` 纯插槽
104-
(该槽**不额外包 DOM、不加类** —— 见 `StatusLine.tsx` 边界④)。 */}
105-
<StatusLine
106-
kind="info"
107-
testId="live-meter"
108-
action={
109-
<>
110-
<Waveform rms={level.rms} clipping={level.clipping} testId="live-waveform" />
111-
<Text as="span" size={4} font="mono" tone="ink-2" testId="live-elapsed">
112-
{fmtMs(elapsedMs)}
113-
</Text>
114-
{paused ? (
115-
<Text as="span" size={5} tone="due" testId="live-paused-badge">
116-
已暂停
117-
</Text>
118-
) : null}
119-
<Button variant="secondary" size="sm" testId="live-pause" onClick={onTogglePause}
120-
icon={<Icon name={paused ? "play" : "pause"} size={16} />}>
121-
{paused ? LIVE_PAUSE_LABELS.paused : LIVE_PAUSE_LABELS.running}
122-
</Button>
123-
<Button variant="ghost" size="sm" testId="live-mark" onClick={onMark}
124-
icon={<Icon name="check" size={16} />}>
125-
标记
126-
</Button>
127-
<Button variant="ghost" size="sm" testId="live-stop" onClick={onStop}
128-
icon={<Icon name="stop" size={16} />}>
129-
停止
130-
</Button>
131-
</>
132-
}
118+
{/* 墨度层(T29「琥珀退去」的载体 · 唯一 GSAP 目标):**新建**的 div,**不声明任何 transition**
119+
(类规则或行内 transition 都会与逐帧写入打架:GSAP 每帧写、CSS 再插值 ⇒ 值永远追不上)。
120+
它的 `opacity` 由 `usePhaseFreeze` 从 1 退到常态档;采集态(freeze 0)恒为 1 ⇒ 零观感变化。 */}
121+
<div
122+
ref={inkRef}
123+
data-testid="live-ink"
124+
style={{ flex: "1 1 auto", minWidth: 0, display: "block" }}
133125
>
134-
LIVE
135-
</StatusLine>
126+
{/* 语义(`role="status"`)与基类由 `StatusLine` 原语承载;仪表本体走它的 `action` 纯插槽
127+
(该槽**不额外包 DOM、不加类** —— 见 `StatusLine.tsx` 边界④)。
128+
`kind="warn"` = R4.5 逐字「采集态 LIVE 仪表…的 `--ed-due` 族」:琥珀是**采集态的身份墨**,
129+
退去只退墨度(色值仍逐字是 `--ed-due`,见 `motion.css` 的相位块侧锁)。 */}
130+
<StatusLine
131+
kind="warn"
132+
testId="live-meter"
133+
action={
134+
<>
135+
<Waveform
136+
rms={level.rms}
137+
clipping={level.clipping}
138+
testId="live-waveform"
139+
freeze={barsFreeze}
140+
/>
141+
<Text as="span" size={4} font="mono" tone="ink-2" testId="live-elapsed">
142+
{fmtMs(elapsedMs)}
143+
</Text>
144+
{paused ? (
145+
<Text as="span" size={5} tone="due" testId="live-paused-badge">
146+
已暂停
147+
</Text>
148+
) : null}
149+
<Button variant="secondary" size="sm" testId="live-pause" onClick={onTogglePause}
150+
icon={<Icon name={paused ? "play" : "pause"} size={16} />}>
151+
{paused ? LIVE_PAUSE_LABELS.paused : LIVE_PAUSE_LABELS.running}
152+
</Button>
153+
<Button variant="ghost" size="sm" testId="live-mark" onClick={onMark}
154+
icon={<Icon name="check" size={16} />}>
155+
标记
156+
</Button>
157+
<Button variant="ghost" size="sm" testId="live-stop" onClick={onStop}
158+
icon={<Icon name="stop" size={16} />}>
159+
停止
160+
</Button>
161+
</>
162+
}
163+
>
164+
LIVE
165+
</StatusLine>
166+
</div>
136167
</div>
137168
);
138169
}

‎app/src/shell/PhaseChrome.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default function PhaseChrome({ phase, children }: PhaseChromeProps): Reac
5656
</div>
5757
{/* 采集层:**恒挂载**(两态叠在同一格才能交叉淡入;条件挂载会让淡出变成硬切) */}
5858
<div className={PHASE_LIVE_CLASS} data-testid="phase-live" inert={phase !== "capture"}>
59-
<LiveBarHost />
59+
<LiveBarHost phase={phase} />
6060
</div>
6161
</div>
6262
);
@@ -77,8 +77,11 @@ export default function PhaseChrome({ phase, children }: PhaseChromeProps): Reac
7777
* —— 与 `LiveActivityPanel.tsx:130/154/161` 的既有范式同源。
7878
* ⚠️ 登记:`live_session_status` 的载荷里**没有** `started_at`(`types/live.ts:36-48` 实测)⇒ 前端
7979
* 只能用「自己第一次看到 active 的时刻」,与后端会话起点的毫秒级误差属后端事件面(本任务不改后端)。
80+
* · `phase`(T29 追加的**可选**槽)= 本宿主从 `PhaseChrome` 拿到的同一相位值 ⇒ 往下传给 `LiveBar` 的
81+
* 相变凝固编排(`usePhaseFreeze`)。缺省 `"capture"`:`LiveBarHost` 单测(T19 的 C4–C6)不传它 ⇒
82+
* 冻结进度 0、**不建任何时间线**(相位未变),既有判据零改动。
8083
*/
81-
export function LiveBarHost(): ReactElement {
84+
export function LiveBarHost({ phase = "capture" }: { readonly phase?: ShellPhase }): ReactElement {
8285
const capture = useCaptureControl();
8386
const active = capture.active;
8487
const paused = capture.pausedReason !== null;
@@ -99,6 +102,7 @@ export function LiveBarHost(): ReactElement {
99102
<LiveBar
100103
elapsedMs={elapsedMs}
101104
paused={paused}
105+
phase={phase}
102106
onTogglePause={() => {
103107
void (paused ? capture.resume() : capture.pause());
104108
}}

0 commit comments

Comments
 (0)