diff --git a/renderer/settings.js b/renderer/settings.js
index 2f1880a..2aec445 100644
--- a/renderer/settings.js
+++ b/renderer/settings.js
@@ -40,7 +40,10 @@
const { get, set, clearHistory, openDir, chooseDir, openExternal } = window.settingsApi;
const $auto=document.getElementById('opt-autolaunch'),$hotkey=document.getElementById('opt-hotkey'),$max=document.getElementById('opt-maxitems'),$display=document.getElementById('opt-displaylimit'),$poll=document.getElementById('opt-pollms'),$dir=document.getElementById('opt-datadir'),$change=document.getElementById('btn-change-dir'),$open=document.getElementById('btn-open-dir'),$clear=document.getElementById('btn-clear'),$close=document.getElementById('close-btn'),$status=document.getElementById('status-left');
-function flash(msg){$status.textContent=msg;setTimeout(()=>$status.textContent='就绪',2000)}
+// 连发两条提示时,第一条的 2 秒定时器会把第二条提前刷回「就绪」——
+// 每次 flash 先清掉上一个定时器,重置时间以最后一条消息为准。
+let flashTimer=null;
+function flash(msg){$status.textContent=msg;clearTimeout(flashTimer);flashTimer=setTimeout(()=>$status.textContent='就绪',2000)}
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
// 不能让用户以为设置已经保存。
@@ -48,7 +51,19 @@ function flashIfNotSaved(res){
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
return false
}
-async function refresh(){const c=await get();$auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);$dir.textContent=c.dataDir}
+async function refresh(){
+ const c=await get();
+ $auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);
+ // 显示实际在用的目录(dataDirEffective,含 session 兜底);与配置目录不
+ // 一致时(配置位置暂不可用)两个都给出来,并解释当前数据在哪。
+ if(c.dataDirEffective&&c.dataDirEffective!==c.dataDir){
+ $dir.textContent=c.dataDirEffective;
+ $dir.title='配置位置 '+c.dataDir+' 当前不可用,本会话暂用此目录(重启后若恢复会自动切回)';
+ }else{
+ $dir.textContent=c.dataDir;
+ $dir.title='';
+ }
+}
$auto.addEventListener('change',async()=>{const res=await set('autoLaunch',$auto.checked);if(!flashIfNotSaved(res))flash('已'+($auto.checked?'开启':'关闭')+'开机自启动')});
$max.addEventListener('change',async()=>{const res=await set('maxItems',parseInt($max.value,10));if(!flashIfNotSaved(res))flash('最大条数已设为 '+$max.value)});
$display.addEventListener('change',async()=>{const res=await set('displayLimit',parseInt($display.value,10));if(!flashIfNotSaved(res))flash('界面显示条数已设为 '+$display.value)});
@@ -117,6 +132,10 @@ function renderThemes(currentTheme) {
const card = document.createElement('div');
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
card.dataset.theme = t.id;
+ // div + click 对键盘不可达:补 button 语义。Enter/Space 触发与鼠标一致的 click。
+ card.tabIndex = 0;
+ card.setAttribute('role', 'button');
+ card.setAttribute('aria-pressed', String(t.id === currentTheme));
card.innerHTML = `
Aa
@@ -127,7 +146,14 @@ function renderThemes(currentTheme) {
${t.desc}
`;
+ card.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
+ });
card.addEventListener('click', async () => {
+ // renderThemes 会销毁全部旧卡片:焦点卡没了焦点就掉到 body,键盘用户
+ // 迷失位置。重建后把焦点还到同名新卡(Chromium 会沿用 focus-visible 状态,
+ // 鼠标点击不会因此多出焦点框)。
+ const hadFocus = document.activeElement === card;
const res = await themeSet(t.id);
if (res) {
const eff = res.effectiveTheme || res.theme;
@@ -145,6 +171,10 @@ function renderThemes(currentTheme) {
} catch (_) {}
document.getElementById('opt-follow-system').checked = false;
renderThemes(eff);
+ if (hadFocus) {
+ const next = grid.querySelector(`[data-theme="${t.id}"]`);
+ if (next) next.focus();
+ }
flashIfNotSaved(res);
}
});
diff --git a/renderer/styles.css b/renderer/styles.css
index 9a13f62..4184ebe 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -117,14 +117,14 @@ html, body {
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
#pin-btn[aria-pressed="true"] {
- color: var(--accent);
+ color: var(--accent-fg);
background: var(--accent-soft);
}
#pin-btn[aria-pressed="true"]:hover {
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
background: var(--accent-hover);
- color: var(--accent);
+ color: var(--accent-fg);
}
#refresh-btn.spinning svg {
@@ -191,8 +191,8 @@ html, body {
}
#tabs .tab.active {
- color: var(--accent);
- border-bottom-color: var(--accent);
+ color: var(--accent-fg);
+ border-bottom-color: var(--accent-fg);
}
.tab-count {
@@ -213,7 +213,7 @@ html, body {
#tabs .tab.active .tab-count {
background: var(--accent-soft);
- color: var(--accent);
+ color: var(--accent-fg);
}
/* ---------- 搜索 ---------- */
@@ -258,9 +258,11 @@ html, body {
box-shadow: 0 0 0 3px var(--accent-soft);
}
-#search:focus + #search-clear,
+/* 只有搜索图标随聚焦高亮。之前还有一条 #search:focus + #search-clear,
+ * 会让清空按钮在输入全程保持 accent 色 —— 一个次要的 × 按钮不该比输入框
+ * 本身更抢眼,且与 #search-clear 自身的 muted 设计矛盾,已移除。 */
#search-wrap:focus-within #search-icon {
- color: var(--accent);
+ color: var(--accent-fg);
}
#search::placeholder {
@@ -360,6 +362,12 @@ html, body {
border-color: var(--accent);
}
+/* .card 设过 outline:none,必须用等效焦点指示补回(卡片本身 tabindex=0) */
+.card:focus-visible {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
.card-head {
display: flex;
align-items: center;
@@ -378,7 +386,9 @@ html, body {
display: inline-flex;
align-items: center;
gap: 3px;
- font-size: 10.5px;
+ /* 徽标含中文(文本/图片):tokens 自定标准 --fs-xs=11px 是 CJK 清晰下限,
+ * 之前 10.5px 与自己的标准矛盾,小字中文发糊。 */
+ font-size: var(--fs-xs);
color: var(--text-soft);
background: var(--bg-hover);
border-radius: 4px;
@@ -395,11 +405,11 @@ html, body {
}
.badge-image {
- color: var(--accent);
+ color: var(--accent-fg);
}
.badge-image svg {
- color: var(--accent);
+ color: var(--accent-fg);
opacity: 0.85;
}
@@ -433,8 +443,12 @@ html, body {
border-radius: 6px;
cursor: pointer;
opacity: 0;
+ /* 只设 opacity:0 的按钮「看不见但点得着」:点卡片头部想选中时会误触
+ * 透明的 ★/删除;键盘 Tab 也会聚焦到隐形按钮。visibility 进 transition
+ * 是离散动画:出现时立即可见 + opacity 淡入,消失时等淡出结束才隐藏。 */
+ visibility: hidden;
line-height: 1;
- transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
+ transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast), visibility var(--transition-fast);
}
.action svg {
@@ -443,8 +457,15 @@ html, body {
}
.card:hover .action,
-.card.selected .action {
+.card.selected .action,
+.action:focus-visible {
opacity: 1;
+ visibility: visible;
+}
+
+.action:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
}
.action:hover {
@@ -453,12 +474,12 @@ html, body {
}
.action.copy {
- color: var(--accent);
+ color: var(--accent-fg);
padding-right: 10px;
}
.action.copy:hover {
- color: var(--accent);
+ color: var(--accent-fg);
background: var(--accent-hover);
}
@@ -507,7 +528,9 @@ html, body {
color: var(--text);
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
line-height: 1.6;
- word-break: break-all;
+ /* 超长无空格串(URL/路径)能断行,但不像 word-break:break-all 那样把
+ * 普通英文单词从中间生硬截断;中文本身任意位置可断,不受影响。 */
+ overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -529,6 +552,53 @@ html, body {
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
+ cursor: zoom-in;
+}
+
+/* 缩略图可聚焦(buildCard 里设了 role=button + tabindex),补键盘焦点指示 */
+.image-preview:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* ---------- 图片大图预览 ---------- */
+
+#lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.72);
+ cursor: zoom-out;
+}
+
+/* 焦点放在遮罩上只为接 Esc,不需要焦点描边 */
+#lightbox:focus { outline: none; }
+
+#lightbox:not(.hidden) {
+ animation: lightbox-in var(--transition-fast);
+}
+
+@keyframes lightbox-in {
+ from { opacity: 0; }
+}
+
+#lightbox-img {
+ max-width: 92vw;
+ max-height: 88vh;
+ object-fit: contain;
+ border-radius: 8px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
+ /* 与 .image-preview 同款棋盘格底;深色遮罩上换成浅色格子,透明 PNG 才看得清 */
+ background:
+ linear-gradient(45deg, rgba(255, 255, 255, 0.14) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(255, 255, 255, 0.14) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.14) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.14) 75%);
+ background-size: 16px 16px;
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
/* ---------- 空状态 ---------- */
@@ -652,14 +722,17 @@ kbd {
cursor: pointer;
line-height: 1;
margin-bottom: -1px;
- transition: color 0.12s ease, border-color 0.12s ease;
+ /* 与全局动效 token 统一(--transition-fast = 100ms ease-out),不再散写 0.12s ease */
+ transition: color var(--transition-fast), border-color var(--transition-fast);
}
#tab-bar .tab:hover { color: var(--text); }
#tab-bar .tab[aria-selected="true"] {
color: var(--text);
- border-bottom-color: var(--accent);
+ /* 与 #tabs .tab.active 一致:文字/下划线指示都走 --accent-fg,
+ * light 主题亮色系 accent 下划线才够清晰。 */
+ border-bottom-color: var(--accent-fg);
font-weight: var(--fw-semibold);
}
@@ -759,9 +832,11 @@ kbd {
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { background: var(--bg-soft); }
.btn-danger { color: var(--danger); border-color: var(--danger-bg); background: var(--danger-bg); }
-.btn-danger:hover { background: var(--danger); color: var(--accent-text); border-color: var(--danger); }
+/* hover 是「红底白字」:不能用 --accent-text(它随 accent 色系自适应,
+ * green/orange accent 下是深色字,红底深字对比度崩塌),这里恒为白。 */
+.btn-danger:hover { background: var(--danger); color: #FFFFFF; border-color: var(--danger); }
.danger-zone { border-color: var(--danger-bg); }
-.danger-zone:hover { border-color: rgba(239, 68, 68, 0.4); }
+.danger-zone:hover { border-color: var(--danger-border); }
/* 开关 */
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
@@ -907,7 +982,11 @@ kbd {
width: 44px;
height: 44px;
border-radius: 10px;
- background: linear-gradient(135deg, var(--accent), var(--accent-hover));
+ /* 立体渐变 = 实色 accent 上叠一层 白→黑 overlay(设计系统标准做法)。
+ * 之前渐变末端用 --accent-hover,那是个 20% 透明度的 hover 底色 token,
+ * 图标右下角几乎全透明、透出卡片底色,像褪了一块色。 */
+ background: var(--accent);
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.14)), var(--accent);
display: flex;
align-items: center;
justify-content: center;
@@ -943,10 +1022,28 @@ kbd {
.about-dev { font-size: var(--fs-base); color: var(--text); }
.ext-link {
font-size: var(--fs-sm);
- color: var(--accent);
+ color: var(--accent-fg);
text-decoration: none;
word-break: break-all;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.ext-link:hover { text-decoration: underline; opacity: 0.85; }
+
+/* ---------- 键盘焦点可见性(:focus-visible) ----------
+ * 全局面控件统一的键盘焦点指示。:focus-visible 只在键盘导航时触发,
+ * 鼠标点击不出现描边,不影响现有观感。
+ * (.card / .action / #tab-bar .tab / .switch / select 已在各自区块定义。) */
+.icon-btn:focus-visible,
+#search-clear:focus-visible,
+.btn:focus-visible,
+#tabs .tab:focus-visible,
+.theme-card:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* 圆形色板用双层 ring(与 .active 选中态同构,区别在 accent 色) */
+.accent-swatch:focus-visible {
+ box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--accent);
+}
diff --git a/renderer/themes/accents.css b/renderer/themes/accents.css
index c07d1f4..9db2f61 100644
--- a/renderer/themes/accents.css
+++ b/renderer/themes/accents.css
@@ -21,16 +21,30 @@
--accent: #10B981;
--accent-soft: rgba(16, 185, 129, 0.12);
--accent-hover: rgba(16, 185, 129, 0.20);
+ /* 亮绿底 + 白字只有 2.5:1(WCAG 需 4.5),slot 序号等 accent 底色文字改深色 → 5.8:1 */
+ --accent-text: #1F2937;
}
:root[data-accent="orange"] {
--accent: #F59E0B;
--accent-soft: rgba(245, 158, 11, 0.12);
--accent-hover: rgba(245, 158, 11, 0.20);
+ /* 同 green:亮橙底 + 白字 2.2:1 → 深色 6.8:1 */
+ --accent-text: #1F2937;
}
:root[data-accent="red"] {
--accent: #EF4444;
--accent-soft: rgba(239, 68, 68, 0.12);
--accent-hover: rgba(239, 68, 68, 0.20);
-}
\ No newline at end of file
+}
+
+/* Light 主题下 accent 直接作文字色对比度不足(实测 orange 2.06 / green 2.43 /
+ * blue 3.52,WCAG 正文需 ≥4.5)—— 文字场景(--accent-fg)改用同色系深色变体,
+ * 全部 ≥4.6:1;色块填充 / 边框仍用 --accent 本身,品牌色观感不变。
+ * 深色主题各 accent 文字对比度 ≥3.2 且多为图标/强调短文本,维持原色。 */
+:root[data-theme="light"][data-accent="purple"] { --accent-fg: #4F46E5; }
+:root[data-theme="light"][data-accent="blue"] { --accent-fg: #2563EB; }
+:root[data-theme="light"][data-accent="green"] { --accent-fg: #047857; }
+:root[data-theme="light"][data-accent="orange"] { --accent-fg: #B45309; }
+:root[data-theme="light"][data-accent="red"] { --accent-fg: #DC2626; }
diff --git a/renderer/themes/fonts.css b/renderer/themes/fonts.css
index ff65d10..0f50445 100644
--- a/renderer/themes/fonts.css
+++ b/renderer/themes/fonts.css
@@ -19,8 +19,8 @@
}
/* 字体变量赋值
- * 中文优先:西文(Inter)排在中文兜底之后,App 里大部分文本是中文,
- * 让 YaHei/PingFang 先匹配笔画渲染,CJK 之外再走 Inter 拿更好的西文观感。 */
+ * 西文优先:Inter 排最前拿更好的西文/数字观感;Inter 不含 CJK 字形,
+ * 中文会自动落到后面的 YaHei/PingFang 兜底,两段各取所长。 */
:root {
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
diff --git a/renderer/themes/tokens.css b/renderer/themes/tokens.css
index 7ef4800..089f715 100644
--- a/renderer/themes/tokens.css
+++ b/renderer/themes/tokens.css
@@ -24,7 +24,13 @@
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-hover: rgba(94, 106, 210, 0.20);
+ /* accent 底色上的文字色:亮色系 accent(green/orange)白字对比度仅 ~2:1,
+ * accents.css 对这两个色系覆盖成深色文字。 */
--accent-text: #FFFFFF;
+ /* accent 作「文字色」用时的取值(Tab 激活、复制按钮、外链等)。
+ * 默认 = accent 本身;light 主题下亮色系 accent 对比度不足,
+ * accents.css 按 [data-theme="light"][data-accent="..."] 覆盖成深色变体。 */
+ --accent-fg: var(--accent);
/* 状态色 */
--success: #10B981;
@@ -32,6 +38,7 @@
--warn-bg: rgba(245, 158, 11, 0.12);
--danger: #EF4444;
--danger-bg: rgba(239, 68, 68, 0.12);
+ --danger-border: rgba(239, 68, 68, 0.4);
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
diff --git a/test/clip-list-prepend.test.js b/test/clip-list-prepend.test.js
index 132e8a7..ccb4d49 100644
--- a/test/clip-list-prepend.test.js
+++ b/test/clip-list-prepend.test.js
@@ -80,6 +80,18 @@ check('prependClip 熄灭空状态(#empty)', () => {
);
});
+check('prependClip 重排 slot 徽标数字(后移的卡序号 +1)', () => {
+ // 新卡插到最顶后,原第 1-9 位的卡整体后移:只隐藏第 10 位会留下两个「1」,
+ // 数字键 1-9 按实际下标复制,显示序号与按键错位。曾被"优化"成只动边界卡,
+ // 这里守住:必须把后移卡的 textContent 改写成 i+1。
+ const prepend = rendererSrc.match(/function\s+prependClip\s*\([\s\S]*?\n\}/);
+ assert.ok(prepend, '找不到 prependClip()');
+ assert.ok(
+ /slotEl\.textContent\s*=\s*String\(\s*i\s*\+\s*1\s*\)/.test(prepend[0]),
+ 'prependClip 没有改写后移卡片的 slot 数字 —— 序号会与实际下标错位'
+ );
+});
+
check('buildCard 的点击选中按运行时下标,不捕获构建时 i', () => {
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
assert.ok(
diff --git a/test/data-dir-fallback.test.js b/test/data-dir-fallback.test.js
index 08fa9eb..2abe1b1 100644
--- a/test/data-dir-fallback.test.js
+++ b/test/data-dir-fallback.test.js
@@ -136,6 +136,33 @@ check('defaultDataDir: D 盘可用时首选 D 盘(契约保留)', () => {
}
});
+// ---------- main.js 源码级契约:兜底状态下 openDb/openFavDb 不能 mkdir 坏目录 ----------
+// 启动时 dataDir 不可用会切到 sessionDataDir 兜底,但 openDb/openFavDb 如果
+// mkdir 的是裸 appConfig.dataDir(坏目录),mkdirSync 直接抛 ENOENT ——
+// 兜底逻辑被这一行废掉,照样弹「启动失败」。pin 住:两个函数都必须 mkdir
+// effectiveDataDir()(含 session 兜底)。
+{
+ const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+ const openDbFn = mainSrc.match(/function openDb\(\) \{[\s\S]*?\n\}/);
+ const openFavDbFn = mainSrc.match(/function openFavDb\(\) \{[\s\S]*?\n\}/);
+
+ check('openDb mkdir effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir', () => {
+ assert.ok(openDbFn, '找不到 openDb 函数');
+ assert.ok(
+ /mkdirSync\(\s*effectiveDataDir\(\s*\)/.test(openDbFn[0]),
+ 'openDb 仍在 mkdir appConfig.dataDir —— 配置目录不可用时兜底失效,启动照样失败'
+ );
+ });
+
+ check('openFavDb mkdir effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir', () => {
+ assert.ok(openFavDbFn, '找不到 openFavDb 函数');
+ assert.ok(
+ /mkdirSync\(\s*effectiveDataDir\(\s*\)/.test(openFavDbFn[0]),
+ 'openFavDb 仍在 mkdir appConfig.dataDir —— 配置目录不可用时兜底失效,启动照样失败'
+ );
+ });
+}
+
// 清理
fs.rmSync(tmpHome, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);
\ No newline at end of file
diff --git a/test/settings-render.test.js b/test/settings-render.test.js
index 5a9152c..ccdc612 100644
--- a/test/settings-render.test.js
+++ b/test/settings-render.test.js
@@ -35,6 +35,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
// invoke 会抛错,渲染层的 catch 打 console.error,被下面的"无脚本错误"
// 断言当成 SyntaxError 一类的致命问题误杀。
ipcMain.handle('theme:subscribe', () => true);
+// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
+// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
+ipcMain.handle('app:get-icon', () => '');
let failed = 0;
function check(name, fn) {
diff --git a/test/settings-tabs.test.js b/test/settings-tabs.test.js
index ea5b4eb..667ece7 100644
--- a/test/settings-tabs.test.js
+++ b/test/settings-tabs.test.js
@@ -25,6 +25,9 @@ ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console,
// 但留着噪音会掩盖真问题)。
ipcMain.handle('theme:subscribe', () => true);
+// settings.js 启动时还会拉标题栏图标(window.api.getAppIcon -> app:get-icon),
+// stub 缺这个 handler 同样打一条未捕获错误噪音;返回空串表示图标缺失(静默路径)。
+ipcMain.handle('app:get-icon', () => '');
let passed = 0;
async function check(name, fn) {