From f7fcc33c1b3b99bbc85cfd7052ef42d8f4eecbcb Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 26 Dec 2025 12:39:10 +0800 Subject: [PATCH 1/4] feat(theme): add dark mode lint check and best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scripts/theme-check.ts to validate dark mode usage - Add docs/white-book/02-设计篇/02-视觉设计/dark-mode.md - Update best-practices.md with dark mode rules - Add theme:check and theme:run scripts to package.json - Add theme:run task to turbo.json - Integrate theme:run into CI workflow Rules implemented: - no-bg-as-text: Prevent using background colors as text colors - orphan-foreground: Warn when foreground colors lack matching background - missing-dark-variant: Warn when hardcoded colors lack dark: variants --- .github/workflows/ci.yml | 12 +- .../best-practices.md" | 2 + .../dark-mode.md" | 128 ++++++ package.json | 4 +- scripts/theme-check.ts | 376 ++++++++++++++++++ turbo.json | 6 + 6 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 "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" create mode 100644 scripts/theme-check.ts 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..4feaaa03 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,8 @@ - ❌ 安装新 UI 库 → ✅ shadcn/ui(已集成) - ❌ 新建 CSS → ✅ Tailwind CSS - ❌ text-secondary → ✅ text-muted-foreground 或 bg-secondary text-secondary-foreground(详见白皮书 02-设计篇/02-视觉设计/theme-colors) +- ❌ bg-gray-100 无 dark: 变体 → ✅ bg-gray-100 dark:bg-gray-800 或使用 bg-muted(详见白皮书 02-设计篇/02-视觉设计/dark-mode) +- ❌ text-primary-foreground 无背景 → ✅ 确保元素或父级有 bg-primary - ❌ 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..de6be0ca --- /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,128 @@ +# 暗色模式最佳实践 + +> 确保应用在浅色和暗色主题下都有良好的可读性和视觉体验。 + +--- + +## 核心原则 + +### 1. 使用语义化颜色 Token + +**始终优先使用 shadcn/ui 的语义化颜色变量**,它们会自动适配主题: + +```tsx +// ✅ 正确 - 使用语义化颜色 +
+

辅助文字

+ +
+ +// ❌ 错误 - 硬编码颜色 +
+

辅助文字

+
+``` + +### 2. 配色对必须配套使用 + +每个 shadcn/ui 颜色变量都有对应的 `-foreground` 变体: + +| 背景色 | 前景色 | 使用场景 | +|--------|--------|----------| +| `bg-primary` | `text-primary-foreground` | 主要按钮 | +| `bg-secondary` | `text-secondary-foreground` | 次要按钮 | +| `bg-destructive` | `text-destructive-foreground` | 危险按钮 | +| `bg-muted` | `text-muted-foreground` | 低调区域 | +| `bg-accent` | `text-accent-foreground` | 强调区域 | +| `bg-card` | `text-card-foreground` | 卡片容器 | + +**例外**:`text-muted-foreground` 可以单独用于次要文字,不需要配套 `bg-muted`。 + +### 3. 不要将背景色用作文字色 + +```tsx +// ❌ 错误 - secondary 是背景色 +

看不见的文字

+ +// ✅ 正确 +

次要文字

+// 或用于按钮 + +``` + +--- + +## 硬编码颜色规则 + +### 需要 dark: 变体的情况 + +使用硬编码灰色时,**必须**添加暗色变体: + +```tsx +// ✅ 正确 - 有 dark: 变体 +
...
+

...

+
...
+ +// ❌ 错误 - 缺少 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 - 在渐变背景上 +
+ 半透明装饰 +

白色文字

