Skip to content

Commit 66f0e4a

Browse files
committed
feat(motion): 落刻度生长与回缩动效
1 parent 0772d1a commit 66f0e4a

6 files changed

Lines changed: 525 additions & 11 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,46 @@ describe("DueScale · 源码面判据(R5.4 的明文禁止 → 守卫)", ()
136136
expect(readProd(PROD[0]).stripped.join("\n").includes("@tauri-apps")).toBe(false);
137137
});
138138
});
139+
140+
describe("DueScale · 生长落点(批 6 T30 · §8.6 #4「刻度生长」)", () => {
141+
/** 句柄形态 = React 的 ref 对象(本件不碰 GSAP:动画归调用方的 `useScaleGrowth`)。 */
142+
const newRef = (): { current: HTMLSpanElement | null } => ({ current: null });
143+
const origins = (c: HTMLElement): string[] =>
144+
[...c.querySelectorAll("[data-tick]")].map((el) => (el as HTMLElement).style.transformOrigin);
145+
146+
it("G1 · 目标回声 + 生长标记**只在首段**(逐序),句柄落在首段,且几何仍只由 intervals 决定", () => {
147+
const growRef = newRef();
148+
const { container } = render(
149+
<DueScale due={3} intervals={[10, 2, 30]} granularity="exact" growTo={0.5} growRef={growRef} testId="scale" />,
150+
);
151+
expect(rootOf(container).getAttribute("data-grow-to")).toBe("0.5");
152+
expect(attr(container, "data-grow"), "生长落点**恰**首段(逐序数组相等:集合相等测不出换位)").toEqual([
153+
"1", null, null,
154+
]);
155+
expect(attr(container, "data-tone"), "R3.4:#4 是「读数」面 ⇒ 首段声明 instrument").toEqual([
156+
"instrument", null, null,
157+
]);
158+
expect(growRef.current, "句柄必须落在首段(被动画的只有它)").toBe(container.querySelectorAll("[data-tick]")[0]);
159+
expect(origins(container), "只有生长段有 transform-origin(静态样式,不进动效属性集合)").toEqual([
160+
"bottom", "", "",
161+
]);
162+
// 目标**不是**几何真源:段数与段高仍只由 due / intervalDays 决定(R8.4:不动 layout 属性)
163+
expect(ticks(container)).toEqual(["0", "1", "2"]);
164+
expect(attr(container, "data-interval")).toEqual(["10", "2", "30"]);
165+
});
166+
167+
it("G2 · 不给 growTo ⇒ 零生长标记、零 transform-origin(T21 的静态形态逐字保留)", () => {
168+
const { container, rerender } = render(<DueScale due={3} intervals={[10, 2, 30]} testId="scale" />);
169+
expect(rootOf(container).getAttribute("data-grow-to")).toBeNull();
170+
expect(attr(container, "data-grow")).toEqual([null, null, null]);
171+
expect(attr(container, "data-tone")).toEqual([null, null, null]);
172+
expect(origins(container)).toEqual(["", "", ""]);
173+
const heights = [...container.querySelectorAll("[data-tick]")].map((el) => (el as HTMLElement).style.height);
174+
rerender(<DueScale due={3} intervals={[10, 2, 30]} growTo={0.5} testId="scale" />);
175+
expect(
176+
[...container.querySelectorAll("[data-tick]")].map((el) => (el as HTMLElement).style.height),
177+
"给了目标也不许改分段几何(height 不是动效属性)",
178+
).toEqual(heights);
179+
expect(ticks(container)).toEqual(["0", "1", "2"]);
180+
});
181+
});

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* 而签名动效 #4 要「生长 / 回缩」,前提是先有一个**可补间的几何量**(刻度长度)。
77
* 本件就是那个承载面:长度 = 后端计数;逐段长度 = 单卡间隔。形态是 **div 条阵列**
88
* (不是内联 svg —— `ui/icons/no-inline-svg.test.ts` 明禁新增内联 svg)。
9-
* 🔴 交付边界:本件**只建静态形态**(承载面 + 数据接线 + 判据)。生长 / 回缩动效归
10-
* **T30**(波 C)—— 本件交付时刻度是**静止的**,不许把 #4 说成已交付。
9+
* 🔴 交付边界(T30 已更新本条):本件仍是**静态承载面** —— 段数 / 段高只由 `due` 与
10+
* `intervals` 决定(零 GSAP、零 effect);「生长 / 回缩」由**调用方**经 `growTo` + `growRef`
11+
* 注入(编排在 `useScaleGrowth`,唯一 timeline),本件只提供落点(首段)与基调声明。
1112
* @ai-context: 数据真源(各一,且只此两处):① 段数 = `count_due_cards` 的计数(`due` prop,由调用点
1213
* 透传 —— 本件**不发 IPC、不二次查询**,全件零 `@tauri-apps` 依赖);② 逐段间隔 =
1314
* `Flashcard.intervalDays`(`intervals` prop)。🔴 **禁止自造间隔**:本件不解析
@@ -23,15 +24,16 @@
2324
* 是 R4.5 琥珀族的**唯一**来源 —— 本件不写任何琥珀字面量,段色取 `currentColor` 继承)。
2425
* 基类 `ed-surface` 与它同元素(同 `ed-text--low-confidence` 的用法);本件**不**用
2526
* `<Surface>` 原语,因为新调用点要登记进 `surfaceResidual.ts`(本任务禁碰棘轮表)。
26-
* 副作用:**无**(纯展示:不读 store、不发 IPC、不写盘、无定时器、无 effect、无 GSAP)。
27+
* 副作用:**无**(纯展示:不读 store、不发 IPC、不写盘、无定时器、无 effect、无 GSAP —— 即便 `growTo`
28+
* 已给:动画由调用方的 hook 经 `growRef` 写 `transform`,本件自身不碰任何动效 API)。
2729
* 边界:① `due === 0` ⇒ 渲染**空刻度**(不落空态文案 —— 空态归调用点 `ReviewPage`);
2830
* ② `intervals[i] <= 0`(新卡 / 无复习记录)⇒ 画「无间隔」档,**不是** 0 长度(0 长度会让
2931
* 「新卡」与「这段不存在」不可分);③ `intervals` 短于 `due`(后端队列上限 200 < 全量到期数)
3032
* ⇒ 缺位段同样走「无间隔」档:「不在本轮队列里」≠「间隔为 0」,本件不替后端猜;
3133
* ④ 段数多时靠 flex 压缩与裁切,**不省略 DOM 段** —— 段数恒等于 `due`(那正是判据 V1)。
3234
* ⑤ 零裸 `<button>`(纯展示;`components/**` 在 `nativeButton` 棘轮域内)。
3335
*/
34-
import type { CSSProperties, ReactElement } from "react";
36+
import type { CSSProperties, ReactElement, Ref } from "react";
3537

