Skip to content

Commit c0ce993

Browse files
committed
feat(motion): 落三轨对齐签名动效
1 parent 3277eba commit c0ce993

5 files changed

Lines changed: 697 additions & 11 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionTriTrackView.align.test.tsx — T27 在**视图层**的集成判据(#1「对齐」的接线:共轴高亮 +
4+
* 落点登记 + 两条非参考轨的位移真的被写)。
5+
*
6+
* @ai-context 与另外两件的分工:`SessionTriTrackView.test.tsx` 的 5 条(T1–T5,批 5 交付)与
7+
* `SessionTriTrackView.rail.test.tsx`(T25)**逐字不动**;本件只加 T27 新增的面。⚠️ 本件落在
8+
* **新文件**而不是往那 239 行里追加:既有文件只剩 61 行余量(R8.8「贴边文件动手前先量」),
9+
* 而本件要断的面(逐序高亮集合 + 落点登记 + 属性集合审计)不是几十行能写完的 —— **零余量**的
10+
* 文件不该再动(先例:T25 也是新开 `…rail.test.tsx` 而非改既有件)。
11+
* @ai-context 生产侧时间线**不 paused**(ticker 驱动)⇒ 本件只断**方向 / 范围 / 结构**,逐端点的
12+
* 精确读数归 `useTriTrackAlign.test.tsx`(那是 `paused: true` + `freezeAt` 的确定性面,同
13+
* `phaseFreeze.seams.test.tsx` 与 `usePhaseFreeze.test.tsx` 的分工)。
14+
* @ai-context **不 mock Tauri**:本件不取数、不真播放 ⇒ 没有 `@tauri-apps` 的模块边界可 mock。
15+
* 副作用:挂 React 树 + 建 GSAP 时间线(`afterEach` 收尸)+ 只读同目录 `TriTrackAlign.css` 的文本。
16+
* 边界:**jsdom 不排版** ⇒ 「三轨看起来真的对齐了吗」不可判(如实进报告 `## 诚实边界`)。
17+
*/
18+
import { readFileSync } from "node:fs";
19+
import { dirname, join } from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
22+
import { afterEach, describe, expect, it, vi } from "vitest";
23+
import { gsap } from "../../motion/engine";
24+
import { animatedProps, currentTransform, tweenCount } from "../../test/motionHarness";
25+
import { stripComments } from "../../ui/primitives/sliceScan";
26+
import type { SessionDetail } from "../../types/session";
27+
import SessionTriTrackView, { COALIGNED_CLASS, ITEM_CLASS } from "./SessionTriTrackView";
28+
import { shiftYOf } from "./useTriTrackAlign";
29+
30+
const HERE = dirname(fileURLToPath(import.meta.url));
31+
const CSS = stripComments(readFileSync(join(HERE, "TriTrackAlign.css"), "utf8"));
32+
/** 动态 import 的到达窗口(**不是**动画的等待窗口);同 T29/T25 先例放宽上限)。 */
33+
const WAIT = { timeout: 5000 } as const;
34+
/** 夹具自己写进轨容器的**布局**行内属性(= 视图的 `LANE_STYLE`)。 */
35+
const LAYOUT_PROPS = ["display", "flex-direction", "gap"];
36+
/** 共轴组:9000 时刻三条轨都有条目;另有 0 / 500 两条**不在组里** ⇒ 高亮的**选择性**可判。 */
37+
const MS = 9_000;
38+
39+
function detailOf(): SessionDetail {
40+
return {
41+
session: { id: 1042, title: "构图与调色", source_window: "Chrome", started_at: 1_700_000_000_000, ended_at: 1_700_003_800_000, status: "finished", kind: null },
42+
segments: [
43+
{ id: 9001, session_id: 1042, start_ms: 0, end_ms: 2_500, text: "开场:构图三要素", source: "subtitle", confidence: 0.9 },
44+
{ id: 9003, session_id: 1042, start_ms: MS, end_ms: 12_000, text: "第二段:色轮", source: "fused", confidence: 0.8 },
45+
],
46+
screens: [
47+
{ session_id: 1042, screen_id: 1, first_seen_ms: MS, last_seen_ms: 12_000, title: "调色面板", body: ["色相环"], labels: [], image_ref: null, structure: [] },
48+
],
49+
ocr_blocks: [
50+
{ id: 5001, session_id: 1042, timestamp_ms: MS, text: "色相 / 饱和度", score: 0.93, region: "full" },
51+
{ id: 5002, session_id: 1042, timestamp_ms: 500, text: "课程封面", score: 0.81, region: "subtitle" },
52+
],
53+
};
54+
}
55+
const q = (container: HTMLElement, sel: string): HTMLElement => {
56+
const el = container.querySelector(sel);
57+
if (el === null) throw new Error(`没有渲染出 ${sel}`);
58+
return el as HTMLElement;
59+
};
60+
const laneEl = (container: HTMLElement, track: string): HTMLElement => q(container, `[data-track="${track}"]`);
61+
const yOf = (container: HTMLElement, track: string): number => shiftYOf(currentTransform(laneEl(container, track)));
62+
/** 时间码按钮(T25 起时间码是定位按钮)。 */
63+
const codeAt = (container: HTMLElement, ms: number): HTMLElement => q(container, `[data-track="transcript"] [data-ms="${ms}"] button`);
64+
/** 带共轴高亮类的条目,按 **DOM 序**(= 转写 → 画面 → OCR,T2 的既有判据)写成 `轨@ms`。 */
65+
const highlighted = (container: HTMLElement): string[] =>
66+
[...container.querySelectorAll(`.${COALIGNED_CLASS}`)].map((el) => {
67+
const lane = el.closest("[data-track]") as HTMLElement | null;
68+
return `${lane?.dataset.track ?? "?"}@${(el as HTMLElement).dataset.ms ?? "?"}`;
69+
});
70+
const rails = () => gsap.globalTimeline.getChildren(false, true, true).length;
71+
72+
afterEach(() => {
73+
cleanup();
74+
for (const el of [...document.body.children]) {
75+
gsap.killTweensOf(el);
76+
el.remove();
77+
}
78+
});
79+
80+
describe("A1 · 共轴 + 同 ms 高亮(R5.1 的后半句)", () => {
81+
it("点时间码 ⇒ **恰**三个条目(转写/画面/OCR,逐序)带高亮类;非组条目一个都不带;再点 ⇒ 清空", async () => {
82+
const onSeekMs = vi.fn();
83+
const { container } = render(<SessionTriTrackView detail={detailOf()} onSeekMs={onSeekMs} />);
84+
expect(highlighted(container), "未触发时高亮必须是**空集**(不是「全部」)").toEqual([]);
85+
86+
fireEvent.click(codeAt(container, MS));
87+
expect(onSeekMs.mock.calls.length, "既有上行口被改动(仍须恰 1 次)").toBe(1);
88+
expect(onSeekMs.mock.calls[0][0]).toBe(MS);
89+
expect(highlighted(container), "同 ms 的三个条目高亮共轴关系(DOM 序 = 转写 → 画面 → OCR)").toEqual([
90+
"transcript@9000",
91+
"screen@9000",
92+
"ocr@9000",
93+
]);
94+
// 🔴 第三轨 = OCR(§8.6 的批 5 加注逐字)⇒ 上面那三行里必须有 `ocr@9000`;DOM 顺序另用既有判据钉住
95+
const at = (track: string): HTMLElement => q(container, `[data-track="${track}"] [data-ms="${MS}"]`);
96+
const following = (a: Element, b: Element): boolean => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
97+
expect([following(at("transcript"), at("screen")), following(at("screen"), at("ocr"))]).toEqual([true, true]);
98+
// 每个条目都带**基类**(高亮类必须是 `<基类>--<修饰>` 形状,而不是一个孤立的类名)
99+
const bare = [...container.querySelectorAll("[data-ms]")].filter((el) => !el.classList.contains(ITEM_CLASS));
100+
expect(bare.map((el) => (el as HTMLElement).dataset.ms), "有条目掉了基类").toEqual([]);
101+
102+
fireEvent.click(codeAt(container, MS)); // 反向:再次触发 ⇒ 对齐 → 错位 ⇒ 高亮清空
103+
expect(highlighted(container), "反向没有清掉共轴高亮").toEqual([]);
104+
});
105+
});
106+
107+
describe("A2 · 落点登记与位移接线(只有两条**非参考轨**被写)", () => {
108+
it("动效落点带 `data-tone=\"instrument\"`(R3.4);挂载即物化错位位形;转写轨(时间基)零位移", async () => {
109+
const { container } = render(<SessionTriTrackView detail={detailOf()} />);
110+
expect(q(container, "[data-track]").parentElement?.parentElement?.getAttribute("data-tone"), "落点没有声明基调").toBe("instrument");
111+
await waitFor(() => expect([yOf(container, "screen"), yOf(container, "ocr")]).toEqual([8, -8]), WAIT);
112+
expect(animatedProps(laneEl(container, "transcript")), "转写轨是时间基 ⇒ 不许被位移").not.toContain("transform");
113+
expect(rails(), "挂载只物化静态位形 ⇒ 零编排时间线").toBe(0);
114+
});
115+
});
116+
117+
describe("A3 · 触发真的起了编排(方向 / 范围口径)与属性集合审计(R8.4)", () => {
118+
it("点时间码 ⇒ 两条非参考轨各起 1 条时间线、位移落在 [对齐位, 错位位] 之间;越界属性恰是夹具的布局属性", async () => {
119+
const { container } = render(<SessionTriTrackView detail={detailOf()} />);
120+
await waitFor(() => expect(yOf(container, "screen")).toBe(8), WAIT);
121+
fireEvent.click(codeAt(container, MS));
122+
await waitFor(() => expect(rails(), "两条非参考轨各 1 条编排时间线").toBe(2), WAIT);
123+
expect([tweenCount(laneEl(container, "screen")), tweenCount(laneEl(container, "ocr"))]).toEqual([1, 1]);
124+
// 生产侧不 paused ⇒ 只断方向/范围(端点精确读数在 `useTriTrackAlign.test.tsx` 的 paused 面)
125+
expect([yOf(container, "screen") <= 8, yOf(container, "screen") >= 0], "位移必须落在错位位与对齐位之间").toEqual([true, true]);
126+
const props = animatedProps(laneEl(container, "screen"));
127+
expect(props, "transform 必须真被写(否则本条是空真)").toContain("transform");
128+
expect(props.filter((p) => !["transform", "translate", "rotate", "scale", "opacity", "filter"].includes(p)), "越界集合必须恰是夹具的布局属性(delta 口径,同 T33 W4)").toEqual(LAYOUT_PROPS);
129+
});
130+
});
131+
132+
describe("A4 · 高亮落点的样式接缝(TriTrackAlign.css)", () => {
133+
it("选择器 = 修饰类 · 颜色只经 `var(--ed-link)` · **零** transition/animation/@media/z-index/色值字面量", () => {
134+
expect(CSS, "修饰类没有规则 ⇒ 「高亮」是空话").toContain(`.${COALIGNED_CLASS}`);
135+
expect(COALIGNED_CLASS, "高亮类必须是 `<基类>--<修饰>` 形状").toBe(`${ITEM_CLASS}--coaligned`);
136+
expect(CSS, "高亮取 `--ed-link`(其语义含「时间码」)").toContain("var(--ed-link)");
137+
expect(CSS, "色值只许在 tokens.css 与生成器里").not.toMatch(/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/);
138+
// 零 transition/animation ⇒ 不必进 `motion.css` 的 reduced-motion 名单(R48.1 的机制缺口不适用本落点)
139+
expect(CSS, "transition-only 落点会加不进 reduced-motion 名单(R48.1)").not.toMatch(/transition|animation|@keyframes/);
140+
expect(CSS, "本层不新增媒体查询(全仓只许一条 reduced-motion 块)").not.toContain("@media");
141+
expect(CSS, "层级只在 ui/zIndex.ts").not.toContain("z-index");
142+
});
143+
});

