Skip to content

Commit 51f6019

Browse files
committed
feat(motion): 落记忆浮现四件动效
1 parent d90b89d commit 51f6019

5 files changed

Lines changed: 699 additions & 8 deletions

File tree

‎app/src/components/review/ReviewSessionPanel.test.tsx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@
77
* 完成收尾回总览。invoke 全 mock(list_due_cards/review_card)。
88
*/
99
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { readFileSync } from "node:fs";
11+
import { dirname, join } from "node:path";
12+
import { fileURLToPath } from "node:url";
1013
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
1114
import type { Flashcard } from "../../types/notes";
15+
import { relOf, stripComments, walkSources } from "../../ui/primitives/sliceScan";
16+
17+
const HERE = dirname(fileURLToPath(import.meta.url)); // app/src/components/review
18+
const SRC = join(HERE, "..", ".."); // app/src
1219

1320
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
1421
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
@@ -188,3 +195,70 @@ describe("ReviewSessionPanel · 刻度生长(批 6 T30 · §8.6 #4 · PB2 精
188195
expect(screen.queryByTestId("session-last-interval")).toBeNull();
189196
});
190197
});
198+
199+
describe("ReviewSessionPanel · 记忆浮现四件(批 6 T32 · §8.6 #6 · R5.6 · §8.6.1 第 3 条)", () => {
200+
/** 四件的**逐序**落点口径(R8.5 的结构锚点:`data-reveal`)。 */
201+
const ANCHORS = ["ink", "track", "clip", "rate"] as const;
202+
const anchor = (v: (typeof ANCHORS)[number]): HTMLElement | null => document.querySelector(`[data-reveal="${v}"]`);
203+
204+
it("P1 · 揭晓前四件落点一件都不在;揭晓后**逐序**在位(data-reveal = ink/track/clip/rate)", async () => {
205+
renderPanel();
206+
await screen.findByText("隔离霜作用");
207+
expect(ANCHORS.map((v) => anchor(v)), "揭晓前不许有任何揭晓落点(揭晓是「整棵子树瞬切」的既有形态)")
208+
.toEqual([null, null, null, null]);
209+
fireEvent.click(screen.getByText("回忆完成 · 查看答案"));
210+
await screen.findByText("打底隔离彩妆与污染");
211+
expect(ANCHORS.map((v) => anchor(v)?.getAttribute("data-reveal"))).toEqual(["ink", "track", "clip", "rate"]);
212+
expect(anchor("rate")?.getAttribute("data-tone"), "§8.3:复习面 = 精密仪器(基调声明在落点上)").toBe("instrument");
213+
});
214+
215+
it("P2 · ③剪报底纹是**左刷**:原点是 left(R5.6 逐字)· 色取既有 token · 纯装饰(aria-hidden)", async () => {
216+
renderPanel();
217+
fireEvent.click(await screen.findByText("回忆完成 · 查看答案"));
218+
await screen.findByTestId("session-answer");
219+
const node = anchor("clip");
220+
expect(node, "底纹承载层必须在场(本任务新建的视觉层,R5.6 诚实边界②)").not.toBeNull();
221+
expect((node as HTMLElement).style.transformOrigin, "M4:改成 right ⇒ 这条红(「左刷」是 R5.6 逐字)").toBe("left center");
222+
expect((node as HTMLElement).style.background, "零新色值:底纹取既有 token --ed-mark-clip").toBe("var(--ed-mark-clip)");
223+
expect((node as HTMLElement).getAttribute("aria-hidden"), "纯装饰层不进无障碍树").toBe("true");
224+
});
225+
226+
it("P3 · 🔴 `Text.css:15-20` 的墨度钩子**真被接上**:答案文本是 `.ed-text`,且揭晓时**两相翻档**(低 → 高墨度)", async () => {
227+
const records: MutationRecord[] = [];
228+
renderPanel();
229+
await screen.findByText("隔离霜作用");
230+
const observer = new MutationObserver((list) => records.push(...list));
231+
observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["class"], attributeOldValue: true });
232+
fireEvent.click(screen.getByText("回忆完成 · 查看答案"));
233+
const answer = await screen.findByTestId("session-answer");
234+
await waitFor(() =>
235+
expect(records.some((r) => (r.oldValue ?? "").includes("ed-text--ink-3")), "DOM 里必须发生过 ink-3 → 高墨度的翻档").toBe(true),
236+
);
237+
observer.disconnect();
238+
const flip = records.find((r) => (r.oldValue ?? "").includes("ed-text--ink-3"));
239+
expect(answer.className, "答案文本走 `Text` 原语 ⇒ 带 `.ed-text`(钩子载体;行内 fontSize 交回原语)").toContain("ed-text");
240+
expect(answer.className, "落定 = 高墨度(低档取 ink-3 而非过渡态 ink-4)").toContain("ed-text--ink-1");
241+
expect((flip?.target as HTMLElement).className, "翻档的目标就是答案文本本身").toContain("ed-text--ink-1");
242+
});
243+
244+
it("P4 · 🔴 硬判据(R5.6)**静态齿 A**:全仓两个字距写法的读数仍为 5 处、3 文件(本任务不得新增第 6 处)", () => {
245+
const LS = "letter" + "Spacing"; // 🔴 被扫串一律**拼接构造**:本文件也在扫描域内(`app/src/**`)⇒ 写全会自伤
246+
const hits = walkSources(SRC)
247+
.map((f) => ({ rel: relOf(SRC, f), n: stripComments(readFileSync(f, "utf8")).split(LS).length - 1 }))
248+
.filter((h) => h.n > 0);
249+
expect(hits.map((h) => h.rel).sort(), "基线 = 3 文件(ColumnBar / SettingsPage / Text.test)").toEqual([
250+
"components/ColumnBar.tsx", "pages/SettingsPage.tsx", "ui/primitives/Text.test.tsx",
251+
]);
252+
expect(hits.reduce((a, b) => a + b.n, 0), "全仓读数(剥注释口径,基线 5;M1 ⇒ 6)").toBe(5);
253+
});
254+
255+
it("P5 · 🔴 硬判据(R5.6)**静态齿 B**:两个字距写法在 `useRevealMemory.ts` / 本面板里 0 命中", () => {
256+
const LS = "letter" + "Spacing";
257+
const LSK = "letter" + "-spacing";
258+
for (const file of ["useRevealMemory.ts", "ReviewSessionPanel.tsx"]) {
259+
const code = stripComments(readFileSync(join(HERE, file), "utf8"));
260+
expect(code.length, "扫描域缩到空 ⇒ 「0 命中」一文不值").toBeGreaterThan(1000);
261+
expect([code.includes(LS), code.includes(LSK)], `${file} 出现字距字面量(layout 属性)`).toEqual([false, false]);
262+
}
263+
});
264+
});

