用户两句话:
「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
「部分动画十分不合理,会导致页面大范围的闪动,且不能让人自然的把注意力集中在
将要出现的页面上」
## ① 导航:他说得对,我之前是用错误的方式解决对比度
这个应用是**浅色底 + 深字**,导航却是全页唯一一块黑的 —— 那才是"割裂"。我上一轮
为了"白字对比度"把它做成深玻璃,等于**把一块地方永久压黑**来回避问题。
现在导航底色/文字全部走令牌,按主题切换:
浅色:白玻璃 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 条全绿(含"导航走令牌 + 两套令牌都在")。
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||
import { useMailStore } from '../stores/mailStore';
|
||
import { useContactStore } from '../stores/contactStore';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import {
|
||
InboxIcon,
|
||
ContactsIcon,
|
||
PersonIcon,
|
||
CalendarIcon
|
||
} from './icons';
|
||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
|
||
|
||
/**
|
||
* 窄屏底部导航。
|
||
*
|
||
* 移动端把主导航放底部而不是顶部:拇指够得到。
|
||
* 宽屏用的是左侧竖条(Sidebar),两者共用 uiStore 的 viewMode,
|
||
* 所以从窄拖到宽不会丢失当前位置。
|
||
*
|
||
* 这里只放最常用的几项 + 一个「更多」入口(打开抽屉式 Sidebar)——
|
||
* 底部塞满图标会挤成一排看不懂的小方块。
|
||
*/
|
||
const items: {
|
||
short: string;
|
||
mode: ViewMode;
|
||
/** 哪些 viewMode 算这一项选中(「通信」含三个子页签) */
|
||
modes?: ViewMode[];
|
||
Icon: (p: { className?: string }) => JSX.Element;
|
||
adminOnly?: boolean;
|
||
}[] = [
|
||
// 与桌面侧栏同一套合并(用户:「导航项就只剩通信,日历,联系人」):
|
||
// 收件/发件/授权 = 一项「通信」,进去由 CommTabs 区分;管理移到「我的」页底部。
|
||
{ short: '通信', mode: 'inbox', modes: ['inbox', 'sent', 'permissions'], Icon: InboxIcon },
|
||
{ short: '日历', mode: 'calendar', Icon: CalendarIcon },
|
||
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon }
|
||
];
|
||
|
||
export default function NarrowNav() {
|
||
const viewMode = useUIStore(s => s.viewMode);
|
||
const setViewMode = useUIStore(s => s.setViewMode);
|
||
const composing = useUIStore(s => s.composing);
|
||
const narrowPane = useUIStore(s => s.narrowPane);
|
||
|
||
const inbox = useMailStore(s => s.inbox);
|
||
// 与 Sidebar 同一判据:权限请求归「授权」,不算进收件箱未读
|
||
const { normal, permissions } = splitByPermission(inbox);
|
||
const unread = normal.filter(m => m.status === 'unread').length;
|
||
const pendingPerms = countPendingPermissions(permissions);
|
||
const contacts = useContactStore(s => s.contacts);
|
||
|
||
const user = useAuthStore(s => s.user);
|
||
const isAdmin = user?.role === 'admin';
|
||
|
||
const visible = items.filter(n => !n.adminOnly || isAdmin);
|
||
// 点「通信」回到上次用的子页签(与桌面侧栏同一语义)
|
||
const commTab = useUIStore(s => s.commTab);
|
||
|
||
return (
|
||
<nav
|
||
// 悬浮玻璃条的样式全在 index.css 的 .narrow-nav 里(含安全区外边距):
|
||
// 这里不再写 border-t/bg-chrome-900 —— 那是"贴底通栏"的写法,与悬浮冲突
|
||
className="narrow-nav shrink-0 flex items-stretch"
|
||
>
|
||
{visible.map(({ short, mode, modes, Icon }) => {
|
||
const itemModes = modes ?? [mode];
|
||
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件
|
||
const active =
|
||
itemModes.includes(viewMode) && !composing && narrowPane === 'list';
|
||
// 「通信」合并了三项:徽标 = 未读 + 待决策(授权信息不能因为合并而消失)
|
||
const isComm = itemModes.includes('inbox');
|
||
const badge = isComm
|
||
? unread + pendingPerms
|
||
: itemModes.includes('contacts')
|
||
? contacts.length
|
||
: 0;
|
||
const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain';
|
||
return (
|
||
<button
|
||
key={short}
|
||
onClick={() => setViewMode(isComm ? commTab : mode)}
|
||
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>
|
||
{badge > 0 && (
|
||
<span
|
||
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-3xs font-bold flex items-center justify-center ${
|
||
badgeTone === 'unread'
|
||
? 'bg-red-600 text-white'
|
||
: badgeTone === 'perm'
|
||
? 'bg-orange-700 text-white'
|
||
: 'bg-chrome-600 text-chrome-100'
|
||
}`}
|
||
>
|
||
{badge > 99 ? '99+' : badge}
|
||
</span>
|
||
)}
|
||
{active && <span className="absolute top-0 left-1/4 right-1/4 h-0.5 bg-blue-400" />}
|
||
</button>
|
||
);
|
||
})}
|
||
{/* 新建改到列表栏的悬浮圆钮(ComposeFab),与桌面一致 */}
|
||
|
||
<button
|
||
onClick={() => setViewMode('account')}
|
||
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 />
|
||
<span className="absolute -top-0.5 -right-1.5">
|
||
<ConnectionIndicator />
|
||
</span>
|
||
</div>
|
||
<span className="text-3xs leading-none">我的</span>
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|