setRecentsOpen(false)}>
{vault.recents.map(r => (
{ setRecentsOpen(false); await useStore.getState().saveNow(); await invoke(IPC.vaultSwitch, { root: r }); useTabs.getState().closeAll(); useStore.getState().resetEditor(); await loadVault(); await loadTree(); }}
style={{ padding: '6px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 12, color: r === vault.currentRoot ? 'var(--accent)' : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
diff --git a/apps/desktop/src/renderer/store.ts b/apps/desktop/src/renderer/store.ts
index cae3cda..7ca6a09 100644
--- a/apps/desktop/src/renderer/store.ts
+++ b/apps/desktop/src/renderer/store.ts
@@ -3,6 +3,7 @@ import { IPC } from '@hunter/shared';
import type { TreeNode, VaultMeta, DocMeta, ReadDocResult } from '@hunter/shared';
import { invoke } from './ipc-client';
import { useTabs } from './features/tabs/tab-store';
+import { useSettings } from './features/settings/settings-store';
export type SaveState = 'saved' | 'saving' | 'error';
@@ -108,9 +109,9 @@ export const useStore = create((set, get) => ({
},
setBody: (body: string) => {
set({ body, saveState: 'saving' });
- // FR-S1:防抖自动保存——间隔读 settings.autoSaveInterval(秒→毫秒)
+ // FR-S1:防抖自动保存——间隔读 settings.autoSaveInterval(秒→毫秒);直接取已解析的设置,避免每次按键重复解析 localStorage
if (saveTimer) clearTimeout(saveTimer);
- const interval = (() => { try { const s = JSON.parse(localStorage.getItem('dsh-settings') ?? '{}'); return (s.autoSaveInterval ?? 3) * 1000; } catch { return 3000; } })();
+ const interval = (useSettings.getState().autoSaveInterval ?? 1) * 1000;
saveTimer = setTimeout(() => { get().saveNow(); }, interval);
},
diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css
index 893aed9..dcb6987 100644
--- a/apps/desktop/src/renderer/styles.css
+++ b/apps/desktop/src/renderer/styles.css
@@ -1,21 +1,111 @@
* { box-sizing: border-box; }
-html, body, #root { margin: 0; height: 100%; }
+html, body, #root { margin: 0; height: 100%; overflow: hidden; }
+/* =========================================================
+ UI 重新设计 · 液态玻璃(分层材质)设计系统
+ L0 space / L1 glass / L2 paper / L3 float
+ 黑白灰墨色阶 · 克制动效 · 统一线条图标
+ ========================================================= */
/* 全局统一字体栈——消灭按钮/输入框回退到 Arial 的观感不一致(A档-1) */
body {
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
+ background: var(--space, var(--bg-primary));
}
button, input, select, textarea { font-family: inherit; }
svg { display: block; }
-/* 自定义滚动条——细+半透明+圆角 */
-::-webkit-scrollbar { width: 6px; height: 6px; }
+/* 统一线条图标——细描边,形成一致的线性语言(stroke-width 1.6) */
+svg.lucide, .lucide { stroke-width: 1.6; }
+
+/* 自定义滚动条——细+圆角,墨色阶常显(不再悬停才出现,避免“看不见滚动条”),hover 加深 */
+::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: var(--control-border); border-radius: 3px; }
-::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
-* { scrollbar-width: thin; scrollbar-color: var(--control-border) transparent; }
+::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--line-strong); }
+::-webkit-scrollbar-corner { background: transparent; }
+* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
+*:hover { scrollbar-color: var(--line-strong) transparent; }
+
+/* ---- 高级滑动条(range):细轨道 + 白玻璃圆滑块,墨色系 ---- */
+input[type="range"] {
+ -webkit-appearance: none; appearance: none;
+ width: 120px; height: 18px; background: transparent; cursor: pointer;
+}
+input[type="range"]::-webkit-slider-runnable-track {
+ height: 4px; border-radius: 999px;
+ background: color-mix(in srgb, var(--ink) 12%, transparent);
+}
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: var(--paper); border: 1px solid var(--line-strong);
+ margin-top: -5px;
+ box-shadow: 0 1px 3px rgba(20,22,28,0.16), 0 1px 1px rgba(20,22,28,0.08);
+ transition: transform .16s var(--ease), border-color .16s var(--ease), background .16s var(--ease);
+}
+input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.15); border-color: var(--ink-3); }
+input[type="range"]:active::-webkit-slider-thumb { background: var(--ink); border-color: var(--ink); }
+
+/* ---- 分层材质基类 ---- */
+/* L1 玻璃:半透明 + 模糊 + 顶部高光线 */
+.glass {
+ background: var(--glass-bar);
+ -webkit-backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
+ backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
+ position: relative;
+}
+.glass-side {
+ background: var(--glass-side);
+ -webkit-backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
+ backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
+ position: relative;
+}
+/* 顶部高光线(玻璃上缘一道亮边) */
+.glass::before, .glass-side::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, #fff 70%, transparent), transparent);
+ pointer-events: none;
+}
+/* L2 纸:编辑正文,最清晰 */
+.paper {
+ background: var(--paper);
+}
+/* L3 悬浮:弹窗/节点卡片,近实白 + 强投影 */
+.float {
+ background: var(--float);
+ box-shadow: var(--shadow-lift);
+}
+
+/* L0 空间层:环境氛围——一束顶光 + 极淡噪点纹理(Apple 原生纵深感) */
+.ambient-bg {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background-image: var(--ambient, linear-gradient(168deg, #f2f4f7 0%, #e9ecf1 100%));
+}
+.ambient-bg::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ opacity: .5;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
+}
+
+/* 克制动效:面板进入 fade+上移(380ms ease) */
+@keyframes panel-enter {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.panel-enter { animation: panel-enter 380ms var(--ease) both; }
+
+/* 柔和 hover 过渡 */
+.hover-soft { transition: background 160ms var(--ease), color 160ms var(--ease), opacity 160ms var(--ease), box-shadow 160ms var(--ease), transform 160ms var(--ease); }
/* Tiptap 编辑器正文(B档-7 排版打磨:CJK 阅读优化、标题层级、选区、引用/链接/表格) */
.tiptap-wrap .ProseMirror {
font-size: var(--editor-font-size, 14px) !important;
@@ -44,8 +134,9 @@ svg { display: block; }
.tiptap-wrap .ProseMirror h3 { font-size: 1.25em; margin: 0.8em 0 0.4em; font-weight: 600; color: var(--text-primary); }
.tiptap-wrap .ProseMirror h4, .tiptap-wrap .ProseMirror h5, .tiptap-wrap .ProseMirror h6 { margin: 0.7em 0 0.35em; font-weight: 600; }
.tiptap-wrap .ProseMirror code {
- background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent-text);
- padding: 1px 5px; border-radius: 4px; font-family: 'Consolas', 'Source Code Pro', monospace;
+ background: color-mix(in srgb, var(--ink) 7%, transparent); color: var(--ink);
+ padding: 2px 6px; border-radius: 5px; border: 1px solid var(--line);
+ font-family: 'Consolas', 'Source Code Pro', monospace; font-weight: 500;
}
.tiptap-wrap .ProseMirror pre {
background: var(--bg-secondary); padding: 12px 16px; border-radius: var(--radius-md); overflow: auto;
@@ -144,4 +235,12 @@ button:hover { opacity: 0.85; }
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
-.panel-slide-in { animation: panel-slide-in 0.2s ease-out; }
\ No newline at end of file
+.panel-slide-in { animation: panel-slide-in 0.2s ease-out; }
+
+/* ---- 编辑区:L2 纸面,最清晰 ---- */
+.tiptap-wrap { background: var(--paper); }
+.tiptap-wrap .ProseMirror pre {
+ background: color-mix(in srgb, var(--ink) 5%, var(--paper));
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow-soft);
+}
diff --git a/apps/desktop/tests/e2e/button-all.spec.ts b/apps/desktop/tests/e2e/button-all.spec.ts
index ea1fb45..fa7be48 100644
--- a/apps/desktop/tests/e2e/button-all.spec.ts
+++ b/apps/desktop/tests/e2e/button-all.spec.ts
@@ -299,22 +299,15 @@ test('Graph: 控件+模式切换可用', async () => {
await a.app.close();
});
-// === TabBar pin/close ===
-test('TabBar: 固定+关闭可用', async () => {
+// === TabBar close ===
+test('TabBar: 关闭标签可用(pin 点已移除)', async () => {
const a = await launch(nodePath.join(os.tmpdir(), 'hn-tab-' + Date.now()));
const { v } = await setupVault(a, [{ name: 'Tab1', body: 't1' }, { name: 'Tab2', body: 't2' }]);
await a.page.getByTestId('filetree').getByText('Tab1').click();
await a.page.waitForTimeout(300);
await a.page.getByTestId('filetree').getByText('Tab2').click();
await a.page.waitForTimeout(300);
- // 固定 Tab1(pin 按钮是 span[title="固定"]●)
- const tab1 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab1' }).first();
- await tab1.locator('span[title="固定"]').click();
- await a.page.waitForTimeout(300);
- // 取消固定
- await tab1.locator('span[title="取消固定"]').click();
- await a.page.waitForTimeout(300);
- // 关闭 Tab2(close 按钮是 span 文本 x)
+ // 关闭 Tab2(close 按钮是 span 文本 x;pin 点按钮已按 UI 需求移除)
const tab2 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab2' }).first();
await tab2.locator('span').filter({ hasText: 'x' }).click();
await a.page.waitForTimeout(300);
@@ -354,4 +347,4 @@ test('OutlinePanel: 切换文档后大纲自动更新', async () => {
await expect(a.page.locator('.outline-item', { hasText: 'Title B' })).toBeVisible({ timeout: 5000 });
await expect(a.page.locator('.outline-item', { hasText: 'Deep B' })).toBeVisible({ timeout: 5000 });
await a.app.close();
-});
+});
\ No newline at end of file
diff --git a/apps/desktop/tests/e2e/graph-render.spec.ts b/apps/desktop/tests/e2e/graph-render.spec.ts
index 790a71f..76aac6c 100644
--- a/apps/desktop/tests/e2e/graph-render.spec.ts
+++ b/apps/desktop/tests/e2e/graph-render.spec.ts
@@ -60,18 +60,19 @@ test('思维导图+知识图谱渲染验证(含可见性)', async () => {
const rects = svg.querySelectorAll('rect').length;
const texts = svg.querySelectorAll('text').length;
const lines = svg.querySelectorAll('line').length;
+ const linkPaths = svg.querySelectorAll('path').length;
// Check a node card is visible within the svg
const visibleCard = Array.from(svg.querySelectorAll('rect')).some((r) => {
const b = r.getBoundingClientRect();
return b.width > 0 && b.height > 0;
});
- return { found: true, w: rect.width, h: rect.height, rects, texts, lines, visibleCard };
+ return { found: true, w: rect.width, h: rect.height, rects, texts, lines, linkPaths, visibleCard };
});
console.log('Knowledge graph:', JSON.stringify(graph));
expect(graph.found).toBe(true);
expect(graph.h).toBeGreaterThan(100);
- expect(graph.rects).toBeGreaterThanOrEqual(5);
- expect(graph.lines).toBeGreaterThanOrEqual(1);
+ expect(graph.rects).toBeGreaterThanOrEqual(3); // 2 卡 rect + 1 背景 rect(demo 卡片无左侧色条)
+ expect(graph.linkPaths).toBeGreaterThanOrEqual(1); // 曲线连线为 path
expect(graph.visibleCard).toBe(true);
await app.close();
diff --git a/apps/desktop/tests/e2e/graph.spec.ts b/apps/desktop/tests/e2e/graph.spec.ts
index 9a3a00b..a55b82c 100644
--- a/apps/desktop/tests/e2e/graph.spec.ts
+++ b/apps/desktop/tests/e2e/graph.spec.ts
@@ -38,7 +38,8 @@ test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
// 限定在知识图谱画布内统计卡片 rect(标题栏等 UI 图标也是 svg,不能全页统计)
- await expect(a.page.locator('[data-testid="graph-canvas"] rect')).toHaveCount(5, { timeout: 10000 });
+ // 2 张卡片(2 卡 rect)+ 1 背景 rect = 3(demo 卡片无左侧色条)
+ await expect(a.page.locator('[data-testid="graph-canvas"] rect')).toHaveCount(3, { timeout: 10000 });
await a.app.close();
});
diff --git a/apps/desktop/tests/e2e/transparency-check.spec.ts b/apps/desktop/tests/e2e/transparency-check.spec.ts
index 4d4b234..e8da64a 100644
--- a/apps/desktop/tests/e2e/transparency-check.spec.ts
+++ b/apps/desktop/tests/e2e/transparency-check.spec.ts
@@ -6,8 +6,8 @@ import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
-// 透明主题真透明:bgPrimary=transparent 时根背景必须是 alpha=0(配合主进程 transparent:true + backgroundColor:#00000000 + backgroundMaterial:'none' 即可透出下层软件)
-test('透明主题:根背景 alpha=0(真透明,可见下层软件)', async () => {
+// UI 重新设计:窗口不透明(transparent:false + backgroundColor 空间底色),根背景应为不透明的 L0 空间色
+test('窗口不透明:根背景为 L0 空间底色(alpha=1,非透明)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-tr-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
@@ -15,15 +15,7 @@ test('透明主题:根背景 alpha=0(真透明,可见下层软件)', asy
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
await page.evaluate(({ root }) => (globalThis as any).dsh.invoke('vault:switch', { root }), { root: v });
- // 应用透明主题的 CSS 变量(bgPrimary='transparent'),并移除背景图遮罩
- await page.evaluate(() => {
- const el = document.documentElement;
- el.style.setProperty('--bg-primary', 'transparent');
- el.style.setProperty('--bg-secondary', 'rgba(20,20,28,0.5)');
- el.style.setProperty('--text-primary', '#ddd');
- document.querySelectorAll('div[style*="background-image"]').forEach((n: any) => n.remove());
- });
- await page.waitForTimeout(400);
+ await page.waitForTimeout(500);
const rootBg = await page.evaluate(() => {
const root = document.querySelector('#root > div') as HTMLElement;
if (!root) return null;
@@ -31,7 +23,15 @@ test('透明主题:根背景 alpha=0(真透明,可见下层软件)', asy
});
console.log('ROOT_BG', rootBg);
expect(rootBg).not.toBeNull();
- // 透明背景应为 alpha=0(rgba(0,0,0,0) 或 transparent)
- expect(rootBg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/i);
+ // 窗口不透明:根背景为不透明空间底色(alpha=1,非 rgba(...,0) / transparent)
+ const m = rootBg!.match(/rgba\(([\d.]+),\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)\)|^rgb\(([\d.]+),\s*([\d.]+),\s*([\d.]+)\)$/);
+ expect(m).not.toBeNull();
+ // 不透明:要么是 rgb(...)(隐式 alpha=1),要么 rgba alpha=1
+ if (rootBg!.startsWith('rgba')) {
+ const alpha = parseFloat(m![4]);
+ expect(alpha).toBe(1);
+ } else {
+ expect(rootBg!.startsWith('rgb(')).toBe(true);
+ }
await app.close();
});
\ No newline at end of file