diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26a949e0..7888459b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,10 +42,10 @@ jobs:
pnpm install --frozen-lockfile
if [ "${{ steps.changes.outputs.code }}" == "true" ]; then
- # 运行所有测试:单元测试 + Storybook 组件测试 + E2E 测试
- TASKS="typecheck:run build i18n:run test:run test:storybook e2e:ci e2e:ci:mock e2e:ci:real"
+ # 运行所有测试:单元测试 + Storybook 组件测试 + E2E 测试 + 主题检查
+ TASKS="typecheck:run build i18n:run theme:run test:run test:storybook e2e:ci e2e:ci:mock e2e:ci:real"
else
- TASKS="typecheck:run build i18n:run test:run test:storybook"
+ TASKS="typecheck:run build i18n:run theme:run test:run test:storybook"
fi
# Run turbo and capture output, pipefail ensures we get turbo's exit code
@@ -115,10 +115,10 @@ jobs:
E2E_TEST_MNEMONIC: ${{ secrets.E2E_TEST_MNEMONIC }}
run: |
if [ "${{ steps.changes.outputs.code }}" == "true" ]; then
- # 运行所有测试:单元测试 + Storybook 组件测试 + E2E 测试
- pnpm turbo run typecheck:run build i18n:run test:run test:storybook e2e:ci e2e:ci:mock e2e:ci:real
+ # 运行所有测试:单元测试 + Storybook 组件测试 + E2E 测试 + 主题检查
+ pnpm turbo run typecheck:run build i18n:run theme:run test:run test:storybook e2e:ci e2e:ci:mock e2e:ci:real
else
- pnpm turbo run typecheck:run build i18n:run test:run test:storybook
+ pnpm turbo run typecheck:run build i18n:run theme:run test:run test:storybook
fi
checks-standard:
diff --git "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md" "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
index acc252f2..9a697ce4 100644
--- "a/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
+++ "b/docs/white-book/00-\345\277\205\350\257\273/best-practices.md"
@@ -12,6 +12,9 @@
- ❌ 安装新 UI 库 → ✅ shadcn/ui(已集成)
- ❌ 新建 CSS → ✅ Tailwind CSS
- ❌ text-secondary → ✅ text-muted-foreground 或 bg-secondary text-secondary-foreground(详见白皮书 02-设计篇/02-视觉设计/theme-colors)
+- ❌ bg-primary text-white → ✅ bg-primary text-primary-foreground(暗色模式下 text-white 对比度不足)
+- ❌ bg-muted 无文字色 → ✅ bg-muted text-muted-foreground(详见白皮书 02-设计篇/02-视觉设计/dark-mode)
+- ❌ bg-gray-100 无 dark: 变体 → ✅ bg-gray-100 dark:bg-gray-800 或使用 bg-muted
- ❌ getByText('硬编码中文') → ✅ getByText(t('i18n.key')) 或 getByTestId
- ❌ password/Password(宽泛含义) → ✅ walletLock(钱包锁)/ twoStepSecret(安全密码)/ payPassword(支付密码)等具体命名
- 圆形元素必须使用 aspect-square 标记,与 w-*/h-*/size-* 不冲突,是规范要求
diff --git "a/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/dark-mode.md" "b/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/dark-mode.md"
new file mode 100644
index 00000000..f872505e
--- /dev/null
+++ "b/docs/white-book/02-\350\256\276\350\256\241\347\257\207/02-\350\247\206\350\247\211\350\256\276\350\256\241/dark-mode.md"
@@ -0,0 +1,162 @@
+# 暗色模式最佳实践
+
+> 确保应用在浅色和暗色主题下都有良好的可读性和视觉体验。
+
+---
+
+## 核心原则
+
+### 1. 使用语义化颜色 Token
+
+**始终优先使用 shadcn/ui 的语义化颜色变量**,它们会自动适配主题:
+
+```tsx
+// ✅ 正确 - 使用语义化颜色
+
...
+...
+...
+
+// ❌ 错误 - 缺少 dark: 变体
+...
+```
+
+### 灰度映射参考
+
+| 浅色 | 暗色 |
+|------|------|
+| gray-50 | gray-900 |
+| gray-100 | gray-800 |
+| gray-200 | gray-700 |
+| gray-300 | gray-600 |
+| gray-400 | gray-500 |
+| gray-500 | gray-400 |
+
+### 可接受的例外
+
+以下情况**不需要** dark: 变体:
+
+1. **半透明颜色在渐变/彩色背景上**:
+ ```tsx
+ // ✅ OK - 在渐变背景上
+ = {
+ '50': '900',
+ '100': '800',
+ '200': '700',
+ '300': '600',
+ '400': '500',
+ '500': '400',
+ '600': '300',
+ '700': '200',
+ '800': '100',
+ '900': '50',
+ }
+ return shadeMap[shade] || '800'
+}
+
+function shouldSkipFile(filePath: string): boolean {
+ return SKIP_PATTERNS.some((pattern) => filePath.includes(pattern))
+}
+
+function getAllFiles(dir: string, files: string[] = []): string[] {
+ let entries: string[]
+ try {
+ entries = readdirSync(dir)
+ } catch {
+ return files
+ }
+
+ for (const entry of entries) {
+ const fullPath = join(dir, entry)
+
+ if (shouldSkipFile(fullPath)) continue
+
+ let stat
+ try {
+ stat = statSync(fullPath)
+ } catch {
+ continue
+ }
+
+ if (stat.isDirectory()) {
+ getAllFiles(fullPath, files)
+ } else if (entry.endsWith('.tsx') || entry.endsWith('.jsx')) {
+ files.push(fullPath)
+ }
+ }
+
+ return files
+}
+
+// ==================== Main Logic ====================
+
+async function main() {
+ const args = process.argv.slice(2)
+ const verbose = args.includes('--verbose')
+
+ console.log(`
+${colors.cyan}╔════════════════════════════════════════╗
+║ Theme (Dark Mode) Lint ║
+╚════════════════════════════════════════╝${colors.reset}
+`)
+
+ const files = getAllFiles(SRC_DIR)
+ log.info(`Checking ${files.length} files...`)
+
+ const allIssues: Issue[] = []
+
+ for (const file of files) {
+ const content = readFileSync(file, 'utf-8')
+ const relPath = relative(ROOT, file)
+
+ const issues = [
+ ...checkBackgroundAsText(content, relPath),
+ ...checkOrphanForeground(content, relPath),
+ ...checkMissingDarkVariant(content, relPath),
+ ...checkTextWhiteOnSemanticBg(content, relPath),
+ ...checkBgMutedWithoutText(content, relPath),
+ ...checkSemanticColors(content, relPath),
+ ]
+
+ allIssues.push(...issues)
+
+ if (verbose && issues.length === 0) {
+ log.success(relPath)
+ }
+ }
+
+ // Report results
+ log.step('Results')
+
+ if (allIssues.length === 0) {
+ log.success('No theme issues found!')
+ console.log(`
+${colors.green}✓ All ${files.length} files follow dark mode best practices${colors.reset}
+`)
+ process.exit(0)
+ }
+
+ // Group by file
+ const byFile = new Map
()
+ for (const issue of allIssues) {
+ if (!byFile.has(issue.file)) {
+ byFile.set(issue.file, [])
+ }
+ byFile.get(issue.file)!.push(issue)
+ }
+
+ const errorCount = allIssues.filter((i) => i.severity === 'error').length
+ const warningCount = allIssues.filter((i) => i.severity === 'warning').length
+
+ for (const [file, issues] of byFile) {
+ console.log(`\n${colors.bold}${file}${colors.reset}`)
+
+ for (const issue of issues) {
+ const icon = issue.severity === 'error' ? colors.red + '✗' : colors.yellow + '⚠'
+ console.log(` ${icon}${colors.reset} Line ${issue.line}: ${issue.message}`)
+ if (issue.suggestion) {
+ log.dim(` → ${issue.suggestion}`)
+ }
+ }
+ }
+
+ console.log(`
+${colors.bold}Summary:${colors.reset}
+ ${colors.red}Errors: ${errorCount}${colors.reset}
+ ${colors.yellow}Warnings: ${warningCount}${colors.reset}
+`)
+
+ // Exit with error if there are errors (warnings are OK for CI)
+ if (errorCount > 0) {
+ log.info(`See docs/white-book/02-设计篇/02-视觉设计/theme-colors.md for guidance`)
+ process.exit(1)
+ }
+
+ log.success('No blocking errors (warnings can be addressed later)')
+}
+
+main().catch((error) => {
+ log.error(`Check failed: ${error.message}`)
+ console.error(error)
+ process.exit(1)
+})
diff --git a/src/components/layout/tab-bar.tsx b/src/components/layout/tab-bar.tsx
index d38e59d8..92c590e6 100644
--- a/src/components/layout/tab-bar.tsx
+++ b/src/components/layout/tab-bar.tsx
@@ -45,7 +45,7 @@ export function TabBar({ items, activeId, onTabChange, className }: TabBarProps)
{isActive && item.activeIcon ? item.activeIcon : item.icon}
{item.badge !== undefined && (
-
+
{typeof item.badge === 'number' && item.badge > 99 ? '99+' : item.badge}
)}
diff --git a/src/components/onboarding/create-wallet-success.tsx b/src/components/onboarding/create-wallet-success.tsx
index b4857d66..a2577b71 100644
--- a/src/components/onboarding/create-wallet-success.tsx
+++ b/src/components/onboarding/create-wallet-success.tsx
@@ -71,7 +71,7 @@ export function CreateWalletSuccess({
type="button"
onClick={onBackup}
className={cn(
- 'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium text-white transition-colors',
+ 'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium text-primary-foreground transition-colors',
'bg-primary hover:bg-primary/90',
)}
>
@@ -87,7 +87,7 @@ export function CreateWalletSuccess({
'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium transition-colors',
skipBackup && onBackup
? 'border-border text-foreground hover:bg-muted border'
- : 'bg-primary hover:bg-primary/90 text-white',
+ : 'bg-primary hover:bg-primary/90 text-primary-foreground',
)}
>
{skipBackup && onBackup ? t('create.success.backupLater') : t('create.success.enterWallet')}
diff --git a/src/components/onboarding/import-wallet-success.tsx b/src/components/onboarding/import-wallet-success.tsx
index f178e4dc..f72a0d13 100644
--- a/src/components/onboarding/import-wallet-success.tsx
+++ b/src/components/onboarding/import-wallet-success.tsx
@@ -50,7 +50,7 @@ export function ImportWalletSuccess({
onClick={onEnterWallet}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium transition-colors',
- 'bg-primary hover:bg-primary/90 text-white',
+ 'bg-primary hover:bg-primary/90 text-primary-foreground',
)}
>
{t('import.success.enterWallet')}
diff --git a/src/components/onboarding/recover-wallet-form.tsx b/src/components/onboarding/recover-wallet-form.tsx
index 1a370ce8..363199da 100644
--- a/src/components/onboarding/recover-wallet-form.tsx
+++ b/src/components/onboarding/recover-wallet-form.tsx
@@ -250,7 +250,7 @@ export function RecoverWalletForm({ onSubmit, isSubmitting = false, className }:
data-testid="continue-button"
disabled={!validation.isValid || isSubmitting}
className={cn(
- 'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium text-white transition-colors',
+ 'flex w-full items-center justify-center gap-2 rounded-full py-3 font-medium text-primary-foreground transition-colors',
'bg-primary hover:bg-primary/90',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
diff --git a/src/components/transaction/tx-status-display.tsx b/src/components/transaction/tx-status-display.tsx
index f2be31b8..e2b5e4b3 100644
--- a/src/components/transaction/tx-status-display.tsx
+++ b/src/components/transaction/tx-status-display.tsx
@@ -217,7 +217,7 @@ export function TxStatusDisplay({
onClick={onDone}
data-testid="tx-status-done-button"
className={cn(
- "w-full rounded-full py-3 font-medium text-white transition-colors",
+ "w-full rounded-full py-3 font-medium text-primary-foreground transition-colors",
"bg-primary hover:bg-primary/90"
)}
>
@@ -256,7 +256,7 @@ export function TxStatusDisplay({
onClick={onRetry}
data-testid="tx-status-retry-button"
className={cn(
- "w-full max-w-xs rounded-full py-3 font-medium text-white transition-colors",
+ "w-full max-w-xs rounded-full py-3 font-medium text-primary-foreground transition-colors",
"bg-primary hover:bg-primary/90"
)}
>
diff --git a/src/components/transfer/send-result.tsx b/src/components/transfer/send-result.tsx
index 30b1d7ff..d197d95a 100644
--- a/src/components/transfer/send-result.tsx
+++ b/src/components/transfer/send-result.tsx
@@ -143,7 +143,7 @@ export function SendResult({
@@ -156,7 +156,7 @@ export function SendResult({
className={cn(
'flex items-center justify-center gap-2 rounded-full py-3 font-medium transition-colors',
isSuccess || isPending
- ? 'bg-primary hover:bg-primary/90 text-white'
+ ? 'bg-primary hover:bg-primary/90 text-primary-foreground'
: 'border-border hover:bg-muted border',
)}
>
diff --git a/src/pages/address-book/index.tsx b/src/pages/address-book/index.tsx
index 202776a9..c18d6a56 100644
--- a/src/pages/address-book/index.tsx
+++ b/src/pages/address-book/index.tsx
@@ -162,7 +162,7 @@ export function AddressBookPage() {