3638
export interface DueScaleProps {
3739
/** 到期卡张数(来自 count_due_cards;**唯一**的"有多少"真源) */
@@ -44,13 +46,22 @@ export interface DueScaleProps {
4446
* `review_card` 返回体的**精确值**(PB2 ①域)。两个域**不得**互相冒充。
4547
*/
4648
readonly granularity?: "day" | "exact";
49+
/**
50+
* **生长目标**(归一长度 0..1,T30/§8.6 #4)—— **受控**:由调用方从 `review_card` 的**精确**
51+
* `intervalDays` 换算(`useScaleGrowth` 的 `intervalToScale`)。`null` / 缺省 ⇒ **不生长**:
52+
* 刻度静止在 T21 的静态形态(本件**不**自造目标、不读 IPC)。
53+
*/
54+
readonly growTo?: number | null;
55+
/** 生长刻度的元素句柄(`useScaleGrowth` 的 `ref`)—— 被动画的**只有首段**(本轮在评的那张卡)。 */
56+
readonly growRef?: Ref<HTMLSpanElement>;
4757
}
4858

4959
/** 「无间隔」档的段高(px)—— 新卡 / 间隔 0 / 不在本轮队列内。**视觉常量,不是数据真源**。 */
50-
const TICK_NONE_PX = 6;
60+
export const TICK_NONE_PX = 6;
5161
/** 按天刻度的段高区间:1 天 ⇒ 8px,`TICK_DAY_CAP` 天及以上 ⇒ 28px(单调不减 ⇒ 间隔越长刻度越长)。 */
5262
const TICK_DAY_MIN_PX = 8;
53-
const TICK_DAY_MAX_PX = 28;
63+
/** 满刻度段高(px)—— **归一长度的分母**(`useScaleGrowth` 的 `intervalToScale` 复用同一个数)。 */
64+
export const TICK_DAY_MAX_PX = 28;
5465
/** 封顶天数:再长的间隔不再变高 —— 刻度要的是「可感觉」,不是「可测量」(不发明数字)。 */
5566
const TICK_DAY_CAP = 30;
5667

@@ -59,8 +70,11 @@ function tickTier(days: number | undefined): "none" | "day" {
5970
return days === undefined || !Number.isFinite(days) || days <= 0 ? "none" : "day";
6071
}
6172

62-
/** 一段刻度的**视觉长度**(px)。`days <= 0` 或未知 ⇒ 「无间隔」档的定长(**不是** 0 长度)。 */
63-
function tickHeight(days: number | undefined): number {
73+
/**
74+
* 一段刻度的**视觉长度**(px)。`days <= 0` 或未知 ⇒ 「无间隔」档的定长(**不是** 0 长度)。
75+
* **导出**给 `useScaleGrowth`:生长必须复用这**同一个**单调映射(两处各写一份必然会漂)。
76+
*/
77+
export function tickHeight(days: number | undefined): number {
6478
if (tickTier(days) === "none") return TICK_NONE_PX;
6579
const capped = Math.min(days as number, TICK_DAY_CAP);
6680
return Math.round(TICK_DAY_MIN_PX + ((capped - 1) / (TICK_DAY_CAP - 1)) * (TICK_DAY_MAX_PX - TICK_DAY_MIN_PX));
@@ -78,7 +92,7 @@ function tickLabel(index: number, days: number | undefined, granularity: "day" |
7892
*
7993
* 刻度底轨的琥珀来自 `ed-surface--due-glow` 的 `color: var(--ed-due)`(R4.5 的唯一合法来源)。
8094
*/
81-
export default function DueScale({ due, intervals, testId, granularity = "day" }: DueScaleProps): ReactElement {
95+
export default function DueScale({ due, intervals, testId, granularity = "day", growTo = null, growRef }: DueScaleProps): ReactElement {
8296
// 段数 = 计数(防御:非有限值 / 负数 / 小数一律按 0 段或截断处理 —— 不 panic、不造幽灵段)
8397
const count = Number.isFinite(due) ? Math.max(0, Math.trunc(due)) : 0;
8498
const queueMarks = Math.min(intervals?.length ?? 0, count);
@@ -93,19 +107,34 @@ export default function DueScale({ due, intervals, testId, granularity = "day" }
93107
flex: "1 1 0", minWidth: 1, maxWidth: 5, background: "currentColor", borderRadius: 1,
94108
};
95109
return (
96-
<div data-testid={testId} data-due={count} data-granularity={granularity} title={label} role="img" aria-label={label}>
110+
<div
111+
data-testid={testId}
112+
data-due={count}
113+
data-granularity={granularity}
114+
data-grow-to={growTo === null ? undefined : String(growTo)}
115+
title={label}
116+
role="img"
117+
aria-label={label}
118+
>
97119
{count > 0 && (
98120
<div className="ed-surface ed-surface--sunken ed-surface--due-glow" style={lane}>
99121
{Array.from({ length: count }, (_, i) => {
100122
const days = intervals?.[i];
123+
// 生长落点 = **首段**(当前卡的那一段):它声明基调(R3.4:#4 = 读数面 ⇒ instrument)并接住
124+
// 句柄;`transformOrigin: bottom` 是**静态**样式(不参与动画 —— 否则属性集合会多出
125+
// `transform-origin`,越出 R8.4 的合成属性白名单)。
126+
const grows = i === 0 && growTo !== null;
101127
return (
102128
<span
103129
key={i}
130+
ref={grows ? growRef : undefined}
104131
data-tick={i}
105132
data-tier={tickTier(days)}
133+
data-grow={grows ? "1" : undefined}
134+
data-tone={grows ? "instrument" : undefined}
106135
data-interval={days === undefined ? undefined : String(days)}
107136
title={tickLabel(i, days, granularity)}
108-
style={{ ...tick, height: tickHeight(days) }}
137+
style={{ ...tick, height: tickHeight(days), transformOrigin: grows ? "bottom" : undefined }}
109138
/>
110139
);
111140
})}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
1414
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
1515

1616
import ReviewSessionPanel from "./ReviewSessionPanel";
17+
import { intervalToScale } from "./useScaleGrowth";
1718

1819
function card(id: number, front: string, back: string, kind = "fact"): Flashcard {
1920
return {
@@ -125,3 +126,65 @@ describe("ReviewSessionPanel 复习流", () => {
125126
expect(onExit).toHaveBeenCalledTimes(1);
126127
});
127128
});
129+
130+
describe("ReviewSessionPanel · 刻度生长(批 6 T30 · §8.6 #4 · PB2 精确域)", () => {
131+
/** 生长刻度的**受控目标回声**(`DueScale` 根上的 `data-grow-to`)—— 由 `review_card` 的精确值换算 */
132+
const growToAttr = (): string | null =>
133+
screen.getByTestId("session-interval-scale").getAttribute("data-grow-to");
134+
const scaleTick = (): HTMLElement =>
135+
screen.getByTestId("session-interval-scale").querySelector("[data-tick]") as HTMLElement;
136+
/** 两张卡的队列 + 按 cardId 给不同的精确 `intervalDays`(模拟「记得 ⇒ 变长 / 忘了 ⇒ 回缩」) */
137+
const mockExact = (byCard: Readonly<Record<number, number>>): void => {
138+
invokeMock.mockImplementation(async (cmd: string, args: Record<string, unknown>) => {
139+
const id = Number(args.cardId);
140+
if (cmd === "list_due_cards") return [card(41, "甲卡", "甲背"), card(42, "乙卡", "乙背")];
141+
if (cmd === "review_card") return { ...card(id, "卡", "背"), intervalDays: byCard[id] ?? 1 };
142+
throw new Error(`unexpected: ${cmd}`);
143+
});
144+
};
145+
146+
it("R1 · 目标由精确 intervalDays 驱动(不是 due 计数):返回 10 ⇒ data-grow-to == intervalToScale(10)", async () => {
147+
mockExact({ 41: 10, 42: 10 });
148+
renderPanel();
149+
// 评分前:没有精确值 ⇒ 零目标(不猜、不预置 0)
150+
expect(await screen.findByTestId("session-interval-scale")).toBeTruthy();
151+
expect(growToAttr()).toBeNull();
152+
expect(scaleTick().getAttribute("data-tier")).toBe("none");
153+
fireEvent.click(screen.getByText("回忆完成 · 查看答案"));
154+
fireEvent.click(screen.getByText("记得"));
155+
await waitFor(() => expect(growToAttr()).toBe(String(intervalToScale(10))));
156+
expect(growToAttr(), "若目标由 due(=1)驱动,读数会等于 intervalToScale(1)").not.toBe(
157+
String(intervalToScale(1)),
158+
);
159+
expect(screen.getByTestId("session-interval-scale").getAttribute("data-granularity")).toBe("exact");
160+
expect(screen.getByTestId("session-interval-scale").getAttribute("data-due")).toBe("1");
161+
expect(scaleTick().getAttribute("data-interval"), "精确域:10 天(行派生的整天近似冒充不了它)").toBe("10");
162+
expect(scaleTick().getAttribute("title"), "精确域的标签不带「约…整天粒度」").toBe("第 1 段 · 10 天");
163+
});
164+
165+
it("R2 · 答「忘了」⇒ 目标回缩(30 → 1:终值 < 起始值的 1/2,方向断言)", async () => {
166+
mockExact({ 41: 30, 42: 1 });
167+
renderPanel();
168+
fireEvent.click(await screen.findByText("回忆完成 · 查看答案"));
169+
fireEvent.click(screen.getByText("记得"));
170+
await waitFor(() => expect(growToAttr()).toBe(String(intervalToScale(30))));
171+
const grown = Number(growToAttr());
172+
fireEvent.click(await screen.findByText("回忆完成 · 查看答案"));
173+
fireEvent.click(screen.getByText("忘了"));
174+
await waitFor(() => expect(growToAttr()).toBe(String(intervalToScale(1))));
175+
const shrunk = Number(growToAttr());
176+
expect(shrunk, "防空真:两个目标必须不同").toBeLessThan(grown);
177+
expect(shrunk, "回缩方向:终值必须不到起始值的一半").toBeLessThan(grown / 2);
178+
expect(shrunk).toBeGreaterThan(0);
179+
});
180+
181+
it("R3 · 缺 intervalDays(后端只回 true)⇒ 不生长、不抛、不猜(「缺失」≠「间隔为 0」)", async () => {
182+
renderPanel();
183+
fireEvent.click(await screen.findByText("回忆完成 · 查看答案"));
184+
fireEvent.click(screen.getByText("记得"));
185+
await waitFor(() => expect(screen.getByTestId("session-progress").textContent).toBe("2/2"));
186+
expect(growToAttr(), "缺字段 ⇒ 零目标(把它当 0 天就会把刻度清零)").toBeNull();
187+
expect(scaleTick().getAttribute("data-tier")).toBe("none");
188+
expect(screen.queryByTestId("session-last-interval")).toBeNull();
189+
});
190+
});

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { useCallback, useEffect, useState } from "react";
1919
import { invoke } from "@tauri-apps/api/core";
2020
import type { Flashcard } from "../../types/notes";
2121
import PromoteCardButton from "../PromoteCardButton";
22+
import DueScale from "./DueScale";
23+
import { intervalToScale, useScaleGrowth } from "./useScaleGrowth";
2224
import { Loading, Skeleton, StatusLine, Text } from "../../ui/primitives";
2325

2426
/** 四档评分按钮(文案=回忆质量自评) */
@@ -89,6 +91,11 @@ export default function ReviewSessionPanel({ groupId, groupName, active = true,
8991

9092
const current = queue[index] ?? null;
9193

94+
// 刻度生长的落点(§8.6 #4):目标只由 `review_card` 的 `intervalDays`(PB2 ①域 = 精确值)驱动,
95+
// **不**由 `due` 计数驱动(绑总数会出现「回缩了但数字没变」—— 本页总到期数走 token 门控重载)。
96+
const growTo = intervalToScale(lastInterval);
97+
const growth = useScaleGrowth(growTo);
98+
9299
const rate = async (rating: string) => {
93100
if (!current) return;
94101
try {
@@ -129,6 +136,19 @@ export default function ReviewSessionPanel({ groupId, groupName, active = true,
129136
</button>
130137
</div>
131138

139+
{/* 本卡刻度生长/回缩的落点(§8.6 #4):间隔取 `review_card` 的**精确值**(`granularity="exact"`
140+
—— 行派生的整天值不许冒充精确值,PB2 双精度域);`due` 恒 = 1 = 当前这张卡。 */}
141+
<div style={{ padding: "0 16px 8px", flexShrink: 0 }}>
142+
<DueScale
143+
due={current ? 1 : 0}
144+
intervals={lastInterval === null ? undefined : [lastInterval]}
145+
granularity="exact"
146+
growTo={growTo}
147+
growRef={growth.ref}
148+
testId="session-interval-scale"
149+
/>
150+
</div>
151+
132152
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 24 }}>
133153
<div style={{ maxWidth: 640, margin: "0 auto" }}>
134154
{!loaded && (

0 commit comments

Comments
 (0)