# 自定义背景(新功能)
三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。
**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(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,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
213 lines
9.3 KiB
JavaScript
213 lines
9.3 KiB
JavaScript
/** @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,这里逐档覆写 400–900
|
||
* (50–300 是表面段,仍从 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 单独覆盖强调色的 400–900 档,让实心按钮底保持饱和。
|
||
*
|
||
* 每个强调色的 400–900 在这套代码里承担**两个冲突用途**:
|
||
* - `text-red-600` / `border-red-300` = 深色下必须**提亮**才读得动
|
||
* - `bg-red-600` + `text-white` = 深色下必须**保持饱和**
|
||
*
|
||
* 共用一档时后者必坏:深色下 red-600 被提亮到 rgb(246,141,141),
|
||
* 白字落上去只有 1.6:1 —— 与 text-white/bg-white 那次是同一类错误
|
||
* (一个名字服务两种语义)。
|
||
*
|
||
* 50–300 不覆盖:那是表面段,深色下就该跟着变暗。
|
||
*/
|
||
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')
|
||
},
|
||
/*
|
||
* 强调色。表面段(50–300)在深色下变暗、前景段(400–900)变亮,
|
||
* 两段走向相反 —— 详见 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: []
|
||
};
|