Skip to content

Commit 3abe4cf

Browse files
committed
feat(ui): 落审校模式的墨度覆盖与快捷键
1 parent f673bef commit 3abe4cf

7 files changed

Lines changed: 392 additions & 1 deletion

File tree

‎app/src/App.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { pauseReasonLabel } from "./hooks/liveCaptureState";
7777
// `active` 门控都收在 `shell/phaseSource.ts`,T19)
7878
import { useShellPhaseState } from "./shell/phaseSource";
7979
import PhaseChrome from "./shell/PhaseChrome"; // T19:两态叠层(A′ 顶栏 / 58px LIVE 仪表交叉淡入;§8.4)
80+
import { useProofreadMode } from "./shell/proofreadMode"; // 批 8 T9:审校模式位(规格 §4.3③;与 LLM 文本校对无关)
8081
import type { AiTaskState } from "./types";
8182

8283
// 页面键 = 注册表键集(批 3 T6:从前是 9 个字面量的手写联合,现在从注册表派生)
@@ -326,6 +327,17 @@ function MainShell() {
326327
return () => window.removeEventListener("keydown", onKey);
327328
}, []);
328329

330+
const [proofread, toggleProofread] = useProofreadMode(); // 批 8 T9:两条出口(规格 §4.3③)—— ⌘/Ctrl+Shift+R 切换、Esc 退出;按钮归 T11
331+
useEffect(() => {
332+
const onKey = (e: KeyboardEvent) => {
333+
const hit = e.ctrlKey && e.shiftKey && (e.key === "R" || e.key === "r");
334+
if (hit) { e.preventDefault(); toggleProofread(); }
335+
else if (e.key === "Escape" && proofread === "on") toggleProofread();
336+
};
337+
window.addEventListener("keydown", onKey);
338+
return () => window.removeEventListener("keydown", onKey);
339+
}, [proofread, toggleProofread]);
340+
329341
return (
330342
// 全局错误边界在 App 外层(批 2b 结构调整后包住 provider+壳,职责不变)
331343
<div style={{ height: "100vh", display: "flex", flexDirection: "column", fontFamily: "system-ui, sans-serif" }}>

‎app/src/main.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import App from "./App";
55
import "./ui/tokens.css";
66
// v0.16.1:正文多色荧光笔样式(remarkMarkHighlight 注入类名 note-mark[-{colorId}])
77
import "./note-mark.css";
8+
// 批 8 T9:审校模式的墨度覆盖(规格 §4.3③)—— 🔴 **必须在 token CSS(:5)之后**
9+
import "./ui/proofread.css";
810

911
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
1012
<React.StrictMode>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context proofreadMode.dom.test.tsx — 审校模式通道的 **jsdom 面**(真 `<html>` 挂载 / 切换 / 卸载)。
4+
*
5+
* Why 必须单独一件:`proofreadMode.test.ts` 是 **node 环境**文件(那里只能用**假元素**判「写了个叫
6+
* data-proofread-mode 的属性」,且「无 document ⇒ 静默降级」那条也只有 node 能判)—— 但**真 `<html>`**
7+
* 上的写入、以及 hook 的 **effect 生命周期**(挂载不落属性 / 切换跟手 / **卸载清回**)在 node 下不可判
8+
* (`renderToString` 不执行 `useEffect`)。两侧互补,都不可删(先例 = `shellPhase.dom.test.tsx`)。
9+
*
10+
* 判据与它的牙(每条各带专属变异体):
11+
* ① 缺省**不落属性**(规格 §4.3④ 逐字)⇒ 变异体 = effect 里写 `applyProofreadMode("off")` 之外
12+
* 还 `setAttribute(ATTR, "off")`(首屏就多一个属性 ⇒ 红);
13+
* ② 切换跟手 ⇒ 变异体 = 把 effect 的依赖数组改成 `[]`(切了一次属性就不再动 ⇒ 红);
14+
* ③ 🔴 **卸载清回** ⇒ 变异体 = 删掉 cleanup 的 `return () => applyProofreadMode("off")`
15+
* (残留 `data-proofread-mode="on"` ⇒ 红)—— 防「离开阅读面后墨度永久停在审校档」。
16+
*
17+
* 副作用:只写 jsdom 的 `<html data-proofread-mode>`(每个用例前后各清一次,防残留属性让断言空真)。
18+
* 边界(诚实登记):jsdom **不做样式级联**、也**不解析 `var()`** ⇒ 本文件判的是**属性与生命周期**,
19+
* **判不了**「墨度真的变深了」(真解算值与像素面归 T12–T15 的 CDP 读数,本批不判)。
20+
*/
21+
import { act, cleanup, renderHook } from "@testing-library/react";
22+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
23+
import { PROOFREAD_MODE_ATTR, applyProofreadMode, readProofreadMode, useProofreadMode } from "./proofreadMode";
24+
25+
/** `<html>` 上的模式位(逐字读真属性,不走 dataset 别名) */
26+
const htmlMode = (): string | null => document.documentElement.getAttribute(PROOFREAD_MODE_ATTR);
27+
const htmlHas = (): boolean => document.documentElement.hasAttribute(PROOFREAD_MODE_ATTR);
28+
const clearMode = (): void => document.documentElement.removeAttribute(PROOFREAD_MODE_ATTR);
29+
30+
beforeEach(clearMode);
31+
afterEach(() => {
32+
cleanup();
33+
clearMode();
34+
});
35+
36+
describe("真 DOM:默认宿主就是 `<html>`(假元素判不到这一层)", () => {
37+
it("不传元素 ⇒ `<html>` 上出现真属性,且 readProofreadMode 读得回来", () => {
38+
expect(htmlMode(), "起点必须干净,否则下面的断言会空真").toBeNull();
39+
applyProofreadMode("on");
40+
expect(document.documentElement.getAttribute("data-proofread-mode")).toBe("on");
41+
expect(readProofreadMode()).toBe("on");
42+
applyProofreadMode("off");
43+
expect(htmlHas(), "off 之后 `<html>` 上仍留着属性").toBe(false);
44+
});
45+
});
46+
47+
describe("useProofreadMode:挂载不落属性 / 切换跟手 / 卸载清回", () => {
48+
it("挂载 ⇒ **缺省不落属性**(首屏 DOM 上不该多一个属性)", () => {
49+
expect(htmlHas()).toBe(false);
50+
renderHook(() => useProofreadMode());
51+
expect(htmlHas(), "缺省态就落了属性 ⇒ 违反规格 §4.3④「缺省不落属性」").toBe(false);
52+
});
53+
54+
it("toggle 一次 ⇒ 属性出现且值逐字 \"on\";再 toggle ⇒ **摘掉**(不是 \"off\")", () => {
55+
const { result } = renderHook(() => useProofreadMode());
56+
expect(result.current[0]).toBe("off");
57+
act(() => result.current[1]());
58+
expect(htmlMode()).toBe("on");
59+
expect(result.current[0]).toBe("on");
60+
act(() => result.current[1]());
61+
expect(htmlHas(), "第二次 toggle 后属性还在(写成 \"off\" 也算落属性)").toBe(false);
62+
expect(readProofreadMode()).toBeNull();
63+
});
64+
65+
it("卸载 ⇒ 清回常态(属性不残留)—— 防离开阅读面后墨度永久停在审校档", () => {
66+
const { result, unmount } = renderHook(() => useProofreadMode());
67+
act(() => result.current[1]());
68+
expect(htmlMode()).toBe("on");
69+
unmount();
70+
expect(htmlHas(), "卸载后残留 data-proofread-mode ⇒ 覆盖块对下一屏仍生效").toBe(false);
71+
});
72+
73+
it("单一写入方:第二个实例挂载会按**自己的**缺省态把 `<html>` 清回常态 ⇒ 生产只许一处调用", () => {
74+
// 这条判的不是「多实例很安全」,恰恰相反:属性是**全局唯一**的载体,两个实例各持一份 state 必然互擦。
75+
// ⇒ T11 的入口按钮**必须**复用 `App.tsx` 的 `toggleProofread`(`useProofreadMode` 只调一次)。
76+
const a = renderHook(() => useProofreadMode());
77+
act(() => a.result.current[1]());
78+
expect(htmlMode()).toBe("on");
79+
const b = renderHook(() => useProofreadMode());
80+
expect(b.result.current[0], "第二个实例自持一份状态(缺省 off)").toBe("off");
81+
expect(htmlMode(), "第二个实例挂载即写 ⇒ 把第一个实例打开的模式位清掉了").toBeNull();
82+
b.unmount();
83+
a.unmount();
84+
});
85+
});
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* @ai-context proofreadMode.test.ts — 审校模式通道的**纯函数与静态判据**(node 环境,不写 jsdom 头)。
3+
*
4+
* Why node:本文件判的事实恰好与 jsdom 无关 —— ① 属性名与两态的**字面量冻结**;② 写读通道在**注入的
5+
* 假宿主**上的往返(含 `off` ⇒ **摘属性**);③ 「无 `document` ⇒ 静默不动、不抛」这条**只能在真 node
6+
* 环境判**(jsdom 里 `document` 恒存在 ⇒ 那条降级路径不可达);④ `App.tsx` 的第三条 keydown 与两个
7+
* 既有出口的源码形态;⑤ `main.tsx` 的 **import 源序**(CSS 覆盖块的生效纪律);⑥ 「不得合并结案」的解耦。
8+
* 真 `<html>` 上的挂载 / 切换 / 卸载在 `proofreadMode.dom.test.tsx` —— 两侧互补,都不可删
9+
* (先例 = `shellPhase.test.ts` + `shellPhase.dom.test.tsx`)。
10+
*
11+
* 副作用:只读磁盘(`App.tsx` / `main.tsx` / `App.tsx` 的兄弟文件)与临时改写 `globalThis.document`
12+
* (逐条 `finally` 还原,照 `motion/intensity.test.ts` 的 `withDocument` 范式)。
13+
* 边界:① 宿主用**假元素**(只实现 setAttribute / getAttribute / removeAttribute / hasAttribute);
14+
* ② 🔴 **判不到解算后的墨度**(jsdom 与文本级仪器都不解析 `var()`)⇒ 本文件不写「已升到 ≥4.5:1」
15+
* (真解算值归 T12–T15 的 CDP 读数);③ 入口按钮归 T11 ⇒ 「三出口」里的**按钮出口本任务不存在**,
16+
* 下面只守「本层零原生 `<button>`」这条不冲突的形态。
17+
*/
18+
import { readFileSync } from "node:fs";
19+
import { dirname, join } from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
import { createElement } from "react";
22+
import { renderToString } from "react-dom/server";
23+
import { describe, expect, it } from "vitest";
24+
import { stripComments, walkSources, relOf } from "../ui/primitives/sliceScan";
25+
import { PROOFREAD_MODE_ATTR, applyProofreadMode, readProofreadMode, useProofreadMode } from "./proofreadMode";
26+
import type { ProofreadMode } from "./proofreadMode";
27+
28+
const HERE = dirname(fileURLToPath(import.meta.url));
29+
const SRC = join(HERE, "..");
30+
const read = (rel: string): string => readFileSync(join(SRC, ...rel.split("/")), "utf8").replace(/\r\n/g, "\n");
31+
32+
/** 假宿主:写入面 = 属性表(真 DOM 面在 jsdom 文件)。`removeAttribute` / `hasAttribute` 是 V1 的牙。 */
33+
function fakeEl(): HTMLElement {
34+
const attrs = new Map<string, string>();
35+
return {
36+
setAttribute: (k: string, v: string): void => void attrs.set(k, v),
37+
getAttribute: (k: string): string | null => attrs.get(k) ?? null,
38+
removeAttribute: (k: string): void => void attrs.delete(k),
39+
hasAttribute: (k: string): boolean => attrs.has(k),
40+
} as unknown as HTMLElement;
41+
}
42+
43+
/** 逐条注入假全局并还原(`Reflect.deleteProperty` 处理「本来就没有」的情形)。 */
44+
function withDocument<T>(value: unknown, run: () => T): T {
45+
const had = Object.getOwnPropertyDescriptor(globalThis, "document");
46+
Object.defineProperty(globalThis, "document", { value, configurable: true, writable: true });
47+
try {
48+
return run();
49+
} finally {
50+
if (had) Object.defineProperty(globalThis, "document", had);
51+
else Reflect.deleteProperty(globalThis, "document");
52+
}
53+
}
54+
55+
describe("① 属性名(逐字冻结)与消歧(控制方 §2 B2)", () => {
56+
it("属性名逐字 data-proofread-mode;且**不得**等于 data-proofread", () => {
57+
expect(PROOFREAD_MODE_ATTR).toBe("data-proofread-mode");
58+
expect(PROOFREAD_MODE_ATTR, "data-proofread 已被 LLM 文本校对占用(控制方 §2 B2)").not.toBe("data-proofread");
59+
});
60+
61+
it("消歧正控:`proofread` 一名的既有占用面确实还在(本通道不占用它)", () => {
62+
// 域 = 入库的 `app/src` 生产文件(P-34 的入库域口径;测试文件不算占用面)
63+
const prod = walkSources(SRC)
64+
.map((abs) => relOf(SRC, abs))
65+
.filter((rel) => !/\.test\.tsx?$/.test(rel));
66+
const llmSide = prod.filter((rel) => /proofread/i.test(rel));
67+
expect(llmSide.length, "LLM 文本校对的占用面读空了 ⇒ 上面的消歧是无的放矢").toBeGreaterThanOrEqual(3);
68+
// 本通道的实现面里**不得**出现裸 `data-proofread`(除 `-mode` 后缀外)
69+
const mine = ["shell/proofreadMode.ts"];
70+
expect(mine.filter((rel) => /data-proofread(?!-mode)/.test(stripComments(read(rel))))).toEqual([]);
71+
});
72+
});
73+
74+
describe("② 写通道:`on` 置属性 · `off` **摘属性**(规格 §4.3④「缺省不落属性」)", () => {
75+
it("退出后必须不落属性:`off` ⇒ hasAttribute === false(**不是**写成 \"off\")", () => {
76+
const el = fakeEl();
77+
applyProofreadMode("on", el);
78+
expect(el.getAttribute("data-proofread-mode")).toBe("on");
79+
expect(el.hasAttribute("data-proofread-mode")).toBe(true);
80+
applyProofreadMode("off", el);
81+
expect(el.hasAttribute("data-proofread-mode"), "退出后必须不落属性(写成 \"off\" 会让缺省态与显式关不可分)").toBe(false);
82+
expect(el.getAttribute("data-proofread-mode")).toBeNull();
83+
});
84+
85+
it("幂等:连写两次 `on` / 连写两次 `off` 的终态与写一次相同", () => {
86+
const el = fakeEl();
87+
applyProofreadMode("on", el);
88+
applyProofreadMode("on", el);
89+
expect(el.getAttribute("data-proofread-mode")).toBe("on");
90+
applyProofreadMode("off", el);
91+
applyProofreadMode("off", el);
92+
expect(el.hasAttribute("data-proofread-mode")).toBe(false);
93+
});
94+
95+
it("不传元素 ⇒ 默认宿主是 document.documentElement(`<html>`)", () => {
96+
const el = fakeEl();
97+
withDocument({ documentElement: el }, () => applyProofreadMode("on"));
98+
expect(el.getAttribute("data-proofread-mode")).toBe("on");
99+
});
100+
101+
it("无 document(真 node 环境)且不传元素 / 传 null ⇒ 静默不动,不抛", () => {
102+
expect(typeof document).toBe("undefined");
103+
expect(() => applyProofreadMode("on")).not.toThrow();
104+
expect(() => applyProofreadMode("off", null)).not.toThrow();
105+
});
106+
107+
it("非法值 ⇒ **不动**既有属性(不是回退 off:静默不动优于替调用方猜一个值)", () => {
108+
const el = fakeEl();
109+
applyProofreadMode("on", el);
110+
applyProofreadMode("yes" as unknown as ProofreadMode, el);
111+
expect(el.getAttribute("data-proofread-mode"), "非法值把既有属性改掉了").toBe("on");
112+
applyProofreadMode("OFF" as unknown as ProofreadMode, el);
113+
expect(el.hasAttribute("data-proofread-mode"), "大小写不符的值被当成 off 摘了属性").toBe(true);
114+
});
115+
});
116+
117+
describe("③ 读通道:缺失与垃圾值一律 null", () => {
118+
it("属性缺失 / 空串 / 旧版遗留值 / 大小写不符 ⇒ null;两个字面量 ⇒ 原样读回", () => {
119+
const el = fakeEl();
120+
expect(readProofreadMode(el)).toBeNull();
121+
for (const junk of ["", "true", "ON", "on ", "on;off", "mode"]) {
122+
el.setAttribute("data-proofread-mode", junk);
123+
expect(readProofreadMode(el), `垃圾值 ${JSON.stringify(junk)}`).toBeNull();
124+
}
125+
el.setAttribute("data-proofread-mode", "on");
126+
expect(readProofreadMode(el)).toBe("on");
127+
// `"off"` 也在名册内(守卫只认两个字面量),但**写入侧从不落它**(`off` ⇒ removeAttribute)
128+
// ⇒ 这个形态只可能来自外部手写 DOM;读回 `"off"` 与「未设置」在调用方等价(都 = 常态)。
129+
el.setAttribute("data-proofread-mode", "off");
130+
expect(readProofreadMode(el)).toBe("off");
131+
});
132+
133+
it("无 document 且不传元素 ⇒ null,不抛;有宿主时读到的是**真属性**(不是内存副本)", () => {
134+
expect(typeof document).toBe("undefined");
135+
expect(readProofreadMode()).toBeNull();
136+
expect(() => readProofreadMode(null)).not.toThrow();
137+
const el = fakeEl();
138+
applyProofreadMode("on", el);
139+
expect(readProofreadMode(el)).toBe(el.getAttribute("data-proofread-mode"));
140+
});
141+
});
142+
143+
describe("④ useProofreadMode 在 node 环境下可调用(SSR 不执行 effect ⇒ 只证「不抛」)", () => {
144+
it("renderToString 渲染一个调用本 hook 的探针组件 ⇒ 不抛,且 `<html>` 上什么都没写", () => {
145+
const Probe = (): string => {
146+
const [, toggle] = useProofreadMode();
147+
expect(typeof toggle).toBe("function");
148+
return "ok";
149+
};
150+
expect(typeof document).toBe("undefined");
151+
expect(renderToString(createElement(Probe))).toBe("ok");
152+
});
153+
});
154+
155+
describe("⑤ App.tsx 的第三条 window 级 keydown(V1 的机器面;两条既有出口逐字未动)", () => {
156+
const APP = read("App.tsx");
157+
158+
it("R 组合切换 + Esc 退出的两条分支都在,且 hook 被调用", () => {
159+
expect(APP, "本层没有接上模式位 hook").toContain("useProofreadMode()");
160+
expect(
161+
/e\.ctrlKey && e\.shiftKey && \(e\.key === "R" \|\| e\.key === "r"\)/.test(APP),
162+
"缺 ⌘/Ctrl+Shift+R 的判定分支",
163+
).toBe(true);
164+
expect(/e\.key === "Escape" && proofread === "on"/.test(APP), "缺 Esc 退出分支(或没按模式位门控)").toBe(true);
165+
});
166+
167+
it("既有两条 keydown 逐字未动(Ctrl+Shift+A / Ctrl+K),且 window 级 keydown 计数恰 3", () => {
168+
expect(/e\.ctrlKey && e\.shiftKey && \(e\.key === "A" \|\| e\.key === "a"\)/.test(APP), "Ctrl+Shift+A 被改了").toBe(true);
169+
expect(
170+
/e\.ctrlKey && !e\.shiftKey && !e\.altKey && \(e\.key === "k" \|\| e\.key === "K"\)/.test(APP),
171+
"Ctrl+K 被改了(CommandPalette.test.tsx:199 逐字读它)",
172+
).toBe(true);
173+
expect([...APP.matchAll(/window\.addEventListener\("keydown"/g)].length, "本层的 keydown 监听数变了").toBe(3);
174+
expect([...APP.matchAll(/window\.removeEventListener\("keydown"/g)].length, "解绑数 ≠ 监听数").toBe(3);
175+
});
176+
177+
it("本层零原生 `<button>`(按钮出口归 T11 的 TopBar,且必须走 Button 原语)", () => {
178+
expect(stripComments(APP), "App.tsx 出现原生 <button> ⇒ 撞 FROZEN_NATIVE_BUTTON_BY_FILE").not.toContain("<button");
179+
});
180+
});
181+
182+
describe("⑥ 源码顺序(§C53.5 适用点②):main.tsx 的覆盖 CSS 必须在 token CSS **之后**", () => {
183+
const main = read("main.tsx");
184+
const atTokens = main.indexOf('import "./ui/tokens.css";');
185+
const atProof = main.indexOf('import "./ui/proofread.css";');
186+
187+
it("两条 import 都 ≥ 0(反空真)且 token < proofread", () => {
188+
expect(atTokens, "token CSS 的 import 锚读不到 ⇒ 下面的顺序断言会空真").toBeGreaterThanOrEqual(0);
189+
expect(atProof, "proofread.css 的 import 锚读不到(接线丢了?)").toBeGreaterThanOrEqual(0);
190+
expect(atTokens, "覆盖 CSS 排在 token CSS 之前(源序纪律破了)").toBeLessThan(atProof);
191+
});
192+
193+
it("仪器自证:同一台读法对**颠倒顺序**的样本必须判否(否则上面那条恒真)", () => {
194+
const bogus = 'import "./ui/proofread.css";\nimport "./ui/tokens.css";\n';
195+
expect(bogus.indexOf('import "./ui/tokens.css";')).toBeGreaterThan(bogus.indexOf('import "./ui/proofread.css";'));
196+
});
197+
});
198+
199+
describe("⑦ 不得合并结案(规格 §4.3⑦):低置信墨度与审校模式位**互不推出**", () => {
200+
/** 生产文件(剥注释;测试文件不是实现面)。 */
201+
const prod = walkSources(SRC)
202+
.map((abs) => ({ rel: relOf(SRC, abs), text: stripComments(readFileSync(abs, "utf8")) }))
203+
.filter((f) => !/\.test\.tsx?$/.test(f.rel));
204+
205+
it("两面各自活着,且**实现面不相交**(改一边不构成另一边的证据)", () => {
206+
const lowConfidence = prod.filter((f) => /\blowConfidenceClass\s*\(/.test(f.text)).map((f) => f.rel);
207+
const modeFace = prod.filter((f) => f.text.includes("PROOFREAD_MODE_ATTR")).map((f) => f.rel);
208+
// 反空真:两面都必须真的读到东西(否则「互相推不出」是两台空仪器在互相见证)
209+
expect(lowConfidence.length, "低置信墨度的调用点读法失效(C4.2 面)").toBeGreaterThanOrEqual(2);
210+
expect(modeFace, "模式位的实现面读法失效").toContain("shell/proofreadMode.ts");
211+
// 🔴 解耦断言:两件事的实现面**互不相交** —— 低置信墨度(§4.2 的四档墨度)与审校模式位(§4.3③)
212+
// 是两条独立的条件,任何一边的存在都**不得**被当成另一边已兑现的证据。
213+
expect(modeFace.filter((rel) => lowConfidence.includes(rel)), "模式位的实现面里出现低置信调用点 ⇒ 两条条件被合并结案").toEqual([]);
214+
expect(lowConfidence.filter((rel) => prod.find((f) => f.rel === rel)!.text.includes("data-proofread-mode")), "低置信面里出现模式位属性 ⇒ 两条条件被合并结案").toEqual([]);
215+
});
216+
});

0 commit comments

Comments
 (0)