From 3a2437369efdfaa862ed120dd47d32f7484cfb46 Mon Sep 17 00:00:00 2001 From: Mohan Date: Mon, 10 Aug 2026 12:36:35 +0800 Subject: [PATCH] feat(theme): add SongUI token system with light/dark palette and LXGW WenKai font --- package-lock.json | 7 ++ package.json | 1 + src/composables/useTheme.ts | 36 +++++++ src/main.ts | 6 ++ src/styles/song-theme.css | 208 ++++++++++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 src/composables/useTheme.ts create mode 100644 src/styles/song-theme.css diff --git a/package-lock.json b/package-lock.json index ac1f160..e7cae7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.7.9", "dompurify": "^3.2.4", "highlight.js": "^11.11.1", + "lxgw-wenkai-webfont": "^1.7.0", "marked": "^18.0.9", "marked-highlight": "^2.2.4", "pinia": "^2.3.0", @@ -2544,6 +2545,12 @@ "node": "20 || >=22" } }, + "node_modules/lxgw-wenkai-webfont": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/lxgw-wenkai-webfont/-/lxgw-wenkai-webfont-1.7.0.tgz", + "integrity": "sha512-kVJmT4xMAyOLhG6q0w63wJkVU7RP8UMQRGoAWj9tnpzL7sWFFYiEHBdUQyW9myqBQFa5SQfWfa2VLYF4IJBL5g==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 42e047b..b6444ad 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "axios": "^1.7.9", "dompurify": "^3.2.4", "highlight.js": "^11.11.1", + "lxgw-wenkai-webfont": "^1.7.0", "marked": "^18.0.9", "marked-highlight": "^2.2.4", "pinia": "^2.3.0", diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 0000000..87fc2a1 --- /dev/null +++ b/src/composables/useTheme.ts @@ -0,0 +1,36 @@ +import { ref } from 'vue' + +const STORAGE_KEY = 'mach-theme' +const isDark = ref(false) +let initialized = false + +export function applyTheme(v: boolean): void { + isDark.value = v + document.documentElement.setAttribute('data-theme', v ? 'dark' : 'light') + try { + localStorage.setItem(STORAGE_KEY, v ? 'dark' : 'light') + } catch { + /* ignore */ + } +} + +export function initTheme(): void { + if (initialized) return + initialized = true + let stored: string | null = null + try { + stored = localStorage.getItem(STORAGE_KEY) + } catch { + /* ignore */ + } + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + applyTheme(stored ? stored === 'dark' : prefersDark) +} + +export function useTheme() { + initTheme() + return { + isDark, + toggle: () => applyTheme(!isDark.value) + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index f0f1cb2..0ec0d43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,13 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import 'lxgw-wenkai-webfont/lxgwwenkai-regular.css' +import 'lxgw-wenkai-webfont/lxgwwenkai-bold.css' +import './styles/song-theme.css' import App from './App.vue' import router from './router' +import { initTheme } from './composables/useTheme' + +initTheme() const app = createApp(App) app.use(createPinia()) diff --git a/src/styles/song-theme.css b/src/styles/song-theme.css new file mode 100644 index 0000000..bfe1a57 --- /dev/null +++ b/src/styles/song-theme.css @@ -0,0 +1,208 @@ +/* SongUI · 檐下 — 设计契约实现 + * 参照 frontendui/SongUI研究报告.md §2-§9 与 src/styles/tokens/{light,dark}.css + * :attr=light 天青昼 / :attr=dark 黛墨夜 + */ + +:root, +:root[data-theme='light'] { + color-scheme: light; + --song-bg: #f7f4ec; + --song-bg-elevated: #fffdf8; + --song-surface-3: #ece6d8; + + --song-text: #3a4a52; + --song-text-secondary: #5a6b74; + --song-text-disabled: #9aa7ad; + + --song-primary: #4f78b8; + --song-primary-bg: #e7eef9; + --song-success: #4e7a45; + --song-success-bg: #e9f0e4; + --song-warning: #8f7a1e; + --song-warning-bg: #f6f1df; + --song-danger: #a84635; + --song-danger-bg: #f6e9e2; + + --song-border: rgba(58, 74, 82, 0.14); + --song-border-strong: rgba(58, 74, 82, 0.28); + --song-hover: rgba(79, 120, 184, 0.08); + --song-active: rgba(79, 120, 184, 0.14); + + --song-btn-bg: #4f78b8; + --song-btn-text: #fff; + + --song-seal: #a84635; + + --song-shadow-1: 0 1px 2px rgba(20, 24, 27, 0.06); + --song-shadow-2: 0 2px 8px rgba(20, 24, 27, 0.08); + --song-shadow-3: 0 8px 24px rgba(20, 24, 27, 0.12); + + --song-font-display: 'LXGW WenKai', 'Noto Serif SC', 'Songti SC', serif; + --song-font-serif: 'Noto Serif SC', 'Songti SC', serif; + --song-font-sans: 'Noto Sans SC', -apple-system, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --song-font-body: var(--song-font-sans); + + --song-radius-s: 2px; + --song-radius-m: 4px; + --song-radius-l: 6px; + + --song-space-1: 4px; + --song-space-2: 8px; + --song-space-3: 12px; + --song-space-4: 16px; + --song-space-5: 24px; + --song-space-6: 32px; + --song-space-8: 64px; + + --song-ease: cubic-bezier(0.33, 1, 0.68, 1); + --song-dur-1: 200ms; + --song-dur-2: 320ms; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --song-bg: #14181b; + --song-bg-elevated: #1d2326; + --song-surface-3: #232b2f; + + --song-text: #e6ecef; + --song-text-secondary: #a9b6be; + --song-text-disabled: #5e6a71; + + --song-primary: #9dbeee; + --song-primary-bg: rgba(113, 150, 203, 0.16); + --song-success: #94b989; + --song-success-bg: rgba(148, 185, 137, 0.14); + --song-warning: #d3bc6a; + --song-warning-bg: rgba(211, 188, 106, 0.14); + --song-danger: #d08a79; + --song-danger-bg: rgba(208, 138, 121, 0.14); + + --song-border: rgba(230, 236, 239, 0.12); + --song-border-strong: rgba(230, 236, 239, 0.26); + --song-hover: rgba(157, 190, 238, 0.1); + --song-active: rgba(157, 190, 238, 0.16); + + --song-btn-bg: #48689e; + --song-btn-text: #fff; + + --song-seal: #c76a4f; + + --song-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3); + --song-shadow-2: 0 2px 8px rgba(0, 0, 0, 0.35); + --song-shadow-3: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +/* ── 应用语义别名(两主题自适应) ───────────────────────────── */ +:root { + --ac-bg: var(--song-bg); + --ac-bg-card: var(--song-bg-elevated); + --ac-surface: var(--song-surface-3); + + --ac-text: var(--song-text); + --ac-text-secondary: var(--song-text-secondary); + --ac-text-disabled: var(--song-text-disabled); + + --ac-primary: var(--song-primary); + --ac-primary-strong: var(--song-btn-bg); + --ac-primary-text: var(--song-btn-text); + --ac-primary-bg: var(--song-primary-bg); + + --ac-success: var(--song-success); + --ac-success-bg: var(--song-success-bg); + --ac-warning: var(--song-warning); + --ac-warning-bg: var(--song-warning-bg); + --ac-danger: var(--song-danger); + --ac-danger-bg: var(--song-danger-bg); + + --ac-border: var(--song-border); + --ac-border-strong: var(--song-border-strong); + --ac-input-border: var(--song-border-strong); + --ac-hover: var(--song-hover); + --ac-active: var(--song-active); + + --ac-shadow-1: var(--song-shadow-1); + --ac-shadow-2: var(--song-shadow-2); + --ac-shadow-3: var(--song-shadow-3); + + --ac-radius-s: 2px; + --ac-radius-m: 4px; + --ac-radius-l: 6px; + + --ac-header-bg: #232d34; + --ac-header-text: #eef2f4; + --ac-header-muted: #a9b6be; + --ac-header-active-bg: #31404a; +} + +:root[data-theme='dark'] { + --ac-header-bg: var(--song-bg-elevated); + --ac-header-text: var(--song-text); + --ac-header-muted: var(--song-text-secondary); + --ac-header-active-bg: var(--song-hover); +} + +/* ── 基础 §3 ──────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--song-bg); + color: var(--song-text); + font-family: var(--song-font-body); + font-size: 14px; + line-height: 22px; + -webkit-font-smoothing: antialiased; + transition: background-color var(--song-dur-2) var(--song-ease), color var(--song-dur-2) var(--song-ease); +} + +a { + color: var(--ac-primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +/* 焦点态 §9 — 主色 2px 轮廓 */ +:focus-visible { + outline: 2px solid var(--song-primary); + outline-offset: 2px; + border-radius: var(--song-radius-s); +} + +/* 数字等宽 */ +.tnum { + font-variant-numeric: tabular-nums; +} + +/* 页面转场 — 雾隐淡入 §7 */ +.page-enter-active, +.page-leave-active { + transition: opacity var(--song-dur-2) var(--song-ease), transform var(--song-dur-2) var(--song-ease); +} +.page-enter-from, +.page-leave-to { + opacity: 0; + transform: translateY(8px); +} + +/* 动效可关 §7 */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} \ No newline at end of file