Files
MailUI4Agents/client/electron/tailwind.config.js
JianFeeeee 84c1d749cd feat(webui): 自定义背景 + 外观现代化;修正实心按钮白字在深色下的对比度
# 自定义背景(新功能)

三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。

**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(50–300)在深色下本来就是暗的(见 .dark 与 theme.test.mjs 第 19 条),
于是浅色得到柔和 pastel、深色得到低沉暗调,不需要维护两套渐变,也不会
出现「深色模式下原样落下浅色渐变」这类绕过主题变量的错误。

图片路径的关键取舍:
- **先压缩再存**。手机直出照片 4–8MB,而 localStorage 配额约 5MB,直接写会抛
  异常,用户看到的是「选了图片没反应」。等比缩到最长边 2560px、转 JPEG;
  仍超限则再缩一档;再不行就**明确拒绝并说明原因**(不是静默失败)。
- 失败一律返回 `{ok:false, reason}` 并渲染成 `role="alert"`。

# 背景层为什么不放进主题 store

主题(light/dark/system)是必须全局一致的语义;背景是纯装饰偏好,取值空间
与主题毫无关系。混在一起会让「跟随系统」的实现被背景字段淹没。

# 背景层实现在 CSS,不改 27 个组件

按 Tailwind 生成的实际类名统一接管:背景开启时让出不透明的页面底
(body / bg-gray-50 / bg-slate-100 → 透明),并把卡片(bg-white)与框架
(bg-chrome-800/900)变成半透明 + 背景模糊。

逐个组件加 class 必然漏 —— 漏掉的那块就是一张不透明卡片浮在背景上。
这段 CSS **刻意放在所有 @layer 之外**:它要覆盖的正是 utilities 生成的
`.bg-white`,写进 @layer components 会被 utilities 压过去(静默失效),
而分层 CSS 恒输给未分层 CSS,这是唯一稳定可靠的位置。

**chrome-600/700 刻意保持不透明**:它们不是大面板,而是导航项与 15px 的
计数徽标。真实渲染量得半透明会把徽标上的数字压到 4.46:1,低于 AA 4.5 ——
小控件的可读性优先于装饰效果(已用脚本量出,见下)。

# 「跟随系统」的可见性

三态本来就已实现(system 为默认值 + matchMedia 监听)。这次做的是让它可被
发现与信任:选择器改成分段控件(role=radiogroup + aria-checked),说明文案
写清「跟随系统会随系统的深色开关自动切换」,并保留单选按钮入口的
「当前跟随系统:深色/浅色」提示。

# 外观现代化

- **圆角整体调大一档**(默认 0.25→0.5rem)。原值是几年前的紧凑风格,
  在宽屏桌面应用上偏硬。只改比例尺,200 处圆角一次性刷新,不产生
  「新组件大圆角、旧组件小圆角」的断层。
- 语义化圆角令牌:`rounded-card` / `rounded-control`(数值档位答的是「多大」,
  这两个名字答的是「用在哪」)。
- 自定义滚动条(桌面应用里常驻可见,系统默认样式偏旧)。
- 键盘焦点环(`:focus-visible`,仅键盘导航时出现;可访问性硬要求)。
- 交互元素统一过渡;并尊重 `prefers-reduced-motion`。

# 顺带修正两处真实问题(都由真实渲染量出,不是估算)

1. **实心按钮白字在深色下 4.46:1,低于 AA**。
   深色 `--c-on-accent` 是「近白」244 246 250(为了不刺眼),而结构检查第 23
   条只拿**浅色**的纯白 255 去算 → 4.83 通过。**测试存在盲区**:
   同一个实心底,白字换暗一点点就越过 AA 线。导航未读徽标「12」正是这个组合。

   两处都修:把第 23 条改成**两种模式的 on-accent 都算**(闭合盲区),
   并把深色 on-accent 抬到 250 250 252(4.65:1,仍非纯白,保留原初衷)。

2. **theme.test.mjs 切颜色块的方式很脆**:它用 `indexOf('.dark')` 切片,于是在
   :root 的注释里写一句带点的选择器写法就会把浅色块提前截断(我加注释时
   真的踩到了,第 8 条假失败)。更危险的是反向情形:块被截短后变量集合变小,
   「覆盖齐全」这类断言可能**真空通过**。改为所有块切分都基于**剥注释后**的文本。

# 测试

- 新增 `test/background.test.mjs`(15 条结构检查):遮罩两主题各一份、
  背景层必须负 z-index(0 会盖住界面)、背景开启时必须让出页面底、
  玻璃化只在 data-bg=on 下、悬停态一并接管、预设复用调色板变量、
  图片上限与失败原因存在、尊重 reduced-motion 等。
- 新增 `test/stores/background.test.ts`(16 条):脏数据归一化(未知预设、
  kind=image 却无图、越界数值)、CSS 变量写入与清理成对(残留 --bg-image 会
  让「关掉背景」后仍显示旧图)、localStorage 抛异常不打断操作。