+
+ ``` + +2. **相机/扫描器界面**: + ```tsx + // ✅ OK - 相机背景 +
+
+ ``` + +3. **开发工具** (`mock-devtools/`):规则可以放宽 + +--- + +## 自动检查 + +运行主题检查: + +```bash +pnpm theme:check +``` + +检查规则: + +| 规则 | 严重性 | 说明 | +|------|--------|------| +| `no-bg-as-text` | Error | 禁止将背景色用作文字色 | +| `orphan-foreground` | Warning | foreground 颜色应有配套背景 | +| `missing-dark-variant` | Warning | 硬编码灰色应有 dark: 变体 | + +--- + +## 相关文档 + +- [主题配色系统](./theme-colors.md) - 配色对的详细说明 +- [shadcn/ui 主题文档](https://ui.shadcn.com/docs/theming) diff --git a/package.json b/package.json index 49809412..cbed76de 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,10 @@ "i18n:check": "turbo run i18n:run --", "i18n:run": "bun scripts/i18n-check.ts", "i18n:validate": "bun scripts/i18n-validate.ts", + "theme:check": "turbo run theme:run --", + "theme:run": "bun scripts/theme-check.ts", "agent": "bun scripts/agent/cli.ts", - "check": "turbo run typecheck:run test:run i18n:run" + "check": "turbo run typecheck:run test:run i18n:run theme:run" }, "dependencies": { "@base-ui/react": "^1.0.0", diff --git a/scripts/theme-check.ts b/scripts/theme-check.ts new file mode 100644 index 00000000..dff0a039 --- /dev/null +++ b/scripts/theme-check.ts @@ -0,0 +1,376 @@ +#!/usr/bin/env bun +/** + * Theme (Dark Mode) Lint Script + * + * Validates that components follow the dark mode best practices: + * 1. Color pairs must be used together (bg-xxx with text-xxx-foreground) + * 2. Hardcoded colors should have dark: variants + * 3. Don't use background colors as text colors + * + * Usage: + * pnpm theme:check # Check for theme issues + * pnpm theme:check --fix # Auto-fix some issues (experimental) + * pnpm theme:check --verbose # Show all checked files + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs' +import { resolve, join, relative } from 'node:path' + +// ==================== Configuration ==================== + +const ROOT = resolve(import.meta.dirname, '..') +const SRC_DIR = join(ROOT, 'src') + +// Files/directories to skip +const SKIP_PATTERNS = [ + '/node_modules/', + '/.git/', // Note: /.git/ not .git to avoid matching .git-worktree + '/dist/', + '/coverage/', + '/__tests__/', + '.stories.', + '.test.', + '/mock-devtools/', // Dev tools can have looser rules +] + +// Contexts where hardcoded colors are acceptable +const ACCEPTABLE_CONTEXTS = [ + 'bg-black', // Scanner/camera overlay + 'bg-white/20', // Semi-transparent on gradient backgrounds + 'bg-white/10', + 'bg-white/30', + 'text-white', // On colored backgrounds (primary, gradient, etc.) + 'text-white/80', + 'text-white/50', +] + +// ==================== Colors ==================== + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + bold: '\x1b[1m', +} + +const log = { + info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), + success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`), + warn: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), + error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), + step: (msg: string) => console.log(`\n${colors.cyan}▸${colors.reset} ${colors.cyan}${msg}${colors.reset}`), + dim: (msg: string) => console.log(`${colors.dim} ${msg}${colors.reset}`), +} + +// ==================== Types ==================== + +interface Issue { + file: string + line: number + column: number + rule: string + message: string + severity: 'error' | 'warning' + suggestion?: string +} + +// ==================== Rules ==================== + +/** + * Rule 1: Don't use background color tokens as text colors + * e.g., text-secondary, text-accent (these are background colors) + */ +function checkBackgroundAsText(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + // Background color tokens that should NOT be used with text- + const bgOnlyTokens = ['secondary', 'accent', 'muted', 'card', 'popover', 'sidebar'] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + for (const token of bgOnlyTokens) { + // Match text-{token} but NOT text-{token}-foreground + const regex = new RegExp(`text-${token}(?!-foreground)\\b`, 'g') + let match: RegExpExecArray | null + + while ((match = regex.exec(line)) !== null) { + issues.push({ + file, + line: i + 1, + column: match.index + 1, + rule: 'no-bg-as-text', + message: `'text-${token}' uses a background color as text color`, + severity: 'error', + suggestion: token === 'secondary' || token === 'muted' + ? `Use 'text-muted-foreground' for secondary text, or 'bg-${token} text-${token}-foreground' for buttons` + : `Use 'text-${token}-foreground' with 'bg-${token}' background`, + }) + } + } + } + + return issues +} + +/** + * Rule 2: Foreground colors (except muted-foreground) should have matching background + * Note: text-muted-foreground can be used standalone for secondary text + */ +function checkOrphanForeground(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + // These foreground colors need their background pair + // Note: muted-foreground is excluded - it's designed for standalone use + const foregroundTokens = ['primary', 'secondary', 'destructive', 'accent', 'card', 'popover'] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + for (const token of foregroundTokens) { + // Look for text-{token}-foreground + if (line.includes(`text-${token}-foreground`)) { + // Check if the same line or nearby context has bg-{token} + const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join(' ') + + if (!context.includes(`bg-${token}`)) { + issues.push({ + file, + line: i + 1, + column: line.indexOf(`text-${token}-foreground`) + 1, + rule: 'orphan-foreground', + message: `'text-${token}-foreground' without visible 'bg-${token}' - verify background is set`, + severity: 'warning', + suggestion: `Ensure 'bg-${token}' is set on this element or a parent`, + }) + } + } + } + } + + return issues +} + +/** + * Rule 3: Hardcoded gray colors should have dark: variants + * e.g., bg-gray-100 should have dark:bg-gray-800 + */ +function checkMissingDarkVariant(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + // Patterns that need dark: variants + const needsDarkVariant = [ + { pattern: /\bbg-gray-(\d+)\b/g, type: 'bg' }, + { pattern: /\bbg-slate-(\d+)\b/g, type: 'bg' }, + { pattern: /\bbg-zinc-(\d+)\b/g, type: 'bg' }, + { pattern: /\btext-gray-(\d+)\b/g, type: 'text' }, + { pattern: /\bborder-gray-(\d+)\b/g, type: 'border' }, + ] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + for (const { pattern, type } of needsDarkVariant) { + let match: RegExpExecArray | null + const patternCopy = new RegExp(pattern.source, pattern.flags) + + while ((match = patternCopy.exec(line)) !== null) { + const fullMatch = match[0] + const shade = match[1] + + // Skip if acceptable context + if (ACCEPTABLE_CONTEXTS.some((ctx) => line.includes(ctx))) { + continue + } + + // Check if there's a dark: variant for this class + const context = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 2)).join(' ') + const darkPattern = new RegExp(`dark:${type}-(?:gray|slate|zinc)-\\d+`) + + if (!darkPattern.test(context)) { + issues.push({ + file, + line: i + 1, + column: match.index + 1, + rule: 'missing-dark-variant', + message: `'${fullMatch}' should have a dark: variant`, + severity: 'warning', + suggestion: `Add 'dark:${type}-gray-${invertShade(shade)}' or use semantic colors like 'bg-muted'`, + }) + } + } + } + } + + return issues +} + +/** + * Rule 4: Success/error states should use semantic colors + */ +function checkSemanticColors(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + // Check for hardcoded success/error colors that should use theme variables + const semanticPatterns = [ + { pattern: /\btext-green-[45]00\b/g, suggestion: 'text-success or text-green-500 (already ok)' }, + { pattern: /\btext-red-[45]00\b/g, suggestion: 'text-destructive' }, + { pattern: /\bbg-green-[45]00\b/g, suggestion: 'bg-success' }, + { pattern: /\bbg-red-[45]00\b/g, suggestion: 'bg-destructive' }, + ] + + // This rule is informational only - semantic colors are preferred but hardcoded ones work + // Skip for now to reduce noise + return issues +} + +// ==================== Utilities ==================== + +function invertShade(shade: string): string { + const shadeMap: Record = { + '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), + ...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/turbo.json b/turbo.json index 931e8b88..bb583fb7 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,12 @@ "outputs": [], "cache": true }, + "theme:run": { + "dependsOn": [], + "inputs": ["src/**/*.tsx", "src/**/*.jsx", "scripts/theme-check.ts"], + "outputs": [], + "cache": true + }, "e2e:run": { "dependsOn": [], "inputs": [ From 1f8625021b64e10e228156c5c012cd7e90607ed6 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 26 Dec 2025 12:47:33 +0800 Subject: [PATCH 2/4] feat(theme): enhance dark mode check with new rules Add two new lint rules: - text-white-on-semantic-bg: Detect text-white on bg-primary/destructive - bg-muted-no-text-color: Detect bg-muted without explicit text color Update documentation with new rules and examples. Current detection: 19 errors, 30 warnings --- .../best-practices.md" | 5 +- .../dark-mode.md" | 38 ++++++- scripts/theme-check.ts | 99 ++++++++++++++++++- 3 files changed, 135 insertions(+), 7 deletions(-) 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 4feaaa03..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,8 +12,9 @@ - ❌ 安装新 UI 库 → ✅ shadcn/ui(已集成) - ❌ 新建 CSS → ✅ Tailwind CSS - ❌ text-secondary → ✅ text-muted-foreground 或 bg-secondary text-secondary-foreground(详见白皮书 02-设计篇/02-视觉设计/theme-colors) -- ❌ bg-gray-100 无 dark: 变体 → ✅ bg-gray-100 dark:bg-gray-800 或使用 bg-muted(详见白皮书 02-设计篇/02-视觉设计/dark-mode) -- ❌ text-primary-foreground 无背景 → ✅ 确保元素或父级有 bg-primary +- ❌ 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" index de6be0ca..f872505e 100644 --- "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" @@ -38,7 +38,19 @@ **例外**:`text-muted-foreground` 可以单独用于次要文字,不需要配套 `bg-muted`。 -### 3. 不要将背景色用作文字色 +### 3. 禁止 `bg-primary text-white` + +```tsx +// ❌ 错误 - text-white 在暗色模式下不会变色 + + +// ✅ 正确 - text-primary-foreground 会自动适配主题 + +``` + +**原因**:在暗色模式下,`--primary` 变亮而 `--primary-foreground` 变暗,保持对比度。`text-white` 始终是白色,在亮色的 `bg-primary` 上可能对比度不足。 + +### 4. 不要将背景色用作文字色 ```tsx // ❌ 错误 - secondary 是背景色 @@ -50,6 +62,20 @@ ``` +### 5. `bg-muted` 必须指定文字颜色 + +```tsx +// ❌ 错误 - 没有文字颜色,可能继承错误的颜色 +
+ 这段文字可能不可见 +
+ +// ✅ 正确 +
+ 清晰可见 +
+``` + --- ## 硬编码颜色规则 @@ -100,7 +126,13 @@ ``` -3. **开发工具** (`mock-devtools/`):规则可以放宽 +3. **固定颜色背景上的白字**(如 `bg-green-500`、`bg-red-500`): + ```tsx + // ✅ OK - 固定颜色背景 +
成功
+ ``` + +4. **开发工具** (`mock-devtools/`):规则可以放宽 --- @@ -117,7 +149,9 @@ pnpm theme:check | 规则 | 严重性 | 说明 | |------|--------|------| | `no-bg-as-text` | Error | 禁止将背景色用作文字色 | +| `text-white-on-semantic-bg` | Error | 禁止在语义背景色上使用 text-white | | `orphan-foreground` | Warning | foreground 颜色应有配套背景 | +| `bg-muted-no-text-color` | Warning | bg-muted 需要明确的文字颜色 | | `missing-dark-variant` | Warning | 硬编码灰色应有 dark: 变体 | --- diff --git a/scripts/theme-check.ts b/scripts/theme-check.ts index dff0a039..7340740e 100644 --- a/scripts/theme-check.ts +++ b/scripts/theme-check.ts @@ -39,9 +39,19 @@ const ACCEPTABLE_CONTEXTS = [ 'bg-white/20', // Semi-transparent on gradient backgrounds 'bg-white/10', 'bg-white/30', - 'text-white', // On colored backgrounds (primary, gradient, etc.) - 'text-white/80', + 'text-white/80', // Semi-transparent white on colored backgrounds 'text-white/50', + 'bg-gradient-', // Gradient backgrounds +] + +// Backgrounds that allow text-white (these don't change much in dark mode) +const ALLOWS_TEXT_WHITE = [ + 'bg-gradient-', + 'bg-green-', + 'bg-red-', + 'bg-blue-', + 'bg-orange-', + 'bg-black', ] // ==================== Colors ==================== @@ -213,7 +223,88 @@ function checkMissingDarkVariant(content: string, file: string): Issue[] { } /** - * Rule 4: Success/error states should use semantic colors + * Rule 4: bg-primary/destructive should use text-xxx-foreground, not text-white + * In dark mode, primary-foreground changes to dark color while text-white stays white + */ +function checkTextWhiteOnSemanticBg(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + // Semantic backgrounds that need their foreground pair, not text-white + const semanticBgs = ['primary', 'destructive', 'secondary'] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check if line has text-white + if (!line.includes('text-white')) continue + + // Skip if text-white is with acceptable backgrounds + if (ALLOWS_TEXT_WHITE.some((bg) => line.includes(bg))) continue + + // Check if this line or nearby context has a semantic background + const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join(' ') + + for (const bg of semanticBgs) { + if (context.includes(`bg-${bg}`) && !context.includes(`text-${bg}-foreground`)) { + issues.push({ + file, + line: i + 1, + column: line.indexOf('text-white') + 1, + rule: 'text-white-on-semantic-bg', + message: `'text-white' with 'bg-${bg}' - use 'text-${bg}-foreground' instead`, + severity: 'error', + suggestion: `Replace 'text-white' with 'text-${bg}-foreground' for proper dark mode support`, + }) + break // Only report once per line + } + } + } + + return issues +} + +/** + * Rule 5: bg-muted without text color may be invisible in dark mode + */ +function checkBgMutedWithoutText(content: string, file: string): Issue[] { + const issues: Issue[] = [] + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check if line has bg-muted (not bg-muted/xx) + const bgMutedMatch = line.match(/\bbg-muted\b(?!\/|-)/) + if (!bgMutedMatch) continue + + // Check if there's any text color in the same className context + const context = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 2)).join(' ') + + // Look for text-* colors (except text-white which would be wrong) + const hasTextColor = /\btext-(?:muted-foreground|foreground|primary|destructive|green-|red-|blue-|gray-|slate-|zinc-)/.test(context) + + // Also check if it's used purely as decorative/layout (no text content expected) + const isDecorative = /(?:size-|w-|h-)\d|aspect-square|rounded-full.*(?:p-\d|size-)/.test(context) + + if (!hasTextColor && !isDecorative) { + issues.push({ + file, + line: i + 1, + column: bgMutedMatch.index! + 1, + rule: 'bg-muted-no-text-color', + message: `'bg-muted' without explicit text color - may be invisible in dark mode`, + severity: 'warning', + suggestion: `Add 'text-muted-foreground' or 'text-foreground' for text elements`, + }) + } + } + + return issues +} + +/** + * Rule 6: Success/error states should use semantic colors */ function checkSemanticColors(content: string, file: string): Issue[] { const issues: Issue[] = [] @@ -309,6 +400,8 @@ ${colors.cyan}╔═════════════════════ ...checkBackgroundAsText(content, relPath), ...checkOrphanForeground(content, relPath), ...checkMissingDarkVariant(content, relPath), + ...checkTextWhiteOnSemanticBg(content, relPath), + ...checkBgMutedWithoutText(content, relPath), ...checkSemanticColors(content, relPath), ] From 5cb06296ed4b41f3cf1c066ff0de752309698d2b Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 26 Dec 2025 12:58:07 +0800 Subject: [PATCH 3/4] fix(theme): replace text-white with semantic foreground colors Fix 19 dark mode errors by replacing text-white with text-xxx-foreground: - bg-primary text-white -> bg-primary text-primary-foreground - bg-destructive text-white -> bg-destructive text-destructive-foreground - bg-muted -> bg-muted text-muted-foreground (where needed) Also improve theme-check.ts to reduce false positives: - Skip hover/focus/active-only bg-muted patterns - Skip peer/group conditional foreground styles - Expand context range for better detection Files modified: - 15 component/page files with dark mode fixes - scripts/theme-check.ts with improved detection Result: 0 errors, 5 warnings (down from 19 errors, 30 warnings) --- scripts/theme-check.ts | 90 ++++++++++++------- src/components/layout/tab-bar.tsx | 2 +- .../onboarding/create-wallet-success.tsx | 4 +- .../onboarding/import-wallet-success.tsx | 2 +- .../onboarding/recover-wallet-form.tsx | 2 +- .../transaction/tx-status-display.tsx | 4 +- src/components/transfer/send-result.tsx | 4 +- src/pages/address-book/index.tsx | 2 +- src/pages/wallet/list.tsx | 4 +- .../activities/sheets/ContactEditJob.tsx | 2 +- .../activities/sheets/MnemonicOptionsJob.tsx | 2 +- .../activities/sheets/SetTwoStepSecretJob.tsx | 2 +- .../activities/sheets/TransferConfirmJob.tsx | 2 +- .../sheets/TransferWalletLockJob.tsx | 2 +- .../sheets/TwoStepSecretConfirmJob.tsx | 2 +- .../activities/sheets/WalletRenameJob.tsx | 2 +- 16 files changed, 76 insertions(+), 52 deletions(-) diff --git a/scripts/theme-check.ts b/scripts/theme-check.ts index 7340740e..9419f414 100644 --- a/scripts/theme-check.ts +++ b/scripts/theme-check.ts @@ -131,6 +131,7 @@ function checkBackgroundAsText(content: string, file: string): Issue[] { /** * Rule 2: Foreground colors (except muted-foreground) should have matching background * Note: text-muted-foreground can be used standalone for secondary text + * Skip peer/group conditional styles as they pair with conditional backgrounds */ function checkOrphanForeground(content: string, file: string): Issue[] { const issues: Issue[] = [] @@ -144,22 +145,33 @@ function checkOrphanForeground(content: string, file: string): Issue[] { const line = lines[i] for (const token of foregroundTokens) { - // Look for text-{token}-foreground - if (line.includes(`text-${token}-foreground`)) { - // Check if the same line or nearby context has bg-{token} - const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join(' ') + // Look for text-{token}-foreground (including variants like /70) + const fgMatch = line.match(new RegExp(`text-${token}-foreground`)) + if (!fgMatch) continue + + // Skip conditional foreground styles (peer-*, group-*, data-*) + // These pair with conditional bg-* styles + if (new RegExp(`(peer-|group-|data-)\\S*text-${token}-foreground`).test(line)) { + continue + } - if (!context.includes(`bg-${token}`)) { - issues.push({ - file, - line: i + 1, - column: line.indexOf(`text-${token}-foreground`) + 1, - rule: 'orphan-foreground', - message: `'text-${token}-foreground' without visible 'bg-${token}' - verify background is set`, - severity: 'warning', - suggestion: `Ensure 'bg-${token}' is set on this element or a parent`, - }) - } + // Check if the same line or nearby context has bg-{token} + // Expand context to 5 lines before and after + const context = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 6)).join(' ') + + // Check for bg-{token} including conditional variants + const hasBgToken = new RegExp(`bg-${token}|peer-\\S*:bg-${token}|data-\\S*:bg-${token}|group-\\S*:bg-${token}`).test(context) + + if (!hasBgToken) { + issues.push({ + file, + line: i + 1, + column: fgMatch.index! + 1, + rule: 'orphan-foreground', + message: `'text-${token}-foreground' without visible 'bg-${token}' - verify background is set`, + severity: 'warning', + suggestion: `Ensure 'bg-${token}' is set on this element or a parent`, + }) } } } @@ -266,6 +278,7 @@ function checkTextWhiteOnSemanticBg(content: string, file: string): Issue[] { /** * Rule 5: bg-muted without text color may be invisible in dark mode + * Only warn if the element seems to have direct text content */ function checkBgMutedWithoutText(content: string, file: string): Issue[] { const issues: Issue[] = [] @@ -274,30 +287,41 @@ function checkBgMutedWithoutText(content: string, file: string): Issue[] { for (let i = 0; i < lines.length; i++) { const line = lines[i] - // Check if line has bg-muted (not bg-muted/xx) - const bgMutedMatch = line.match(/\bbg-muted\b(?!\/|-)/) + // Check if line has bg-muted (not bg-muted/xx which is semi-transparent) + const bgMutedMatch = line.match(/\bbg-muted\b(?!\/)/) if (!bgMutedMatch) continue - // Check if there's any text color in the same className context - const context = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 2)).join(' ') + // Skip if it's hover/focus/active state only + if (/hover:bg-muted|focus:bg-muted|active:bg-muted/.test(line) && !/\sbg-muted\b/.test(line)) { + continue + } + + // Check if there's any text color in the same className context (within 3 lines) + const context = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 4)).join(' ') - // Look for text-* colors (except text-white which would be wrong) - const hasTextColor = /\btext-(?:muted-foreground|foreground|primary|destructive|green-|red-|blue-|gray-|slate-|zinc-)/.test(context) + // Look for text-* colors + const hasTextColor = /\btext-(?:muted-foreground|foreground|primary|destructive|green-|red-|blue-|gray-|slate-|zinc-|white)/.test(context) // Also check if it's used purely as decorative/layout (no text content expected) - const isDecorative = /(?:size-|w-|h-)\d|aspect-square|rounded-full.*(?:p-\d|size-)/.test(context) + const isDecorative = /(?:size-|w-|h-)\d+.*(?:rounded|aspect)|flex.*items-center.*justify-center.*(?:rounded|size-)/.test(context) - if (!hasTextColor && !isDecorative) { - issues.push({ - file, - line: i + 1, - column: bgMutedMatch.index! + 1, - rule: 'bg-muted-no-text-color', - message: `'bg-muted' without explicit text color - may be invisible in dark mode`, - severity: 'warning', - suggestion: `Add 'text-muted-foreground' or 'text-foreground' for text elements`, - }) - } + // Skip if has text color or is decorative + if (hasTextColor || isDecorative) continue + + // Skip if it's a container with child elements that likely have their own text colors + const isContainer = /className.*bg-muted.*>\s*$| {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() {