Skip to content

Commit b1534d3

Browse files
committed
feat(shell): 落壳层相变态通道
1 parent 0840fe4 commit b1534d3

5 files changed

Lines changed: 318 additions & 1 deletion

File tree

‎app/src/App.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ 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";
7678
import type { AiTaskState } from "./types";
7779

7880
// 页面键 = 注册表键集(批 3 T6:从前是 9 个字面量的手写联合,现在从注册表派生)
@@ -286,6 +288,9 @@ function MainShell() {
286288
// 批 2b:capturing/recovering/paused 三份本地状态删除——采集控制单一状态源
287289
// (CaptureStatusProvider context;挂载拉取+事件+看门狗全在其内)
288290
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");
289294
// v0.8.0 F2(2026-08-21):AI 任务完成通知——全局监听 ai:task-update,
290295
// 跨页面可见(REQ-145"完成通知"落地;内联卡片之外的第二通道)
291296
const [aiToast, setAiToast] = useState<{ text: string; kind: "ok" | "err" } | null>(null);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context shellPhase.dom.test.tsx — 相变态通道的 **jsdom 面**(挂载 / 变更 / 卸载)。
4+
*
5+
* Why 必须单独一件:`shellPhase.test.ts` 是 **node 环境**文件(那里只能用**假元素**判「写了个叫
6+
* data-shell-phase 的属性」,且「无 document ⇒ 静默降级」那条也只有 node 能判)—— 但**真 `<html>`**
7+
* 上的写入、以及 hook 的 **effect 生命周期**(挂载即写 / 变更跟手 / **卸载清回**)在 node 下不可判
8+
* (`renderToString` 不执行 `useEffect`)。两侧互补,都不可删(先例 = T6 的 `intensity.test.ts`
9+
* node 面 + T7 的 `MotionIntensityControl.test.tsx` jsdom 面)。
10+
*
11+
* 判据与它的牙(每条各带专属变异体):
12+
* ① 默认宿主 = 真 `<html>` ⇒ 变异体 = 把 `hostOf` 的默认分支改成 `null`(属性写不上去 ⇒ 红);
13+
* ② 挂载即写 ⇒ 变异体 = 删掉 effect 里的 `applyShellPhase(phase)`;
14+
* ③ 变更跟手 ⇒ 变异体 = 把 effect 的依赖数组改成 `[]`(相位改了属性不动 ⇒ 红);
15+
* ④ 🔴 **卸载清回 "idle"** ⇒ 变异体 = 删掉 cleanup 的 `return () => applyShellPhase("idle")`
16+
* (残留 "review" ⇒ 红)—— 这条防「切走后壳层永久停在该态」(保活挂载下页面不卸载,
17+
* 相位由页面自己写,残留即零 chrome 卡死)。
18+
*
19+
* 副作用:只写 jsdom 的 `<html data-shell-phase>`(每个用例前后各清一次,防残留属性让断言空真)。
20+
* 边界(诚实登记):jsdom **不做样式级联** ⇒ 本文件判的是**属性与生命周期**,**判不了**「采集态顶栏
21+
* 真的变 58px」「复习态用户真的看不到顶栏」—— 像素面归 T18/T19 的消费方与 headless 抽检(本批不做)。
22+
* `useShellPhase` **不读** `matchMedia` ⇒ 本文件不装 `test/motionHarness.ts` 的桩(装了也没人调)。
23+
*/
24+
import { cleanup, renderHook } from "@testing-library/react";
25+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
26+
import { SHELL_PHASE_ATTR, applyShellPhase, readShellPhase, useShellPhase } from "./shellPhase";
27+
import type { ShellPhase } from "./shellPhase";
28+
29+
/** `<html>` 上的相位(逐字读真属性,不走 dataset 别名) */
30+
const htmlPhase = (): string | null => document.documentElement.getAttribute(SHELL_PHASE_ATTR);
31+
const clearPhase = (): void => document.documentElement.removeAttribute(SHELL_PHASE_ATTR);
32+
33+
beforeEach(clearPhase);
34+
afterEach(() => {
35+
cleanup();
36+
clearPhase();
37+
});
38+
39+
describe("真 DOM:默认宿主就是 `<html>`(假元素判不到这一层)", () => {
40+
it("不传元素 ⇒ `<html>` 上出现真属性 data-shell-phase,且 readShellPhase 读得回来", () => {
41+
expect(htmlPhase(), "起点必须干净,否则下面的断言会空真").toBeNull();
42+
applyShellPhase("review");
43+
expect(document.documentElement.getAttribute("data-shell-phase")).toBe("review");
44+
expect(readShellPhase()).toBe("review");
45+
});
46+
});
47+
48+
describe("useShellPhase:挂载即写 / 变更跟手 / 卸载清回", () => {
49+
it("挂载即写 `<html>`:渲染前为空,渲染后 = review", () => {
50+
expect(htmlPhase()).toBeNull();
51+
renderHook(() => useShellPhase("review"));
52+
expect(htmlPhase()).toBe("review");
53+
});
54+
55+
it("相位变更 ⇒ 属性跟手(读到的是新态,不是上一次的残留)", () => {
56+
const { rerender } = renderHook(({ p }: { p: ShellPhase }) => useShellPhase(p), {
57+
initialProps: { p: "capture" as ShellPhase },
58+
});
59+
expect(htmlPhase()).toBe("capture");
60+
rerender({ p: "idle" });
61+
expect(htmlPhase()).toBe("idle");
62+
rerender({ p: "review" });
63+
expect(htmlPhase()).toBe("review");
64+
});
65+
66+
it('卸载 ⇒ 清回 "idle"(**不是**残留 review,也不是把属性删掉)—— 防切走后壳层永久零 chrome', () => {
67+
const { unmount } = renderHook(() => useShellPhase("review"));
68+
expect(htmlPhase()).toBe("review");
69+
unmount();
70+
expect(htmlPhase(), "残留 review ⇒ 顶栏与域导航都不在(零 chrome 卡死)").toBe("idle");
71+
});
72+
73+
it("三态逐个挂载 ⇒ 卸载:卸载一律回 idle(cleanup 不许硬编码上一次的相位)", () => {
74+
for (const phase of ["capture", "review", "idle"] as const) {
75+
const { unmount } = renderHook(() => useShellPhase(phase));
76+
expect(htmlPhase()).toBe(phase);
77+
unmount();
78+
expect(htmlPhase(), `相位 ${phase} 卸载后没有回到 idle`).toBe("idle");
79+
}
80+
});
81+
});

‎app/src/shell/shellPhase.test.ts‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* @ai-context shellPhase.test.ts — 壳层相变态通道的**纯函数与静态判据**(node 环境,不写 jsdom 头)。
3+
*
4+
* Why node:本文件要判的事实恰好与 jsdom 无关 —— ① 三态名册与属性名的**字面量冻结**(逐序数组相等);
5+
* ② 写读通道在**注入的宿主元素**上的往返;③ 「无 `document` ⇒ 静默不动、不抛」这一条**只能在真 node
6+
* 环境判**(jsdom 里 `document` 恒存在 ⇒ 那条降级路径不可达);④ 采集态高度 token 的真源 ↔ 产物绑定;
7+
* ⑤ `motion.css` 相位选择器的**值域闭合**(纯文本判定,**先剥注释**)。
8+
* 与之互补的 jsdom 面(真 `<html>` 的挂载/变更/卸载)在 `shellPhase.dom.test.tsx` —— 两侧都不可删。
9+
*
10+
* 副作用:只读磁盘(`ui/tokens.css` · `ui/primitives/motion.css`)与临时改写 `globalThis.document`
11+
* (逐条 `finally` 还原,照 `motion/intensity.test.ts` 的 `withGlobal` 范式)。
12+
* 边界:① 宿主用**假元素**(只实现 `setAttribute` / `getAttribute`)—— 真 DOM 的同一断言在 jsdom 文件;
13+
* ② 「相位是否真的**切对了**」(采集时顶栏真的变 58px)**不在本任务判据内**:jsdom 不做样式级联、
14+
* `getComputedStyle` 拿不到生效值 ⇒ 像素面进报告的诚实边界(消费方是 T18/T19)。
15+
*/
16+
import { readFileSync } from "node:fs";
17+
import { dirname, join } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
import { createElement } from "react";
20+
import { renderToString } from "react-dom/server";
21+
import { describe, expect, it } from "vitest";
22+
import { SCALE_TOKENS } from "../ui/tokens.gen";
23+
import { stripComments } from "../ui/primitives/sliceScan";
24+
import { SHELL_PHASE_ATTR, SHELL_PHASES, applyShellPhase, readShellPhase, useShellPhase } from "./shellPhase";
25+
import type { ShellPhase } from "./shellPhase";
26+
27+
const HERE = dirname(fileURLToPath(import.meta.url));
28+
const SRC = join(HERE, "..");
29+
30+
/** 假宿主:只带判据要用的那一层(写入面 = 属性表;真 DOM 面见 jsdom 文件)。 */
31+
function fakeEl(): HTMLElement {
32+
const attrs = new Map<string, string>();
33+
return {
34+
setAttribute: (k: string, v: string): void => void attrs.set(k, v),
35+
getAttribute: (k: string): string | null => attrs.get(k) ?? null,
36+
} as unknown as HTMLElement;
37+
}
38+
39+
/** 逐条注入假全局并还原(`Reflect.deleteProperty` 处理「本来就没有」的情形)。 */
40+
function withDocument<T>(value: unknown, run: () => T): T {
41+
const had = Object.getOwnPropertyDescriptor(globalThis, "document");
42+
Object.defineProperty(globalThis, "document", { value, configurable: true, writable: true });
43+
try {
44+
return run();
45+
} finally {
46+
if (had) Object.defineProperty(globalThis, "document", had);
47+
else Reflect.deleteProperty(globalThis, "document");
48+
}
49+
}
50+
51+
const read = (rel: string): string => readFileSync(join(SRC, rel), "utf8");
52+
53+
describe("相态名册与属性名(规格 §6.3 的三态;逐字冻结)", () => {
54+
it("SHELL_PHASES 恰三项且逐序等于 [idle, capture, review];属性名逐字 data-shell-phase", () => {
55+
// 硬字面量对照(**不从被测真源派生期望值**):往名册里塞第 4 个取值 / 改名 ⇒ 本条当场红,
56+
// 这正是「三处集合闭合」的第一颗牙 —— CSS 侧那颗牙在下面第 ⑤ 段。
57+
expect([...SHELL_PHASES]).toEqual(["idle", "capture", "review"]);
58+
expect(SHELL_PHASE_ATTR).toBe("data-shell-phase");
59+
});
60+
61+
it("写读闭合:三态逐个「写 ⇒ 读回」,逐序数组相等(一个值都不许被通道改写)", () => {
62+
const el = fakeEl();
63+
const roundTrip = SHELL_PHASES.map((p) => {
64+
applyShellPhase(p, el);
65+
return readShellPhase(el);
66+
});
67+
expect(roundTrip).toEqual(["idle", "capture", "review"]);
68+
});
69+
});
70+
71+
describe("采集态高度 token(T17 的 token 半;R4.1)", () => {
72+
it("tokens.css 同时含 --ed-nav-h: 56px 与 --ed-nav-h-live: 58px,且 56 一字未动、顺序在前", () => {
73+
const css = read("ui/tokens.css");
74+
const base = "--ed-nav-h: 56px;";
75+
const live = "--ed-nav-h-live: 58px;";
76+
expect(css.includes(base), `tokens.css 缺 ${base}(R4.1:--ed-nav-h 必须保持 56px 不动)`).toBe(true);
77+
expect(css.includes(live), `tokens.css 缺 ${live}(采集态 58px 的新 token)`).toBe(true);
78+
// ⚠️ 顺序是零成本的保险:`navHeight.consumption.test.ts` 的 TOKEN_DECL 取**首个**匹配
79+
expect(css.indexOf(base), "`--ed-nav-h` 必须排在 `--ed-nav-h-live` 之前").toBeLessThan(css.indexOf(live));
80+
});
81+
82+
it("产物同值:58 == SCALE_TOKENS.navHeightLive,且 navHeight 仍 56", () => {
83+
// ⚠️ 真源 `SCALE_SOURCE` 在 TS 侧**读不到这两把键**(实测:`SCALE_SOURCE` 有 12 个键,而 TS 从
84+
// `.mjs` 推断出的键联合只有**前 10 个** —— `navHeight` 是第 11 个、`navHeightLive` 是第 12 个;
85+
// 探针 `keyof typeof SCALE_SOURCE` 打印的联合止于 `iconSizes`)⇒ 本层改用**产物** `tokens.gen.ts`,
86+
// 真源 ↔ 产物 的绑定由 `ui/tokens.drift.test.ts`(重跑生成器逐字节比对)与 `gen-tokens.test.mjs` 承担。
87+
expect(SCALE_TOKENS.navHeightLive).toBe(58);
88+
expect(SCALE_TOKENS.navHeight, "R4.1 逐字:既有顶栏高度一个字节都不许改").toBe(56);
89+
expect(read("ui/tokens.css")).toContain(`--ed-nav-h-live: ${SCALE_TOKENS.navHeightLive}px;`);
90+
});
91+
});
92+
93+
describe("applyShellPhase:写真属性 / 三处降级", () => {
94+
it("显式传元素 ⇒ 属性落在它身上;再写一次覆盖旧值", () => {
95+
const el = fakeEl();
96+
applyShellPhase("review", el);
97+
expect(el.getAttribute("data-shell-phase")).toBe("review");
98+
applyShellPhase("capture", el);
99+
expect(el.getAttribute("data-shell-phase")).toBe("capture");
100+
});
101+
102+
it("不传元素 ⇒ 默认宿主是 document.documentElement(`<html>`)", () => {
103+
const el = fakeEl();
104+
withDocument({ documentElement: el }, () => applyShellPhase("review"));
105+
expect(el.getAttribute("data-shell-phase")).toBe("review");
106+
});
107+
108+
it("无 document(真 node 环境)且不传元素 / 传 null ⇒ 静默不动,不抛", () => {
109+
expect(typeof document).toBe("undefined");
110+
expect(() => applyShellPhase("capture")).not.toThrow();
111+
expect(() => applyShellPhase("capture", null)).not.toThrow();
112+
});
113+
114+
it("非法值 ⇒ **不动**既有属性(不是回退 idle:静默不动优于替调用方猜一个值)", () => {
115+
const el = fakeEl();
116+
applyShellPhase("review", el);
117+
applyShellPhase("recording" as unknown as ShellPhase, el);
118+
expect(el.getAttribute("data-shell-phase")).toBe("review");
119+
});
120+
});
121+
122+
describe("readShellPhase:缺失与垃圾值一律 null", () => {
123+
it("属性缺失 / 空串 / 旧版遗留值 / 大小写不符 ⇒ null;三态字面量 ⇒ 原样读回", () => {
124+
const el = fakeEl();
125+
expect(readShellPhase(el)).toBeNull();
126+
for (const junk of ["", "Capture", "capture ", "live", "review;capture"]) {
127+
el.setAttribute("data-shell-phase", junk);
128+
expect(readShellPhase(el), `垃圾值 ${JSON.stringify(junk)}`).toBeNull();
129+
}
130+
el.setAttribute("data-shell-phase", "idle");
131+
expect(readShellPhase(el)).toBe("idle");
132+
});
133+
134+
it("无 document 且不传元素 ⇒ null,不抛", () => {
135+
expect(typeof document).toBe("undefined");
136+
expect(readShellPhase()).toBeNull();
137+
expect(() => readShellPhase(null)).not.toThrow();
138+
});
139+
});
140+
141+
describe("useShellPhase 在 node 环境下可调用(SSR 不执行 effect ⇒ 只证「不抛」)", () => {
142+
it("renderToString 渲染一个调用 useShellPhase 的探针组件 ⇒ 不抛,且 `<html>` 上什么都没写", () => {
143+
const Probe = (): string => {
144+
useShellPhase("capture");
145+
return "ok";
146+
};
147+
expect(typeof document).toBe("undefined");
148+
expect(renderToString(createElement(Probe))).toBe("ok");
149+
});
150+
});
151+
152+
describe("⑤ CSS 侧值域闭合:motion.css 的相位选择器只许用名册里的三态(先剥注释)", () => {
153+
/** 相位选择器的取值:`[data-shell-phase="x"]` */
154+
const PHASE_SEL = /\[\s*data-shell-phase\s*=\s*"([^"]*)"\s*\]/g;
155+
/** 仪器正对照:同一读法必须能在同一文件里认出**已存在**的档位选择器(`data-motion`) */
156+
const MOTION_SEL = /\[\s*data-motion\s*=\s*"([^"]*)"\s*\]/g;
157+
158+
it("仪器双侧自证 + 值域闭合(剥注释后判定;注释里的字面量不算犯规)", () => {
159+
// 🔴 先剥注释(R8.7 / R40.2 预判):`shell/columnRegistry.ts` 的**注释**里逐字写着
160+
// `data-shell-phase`(T14b 落的前向引用)—— 不剥注释的扫描器会被注释字面量骗到。
161+
const code = stripComments(read("ui/primitives/motion.css"));
162+
const motionHits = [...code.matchAll(MOTION_SEL)].map((m) => m[1]);
163+
expect(motionHits.length, "正对照失败:这台仪器在 motion.css 上认不出 data-motion(域或口径漂了)").toBeGreaterThan(0);
164+
expect(motionHits.every((v) => v.length > 0), "认出了 data-motion 却取不到值 ⇒ 捕获组写错了").toBe(true);
165+
// 邻居家族的负对照:相位正则不许对 `[data-motion="eco"]` 开火(口径隔离)
166+
expect([...`[data-motion="eco"]`.matchAll(PHASE_SEL)].length, "相位正则误伤档位选择器").toBe(0);
167+
// 值域闭合:相位选择器的每个取值都必须在名册内(T19 落地相位块后这条开始有牙)
168+
const phaseHits = [...code.matchAll(PHASE_SEL)].map((m) => m[1]);
169+
const foreign = phaseHits.filter((v) => !(SHELL_PHASES as readonly string[]).includes(v));
170+
expect(foreign, `motion.css 用了名册外的相位值(拼错?):\n${foreign.join("\n")}`).toEqual([]);
171+
// 反空真:无意义串必须 0 命中(同域双侧自证的负样本)
172+
expect([...code.matchAll(/\[\s*data-shell-phase\s*=\s*"zzz-not-a-phase"\s*\]/g)].length).toBe(0);
173+
});
174+
});