‎app/src/views/session/SessionTriTrackView.tsx‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
* @ai-context 与「印样」视图的边界(计划 Task 8 Step 1 的逐字要求在这里登记):**本件不渲染图片**。
4444
* 配图需要容器注入的 `imageUrl()` 槽(本件不接该槽),且「一屏一段区间 + 配图」是印样视图的形态;
4545
* 三轨对齐只需要「时间 + 这一条是什么」⇒ 画面轨只出 `title`/`body` 文本与区间时间码。
46+
* @ai-context **批 6 T27:#1「对齐」(R5.1 的形态裁定 = 该动效的定义)**。三轨条目按 `data-ms` 共轴;
47+
* **非对齐轨**(画面 / OCR 两条派生轨 —— 🔴 **第三轨 = OCR**,§8.6 的批 5 加注逐字)在错位态各带
48+
* ≤8px 的位移偏移,交互(**时间码点击** = 本视图唯一的用户动作)把它们滑到对齐位(`y → 0`),
49+
* 同 ms 的三个条目**高亮共轴关系**(`ed-tritrack__item--coaligned`)。反向 = 再次触发或切走再回;
50+
* 可中断 = 新输入接管。编排、两个持有量、三档与 reduced-motion 全在 `useTriTrackAlign.ts` 的文件头。
51+
* ⚠️ **视图自身仍零副作用**:位移只由那条 hook 经出口写;本件只加两个 DOM 锚(`data-tone="instrument"`
52+
* 与两条非参考轨的 ref 登记)与一个**受控**的高亮类(`aligned && item.ms === ms`)。
4653
* 副作用:无(纯函数式渲染;无 I/O、无订阅、无定时器、无本地 state、无全局单例)。T25 增:
4754
* 子件 `TimeRail` 的一次元素级滚动写入(DOM 属性,随 `playheadMs` 变化)。
4855
* 边界:① `detail` 三个数组**都空** ⇒ 整块换成 `EmptyState`(根 `data-testid` 仍在,形态可判)
@@ -56,9 +63,10 @@ import { Button, EmptyState, Text } from "../../ui/primitives";
5663
import { fmtMs } from "../../utils/fmt";
5764
import type { SessionViewSlot } from "../registry";
5865
import TimeRail from "./TimeRail";
59-
60-
/** 三轨的轨名(DOM 锚点 `data-track` 与纵向固定序的唯一字面量来源) */
61-
type TrackKey = "transcript" | "screen" | "ocr";
66+
import "./TriTrackAlign.css";
67+
import { useTriTrackAlign } from "./useTriTrackAlign";
68+
// 轨名类型(`data-track` 锚的唯一字面量来源)由编排层持有 ⇒ 两处不各写一份字面量
69+
import type { TrackKey, TriTrackAlign } from "./useTriTrackAlign";
6270