‎app/src/components/review/ReviewSessionPanel.tsx‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { Flashcard } from "../../types/notes";
2121
import PromoteCardButton from "../PromoteCardButton";
2222
import DueScale from "./DueScale";
2323
import { intervalToScale, useScaleGrowth } from "./useScaleGrowth";
24+
import { INK_FROM, RATE_FROM_PX, TRACK_FROM_PX, inkTone, useRevealMemory } from "./useRevealMemory";
2425
import { Loading, Skeleton, StatusLine, Text } from "../../ui/primitives";
2526

2627
/** 四档评分按钮(文案=回忆质量自评) */
@@ -96,6 +97,10 @@ export default function ReviewSessionPanel({ groupId, groupName, active = true,
9697
const growTo = intervalToScale(lastInterval);
9798
const growth = useScaleGrowth(growTo);
9899

100+
// 「揭晓」(#6 记忆浮现)的四段编排 —— 唯一 timeline、四件落点(`data-reveal` 锚点)全在 back 子树里。
101+
// 四件动效的形态、`x` 而非 `letterSpacing` 的理由、持有/可中断/降级/三档全在 `useRevealMemory` 头注。
102+
const reveal = useRevealMemory(revealed);
103+
99104
const rate = async (rating: string) => {
100105
if (!current) return;
101106
try {
@@ -221,16 +226,32 @@ export default function ReviewSessionPanel({ groupId, groupName, active = true,
221226
</button>
222227
) : (
223228
<>
224-
{/* back(验证材料) */}
225-
<div style={{
229+
{/* back(验证材料)—— §8.6 #6 的 ①墨色洇开 / ③剪报底纹左刷 的宿主。`data-reveal` 是本批的
230+
结构锚点(R8.5);四段的编排与理由在 `useRevealMemory` 头注(本处只放落点与静态起始形态)。 */}
231+
<div ref={reveal.ink} data-reveal="ink" style={{
226232
marginTop: 10, minHeight: 70, padding: 14, background: "#f0fdfa",
227-
borderRadius: 8, fontSize: 13, lineHeight: 1.7, whiteSpace: "pre-wrap",
228-
maxHeight: 220, overflowY: "auto",
233+
borderRadius: 8, maxHeight: 220, overflowY: "auto", opacity: INK_FROM,
229234
}}>
230-
{current.back}
235+
{/* 剪报底纹(本任务新建的**承载层**:复习面板今天没有既有载体 —— R5.6 诚实边界②)。
236+
色取既有 token `--ed-mark-clip`(零新色值);`transformOrigin: left` 是**静态**样式
237+
(不进被动画属性集合 —— 否则 `transform-origin` 会越出 R8.4 的合成属性白名单)。 */}
238+
<div ref={reveal.clip} data-reveal="clip" aria-hidden="true" style={{
239+
height: 6, marginBottom: 8, background: "var(--ed-mark-clip)",
240+
transformOrigin: "left center", transform: "scaleX(0)",
241+
}} />
242+
{/* ②字距极轻收敛:载体是这一层的 `x`(**不是** `letter-spacing` —— layout 属性会重排,R5.6 硬判据) */}
243+
<div ref={reveal.track} data-reveal="track" style={{ transform: `translate(${TRACK_FROM_PX}px, 0px)` }}>
244+
{/* 答案文本交回 `Text` 原语:`.ed-text` 是「墨度 + 字距」钩子(`Text.css:15-20`)的唯一出口,
245+
揭晓挂载后墨度由 `ink-3` 翻到 `ink-1` ⇒ 那条 `color` transition 真的会跑(接缝兑现)。 */}
246+
<Text as="p" size={4} tone={inkTone(reveal.inked)} testId="session-answer" style={{ whiteSpace: "pre-wrap" }}>
247+
{current.back}
248+
</Text>
249+
</div>
231250
</div>
232-
{/* 四档评分(提取质量自评 → FSRS 调度) */}
233-
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
251+
{/* 四档评分(提取质量自评 → FSRS 调度)+ §8.6 #6 的 ④评分按钮随后浮起(最后一段才动) */}
252+
<div ref={reveal.rate} data-reveal="rate" data-tone="instrument" style={{
253+
display: "flex", gap: 8, marginTop: 12, opacity: 0, transform: `translate(0px, ${RATE_FROM_PX}px)`,
254+
}}>
234255
{RATINGS.map((r) => (
235256
<button
236257
key={r.value}

0 commit comments

Comments
 (0)