回复 {target}
diff --git a/client/electron/src/index.css b/client/electron/src/index.css
index bb82588..28b7613 100644
--- a/client/electron/src/index.css
+++ b/client/electron/src/index.css
@@ -1051,7 +1051,16 @@ html[data-bg='on'] .nav-rail {
border-radius: 0.25rem;
}
-/* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。 */
+/*
+ * 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。
+ *
+ * ★ 2026-09-15 用户:「主页点击按钮有明显的卡顿」。
+ * 原先这里还带一条 `box-shadow var(--dur-base)` —— **阴影是绘制属性,不是合成器属性**:
+ * 每次 hover/active 都要把元素连同阴影覆盖区重绘一遍,而且它挂在**所有** button/a/input 上。
+ * 列表里几十个按钮 + 玻璃卡片背景时,这笔重绘正好落在点击那一帧上,就是"点按钮卡一下"。
+ * 阴影现在直接跟随状态切换(瞬切),不再补间;观感差别很小,代价差很多。
+ * 真要动画阴影的个别表面,自己显式写 transition。
+ */
button,
a,
input,
@@ -1060,7 +1069,7 @@ select,
[role='button'] {
transition: background-color var(--dur-fast) var(--ease-out-soft),
border-color var(--dur-fast) var(--ease-out-soft),
- color var(--dur-fast) var(--ease-out-soft), box-shadow var(--dur-base) var(--ease-out-soft);
+ color var(--dur-fast) var(--ease-out-soft);
}
/* 尊重系统的「减弱动态效果」:前庭功能障碍者会因动画不适。 */
@@ -1170,6 +1179,8 @@ html.view-switch .glass-control,
/* 尊重系统设置:关了动画就一点都不动(前庭功能敏感的人会被位移动画伤到) */
@media (prefers-reduced-motion: reduce) {
+ /* 2026-09-15:`.rise-in`(挂载即播那档)原先漏在这儿 —— 关掉动画的人照样会看到它 */
+ .rise-in,
html.view-switch .app-shell > *,
html.view-switch .narrow-shell > *,
html.view-switch .rise-in,
diff --git a/client/electron/src/lib/composeOrigin.ts b/client/electron/src/lib/composeOrigin.ts
index 5148f4e..5866f4d 100644
--- a/client/electron/src/lib/composeOrigin.ts
+++ b/client/electron/src/lib/composeOrigin.ts
@@ -28,7 +28,8 @@ if (typeof document !== 'undefined') {
const target = e.target as Element | null;
// 优先取显式标记的入口;同一段布局里打开写信页的多半就是某个 button,
// 所以退一步认最近的 button —— 这样 4 个入口不必逐个改(漏一个的后果是静默无动画)
- const el = target?.closest?.('[data-compose-trigger]') || target?.closest?.('button');
+ const el =
+ target?.closest?.('[data-compose-trigger], [data-morph-trigger]') || target?.closest?.('button');
if (el) {
origin = el.getBoundingClientRect();
at = Date.now();
@@ -38,6 +39,16 @@ if (typeof document !== 'undefined') {
);
}
+/**
+ * 就地记下一个起点(给"点击处已经握着 currentTarget"的场景用,例如回复球:
+ * 那儿不需要全局监听,onClick 里直接记下自己的矩形最准)。
+ */
+export function rememberOrigin(el: Element | null): void {
+ if (!el) return;
+ origin = el.getBoundingClientRect();
+ at = Date.now();
+}
+
/** 只读:渲染期判断"这次有没有起点" */
export function peekComposeOrigin(): DOMRect | null {
return fresh(origin);
diff --git a/client/electron/test/narrow-layout.test.mjs b/client/electron/test/narrow-layout.test.mjs
index 7eafcee..c0b8804 100644
--- a/client/electron/test/narrow-layout.test.mjs
+++ b/client/electron/test/narrow-layout.test.mjs
@@ -281,6 +281,26 @@ check(
*/
const originSrc = code('src/lib/composeOrigin.ts');
const composeSrc2 = code('src/components/ComposePage.tsx');
+
+/*
+ * 回复球也要 morph(2026-09-15 用户:「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」)。
+ * 上一版只给了它 150ms 淡入 —— 观感上等于没有;要的形态与写信页一致:球自己长成回复框。
+ * 判据钉:① 球带 morph 起点标记;② 起点就地取(onClick 里 currentTarget 最准);
+ * ③ 用 useLayoutEffect(首帧前换掉,否则先闪淡入再跳);④ 让位给 morph 时把 CSS 动画关掉
+ * (两条动画都动 transform/opacity,同挂会打架);⑤ 只动合成器属性。
+ */
+const mvSrc = code('src/components/MailView.tsx');
+check(
+ '回复球带 morph 起点标记且就地取矩形',
+ /data-morph-trigger/.test(mvSrc) && /rememberOrigin\(e\.currentTarget\)/.test(mvSrc),
+ '蓝球没接 morph —— 点了只有一层几乎看不见的淡入'
+);
+check(
+ '回复框 morph 在首帧前接管(useLayoutEffect + 关掉 CSS 动画)',
+ /useLayoutEffect\(\(\) => \{[\s\S]{0,900}?el\.style\.animation = 'none'/.test(mvSrc),
+ '用 useEffect 或没关 CSS 动画 —— 会先闪一下淡入再跳一次'
+);
+
check(
'写信页起点矩形:捕获阶段取 + 有时效',
/addEventListener\(\s*'click'/.test(originSrc) && /,\s*true\s*\)/.test(originSrc) && /TTL_MS/.test(originSrc),
@@ -321,17 +341,36 @@ check(
/animation:\s*rise-in/.test(bareRise),
'回复框/转发面板的动画仍被绑在 view-switch 窗口上 —— 那两个动作根本不会触发窗口,等于没动画'
);
+
+/*
+ * 点击卡顿(2026-09-15 用户:「主页点击按钮有明显的卡顿」)。
+ * 阴影是**绘制**属性:把它挂在"所有交互元素"的 transition 上,每次 hover/active 都要
+ * 重绘元素连同阴影区。判据钉"全局控件过渡不得含 box-shadow",
+ * 并留出旁路(个别表面自己写 transition 仍可以动画阴影)。
+ */
+const ctrlRule = (css.match(/button,\s*a,\s*input,\s*textarea,\s*select,\s*\[role='button'\]\s*\{[\s\S]*?\n\}/) || [''])[0];
check(
- '常驻面板用窗口触发的 pane-rise(列表/日历)',
- /pane-rise/.test(code('src/components/MailList.tsx')) &&
- /pane-rise/.test(code('src/components/CalendarView.tsx')) &&
+ '全局控件过渡不含 box-shadow(绘制属性)',
+ ctrlRule.length > 0 && !/box-shadow/.test(ctrlRule),
+ '全局 button/a/input 的 transition 里又出现了 box-shadow —— 每次 hover/active 都重绘阴影区,点击会卡'
+);
+
+check(
+ '日历用窗口触发的 pane-rise,**邮件列表不许挂**',
+ /pane-rise/.test(code('src/components/CalendarView.tsx')) &&
+ !/pane-rise/.test(code('src/components/MailList.tsx')) &&
/html\.view-switch \.pane-rise\s*\{/.test(css),
- '列表/日历没有入场动画,或者把"常驻面板"误挂到了挂载即播的档上(首屏会跟着闪)'
+ '邮件列表挂上了入场动画 —— view-switch 窗口在**选中邮件**时也会触发,' +
+ '于是每点一封信整个列表重播一次,观感就是"邮件列表会闪"(2026-09-15 用户报的就是这个)'
);
check(
'rise-in 也受 prefers-reduced-motion 管',
- reducedBlocks.some(b => /view-switch \.rise-in/.test(b)) && reducedBlocks.some(b => /view-switch \.pane-rise/.test(b)),
- 'reduced-motion 块里漏了 rise-in —— 关掉动画的人仍会被它位移'
+ // 必须行首就是 .rise-in(否则 `html.view-switch .rise-in,` 会把这个判断满足掉 ——
+ // 第一版就是这么假绿的,变异测试抓到了)
+ reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)) &&
+ reducedBlocks.some(b => /view-switch \.rise-in/.test(b)) &&
+ reducedBlocks.some(b => /view-switch \.pane-rise/.test(b)),
+ 'reduced-motion 覆盖不全 —— 漏掉「挂载即播」那档(裸 .rise-in)时,关掉动画的人照样会看到它'
);
check(
'淡出规则同时带 -webkit- 前缀(Electron 里以哪个为准不由我们定)',