Files
MailUI4Agents/client/electron/src/index.css
jianf 1094154f26 feat(ele): 自绘窗口标题栏 —— 对齐 HarmonyOS 的 PC/2in1 窗口外观
## 问题

用户:「现在窗口外观还是 ele 默认外观,很原始」。

查证了两端在 PC 上的实际做法,差距是**结构性的**:

| | HarmonyOS (PC/2in1) | Electron(改之前)|
|---|---|---|
| 窗口装饰 | `setWindowDecorVisible(false)` 隐掉系统标题栏 | 系统默认标题栏 |
| 顶栏 | 自绘 `AppHeader`(圆角/材质/标题/可选返回键) | 无 |
| 避让 | `Insets` 三量:`statusBar`/`navIndicator`/`windowDecor` | 无这个概念 |
| 拖动 | 系统 | 系统 |

⇒ `grep app-region|titleBarStyle` 在整个 `client/electron/` 命中 **0**。
鸿蒙那边早就走完「内容铺满 + 自绘顶栏」,Electron 还停在最原始的系统窗口。

## 修法

* `frame: false` + `titleBarStyle: 'hidden'` —— 两个**一起**。
  只给 `titleBarStyle` 在 Win/Linux 上仍留着系统边框(可拖动、可双击),
  观感还是「系统窗口 + 一条自己画的头」;既然自绘窗口按钮 = 框架整个接管,
  那圈系统边框就是多余的一层。
  ★ 不用 `titleBarOverlay`(官方「留系统按钮」那条):它留的是**系统**按钮,
  观感仍由系统决定,与「自绘」目标相反,且 Linux 支持不齐。
  本项目只有 win/linux target(无 mac)⇒ 统一一条路,不做两套形态。

* 新增 `src/components/TitleBar.tsx`:固定顶栏 + 左标题 + 右三键。
  ★ 挂点选在 `main.tsx`、与 `.app-backdrop` 同层,**不在 App 里面** ——
    App 的根节点有四个 return 分支(宽屏/窄屏/独页/…),
    塞进去就得改四处,漏一处就是「某个页面没有标题栏」。
    它自己是 `position: fixed`,与 App 布局零耦合。

## 三条实现纪律(都写进注释了)

① **拖动靠 CSS `-webkit-app-region: drag`,不靠 JS 鼠标事件。**
   `drag` 区域由浏览器/系统处理,不受页面重排影响(sandbox 下那种
   mousemove 算窗口位置的写法既慢又脆)。
   ★ 代价:**drag 区域里的交互元素收不到点击** ⇒ 按钮与标题文字都显式
   `no-drag`。实测 `elementFromPoint` 命中 `BUTTON.titlebar-btn`(不是拖拽层)。

② **最大化状态是「订阅」来的,不是「查」来的。**
   最大化有三条**不经过按钮**的路径:双击拖拽区(系统处理,JS 收不到事件)、
   `Win+↑↓`、拖到屏幕边缘的 Snap Layouts ⇒ 只在点按钮时查一次,
   图标必然与真实状态脱节。所以主进程用 `pushMaxState` 主动推
   (`maximize`/`unmaximize`/进退全屏四个事件),渲染层只订阅。
   另:`toggleMaximize` 刻意**不**拆成 maximize/unmaximize ——
   双击时序上会多一次异步往返,IPC 往返期间用户可能又双击了一次。
   ⇒ 读状态与决定动作在主进程侧原子完成。

③ **浏览器里整条不渲染。**
   没有 bridge 时 `TitleBar` 返回 `null`;高度占位(`html.titlebar-on`)
   由 `main.tsx` 用**同一个** `__AGENTMAIL_SHELL__` 判据挂上 ——
   CSS 不会看 bridge,不挂则网页端白丢 36px。

## 尺寸为什么是 36px

鸿蒙 PC/2in1 实测 `windowDecor=37`(见 `MainPage.ets` 的 insets 日志
statusBar=38.6 navIndicator=27.8 windowDecor=37)⇒ 两端窗口控件高度对齐同一量级,
免得并排摆两个应用时一个头厚一个头薄。这里取 36:桌面端按物理像素算,
1x 下更接近常见做法,且 12px 字号不出血。**要改就两端一起改。**

## 顺带记一条踩过的坑(它就在这批代码里)

`preload.cjs` 是 `.cjs`,**不能写 TS 类型标注**。第一版写了
`(cb: (maximized: boolean) => void)` ⇒ 整个 preload **静默**加载失败 ⇒
`window.agentmail === undefined` ⇒ 账号读不到(回退到网页版登录页,
显示用户名+密码,桌面壳里注定失败)+ 标题栏不渲染。
★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,照常全绿。
已在 preload 注释里写明,并在 `main-process-security.test.mjs` 加了加载闸门
(下一个提交)。

## 实测

`DISPLAY` 起真窗口 + CDP 取证:
  shell=desktop  hasBridge=true  hasWin=true  titlebar=true  h=36  appRegion=drag
  btns=[最小化, 还原, 关闭]  btnHitTarget=BUTTON.titlebar-btn
  errs=[](渲染层零异常)
标题栏实测截图含深浅两态,面板圆角与阴影不变。

## 未验(本机无 GUI 交互,只能取证不能点)

最大化/还原按钮点击、双击标题栏、**关闭进托盘**(这个最需要小心,
点错会把应用整个退出)。上一条留待有人手上有真桌面时验。
2026-10-04 11:07:56 +08:00

