Skip to content

Commit 0772d1a

Browse files
committed
feat(motion): 落相变态 chrome 与交叉淡入
1 parent 911b619 commit 0772d1a

9 files changed

Lines changed: 859 additions & 6 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ import BrowserChrome from "./components/BrowserChrome";
7373
// 批 2b:采集控制单一状态源(每窗口单实例 provider + 消费 hook + reason 文案)
7474
import { CaptureStatusProvider, useCaptureControl } from "./hooks/useLiveCaptureControl";
7575
import { pauseReasonLabel } from "./hooks/liveCaptureState";
76-
// 批 6 T17:壳层相变态通道(规格 §6.3)—— 采集态取既有采集单一状态源,复习态由复习页自持(T19 接线)
77-
import { useShellPhase } from "./shell/shellPhase";
76+
// 批 6 T17/T19:壳层相变态(规格 §6.3)—— 本层仍是唯一写入方;复习页只**上抛事实**(相位判定与
77+
// `active` 门控都收在 `shell/phaseSource.ts`,T19)
78+
import { useShellPhaseState } from "./shell/phaseSource";
79+
import PhaseChrome from "./shell/PhaseChrome"; // T19:两态叠层(A′ 顶栏 / 58px LIVE 仪表交叉淡入;§8.4)
7880
import type { AiTaskState } from "./types";
7981

8082
// 页面键 = 注册表键集(批 3 T6:从前是 9 个字面量的手写联合,现在从注册表派生)
@@ -288,9 +290,9 @@ function MainShell() {
288290
// 批 2b:capturing/recovering/paused 三份本地状态删除——采集控制单一状态源
289291
// (CaptureStatusProvider context;挂载拉取+事件+看门狗全在其内)
290292
const capture = useCaptureControl();
291-
// 批 6 T17(规格 §6.3 / R4.4):本层是壳层相变的**唯一写入方**——采集进行中 ⇒ "capture"(58px LIVE
292-
// 仪表相位),否则 "idle"(常态)。不新增 state / effect:相位由既有 `capture.active` 派生。
293-
useShellPhase(capture.active ? "capture" : "idle");
293+
// 批 6 T17/T19(§6.3 / R4.4 / R42⑥):本层是相变的**唯一写入方** —— 采集 ⇒ "capture"(58px 仪表),
294+
// 复习页可见**且**会话在跑 ⇒ "review"(零 chrome),否则 "idle";判定/门控/订阅都在 phaseSource。
295+
const phase = useShellPhaseState(capture.active, page === "review");
294296
// v0.8.0 F2(2026-08-21):AI 任务完成通知——全局监听 ai:task-update,
295297
// 跨页面可见(REQ-145"完成通知"落地;内联卡片之外的第二通道)
296298
const [aiToast, setAiToast] = useState<{ text: string; kind: "ok" | "err" } | null>(null);
@@ -389,6 +391,7 @@ function MainShell() {
389391
T11 的命令面板里另给一条「对话面板」命令,那条是**增量**入口,不替代它);
390392
· 采集徽标 —— ADR-007,切页/最小化后仍可见采集状态(规格 §6.1 明确列了「采集状态」)。
391393
AI toast **不在**这里:它是本文件下方 `MainShell` 最外层渲染的 fixed 覆盖层(裁决 A3)。 */}
394+
<PhaseChrome phase={phase}>
392395
<TopBar
393396
page={page}
394397
onSelect={(key) => setPage(key)}
@@ -436,6 +439,7 @@ function MainShell() {
436439
</>
437440
}
438441
/>
442+
</PhaseChrome>
439443

440444
{/* 页面区(TD-004:保留挂载 + display 切换——页面切换不重挂载,
441445
避免 ClassroomPage 每次进入重复窗口枚举 100-500ms 停顿;状态与事件监听保留) */}

