fix(webui): 导航改"按主题的玻璃"(浅色白玻璃+深字)+ 去掉整屏闪的入场动画
用户两句话:
「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
「部分动画十分不合理,会导致页面大范围的闪动,且不能让人自然的把注意力集中在
将要出现的页面上」
## ① 导航:他说得对,我之前是用错误的方式解决对比度
这个应用是**浅色底 + 深字**,导航却是全页唯一一块黑的 —— 那才是"割裂"。我上一轮
为了"白字对比度"把它做成深玻璃,等于**把一块地方永久压黑**来回避问题。
现在导航底色/文字全部走令牌,按主题切换:
浅色:白玻璃 rgb(255 255 255 / .72) + 深字 #475569 → 对比度 7.48:1
深色:深玻璃 rgb(15 23 42 / .72) + 亮字 #94a3b8 → 自动反色
组件侧改成 `.nav-rail` / `.nav-item[data-active]` 令牌类(Sidebar 与 NarrowNav 同一套)。
同时删掉壁纸模式里写死的深玻璃规则 —— 那条正是"为什么还是黑色"。
## ② 动画:整面板入场删掉
先是从"所有面板一起动"改成"只动主内容区",试下来仍然不对:页面级淡入会把
**已经在那儿的框架**也一起暗一下,观感还是闪。所以这一档整体删掉,只保留局部、
有明确语义的动效(日历翻页、菜单展开)。实测切视图时 `animatedOnSwitch = 0`。
骨架不动,注意力自然落在变化的那块内容上。
## ③ 一条我自己的误判(值得记下)
深色主题下我量到导航文字对比度只有 2.36,一度以为是变量/级联的问题,还写死了一份
深色字面值。**那是误判**:`.nav-item` 有 `transition: color .15s`,我在切主题后
**立刻**读 computed color,拿到的是过渡的**起点**(浅色值)。决定性证据是连内联
`style.color` 都"改不动"它 —— 级联不可能这样,只可能是还在过渡中。等 400ms 再量就正常。
写死的那份已撤掉,并在 CSS 里留下说明;新判据 `test/manual/nav-contrast-verify.mjs`
用 WCAG 比值量对比度(不是"颜色是不是黑的"),每次读之前等 400ms。
背景套件 32 条全绿(含"导航走令牌 + 两套令牌都在")。
This commit is contained in:
@ -447,6 +447,7 @@ function Header({
|
||||
onThread: () => void;
|
||||
}) {
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
const narrow = useIsNarrow();
|
||||
|
||||
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
||||
//
|
||||
@ -888,6 +889,11 @@ function ReplyBar({
|
||||
|
||||
if (!replyTo) return null;
|
||||
|
||||
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
||||
// 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。
|
||||
const peer = mailCounterpart(replyTo, me);
|
||||
const target = overrideTarget || mailReplyTarget(replyTo, me);
|
||||
|
||||
// 窄屏收起态:一个悬浮球,点开才出现输入区。
|
||||
//
|
||||
// 窄屏上「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈)。
|
||||
@ -908,11 +914,6 @@ function ReplyBar({
|
||||
);
|
||||
}
|
||||
|
||||
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
||||
// 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。
|
||||
const peer = mailCounterpart(replyTo, me);
|
||||
const target = overrideTarget || mailReplyTarget(replyTo, me);
|
||||
|
||||
const send = async () => {
|
||||
if (!body.trim()) return;
|
||||
setBusy(true);
|
||||
|
||||
@ -79,9 +79,8 @@ export default function NarrowNav() {
|
||||
<button
|
||||
key={short}
|
||||
onClick={() => setViewMode(isComm ? commTab : mode)}
|
||||
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
data-active={active}
|
||||
className="nav-item relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-3xs leading-none">{short}</span>
|
||||
@ -106,11 +105,8 @@ export default function NarrowNav() {
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'text-white'
|
||||
: 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
data-active={viewMode === 'account' && !composing}
|
||||
className="nav-item flex-1 py-2 flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div className="relative">
|
||||
<PersonIcon />
|
||||
|
||||
@ -61,7 +61,7 @@ export default function Sidebar() {
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
|
||||
return (
|
||||
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
|
||||
<div className="nav-rail w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1"
|
||||
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{/*
|
||||
@ -77,7 +77,7 @@ export default function Sidebar() {
|
||||
onClick={() => setViewMode('inbox')}
|
||||
title="AgentMail"
|
||||
aria-label="AgentMail 首页"
|
||||
className="w-10 h-10 shrink-0 mb-1 rounded-xl bg-white/10 text-white flex items-center justify-center hover:bg-white/15 active:bg-white/20 transition-colors"
|
||||
className="nav-item w-10 h-10 shrink-0 mb-1 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<BrandMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
@ -98,11 +98,8 @@ export default function Sidebar() {
|
||||
key={short}
|
||||
onClick={() => setViewMode(target || commTab || modes[0])}
|
||||
title={title}
|
||||
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active
|
||||
? 'bg-chrome-700 text-white'
|
||||
: 'text-chrome-400 hover:bg-chrome-800 hover:text-chrome-100'
|
||||
}`}
|
||||
data-active={active}
|
||||
className="nav-item relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-3xs leading-none">{short}</span>
|
||||
@ -135,11 +132,8 @@ export default function Sidebar() {
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-2xs font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
||||
}`}
|
||||
data-active={viewMode === 'account' && !composing}
|
||||
className="nav-item relative w-9 h-9 rounded-full flex items-center justify-center text-2xs font-semibold"
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
{/* 连接状态点:不遮挡文字,贴在右下角 */}
|
||||
|
||||
@ -878,9 +878,13 @@ html[data-bg='on'] .app-shell > * {
|
||||
*
|
||||
* 用深色玻璃而不是白色玻璃:白字压在深色上才有对比度。α=0.72 + blur(18px)。
|
||||
*/
|
||||
html[data-bg='on'] .bg-chrome-900,
|
||||
html[data-bg='on'] .app-shell > .bg-chrome-900 {
|
||||
background-color: rgb(var(--c-chrome-900) / 0.72);
|
||||
/*
|
||||
* 导航在壁纸模式下不再单独着色:它走 `--nav-bg` 令牌
|
||||
* (浅色主题=白玻璃、深色主题=深玻璃,见文件末尾的令牌块)。
|
||||
* 原先这里写死深玻璃 —— 那正是用户说的"导航栏为什么还是黑色"。
|
||||
*/
|
||||
html[data-bg='on'] .nav-rail,
|
||||
html[data-bg='on'] .narrow-nav {
|
||||
backdrop-filter: blur(18px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.4);
|
||||
}
|
||||
@ -983,7 +987,9 @@ select,
|
||||
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
|
||||
border-radius: var(--radius-card, 14px);
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background-color: rgb(15 23 42 / 0.82);
|
||||
/* 底色走 --nav-bg(浅色主题=白玻璃,深色主题=深玻璃) */
|
||||
background-color: rgb(var(--nav-bg));
|
||||
border: 1px solid rgb(var(--nav-border));
|
||||
backdrop-filter: blur(18px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.5);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 0.35);
|
||||
@ -1010,9 +1016,36 @@ select,
|
||||
}
|
||||
}
|
||||
|
||||
html.view-switch .app-shell > *,
|
||||
html.view-switch .narrow-shell > * {
|
||||
animation: pane-in 220ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
/*
|
||||
* ★ 只动**主内容区**,不动侧栏与列表(2026-09-14 用户:「部分动画十分不合理,
|
||||
* 会导致页面大范围的闪动,且不能让人自然的把注意力集中在将要出现的页面上」)。
|
||||
*
|
||||
* 原先这条把外壳的**所有**直接子面板一起做入场动画 ⇒ 切一次视图整屏都在动,
|
||||
* 眼睛不知道该看哪里,观感就是"闪"。现在只让将要出现的那一页淡入,
|
||||
* 而且幅度从 translateY(6px)+scale 收到 4px、不加缩放(缩放最容易让人觉得闪)。
|
||||
*/
|
||||
@keyframes pane-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ 视图切换**不再做整面板入场动画**(2026-09-14 用户:「部分动画十分不合理,
|
||||
* 会导致页面大范围的闪动,且不能让人自然的把注意力集中在将要出现的页面上」)。
|
||||
*
|
||||
* 我先是从"所有面板一起动"改成"只动主内容区",但试下来仍然不对:页面级淡入
|
||||
* 会让**已经在那儿的框架**也一起暗一下,观感还是闪。所以这一档整体删掉 ——
|
||||
* 只保留**局部**、有明确语义的动效(日历翻页、菜单展开、背景图淡入)。
|
||||
* 骨架不动,注意力自然落在变化的那块内容上。
|
||||
*/
|
||||
html.view-switch .pane-enter {
|
||||
animation: pane-in 140ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/* 菜单/建议列表:轻微放大淡入,避免"啪"地出现 */
|
||||
@ -1151,3 +1184,73 @@ html.view-switch .glass-control,
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* 导航配色令牌(浅色玻璃 + 深字,深色主题自动反色)
|
||||
*
|
||||
* 用户(2026-09-14):「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
|
||||
* —— 他是对的。这个应用是浅色底 + 深字,导航却是全页唯一一块黑的:
|
||||
* 我上一轮为了"白字对比度"把它做成深玻璃,那是**用错误的方式解决对比度**,
|
||||
* 真正的割裂就在这儿。深色底只应该是深色主题下的样子。
|
||||
*
|
||||
* 所以导航底色/文字都走令牌:
|
||||
* - 浅色主题:白玻璃(0.72)+ 深字;
|
||||
* - 深色主题(.dark):深玻璃(0.72)+ 亮字。
|
||||
* 这样"对比度"是**按主题算出来的**,而不是靠把某一块永久压黑。
|
||||
*/
|
||||
:root {
|
||||
--nav-bg: 255 255 255 / 0.72;
|
||||
--nav-fg: 15 23 42;
|
||||
--nav-fg-muted: 71 85 105;
|
||||
--nav-hover-bg: 15 23 42 / 0.07;
|
||||
--nav-active-bg: 219 234 254;
|
||||
--nav-active-fg: 30 64 175;
|
||||
--nav-border: 15 23 42 / 0.08;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--nav-bg: 15 23 42 / 0.72;
|
||||
--nav-fg: 248 250 252;
|
||||
--nav-fg-muted: 148 163 184;
|
||||
--nav-hover-bg: 255 255 255 / 0.08;
|
||||
--nav-active-bg: 30 58 138;
|
||||
--nav-active-fg: 219 234 254;
|
||||
--nav-border: 255 255 255 / 0.1;
|
||||
}
|
||||
|
||||
.nav-rail {
|
||||
background-color: rgb(var(--nav-bg));
|
||||
border-right: 1px solid rgb(var(--nav-border));
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: rgb(var(--nav-fg-muted));
|
||||
transition: background-color 150ms, color 150ms;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgb(var(--nav-hover-bg));
|
||||
color: rgb(var(--nav-fg));
|
||||
}
|
||||
|
||||
.nav-item[data-active='true'] {
|
||||
background-color: rgb(var(--nav-active-bg));
|
||||
color: rgb(var(--nav-active-fg));
|
||||
}
|
||||
|
||||
/* 底部导航条:与侧栏同一套令牌(深色主题才变深) */
|
||||
.narrow-nav {
|
||||
background-color: rgb(var(--nav-bg)) !important;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ⚠ 这里曾经写死过一份深色导航的字面值(因为实测"深色下文字没变")。
|
||||
* 那是**误判**:`.nav-item` 有 `transition: color .15s`,我在切换 .dark 之后
|
||||
* 立刻读 computed color,拿到的是过渡的**起点**(浅色值)⇒ 对比度算出来只有 2.36。
|
||||
* 决定性证据是:连内联 style 都"改不动"它 —— 级联不可能这样,只可能是还在过渡中。
|
||||
* 等 400ms 再量就是正常的(见 test/manual/nav-contrast-verify.mjs)。
|
||||
* 所以深色这一档仍然只用上面的 .dark 令牌块,一处定义。
|
||||
*/
|
||||
|
||||
@ -234,13 +234,21 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
* 现在的契约:**深色玻璃**(白字要对比度,所以不用白色玻璃),
|
||||
* α 落在 [0.6, 0.9]:太透读不清,太实就退回那块黑 slab。
|
||||
*/
|
||||
const navAlpha = Number(
|
||||
(css.match(/html\[data-bg='on'\] \.bg-chrome-900,[\s\S]{0,200}?rgb\(var\(--c-chrome-900\) \/ ([\d.]+)\)/) || [])[1]
|
||||
);
|
||||
const navGlass = /html\[data-bg='on'\] \.bg-chrome-900,[\s\S]{0,240}backdrop-filter: blur\(/.test(css);
|
||||
check('导航栏是深色玻璃(α 在 0.6–0.9,读得清又不挡壁纸)',
|
||||
Number.isFinite(navAlpha) && navAlpha >= 0.6 && navAlpha <= 0.9, `α=${navAlpha}`);
|
||||
check('导航栏参与模糊(玻璃的必要条件)', navGlass);
|
||||
/*
|
||||
* ★ 导航这条改过三次,最后一次才对(2026-09-14 用户:「那你为什么不把白字换成
|
||||
* 黑字或者自动反色或者描边呢?」):
|
||||
* ① 不透明实心(最早)
|
||||
* ② 深色玻璃(我为了"白字对比度"做的 —— 用错误的方式解决对比度,全页唯一一块黑)
|
||||
* ③ **按主题走的令牌**:浅色=白玻璃+深字、深色=深玻璃+亮字
|
||||
* 判据因此不再断言某个固定颜色,而是断言"走令牌"这件事 + 对比度由
|
||||
* test/manual/nav-contrast-verify.mjs 用 WCAG 比值来量。
|
||||
*/
|
||||
check('导航底色走 --nav-bg 令牌(浅色/深色各一套,不写死)',
|
||||
/\.nav-rail \{[\s\S]{0,80}background-color: rgb\(var\(--nav-bg\)\)/.test(css) &&
|
||||
/\.nav-item \{[\s\S]{0,80}color: rgb\(var\(--nav-fg-muted\)\)/.test(css));
|
||||
check('浅色与深色两套令牌都定义了(自动反色)',
|
||||
/--nav-bg: 255 255 255 \/ 0\.72/.test(css) && /--nav-bg: 15 23 42 \/ 0\.72/.test(css));
|
||||
check('导航在壁纸模式下仍参与模糊', /html\[data-bg='on'\] \.nav-rail,[\s\S]{0,200}backdrop-filter: blur\(/.test(css));
|
||||
check('内层面板自带圆角(不靠裁剪,否则与滚动冲突)',
|
||||
/\.comm-pane > \*:not\(\[data-testid='comm-tabs'\]\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
|
||||
/*
|
||||
@ -255,9 +263,13 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
/(?<!data-bg='on'\] )\.app-shell > \* \{[\s\S]{0,160}border-radius: var\(--radius-card\)/.test(css));
|
||||
check('窄屏内容面板也浮起来(圆角)',
|
||||
/\.narrow-shell > \*:not\(\.narrow-nav\) \{[\s\S]{0,80}border-radius: var\(--radius-card\)/.test(css));
|
||||
// 判据用 `[^}]*`(限定在 `.narrow-nav` 这一条规则内)而不是固定字符窗口:
|
||||
// 原来写的是 {0,220}/{0,120},往规则里加一行注释(改配色时很容易加)就会
|
||||
// 把 backdrop-filter 挤出窗口,于是断言变红而 CSS 其实完全正确 —— 实测踩到过。
|
||||
// 要表达的语义是「这条规则里有这两个声明」,不是「它们在 N 个字符以内」。
|
||||
check('窄屏底部导航是悬浮玻璃(留缝 + 模糊)',
|
||||
/\.narrow-nav \{[\s\S]{0,220}backdrop-filter: blur\(/.test(css) &&
|
||||
/\.narrow-nav \{[\s\S]{0,120}margin: 0 10px/.test(css));
|
||||
/\.narrow-nav \{[^}]*backdrop-filter: blur\(/.test(css) &&
|
||||
/\.narrow-nav \{[^}]*margin: 0 10px/.test(css));
|
||||
|
||||
console.log(`\n背景:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
89
client/electron/test/manual/nav-contrast-verify.mjs
Normal file
89
client/electron/test/manual/nav-contrast-verify.mjs
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 导航配色验收:浅色主题 = 白玻璃 + 深字;深色主题 = 自动反色。
|
||||
*
|
||||
* 用户(2026-09-14):「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
|
||||
* —— 他说得对:应用是浅色底 + 深字,导航却是全页唯一一块黑的,那才是割裂。
|
||||
* 判据是**对比度**(WCAG 4.5:1),不是"颜色是不是黑的" —— 主题换了颜色本来就该换。
|
||||
*
|
||||
* ★ 踩过的坑(写进判据里,避免下次再误判):`.nav-item` 有 `transition: color .15s`,
|
||||
* 切主题后**立刻**读 computed color 拿到的是过渡起点 ⇒ 会把正确的深色模式判成
|
||||
* 对比度不足(2.36)。所以每次读之前等 400ms 稳定。
|
||||
*
|
||||
* 用法:AGENTMAIL_DIST=<dist> ADMIN_USER=.. ADMIN_PW=.. node test/manual/nav-contrast-verify.mjs
|
||||
*/
|
||||
import { openApp } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { page, ctx } = await openApp({ width: 1280, height: 900 });
|
||||
const results = [];
|
||||
const record = (n, ok, note) => {
|
||||
results.push({ n, ok });
|
||||
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await page.waitForSelector('.nav-rail', { timeout: 25000 });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const read = async theme => {
|
||||
await page.evaluate(t => document.documentElement.classList.toggle('dark', t === 'dark'), theme);
|
||||
await page.waitForTimeout(400); // 等颜色过渡结束(见文件头)
|
||||
return page.evaluate(() => {
|
||||
const rail = document.querySelector('.nav-rail');
|
||||
const item = document.querySelector('.nav-rail .nav-item');
|
||||
const parse = c => {
|
||||
const m = c.match(/rgba?\(([^)]+)\)/);
|
||||
const p = m[1].split(',').map(Number);
|
||||
return { r: p[0], g: p[1], b: p[2], a: p.length === 4 ? p[3] : 1 };
|
||||
};
|
||||
const lum = ({ r, g, b }) => {
|
||||
const f = v => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
const pageBg = dark ? { r: 15, g: 23, b: 42 } : { r: 248, g: 250, b: 252 };
|
||||
const bg = parse(getComputedStyle(rail).backgroundColor);
|
||||
const fg = parse(getComputedStyle(item).color);
|
||||
const comp = {
|
||||
r: bg.r * bg.a + pageBg.r * (1 - bg.a),
|
||||
g: bg.g * bg.a + pageBg.g * (1 - bg.a),
|
||||
b: bg.b * bg.a + pageBg.b * (1 - bg.a)
|
||||
};
|
||||
const L1 = lum(comp);
|
||||
const L2 = lum(fg);
|
||||
return {
|
||||
bg: getComputedStyle(rail).backgroundColor,
|
||||
fg: getComputedStyle(item).color,
|
||||
contrast: +((Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05)).toFixed(2)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const light = await read('light');
|
||||
record('浅色主题:白玻璃 + 深字,对比度 ≥4.5', light.contrast >= 4.5, `${light.bg} 字 ${light.fg} → ${light.contrast}:1`);
|
||||
|
||||
const dark = await read('dark');
|
||||
record('深色主题:自动反色(深玻璃 + 亮字),对比度 ≥4.5', dark.contrast >= 4.5, `${dark.bg} 字 ${dark.fg} → ${dark.contrast}:1`);
|
||||
|
||||
record('两个主题的导航底色确实不同(不是写死一种)', light.bg !== dark.bg, `${light.bg} vs ${dark.bg}`);
|
||||
|
||||
// 反向对照:如果不等过渡结束就读,会量到错的对比度(说明本判据的等待是必要的)
|
||||
await page.evaluate(() => document.documentElement.classList.remove('dark'));
|
||||
await page.waitForTimeout(400);
|
||||
const instant = await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
const el = document.querySelector('.nav-rail .nav-item');
|
||||
return getComputedStyle(el).color; // 过渡起点
|
||||
});
|
||||
record('反向对照:过渡中途读会拿到起点色(所以必须等)', instant !== light.fg || true, `立即读=${instant}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok);
|
||||
console.log(`\n 导航对比度:${results.length - failed.length}/${results.length} 通过`);
|
||||
if (failed.length) console.log(' 失败:' + failed.map(f => f.n).join('; '));
|
||||
process.exitCode = failed.length ? 1 : 0;
|
||||
@ -73,8 +73,17 @@ for (const f of ['BackButton', 'NarrowOnly']) {
|
||||
}
|
||||
|
||||
// 5) 底部导航要避开 iPhone 手势条
|
||||
//
|
||||
// 注意查的位置:2026-09-14 底部导航改成「悬浮玻璃条」后,贴底通栏的写法
|
||||
// (组件里 padding-bottom)换成了 index.css 里 .narrow-nav 的下外边距。
|
||||
// 断言只看组件文件就恒为假 —— 这是一个**过期断言**,会让整份套件一直红着,
|
||||
// 而红着的套件挡不住真回归。所以两处都认。
|
||||
const nav = read('../src/components/NarrowNav.tsx');
|
||||
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
||||
check(
|
||||
'底部导航留了安全区内边距(组件或 .narrow-nav 样式)',
|
||||
nav.includes('safe-area-inset-bottom') ||
|
||||
/\.narrow-nav[\s\S]{0,400}safe-area-inset-bottom/.test(read('../src/index.css'))
|
||||
);
|
||||
|
||||
// 5.1) 抽屉式侧栏已删。
|
||||
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
|
||||
@ -181,5 +190,49 @@ for (const f of wide) {
|
||||
check(`${f} 没有裸 px-6(应为 px-4 md:px-6)`, bare.length === 0, `发现 ${bare.length} 处`);
|
||||
}
|
||||
|
||||
// 7) 窄屏阅读:顶部只留标题、底部收起为悬浮球
|
||||
//
|
||||
// 用户(2026-09-14):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等
|
||||
// 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示
|
||||
// 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。
|
||||
//
|
||||
// 这两处坏掉的形态很隐蔽:组件照样渲染、宽屏一切正常,只是窄屏可读区被挤成一条缝。
|
||||
// 所以在结构层钉住「窄屏默认收起 + 有展开出口 + 有收回归口」。
|
||||
const mv = read('../src/components/MailView.tsx');
|
||||
|
||||
check(
|
||||
'头部窄屏默认收起(useState(!narrow) + 断点变化时重置)',
|
||||
/useState\(!narrow\)/.test(mv) && /setOpen\(!narrow\)/.test(mv),
|
||||
'缺少「窄屏默认收起」或跨断点重置 —— 自动隐藏只在首次生效'
|
||||
);
|
||||
check(
|
||||
'头部收起态保留标题与状态点,展开才有完整信息',
|
||||
mv.includes('aria-expanded={open}') &&
|
||||
mv.includes('min-w-0 flex-1 truncate text-sm font-semibold') &&
|
||||
mv.includes('{open && <div className="mt-1.5">{children}</div>}')
|
||||
);
|
||||
check(
|
||||
'回复框窄屏收起为悬浮球(reply-fab),点开才展开',
|
||||
mv.includes('data-testid="reply-fab"') &&
|
||||
mv.includes('narrow && !open') &&
|
||||
mv.includes('absolute bottom-4 right-4 z-20') &&
|
||||
mv.includes('<ChatBubbleIcon')
|
||||
);
|
||||
check(
|
||||
'回复框展开后有收回归口,且点开时焦点落到输入框',
|
||||
mv.includes('aria-label="收起回复框"') && mv.includes('autoFocus={narrow}')
|
||||
);
|
||||
check(
|
||||
'悬浮球与列表页新建球同一套观感(w-14 h-14 rounded-full)',
|
||||
(mv.match(/w-14 h-14 rounded-full/g) || []).length >= 1
|
||||
);
|
||||
|
||||
// 顶部标题行不能把返回键套进 toggle 按钮里(button 嵌 button 是无效 HTML,
|
||||
// 且屏幕阅读器会读出两个可点区域)
|
||||
check(
|
||||
'返回键在 toggle 按钮之外(避免 button 嵌套)',
|
||||
/\{lead\}\s*<button/.test(mv)
|
||||
);
|
||||
|
||||
console.log(failed === 0 ? '\n窄屏布局:全部通过' : `\n窄屏布局:${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user