1934 lines
80 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* ─── 主题色板 ───
*
* 全站颜色都经 tailwind.config.js 指向这些变量,因此深色模式**不需要**
* 在组件里写 dark: 前缀。逐处加前缀的方案在这里必然失败:约 700 处颜色
* 散在 21 个组件里,漏一处就是深色下的白底白字,而且只有肉眼能发现。
*
* 深色模式的做法是**反转灰阶**:white → 近黑、gray-900 → 近白。
* 这套代码里灰阶本身就是语义色阶(表面层次 / 分隔线 / 文字主次),
* 反转之后 `bg-white text-gray-900` 自动变成「深色卡片 + 浅色文字」。
* 新加的组件照常写浅色类名也自动适配。
*
* 值存 RGB 三元组而不是 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
* Tailwind 会生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS
* —— 那些半透明高亮会静默失效(不报错,只是不透明)。
*/
:root {
/*
* 表面色(卡片 / 输入框底)。深色模式下变暗。
*/
--c-white: 255 255 255;
/*
* ★ 玻璃的"基材":**永远是白色**(2026-09-14 用户:「什么叫深色模式也是玻璃?
* 深色模式不应该是浅色玻璃吗?」)。
*
* 他是对的,我原先的说法("深色玻璃 = rgb(15 23 42 / .72)")是概念错误:
* 磨砂玻璃是**白色材料**,深色模式下要做的是**降低这层白的透明度**让深背景透出来,
* 而不是换成一整层深色 —— 后者不是玻璃,是把面板压黑。
*
* 之前所有玻璃面都写成 `rgb(var(--glass-base) / α)`,而 `.dark` 把 `--c-white`
* 改成了 `24 27 33`(近黑)⇒ 深色模式下**整个玻璃层系**变成深色,
* 而 Tailwind 的浅色工具类照旧 ⇒ 界面半深不浅。玻璃与"白"这个颜色概念的耦合就在这里。
*/
--glass-base: 255 255 255;
/*
* 彩色按钮与深色框架上的文字。**不随主题反转。**
*
* 它与 --c-white 必须分开,因为 `white` 在这套代码里服务两种互相冲突的用途:
* - `bg-white` = 卡片表面 → 深色模式必须变暗
* - `text-white` = 按钮上文字 → 深色模式必须保持浅色
*
* 共用一个变量时后者跟着变暗,实测激活导航项的「收件」在 bg-chrome-700 上
* 只剩 1.34:1 —— 几乎看不见。见 tailwind.config.js 的 textColor 覆盖。
*/
--c-on-accent: 255 255 255;
--c-gray-50: 249 250 251;
--c-gray-100: 243 244 246;
/*
* 分隔线刻意比「正牌 gray-200」再淡一档。
*
* 全站 67 处 `border-*-gray-200` 是「盒子感」的主要来源:硬线把界面切成
* 一块块,看着像 2015 年的后台。现代做法是让**留白与表面色差**承担分组,
* 线条只留一丝提示。改在令牌上而不是逐个类名,67 处(含 8 处作为底色的
* 进度条/骨架)一次性生效,且不会漏。
*/
--c-gray-200: 234 236 241;
--c-gray-300: 209 213 219;
/* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */
--c-gray-400: 107 114 128;
/*
* gray-500 必须比 gray-400 **深一档**。
*
* 原来两者都是 107 114 128(完全相同)—— 调色板里因而不存在「比次要文字
* 再深一点的中间色」,于是时间戳这类要压在高亮行淡蓝底上的文字没得选,
* 只能停在 gray-400:实测在 bg-blue-50 上只有 4.44:1,低于 AA 4.5
* (真实渲染量得)。拉开一档后为 5.65:1。
*/
--c-gray-500: 90 98 112;
--c-gray-600: 75 85 99;
--c-gray-700: 55 65 81;
--c-gray-800: 31 41 55;
--c-gray-900: 17 24 39;
--c-gray-950: 3 7 18;
/*
* 应用框架(侧栏 / 底部导航)。
*
* 独立成一条色阶而不跟 gray 走:这两块**在浅色模式下本来就是深色的**
* (深色侧栏配浅色内容区是这套 UI 的原本设计)。并入反转的 gray 之后,
* 深色模式下 bg-slate-900 会变成近白色 —— 侧栏比内容区还亮,
* 整个层次翻过来(实测 rgb(243,245,248) vs 内容区 rgb(17,19,24))。
*/
--c-chrome-100: 241 245 249;
--c-chrome-200: 226 232 240;
--c-chrome-400: 148 163 184;
--c-chrome-600: 71 85 105;
--c-chrome-700: 51 65 85;
--c-chrome-800: 30 41 59;
--c-chrome-900: 15 23 42;
/*
* ─── 强调色(浅色)───
*
* 值就是 Tailwind 的官方色阶,浅色模式下视觉零变化。
*
* 这条色阶与灰阶一样是**语义色阶**,而且两段的用途相反:
* - 50–300 = 表面(chip 底、提示条底、徽标底、边框)→ 深色下变暗
* - 400–900 = 前景(文字、图标) → 深色下变亮
*
* 上一版把它们写成 tailwind.config.js 里的固定 hex,同时又留了一份
* accent() 定义 —— JS 对象字面量重复键后者胜出,而当时 index.css 里
* 没有对应的 --c-red-* 等变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让
* **整条声明失效**,于是 bg-red-600 退回透明、白字落在白卡片上:
* 按钮看不见但点得动(生产实测,32 个变量全部缺失)。
*/
/* blue */
--c-blue-50: 239 246 255;
--c-blue-100: 219 234 254;
--c-blue-200: 191 219 254;
--c-blue-300: 147 197 253;
--c-blue-400: 96 165 250;
--c-blue-500: 59 130 246;
--c-blue-600: 37 99 235;
--c-blue-700: 29 78 216;
--c-blue-800: 30 64 175;
--c-blue-900: 30 58 138;
/* red */
--c-red-50: 254 242 242;
--c-red-100: 254 226 226;
--c-red-200: 254 202 202;
--c-red-300: 252 165 165;
--c-red-400: 248 113 113;
--c-red-500: 239 68 68;
/* 比 Tailwind 官方的 220 38 38 略暗:官方值落在 red-50 上只有 4.41:1,
而 `bg-red-50 text-red-600` 正是错误提示条 —— 差 2% 也是差。
这一档同时用在白卡片上(4.83 → 5.10),压暗后两处都过线。 */
--c-red-600: 213 37 37;
--c-red-700: 185 28 28;
--c-red-800: 153 27 27;
--c-red-900: 127 29 29;
/* green */
--c-green-50: 240 253 244;
--c-green-100: 220 252 231;
--c-green-200: 187 247 208;
--c-green-300: 134 239 172;
--c-green-400: 74 222 128;
--c-green-500: 34 197 94;
--c-green-600: 22 163 74;
--c-green-700: 21 128 61;
--c-green-800: 22 101 52;
--c-green-900: 20 83 45;
/* amber */
--c-amber-50: 255 251 235;
--c-amber-100: 254 243 199;
--c-amber-200: 253 230 138;
--c-amber-300: 252 211 77;
--c-amber-400: 251 191 36;
--c-amber-500: 245 158 11;
--c-amber-600: 217 119 6;
--c-amber-700: 180 83 9;
--c-amber-800: 146 64 14;
--c-amber-900: 120 53 15;
/* orange */
--c-orange-50: 255 247 237;
--c-orange-100: 255 237 213;
--c-orange-200: 254 215 170;
--c-orange-300: 253 186 116;
--c-orange-400: 251 146 60;
--c-orange-500: 249 115 22;
--c-orange-600: 234 88 12;
--c-orange-700: 194 65 12;
--c-orange-800: 154 52 18;
--c-orange-900: 124 45 18;
/* yellow */
--c-yellow-50: 254 252 232;
--c-yellow-100: 254 249 195;
--c-yellow-200: 254 240 138;
--c-yellow-300: 253 224 71;
--c-yellow-400: 250 204 21;
--c-yellow-500: 234 179 8;
--c-yellow-600: 202 138 4;
--c-yellow-700: 161 98 7;
--c-yellow-800: 133 77 14;
--c-yellow-900: 113 63 18;
/*
* ─── 实心按钮/徽标的底色 ───
*
* 与上面 --c-* 的 400–900 **同值,但两种模式下都不变**。
*
* 为什么要单独一组:那六档在深色模式下被提亮成了浅色(red-600 →
* rgb(246,141,141)),因为 `text-red-600` 得在深底上读得动。而
* `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
* 一个名字服务两种语义就必然坏掉一头 —— 与 text-white/bg-white 那次同理。
*
* 只有 backgroundColor 走这组(见 tailwind.config.js)。
*/
--s-blue-400: 96 165 250;
--s-blue-500: 59 130 246;
--s-blue-600: 37 99 235;
--s-blue-700: 29 78 216;
--s-blue-800: 30 64 175;
--s-blue-900: 30 58 138;
--s-red-400: 248 113 113;
--s-red-500: 239 68 68;
--s-red-600: 220 38 38;
--s-red-700: 185 28 28;
--s-red-800: 153 27 27;
--s-red-900: 127 29 29;
--s-green-400: 74 222 128;
--s-green-500: 34 197 94;
--s-green-600: 22 163 74;
--s-green-700: 21 128 61;
--s-green-800: 22 101 52;
--s-green-900: 20 83 45;
--s-amber-400: 251 191 36;
--s-amber-500: 245 158 11;
--s-amber-600: 217 119 6;
--s-amber-700: 180 83 9;
--s-amber-800: 146 64 14;
--s-amber-900: 120 53 15;
--s-orange-400: 251 146 60;
--s-orange-500: 249 115 22;
--s-orange-600: 234 88 12;
--s-orange-700: 194 65 12;
--s-orange-800: 154 52 18;
--s-orange-900: 124 45 18;
--s-yellow-400: 250 204 21;
--s-yellow-500: 234 179 8;
--s-yellow-600: 202 138 4;
--s-yellow-700: 161 98 7;
--s-yellow-800: 133 77 14;
--s-yellow-900: 113 63 18;
/*
* ─── 背景层 ───
*
* 自定义背景是**装饰层**,它的取值空间与主题色板无关,所以不复用 --c-*:
* --c-* 是要被主题反转的语义色,而这里只是「叠在图片/渐变上的一层遮罩」。
*
* --bg-scrim 是遮罩颜色:浅色下用白(把花哨的图案洗淡),
* 深色下用黑(压暗)。两者都在 .dark 里换值。
* --bg-dim 与 --bg-blur 由 backgroundStore 在运行时写入,这里是初值。
*/
--bg-scrim: 255 255 255;
--bg-dim: 12%;
/*
* ★★ 2026-09-17:深色下的**压暗下限**(浅色下是 0 = 不干预)。
*
* 为什么需要它:遮罩是"黑 56%"这种**比例**,而比例压不住一张**浅**壁纸。
* 用户 jianf 的壁纸均值 RGB 213(浅照片)、dim=56 ⇒ 遮罩后底 ≈ 213×0.44 = 94,
* 仍是中灰;再叠各层玻璃白之后 108–134。而深色正文是近白(243),次要文字
* gray-400 是 rgb(138,146,161) ⇒ 实测**最差 1.19:1**(日历页":root"那些日期)。
* 这不是"文字颜色没跟上"(`--c-gray-*` 一直是反的),而是**底根本没暗下来**。
*
* 契约(background.test:"玻璃是白色材料"):深色下玻璃之所以能维持近白基材,
* 前提就是"背后是深底"。所以深色必须保证这个前提 —— 壁纸是浅的时候,
* 用户的 56% 不够,得抬到下限。
*
* 代价(明写在案):深色下浅壁纸会被压得很淡(92% ⇒ 只剩 8% 亮度)。
* 这是**可读性优先**的取舍:用户报的正是"深色模式可读性极差",
* 而且壁纸仍在(8% + 玻璃质感 + 模糊),只是不再是主体。
*/
--bg-dim-min: 0%;
--bg-blur: 4px;
/* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */
--bg-glass: 0.88;
/*
* 第三层嵌套:浅色下**仍然是透明**(你当初要的效果 —— 不要再叠白、别把壁纸吃掉)。
* 深色下它必须有个小值,见 .dark 里的同名令牌与那一段的注释。
*/
--bg-glass-nested3: 0;
/*
* 嵌套面板那一层的不透明度。
*
* ★ 为什么必须有这个变量(2026-09-13 用户报的缺陷):「壁纸底上叠了太多
* 不透明层,导致壁纸效果很差,几乎看不出来」。根因就是**每一层 bg-white
* 都用同一个 0.82**,而嵌套是相乘的:0.82 × 0.82 = 0.97,再叠一层就到 0.995
* —— 壁纸被彻底盖住,而且每层各做一次模糊,图案被糊成一片灰。
*
* 原则是「玻璃只该出现一次」:最外层负责遮罩与模糊,里面几层只留一点点
* 色调(保住正文对比度),第三层起直接透明(见下面 data-bg='on' 的规则)。
*/
--bg-glass-inner: 0.82;
/*
* 控件级透度:复选框、选项胶囊、地址建议菜单这类**小控件**。
*
* 用户第三轮的原话:「复选框和地址猜测项的透明度问题还没改,他们才是真正需要
* 拉低透明度的地方」。也就是说:承载正文的大面必须够实(读得清),
* 而这些小块本身没有大段文字,透一点反而好看 —— 壁纸透过它们,界面才有层次。
* 三档从实到透:正文面(--bg-glass) > 嵌套卡(--bg-glass-inner) > 控件(--bg-glass-control)。
*/
--bg-glass-control: 0.5;
--glass-card-a: 0.92;
--glass-card-hover-a: 0.99;
--glass-card-wall-a: 0.78;
--glass-card-wall-hover-a: 0.88;
/* 卡片边框:浅色用深色细线,深色用亮线(见 .dark,深色下层次靠边框不靠阴影)。 */
--hairline-fg: 15 23 42 / 0.07;
--hairline-fg-strong: 15 23 42 / 0.12;
/* 面板的朦胧感(与壁纸自身的 --bg-blur 分开:那层给照片打底,这层给面板) */
--bg-blur-panel: 10px;
/* 浮动面板之间的缝隙:壁纸从缝隙里露出来,是圆角化玻璃化的关键 */
--pane-gap: 10px;
/*
* ─── 尺寸与动效 ───
*
* 提取成变量的理由与颜色相同:一处定义、全站一致。写死数值时「圆角"
* 会随组件作者的随手一写而漂移(本仓库曾同时存在 4 种圆角),
* 而动效时长不一致会让界面显得格崩。
*/
--radius-card: 0.875rem;
--radius-control: 0.5rem;
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
/*
* 抬高阴影。
*
* 浅色下靠阴影表达层次;深色下黑色阴影几乎看不见,层次改由更亮的边框
* 与表层面亮度差承担 —— 因此深色主题里换一整组值(见下),
* 而不是继续加深同一个阴影。
*
* 注:本注释刻意不写出深色选择器的字面量 —— theme.test.mjs 用
* indexOf(选择器) 切分两个颜色块,在 :root 的注释里出现那个字符串
* 会把它提前截断(已踩过一次)。该测试现已先剥注释,但这里仍避开。
*/
--shadow-1: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.1);
--shadow-2: 0 2px 4px rgb(15 23 42 / 0.05), 0 4px 12px rgb(15 23 42 / 0.1);
--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12), 0 2px 6px rgb(15 23 42 / 0.08);
/*
* 侧边面板专用:横向偏移 + 大扩散,模拟「两块面板叠在一起」而不是「被线分开」。
* 竖向几乎不偏移,否则整列会看起来浮在半空(面板是全高的)。
*/
--shadow-panel: 1px 0 2px rgb(15 23 42 / 0.04), 4px 0 16px -4px rgb(15 23 42 / 0.08);
--hairline: 0 0 0;
color-scheme: light;
}
/*
* 深色模式。
*
* # 灰阶整体反转
*
* - `white`(卡片底)→ 近黑的深灰。**不用纯黑**:纯黑上的浅色文字
* 对比过强,长时间看更累,也看不出层次。
* - `gray-50`(页面底)→ 比卡片**更暗**。浅色下页面底比卡片浅,
* 深色下必须反过来,否则卡片会陷进背景失去边界。
* - `gray-200/300`(分隔线)→ 中低亮度灰。照搬浅色值会得到刺眼的白线。
* - `gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400 只有
* 约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— 看得见但读不动。
*
* # 强调色不反转
*
* blue/red/green/... 在 tailwind.config.js 里是**固定值**,不走变量。
* 按钮底色在深色模式下依然是 blue-600 那样的彩色,跟着变会让主按钮
* 在深色页面上失去「这是主操作」的视觉重量。
*
* # 框架色阶只微调
*
* 见下面 --c-chrome-* 的注释。
*/
.dark {
--c-white: 24 27 33;
/*
* 近白而非纯白:深色页面上纯白字偏刺眼。
*
* 但它同时是**实心彩底按钮上的文字**,而那儿的对比度是硬指标:实测 244
* 时「红底白字」只有 4.46:1,低于 WCAG AA 的 4.5(真实渲染量出,见
* test/manual/background-verify.mjs)。抬到 250 后为 4.65:1,
* 仍不是纯白,观感上仍保留了「不刺眼」的初衷。
*/
--c-on-accent: 250 250 252;
--c-gray-50: 17 19 24;
--c-gray-100: 32 36 44;
--c-gray-200: 39 43 52;
--c-gray-300: 61 68 81;
--c-gray-400: 138 146 161;
--c-gray-500: 165 173 186;
--c-gray-600: 190 197 208;
--c-gray-700: 212 217 225;
--c-gray-800: 231 235 240;
--c-gray-900: 243 245 248;
--c-gray-950: 250 251 253;
/*
* 框架色阶**不反转,只微调**:比内容区(--c-gray-50 = 17 19 24)
* 再深一档,保持「框架比内容更沉」这个浅色下就有的关系。
* 文字档位相应提亮 —— 底色变深后原来的 chrome-400 只剩约 2.9:1。
*/
--c-chrome-100: 236 240 246;
--c-chrome-200: 214 221 232;
--c-chrome-400: 148 158 175;
--c-chrome-600: 58 65 78;
--c-chrome-700: 44 50 61;
--c-chrome-800: 30 35 44;
--c-chrome-900: 12 14 18;
/*
* 背景遮罩换成黑:浅色下用白把图案洗淡,深色下必须压暗,
* 否则一张浅色照片会在深色界面里舗成一块亮斑,正文完全读不动。
*/
--bg-scrim: 0 0 0;
/*
* ★★ 2026-09-17:这三个值原本是 0.9 / 0.84 / 0.55 —— 壁纸模式下的**白底白字**。
*
* 机制:每个玻璃面都是 `rgb(var(--glass-base) / α)`,而 `--glass-base` **恒为白**
* (用户定的契约:「玻璃是白色材料」,见 background.test 那组判据)。
* 所以 α 就是「这层白有多实」,而它必须**跟主题反着走**:
* · 浅色主题:页面底是白的、文字是深的 ⇒ 白面板实一点无所谓(底本来白);
* · 深色主题:页面底是深的、文字是近白的 ⇒ 白面板**必须很透**,
* 否则就是「白色面板 + 近白文字」。
*
* 实测事故(用户 jianf 账号:`bg_kind=image`、dim=56、blur=3,
* 壁纸均值 RGB 213,212,225 —— 一张**浅**照片):只把 alpha 调到 0.08/0.05/0.04
* 之后仍然**不够** —— 内层透明那一档让浅壁纸直接透上来,
* 1280×800 真实渲染采样(逐个叶子文本节点采样其实际底色)最差 **1.19:1**
* (日历页的日期/农历字:「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132))。
* 用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面深色模式可读性都极差」。
*
* ★ 真正的根因不是 alpha,而是**底没暗下来**:dim 是比例(暗 56%),
* 而 56% 压不住一张 213 的浅壁纸(213×0.44 ≈ 94,仍是中灰)。
* 近白正文对 94 只有约 3.6:1,次要文字 gray-400 对 94/134 只有 1.2–1.4:1
* —— **没有任何文字颜色能救**。所以深色下加了 `--bg-dim-min`(见 :root)。
* 加了下限之后复测:四页最差 通信 6.14 / 日历 4.90 / 联系 4.96 / 我的 5.38,全部 ≥ 4.5:1。
*
* 为什么旧判据没拦住:它们只判 `--c-*` 色板(那套确实是反的),
* 而这三个是 **alpha 档**,不在色板里 —— 与 `.glass-card` 写死白色是同一类盲区。
* 现在 alpha 档也由判据**按实际合成**验算(theme.test 的「合成对比度」一条)。
*
* 低到什么程度:让深底主导,但保留一点点白当「玻璃的质感」
* (全透明就失去玻璃层了,而且会让壁纸完全穿透面板)。
*/
--bg-glass: 0.04;
--bg-glass-inner: 0.03;
--bg-glass-control: 0.04;
/* 第三层嵌套:深色下不能是透明(浅壁纸会透上来),给一点白压住即可。 */
--bg-glass-nested3: 0.02;
/*
* ★★ 2026-09-17:深色下压暗下限 92%(浅色下是 0)。见 `:root` 里同一令牌的注释。
* 实测:56% 时三页最差 1.19:1,92% 后四页全部 ≥ 4.5:1(1280×800 真渲染采样)。
*/
--bg-dim-min: 92%;
/*
* 深色下的层次靠「边框亮于底」而不是阴影。
* 保留一行极淡的黑色阴影只为了与浅色统一接管机制,真正的边界感来自 --hairline。
*/
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.45);
--shadow-3: 0 10px 30px rgb(0 0 0 / 0.55);
/*
* 深色下层次比浅色更难感知,所以面板阴影要更实一些;
* 但仍以「方向性」为主(横偏移),避免整列看起来悬空。
*/
--shadow-panel: 1px 0 2px rgb(0 0 0 / 0.35), 4px 0 16px -4px rgb(0 0 0 / 0.5);
--hairline: 0 0 0 1px rgb(255 255 255 / 0.06);
/*
* ─── 强调色(深色)───
*
* 两段走向相反,理由都是实测出来的:
*
* **表面段 50–300 变暗**(朝色相方向偏移卡片色)。照搬浅色值的话
* red-50 (#fef2f2) 在深色页面上是一块近白亮斑 —— 那是「错误提示条」的底,
* 结果比正文还抢眼,而它上面的红字反而读不动。
*
* **前景段 400–900 变亮**。照搬浅色值时 red-700 (#b91c1c) 落在深色卡片上
* 只有 2.67:1、amber-900 只有 1.90:1 —— 远低于 WCAG AA 的 4.5:1。
* 这里每一档都拉到 ≥4.5(实测最低 red-400 = 5.93),
* 由 test/theme.test.mjs 逐档断言。
*
* 实心按钮底不在这里 —— 那组是 --s-*,定义在 :root 且两种模式同值。
*/
/* blue */
--c-blue-50: 28 37 54;
--c-blue-100: 30 43 67;
--c-blue-200: 32 52 84;
--c-blue-300: 36 62 105;
--c-blue-400: 92 152 247;
--c-blue-500: 108 162 248;
--c-blue-600: 128 175 249;
--c-blue-700: 154 191 250;
--c-blue-800: 183 210 251;
--c-blue-900: 213 228 253;
/* red */
--c-red-50: 46 31 37;
--c-red-100: 58 34 39;
--c-red-200: 76 37 41;
--c-red-300: 97 41 45;
--c-red-400: 243 109 109;
--c-red-500: 244 124 124;
--c-red-600: 246 141 141;
--c-red-700: 248 164 164;
--c-red-800: 250 191 191;
--c-red-900: 252 217 217;
/* green */
--c-green-50: 25 44 39;
--c-green-100: 26 54 43;
--c-green-200: 26 68 48;
--c-green-300: 27 85 54;
--c-green-400: 34 197 94;
--c-green-500: 56 203 110;
--c-green-600: 83 210 129;
--c-green-700: 118 219 155;
--c-green-800: 158 229 184;
--c-green-900: 198 240 213;
/* amber */
--c-amber-50: 46 40 31;
--c-amber-100: 59 48 29;
--c-amber-200: 77 58 28;
--c-amber-300: 99 72 26;
--c-amber-400: 245 158 11;
--c-amber-500: 246 168 35;
--c-amber-600: 247 179 65;
--c-amber-700: 249 195 104;
--c-amber-800: 251 212 148;
--c-amber-900: 252 230 192;
/* orange */
--c-orange-50: 47 36 32;
--c-orange-100: 60 41 31;
--c-orange-200: 78 48 30;
--c-orange-300: 101 57 29;
--c-orange-400: 249 115 22;
--c-orange-500: 250 129 45;
--c-orange-600: 250 146 73;
--c-orange-700: 251 168 111;
--c-orange-800: 252 193 152;
--c-orange-900: 253 219 194;
/* yellow */
--c-yellow-50: 45 42 31;
--c-yellow-100: 58 51 29;
--c-yellow-200: 74 63 27;
--c-yellow-300: 95 79 25;
--c-yellow-400: 234 179 8;
--c-yellow-500: 236 187 33;
--c-yellow-600: 239 196 62;
--c-yellow-700: 242 208 102;
--c-yellow-800: 246 222 146;
--c-yellow-900: 250 235 191;
/* 让浏览器把滚动条、表单控件、autofill 一并切深色 */
color-scheme: dark;
}
@layer base {
:root {
/* main.tsx 会用 Visual Viewport 覆盖;这里保证脚本执行前也有正确高度。 */
--app-height: 100vh;
}
@supports (height: 100dvh) {
:root {
--app-height: 100dvh;
}
}
html, body, #root {
height: var(--app-height);
min-height: 0;
}
body {
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
/*
* 显式给 body 底色。移动端橡皮筋回弹时露出的是 body 背景 ——
* 不设的话深色模式下滑到边界会闪出一条白边。
*/
background-color: rgb(var(--c-gray-50));
color: rgb(var(--c-gray-900));
}
/* 统一作者样式,避免 Chromium/Electron autofill 在深色卡片上画白底。 */
input:not([type='checkbox']):not([type='radio']):not([type='file']),
textarea,
select {
background-color: rgb(var(--c-white));
color: rgb(var(--c-gray-900));
}
input::placeholder,
textarea::placeholder {
color: rgb(var(--c-gray-400));
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: rgb(var(--c-gray-900));
box-shadow: 0 0 0 1000px rgb(var(--c-white)) inset;
transition: background-color 9999s ease-out;
}
}
@layer components {
/* react-markdown 的本地排版层;不依赖固定色板的 typography 插件。 */
.markdown {
color: rgb(var(--c-gray-800));
overflow-wrap: anywhere;
}
.markdown > * + * { margin-top: 0.75rem; }
.markdown h1 { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
.markdown h2 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 700; }
.markdown h3 { font-size: 1.05rem; line-height: 1.5rem; font-weight: 600; }
.markdown ul { list-style: disc; padding-left: 1.35rem; }
.markdown ol { list-style: decimal; padding-left: 1.35rem; }
.markdown li + li { margin-top: 0.25rem; }
.markdown a { color: rgb(var(--c-blue-600)); text-decoration: underline; }
.markdown blockquote {
border-left: 3px solid rgb(var(--c-gray-300));
padding-left: 0.75rem;
color: rgb(var(--c-gray-600));
}
.markdown code {
border-radius: 0.25rem;
background: rgb(var(--c-gray-100));
padding: 0.1rem 0.3rem;
font-size: 0.875em;
}
.markdown pre {
overflow-x: auto;
border: 1px solid rgb(var(--c-gray-200));
border-radius: 0.5rem;
background: rgb(var(--c-gray-100));
padding: 0.75rem;
}
.markdown pre code { background: transparent; padding: 0; }
.markdown table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
.markdown th, .markdown td { border: 1px solid rgb(var(--c-gray-200)); padding: 0.4rem 0.55rem; }
.markdown th { background: rgb(var(--c-gray-100)); text-align: left; }
}
@layer utilities {
/*
* .tap —— 保证 44x44 的触摸命中区,但不改变视觉尺寸。
*
* 移动端 44x44 是通行下限(Apple HIG 与 Material 都取这个数),而这些图标/
* 小字按钮视觉上只有 15-24px 高 —— 实测「标记已读」48x16、「转发」42x16、
* 「抄送」20x15。直接加 padding 会把本来就挤的头部撑散,在 320px 屏上还会换行。
*
* 改用居中的透明伪元素扩大命中区:视觉一像素不动,手指够得到。
*
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
* 会互相重叠,点一个可能命中隔壁那个。
*/
/* 与 useIsNarrow / Tailwind lg 统一:竖屏平板也需要触摸命中区。 */
@media (max-width: 1023px) {
.safe-frame {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.narrow-nav {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/*
* ★ 2026-09-15 用户:「输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」。
*
* 原先这里是 `.form-editing .narrow-nav { display: none }` —— `form-editing` 由
* main.tsx 在**任何输入框获得焦点**时挂上,而回复框一展开就会自动聚焦它的 textarea
* ⇒ 导航立刻消失;只有点到别处(失焦)才回来。
*
* 但它已经没必要了:viewport 里声明了 `interactive-widget=resizes-content`,
* 键盘弹出时浏览器会重排布局(导航自然落在键盘上方),不需要靠隐藏来腾地方。
* 而"弹个回复框导航就没了、还要再点一下才回来"是纯损失。
*/
/* 编辑态不再隐藏底部导航:见上 */
.tap {
position: relative;
}
.tap::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: 44px;
min-height: 44px;
transform: translate(-50%, -50%);
}
}
/*
* .reveal —— 悬停才显形的次要动作(写信 / 归档)。
*
* 原先直接写 `opacity-0 group-hover:opacity-100`。**触摸设备没有 hover**,
* 于是这些按钮永远是透明的,却仍然接收点击 —— 实测在联系人列表上
* elementFromPoint 命中的就是那个看不见的「归档」。一个看不见却按得动的
* 破坏性按钮比没有按钮更糟:人以为自己点的是卡片,实际归档了一条会话。
*
* 因此默认可见,只在**真的支持悬停**的设备上才隐藏。判据用
* `(hover: hover) and (pointer: fine)`:单看 hover 会把带触摸板的平板算进去。
*/
.reveal {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.reveal {
opacity: 0;
transition: opacity 150ms;
}
.group:hover .reveal,
.reveal:focus-within {
opacity: 1;
}
}
}
/*
* ═══════════════════════════════════════════════════════════════════
* 自定义背景 + 现代化打磨
* ═══════════════════════════════════════════════════════════════════
*
* # 为什么这一段放在所有 @layer 之外
*
* Tailwind 的层序是 base < components < utilities,而这里要覆盖的正是
* **utilities 生成的** `.bg-white` / `.bg-gray-50`。写进 @layer components
* 会被 utilities 压过去(静默失效,只有肉眼能看出「背景没生效」);
* 写进 @layer utilities 则取决于 Tailwind 内部的合并顺序,不可靠。
* 未分层的 CSS 在层叠中恒胜过分层 CSS —— 这是唯一稳定可靠的位置。
*/
/* ─── 背景层本体 ─── */
.app-backdrop {
position: fixed;
inset: 0;
/*
* 必须是**负** z-index。
*
* 层叠顺序里 z-index:0/auto 的定位元素画在常规流内容**之上**,用它会把
* 整个界面盖住(背板是 fixed 全屏的,连点击区都会挡住 —— 虽然有
* pointer-events:none 兵底,但视觉上完全遮住)。负值才画在常规流内容
* 之下、同时在父元素背景之上,正是「缓景」该在的位置。
*
* 它能露出来还依赖另一条:背景开启时把所有不透明的页面底
* (bg-gray-50 / bg-slate-100 / body)改成透明,见下面。
*/
z-index: -1;
pointer-events: none;
/* 默认不可见;背景开启时才铺开(data-bg 由 backgroundStore 写在 <html> 上)。 */
background-image: var(--bg-image, none);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(var(--bg-blur));
/* 模糊会把边缘拖进画面,放大 2% 抵消。 */
transform: scale(1.02);
opacity: 0;
transition: opacity var(--dur-base) var(--ease-out-soft);
will-change: opacity;
}
html[data-bg='on'] .app-backdrop {
opacity: 1;
}
/*
* 遮罩:把图案压下去,让正文读得动。
*
* 用单独一层而不是直接调低 background 的透明度:透明度会让背景变淡但仍与
* 正文争夺对比度,而遮罩是**在两者之间插一层**,颜色随主题反转(浅色用白、
* 深色用黑),因此两种模式下都是「背景退后、内容在前」。
*/
.app-backdrop::after {
content: '';
position: absolute;
inset: 0;
/*
* `max()` 而不是直接 `var(--bg-dim)`:深色主题下用户把压暗调到 56% 时,
* 浅壁纸仍会透成中灰(实测最差 1.19:1)。下限制在深色里给 92%,
* 浅色里给 0(不干预)。取 max 而非覆盖,是为了**不下调用户的选择**:
* 用户调得比下限更高时以用户的为准。
*/
background-color: rgb(var(--bg-scrim) / max(var(--bg-dim), var(--bg-dim-min)));
}
/*
* ─── 预设渐变 ───
*
* 全部用现有调色板的**表面段**(50–300)拼出来,因此:
* - 无需新增任何写死的十六进制值
* - 自动随主题变化 —— 那一段在深色下本来就是暗的(见 index.css 的 .dark
* 与 theme.test.mjs 第 19 条),浅色得到柔和pastel、深色得到低沉暗调
*/
.bg-preset-aurora {
--bg-image: radial-gradient(at 18% 22%, rgb(var(--c-blue-200)) 0%, transparent 55%),
radial-gradient(at 82% 12%, rgb(var(--c-green-100)) 0%, transparent 50%),
radial-gradient(at 68% 82%, rgb(var(--c-blue-100)) 0%, transparent 55%);
}
.bg-preset-dusk {
--bg-image: radial-gradient(at 12% 80%, rgb(var(--c-orange-100)) 0%, transparent 55%),
radial-gradient(at 85% 25%, rgb(var(--c-blue-200)) 0%, transparent 55%);
}
.bg-preset-mint {
--bg-image: radial-gradient(at 25% 30%, rgb(var(--c-green-100)) 0%, transparent 55%),
radial-gradient(at 78% 70%, rgb(var(--c-blue-100)) 0%, transparent 55%);
}
.bg-preset-sand {
--bg-image: radial-gradient(at 20% 25%, rgb(var(--c-amber-100)) 0%, transparent 60%),
radial-gradient(at 80% 75%, rgb(var(--c-orange-100)) 0%, transparent 55%);
}
.bg-preset-ink {
--bg-image: radial-gradient(at 30% 20%, rgb(var(--c-gray-200)) 0%, transparent 60%),
linear-gradient(160deg, rgb(var(--c-gray-100)), rgb(var(--c-gray-200)));
}
.bg-preset-mesh {
--bg-image: repeating-linear-gradient(
0deg,
rgb(var(--c-gray-200) / 0.55) 0 1px,
transparent 1px 28px
),
repeating-linear-gradient(90deg, rgb(var(--c-gray-200) / 0.55) 0 1px, transparent 1px 28px);
}
/*
* ─── 背景开启时的表面处理 ───
*
* 不改 27 个组件的 class:背景是全局装饰,逐个组件加 class 必然漏(漏掉的那块
* 就是一张不透明卡片浮在背景上)。这里按 Tailwind 生成的实际类名统一接管。
*
* 被接管的是三类:
* - 页面底(bg-gray-50 / bg-slate-100)→ 完全透明,让出背景
* - 卡片/面板(bg-white) → 半透明 + 背景模糊(玻璃)
* - 应用框架(bg-chrome-*) → 半透明,保持「框架比内容沉」
*/
html[data-bg='on'] body,
html[data-bg='on'] .bg-gray-50,
html[data-bg='on'] .bg-slate-100 {
background-color: transparent;
/* 空白区不模糊:壁纸在这里要清晰可辨(用户:"真正该透明的地方加了很浓的模糊") */
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/*
* .glass-control —— 小控件用的"更透"档。
*
* 必须显式加在元素上、并且**不要同时写 bg-white**:我的 `html[data-bg='on'] .bg-white`
* 接管规则会把任何 bg-white 拉回正文面那一档(0.88),写在这儿的透度会被它盖掉。
*
* ★ 只给**贴着面板、背后是自己人**的小控件用(按钮、输入框底)。
* 浮在别的内容**之上**的弹层不能用它 —— 见 .popup-surface。
*/
html[data-bg='on'] .glass-control {
background-color: rgb(var(--glass-base) / var(--bg-glass-control));
backdrop-filter: blur(8px) saturate(1.1);
-webkit-backdrop-filter: blur(8px) saturate(1.1);
}
/*
* .popup-surface —— 浮在**别的内容之上**的弹层表面(候选菜单、下拉)。
*
* ★ 2026-09-15 用户(看着抄送/转发两处的候选列表):
* 「你在选择框的模糊逻辑上用力过猛,导致只能显示一条信息」
*
* 实测(壁纸开、生产构建 4c2bf26,在浏览器里读计算样式):
* 菜单 `background-color: rgba(255,255,255,0.5)` + `backdrop-filter: blur(8px)`,
* 而它压着的是**表单自己**(主题输入框、各段标签)⇒ 背后的控件被糊成一片灰、
* 从半透明的列表底下透上来。列表越往下,越分不清哪行是候选、哪行是背后的输入框。
*
* 根因与 2026-09-14 那次「模糊叠模糊」同族:把**控件档**(0.5 透 + 8px 模糊)
* 用在了**弹层**上。控件背后是面板自身,透一点好看;弹层背后是别的内容,
* 透就是把两份内容叠在一起。所以弹层给**不透明**表面,且不取背景模糊。
*/
.popup-surface {
background-color: rgb(var(--c-white));
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* 壁纸开着也**不透明**:弹层不是壁纸的一部分,没有人希望壁纸从候选列表里透出来 */
html[data-bg='on'] .popup-surface {
background-color: rgb(var(--c-white));
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
html[data-bg='on'] .bg-white {
background-color: rgb(var(--glass-base) / var(--bg-glass));
}
/*
* ★ 嵌在玻璃面板**里面**的面板不再各叠一次不透明度(2026-09-13 用户报的缺陷)。
*
* 相乘是这里的关键:两层 0.82 得 0.97、三层 0.995 —— 壁纸就是这么被吃掉的,
* 而且每层各做一次 16px 模糊,图案被糊成一片灰。
* 第二层只留一点色调(保住正文对比度),第三层起透明,且都**不再叠加模糊**。
*/
html[data-bg='on'] .bg-white .bg-white {
background-color: rgb(var(--glass-base) / var(--bg-glass-inner));
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/*
* ★★ 2026-09-17:第三层原本是 `background-color: transparent` —— **深色下的白底白字**。
*
* 那条规则的初衷是对的(2026-09-13 用户报「壁纸底上叠了太多不透明层,导致壁纸效果
* 很差,几乎看不出来」):三层 0.82 相乘 = 0.995,壁纸被吃光了。
* 但它的**代价在深色主题下不同**:浅色下第三层透明没关系(底本来就是浅的、字是深的),
* 而深色下"透明"意味着**浅色壁纸直接透上来**,而上层文字是近白的 ⇒ 白底白字。
*
* 实测事故(用户 jianf 账号:壁纸 `image`、dim=56、blur=3,均值 RGB 213,212,225
* 是一张**浅**照片):日历 / 联系人 / 我的 三页最差 **1.06:1**,而通信页正常 ——
* 差别恰好是三页的面板都是「面板 > 卡片 > 内部」三层嵌套,最内层正文面被这条规则
* 设成了全透明。用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面
* 深色模式可读性都极差」。
*
* 修法:保留"不再层层叠白"的本意,但**透明换成"很淡的白"**(深色下 0.04,
* 浅色下仍是你当初要的透明)—— 壁纸在深色下不至于被吃光(0.08→0.05→0.04 三层
* 合成约 0.16,仍看得见图案),而最内层正文面又能压住浅壁纸。
*/
html[data-bg='on'] .bg-white .bg-white .bg-white {
background-color: rgb(var(--glass-base) / var(--bg-glass-nested3));
}
/* 侧栏里的玻璃面板同理:框架已经有一层底色,里面不能再叠。 */
html[data-bg='on'] .bg-chrome-900 .bg-white,
html[data-bg='on'] .bg-chrome-800 .bg-white {
background-color: rgb(var(--glass-base) / var(--bg-glass-inner));
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* 悬停态也要接管:不接管的话鼠标一进面板就会闪成不透明(明显跳动)。 */
html[data-bg='on'] .hover\:bg-gray-50:hover,
html[data-bg='on'] .hover\:bg-gray-100:hover {
background-color: rgb(var(--c-gray-100) / var(--bg-glass));
}
/*
* ★ 浮动面板布局(2026-09-14 用户要求:导航栏完全不透明、整个界面圆角化玻璃化)。
*
* 三件事:
* ① 外壳留缝:面板之间露出壁纸 —— 没有缝隙的"玻璃"看上去仍是一整块板;
* ② 面板圆角:圆角 + 缝隙才是"浮动玻璃"的形状语言;
* ③ 导航栏**完全不透明**:它是框架,不该跟着壁纸一起虚化。
* 这与内容面板要通透是**两个方向**的要求,别合并成一条规则。
*/
/*
* ★ 两类面要分开处理(2026-09-14 用户第二轮批评):
*
* 「你把大量需要打底的场景(弹窗正文等)改为了透明。真正该透明的地方(空白区域)
* 加了很浓的模糊」
*
* 也就是我上一版把两类搞反了:
* - **承载文字的面**(正文卡、弹窗、列表、输入条):必须够实 + 轻模糊,
* 否则字压在壁纸上读不动 —— 这是"打底",不是"玻璃";
* - **空白/页面底**(详情区空态、面板缝隙、page 底):透明且**不模糊**,
* 壁纸在这里应该是清晰的照片,不是一团糊。
*
* 规则落点:`--bg-glass` 只作用在 `.bg-white` 这一族(承载内容的那些面),
* 而 `.bg-gray-50` / 页面底保持 transparent 且不参与模糊。
*/
/*
* 浮动面板的几何 **始终生效**(圆角 + 缝隙 + 投影 + 一点朦胧),不只在壁纸模式下。
*
* 用户(2026-09-14):「通信页面大面积缺失圆角与玻璃效果,所有有内容与无内容区域
* 都是硬截断」—— 根因是这些规则原先**全写在 `html[data-bg='on']` 里**:
* 没开壁纸的账号看到的是硬边、不透明、贴在一起的面板,硬截断就是这么来的。
*
* 所以几何部分下沉到这里(无条件的基线),壁纸相关的加强(朝向照片的透明度、
* 强模糊、面板间露出壁纸)仍留在下面的 data-bg 段里 —— 两者是叠加关系,不是替代。
*/
.app-shell {
padding: var(--pane-gap);
gap: var(--pane-gap);
}
.app-shell > * {
border-radius: var(--radius-card);
/*
* ★ 这里**不能**写 `overflow: hidden`(2026-09-14 用户:「通信页面完全无法上下滑动」)。
*
* 面板自己就是滚动容器(列表/详情都是 overflow-y-auto),而这条规则的特异性
* 比 Tailwind 的 `.overflow-y-auto` 高 ⇒ 滚动被静默干掉:实测当时**一个可滚动
* 容器都不存在**(scrollerCount=0),内容是直接被裁掉的,连"滚到底"都做不到。
*
* 圆角仍然生效(border-radius 不影响滚动);角落的方角残影改用
* background-clip 处理,代价是溢出内容在圆角处可能露一点点 —— 比不能滚动好得多。
*/
box-shadow: 0 8px 24px rgb(15 23 42 / 0.08);
background-clip: padding-box;
}
/* 基线朦胧:没开壁纸时也有一点玻璃感,而不是一块硬塑料 */
/*
* ★ 面板**不再各自打模糊**(2026-09-14 用户:「你又犯了模糊叠模糊的毛病,
* 整体的模糊是由壁纸那一层模糊确定的,而你在每一个卡片又打了固定的模糊底」)。
*
* 浮在壁纸上的大面(列表栏/详情栏/卡片)背后只有**已经模糊过的壁纸**,
* 再 backdrop-filter 一次纯属叠加:不会更"玻璃",只会更脏更糊,而且每层都要
* 重新采样一次背景(滚动时明显掉帧)。
*
* 模糊只留给**真正悬浮在内容之上**的层:底部导航条、地址建议菜单、弹层 ——
* 它们背后是会滚动的内容,模糊在那里才有信息遮蔽的意义。
*/
.narrow-shell > *:not(.narrow-nav) {
}
/* 窄屏:内容面板同样浮起来(与底部导航那条悬浮玻璃一致) */
.narrow-shell {
padding: var(--pane-gap) var(--pane-gap) 0;
gap: var(--pane-gap);
}
.narrow-shell > *:not(.narrow-nav) {
/* 同样不写 overflow: hidden —— 见上面 app-shell 那段(会杀掉滚动) */
border-radius: var(--radius-card);
}
/*
* ★ 窄屏「页面覆盖」容器(NarrowStack 的根节点)
* 用户(2026-09-14):「我这边看还是方角」—— 上一轮我只修了宽屏那条分支。
*
* 它是窄屏上**唯一**同时具备两个条件的层:
* ① 自己 `overflow: hidden`(能裁);
* ② 包着真正有底色的那一层(底层页 / 滑入的覆盖层)。
*
* 而 `.narrow-shell > *` 那条圆角落在它**外面**那层透明 flex 容器上
* (App.tsx 的 `<div className="flex-1 min-h-0 flex">`),那层 `overflow: visible`
* 裁不住任何东西 —— 于是白底页面的直角原样露出来。
* 实测 390x844 日历页:左上角像素纯白(方角),面板 computed radius = 0px。
*
* 所以圆角必须加在这一层:加给它,底层与覆盖层就**一起**被裁到圆角里。
* (日历与收件箱详情都走这个容器,一条规则同时修好;收件箱详情以前也是方角。)
*/
.narrow-stack {
border-radius: var(--radius-card);
}
/*
* ★ 窄屏「单页」容器(没有列表栏的页面:我的 / 管理 / 写信)
* 用户(2026-09-14):「webui的『我的』页面,还是没有圆角」。
*
* 与 `.narrow-stack` 那次**同一个根因**,只是另一条分支:
* `.narrow-shell > *` 那条圆角落在这一层(透明、overflow: visible),
* 而真正有底色的是它里面的页面根节点(`AccountPage` 的 `… bg-white`)——
* 内层的直角从透明外壳里原样戳出来。
* 实测 390×844「我的」页:面板 computed radius = **0px**,左下/右下都是白像素。
*
* 修法照 `cal-panes` 的做法:**圆角给到有底色的那一层**(`> *`),
* 并且不写 `overflow: hidden` —— 这一层下面的子元素自己就是滚动容器。
*/
.narrow-solo > * {
border-radius: var(--radius-card);
background-clip: padding-box;
}
/*
* 横屏紧凑的两栏(列表 + 主区域并排):两栏之间只有 1px 分隔线,
* 所以只能给**外沿** —— 左边那块给左两角、右边那块给右两角;
* 内侧也给会露出底色缺口(这一条与 `.cal-panes` 是同一套判断)。
*/
.narrow-duo > :first-child {
border-start-start-radius: var(--radius-card);
border-end-start-radius: var(--radius-card);
background-clip: padding-box;
}
.narrow-duo > :last-child {
border-start-end-radius: var(--radius-card);
border-end-end-radius: var(--radius-card);
background-clip: padding-box;
}
html[data-bg='on'] .app-shell {
padding: var(--pane-gap);
gap: var(--pane-gap);
}
html[data-bg='on'] .app-shell > * {
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: 0 8px 28px rgb(0 0 0 / 0.16);
}
/*
* 导航栏改**深色玻璃**(2026-09-14 用户:「导航栏不也应该改为玻璃样式吗,为什么还是黑色」)。
*
* 这条规则原本是"完全不透明 + 不参与模糊" —— 那是更早一轮的要求(当时面板太透,
* 壁纸从导航里透出来显得脏)。现在整套语言已经统一到玻璃,导航继续做一块实心黑
* 就成了全页唯一的例外,也就是用户说的"割裂"。
*
* 用深色玻璃而不是白色玻璃:白字压在深色上才有对比度。α=0.72 + blur(18px)。
*/
/*
* 导航在壁纸模式下不再单独着色:它走 `--nav-bg` 令牌
* (浅色主题=白玻璃、深色主题=深玻璃,见文件末尾的令牌块)。
* 原先这里写死深玻璃 —— 那正是用户说的"导航栏为什么还是黑色"。
*/
html[data-bg='on'] .nav-rail {
/* 侧栏浮在壁纸上:模糊由壁纸层负责,这里不叠 */
}
/*
* chrome-600/700 **刻意保持不透明**。
*
* 它们不是大面板,而是导航项与 15px 的计数徽标(如「收件 12」)。给它们加
* 透明度有两个代价:一是看不出背景(本来就太小),二是**正文对比度被拉低**
* ——实测徽标上的数字从原值降到 4.46:1,低于 WCAG AA 的 4.5(真实渲染量得,
* 不是估算)。小控件的可读性优先于装饰效果。
*/
/*
* ─── 现代化打磨 ───
*/
/* 滚动条:桌面应用里它常驻可见,系统默认样式(尤其窄屏上的粗条)很旧。 */
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--c-gray-300)) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgb(var(--c-gray-300));
border-radius: 9999px;
/* 透明边框 + background-clip:让滑块比轨道窄,不贴边,观感更轻。 */
border: 3px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgb(var(--c-gray-400));
}
.dark * {
scrollbar-color: rgb(var(--c-gray-600)) transparent;
}
.dark *::-webkit-scrollbar-thumb {
background-color: rgb(var(--c-gray-600));
}
.dark *::-webkit-scrollbar-thumb:hover {
background-color: rgb(var(--c-gray-500));
}
/*
* 键盘焦点环。
*
* 只在**键盘**导航时出现(:focus-visible),鼠标点击不画 —— 常驻焦点框会让
* 界面显得脏。这也是可访问性的硬要求:没有可见焦点,键盘用户无法定位。
*/
:focus-visible {
outline: 2px solid rgb(var(--c-blue-500));
outline-offset: 2px;
border-radius: 0.25rem;
}
/*
* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。
*
* ★ 2026-09-15 用户:「主页点击按钮有明显的卡顿」。
* 原先这里还带一条 `box-shadow var(--dur-base)` —— **阴影是绘制属性,不是合成器属性**:
* 每次 hover/active 都要把元素连同阴影覆盖区重绘一遍,而且它挂在**所有** button/a/input 上。
* 列表里几十个按钮 + 玻璃卡片背景时,这笔重绘正好落在点击那一帧上,就是"点按钮卡一下"。
* 阴影现在直接跟随状态切换(瞬切),不再补间;观感差别很小,代价差很多。
* 真要动画阴影的个别表面,自己显式写 transition。
*/
button,
a,
input,
textarea,
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);
}
/* 尊重系统的「减弱动态效果」:前庭功能障碍者会因动画不适。 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/*
* ═══════════════════════════════════════════════════════════════════
* 窄屏底部导航:悬浮玻璃条
*
* 用户(2026-09-14):「窄屏布局的导航栏还是固定底部延伸,没有玻璃效果,也没有悬浮」。
* 原来它是 `border-t bg-chrome-900` 的通栏 —— 贴着屏幕底边铺满宽度,与宽屏那套
* 「浮动面板 + 留缝」的观感完全脱节(宽屏侧栏虽然不是玻璃,但内容面板是浮起来的)。
*
* 改成:离底边留 10px、左右各留 10px、圆角、深色半透明 + 背景模糊,落在壁纸之上。
* 安全区(iPhone 手势条)并入下外边距,否则悬浮条会被手势条压住。
*/
.narrow-nav {
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);
/* 底色走 --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);
overflow: hidden;
}
/*
* 视图切换动画。
*
* 用户:「页面极度缺少动画,所有页面都是直接出现」。
*
* 做法:只在 <html> 上挂一个短命的类(由 App 的 effect 在 viewMode/页签变化时重新触发),
* 由它给外壳的直接子面板加一次入场动画。**不改 DOM 结构** —— 面板本身是 flex 链上的
* 一环,套一层动画 wrapper 会把 flex 传递改掉(这类改动最容易把窄屏覆盖层弄坏)。
*/
/*
* ★ 只动**主内容区**,不动侧栏与列表(2026-09-14 用户:「部分动画十分不合理,
* 会导致页面大范围的闪动,且不能让人自然的把注意力集中在将要出现的页面上」)。
*
* 原先这条把外壳的**所有**直接子面板一起做入场动画 ⇒ 切一次视图整屏都在动,
* 眼睛不知道该看哪里,观感就是"闪"。现在只让将要出现的那一页淡入,
* 而且幅度从 translateY(6px)+scale 收到 4px、不加缩放(缩放最容易让人觉得闪)。
*/
/*
* ★ 视图切换**不再做整面板入场动画**(2026-09-14 用户:「部分动画十分不合理,
* 会导致页面大范围的闪动,且不能让人自然的把注意力集中在将要出现的页面上」)。
*
* 我先是从"所有面板一起动"改成"只动主内容区",但试下来仍然不对:页面级淡入
* 会让**已经在那儿的框架**也一起暗一下,观感还是闪。所以这一档整体删掉 ——
* 只保留**局部**、有明确语义的动效(日历翻页、菜单展开、背景图淡入)。
* 骨架不动,注意力自然落在变化的那块内容上。
*/
/*
* ★ 2026-09-15 全量盘点(用户:「全面检查整体的动画」):这里原本挂着
* `html.view-switch .pane-enter { animation: pane-in … }`,而 **`.pane-enter` 没有任何
* 组件在穿** —— 9aa702b 删掉整面板入场时留下的死规则(连 @keyframes pane-in 一起删了,
* 留着就是下一次"看起来有动画其实没人用"的源头)。
* 页面级入场仍不做:用户 09-14 明确否掉过"整屏一起淡";局部入场走 .rise-in / .pane-rise。
*/
/* 菜单/建议列表:轻微放大淡入,避免"啪"地出现 */
@keyframes menu-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
/*
* 只给**真正是弹层**的东西:`.animate-menu-in`(候选/下拉菜单自己穿)。
* ★ 2026-09-15 盘点:原先这条还带着 `html.view-switch .glass-control` —— 那是把
* "菜单入场"错当成了"控件档入场",于是每次切视图,页面上**所有**按钮与输入框一起
* 淡入位移(几十个元素同时动)。控件不需要入场动画,要动的是"刚弹出来的那张列表"。
*/
.animate-menu-in {
animation: menu-in 140ms ease-out both;
}
/* 尊重系统设置:关了动画就一点都不动(前庭功能敏感的人会被位移动画伤到) */
@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,
html.view-switch .pane-rise,
html.view-switch .glass-control,
.animate-menu-in {
animation: none !important;
}
}
/*
* .rise-in —— **局部**入场(写信页、回复框、转发面板)。
*
* ★ 2026-09-15 用户:「从发信按钮到发信页面,从聊天按钮到聊天输入,以及转发拉起
* 输入框都没有对应的动画,所有的动画都消失了,是不是我批评一下用力过猛你就
* 全给删了」—— 他猜对了。
*
* 9aa702b(2026-09-14)把 `html.view-switch .app-shell > *, .narrow-shell > *`
* 整条删掉、换成 `.pane-enter`,而 **`.pane-enter` 没有任何组件在穿**(已 grep 确认)
* ⇒ 这一档从「全部一起动」直接变成「一条都不动」,连这三处**局部**、
* 有明确语义的入场也一起没了。当时的批评针对的是**整面板/整屏**一起淡入
* (骨架跟着暗一下 = 闪),不是针对局部入场。
*
* 所以这里只补回"刚出现的那一块":4px + 不缩放,150ms,骨架一动不动。
* 位移与时长跟 pane-in/menu-in 同一套尺子(用户对"闪"的容忍度很低,
* 缩放与长位移是最容易读出"闪"的两项,已经拿掉了)。
*/
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
/*
* ★ 2026-09-15 用户:「回复邮件那个按钮还是没有动画」(我上一版只给回复框/转发面板挂了
* 类,却没让触发窗口在那两个动作上出现 —— view-switch 只由 视图/页签/窄屏/写信 触发,
* 于是类挂着、动画永远不播)。
*
* 所以拆成两档,各管各的:
* .rise-in —— **挂载即播**(无条件)。给"点了才出现"的面:写信页、回复框、转发面板。
* 它们只在用户动作时挂载,首屏不会有一堆东西同时淡入。
* .pane-rise —— 由 `html.view-switch` 窗口触发。给**常驻**面板(列表栏、日历):
* 它们一直挂在 DOM 上,只能靠切换窗口重放动画。
*/
.rise-in,
.pane-rise {
animation: none;
}
.rise-in {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
will-change: transform, opacity;
}
html.view-switch .rise-in {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
will-change: transform, opacity;
}
html.view-switch .pane-rise {
animation: rise-in 150ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
/*
* ★ 2026-09-15 用户:「动画卡顿严重」。
*
* 这一档只动 opacity + transform(合成器可做,不触发 layout/paint),
* 但没提层的时候浏览器不一定把它提到独立图层 —— 一提就不可能在主线程上抖动。
* `will-change` 写在 view-switch 窗口里(只在切换的 ~400ms 内有效),
* 不是写在元素上:常驻 will-change 会把每个面板都变成常驻图层,内存白吃。
*/
will-change: transform, opacity;
}
/*
* ★ 通信中栏里的直接子面板必须能被压缩(2026-09-14 用户:「通信页面的各个子页面
* 还是无法滑动啊,我真服了」)。
*
* 根因:我把「页签 + 列表 + 悬浮加号」包进了一个 flex 列 wrapper 来挂内部导航,
* 而 **flex 子项默认 min-height: auto** ⇒ 子面板不能被压到比内容矮 ⇒ 列表被撑高 ⇒
* 外壳的 overflow:hidden 把它整段裁掉:表现出来就是"完全无法上下滑动",
* 而且因为容器自己没溢出,连滚动条都不会出现。
*
* 为什么以前没坏:列表原先直接挂在窄屏覆盖层(`absolute inset-0 flex`,**行**方向)下,
* 行方向的高度来自 align-items: stretch,不需要 min-height:0;换成列方向后就必需了。
*
* 这条规则对所有子页面一视同仁(收件/发件/授权/联系人),避免只修好一个。
*/
/*
* 内层面板自己也要圆角。
*
* 之前圆角只加在外壳(.app-shell > * 与 .narrow-shell > *)上,靠 overflow:hidden 裁掉
* 内层直角 —— 但那个 overflow 会杀掉滚动(见上),必须去掉。于是内层白底面板的
* **直角从圆角外壳里戳出来**,看起来就是"大面积的非圆角元素"。
* 把圆角直接给内层面板,两边就对齐了,而且不依赖裁剪 ⇒ 与滚动不冲突。
*/
.comm-pane > *:not([data-testid='comm-tabs']) {
border-radius: var(--radius-card);
/*
* 既要能压缩(min-height:0),也要**填满**这一列(flex:1 1 0%):
* 子面板(如 MailList 的根)原本写着 `shrink-0` —— 那是给"行"方向布局写的,
* 放进列方向后它会连高度都不肯让,内部 `flex-1 overflow-y-auto` 永远拿不到
* 可用高度,于是"完全无法上下滑动"。
*/
flex: 1 1 0%;
min-height: 0;
}
/*
* ★ 日历:网格栏与右栏拼成**一张卡**(中间是分隔线,不是外壳间距)
* 用户(2026-09-14):「日历页面还没有添加圆角」。
*
* 为什么它一直没圆角:外壳那条 `.app-shell > *` 的圆角加在日历**根节点**上,
* 而根节点是 `flex-1 min-w-0 flex min-h-0` —— **它自己没有底色**。
* 真正白底的是里面那两块面板,直角从透明外壳里戳出来,看上去就是没圆角。
*
* 两个不能照搬收件箱做法的地方:
*
* ① 这两块面板**不是两张卡**(中间只有 1px 分隔线、没有外壳间距),
* 所以只能给**外沿**:左边那块给左两角、右边那块给右两角。
* 内侧要是也圆了,分隔线两边会露出底色缺口。
* ② 右栏外面还套了一层容器(管宽度 `lg:w-[400px]` 与 `border-l`,自己不上色),
* 真正上色的是它里面的 DayAgendaPane / CalendarEventEditor。
* 圆角只加在容器上会被内层的直角**原地盖掉** —— 所以要再往下给一层。
* (左栏本身就是白底面板,不能再往下给:它的第一个子元素是工具条,
* 给下去变成"工具条圆角",那不是我们要的东西。)
*/
.cal-panes > :first-child {
border-start-start-radius: var(--radius-card);
border-end-start-radius: var(--radius-card);
background-clip: padding-box;
}
.cal-panes > :last-child,
.cal-panes > :last-child > * {
border-start-end-radius: var(--radius-card);
border-end-end-radius: var(--radius-card);
background-clip: padding-box;
}
/*
* 桌面侧栏:无论有没有壁纸都做深色玻璃 —— 否则它是全页唯一一块实心黑。
* α=0.82 比壁纸模式的 0.72 实一点:此时它后面是页面底色而不是照片,太透只会显脏。
*/
/*
* 这里曾有一条 `.app-shell > .bg-chrome-900 { background-color: rgb(var(--c-chrome-900) / 0.82) }`。
*
* 用户报「宽屏 ui 你是一点没修复啊」「导航栏为什么还是黑色」——它就是元凶:
* 侧栏改成 `.nav-rail`(走 --nav-bg 令牌,浅色主题=白玻璃)之后,这条更早的规则
* **特异性更高**((0,2,0) > (0,1,0)),继续把侧栏压成深色,看起来就像"根本没改"。
* 现在整条删掉:侧栏底色只有 --nav-bg 一个来源。
*/
/*
* 日历翻页过渡(2026-09-14 用户:「日历滑动页面为什么没有切换动画?」)。
*
* 方向由 shift() 记录:下一段从右边推进来、上一段从左边推进来 —— 与手势方向一致。
* 位移比整屏小(12% / 上限 56px):日历是密排网格,整屏平移会显得晃。
*/
@keyframes cal-in-next {
from {
opacity: 0;
transform: translateX(12%);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes cal-in-prev {
from {
opacity: 0;
transform: translateX(-12%);
}
to {
opacity: 1;
transform: none;
}
}
.cal-slide-next {
animation: cal-in-next 200ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.cal-slide-prev {
animation: cal-in-prev 200ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.cal-slide-next,
.cal-slide-prev {
animation: none;
}
}
/*
* 紧凑双栏(手机横屏)下,列表栏固定 320px。
*
* 为什么不能靠 Tailwind:列表栏的宽度写的是 `lg:w-[320px]`,而 lg 断点是 1024px ——
* 844 宽的横屏**不满足** ⇒ 它退回 `w-full` ⇒ 列表吃掉整宽、详情被挤成一条缝
* (这正是用户最早说"横屏没自适应"背后的一半原因)。
* 这里按"紧凑双栏"这个语义条件给宽度,而不是再猜一个像素断点。
*/
.compact-2pane .comm-pane {
flex: 0 0 320px;
max-width: 320px;
}
/* 详情栏占满剩下的宽度 */
.compact-2pane > .flex-1 > :last-child {
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;
}
/*
* ★ 深色主题:**暂不单独给导航换色**(2026-09-14 用户:「导航栏为什么还是黑色」)。
*
* 根因不是令牌抄错,而是**整个应用还没有深色主题**:
* `tailwind.config.js` 里 `darkMode: 'class'` 是配好的,但**没有任何组件写 `dark:` 变体**
* ⇒ 挂上 `.dark` 只会切换我手写的 CSS 变量,Tailwind 工具类一律照旧。
* 于是系统是深色时:导航(有深色令牌)变深、正文(没有深色样式)仍是浅色 ——
* 界面半深不浅,用户看到的就是"导航栏还是黑色"。
*
* 在真正的深色主题做出来之前,导航跟随**内容的实际形态**(浅色)。
* 要做深色主题,正确做法是给组件补齐 `dark:` 变体(那是独立一件事),
* 而不是先把导航单独压深。
*/
/*
* 深色主题 —— **2026-09-17 落地**(用户报「深色模式可读性差」)。
*
* 这里原先是有意留空的,理由是「组件侧没有 dark: 变体,只换令牌会半深不浅」。
* 实测证明那个担心方向反了:组件写的是**语义色阶**(`bg-white` / `text-gray-900`),
* 灰阶反转后它们自动适配;真正没适配的是**手写 CSS 里那几处硬编码白色**。
*
* 实测(深色、1280×800,读页面计算值):
* .nav-rail 合成 rgb(188,189,190) 浅灰条;--nav-fg-muted 文字只有 4.03:1
* .glass-card 合成 rgb(237,237,237) 白卡;--c-gray-900 文字 1.07:1(白底白字)
* 也就是「深色下整片白」—— 这正是可读性差的来源。
*
* 修法遵守既有契约(background.test.mjs「玻璃是白色材料」):**基材不改**
* (--glass-base 仍只有一处定义、仍是白),主题之间只差 **alpha** ——
* 深色下把白的 alpha 压到 0.06,让深底透上来;文字令牌本来就已反转。
*/
.dark {
/*
* 导航:深玻璃 + 亮字。
*
* 这里曾有一条判据禁止 `.dark` 出现 `--nav-*`(背景是**当时还没有深色主题**
* ⇒ 只压深导航就会「导航黑、正文白」,比全浅更割裂)。那条判据的注释写明
* 「真做深色主题时,这条要连同 dark: 变体一起改」—— 本行即那个时刻,
* 判据已同步改成断言这套深色令牌**存在且对比度达标**。
*/
--nav-bg: 30 35 44 / 0.72;
--nav-fg: 236 240 246;
--nav-fg-muted: 148 158 175;
--nav-hover-bg: 255 255 255 / 0.08;
--nav-active-bg: 30 58 95;
--nav-active-fg: 154 191 250;
--nav-border: 255 255 255 / 0.08;
/* 卡片面:白基材只降 alpha(0.92 → 0.06),深底便透上来。 */
--glass-card-a: 0.06;
--glass-card-hover-a: 0.1;
--glass-card-wall-a: 0.04;
--glass-card-wall-hover-a: 0.07;
/* 深色下靠「边框亮于底」而不是阴影来分层。 */
--hairline-fg: 255 255 255 / 0.09;
--hairline-fg-strong: 255 255 255 / 0.16;
}
.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));
}
/*
* 底部导航的选中态**只换颜色**。
*
* 用户(2026-09-14):「同时底部导航栏选中对应的文字和图标变色即可」。
* 之前底部这一栏的选中同时被三样东西表达:`--nav-active-bg` 底色、一条
* `bg-blue-400` 顶部指示条、以及颜色。底部条只有 51px 高,多出来的两个"形状"
* 只是把图标与文字挤小。
*
* 只作用在 `.narrow-nav` 上:宽屏侧栏是 48px 宽的竖条,图标底下那一块底色是它
* 唯一的选中线索(窄屏还有文字标签与当前页面对应,宽屏没有可参照的文字),
* 所以"只变色"不能无差别推广到所有 `.nav-item`。
*/
.narrow-nav .nav-item[data-active='true'] {
background-color: transparent;
}
/* 底部导航条:与侧栏同一套令牌(深色主题才变深) */
.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 令牌块,一处定义。
*/
/*
* ═══════════════════════════════════════════════════════════════════
* 列表项自己的玻璃卡(.glass-card)
*
* 用户(2026-09-14):「你为什么是给所有列表项一起套了一个玻璃外框,
* 而不是每个列表项单独套外框?」
*
* 他说得对。之前的做法是「面板 = 一张玻璃,行躺在里面」—— 那是把列表当成
* 一个整体容器,行只是它的内容。但界面上其它地方(正文卡、弹层)都是"每一项
* 自己是一块面",列表却是个例外,观感上就少了一层层次。
*
* 改成:面板退成透明,**每一项自己是一张玻璃卡**(圆角 + 半透明 + 细边框 +
* 悬停加深)。这样每项都是独立可点、可拖、可单独高亮的一块。
*/
html[data-bg='on'] .comm-pane > .bg-white {
/* 列表/详情的外框在壁纸模式下让位给"每项一张卡",但**工具条与头部**仍要
有底色,否则会直接压在壁纸上读不清 —— 所以只把列表容器那一层放透明。 */
background-color: transparent;
}
.glass-card {
border-radius: var(--radius-card);
border: 1px solid rgb(var(--hairline-fg));
/*
* 基材**恒为白**,主题之间只差 alpha(--glass-card-a)。
*
* 这里原先写死 `rgb(255 255 255 / 0.92)` —— 深色下它就是一块白卡,
* 而文字走的是反转后的近白令牌 ⇒ 实测 1.07:1 的白底白字。
* 写死白色的地方,深色主题一律改不成 —— 走令牌才有得改。
*/
background-color: rgb(var(--glass-base) / var(--glass-card-a));
transition: background-color 150ms, border-color 150ms;
}
.glass-card:hover {
background-color: rgb(var(--glass-base) / var(--glass-card-hover-a));
border-color: rgb(var(--hairline-fg-strong));
}
html[data-bg='on'] .glass-card {
background-color: rgb(var(--glass-base) / var(--glass-card-wall-a));
border-color: rgb(var(--hairline-fg));
}
html[data-bg='on'] .glass-card:hover {
background-color: rgb(var(--glass-base) / var(--glass-card-wall-hover-a));
}
/*
* (这里曾有 `.dark .glass-card { background: rgba(255,255,255,0.06) }`。
* 它当年被撤掉的理由是「组件还没有 dark 变体,这条只会让卡片在浅色页面上
* 几乎消失」—— 2026-09-17 实测确认组件侧其实是自洽的(灰阶已反转),
* 缺的正是这一档 alpha,于是它现在以 `--glass-card-a` 的形态回来了。)
*/
/*
* ★ 宽屏下通信中栏必须**贴住列表面板的宽度**(2026-09-14 用户截图指出:
* 「外框列表和右边正文那么大一个空隙」)。
*
* 根因是我为了挂内部页签与悬浮加号加的包装层:它写着 `flex-1`,
* 而里面的列表面板是 `lg:w-[320px]` 固定宽 ⇒ 包装层把剩余宽度全占了,
* 列表右边到详情左边就空出一条大缝(缝在包装层里,不在任何面板里,
* 所以看代码很难发现)。
*
* 窄屏不用改:那边外壳是列方向,`flex-1` 管的是**高度**,宽度由 `w-full` 决定。
*/
.app-shell .comm-pane {
flex: 0 0 auto;
width: 320px;
}
/*
* ★ 滚动容器的边缘淡出(2026-09-14 用户:「内容项上下滑动会直接被切断」)。
*
* 滚动条本身是"一刀切":卡片滚到边缘就被硬生生截断。给滚动容器加一层
* 上下渐隐的遮罩,切断处变成渐隐 —— 切得有交代,观感也不再像 bug。
*
* 只作用在**纵向**滚动容器(`.overflow-y-auto`):周视图那种**横向**滚动
* 有自己的横向滚动条,加纵向遮罩会跟它打架(用户刚提过手势冲突那件事)。
* 上下各 12px 与列表内边距(10px)接近,静止时几乎看不出,滚动时才起作用。
*
* ★ 2026-09-14 追加(用户:「你有的地方渐变用的过猛了,比如收件人候选那里」):
* 那个「12px」是拿**长列表**的内边距(10px)当尺子量的,可它作用在**所有**
* `overflow-y-auto` 上。收件人候选菜单整块才 58px 高(提示行 22px + 一条候选 34px),
* 上下各淡 12px 共 24px —— 小半个菜单是渐变的,那条唯一的候选底部直接被洗白。
* 「过猛」的根因不是 12px 太大,而是**固定像素用在了一个高度不固定的东西上**。
* 所以改成按容器可见高度封顶:min(12px, 10%)。
* - 高列表(≥120px)拿到的还是 12px,原观感不变;
* - 矮容器按比例收敛(58px 的菜单 → 5.8px)。
*/
.overflow-y-auto {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 min(12px, 10%),
#000 calc(100% - min(12px, 10%)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 min(12px, 10%),
#000 calc(100% - min(12px, 10%)),
transparent 100%
);
}
/*
* 弹层(控件档的滚动区)**不淡**:它是圆角 + 边框的独立表面,内容被边框截住
* 已经「有交代」了 —— 那个淡出本来是为了不让长列表的卡片显得被硬切断的。
* 而弹层高度本来就小,淡出只会把首尾项洗白(实测:候选菜单 58px,淡掉 24px)。
*/
.overflow-y-auto.glass-control,
.overflow-y-auto.popup-surface {
-webkit-mask-image: none;
mask-image: none;
}
/* ══════════════════════════════════════════════════════════════════════
自绘窗口标题栏(2026-10-04)
══════════════════════════════════════════════════════════════════════
主进程已改成 `frame: false`(见 electron/main.cjs 的 BrowserWindow 配置),
所以这一条是**窗口框架本身**,不是装饰。
★ 高度为什么定 36px:鸿蒙侧 PC/2in1 的 `windowDecor` 实测 37vp
(见 `MainPage.ets` 的 insets 日志:statusBar=38.6 navIndicator=27.8
**windowDecor=37**),两端的窗口控件高度对齐到同一量级,
免得并排摆两个应用时一个头厚一个头薄。
这里取 36 而不是 37:桌面端按物理像素算,36 在 1x 下更接近常见做法,
且能让标题文字在 12px 字号下不出血。**要改就两个一起改**(本仓只有 win/linux)。
★ 为什么用 `position: fixed` 而不是布局里的一行:
它必须覆盖**所有**路由(登录页、加载页、初始化向导、写信全屏),
而那些分支各自 `return` 自己的根 div —— 塞进布局就得改每一处,
漏一处就是「某个页面没有标题栏」。见 main.tsx 的接入点说明。
*/
.titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 36px;
z-index: 40;
/*
* ★ 拖动区。整条是 drag,但里面的按钮与文字必须 no-drag ——
* `drag` 区域里的交互元素**收不到鼠标事件**(这是 Chromium 的规定,
* 不是本项目的取舍)。漏了 no-drag 的症状是「按钮画得出来、点不动」。
*/
-webkit-app-region: drag;
app-region: drag;
display: flex;
align-items: center;
gap: 8px;
/* 标题文字不参与拖拽命中:否则双击标题想最大化会被当成选中文字 */
user-select: none;
/*
* 底色跟着主题走:浅色用 chrome-100、深色用 chrome-900 ——
* 与侧栏同族(见上面 --c-chrome-* 的色阶说明),
* 这样标题栏与侧栏看起来是**同一块** chrome,不会多出一条"外来"的带子。
*/
background: rgb(var(--c-chrome-100));
/* 底下那条 1px 分隔线用面板分隔线色,不用黑色半透明 —— 深色下后者会脏。 */
border-bottom: 1px solid rgb(var(--c-gray-200) / 0.9);
}
.dark .titlebar {
background: rgb(var(--c-chrome-900));
border-bottom-color: rgb(var(--c-chrome-800));
}
/*
* 标题文字。
*
* ★ `-webkit-app-region: no-drag` 在这里**不是**为了让文字可点,
* 而是为了让**双击标题栏最大化**生效:drag 区域的双击由系统接管,
* 而文字层如果自己是 no-drag,双击就会被它吃掉、系统收不到。
* (按钮也 no-drag,但按钮上的双击不构成「双击标题栏」,语义不同。)
*/
.titlebar-title {
-webkit-app-region: no-drag;
app-region: no-drag;
flex: 1;
min-width: 0;
padding-left: 12px;
font-size: 12px;
font-weight: 500;
color: rgb(var(--c-gray-600));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dark .titlebar-title {
color: rgb(var(--c-chrome-400));
}
/* 右侧三个按钮 */
.titlebar-btns {
-webkit-app-region: no-drag;
app-region: no-drag;
display: flex;
align-items: stretch;
height: 100%;
flex: none;
}
.titlebar-btn {
/*
* 命中区 46×36:宽高都过了通行下限(Windows 标题栏按钮 46×32,
* 这里跟随宽度、把高度补满整条,视觉上更整)。
*/
width: 46px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: rgb(var(--c-gray-700));
cursor: default; /* 系统标题栏按钮不是 pointer */
transition: background 0.12s ease;
}
.dark .titlebar-btn {
color: rgb(var(--c-chrome-400));
}
.titlebar-btn:hover {
background: rgb(var(--c-gray-900) / 0.08);
}
.dark .titlebar-btn:hover {
background: rgb(var(--c-chrome-400) / 0.12);
}
/* 关闭 hover 用红,与 Windows 一致 —— 这是"唯一危险动作"的通用语言 */
.titlebar-btn-close:hover {
background: #e81123;
color: #fff;
}
.dark .titlebar-btn-close:hover {
background: #c42b1c;
color: #fff;
}
/* 焦点可见性:键盘用户必须能看到焦点在哪(见 G-1 无障碍欠账的同族要求) */
.titlebar-btn:focus-visible {
outline: 2px solid rgb(var(--c-accent, 37 99 235));
outline-offset: -2px;
}
/*
* 图标:12×12 的 viewBox,用 stroke 画(与 icons.tsx 同一套 `currentColor` 约定)。
* `fill: none` 必须显式给 —— 否则 SVG 默认 fill=black,叉会变成一个黑块。
*/
.tb-glyph {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 1;
stroke-linecap: round;
pointer-events: none; /* 图标不参与命中,命中归按钮 */
}
/*
* ══ 标题栏占位:把内容整体往下让出 36px ══
*
* ★ 为什么用 padding 而不是把 App 往下挪:
* App 的三个分支(宽屏 / 窄屏 / 无列表的独页)各自是 `h-full`,
* 改它们的根节点要改三处且都与各自的布局判断耦合。
* 而 `html.titlebar-on` 上加 padding-top 是**一处**、对所有分支一致 ——
* 与 `.safe-frame` 处理窄屏安全区是同一形状(那里也是在外层加 padding)。
*
* ⚠️ 但 `html, body, #root { height: var(--app-height) }` 是 `height` 不是 `min-height`
* ⇒ 在 html 上加 padding 会让**总高超出视口**(100% + 36px),
* 于是内容底部被裁掉 36px。所以下面这条必须把高度改成 `calc`:
* 见 `.titlebar-on { height: ... }` 那段。
*/
html.titlebar-on {
height: calc(var(--app-height) - 36px);
/* 上面减了 36,下面补 36 ⇒ 布局视口仍是满的,只有内容被推下去 */
padding-top: 36px;
}
html.titlebar-on body,
html.titlebar-on #root {
height: 100%;
}
/*
* 开机动画:`App.tsx` 根上那个 `animation` 会做位移/缩放
* (见它 `viewMode`/`commTab` 变化时 cancelAnimationFrame 那段)。
* 标题栏是 fixed 的、与内容无关 ⇒ 不参与那些动画,否则每切一次路由
* 标题栏自己也要淡入一次(看着像闪)。
*/
/*
* ══ 最大化时:标题栏仍然在(Win/Linux 的系统标题栏最大化后也在),
* 但不再需要那条分隔线 —— 面板顶边就是分界。 ══
*/