Skip to content

Commit 6cb4442

Browse files
committed
fix(ui): Toast 补报告与 action/EXIT_MS/落点注释订正
1 parent ef24081 commit 6cb4442

5 files changed

Lines changed: 53 additions & 6 deletions

File tree

‎app/src/ui/primitives/Toast.css‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
.ed-toast {
2323
position: fixed;
2424
right: 18px;
25-
/* 18px 与现状 4 套 toast 的落点一致(recon §3.2)—— 批 4 迁移时位置不得移动;
25+
/* 本原语的**默认落点**:右下 18px。
26+
⚠️ 不声称"与现状 4 套 toast 一致"—— 实测它们彼此不一(`hooks/useTransientToast.tsx:58-61` 是右下 18,
27+
`pages/SessionsPage.tsx:333-336` 是 `top:64/right:16`,`App.tsx:287-306` 与
28+
`components/session-detail/SessionScreenCards.tsx:131-135` 根本不是浮层;`recon §3.2` 只记形态与时长,
29+
未记坐标)。**迁移时每条 toast 的落点由它自己的场景决定**(本文件不为它们背书)。
2630
堆叠偏移留给批 6 的宿主按序赋值(见文件头接缝②),默认展开为 0px */
2731
bottom: calc(18px + var(--ed-toast-stack-offset, 0px));
2832
display: flex;

‎app/src/ui/primitives/Toast.style.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* —— 批 6 的动效 token 真源落地后删 `motion.css` 的变量块即生效。
1414
* ③ 与其它原语同向的纪律:类名 ↔ CSS 规则一致 · 零颜色字面量 · 零 `z-index`(层级是 TS 标尺的
1515
* 职责)· `--ed-stamp` 不进任何 `background` 声明(规格 §4.1「绝不用于按钮」)。
16+
* ④ **退场时长的两个真源对拍**(T9 评审 I-3):`Toast.tsx` 的 `EXIT_MS` 与 `motion.css` 的
17+
* `--ed-dur-toast-out` 必须同值 —— 前者还决定 `usePresence` 的兜底窗口(+80ms),两者脱钩会让
18+
* "退场中途被兜底摘掉"成为无判据可抓的静默缺陷。
1619
*
1720
* 副作用:只读磁盘(同目录两个文件),不修改任何文件。
1821
* 边界:判据前先**剥注释**(注释里会提到反例串);EOL 归一(本仓无 `.gitattributes` 且
@@ -29,6 +32,7 @@ const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
2932

3033
const CLEAN = stripComments(read("Toast.css"));
3134
const TSX = read("Toast.tsx");
35+
const MOTION_CSS = read("motion.css");
3236

3337
/** 颜色字面量(与 `style-seams.test.ts` 同一口径:本层不得出现,颜色只经 `var(--ed-*)`) */
3438
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|hsl)a?\(/;
@@ -106,3 +110,23 @@ describe("③ 纪律判据(与批 0-D 其它原语同向)", () => {
106110
expect(TSX, "不 import 自己的 CSS ⇒ 全部类静默无样式").toContain('import "./Toast.css";');
107111
});
108112
});
113+
114+
describe("④ 退场时长的两个真源对拍(T9 评审 I-3)", () => {
115+
/**
116+
* `Toast.tsx` 的 `EXIT_MS` 与 `motion.css` 的 `--ed-dur-toast-out` 是**并列的真源**:前者还是
117+
* `usePresence` 兜底窗口的一半(`EXIT_MS + timeoutSlackMs(80)` = 220ms)。若将来有人只把 token
118+
* 调大(如 260ms)而 `EXIT_MS` 不动 ⇒ 名义过渡比兜底还长,**退场演到一半就被兜底计时器摘掉**,
119+
* 而没有任何断言会红。故把两者钉成同一个数字。
120+
*
121+
* 边界(本条**不覆盖**):① 只判"两个数字相等",不判 CSS 是否真的引用了该变量(那是 ② 的职责);
122+
* ② `usePresence` 的 slack 默认值(80)不在此判据内 —— 改它须连 `usePresence.test.tsx` 一起改;
123+
* ③ 若批 6 把时长搬进 token 真源并删掉 `motion.css` 的变量块,本条会红,届时按新的唯一真源改写。
124+
*/
125+
it("`const EXIT_MS` == `--ed-dur-toast-out`(否则兜底窗口会把退场中途摘掉)", () => {
126+
const exitMs = /const EXIT_MS = (\d+);/.exec(TSX)?.[1];
127+
const tokenMs = /--ed-dur-toast-out:\s*(\d+)ms;/.exec(MOTION_CSS)?.[1];
128+
expect(exitMs, "Toast.tsx 缺 `const EXIT_MS = <n>;`").toBeDefined();
129+
expect(tokenMs, "motion.css 缺 `--ed-dur-toast-out: <n>ms;`").toBeDefined();
130+
expect(Number(exitMs), "退场名义时长必须与 --ed-dur-toast-out 同值(兜底窗口 = EXIT_MS + 80)").toBe(Number(tokenMs));
131+
});
132+
});