‎app/src/pages/ReviewPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import ReviewSessionPanel from "../components/review/ReviewSessionPanel";
2222
import DueScale from "../components/review/DueScale";
2323
import { dueGroupRows, scopeDueCount, scopeLabel } from "../utils/reviewStats";
2424
import { Loading, Skeleton, StatusLine, Text } from "../ui/primitives";
25+
// 批 6 T19:复习相位**只上抛事实**(有一轮会话在跑 + 本页可见)—— 写 `<html data-shell-phase>` 的
26+
// 唯一写入方仍是 `MainShell`;`visible &&` 这条 active 门控在 `usePublishReviewSession` 内(有判据)。
27+
import { usePublishReviewSession } from "../shell/phaseSource";
2528

2629
interface Props {
2730
/** 页面是否可见(App 层 display 门控同步透传——切回时重载到期统计) */
@@ -51,6 +54,9 @@ export default function ReviewPage({ active, focusGroupId, onFocusGroupConsumed
5154
// 过滤器范围(null=全部;只选不启——「开始复习」才进入会话)
5255
const [selGroupId, setSelGroupId] = useState<number | null>(null);
5356
const [session, setSession] = useState<SessionState | null>(null);
57+
// 批 6 T19(规格 §6.3):会话进行中 ∧ 本页可见 ⇒ 壳层进复习相位(零 chrome)。
58+
// 🔴 不在这里写相位属性(计划 `:1527/:1542` 的写法是双写者 + 无 active 门控 ⇒ 保活挂载下切走会卡死)。
59+
usePublishReviewSession(active, session !== null);
5460

5561
// active 门控切回重载:首挂跳过(挂载 effect 已拉取)——仅 false→true 时递增
5662
const firstRender = useRef(true);

‎app/src/shell/PhaseChrome.test.tsx‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context PhaseChrome.test.tsx — 相变 chrome 的两态叠层 + LIVE 仪表**接线宿主**的 jsdom 面判据
4+
* (批 6 T19;规格 §6.3 / §8.4 / R4.4 / R46②)。
5+
*
6+
* Why 每条判据各带**专属变异体**(R8.6 / R41.7:主闸是**期望比对**,`ran > 0` 只是旁证):
7+
* · C1 叠层**恰两层、逐序**(`[idle, live]`)且三层类名逐字来自导出的常量
8+
* ⇒ 变异体 = 对调两层顺序(交叉淡入的方向语义随之反转)。
9+
* · C2 三个相位下 `inert` 的**逐序矩阵**(含回切)⇒ 变异体 = 给两层写同一个 `inert={false}`
10+
* (不可见的那层会变成可 Tab 聚焦 = 「零 chrome」变成视觉假话)。
11+
* · C3 常态 chrome 的 children 只落在 idle 层里 ⇒ 变异体 = 把 children 渲染到 live 层。
12+
* · C4 三键各自打到**自己的**动作上、`onMark` 打的是**既有命令名** `save_user_screenshot`
13+
* (逐序计数数组)⇒ 变异体 = 把 `onStop` 接到 `pause()`(停止键会变成暂停)。
14+
* · C5 `paused` 的两处语义(`data-paused` + 暂停键可访问名翻转)逐序 ⇒ 变异体 = 恒传 `paused={false}`。
15+
* · C6 `elapsedMs` **只在采集中**走表(不采集时连计时器都不起)⇒ 变异体 = 删掉 `if (!active)` 那一支
16+
* (不采集也在走表 = 仪表报假读数)。
17+
*
18+
* 副作用:只挂 jsdom 容器;`@tauri-apps/api/{core,event}` 与**采集单一状态源**按仓内既有范式桩掉
19+
* (`LiveBar.test.tsx` / `KnowledgePage.test.tsx` 同款),绝不连真后端。
20+
* 边界:jsdom 无排版引擎 ⇒ 判得到「层序 / 类名 / 属性 / 接线」,**判不到**「真的交叉淡入了 220ms」
21+
* 「真的零 chrome」(像素与合成器面进报告 `## 诚实边界`)。
22+
*/
23+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
24+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25+
import { invoke } from "@tauri-apps/api/core";
26+
import { fmtMs } from "../utils/fmt";
27+
import PhaseChrome, {
28+
LiveBarHost,
29+
PHASE_CHROME_CLASS,
30+
PHASE_IDLE_CLASS,
31+
PHASE_LIVE_CLASS,
32+
} from "./PhaseChrome";
33+
import type { ShellPhase } from "./shellPhase";
34+
35+
/** 采集单一状态源的桩:`LiveBarHost` 只消费 `active` / `pausedReason` / 三个动作(其余面不碰)。 */
36+
const host = vi.hoisted(() => ({
37+
control: {
38+
active: false,
39+
pausedReason: null as "manual" | "media" | "foreground" | null,
40+
pause: vi.fn(() => Promise.resolve({ ok: true })),
41+
resume: vi.fn(() => Promise.resolve({ ok: true })),
42+
stop: vi.fn(() => Promise.resolve({ ok: true })),
43+
},
44+
}));
45+
vi.mock("../hooks/useLiveCaptureControl", () => ({ useCaptureControl: () => host.control }));
46+
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) }));
47+
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve("ok")) }));
48+
49+
beforeEach(() => {
50+
host.control.active = false;
51+
host.control.pausedReason = null;
52+
host.control.pause.mockClear();
53+
host.control.resume.mockClear();
54+
host.control.stop.mockClear();
55+
vi.mocked(invoke).mockClear();
56+
});
57+
afterEach(cleanup);
58+
59+
/** 取一个必须存在的元素(缺了就抛 —— 不许用 `!` 把缺失静默成 `undefined`)。 */
60+
function need(selector: string): HTMLElement {
61+
const el = document.body.querySelector<HTMLElement>(selector);
62+
if (el === null) throw new Error(`找不到元素:${selector}`);
63+
return el;
64+
}
65+
const byId = (id: string): HTMLElement => need(`[data-testid="${id}"]`);
66+
/** 一次快照:`[idle 层 inert?, live 层 inert?, 容器 data-phase]`(逐序比对用)。 */
67+
const snapshot = (): readonly [boolean, boolean, string | null] => [
68+
byId("phase-idle").hasAttribute("inert"),
69+
byId("phase-live").hasAttribute("inert"),
70+
byId("phase-chrome").getAttribute("data-phase"),
71+
];
72+
73+
describe("C1~C3 两态叠层的结构、`inert` 矩阵与 children 归属", () => {
74+
it("C1:容器恰两层、逐序 [phase-idle, phase-live],三层类名逐字 == 导出的常量", () => {
75+
const { container } = render(
76+
<PhaseChrome phase="idle">
77+
<span data-testid="idle-probe">A′ 顶栏</span>
78+
</PhaseChrome>,
79+
);
80+
const chrome = container.querySelector<HTMLElement>('[data-testid="phase-chrome"]');
81+
if (chrome === null) throw new Error("相变容器没渲染出来");
82+
const layers = [...chrome.children] as HTMLElement[];
83+
expect(layers.map((l) => l.getAttribute("data-testid")), "两态叠层的层序变了(交叉淡入方向随之反转)").toEqual([
84+
"phase-idle",
85+
"phase-live",
86+
]);
87+
expect(
88+
[chrome.className, ...layers.map((l) => l.className)],
89+
"类名是**跨文件契约**:相位块的选择器靠它们命中(改一边必红)",
90+
).toEqual([PHASE_CHROME_CLASS, PHASE_IDLE_CLASS, PHASE_LIVE_CLASS]);
91+
expect(layers.length, "多出第三层 = 容器高度出现第二个竞争者").toBe(2);
92+
});
93+
94+
it("C2:`inert` 在三个相位下的逐序矩阵(含回切)—— 不可见的那层必须不可聚焦", () => {
95+
const phases: readonly ShellPhase[] = ["idle", "capture", "review", "capture", "idle"];
96+
const { rerender } = render(
97+
<PhaseChrome phase="idle">
98+
<i />
99+
</PhaseChrome>,
100+
);
101+
const rows = phases.map((phase, i) => {
102+
if (i > 0) rerender(<PhaseChrome phase={phase}><i /></PhaseChrome>);
103+
return snapshot();
104+
});
105+
expect(rows, "idle ⇒ 常态层可交互;capture ⇒ 采集层可交互;review ⇒ 两层都不可交互").toEqual([
106+
[false, true, "idle"],
107+
[true, false, "capture"],
108+
[true, true, "review"],
109+
[true, false, "capture"],
110+
[false, true, "idle"],
111+
]);
112+
});
113+
114+
it("C3:常态 chrome(children)只落在 idle 层里,live 层里只有 LIVE 仪表", () => {
115+
render(
116+
<PhaseChrome phase="capture">
117+
<span data-testid="idle-probe">A′ 顶栏</span>
118+
</PhaseChrome>,
119+
);
120+
expect(byId("phase-idle").querySelector('[data-testid="idle-probe"]'), "常态 chrome 丢了").toBeTruthy();
121+
expect(byId("phase-live").querySelector('[data-testid="idle-probe"]'), "常态 chrome 落进了采集层").toBeNull();
122+
expect(byId("phase-live").querySelector('[data-testid="live-bar"]'), "采集层里没有 LIVE 仪表").toBeTruthy();
123+
expect(byId("phase-idle").querySelector('[data-testid="live-bar"]')).toBeNull();
124+
});
125+
});
126+
127+
describe("C4~C6 LIVE 仪表接线宿主:三键 / 暂停语义 / 计时窗口", () => {
128+
it("C4:三键各自打到自己的动作上,`onMark` 打的是**既有命令名**(逐序计数数组)", () => {
129+
host.control.active = true;
130+
render(<LiveBarHost />);
131+
fireEvent.click(byId("live-pause"));
132+
fireEvent.click(byId("live-mark"));
133+
fireEvent.click(byId("live-stop"));
134+
expect([
135+
host.control.pause.mock.calls.length,
136+
host.control.resume.mock.calls.length,
137+
host.control.stop.mock.calls.length,
138+
vi.mocked(invoke).mock.calls.map((c) => c[0]),
139+
]).toEqual([1, 0, 1, ["save_user_screenshot"]]);
140+
});
141+
142+
it("C4 变体:暂停中按「继续」走 resume(不串台到 pause)", () => {
143+
host.control.active = true;
144+
host.control.pausedReason = "manual";
145+
render(<LiveBarHost />);
146+
fireEvent.click(byId("live-pause"));
147+
expect([host.control.resume.mock.calls.length, host.control.pause.mock.calls.length]).toEqual([1, 0]);
148+
});
149+
150+
it("C5:`pausedReason` 逐序映射到 `data-paused` + 暂停键可访问名(null ⇒ 暂停 / 非 null ⇒ 继续)", () => {
151+
const cases: ReadonlyArray<"manual" | null> = [null, "manual", null];
152+
const { rerender } = render(<LiveBarHost />);
153+
const rows = cases.map((reason, i) => {
154+
host.control.pausedReason = reason;
155+
if (i > 0) rerender(<LiveBarHost />);
156+
const bar = byId("live-bar");
157+
return [bar.getAttribute("data-paused"), byId("live-pause").textContent ?? ""] as const;
158+
});
159+
expect(rows).toEqual([
160+
["false", "暂停"],
161+
["true", "继续"],
162+
["false", "暂停"],
163+
]);
164+
});
165+
166+
it("C6:`elapsedMs` 只在采集进行中走表(不采集时连计时器都不起)", () => {
167+
vi.useFakeTimers();
168+
try {
169+
const { rerender } = render(<LiveBarHost />);
170+
act(() => {
171+
vi.advanceTimersByTime(5000);
172+
});
173+
const idleReading = byId("live-elapsed").textContent ?? "";
174+
host.control.active = true;
175+
rerender(<LiveBarHost />);
176+
act(() => {
177+
vi.advanceTimersByTime(3000);
178+
});
179+
const liveReading = byId("live-elapsed").textContent ?? "";
180+
expect([idleReading, liveReading], "不采集也在走表 = 仪表报假读数").toEqual([fmtMs(0), fmtMs(3000)]);
181+
expect([idleReading, liveReading]).toEqual(["00:00", "00:03"]);
182+
} finally {
183+
vi.useRealTimers();
184+
}
185+
});
186+
});

