Skip to content

Commit b2d5d47

Browse files
committed
feat(views): add view memory and slot contracts
1 parent 3c739c7 commit b2d5d47

3 files changed

Lines changed: 333 additions & 0 deletions

File tree

‎app/src/views/registry.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* registry — 批 5 视图层的**单一注册表**(C1/C5):按对象类型查「这个对象有哪些视图」。
3+
*
4+
* @ai-context 为什么 `load` 是**可选**的加载器(C1:取代规格 §7.1 的静态 `Component` 字段与
5+
* `lazy` 布尔):默认视图(`raw`;规格 §7.3①「原文永远保留」)必须**常驻** —— 容器同步渲染它、
6+
* 不经 `React.lazy` ⇒ 它的 `load` **缺省**。于是本模块**零静态引用任何视图组件**(顶部只有
7+
* `import type`),却仍持有默认视图的 key/label/icon ⇒ 仍是「按对象类型查可用视图的单一注册表」。
8+
* @ai-context 依赖方向(C1①):`views/registry.ts` **只许被** `pages/SessionsPage.tsx` /
9+
* `pages/NotesPage.tsx` 直接 import。理由:`shell/**` 与 `ui/primitives` barrel 都落在首屏静态
10+
* 可达集内,注册表一旦被它们 import,非默认视图的 `load` 目标就跟着进首屏图。本文件自身
11+
* **不产生任何运行时依赖边**(顶部全是 `import type`)⇒ 静态 import 它 ≠ 把它拉进产物。
12+
* @ai-context **中间态声明(批 5 T6 的依赖顺序造成,不是遗漏)**:视图模块要 `import type` 本文件的
13+
* 槽类型 ⇒ **契约必须先落地**;而「视图清单」(`viewsFor` / `keysFor` / `FROZEN_VIEW_KEYS`)只有
14+
* 在视图模块落地后才满足判据 G3「每个 `load()` 都能解析出真实模块」。故本文件此刻**只含契约**,
15+
* 视图清单随后半提交补齐。详见 `.superpowers/sdd/2026-09-12-frontend-redesign-batch5-view-layer/task-6-report.md`。
16+
* 副作用:无(纯类型 + 常量;0 运行时 import、0 I/O)。
17+
* 边界:本文件只描述**形状**;「有哪些视图 / 什么顺序 / 哪个是默认」由视图清单承载(下半部)。
18+
*/
19+
import type { ComponentType } from "react";
20+
import type { Note } from "../types/notes";
21+
import type { SessionDetail, SessionOcrBlock } from "../types/session";
22+
import type { IconName } from "../ui/icons";
23+
24+
/** 视图记忆的**对象类型粒度**(C5 / 规格决策 23):**不含 `kind`** —— web/photo/video 共记一份。 */
25+
export type ObjectType = "session" | "note";
26+
27+
/** 视图规格(C1)。`key` 是视图记忆里的值,也是视图之间的唯一标识。 */
28+
export interface ViewSpec<P> {
29+
readonly key: string;
30+
readonly label: string;
31+
readonly icon: IconName;
32+
readonly appliesTo: ObjectType;
33+
/** 非默认视图:**模块级惰性**加载器;默认视图**不提供** —— 它由容器同步渲染并常驻(§7.3②)。 */
34+
readonly load?: () => Promise<{ default: ComponentType<P> }>;
35+
}
36+
37+
/** 会话视图槽:全部由**容器**注入 ⇒ 视图自身零 `invoke`、零 `@tauri-apps`(C14②)。 */
38+
export interface SessionViewSlot {
39+
readonly detail: SessionDetail;
40+
readonly imageUrl: (imageRef: string | null) => string | null;
41+
readonly ocrBlocksByScreen: ReadonlyMap<number, SessionOcrBlock[]>;
42+
readonly selectingScreen: number | null;
43+
readonly onSelectScreen: (firstSeenMs: number | null) => void;
44+
readonly panelToast: { screenKey: number; msg: string } | null;
45+
readonly onShowToast: (screenKey: number, msg: string) => void;
46+
readonly onClearToast: () => void;
47+
readonly autoRefineTaskId: number | null;
48+
readonly onRefineTaskStarted?: (sessionId: number, taskId: number) => void;
49+
}
50+
51+
/** 笔记视图槽(同上:零 `invoke`)。`onOpenSession` 可选 —— `session_id` 为空的手动笔记没有目标。 */
52+
export interface NoteViewSlot {
53+
readonly note: Note;
54+
readonly onTaskToggle: (newContent: string) => void;
55+
readonly onOpenSession?: (sessionId: number) => void;
56+
readonly onImageOpen: (src: string, title?: string) => void;
57+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// @vitest-environment node
2+
/**
3+
* useViewMemory.test.ts — 视图记忆的判据 M1–M5(C5 / T6)。
4+
*
5+
* @ai-context 环境:**node 环境**(与全局 `environment:"node"` 一致;仍显式写指令,防全局配置漂移)。
6+
* M1–M5 只依赖**注入的内存 `Storage` 桩**;hook 的渲染路径用 `react-dom/server` 的
7+
* `renderToStaticMarkup` 观测(真 node 无 DOM,`@testing-library/react` 用不了 —— 先例
8+
* `ui/primitives/usePresence.node.test.ts`)。SSR 跑完**渲染阶段**(含 `useState` 的惰性初始化,
9+
* 那正是读记忆的点)⇒「初始值」与「写后重挂载恢复」两条都在判据里。
10+
* @ai-context **诚实边界**:SSR 的 `useState` setter 是 no-op ⇒「setter 触发的重渲染」不可观测;
11+
* 本文件用「写入已落盘 + **新挂载**读回同一值」覆盖同一语义(写入那半段 = `writeViewMemory`,
12+
* 与 hook 的 setter 调的是**同一个**函数)。
13+
* 副作用:无(纯内存;绝不碰 `window.localStorage`)。
14+
*/
15+
import { createElement } from "react";
16+
import type { ReactElement } from "react";
17+
import { renderToStaticMarkup } from "react-dom/server";
18+
import { describe, expect, it } from "vitest";
19+
import type { ObjectType } from "./registry";
20+
import { VIEW_MEMORY_PREFIX, readViewMemory, useViewMemory, viewMemoryKey, writeViewMemory } from "./useViewMemory";
21+
22+
const SESSION_KEYS = ["raw", "tritrack", "proof", "cardflow", "preview"] as const;
23+
const NOTE_KEYS = ["raw", "cardflow"] as const;
24+
25+
/** 记录键集合的内存 `Storage` 桩(M5 要「全程键集合」这个观测面)。 */
26+
function memoryStorage(seed: Record<string, string> = {}): { storage: Storage; keys: () => string[] } {
27+
const map = new Map<string, string>(Object.entries(seed));
28+
const storage = {
29+
get length(): number {
30+
return map.size;
31+
},
32+
clear: (): void => void map.clear(),
33+
getItem: (k: string): string | null => (map.has(k) ? String(map.get(k)) : null),
34+
key: (i: number): string | null => [...map.keys()][i] ?? null,
35+
removeItem: (k: string): void => void map.delete(k),
36+
setItem: (k: string, v: string): void => void map.set(k, v),
37+
} as unknown as Storage;
38+
return { storage, keys: () => [...map.keys()].sort() };
39+
}
40+
41+
/** 任何访问都抛的 `Storage` 桩(隐私模式 / 配额拒绝的形状)。 */
42+
function throwingStorage(): Storage {
43+
const boom = (): never => {
44+
throw new Error("SecurityError: storage 被拒绝");
45+
};
46+
return {
47+
get length(): number {
48+
return boom();
49+
},
50+
clear: boom,
51+
getItem: boom,
52+
key: boom,
53+
removeItem: boom,
54+
setItem: boom,
55+
} as unknown as Storage;
56+
}
57+
58+
interface ProbeProps {
59+
objectType: ObjectType;
60+
defaultKey: string;
61+
validKeys: readonly string[];
62+
/** 省略 = 走 hook 的默认值(`globalThis.localStorage`)—— 真 node 下那是 `undefined`。 */
63+
storage?: Storage;
64+
}
65+
66+
/** 把 hook 的返回值摊到 DOM 属性上(`renderToStaticMarkup` 不允许外部捕获 ⇒ 属性即观测面)。 */
67+
function Probe(props: ProbeProps): ReactElement {
68+
const [key, select] = useViewMemory(props.objectType, props.defaultKey, props.validKeys, props.storage);
69+
return createElement("i", { "data-key": key, "data-select": typeof select });
70+
}
71+
72+
/** 一次「挂载」(独立 hook 实例 ⇒ 再调一次 = **重挂载**)。 */
73+
function mount(props: ProbeProps): string {
74+
return renderToStaticMarkup(createElement(Probe, props));
75+
}
76+
77+
/** 从渲染产物里读回当前视图键(读不到即判据失败,而不是静默返回空)。 */
78+
function shownKey(props: ProbeProps): string {
79+
const html = mount(props);
80+
const m = /data-key="([^"]*)"/.exec(html);
81+
if (m === null || m[1] === undefined) throw new Error(`渲染产物里没有 data-key:${html}`);
82+
return m[1];
83+
}
84+
85+
const sessionProbe = (storage: Storage, defaultKey = "raw"): ProbeProps => ({
86+
objectType: "session",
87+
defaultKey,
88+
validKeys: SESSION_KEYS,
89+
storage,
90+
});
91+
const noteProbe = (storage: Storage, defaultKey = "raw"): ProbeProps => ({
92+
objectType: "note",
93+
defaultKey,
94+
validKeys: NOTE_KEYS,
95+
storage,
96+
});
97+
98+
describe("M1 初始值取记忆(有则取之、无则默认);写后重挂载恢复同一值", () => {
99+
it("无记忆 ⇒ 默认视图;第二个返回值是函数(选择器在位)", () => {
100+
const s = memoryStorage();
101+
expect(shownKey(sessionProbe(s.storage))).toBe("raw");
102+
expect(mount(sessionProbe(s.storage))).toContain('data-select="function"');
103+
});
104+
105+
it("有合法记忆 ⇒ 取记忆值(不是默认值)", () => {
106+
const s = memoryStorage({ "view:default:session": "proof" });
107+
expect(readViewMemory("session", SESSION_KEYS, s.storage)).toBe("proof");
108+
expect(shownKey(sessionProbe(s.storage))).toBe("proof");
109+
});
110+
111+
it("默认值由调用方给(不是硬编码 raw)", () => {
112+
expect(shownKey(sessionProbe(memoryStorage().storage, "cardflow"))).toBe("cardflow");
113+
});
114+
115+
it("写入后**重挂载**读回同一值(记忆真的落到了 storage 上)", () => {
116+
const s = memoryStorage();
117+
expect(shownKey(sessionProbe(s.storage))).toBe("raw");
118+
// 等于 hook 的 setter 调用的那半段(见文件头「诚实边界」)
119+
writeViewMemory("session", "tritrack", s.storage);
120+
expect(shownKey(sessionProbe(s.storage))).toBe("tritrack");
121+
expect(s.storage.getItem("view:default:session")).toBe("tritrack");
122+
});
123+
});
124+
125+
describe("M2 两类互不串(键带 objectType)", () => {
126+
it("写 session 不影响 note,反之亦然;两个键各自独立落盘", () => {
127+
const s = memoryStorage();
128+
writeViewMemory("session", "proof", s.storage);
129+
expect(shownKey(sessionProbe(s.storage))).toBe("proof");
130+
expect(shownKey(noteProbe(s.storage))).toBe("raw");
131+
132+
writeViewMemory("note", "cardflow", s.storage);
133+
expect(shownKey(sessionProbe(s.storage))).toBe("proof");
134+
expect(shownKey(noteProbe(s.storage))).toBe("cardflow");
135+
expect(s.keys()).toEqual(["view:default:note", "view:default:session"]);
136+
});
137+
});
138+
139+
describe("M3 Storage 抛异常 ⇒ 回退默认、不崩、写也不抛", () => {
140+
it("读抛 ⇒ null;hook 挂载不抛且给出默认值", () => {
141+
const s = throwingStorage();
142+
expect(readViewMemory("session", SESSION_KEYS, s)).toBeNull();
143+
expect(() => shownKey(sessionProbe(s.storage))).not.toThrow();
144+
expect(shownKey(sessionProbe(s.storage))).toBe("raw");
145+
});
146+
147+
it("写抛 ⇒ 静默(不抛错)", () => {
148+
expect(() => writeViewMemory("session", "proof", throwingStorage())).not.toThrow();
149+
expect(() => mount(noteProbe(throwingStorage()))).not.toThrow();
150+
});
151+
152+
it("真 node 环境没有 localStorage:**省略注入**也不崩(默认值降级,不是 ReferenceError)", () => {
153+
expect(typeof window).toBe("undefined");
154+
expect(typeof globalThis.localStorage).toBe("undefined");
155+
expect(shownKey({ objectType: "session", defaultKey: "raw", validKeys: SESSION_KEYS })).toBe("raw");
156+
});
157+
});
158+
159+
describe("M4 存值不在 validKeys(被手改成垃圾)⇒ 回退默认", () => {
160+
it("未知视图键 / 空串 / 多余空白 都不算记忆", () => {
161+
const cases: Array<[string, string]> = [
162+
["view:default:session", "nope"],
163+
["view:default:session", ""],
164+
["view:default:session", "proof "],
165+
];
166+
for (const [k, v] of cases) {
167+
const s = memoryStorage({ [k]: v });
168+
expect(readViewMemory("session", SESSION_KEYS, s.storage), `值 ${JSON.stringify(v)} 应视为无记忆`).toBeNull();
169+
expect(shownKey(sessionProbe(s.storage))).toBe("raw");
170+
}
171+
});
172+
173+
it("反向对照:合法值**不**被误判为垃圾(否则 M4 会退化成恒返回默认)", () => {
174+
for (const key of SESSION_KEYS) {
175+
const s = memoryStorage({ "view:default:session": key });
176+
expect(readViewMemory("session", SESSION_KEYS, s.storage)).toBe(key);
177+
expect(shownKey(sessionProbe(s.storage))).toBe(key);
178+
}
179+
});
180+
});
181+
182+
describe("M5 不写 layout: 前缀的任何键(键集合恰为记忆键)", () => {
183+
it("键形状逐字:view:default:{objectType}", () => {
184+
expect(VIEW_MEMORY_PREFIX).toBe("view:default:");
185+
expect(viewMemoryKey("session")).toBe("view:default:session");
186+
expect(viewMemoryKey("note")).toBe("view:default:note");
187+
});
188+
189+
it("一次完整往返(读 + 挂载 + 写)后 storage 的键集合恰为该类型的记忆键,0 个 layout: 键", () => {
190+
const s = memoryStorage();
191+
readViewMemory("session", SESSION_KEYS, s.storage);
192+
mount(sessionProbe(s.storage));
193+
writeViewMemory("session", "cardflow", s.storage);
194+
expect(s.keys()).toEqual(["view:default:session"]);
195+
expect(s.keys().filter((k) => k.startsWith("layout:"))).toEqual([]);
196+
// 与 `shell/columnKeys.freeze.test.ts` 的冻结前缀正面互斥(复用它就会撞红那条判据)
197+
expect(s.keys().filter((k) => k.startsWith("layout:col-width:") || k.startsWith("layout:col-fold:"))).toEqual([]);
198+
});
199+
200+
it("`objectType` 不含 kind:任何 kind 都归一到同一个键(代价已登记)", () => {
201+
const s = memoryStorage();
202+
writeViewMemory("session", "proof", s.storage);
203+
expect(s.keys()).toEqual(["view:default:session"]);
204+
expect(viewMemoryKey("session")).not.toContain("kind");
205+
});
206+
});

