Skip to content

Commit dfcbd49

Browse files
committed
feat(motion): 落列折叠 Flip 动效
1 parent 0ea19a7 commit dfcbd49

6 files changed

Lines changed: 329 additions & 6 deletions

File tree

‎app/src/components/ChatSidebar.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { columnSpec } from "../shell/columnRegistry";
2121
import { useColumnLayout, type ColumnLayout } from "../hooks/useColumnLayout";
2222
import ColumnBar from "./ColumnBar";
2323
import ColumnResizer from "./ColumnResizer";
24+
import { useColumnFlip } from "../shell/useColumnFlip";
2425

2526
/** 任务类型标签(refine/enrich → 中文 + 图标;模块内消费——审查修复:原
2627
* export 无外部消费方,收窄为非导出) */
@@ -73,6 +74,10 @@ export default function ChatSidebar(props: ChatSidebarProps) {
7374
// `useColumnLayout("键", columnSpec("键"))`,换行写法会让那条判据静默空转。
7475
const ownCol = useColumnLayout("chat-sidebar", columnSpec("chat-sidebar"));
7576
const col = injectedCol ?? ownCol;
77+
// T33(裁决「授权路径 B」):折叠前后是两棵子树 ⇒ 靠 `data-flip-id` 让 GSAP Flip 认出「同一条列」。
78+
// 面板与窄条各带一个(值逐字相同),既不需要 ref、也不需要动三元结构。
79+
const FLIP_ID = "col-chat-sidebar";
80+
useColumnFlip(FLIP_ID, col.folded);
7681
const itemBase: React.CSSProperties = {
7782
padding: "6px 8px",
7883
borderRadius: 6,
@@ -89,7 +94,7 @@ export default function ChatSidebar(props: ChatSidebarProps) {
8994
};
9095
// 批 3:折叠态与其它列同款——整列换成 ColumnBar 窄条(26px,点击走 expand())
9196
const panel = (
92-
<div style={{ width: col.width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
97+
<div data-flip-id={FLIP_ID} style={{ width: col.width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minHeight: 0 }}>
9398
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 8px 4px" }}>
9499
<span style={{ fontSize: 12, fontWeight: 600, color: "#6b7280" }}>💬 对话</span>
95100
<div style={{ display: "flex", gap: 2 }}>
@@ -190,7 +195,7 @@ export default function ChatSidebar(props: ChatSidebarProps) {
190195
// 直接子元素(多包一层会改变宽度分配;`ColumnResizer` 恒 5px / flexShrink 0)。
191196
return (
192197
<>
193-
{col.folded ? <ColumnBar icon="💬" title="对话" onClick={col.expand} /> : panel}
198+
{col.folded ? <ColumnBar flipId={FLIP_ID} icon="💬" title="对话" onClick={col.expand} /> : panel}
194199
<ColumnResizer onResize={col.resizeBy} onReset={col.resetWidth} />
195200
</>
196201
);

‎app/src/components/ColumnBar.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ interface Props {
88
icon: string;
99
title: string;
1010
onClick: () => void;
11+
/** 列折叠 Flip 的**跨元素身份**(T33:折叠前后两棵子树各带同一个 `data-flip-id`,GSAP Flip 据此配对;
12+
* 缺省 ⇒ 本窄条不参与 Flip —— 既有 8 个调用点因此零改动) */
13+
flipId?: string;
1114
}
1215

13-
export default function ColumnBar({ icon, title, onClick }: Props) {
16+
export default function ColumnBar({ icon, title, onClick, flipId }: Props) {
1417
return (
1518
<div
1619
data-testid="column-bar"
20+
data-flip-id={flipId}
1721
onClick={onClick}
1822
title={title}
1923
style={{

‎app/src/motion/engine.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin, CustomEase);
4949
*/
5050
CustomEase.create("ed-paper-bleed", "M0,0 C0.215,0.61 0.355,1 1,1");
5151

52-
export { gsap, useGSAP };
52+
/**
53+
* `Flip` 与 `gsap` / `useGSAP` 一并转出(T33 落地;控制方 2026-09-13 裁决「授权路径 B」):
54+
* 列折叠的跨元素配对(`data-flip-id`)需要 Flip,而本文件是**唯一**允许出现 gsap 家族说明符的地方
55+
* (判据 = `engine.guard.test.ts` ③′ 的 importer 集合相等 + ③‴ 的动态说明符域)⇒ 新增第二个 importer
56+
* 文件会当场红,故只能经本文件转出。`registerPlugin` 的插件集合与顺序**一字未动**。
57+
*/
58+
export { gsap, useGSAP, Flip };
5359

5460
/**
5561
* `gsap.core.Timeline` 的**转出**(计划 T10 Interfaces 逐字:`export type { gsap }` 转不出命名空间成员)。

‎app/src/pages/GoalsPage.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useColumnLayout } from "../hooks/useColumnLayout";
1515
import { columnSpec } from "../shell/columnRegistry";
1616
import ColumnResizer from "../components/ColumnResizer";
1717
import ColumnBar from "../components/ColumnBar";
18+
import { useColumnFlip } from "../shell/useColumnFlip";
1819
import type { GoalCardView, GraduationReport } from "../types/goals";
1920
import GoalCard from "../components/GoalCard";
2021
import GoalDetail from "../components/GoalDetail";
@@ -36,6 +37,9 @@ export default function GoalsPage() {
3637
const [archiveOpen, setArchiveOpen] = useState<number | null>(null);
3738
// 批 3(规格 §6.2):目标左列——今日硬编码 380,注册表口径改为默认 320(M4)
3839
const goalsCol = useColumnLayout("goals-left", columnSpec("goals-left"));
40+
// T33:折叠 Flip 的跨元素身份(面板与窄条共用;见 `shell/useColumnFlip`)
41+
const FLIP_ID = "col-goals-left";
42+
useColumnFlip(FLIP_ID, goalsCol.folded);
3943

4044
const load = useCallback(async () => {
4145
try {
@@ -89,9 +93,9 @@ export default function GoalsPage() {
8993
{/* 批 3:折叠态与其它列同款(26px 窄条,点击走 expand()——不是 setManualFolded,
9094
否则窄窗自动折叠下点了不展开) */}
9195
{goalsCol.folded ? (
92-
<ColumnBar icon="🎯" title="目标列表" onClick={goalsCol.expand} />
96+
<ColumnBar flipId={FLIP_ID} icon="🎯" title="目标列表" onClick={goalsCol.expand} />
9397
) : (
94-
<div style={{ width: goalsCol.width, overflow: "auto", padding: 12, borderRight: "1px solid #e5e7eb", boxSizing: "border-box", flexShrink: 0 }}>
98+
<div data-flip-id={FLIP_ID} style={{ width: goalsCol.width, overflow: "auto", padding: 12, borderRight: "1px solid #e5e7eb", boxSizing: "border-box", flexShrink: 0 }}>
9599
{err && <StatusLine kind="error">{err}</StatusLine>}
96100
{loaded && cards.length === 0 && (
97101
<div style={{ padding: 24, textAlign: "center" }}>
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context `useColumnFlip` 的**行为级判据**(批 6 波 C · T33 · 控制方 2026-09-13 裁决「授权路径 B」)。
4+
*
5+
* Why 必须有本文件(裁决第 3 条逐字:「hook **无测试 = 无判据**」):本 hook 是列折叠 Flip 的**唯一**编排点,
6+
* 而它的正确性在 jsdom 里**没有几何证据**(R5.7 实测:`elementStates[0].bounds` 全 0、位移恒
7+
* `translate3d(0px,0px,0px)`、`Flip.fit` 退化成 `scale(0,0)`)⇒ 只能判「**身份契约 + 调用不抛 + timeline 存在 +
8+
* **增量**属性集合 ⊆ 合成属性白名单 + 降级 + 三档真源**」这些**弱判据**;**真实几何位移 / 观感 / 帧率本批未测**
9+
* (逐字登记在 `task-33-report.md` §5/§9)。
10+
*
11+
* 判据与**各自专属**的变异体(变异只在导出副本里做 ⇒ 见报告「变异体实测」节):
12+
* C1 身份契约(跨两棵子树:引用不等 ∧ 同一 `data-flip-id` ∧ 选择器恰命中 1)—— M1:`ColumnBar.tsx` 摘掉 `data-flip-id` ⇒ 红
13+
* C1b/C1c **两个真实落点**(`ChatSidebar` / `GoalsPage`)的两态带同一身份 —— M1 同样打红这两条
14+
* C2 调用不抛 + 真 Timeline(计划 V1 的**有牙**形态)—— M2:`engine.ts` 的 `registerPlugin` 摘掉 `Flip` ⇒ 红
15+
* (⚠️ 计划原写法 `typeof Flip.getState === "function"` 是**空真**:未注册时它照样是 function,T33 实测)
16+
* C3 `scale: true` 的机器抓手(R12.3):Flip **自身写入**的属性增量 ⊆ 白名单 —— M3:hook 去掉 `scale: true` ⇒ 红
17+
* C4 reduced-motion 降级(不建 timeline)—— M4:hook 删掉 reduced 分支 ⇒ 红
18+
* C5 三档时长取自 token 真源 —— M5:`rich` 回落到 `card` 档 ⇒ 红
19+
*
20+
* ⚠️ 仪器边界:jsdom **不排版** ⇒ 本文件**一条几何断言都没有**;动效确定性走 `paused: true` + `timeline.time(t)`
21+
* (R8.1);本仓未装 jest-dom / user-event ⇒ 断言用原生 API、交互用 `fireEvent`;无 `globals` ⇒ 显式 `cleanup`。
22+
* 副作用:临时装 `matchMedia` 桩(`installMatchMediaStub`,每例 `restore()`)、清 `localStorage`、改 `innerWidth`(例末复原);
23+
* `@tauri-apps/api/core` 走 mock ⇒ 不连 IPC、不碰真实数据。
24+
*/
25+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
26+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
27+
import { useState } from "react";
28+
import { animatedProps, installMatchMediaStub } from "../test/motionHarness";
29+
import { ANIMATABLE_PROPERTIES } from "../motion/shift";
30+
import { DURATION_TOKENS } from "../ui/tokens.gen";
31+
import { flipDurationSec, flipSelector, useColumnFlip, type ColumnFlip } from "./useColumnFlip";
32+
import type { ColumnLayout } from "../hooks/useColumnLayout";
33+
import ColumnBar from "../components/ColumnBar";
34+
import ChatSidebar, { type ChatSidebarProps } from "../components/ChatSidebar";
35+
import GoalsPage from "../pages/GoalsPage";
36+
37+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
38+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
39+
40+
const ID = "col-harness";
41+
const CHAT_KEY = "chat-sidebar";
42+
const GOALS_KEY = "goals-left";
43+
44+
/** 等动态 `import("../motion/engine")` 与它的 `.then` 落地(**不是** GSAP 时基:本文件的 timeline 恒 `paused`)。 */
45+
async function flush(): Promise<void> {
46+
for (let i = 0; i < 4; i += 1) await Promise.resolve();
47+
await new Promise<void>((resolve) => {
48+
setTimeout(resolve, 0);
49+
});
50+
}
51+
52+
/** 折叠态的注入式列状态(判据只关心 `folded`;其余成员给足接口面)。 */
53+
function colStub(folded: boolean): ColumnLayout {
54+
return {
55+
width: 260, folded, manuallyFolded: folded,
56+
resizeBy: vi.fn(), resetWidth: vi.fn(), setManualFolded: vi.fn(), expand: vi.fn(),
57+
};
58+
}
59+
60+
const CHAT_BASE: Omit<ChatSidebarProps, "col"> = {
61+
sessions: [], tasks: [], activeChatId: null, activeTaskId: null,
62+
onSelectChat: vi.fn(), onSelectTask: vi.fn(), onNewChat: vi.fn(),
63+
onRenameChat: vi.fn(), onDeleteChat: vi.fn(),
64+
sessionTitles: new Map<number, string>(), noteTitles: new Map<number, string>(),
65+
};
66+
67+
/** 判据用的句柄读取口(渲染期赋值 —— 只有测试 harness 会这么做)。 */
68+
let handle: ColumnFlip["handle"] | null = null;
69+
70+
/** 与生产同形的接线:折叠三元的两棵子树各带同一个 `data-flip-id`(`ChatSidebar` / `GoalsPage` 同款)。 */
71+
function Harness(): React.ReactElement {
72+
const [folded, setFolded] = useState(false);
73+
handle = useColumnFlip(ID, folded, { paused: true }).handle;
74+
return (
75+
<div>
76+
<button data-testid="toggle" onClick={() => setFolded((f) => !f)}>t</button>
77+
{folded ? (
78+
<ColumnBar flipId={ID} icon="📁" title="户" onClick={() => setFolded(false)} />
79+
) : (
80+
<div data-flip-id={ID} data-testid="panel" style={{ width: 240 }}>面板</div>
81+
)}
82+
</div>
83+
);
84+
}
85+
86+
beforeEach(() => {
87+
handle = null;
88+
window.localStorage.clear();
89+
invokeMock.mockReset();
90+
invokeMock.mockResolvedValue([]);
91+
});
92+
afterEach(() => {
93+
cleanup();
94+
window.localStorage.clear();
95+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
96+
});
97+
98+
describe("T33 · 列折叠 Flip(路径 B:`data-flip-id` 跨元素配对)", () => {
99+
it("C1 身份契约:折叠前后是**两棵子树**(引用不等),但两者带**逐字相同**的 `data-flip-id`,且选择器恰命中 1 个", () => {
100+
const { container } = render(<Harness />);
101+
const panel = container.querySelector('[data-testid="panel"]');
102+
expect(panel?.getAttribute("data-flip-id"), "展开态面板未带列身份").toBe(ID);
103+
expect(document.querySelectorAll(flipSelector(ID)).length, "展开态选择器命中数").toBe(1);
104+
105+
fireEvent.click(screen.getByTestId("toggle"));
106+
const bar = screen.getByTestId("column-bar");
107+
expect(bar.getAttribute("data-flip-id"), "折叠态窄条未带同一个列身份").toBe(ID);
108+
expect(bar === panel, "折叠前后竟是同一个 DOM 节点(那是路径 A 的形态,不是本路径)").toBe(false);
109+
expect(document.querySelectorAll(flipSelector(ID)).length, "折叠态选择器命中数").toBe(1);
110+
expect(panel?.isConnected, "旧子树应已卸载").toBe(false);
111+
});
112+
113+
it("C1b 真实落点 1/2 `ChatSidebar`:展开态面板与折叠态窄条带同一个 `data-flip-id`", () => {
114+
const expanded = render(<ChatSidebar {...CHAT_BASE} col={colStub(false)} />);
115+
expect(
116+
expanded.container.querySelector(`[data-flip-id="col-${CHAT_KEY}"]`),
117+
"ChatSidebar 展开态未带列身份(T33 的 2/8 落点之一)",
118+
).not.toBeNull();
119+
expect(screen.queryByTestId("column-bar")).toBeNull();
120+
cleanup();
121+
122+
render(<ChatSidebar {...CHAT_BASE} col={colStub(true)} />);
123+
expect(screen.getByTestId("column-bar").getAttribute("data-flip-id"), "折叠态窄条未带同一身份").toBe(
124+
`col-${CHAT_KEY}`,
125+
);
126+
});
127+
128+
it("C1c 真实落点 2/2 `GoalsPage`:展开态左列与折叠态窄条带同一个 `data-flip-id`", () => {
129+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 });
130+
const expanded = render(<GoalsPage />);
131+
expect(
132+
expanded.container.querySelector(`[data-flip-id="col-${GOALS_KEY}"]`),
133+
"GoalsPage 展开态左列未带列身份(T33 的 2/8 落点之二)",
134+
).not.toBeNull();
135+
cleanup();
136+
137+
window.localStorage.setItem(`layout:col-fold:${GOALS_KEY}`, "1"); // 手动折叠记忆
138+
render(<GoalsPage />);
139+
expect(screen.getByTestId("column-bar").getAttribute("data-flip-id")).toBe(`col-${GOALS_KEY}`);
140+
});
141+
142+
it("C2 调用不抛 + 真 Timeline(计划 V1 的**有牙**形态):折叠变化后句柄里有一条 `duration()` = card 档的真时间线", async () => {
143+
render(<Harness />);
144+
await flush(); // 先让首挂载存下基线(`Flip.getState` 必须不抛:未注册时抛 TypeError)
145+
expect(handle?.current, "首挂载不该建时间线(只存基线)").toBeNull();
146+
147+
fireEvent.click(screen.getByTestId("toggle"));
148+
await flush();
149+
const tl = handle?.current?.timeline ?? null;
150+
expect(tl, "折叠后没有时间线 ⇒ `Flip.getState`/`Flip.from` 没跑成(未注册时会抛 `_toArray`)").not.toBeNull();
151+
expect(typeof tl?.duration(), "返回值不是真 Timeline").toBe("number");
152+
expect(tl?.duration(), "时长未接 token 真源").toBeCloseTo(flipDurationSec("standard") ?? -1, 5);
153+
expect(tl?.getChildren().length, "Flip.from 的补间数(位置 + 尺寸两条)").toBe(2);
154+
});
155+
156+
it("C3 `scale: true`(R12.3)**增量**口径:Flip 自身写入的属性 ⊆ 合成属性白名单(含 `transform`,不含 layout 属性)", async () => {
157+
render(<Harness />);
158+
await flush();
159+
fireEvent.click(screen.getByTestId("toggle"));
160+
const bar = screen.getByTestId("column-bar") as HTMLElement;
161+
const before = animatedProps(bar); // 折叠刚提交、Flip 还没跑:此刻的行内集合全是渲染期写的
162+
await flush();
163+
handle?.current?.timeline.time(0.25); // 确定性推进(R8.1)
164+
const delta = animatedProps(bar).filter((p) => !before.includes(p));
165+
166+
expect(delta, "Flip 一条属性都没写 ⇒ 本判据会退化成空真").not.toEqual([]);
167+
expect(delta, "Flip 写出的属性越出白名单(默认 `scale:false` 会写 width/height/max-*/min-*)").toEqual(
168+
delta.filter((p) => ANIMATABLE_PROPERTIES.includes(p)),
169+
);
170+
expect(delta, "transform 路径未生效").toContain("transform");
171+
expect(before.filter((p) => ANIMATABLE_PROPERTIES.includes(p)), "渲染期不该写合成属性").toEqual([]);
172+
});
173+
174+
it("C4 reduced-motion 降级:命中 `prefers-reduced-motion: reduce` ⇒ **不建**时间线(宽度照旧瞬跳)", async () => {
175+
const stub = installMatchMediaStub({ reduce: true });
176+
try {
177+
render(<Harness />);
178+
await flush();
179+
fireEvent.click(screen.getByTestId("toggle"));
180+
await flush();
181+
expect(handle?.current, "reduced-motion 下仍建了时间线(§8.5 要求跳终态)").toBeNull();
182+
expect(screen.getByTestId("column-bar").getAttribute("data-flip-id"), "降级不该破坏结构契约").toBe(ID);
183+
} finally {
184+
stub.restore();
185+
}
186+
});
187+
188+
it("C5 三档时长取自 token 真源(逐档数组相等):`eco` 不播 · `standard` = card · `rich` = reveal", () => {
189+
const ms = (name: string): number => DURATION_TOKENS.find((t) => t.name === name)?.ms ?? -1;
190+
const tiers = ["eco", "standard", "rich"] as const;
191+
expect(tiers.map((t) => flipDurationSec(t))).toEqual([null, ms("card") / 1000, ms("reveal") / 1000]);
192+
expect(tiers.map((t) => flipDurationSec(t))).toEqual([null, 0.22, 0.5]);
193+
});
194+
});

0 commit comments

Comments
 (0)