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,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
This commit is contained in:
2026-09-12 08:02:30 +08:00
parent dc7bf57ceb
commit 84c1d749cd
13 changed files with 1537 additions and 28 deletions

View File

@ -197,6 +197,51 @@
--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: 24%;
--bg-blur: 8px;
/* 玻璃面板的不透明度:背景越花,面板需要越实才读得动。 */
--bg-glass: 0.82;
/*
* ─── 尺寸与动效 ───
*
* 提取成变量的理由与颜色相同:一处定义、全站一致。写死数值时「圆角"
* 会随组件作者的随手一写而漂移(本仓库曾同时存在 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);
--hairline: 0 0 0;
color-scheme: light;
}
@ -226,8 +271,15 @@
.dark {
--c-white: 24 27 33;
/* 近白而非纯白:深色页面上纯白字偏刺眼。关键是它不跟着 --c-white 变暗。 */
--c-on-accent: 244 246 250;
/*
* 近白而非纯白:深色页面上纯白字偏刺眼。
*
* 但它同时是**实心彩底按钮上的文字**,而那儿的对比度是硬指标:实测 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;
@ -254,6 +306,23 @@
--c-chrome-800: 30 35 44;
--c-chrome-900: 12 14 18;
/*
* 背景遮罩换成黑:浅色下用白把图案洗淡,深色下必须压暗,
* 否则一张浅色照片会在深色界面里舗成一块亮斑,正文完全读不动。
*/
--bg-scrim: 0 0 0;
/* 深色下面板要更实:背景亮部与深色卡片对比过强时,文字会显得发灰。 */
--bg-glass: 0.86;
/*
* 深色下的层次靠「边框亮于底」而不是阴影。
* 保留一行极淡的黑色阴影只为了与浅色统一接管机制,真正的边界感来自 --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);
--hairline: 0 0 0 1px rgb(255 255 255 / 0.06);
/*
* ─── 强调色(深色)───
*
@ -492,3 +561,223 @@
}
}
}
/*
* ═══════════════════════════════════════════════════════════════════
* 自定义背景 + 现代化打磨
* ═══════════════════════════════════════════════════════════════════
*
* # 为什么这一段放在所有 @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;
background-color: rgb(var(--bg-scrim) / var(--bg-dim));
}
/*
* ─── 预设渐变 ───
*
* 全部用现有调色板的**表面段**50300拼出来因此
* - 无需新增任何写死的十六进制值
* - 自动随主题变化 —— 那一段在深色下本来就是暗的(见 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;
}
html[data-bg='on'] .bg-white {
background-color: rgb(var(--c-white) / var(--bg-glass));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
}
/* 悬停态也要接管:不接管的话鼠标一进面板就会闪成不透明(明显跳动)。 */
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));
}
html[data-bg='on'] .bg-chrome-900 {
background-color: rgb(var(--c-chrome-900) / 0.86);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
}
html[data-bg='on'] .bg-chrome-800 {
background-color: rgb(var(--c-chrome-800) / 0.82);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
}
/*
* 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;
}
/* 交互元素统一过渡;只过渡颜色类属性,避免布局抖动。 */
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), box-shadow var(--dur-base) 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;
}
}