-
知笔知己
+
NotesAgent
本地优先的 AI 笔记软件
@@ -161,7 +161,7 @@ async function createVault() {
background:
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
- opacity: 0.5;
+ opacity: 0.62;
}
.entry-container {
@@ -173,6 +173,7 @@ async function createVault() {
gap: 32px;
max-width: 480px;
width: 90%;
+ animation: entry-in var(--motion-slow) both;
}
.brand-section {
@@ -180,8 +181,16 @@ async function createVault() {
}
.logo {
- font-size: 64px;
- margin-bottom: 12px;
+ display: inline-grid;
+ place-items: center;
+ width: 84px;
+ height: 84px;
+ margin-bottom: 14px;
+ border: 1px solid color-mix(in srgb, var(--color-accent-primary) 18%, transparent);
+ border-radius: 24px;
+ background: var(--color-surface-primary);
+ color: var(--color-accent-primary);
+ box-shadow: var(--shadow-lg);
}
.app-title {
@@ -206,7 +215,7 @@ async function createVault() {
border: 1px solid var(--color-border-default);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
- box-shadow: var(--shadow-lg);
+ box-shadow: var(--shadow-xl);
}
.card-title {
@@ -247,11 +256,13 @@ async function createVault() {
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
- transition: all var(--motion-fast);
+ transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
&:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent-secondary);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
}
&:disabled {
@@ -288,8 +299,11 @@ async function createVault() {
.vault-arrow {
color: var(--color-text-tertiary);
font-size: 20px;
+ transition: color var(--motion-fast), transform var(--motion-fast);
}
+.vault-item:hover .vault-arrow { color: var(--color-accent-primary); transform: translateX(3px); }
+
.actions {
display: flex;
flex-direction: column;
@@ -307,7 +321,7 @@ async function createVault() {
font-size: 14px;
font-weight: 500;
cursor: pointer;
- transition: all var(--motion-fast);
+ transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
border: 1px solid transparent;
&:disabled {
@@ -321,6 +335,8 @@ async function createVault() {
&:hover:not(:disabled) {
background: var(--color-accent-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 25%, transparent);
}
}
@@ -400,6 +416,7 @@ async function createVault() {
align-items: center;
justify-content: center;
z-index: var(--z-modal);
+ animation: dialog-backdrop-in var(--motion-fast) both;
}
.dialog {
@@ -409,8 +426,13 @@ async function createVault() {
width: 90%;
max-width: 400px;
box-shadow: var(--shadow-xl);
+ animation: dialog-in var(--motion-normal) both;
}
+@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes dialog-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
+@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
+
.dialog h3 {
margin: 0 0 var(--space-lg) 0;
font-size: 18px;
diff --git a/frontend/src/features/workspace/FileTreePanel.vue b/frontend/src/features/workspace/FileTreePanel.vue
index 7139ea4..38c496b 100644
--- a/frontend/src/features/workspace/FileTreePanel.vue
+++ b/frontend/src/features/workspace/FileTreePanel.vue
@@ -133,5 +133,5 @@ button:hover { background: var(--color-background-secondary); }
.tree { padding: var(--space-xs); }
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
.context-menu button { text-align: left; }
-.context-menu .danger { color: var(--color-danger, #d33); }
+.context-menu .danger { color: var(--color-error); }
diff --git a/frontend/src/features/workspace/WorkspaceView.vue b/frontend/src/features/workspace/WorkspaceView.vue
index 82e2853..61d9013 100644
--- a/frontend/src/features/workspace/WorkspaceView.vue
+++ b/frontend/src/features/workspace/WorkspaceView.vue
@@ -15,10 +15,10 @@ onMounted(() => {
// Already loaded
}
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
- void editorStore.loadFile('/欢迎使用知笔知己.md').then(() => {
+ void editorStore.loadFile('/欢迎使用 NotesAgent.md').then(() => {
// 默认文件加载期间用户可能已经点击了其他文件,不能覆盖用户的选择。
- if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用知笔知己.md') {
- workspaceStore.openFile('/欢迎使用知笔知己.md')
+ if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用 NotesAgent.md') {
+ workspaceStore.openFile('/欢迎使用 NotesAgent.md')
}
})
}
@@ -59,6 +59,11 @@ onMounted(() => {
.empty-content {
text-align: center;
+ padding: var(--space-3xl);
+ border: 1px dashed var(--color-border-default);
+ border-radius: var(--radius-xl);
+ background: var(--color-background-secondary);
+ animation: workspace-empty-in var(--motion-normal) both;
h2 {
font-size: 18px;
@@ -75,4 +80,6 @@ onMounted(() => {
font-size: 48px;
opacity: 0.5;
}
+
+@keyframes workspace-empty-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 426b5f9..4598b05 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -83,7 +83,7 @@ router.beforeEach((to, _from, next) => {
})
router.afterEach((to) => {
- const baseTitle = '知笔知己'
+ const baseTitle = 'NotesAgent'
const title = to.meta.title as string | undefined
document.title = title ? `${title} · ${baseTitle}` : baseTitle
})
diff --git a/frontend/src/services/pluginService.ts b/frontend/src/services/pluginService.ts
index 4ceba69..726a7e3 100644
--- a/frontend/src/services/pluginService.ts
+++ b/frontend/src/services/pluginService.ts
@@ -65,7 +65,7 @@ export const mockPlugins: Plugin[] = [
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '',
- author: '知笔知己团队',
+ author: 'NotesAgent 团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
@@ -117,7 +117,7 @@ export const mockPlugins: Plugin[] = [
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '',
- author: '知笔知己团队',
+ author: 'NotesAgent 团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
diff --git a/frontend/src/services/skillService.ts b/frontend/src/services/skillService.ts
index b05e7f9..2683546 100644
--- a/frontend/src/services/skillService.ts
+++ b/frontend/src/services/skillService.ts
@@ -50,7 +50,7 @@ export const mockSkills: Skill[] = [
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '',
- author: '知笔知己团队',
+ author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 10, rerank: true, citation: true },
@@ -64,7 +64,7 @@ export const mockSkills: Skill[] = [
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '',
- author: '知笔知己团队',
+ author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
retrieval_config: { top_k: 5, rerank: false, citation: true },
diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts
index b734e31..5214280 100644
--- a/frontend/src/services/workspaceService.ts
+++ b/frontend/src/services/workspaceService.ts
@@ -58,7 +58,7 @@ const MOCK_FILE_TREE: FileNode[] = [
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
],
},
- { id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' },
+ { id: 'n-welcome', name: '欢迎使用 NotesAgent.md', path: '/欢迎使用 NotesAgent.md', type: 'file' },
]
const mockFileContents = new Map
()
@@ -89,8 +89,8 @@ export function readFileContent(filePath: string): Promise {
const saved = mockFileContents.get(filePath)
if (saved !== undefined) return Promise.resolve(saved)
const name = filePath.split('/').pop() || 'Untitled'
- if (name === '欢迎使用知笔知己.md') {
- return rememberContent(filePath, `# 欢迎使用知笔知己
+ if (name === '欢迎使用 NotesAgent.md') {
+ return rememberContent(filePath, `# 欢迎使用 NotesAgent
这是一款本地优先的 AI 笔记软件,支持 Markdown 编辑、智能检索、RAG 问答和 Agent 助手。
@@ -207,7 +207,7 @@ def quick_sort(arr):
> 引用内容示例
\`\`\`javascript
-console.log('Hello, Notes Agent!');
+console.log('Hello, NotesAgent!');
\`\`\`
`)
}
diff --git a/frontend/src/stores/theme.spec.ts b/frontend/src/stores/theme.spec.ts
new file mode 100644
index 0000000..9d1d0cc
--- /dev/null
+++ b/frontend/src/stores/theme.spec.ts
@@ -0,0 +1,47 @@
+// @vitest-environment happy-dom
+import { beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { nextTick } from 'vue'
+import { useThemeStore } from './theme'
+
+beforeEach(() => {
+ localStorage.clear()
+ document.documentElement.removeAttribute('data-theme')
+ document.documentElement.removeAttribute('data-code-theme')
+ setActivePinia(createPinia())
+ Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ value: () => ({ matches: false }),
+ })
+})
+
+describe('代码块主题偏好', () => {
+ it('跟随应用主题选择对应的 GitHub 代码主题', async () => {
+ const store = useThemeStore()
+ store.applyTheme('dark')
+ await nextTick()
+
+ expect(store.resolvedCodeBlockTheme).toBe('github-dark')
+ expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
+ })
+
+ it('允许代码块主题独立于应用主题', async () => {
+ const store = useThemeStore()
+ store.applyTheme('dark')
+ store.codeBlockTheme = 'github-light'
+ await nextTick()
+
+ expect(store.resolvedCodeBlockTheme).toBe('github-light')
+ expect(document.documentElement.dataset.codeTheme).toBe('github-light')
+ })
+
+ it('恢复持久化的代码块主题偏好', async () => {
+ localStorage.setItem('editor-appearance', JSON.stringify({ codeBlockTheme: 'github-dark' }))
+ const store = useThemeStore()
+ store.initTheme()
+ await nextTick()
+
+ expect(store.codeBlockTheme).toBe('github-dark')
+ expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
+ })
+})
diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts
index 5b591f6..ffd0f23 100644
--- a/frontend/src/stores/theme.ts
+++ b/frontend/src/stores/theme.ts
@@ -3,17 +3,24 @@ import { ref, computed, watch } from 'vue'
import type { ThemeConfig } from '@/contracts'
const builtinThemes: ThemeConfig[] = [
- { theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
- { theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
- { theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
+ { theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
+ { theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
+ { theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
]
+export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
+
+function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePreference {
+ return value === 'auto' || value === 'github-light' || value === 'github-dark'
+}
+
export const useThemeStore = defineStore('theme', () => {
const themes = ref(builtinThemes)
const currentThemeId = ref('light')
const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7)
+ const codeBlockTheme = ref('auto')
let appearanceHydrated = false
const currentTheme = computed(() =>
@@ -21,6 +28,10 @@ export const useThemeStore = defineStore('theme', () => {
)
const isDark = computed(() => currentTheme.value?.is_dark || false)
+ const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
+ if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
+ return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
+ })
function applyTheme(themeId: string) {
const theme = themes.value.find((t) => t.theme_id === themeId)
@@ -41,10 +52,11 @@ export const useThemeStore = defineStore('theme', () => {
const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) {
try {
- const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number }
+ const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number; codeBlockTheme?: unknown }
if (value.size) fontEditorSize.value = value.size
if (value.family) fontEditorFamily.value = value.family
if (value.lineHeight) lineHeight.value = value.lineHeight
+ if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
} catch { localStorage.removeItem('editor-appearance') }
}
const saved = localStorage.getItem('theme')
@@ -67,12 +79,20 @@ export const useThemeStore = defineStore('theme', () => {
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
lineHeight.value = 1.7
+ codeBlockTheme.value = 'auto'
}
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
- size: fontEditorSize.value, family: fontEditorFamily.value, lineHeight: lineHeight.value,
+ size: fontEditorSize.value,
+ family: fontEditorFamily.value,
+ lineHeight: lineHeight.value,
+ codeBlockTheme: codeBlockTheme.value,
}))
+ watch(resolvedCodeBlockTheme, (theme) => {
+ document.documentElement.setAttribute('data-code-theme', theme)
+ }, { immediate: true })
+
watch(fontEditorSize, (v) => {
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
if (appearanceHydrated) persistAppearance()
@@ -88,6 +108,10 @@ export const useThemeStore = defineStore('theme', () => {
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
+ watch(codeBlockTheme, () => {
+ if (appearanceHydrated) persistAppearance()
+ })
+
return {
themes,
currentThemeId,
@@ -96,6 +120,8 @@ export const useThemeStore = defineStore('theme', () => {
fontEditorSize,
fontEditorFamily,
lineHeight,
+ codeBlockTheme,
+ resolvedCodeBlockTheme,
applyTheme,
initTheme,
toggleTheme,
diff --git a/frontend/src/styles/features.css b/frontend/src/styles/features.css
index 10ea4b9..782c0c2 100644
--- a/frontend/src/styles/features.css
+++ b/frontend/src/styles/features.css
@@ -1,9 +1,18 @@
.feature-page {
height: 100%;
overflow: auto;
- padding: var(--space-2xl);
- background: var(--color-background-primary);
+ padding: clamp(var(--space-lg), 3vw, var(--space-3xl));
+ background:
+ radial-gradient(circle at 92% -8%, var(--color-accent-soft) 0, transparent 28%),
+ var(--color-background-primary);
user-select: text;
+ scrollbar-gutter: stable;
+ animation: page-in var(--motion-normal) both;
+}
+
+.chat-page,
+.workspace-view {
+ animation: page-in var(--motion-normal) both;
}
.feature-header {
@@ -11,13 +20,15 @@
align-items: flex-start;
justify-content: space-between;
gap: var(--space-lg);
- margin-bottom: var(--space-xl);
+ margin: 0 auto var(--space-2xl);
+ max-width: 1180px;
}
-.feature-header h1 { font-size: var(--font-size-3xl); line-height: 1.2; }
-.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
+.feature-header h1 { font-size: clamp(var(--font-size-2xl), 2.3vw, 30px); line-height: 1.15; letter-spacing: -.025em; }
+.feature-header p { max-width: 680px; margin-top: var(--space-sm); color: var(--color-text-secondary); }
.feature-actions, .inline-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
-.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); }
+.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); max-width: 1180px; margin-inline: auto; }
+.feature-page > .panel { max-width: 1180px; margin-inline: auto; }
.split-view { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(360px, 1.7fr); gap: var(--space-lg); min-height: 0; }
.panel, .item-card {
@@ -27,10 +38,13 @@
box-shadow: var(--shadow-sm);
}
-.panel { padding: var(--space-xl); }
-.item-card { padding: var(--space-lg); transition: border-color var(--motion-fast), transform var(--motion-fast); }
-.item-card:hover { border-color: var(--color-accent-secondary); }
-.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 2px var(--color-accent-soft); }
+.panel { padding: clamp(var(--space-lg), 2.2vw, var(--space-2xl)); }
+.item-card {
+ padding: var(--space-lg);
+ transition: border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast), background-color var(--motion-fast);
+}
+.item-card:hover { border-color: var(--color-accent-secondary); box-shadow: var(--shadow-md); transform: translateY(-2px); }
+.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 3px var(--color-accent-soft), var(--shadow-md); }
.panel-title { margin-bottom: var(--space-md); font-size: var(--font-size-xl); }
.muted { color: var(--color-text-secondary); }
.subtle { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
@@ -41,14 +55,18 @@
border: 1px solid transparent;
border-radius: var(--radius-md);
font-weight: 600;
+ line-height: 1;
+ transition: background-color var(--motion-fast), border-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
}
-.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); }
-.button-primary:hover { background: var(--color-accent-primary-hover); }
+.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); box-shadow: 0 4px 12px color-mix(in srgb, var(--color-accent-primary) 24%, transparent); }
+.button-primary:hover { background: var(--color-accent-primary-hover); transform: translateY(-1px); box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 28%, transparent); }
.button-secondary { border-color: var(--color-border-default); background: var(--color-surface-primary); }
-.button-secondary:hover, .icon-button:hover { background: var(--color-background-hover); }
+.button-secondary:hover, .icon-button:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
.button-danger { background: var(--color-error-soft); color: var(--color-error); }
-button:disabled { cursor: not-allowed; opacity: .55; }
+.button-danger:hover { transform: translateY(-1px); box-shadow: 0 5px 14px color-mix(in srgb, var(--color-error) 18%, transparent); }
+button:active:not(:disabled) { transform: translateY(0); }
+button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform: none; }
.field { display: grid; gap: var(--space-xs); }
.field label { color: var(--color-text-secondary); font-size: var(--font-size-sm); font-weight: 600; }
@@ -59,10 +77,12 @@ button:disabled { cursor: not-allowed; opacity: .55; }
outline: none;
background: var(--color-background-primary);
color: var(--color-text-primary);
+ transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background-color var(--motion-fast);
}
-.input, .select { height: 36px; padding: 0 var(--space-md); }
+.input, .select { height: 38px; padding: 0 var(--space-md); }
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
-.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 2px var(--color-accent-soft); }
+.input:hover, .select:hover, .textarea:hover { border-color: var(--color-text-tertiary); }
+.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
.badge {
@@ -75,6 +95,7 @@ button:disabled { cursor: not-allowed; opacity: .55; }
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 600;
+ letter-spacing: .01em;
}
.badge.success { background: var(--color-success-soft); color: var(--color-success); }
.badge.warning { background: var(--color-warning-soft); color: var(--color-warning); }
@@ -82,25 +103,45 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.badge.info { background: var(--color-info-soft); color: var(--color-info); }
.tag-list { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
-.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-2xl); text-align: center; color: var(--color-text-secondary); }
+.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-3xl); border: 1px dashed var(--color-border-default); border-radius: var(--radius-xl); background: color-mix(in srgb, var(--color-background-secondary) 74%, transparent); text-align: center; color: var(--color-text-secondary); }
.empty-state strong { display: block; margin-bottom: var(--space-xs); color: var(--color-text-primary); font-size: var(--font-size-xl); }
-.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
-.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
+.error-banner, .notice-banner { border: 1px solid transparent; animation: notice-in var(--motion-normal) both; }
+.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-error) 22%, transparent); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
+.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-info) 22%, transparent); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
.sidebar-panel { padding: var(--space-md); }
.sidebar-panel .input, .sidebar-panel .select { margin-bottom: var(--space-sm); }
.sidebar-list { display: grid; gap: var(--space-xs); }
-.sidebar-list-item { padding: var(--space-sm); border-radius: var(--radius-md); cursor: pointer; }
-.sidebar-list-item:hover, .sidebar-list-item.active { background: var(--color-background-hover); }
+.sidebar-list-item { padding: 9px var(--space-sm); border: 1px solid transparent; border-radius: var(--radius-md); cursor: pointer; transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); }
+.sidebar-list-item:hover { background: var(--color-background-hover); transform: translateX(2px); }
+.sidebar-list-item.active { border-color: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); background: var(--color-accent-soft); color: var(--color-accent-primary); }
-.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); }
-.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-xl); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
+.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); animation: backdrop-in var(--motion-fast) both; }
+.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-2xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: modal-in var(--motion-normal) both; }
.modal h2 { margin-bottom: var(--space-lg); }
.modal form { display: grid; gap: var(--space-md); }
-.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
-.settings-nav button { padding: var(--space-sm) var(--space-md); border-bottom: 2px solid transparent; color: var(--color-text-secondary); }
-.settings-nav button.active { border-color: var(--color-accent-primary); color: var(--color-accent-primary); }
+.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-secondary); }
+.settings-nav button { padding: 8px var(--space-md); border-radius: var(--radius-md); color: var(--color-text-secondary); transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast); }
+.settings-nav button:hover { color: var(--color-text-primary); background: var(--color-background-hover); }
+.settings-nav button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
+
+@keyframes page-in {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes notice-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
+
+@keyframes modal-in {
+ from { opacity: 0; transform: translateY(8px) scale(.985); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
@media (max-width: 900px) {
.feature-page { padding: var(--space-lg); }
diff --git a/frontend/src/styles/motion.spec.ts b/frontend/src/styles/motion.spec.ts
new file mode 100644
index 0000000..49cad55
--- /dev/null
+++ b/frontend/src/styles/motion.spec.ts
@@ -0,0 +1,44 @@
+///
+
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+const globalStyles = [
+ new URL('./tokens.css', import.meta.url),
+ new URL('./features.css', import.meta.url),
+].map((path) => readFileSync(path, 'utf8')).join('\n')
+
+const markdownStyles = [
+ new URL('../features/editor/VisualMarkdownEditor.vue', import.meta.url),
+ new URL('../features/themes/ThemesView.vue', import.meta.url),
+ new URL('../components/common/MarkdownContent.vue', import.meta.url),
+].map((path) => readFileSync(path, 'utf8')).join('\n')
+
+describe('轻量动效基线', () => {
+ it('为减少动态效果偏好提供全局回退', () => {
+ expect(globalStyles).toContain('@media (prefers-reduced-motion: reduce)')
+ })
+
+ it('不使用全属性过渡或高成本模糊滤镜', () => {
+ expect(globalStyles).not.toMatch(/transition:\s*all\b/)
+ expect(globalStyles).not.toMatch(/(?:backdrop-)?filter\s*:/)
+ })
+
+ it('页面入场只改变透明度和变换', () => {
+ const pageAnimation = globalStyles.match(/@keyframes page-in\s*{[\s\S]*?\n}/)?.[0] ?? ''
+ expect(pageAnimation).toContain('opacity')
+ expect(pageAnimation).toContain('transform')
+ expect(pageAnimation).not.toMatch(/(?:width|height|margin|padding|top|left)\s*:/)
+ })
+
+ it('Markdown 序号和表格使用独立的高对比度主题变量', () => {
+ expect(globalStyles).toContain('--color-markdown-grid:')
+ expect(globalStyles).toContain('--color-markdown-marker:')
+ expect(markdownStyles).toContain('var(--color-markdown-grid)')
+ expect(markdownStyles).toContain('var(--color-markdown-marker)')
+ })
+
+ it('不混用可能丢失后代选择器的 scoped global 写法', () => {
+ expect(markdownStyles).not.toMatch(/:global\([^\n]+\)\s+\./)
+ })
+})
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 8e3156b..b4109a2 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -44,17 +44,22 @@
--color-border-focus: #5b67f1;
--color-border-disabled: #eef0f3;
+ /* Markdown */
+ --color-markdown-grid: #8b949e;
+ --color-markdown-marker: #343b44;
+ --color-markdown-table-header: #eef0f3;
+
/* Shadow */
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
- --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
- --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16);
+ --shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.05), 0 1px 5px rgba(31, 35, 40, 0.03);
+ --shadow-md: 0 8px 22px rgba(31, 35, 40, 0.08), 0 2px 6px rgba(31, 35, 40, 0.04);
+ --shadow-lg: 0 16px 36px rgba(31, 35, 40, 0.11), 0 4px 12px rgba(31, 35, 40, 0.05);
+ --shadow-xl: 0 24px 64px rgba(31, 35, 40, 0.18), 0 8px 20px rgba(31, 35, 40, 0.08);
/* Radius */
- --radius-sm: 4px;
- --radius-md: 6px;
- --radius-lg: 10px;
- --radius-xl: 14px;
+ --radius-sm: 6px;
+ --radius-md: 9px;
+ --radius-lg: 13px;
+ --radius-xl: 18px;
--radius-full: 9999px;
/* Spacing */
@@ -100,16 +105,16 @@
--z-titlebar: 500;
/* Motion */
- --motion-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
- --motion-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
- --motion-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+ --motion-fast: 120ms cubic-bezier(0.2, 0, 0, 1);
+ --motion-normal: 190ms cubic-bezier(0.2, 0, 0, 1);
+ --motion-slow: 260ms cubic-bezier(0.2, 0, 0, 1);
/* Layout */
- --titlebar-height: 38px;
- --sidebar-primary-width: 52px;
+ --titlebar-height: 42px;
+ --sidebar-primary-width: 58px;
--sidebar-primary-width-expanded: 180px;
- --sidebar-secondary-width: 260px;
- --statusbar-height: 26px;
+ --sidebar-secondary-width: 272px;
+ --statusbar-height: 28px;
}
[data-theme='dark'] {
@@ -152,6 +157,10 @@
--color-border-focus: #7d8bff;
--color-border-disabled: #21262d;
+ --color-markdown-grid: #6e7681;
+ --color-markdown-marker: #c9d1d9;
+ --color-markdown-table-header: #21262d;
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -172,12 +181,29 @@
--color-text-tertiary: #9a8a72;
--color-border-default: #ddcfad;
--color-border-subtle: #eadfc4;
+ --color-markdown-grid: #9c8353;
+ --color-markdown-marker: #554735;
+ --color-markdown-table-header: #eadbb8;
--color-accent-primary: #8a5b32;
--color-accent-primary-hover: #704724;
--color-accent-soft: #edddbd;
--color-text-link: #7b512e;
}
+[data-code-theme='github-light'] {
+ --color-code-background: #f6f8fa;
+ --color-code-border: #d0d7de;
+ --color-code-text: #24292f;
+ --color-code-muted: #57606a;
+}
+
+[data-code-theme='github-dark'] {
+ --color-code-background: #161b22;
+ --color-code-border: #30363d;
+ --color-code-text: #c9d1d9;
+ --color-code-muted: #8b949e;
+}
+
* {
box-sizing: border-box;
margin: 0;
@@ -221,6 +247,25 @@ button {
color: inherit;
}
+button,
+a,
+input,
+textarea,
+select {
+ -webkit-tap-highlight-color: transparent;
+}
+
+input[type='checkbox'],
+input[type='radio'],
+input[type='range'] {
+ accent-color: var(--color-accent-primary);
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
input,
textarea,
select {
@@ -261,7 +306,25 @@ ol {
color: var(--color-accent-primary);
}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
[data-theme='dark'] ::selection {
background: var(--color-accent-primary);
color: var(--color-text-inverse);
}
+
+@media (max-width: 900px) {
+ :root {
+ --sidebar-primary-width-expanded: 160px;
+ --sidebar-secondary-width: 224px;
+ }
+}
diff --git a/frontend/src/utils/markdown.spec.ts b/frontend/src/utils/markdown.spec.ts
new file mode 100644
index 0000000..11b2b33
--- /dev/null
+++ b/frontend/src/utils/markdown.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import { highlightCode } from './markdown'
+
+describe('Shiki GitHub 双主题', () => {
+ it('一次渲染同时生成 GitHub Light 和 GitHub Dark 颜色变量', async () => {
+ const html = await highlightCode('const answer = 42', 'typescript')
+
+ expect(html).toContain('github-light')
+ expect(html).toContain('github-dark')
+ expect(html).toContain('--shiki-light')
+ expect(html).toContain('--shiki-dark')
+ })
+})