‎app/src/shell/shellPhase.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* shellPhase.ts — 壳层**相变态通道**(规格 §6.3;裁决 R4.4 / R4.1)。
3+
*
4+
* @ai-context Why:§6.3 的三态(常态 A′ 顶栏 / 采集态 58px LIVE 仪表 / 复习态零 chrome)是**壳层级**
5+
* 的相变 —— 顶栏与域导航要整体换形,而不是逐组件传 prop。载体 = 写在 `<html>` 上的
6+
* `data-shell-phase`(取值逐字 "idle" | "capture" | "review"),与 `motion/intensity.ts` 的
7+
* `data-motion` **同宿主形态**(R3.1 先例)⇒ CSS 侧一条 `html[data-shell-phase="…"] …` 选择器就能
8+
* 换 chrome 形态,零 JS 参与;且「系统 `prefers-reduced-motion` 优先于相位」由 `motion.css` 的
9+
* **源序**实现(相位块写在 reduced-motion 块之前,归 T19)。
10+
* @ai-context 副作用:写 `<html data-shell-phase>`(文档级)。Tauri 的多窗口变体(`?float=1` /
11+
* `?overlay=1`)各有独立 webview ⇒ 相位互不同步(与 `data-motion` 同款边界,本批不处理跨窗口同步)。
12+
* @ai-context 三处降级**一律静默**(照 T6 `applyIntensity` 的同族口径):① 无 `document`(node 测试
13+
* 环境)⇒ 不写 DOM、不抛;② 显式传 `null` 且无 `document` ⇒ 不写;③ 非法值(不在三态内)⇒ 不写
14+
* —— **不**回退成 "idle"(静默不动比替调用方猜一个值安全;调用方写错值时要能看出来)。
15+
* @ai-context 边界:`useShellPhase` **卸载时清回 "idle"** —— 页面保活挂载(TD-004)下相位可能由页面
16+
* 自己写;若卸载后残留 "review"/"capture",壳层就停在该态(零 chrome ⇒ 顶栏与域导航都不在)。
17+
* 本文件**只建通道**:不写任何相位 CSS、不建 LIVE 仪表 —— §6.3 的视觉与交互分别归 T18 / T19。
18+
*/
19+
import { useEffect } from "react";
20+
21+
/** 三态取值(规格 §6.3 的相变态表;`idle` = 常态 = A′ 顶栏)。顺序 = 冻结快照的书写顺序。 */
22+
export const SHELL_PHASES = ["idle", "capture", "review"] as const;
23+
export type ShellPhase = (typeof SHELL_PHASES)[number];
24+
25+
/** 属性名逐字(R4.4);CSS 侧消费形态 = `html[data-shell-phase="capture"] …` */
26+
export const SHELL_PHASE_ATTR = "data-shell-phase";
27+
28+
/** 取值守卫:只有三态字面量能过(外部输入 / 旧版遗留值一律不放行)。 */
29+
const isPhase = (value: string): value is ShellPhase => (SHELL_PHASES as readonly string[]).includes(value);
30+
31+
/** 宿主:显式元素优先,否则 `<html>`;无 `document`(node 环境)⇒ `null`。 */
32+
const hostOf = (el?: HTMLElement | null): HTMLElement | null =>
33+
el ?? (typeof document === "undefined" ? null : document.documentElement);
34+
35+
/** 写相位(默认目标 = `<html>`);无宿主 / 非法值 ⇒ 静默不动,不抛。 */
36+
export function applyShellPhase(phase: ShellPhase, el?: HTMLElement | null): void {
37+
if (!isPhase(phase)) return;
38+
const target = hostOf(el);
39+
if (target !== null) target.setAttribute(SHELL_PHASE_ATTR, phase);
40+
}
41+
42+
/** 读相位:无宿主 / 属性缺失 / 值不在三态内 ⇒ `null`(调用方按「未设置」处理)。 */
43+
export function readShellPhase(el?: HTMLElement | null): ShellPhase | null {
44+
const raw = hostOf(el)?.getAttribute(SHELL_PHASE_ATTR) ?? null;
45+
return raw !== null && isPhase(raw) ? raw : null;
46+
}
47+
48+
/**
49+
* 相位 hook:挂载与相位变更时写 `<html>`,**卸载时清回 "idle"**(防「切走后壳层永久停在该态」)。
50+
* node 环境下同样可调用(`applyShellPhase` 静默降级,不抛;`useEffect` 在 SSR 下本就不执行)。
51+
*/
52+
export function useShellPhase(phase: ShellPhase): void {
53+
useEffect(() => {
54+
applyShellPhase(phase);
55+
return () => applyShellPhase("idle");
56+
}, [phase]);
57+
}

0 commit comments

Comments
 (0)