- 新增 `test/manual/background-verify.mjs`:连真实 Chromium 验收**渲染结果**
  (背景层是否真的可见、玻璃化的计算样式、正文在背景之上是否仍达 WCAG AA、
  自动模式在**不刷新**页面时跟随系统切换、显式选择不被系统覆盖)。
  它拦住了上面两个真问题,也拦住了我自己两次写错的判据。

# 验证

- typecheck 干净
- 主题 30/30、背景 15/15、vitest 216/216(新增 16)
- 真实渲染验收 23/23(AGENTMAIL_DIST 注入本地构建 + 活 Gateway,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
2026-09-12 08:02:30 +08:00

213 lines
9.3 KiB
JavaScript
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.

/** @type {import('tailwindcss').Config} */
/**
* 颜色走 CSS 变量而不是写死的十六进制。
*
* # 为什么不逐处加 dark: 前缀
*
* 全站约 700 处颜色用法散在 21 个组件里。逐个写 `bg-white dark:bg-gray-900`
* 有两个致命问题:漏一处就是深色下的白底白字(而且只有肉眼能发现),
* 以及此后每加一个组件都要记得写两遍 —— 那种约定活不过三次改动。
*
* # 为什么改调色板就够了
*
* 这套代码里灰阶**本身就是语义色阶**
* - `white` / `gray-50` / `gray-100` = 表面层次(卡片 / 页面底 / 悬停)
* - `gray-200` / `gray-300` = 分隔线
* - `gray-900` → `gray-400` = 文字主次
*
* 深色模式要做的正是把这条色阶**反转**white 变近黑、gray-900 变近白。
* 于是零组件改动就能整体切换,新组件照常写 `bg-white text-gray-900`
* 也自动适配 —— 不需要任何人记得任何约定。
*
* # 为什么是 `rgb(var(--x) / <alpha-value>)` 而不是直接存颜色串
*
* 代码里有 `bg-blue-50/70`、`bg-gray-50/60` 这样的透明度修饰符。
* 变量若存 `#f9fafb`Tailwind 生成的 `rgb(#f9fafb / 0.7)` 是无效 CSS
* 那些半透明高亮会静默失效(不报错,只是不透明)。存 RGB 三元组才行。
*/
const withAlpha = (v) => `rgb(var(${v}) / <alpha-value>)`;
const grayScale = {
50: withAlpha('--c-gray-50'),
100: withAlpha('--c-gray-100'),
200: withAlpha('--c-gray-200'),
300: withAlpha('--c-gray-300'),
400: withAlpha('--c-gray-400'),
500: withAlpha('--c-gray-500'),
600: withAlpha('--c-gray-600'),
700: withAlpha('--c-gray-700'),
800: withAlpha('--c-gray-800'),
900: withAlpha('--c-gray-900'),
950: withAlpha('--c-gray-950')
};
/**
* 强调色blue / red / green / amber / orange / yellow
*
* 这条色阶在这套代码里也是**语义色阶**,与灰阶同理:
* - `50` `300` = 表面chip 底、提示条底、徽标底、边框)
* - `400` `900` = 前景(文字、图标、实心按钮底)
*
* 深色模式下两段的走向**相反**:表面段要变暗(照搬浅色的近白值会在深色页面上
* 糊出一块刺眼亮斑),前景段要变亮(照搬浅色的 red-700 落在深色卡片上只有
* 2.67:1读不动。所以它必须走变量不能写死 —— 见 index.css 的两组定义。
*/
const accent = (name) => ({
50: withAlpha(`--c-${name}-50`),
100: withAlpha(`--c-${name}-100`),
200: withAlpha(`--c-${name}-200`),
300: withAlpha(`--c-${name}-300`),
400: withAlpha(`--c-${name}-400`),
500: withAlpha(`--c-${name}-500`),
600: withAlpha(`--c-${name}-600`),
700: withAlpha(`--c-${name}-700`),
800: withAlpha(`--c-${name}-800`),
900: withAlpha(`--c-${name}-900`)
});
/**
* 实心按钮/徽标的底色 —— `--s-*`,两种模式下**同值**。
*
* 为什么不能跟 accent 的前景段走:那一段在深色下被提亮成了浅色
* red-600 → rgb(246,141,141)),而 `bg-red-600 text-white` 的白字落上去
* 只有 1.6:1。实心按钮的底色本来就该保持饱和 —— 深色模式下变的是页面,
* 不是「危险操作按钮是红的」这件事。
*
* 只覆盖 backgroundColor`text-red-600` / `border-red-600` 仍走 accent()。
* Tailwind 的 backgroundColor 默认继承 colors这里逐档覆写 400900
* 50300 是表面段,仍从 colors 继承 —— 它们在深色下就该变暗)。
*/
const solid = (name) => ({
400: withAlpha(`--s-${name}-400`),
500: withAlpha(`--s-${name}-500`),
600: withAlpha(`--s-${name}-600`),
700: withAlpha(`--s-${name}-700`),
800: withAlpha(`--s-${name}-800`),
900: withAlpha(`--s-${name}-900`)
});
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
// class 而不是 media主题要能被人显式选择。跟系统走是**默认值**
// 不是唯一选项 —— 白天开深色主题是常见偏好。
darkMode: 'class',
theme: {
extend: {
/**
* 圆角整体调大一档。
*
* 原值rounded 0.25rem / md 0.375rem)是好几年前 Tailwind 默认的
* 紧凑风格,在宽屏桌面应用上显得硬。这里只动默认比例尺,不改任何
* 组件的类名 —— 全站 200 处圆角一次性刷新,也不会出现「新组件用大圆角、
* 旧组件还是小圆角」的断层。
*
* 不用 CSS 变量Tailwind 的 borderRadius 不参与主题切换,
* 两种模式下圆角本就相同,走变量只会多一层间接。
*/
borderRadius: {
DEFAULT: '0.5rem',
md: '0.625rem',
lg: '0.75rem',
xl: '1rem',
'2xl': '1.25rem',
/*
* 语义化令牌(定义在 index.css 的 :root
*
* 数值档位是「大中小」;这两个名字回答的是「用在哪里」:
* - card 卡片/面板(比控件更大,观感更轻)
* - control 按钮/输入/滑杆(可点控件的统一形状)
* 两者都是显式选择,不依赖作者记得选 md 还是 lg。
*/
card: 'var(--radius-card)',
control: 'var(--radius-control)'
},
/**
* textColor 单独覆盖 white。
*
* `--c-white` 服务两种**互相冲突**的用途:
* - `bg-white` = 卡片表面 → 深色模式必须变暗
* - `text-white` = 彩色按钮上的文字 → 深色模式必须**保持浅色**
*
* 只有一个变量时后者跟着变暗,白字落在 `bg-chrome-700` 的激活导航项上
* 只剩 1.34:1 —— 几乎不可见(实测发现)。按钮底色在深色模式下依然是
* blue-600 那样的彩色,上面的文字本来就该是白的。
*
* Tailwind 的 textColor 默认继承 colors这里只改 white 一项,
* 其余gray/blue/...)仍走反转的色阶。
*/
textColor: {
white: withAlpha('--c-on-accent')
},
/**
* backgroundColor 单独覆盖强调色的 400900 档,让实心按钮底保持饱和。
*
* 每个强调色的 400900 在这套代码里承担**两个冲突用途**
* - `text-red-600` / `border-red-300` = 深色下必须**提亮**才读得动
* - `bg-red-600` + `text-white` = 深色下必须**保持饱和**
*
* 共用一档时后者必坏:深色下 red-600 被提亮到 rgb(246,141,141)
* 白字落上去只有 1.6:1 —— 与 text-white/bg-white 那次是同一类错误
* (一个名字服务两种语义)。
*
* 50300 不覆盖:那是表面段,深色下就该跟着变暗。
*/
backgroundColor: {
blue: solid('blue'),
red: solid('red'),
green: solid('green'),
amber: solid('amber'),
orange: solid('orange'),
yellow: solid('yellow')
},
colors: {
white: withAlpha('--c-white'),
gray: grayScale,
// slate 在这套代码里只用于登录页与少数深色块,与 gray 同源即可 ——
// 保留两个名字是为了不改那些组件,但它们指向同一条色阶。
slate: grayScale,
/**
* chrome —— 应用框架(侧栏 / 底部导航)的专用色阶。
*
* 为什么不能跟 gray 走:这两块**在浅色模式下本来就是深色的**
* (深色侧栏配浅色内容区是这套 UI 的原本设计)。把它们并入反转的
* gray 之后,深色模式下 `bg-slate-900` 变成了近白色 —— 侧栏比内容区
* 还亮,整个层次翻了过来(实测 rgb(243,245,248),而内容区是 rgb(17,19,24))。
*
* 独立成一条色阶后:浅色模式下它是深色框架,深色模式下**微调即可**
* (比内容区略深一点,保持"框架比内容更沉"的关系),两种模式下
* 语义一致。
*/
chrome: {
100: withAlpha('--c-chrome-100'),
200: withAlpha('--c-chrome-200'),
400: withAlpha('--c-chrome-400'),
600: withAlpha('--c-chrome-600'),
700: withAlpha('--c-chrome-700'),
800: withAlpha('--c-chrome-800'),
900: withAlpha('--c-chrome-900')
},
/*
* 强调色。表面段50300在深色下变暗、前景段400900变亮
* 两段走向相反 —— 详见 accent() 的注释与 index.css 的两组定义。
*
* 实心按钮底另走 --s-*(见上面的 backgroundColor
*
* 注意这里**只能有一份定义**JS 对象字面量的重复键后者胜出,
* 而那不会报错。上一版同时写了固定 hex 与 accent() 两份,
* accent() 覆盖了 hex 那份,但 index.css 里当时没有对应的 --c-red-* 等
* 变量 —— `rgb(var(--c-red-600) / 1)` 里的变量未定义使整条声明失效,
* 于是 bg-red-600 退回透明,白字落在白卡片上:按钮看不见但点得动。
*/
blue: accent('blue'),
red: accent('red'),
green: accent('green'),
amber: accent('amber'),
orange: accent('orange'),
yellow: accent('yellow')
}
}
},
plugins: []
};