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:
@ -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));
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── 预设渐变 ───
|
||||
*
|
||||
* 全部用现有调色板的**表面段**(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user