‎app/src/shell/PhaseChrome.tsx‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* PhaseChrome — 壳层相变 chrome 的**宿主与两态叠层**(批 6 T19;规格 §6.3 相变态表;§8.4 / R4.4)。
3+
*
4+
* @ai-context Why:§6.3 的三态要整体换 chrome(常态 = A′ 顶栏 / 采集态 = 58px LIVE 仪表 + 域导航隐藏 /
5+
* 复习态 = 零 chrome)。载体有两半:`<html data-shell-phase>` 的属性通道(T17 建)与 `motion.css` 的
6+
* 相位块(本任务落)。本件补上第三半 —— **两态叠层的 DOM**:同一个格子里放两层
7+
* (`__idle` = 调用方传入的 A′ 顶栏,`__live` = LIVE 仪表),相位块用 `opacity` 交叉淡入
8+
* ⇒ **高度不参与过渡**(§8.4 逐字「相变 chrome 用绝对定位交叉淡入,**不 animate height**」)。
9+
* 域导航不必单独规则:域 Tab 是 A′ 顶栏的子树,整层淡出即「域导航隐藏」(一条规则同时兑现两栏)。
10+
*
11+
* @ai-context `inert` 为什么必须有:`opacity: 0` 只是**看不见** —— 被隐藏那层的按钮仍然可 Tab 聚焦、
12+
* 仍进无障碍树 ⇒「零 chrome」对键盘/读屏用户就是假话。`inert`(React 19 的布尔属性)把整层移出
13+
* 交互与无障碍树,且**是 DOM 事实**(jsdom 判得到),不像 `opacity` 那样要等浏览器级联。
14+
* ⚠️ 诚实边界:`inert` 在本仓只被断言「属性在位」,真实的焦点行为(Tab 是否真的跳过)**未在真机验证**。
15+
*
16+
* @ai-context 副作用:① 写 DOM(三层 div 的类名 / `data-phase` / `inert`);② 挂载 `LiveBarHost`
17+
* ⇒ 它订阅 `live:audio-level` 并在**采集中**起一个 1s tick(不采集时不起计时器)。不读 store、不发 IPC。
18+
* @ai-context 边界:① 本件**只做结构**——相位 CSS 全在 `motion.css` 的相位块里(本文件零行内定位样式);
19+
* ② `phase` 由调用方(`MainShell` 经 `useShellPhaseState`)注入,**本件不自己写属性**(单一写入方);
20+
* ③ 类名三件是**跨文件契约**:`shellPhase.guard.test.ts` 拿本文件的常量与相位块的选择器做**双向闭合**,
21+
* 改名只改一边必红。
22+
*/
23+
import { useEffect, useState } from "react";
24+
import type { ReactElement, ReactNode } from "react";
25+
import { invoke } from "@tauri-apps/api/core";
26+
import { useCaptureControl } from "../hooks/useLiveCaptureControl";
27+
import LiveBar from "./LiveBar";
28+
import type { ShellPhase } from "./shellPhase";
29+
30+
/** 叠层容器类名(相位块的两个选择器目标都在这里;改名必须同步 `motion.css`)。 */
31+
export const PHASE_CHROME_CLASS = "shell-phase";
32+
/** 常态层(A′ 顶栏)类名 —— 复习/采集态被淡出 + 浮起的那一层。 */
33+
export const PHASE_IDLE_CLASS = "shell-phase__idle";
34+
/** 采集层(LIVE 仪表)类名 —— 采集态回到流里、其余相位浮起并透明。 */
35+
export const PHASE_LIVE_CLASS = "shell-phase__live";
36+
37+
export interface PhaseChromeProps {
38+
/** 当前相位(来自唯一写入方 `useShellPhaseState` 的返回值 —— 与写入 `<html>` 的值**同源同帧**) */
39+
readonly phase: ShellPhase;
40+
/** 常态 chrome(A′ 顶栏)—— 由 `MainShell` 传入,本件不 import `TopBar`(保持可替换) */
41+
readonly children: ReactNode;
42+
}
43+
44+
/**
45+
* 渲染相变 chrome 的两态叠层。
46+
*
47+
* 返回 `ReactElement`(= 契约里的 `JSX.Element`):React 19 把全局 `JSX` 命名空间收进 `React.JSX`
48+
* (同 `LiveBar.tsx` / `Text.tsx` 先例)。
49+
*/
50+
export default function PhaseChrome({ phase, children }: PhaseChromeProps): ReactElement {
51+
return (
52+
<div className={PHASE_CHROME_CLASS} data-testid="phase-chrome" data-phase={phase}>
53+
{/* 常态层:idle 之外一律 `inert`(不可聚焦、不进无障碍树) */}
54+
<div className={PHASE_IDLE_CLASS} data-testid="phase-idle" inert={phase !== "idle"}>
55+
{children}
56+
</div>
57+
{/* 采集层:**恒挂载**(两态叠在同一格才能交叉淡入;条件挂载会让淡出变成硬切) */}
58+
<div className={PHASE_LIVE_CLASS} data-testid="phase-live" inert={phase !== "capture"}>
59+
<LiveBarHost />
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
/**
66+
* LIVE 仪表的**接线宿主**(T18 的 `LiveBar` 在 T18 结束时 0 生产调用点 ⇒ 由本件挂载;R46② / §四十六附 2)。
67+
*
68+
* @ai-context 五个 props 的来源**逐项**(`LiveBarProps` 是 T18 的冻结快照,本件只供值、不改快照):
69+
* · `paused` = `capture.pausedReason !== null`(采集单一状态源的既有语义:null = 未暂停)
70+
* · `onTogglePause` = `paused ? capture.resume() : capture.pause()`(失败文案由 context 的
71+
* `CaptureActionResult.message` 承担;本件**不**自造提示——自动暂停期的「物理无变化」说明在 context 里)
72+
* · `onStop` = `capture.stop()`
73+
* · `onMark` = `invoke("save_user_screenshot")` —— 与 `ClassroomCapturePanel.tsx:193` 的「⭐ 标记此刻」
74+
* **同一条既有命令**(壳层复用,不新建命令、不动后端);失败只 `console.warn`(本件没有提示面,
75+
* 采集本身不受影响),照 `App.tsx:337` 的既有口径「不吞异常」。
76+
* · `elapsedMs` = 墙钟(`Date.now() - 本次 active 起点`),**只在采集中**起 1s tick
77+
* —— 与 `LiveActivityPanel.tsx:130/154/161` 的既有范式同源。
78+
* ⚠️ 登记:`live_session_status` 的载荷里**没有** `started_at`(`types/live.ts:36-48` 实测)⇒ 前端
79+
* 只能用「自己第一次看到 active 的时刻」,与后端会话起点的毫秒级误差属后端事件面(本任务不改后端)。
80+
*/
81+
export function LiveBarHost(): ReactElement {
82+
const capture = useCaptureControl();
83+
const active = capture.active;
84+
const paused = capture.pausedReason !== null;
85+
const [elapsedMs, setElapsedMs] = useState(0);
86+
87+
useEffect(() => {
88+
if (!active) {
89+
setElapsedMs(0);
90+
return;
91+
}
92+
const startedAt = Date.now();
93+
setElapsedMs(0);
94+
const timer = setInterval(() => setElapsedMs(Date.now() - startedAt), 1000);
95+
return () => clearInterval(timer);
96+
}, [active]);
97+
98+
return (
99+
<LiveBar
100+
elapsedMs={elapsedMs}
101+
paused={paused}
102+
onTogglePause={() => {
103+
void (paused ? capture.resume() : capture.pause());
104+
}}
105+
onMark={() => {
106+
void invoke<string>("save_user_screenshot").catch((e: unknown) => {
107+
console.warn("[PhaseChrome] 标记此刻失败(采集不受影响):", e);
108+
});
109+
}}
110+
onStop={() => {
111+
void capture.stop();
112+
}}
113+
/>
114+
);
115+
}

0 commit comments

Comments
 (0)