‎app/src/views/useViewMemory.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* useViewMemory — 视图记忆(C5 / 规格决策 23):把「用户上次选的视图」记在 `view:default:{objectType}` 上。
3+
*
4+
* @ai-context 键口径**逐字** `view:default:{objectType}`,`objectType ∈ {"session","note"}`:粒度是
5+
* **对象类型**、**不含 `kind`**(决策 23 字面)⇒ web/photo/video 三种会话共享一份记忆。代价(已
6+
* 登记):同一域里换 kind 不重置视图;换来的是「同一类对象行为一致」。
7+
* @ai-context 为什么**不复用** `useColumnLayout`:它的持久化键前缀被 `shell/columnKeys.freeze.test.ts`
8+
* 钉死为**恰** `["col-fold","col-width"]`(前缀与键名是同一个 localStorage 键的两半,改任一半都会让
9+
* 用户已记住的列宽**静默丢失**)。复用它要么写 `layout:` 前缀(撞红那条冻结判据),要么放宽那条
10+
* 判据(本批「既有断言改动数 = 0」)⇒ 独立一份薄层更便宜。
11+
* @ai-context 为什么注入 `Storage` 且读/写都是纯函数:照 `utils/draftStore.ts` 的范式 —— 纯函数在
12+
* **node 环境**可直接用内存桩单测(无 jsdom、无全局污染),`localStorage` 只是**默认值**。默认值取
13+
* `globalThis.localStorage` 而非裸标识符:真 node 环境里裸标识符抛 `ReferenceError`(**绕过**下面的
14+
* `try/catch`,把一次可降级的情形变成崩溃),而 `globalThis.localStorage` 是 `undefined` ⇒ 落进
15+
* 同一条 `try/catch` 降级路径(隐私模式/配额拒绝同理)。
16+
* 副作用:读/写注入的 `Storage`(默认 `window.localStorage`);异常一律**静默** —— 视图记忆是增强层,
17+
* 且读发生在渲染路径上,`console.warn` 会把一次降级放大成控制台风暴。
18+
* 边界:只认「值 ∈ `validKeys`」;其余(空串、被手改的垃圾值、旧版本遗留值)一律当作**没有记忆**,
19+
* 由调用方回退默认视图(M4)。
20+
*/
21+
import { useCallback, useState } from "react";
22+
import type { ObjectType } from "./registry";
23+
24+
export const VIEW_MEMORY_PREFIX = "view:default:";
25+
26+
/** 唯一构造记忆键的地方(C5)。 */
27+
export function viewMemoryKey(objectType: ObjectType): string {
28+
return `${VIEW_MEMORY_PREFIX}${objectType}`;
29+
}
30+
31+
/** 读记忆:无值 / 值不在 `validKeys` / `Storage` 抛 ⇒ `null`(调用方回退默认视图)。 */
32+
export function readViewMemory(
33+
objectType: ObjectType,
34+
validKeys: readonly string[],
35+
storage: Storage = globalThis.localStorage,
36+
): string | null {
37+
try {
38+
const raw = storage.getItem(viewMemoryKey(objectType));
39+
return raw !== null && validKeys.includes(raw) ? raw : null;
40+
} catch {
41+
return null;
42+
}
43+
}
44+
45+
/** 写记忆:配额满 / 隐私模式抛 ⇒ 静默(记忆失败不阻塞切换)。 */
46+
export function writeViewMemory(objectType: ObjectType, key: string, storage: Storage = globalThis.localStorage): void {
47+
try {
48+
storage.setItem(viewMemoryKey(objectType), key);
49+
} catch {
50+
/* 静默失败(配额满 / 隐私模式 / node 无 localStorage) */
51+
}
52+
}
53+
54+
/** 视图记忆 hook:返回 `[当前视图键, 选择视图]`。初始值**惰性读取**(不必每次渲染读盘)。 */
55+
export function useViewMemory(
56+
objectType: ObjectType,
57+
defaultKey: string,
58+
validKeys: readonly string[],
59+
storage: Storage = globalThis.localStorage,
60+
): readonly [string, (key: string) => void] {
61+
const [key, setKey] = useState<string>(() => readViewMemory(objectType, validKeys, storage) ?? defaultKey);
62+
const select = useCallback(
63+
(next: string) => {
64+
setKey(next);
65+
writeViewMemory(objectType, next, storage);
66+
},
67+
[objectType, storage],
68+
);
69+
return [key, select];
70+
}

0 commit comments

Comments
 (0)