6371
/** 视图的数据面:`detail`(三轨)+ T25 的三个**只读**注入槽(音频引用 / 播放头 / 上行通道) */
6472
type Slot = Pick<SessionViewSlot, "detail" | "audio" | "playheadMs" | "onSeekMs">;
@@ -85,6 +93,10 @@ const ITEM_STYLE: CSSProperties = { display: "flex", gap: 6, alignItems: "baseli
8593
/** 数字等宽:时间码跨行对齐靠它,不靠 `Text` 的字阶(`fontVariantNumeric` 是排版接缝不是墨度) */
8694
const TIME_STYLE: CSSProperties = { fontVariantNumeric: "tabular-nums", flexShrink: 0 };
8795

96+
/** 条目基类与共轴高亮的修饰类(`<基类>--<修饰>` 形状;规则在 `TriTrackAlign.css`,基类故意无规则)。 */
97+
export const ITEM_CLASS = "ed-tritrack__item";
98+
export const COALIGNED_CLASS = `${ITEM_CLASS}--coaligned`;
99+
88100
/**
89101
* 时间码**唯一出口**:一律经 `fmtMs`(`utils/fmt.ts`)。
90102
* Why 单独一个函数而不是就地写 `fmtMs(...)`:判据 T5 钉的是「条目上的时间码与 `fmtMs` 逐字一致」
@@ -192,9 +204,9 @@ export function totalMsOf(detail: Detail, audio: Slot["audio"]): number {
192204
}
193205

194206
/** 一条对齐条目(纯展示:时间码 → 次要信息 → 区间 → 正文;时间码是**定位按钮**) */
195-
function TriTrackItem({ item, onSeek }: { readonly item: AlignedItem; readonly onSeek: (ms: number) => void }): ReactElement {
207+
function TriTrackItem({ item, onSeek, coaligned }: { readonly item: AlignedItem; readonly onSeek: (ms: number) => void; readonly coaligned: boolean }): ReactElement {
196208
return (
197-
<div style={ITEM_STYLE} data-ms={item.ms}>
209+
<div style={ITEM_STYLE} data-ms={item.ms} className={coaligned ? `${ITEM_CLASS} ${COALIGNED_CLASS}` : ITEM_CLASS}>
198210
{/* 时间码 = 唯一的「回跳」入口:点它 ⇒ 播放头滑到该段(R5.5 #5 的可见动作)。
199211
用 `Button` 原语而不是可点 `<div>`:键盘可达与四态由原语保证(§8.6.1 第 4 条)。
200212
首个文本子节点仍是 `fmtMs` 的输出 ⇒ T5 的时间码判据形态不变。 */}
@@ -220,13 +232,21 @@ function TriTrackItem({ item, onSeek }: { readonly item: AlignedItem; readonly o
220232
);
221233
}
222234

235+
/** 对齐动效的落点登记:只有**非参考轨**需要(转写轨是时间基 ⇒ 不施加偏移,`ref` 为 `undefined`)。 */
236+
function attachOf(align: TriTrackAlign, track: TrackKey): ((el: HTMLElement | null) => void) | undefined {
237+
return track === "screen" || track === "ocr" ? align.attach[track] : undefined;
238+
}
239+
223240
/**
224-
* 渲染会话三轨对齐视图 + 时间轨(T25)。数据全来自 props(`detail` + 三个注入槽)
241+
* 渲染会话三轨对齐视图 + 时间轨(T25)+#1 对齐动效(T27)。数据全来自 props(`detail` + 三个注入槽)
225242
* ⇒ 本件不取数、**不自持播放头状态**(受控:点击只上报,位置随 `playheadMs` 回来)。
226243
*/
227244
export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekMs }: Slot): ReactElement {
228245
const lanes = lanesOf(detail);
229246
const empty = lanes.every((lane) => lane.items.length === 0);
247+
// 🔴 hook 在**空态提前返回之前**调用(否则两次渲染的 hook 数不同 ⇒ React 抛错);空态下两条轨
248+
// 容器不存在 ⇒ 编排层走「未挂上」分支,**不建时间线**。
249+
const align = useTriTrackAlign();
230250

231251
// 全空 ⇒ 走 `EmptyState` 原语(不是裸灰字):`emptyStateRatchet` 与「不写裸色值」两条同时成立
232252
if (empty) {
@@ -240,25 +260,28 @@ export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekM
240260
);
241261
}
242262

243-
/** 时间码点击:**只**经唯一上行口请求容器定位(视图自持状态 = 第二份真源,禁止) */
263+
/** 时间码点击:**只**经唯一上行口请求容器定位(视图自持状态 = 第二份真源,禁止)+ 触发 #1 对齐 */
244264
const seek = (ms: number): void => {
265+
align.trigger(ms); // #1 对齐(R5.1):对齐 ⇄ 错位由编排层**持有**的 `aligned` 反转
245266
onSeekMs?.(ms);
246267
};
247268

248269
return (
249270
<div style={ROOT_STYLE} data-testid="session-tritrack-view">
250271
{/* R5.5 #5 的承载面:共享时间轴尺 + 播放头 + `<audio>` 属性契约 + 降级提示行 */}
251272
<TimeRail totalMs={totalMsOf(detail, audio)} playheadMs={playheadMs ?? null} audio={audio} onSeekMs={onSeekMs} />
252-
<div style={TRACKS_STYLE}>
273+
{/* `data-tone="instrument"` = #1 的基调登记(R3.4 逐字「每个动效落点显式声明基调」) */}
274+
<div style={TRACKS_STYLE} data-tone="instrument">
253275
{lanes.map((lane) => (
254276
<div key={lane.track} style={COLUMN_STYLE}>
255277
<Text as="p" size={5} tone="ink-3">
256278
{lane.label}
257279
</Text>
258-
{/* 列内**只有条目**:`[data-track]` 的子项数 == 该轨条目数(判据 T1 的锚) */}
259-
<div style={LANE_STYLE} data-track={lane.track}>
280+
{/* 列内**只有条目**:`[data-track]` 的子项数 == 该轨条目数(判据 T1 的锚);
281+
两条非参考轨额外挂对齐动效的登记口(`ref`) */}
282+
<div style={LANE_STYLE} data-track={lane.track} ref={attachOf(align, lane.track)}>
260283
{lane.items.map((item) => (
261-
<TriTrackItem key={item.key} item={item} onSeek={seek} />
284+
<TriTrackItem key={item.key} item={item} onSeek={seek} coaligned={align.aligned && item.ms === align.ms} />
262285
))}
263286
</div>
264287
</div>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* TriTrackAlign.css — #1「对齐」的**共轴高亮落点**(批 6 T27 · 裁决 R5.1)。
3+
*
4+
* @ai-context 「同 ms 条目高亮**共轴关系**」的视觉接缝:`.ed-tritrack__item--coaligned` 只做一件事
5+
* —— 在条目左缘画一条 `--ed-link` 的引线。取 `--ed-link` 的依据是它自己的语义注释(`tokens.gen.ts`
6+
* 「链接 / **时间码** / 引用」):共轴组正是由**时间码那一刻**选中的 ⇒ 同 ms 的三个条目在三条轨上
7+
* 读起来是**一组**,而不是三件无关的条目。
8+
* @ai-context 落点形状 = `<基类>--<修饰>`;🔴 **基类 `.ed-tritrack__item` 故意不写规则** —— 布局由
9+
* 组件的行内 style 唯一持有(ADR-033 §4)⇒ 为一个名字再写一份布局 = 第二份真源(R1.1 禁止)。
10+
* @ai-context **零 `transition` / 零 `animation` / 零 `@media` / 零 `z-index`**:位移与高亮的时机由
11+
* GSAP 编排层(`useTriTrackAlign.ts`)决定 ⇒ 既不需要进 `motion.css` 的 reduced-motion 名单
12+
* (那张名单的域只含 `ui/primitives/*.css`,见 `motion-coverage.test.ts`),也不会新增第二条
13+
* reduced-motion 媒体查询(`style-seams.test.ts` 的「全仓只许一条」)。
14+
* @ai-context 颜色一律走 token ⇒ 本文件 **0 个色值字面量**(`inset 2px` 是线宽,不是色值)。
15+
* 副作用:无(纯样式表;无 I/O、无动画、无全局选择器 —— 唯一规则锚在 `.ed-tritrack__item` 上)。
16+
*/
17+
18+
.ed-tritrack__item--coaligned {
19+
box-shadow: inset 2px 0 0 var(--ed-link);
20+
}

0 commit comments

Comments
 (0)