diff --git a/client/electron/package.json b/client/electron/package.json index 1b4a48e..37b2ec0 100644 --- a/client/electron/package.json +++ b/client/electron/package.json @@ -19,7 +19,7 @@ "build:linux": "vite build && electron-builder --linux", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && vitest run", + "test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && vitest run", "test:narrow": "node test/manual/narrow-verify.mjs", "test:wide": "node test/manual/wide-regression.mjs", "test:components": "vitest run", diff --git a/client/electron/src/components/AccountPage.tsx b/client/electron/src/components/AccountPage.tsx index 9fb47ab..c5bdb2d 100644 --- a/client/electron/src/components/AccountPage.tsx +++ b/client/electron/src/components/AccountPage.tsx @@ -4,6 +4,7 @@ import * as api from '../api/client'; import { LockIcon, LogoutIcon } from './icons'; import KeyPanel from './KeyPanel'; import ThemePicker from './ThemePicker'; +import BackgroundPicker from './BackgroundPicker'; /** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */ export default function AccountPage() { @@ -214,8 +215,9 @@ export default function AccountPage() { {/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好, 与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */} -
+
+
{/* 退出登录。 diff --git a/client/electron/src/components/BackgroundPicker.tsx b/client/electron/src/components/BackgroundPicker.tsx new file mode 100644 index 0000000..c2fc449 --- /dev/null +++ b/client/electron/src/components/BackgroundPicker.tsx @@ -0,0 +1,241 @@ +import { useRef, useState } from 'react'; +import { + DEFAULT_BACKGROUND, + MAX_DATA_URL_BYTES, + PRESETS, + prepareImage, + useBackgroundStore, + type BackgroundKind +} from '../stores/backgroundStore'; +import { UploadIcon, TrashIcon } from './icons'; + +/** + * 自定义背景设置。 + * + * 三条设计约束: + * + * 1. **预览就是真实效果**。预设缩略图直接复用 `.bg-preset-*` 类(与全屏背景 + * 同一份 CSS),不是另画一张示意图 —— 否则预览与结果必然会漂移。 + * 2. **不让用户交出控制权**。背景会直接影响正文对比度,所以「压暗」与「模糊」 + * 都给显式滑杆,而不是自动选一个值。默认值取偏保守的一侧。 + * 3. **失败要出声**。图片过大/解码失败时给出具体原因;静默失败会让人以为 + * 按钮坏了(本仓库在权限按钮上踩过同类问题)。 + */ +export default function BackgroundPicker() { + const kind = useBackgroundStore(s => s.kind); + const presetId = useBackgroundStore(s => s.presetId); + const imageDataUrl = useBackgroundStore(s => s.imageDataUrl); + const dim = useBackgroundStore(s => s.dim); + const blur = useBackgroundStore(s => s.blur); + const setKind = useBackgroundStore(s => s.setKind); + const setPreset = useBackgroundStore(s => s.setPreset); + const setImage = useBackgroundStore(s => s.setImage); + const setDim = useBackgroundStore(s => s.setDim); + const setBlur = useBackgroundStore(s => s.setBlur); + const reset = useBackgroundStore(s => s.reset); + + const fileRef = useRef(null); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const active = kind !== 'none'; + + const pick = async (file: File | undefined) => { + if (!file) return; + setBusy(true); + setError(''); + const result = await prepareImage(file); + setBusy(false); + if (result.ok) { + setImage(result.dataUrl); + } else { + setError(result.reason); + } + }; + + const optionClass = (on: boolean) => + `px-3 py-2.5 rounded-control border text-xs flex flex-col items-center gap-1.5 transition-colors ${ + on + ? 'border-blue-500 bg-blue-50 text-blue-700' + : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50' + }`; + + return ( +
+
+

背景

+ {active && ( + + )} +
+

+ 背景会显示在卡片后面,两种主题各有对应色调。 +

+ + {/* 三选一:不设 / 预设 / 自定义图片。与主题选择器同一套控件形态, + 让「外观」这一组看起来是一件事。 */} +
+ {( + [ + { value: 'none', label: '无' }, + { value: 'preset', label: '预设' }, + { value: 'image', label: '图片' } + ] as { value: BackgroundKind; label: string }[] + ).map(o => ( + + ))} +
+ + {kind === 'preset' && ( +
+ {PRESETS.map(p => ( + + ))} +
+ )} + + {kind === 'image' && ( +
+ { + void pick(e.target.files?.[0]); + // 清空 value:否则连续选同一张图不会再触发 change + e.target.value = ''; + }} + /> + + {imageDataUrl && ( +
+ 背景预览 +
+ )} +

+ 图片会等比缩放到最长边 2560px 后保存在本机(上限约 2.4MB)。 +

+ {error && ( +

+ {error} +

+ )} +
+ )} + + {/* 只有真的启用了背景才显示这两条 —— 背景为「无」时它们没有任何作用, + 摆在界面上只会让人疑惑「调了为什么没变化」。 */} + {active && ( +
+ + + {/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */} +

+ 当前背景数据约 {Math.round((imageDataUrl.length || 1) / 1024)}KB,上限{' '} + {Math.round(MAX_DATA_URL_BYTES / 1024)}KB。 +

+
+ )} +
+ ); +} + +/** 带数值显示的滑杆。input[type=range] 的原生外观各平台差异很大,这里统一掉。 */ +function Slider({ + label, + hint, + value, + min, + max, + suffix, + onChange +}: { + label: string; + hint: string; + value: number; + min: number; + max: number; + suffix: string; + onChange: (v: number) => void; +}) { + return ( + + ); +} + +/** 「恢复默认」用到,导出以便测试断言默认值形状。 */ +export { DEFAULT_BACKGROUND }; diff --git a/client/electron/src/components/ThemePicker.tsx b/client/electron/src/components/ThemePicker.tsx index 2e63e96..e077a8b 100644 --- a/client/electron/src/components/ThemePicker.tsx +++ b/client/electron/src/components/ThemePicker.tsx @@ -50,29 +50,45 @@ export default function ThemePicker() { return (
-
-

外观

+
+

主题

{pref === 'system' && ( 当前跟随系统:{resolved === 'dark' ? '深色' : '浅色'} )}
-
+

+ 「跟随系统」会随系统的深色开关自动切换,不选它就固定在一种外观。 +

+ + {/* + 分段控件而不是三个独立卡片。 + + 三选一的语义是「三种互斥的偏好」,分段控件正好表达这个;独立卡片看起来 + 像可多选。选中态用整块底色(而非仅描边),在深色下也比描边更容易辨认。 + */} +
{OPTIONS.map(o => { const active = pref === o.value; return ( ); diff --git a/client/electron/src/index.css b/client/electron/src/index.css index 945e0ef..82b1a6c 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -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 写在 上)。 */ + 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; + } +} diff --git a/client/electron/src/main.tsx b/client/electron/src/main.tsx index 4a4d7f1..513f107 100644 --- a/client/electron/src/main.tsx +++ b/client/electron/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { initTheme } from './stores/themeStore'; +import { initBackground } from './stores/backgroundStore'; import './index.css'; // 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。 @@ -9,6 +10,11 @@ import './index.css'; // 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。 initTheme(); +// 自定义背景同样要在 render 之前套用(读 localStorage + 写 CSS 变量)。 +// 它只是装饰层,晚一帧不会像主题那样闪出刺眼白底,但提前套用能避免 +// 「先看到纯色底、再变成背景」的抽动。 +initBackground(); + /** * 用 Visual Viewport 驱动应用高度。 * @@ -39,6 +45,15 @@ document.addEventListener('focusout', () => requestAnimationFrame(syncEditingSta ReactDOM.createRoot(document.getElementById('root')!).render( + {/* + 背景层挂在 App **之外**。 + + 挂在 App 里面的话,它只会存在于主界面的那几个分支上 —— 登录页、加载页、 + 初始化向导都各自 return 自己的外层 div。背景是全局装饰,用户不该 + 「一进登录页背景就没了」。它 position:fixed + z-index:-1,与 App 的 + 布局无关,放这里最稳。 + */} +