‎app/src/ui/primitives/Toast.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,23 @@ describe("① 渲染与语义契约", () => {
147147
});
148148

149149
describe("② 自动消失与「恰好一次 onDismiss」", () => {
150+
it("action 点击既不自关、也不动计时器 ⇒ 到点仍退场且 onDismiss 恰一次(调用方须自己置 open=false)", () => {
151+
const onClick = vi.fn();
152+
const onDismiss = vi.fn();
153+
render(<Host open durationMs={3000} action={{ label: "撤销", onClick }} onDismiss={onDismiss} />);
154+
settle();
155+
tick(1000);
156+
fireEvent.click(screen.getByRole("button", { name: "撤销" }));
157+
expect(onClick).toHaveBeenCalledTimes(1);
158+
expect(toastOrNull(), "点击本身不关闭").not.toBeNull();
159+
tick(2000); // t=3000:原计时器仍在(既没被清、也没被重排)⇒ 到点照样退场
160+
expect(phase(), "点了 action 不等于把 toast 收起来").toBe("exit");
161+
expect(onDismiss).not.toHaveBeenCalled();
162+
tick(140 + 80); // 兜底窗口到点
163+
expect(toastOrNull()).toBeNull();
164+
expect(onDismiss).toHaveBeenCalledTimes(1);
165+
});
166+
150167
it("到点:先 exit 且仍挂载;transitionend 之后才卸载 + onDismiss 恰一次", () => {
151168
const onDismiss = vi.fn();
152169
render(<Host open durationMs={3000} onDismiss={onDismiss} />);

‎app/src/ui/primitives/Toast.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
* 取消待回调、`closing` 归位(退场中 ⇒ `usePresence` 直回 `"entered"`,**不重放进场**,否则闪一下)、
2525
* 重新计时。`restartTimer` 先清后排 ⇒ 任意时刻**至多一个计时器**(「不排队」是机器可判的)。
2626
* ④ `durationMs <= 0` = **不自动消失**(何时收起只由父级 `open` 决定)。
27-
* ⑤ `action.onClick` **不自动消失**(撤销场景要用户看见结果):点完仍挂在屏上,是否收起由调用方的
28-
* `open` 决定;本组件不消费这次动作,也不因此重置计时。
27+
* ⑤ `action.onClick` **既不自关、也不动计时器**(T9 评审实测后裁决的确定语义):点完仍挂在屏上,
28+
* 计时器**既不清、也不重排** ⇒ 到点仍会走退场并回调一次。**调用方必须在 `action.onClick` 里自己把
29+
* `open` 置 false** —— 否则用户点了"撤销",toast 仍会自己消失(批 4 的撤销 toast 尤其要注意)。
2930
* ⑥ `durationMs` 变化只在**显示中**重新计时;退场中改它不取消退场(能取消退场的输入是 ③ 的
3031
* `message` / `kind`,不含 `durationMs` —— 计划表如此)。
3132
* ⑦ 无障碍(§8.6.1 第 4 条):`role="status"`;**只有 `err` 用 `aria-live="assertive"`**(唯一有
@@ -62,7 +63,8 @@ export interface ToastProps {
6263
kind?: ToastKind;
6364
/** 自动消失时长,默认 `3000`;**`<= 0` = 不自动消失**(规格 §5.3 的撤销 toast 用 10_000) */
6465
durationMs?: number;
65-
/** 行动槽(撤销 / 重试);不传则不渲染该按钮 */
66+
/** 行动槽(撤销 / 重试);不传则不渲染该按钮。**点击既不自关、也不重置计时**(见边界⑤)
67+
* ⇒ 调用方需要在 `onClick` 里自己置 `open=false`,否则到点仍会自动退场 */
6668
action?: ToastAction;
6769
/** **计时到点这类"自己消失"的退场**结束后的通知(恰好一次);父级 `open=false` 不触发它 */
6870
onDismiss: () => void;

‎docs/superpowers/plans/2026-09-11-frontend-redesign-batch0d-primitives.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,11 +1024,11 @@ return createPortal(
10241024
| 计时到点 | 内部 `closing=true` ⇒ `phase="exit"`(140ms);出场结束后 `onDismiss()` **只调用一次**(`dismissedRef` 防重) |
10251025
| **显示中 `message`/`kind` 变化** | **接管**:清计时、若在 `exit` 则取消出场回 `entered`、重新计时(**不排队、不新开一条**) |
10261026
| `durationMs` 变化 | 重新计时 |
1027-
| `action.onClick` | 触发后**不自动消失**,由调用方的 `open` 决定(撤销场景需要用户看见结果) |
1027+
| `action.onClick` | 触发后**既不自关、也不清/不重排计时器**(到点仍会走退场并回调一次)⇒ **调用方须在 `onClick` 里自己置 `open=false`**(撤销场景要用户看见结果)。⚠️ 2026-09-11 T9 评审后按实测行为定稿:原措辞"由调用方的 `open` 决定"会被读成"点了就自动关" |
10281028
| 父级 `open=false` | 直接走出场(父级已知情,**不再**调 `onDismiss`) |
10291029
| 卸载 | 清计时器(照 `useTransientToast.tsx:45-50` 的 cleanup 写法) |
10301030

1031-
- [ ] **Step 1: 写失败测试(jsdom + fake timers)**:进入后 `role="status"` 存在 · `kind="err"` 时 `aria-live="assertive"`、其余 `"polite"` · `durationMs` 到点后先 `exit`(仍挂载)再卸载并**恰好一次** `onDismiss` · **打断用例**:出场期间改 `message` ⇒ 仍挂载、`data-phase` 回到 `entered`、旧计时器不再触发卸载 · **不排队用例**:连续改 3 次 message,`vi.getTimerCount()` 始终为 1 · `action` 渲染按钮且点击调 `onClick` 但**不卸载** · `zIndex` 断言 = `String(Z_TIER.toast)`
1031+
- [ ] **Step 1: 写失败测试(jsdom + fake timers)**:进入后 `role="status"` 存在 · `kind="err"` 时 `aria-live="assertive"`、其余 `"polite"` · `durationMs` 到点后先 `exit`(仍挂载)再卸载并**恰好一次** `onDismiss` · **打断用例**:出场期间改 `message` ⇒ 仍挂载、`data-phase` 回到 `entered`、旧计时器不再触发卸载 · **不排队用例**:连续改 3 次 message,`vi.getTimerCount()` 始终为 1 · `action` 渲染按钮且点击调 `onClick` 但**不卸载**、**计时器不受影响**(推送时钟到 `durationMs` ⇒ 仍先 `exit`、兜底到点后卸载且 `onDismiss` 恰一次) · `zIndex` 断言 = `String(Z_TIER.toast)` · **退场时长两真源对拍**:`Toast.tsx` 的 `EXIT_MS` == `motion.css` 的 `--ed-dur-toast-out`(前者还决定 `usePresence` 的兜底窗口 +80ms)
10321032
- [ ] **Step 2: 实现 + CSS**(`.ed-toast` 固定右下 `position: fixed; right: 18px; bottom: 18px`,三档 kind 配色走 token,`[data-phase]` 三态 + `.ed-toast-action`)
10331033
- [ ] **Step 3: 门禁 + 提交**(commit `feat(ui): L1 原语 Toast(进出场 180/140 且可打断)`)
10341034

0 commit comments

Comments
 (0)