@soulbatical/tetra-dev-toolkit 1.20.18 → 1.20.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Dark Mode Autofix
|
|
5
|
+
*
|
|
6
|
+
* Mechanically replaces hardcoded Tailwind colors with Tetra design tokens.
|
|
7
|
+
* Run on a clean git branch so you can easily revert.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node tetra-dark-mode-fix.js # Dry run (shows what would change)
|
|
11
|
+
* node tetra-dark-mode-fix.js --apply # Apply changes
|
|
12
|
+
* node tetra-dark-mode-fix.js --apply --verbose # Apply + show each replacement
|
|
13
|
+
*
|
|
14
|
+
* Strategy:
|
|
15
|
+
* 1. PAIRED replacements first (bg-white dark:bg-gray-900 → bg-background)
|
|
16
|
+
* 2. NEUTRAL unpaired (bg-white → bg-background, text-gray-500 → text-muted-foreground)
|
|
17
|
+
* 3. SEMANTIC unpaired (bg-green-50 → bg-success-subtle, text-red-600 → text-danger-foreground)
|
|
18
|
+
* 4. HOVER/FOCUS modifiers (hover:bg-gray-100 → hover:bg-accent)
|
|
19
|
+
* 5. Remove orphaned dark: classes that no longer have a light counterpart
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
23
|
+
import { join } from 'path'
|
|
24
|
+
import { glob } from 'glob'
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2)
|
|
27
|
+
const APPLY = args.includes('--apply')
|
|
28
|
+
const VERBOSE = args.includes('--verbose')
|
|
29
|
+
const projectRoot = process.cwd()
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// REPLACEMENT RULES — ordered from most specific to least specific
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* PAIRED rules: match "light-class dark:dark-class" and replace both with a single token.
|
|
37
|
+
* These run FIRST to avoid partial matches.
|
|
38
|
+
*/
|
|
39
|
+
const PAIRED_RULES = [
|
|
40
|
+
// ── Backgrounds ──
|
|
41
|
+
[/\bbg-white\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'bg-background'],
|
|
42
|
+
[/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\s+bg-white\b/g, 'bg-background'],
|
|
43
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-50\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'bg-background'],
|
|
44
|
+
[/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\s+bg-(?:gray|slate|zinc|neutral)-50\b/g, 'bg-background'],
|
|
45
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-100\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\b/g, 'bg-secondary'],
|
|
46
|
+
[/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\s+bg-(?:gray|slate|zinc|neutral)-100\b/g, 'bg-secondary'],
|
|
47
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-200\s+dark:bg-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'bg-muted'],
|
|
48
|
+
[/\bdark:bg-(?:gray|slate|zinc|neutral)-(?:700|800)\s+bg-(?:gray|slate|zinc|neutral)-200\b/g, 'bg-muted'],
|
|
49
|
+
// ── Text ──
|
|
50
|
+
[/\btext-(?:gray|slate|zinc|neutral)-900\s+dark:text-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'text-foreground'],
|
|
51
|
+
[/\bdark:text-(?:gray|slate|zinc|neutral)-(?:50|100)\s+text-(?:gray|slate|zinc|neutral)-900\b/g, 'text-foreground'],
|
|
52
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:700|800)\s+dark:text-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'text-foreground'],
|
|
53
|
+
[/\bdark:text-(?:gray|slate|zinc|neutral)-(?:200|300)\s+text-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'text-foreground'],
|
|
54
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:500|600)\s+dark:text-(?:gray|slate|zinc|neutral)-(?:300|400)\b/g, 'text-muted-foreground'],
|
|
55
|
+
[/\bdark:text-(?:gray|slate|zinc|neutral)-(?:300|400)\s+text-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'text-muted-foreground'],
|
|
56
|
+
// ── Borders ──
|
|
57
|
+
[/\bborder-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:border-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'border-border'],
|
|
58
|
+
[/\bdark:border-(?:gray|slate|zinc|neutral)-(?:600|700|800)\s+border-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'border-border'],
|
|
59
|
+
// ── Divide ──
|
|
60
|
+
[/\bdivide-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:divide-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'divide-border'],
|
|
61
|
+
// ── Ring ──
|
|
62
|
+
[/\bring-(?:gray|slate|zinc|neutral)-(?:200|300)\s+dark:ring-(?:gray|slate|zinc|neutral)-(?:600|700|800)\b/g, 'ring-ring'],
|
|
63
|
+
// ── Hover backgrounds ──
|
|
64
|
+
[/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\s+dark:hover:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\b/g, 'hover:bg-accent'],
|
|
65
|
+
[/\bdark:hover:bg-(?:gray|slate|zinc|neutral)-(?:800|900)\s+hover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:bg-accent'],
|
|
66
|
+
// ── Hover text ──
|
|
67
|
+
[/\bhover:text-(?:gray|slate|zinc|neutral)-900\s+dark:hover:text-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:text-foreground'],
|
|
68
|
+
// ── Hover borders ──
|
|
69
|
+
[/\bhover:border-(?:gray|slate|zinc|neutral)-(?:300|400)\s+dark:hover:border-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'hover:border-border'],
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* UNPAIRED neutral rules: single class → token.
|
|
74
|
+
* Run AFTER paired rules so we don't break paired sets.
|
|
75
|
+
*/
|
|
76
|
+
const UNPAIRED_NEUTRAL_RULES = [
|
|
77
|
+
// Backgrounds (including opacity suffixes like bg-black/50)
|
|
78
|
+
[/\bbg-white\b/g, 'bg-background'],
|
|
79
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-50\b/g, 'bg-background'],
|
|
80
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-100\b/g, 'bg-secondary'],
|
|
81
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-200\b/g, 'bg-muted'],
|
|
82
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-(?:300|400|500|600|700)\b/g, 'bg-muted'],
|
|
83
|
+
[/\bbg-(?:gray|slate|zinc|neutral)-(?:800|900|950)\b/g, 'bg-foreground'],
|
|
84
|
+
[/\bbg-black(?:\/\d+)?\b/g, 'bg-foreground/50'],
|
|
85
|
+
// Text (including white on dark surfaces)
|
|
86
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:900|950)\b/g, 'text-foreground'],
|
|
87
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:700|800)\b/g, 'text-foreground'],
|
|
88
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'text-muted-foreground'],
|
|
89
|
+
[/\btext-(?:gray|slate|zinc|neutral)-(?:100|200|300|400)\b/g, 'text-muted-foreground'],
|
|
90
|
+
[/\btext-black\b/g, 'text-foreground'],
|
|
91
|
+
[/\btext-white\b/g, 'text-primary-foreground'],
|
|
92
|
+
// Borders
|
|
93
|
+
[/\bborder-(?:gray|slate|zinc|neutral)-\d+\b/g, 'border-border'],
|
|
94
|
+
// Divide (all shades)
|
|
95
|
+
[/\bdivide-(?:gray|slate|zinc|neutral)-\d+\b/g, 'divide-border'],
|
|
96
|
+
// Ring
|
|
97
|
+
[/\bring-(?:gray|slate|zinc|neutral)-\d+\b/g, 'ring-ring'],
|
|
98
|
+
// Placeholder
|
|
99
|
+
[/\bplaceholder-(?:gray|slate|zinc|neutral)-\d+\b/g, 'placeholder:text-muted-foreground'],
|
|
100
|
+
// Fill / Stroke
|
|
101
|
+
[/\bfill-(?:gray|slate|zinc|neutral)-\d+\b/g, 'fill-muted-foreground'],
|
|
102
|
+
[/\bstroke-(?:gray|slate|zinc|neutral)-\d+\b/g, 'stroke-border'],
|
|
103
|
+
// Hover
|
|
104
|
+
[/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:50|100)\b/g, 'hover:bg-accent'],
|
|
105
|
+
[/\bhover:bg-(?:gray|slate|zinc|neutral)-(?:200|300)\b/g, 'hover:bg-accent'],
|
|
106
|
+
[/\bhover:text-(?:gray|slate|zinc|neutral)-(?:700|800|900)\b/g, 'hover:text-foreground'],
|
|
107
|
+
[/\bhover:text-(?:gray|slate|zinc|neutral)-(?:500|600)\b/g, 'hover:text-muted-foreground'],
|
|
108
|
+
[/\bhover:border-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'hover:border-border'],
|
|
109
|
+
// Focus
|
|
110
|
+
[/\bfocus:ring-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'focus:ring-ring'],
|
|
111
|
+
[/\bfocus:border-(?:gray|slate|zinc|neutral)-(?:300|400|500)\b/g, 'focus:border-ring'],
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* SEMANTIC color rules: map colored classes to semantic tokens.
|
|
116
|
+
*/
|
|
117
|
+
const SEMANTIC_RULES = [
|
|
118
|
+
// ── Success (green/emerald) ──
|
|
119
|
+
[/\bbg-(?:green|emerald)-(?:50|100)\b/g, 'bg-success-subtle'],
|
|
120
|
+
[/\bbg-(?:green|emerald)-(?:500|600)\b/g, 'bg-success'],
|
|
121
|
+
[/\btext-(?:green|emerald)-(?:400|500|600|700|800|900)\b/g, 'text-success-foreground'],
|
|
122
|
+
[/\bborder-(?:green|emerald)-(?:200|300|400)\b/g, 'border-success'],
|
|
123
|
+
// ── Danger (red/rose) ──
|
|
124
|
+
[/\bbg-(?:red|rose)-(?:50|100)\b/g, 'bg-danger-subtle'],
|
|
125
|
+
[/\bbg-(?:red|rose)-(?:500|600)\b/g, 'bg-destructive'],
|
|
126
|
+
[/\btext-(?:red|rose)-(?:400|500|600|700|800|900)\b/g, 'text-danger-foreground'],
|
|
127
|
+
[/\bborder-(?:red|rose)-(?:200|300|400)\b/g, 'border-destructive'],
|
|
128
|
+
// ── Warning (amber/yellow/orange) ──
|
|
129
|
+
[/\bbg-(?:amber|yellow|orange)-(?:50|100)\b/g, 'bg-warning-subtle'],
|
|
130
|
+
[/\bbg-(?:amber|yellow|orange)-(?:500|600)\b/g, 'bg-warning'],
|
|
131
|
+
[/\btext-(?:amber|yellow|orange)-(?:400|500|600|700|800|900)\b/g, 'text-warning-foreground'],
|
|
132
|
+
[/\bborder-(?:amber|yellow|orange)-(?:200|300|400)\b/g, 'border-warning'],
|
|
133
|
+
// ── Info (blue/sky/cyan) ──
|
|
134
|
+
[/\bbg-(?:blue|sky|cyan)-(?:50|100)\b/g, 'bg-info-subtle'],
|
|
135
|
+
[/\bbg-(?:blue|sky|cyan)-(?:500|600)\b/g, 'bg-info'],
|
|
136
|
+
[/\btext-(?:blue|sky|cyan)-(?:400|500|600|700|800|900)\b/g, 'text-info-foreground'],
|
|
137
|
+
[/\bborder-(?:blue|sky|cyan)-(?:200|300|400)\b/g, 'border-info'],
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* BRAND color rules: map indigo/violet/purple to brand tokens.
|
|
142
|
+
* Projects override --tetra-brand-* in their globals.css.
|
|
143
|
+
*/
|
|
144
|
+
const BRAND_RULES = [
|
|
145
|
+
// ── Primary brand (indigo/violet → brand) ──
|
|
146
|
+
[/\bbg-(?:indigo|violet)-(?:50|100)\b/g, 'bg-brand-subtle'],
|
|
147
|
+
[/\bbg-(?:indigo|violet)-(?:500|600)\b/g, 'bg-brand'],
|
|
148
|
+
[/\bbg-(?:indigo|violet)-(?:700|800)\b/g, 'bg-brand-hover'],
|
|
149
|
+
[/\bbg-(?:indigo|violet)-(?:900|950)(?:\/\d+)?\b/g, 'bg-brand/10'],
|
|
150
|
+
[/\btext-(?:indigo|violet)-(?:400|500)\b/g, 'text-brand-text'],
|
|
151
|
+
[/\btext-(?:indigo|violet)-(?:600|700)\b/g, 'text-brand-text'],
|
|
152
|
+
[/\btext-(?:indigo|violet)-(?:800|900)\b/g, 'text-brand-text'],
|
|
153
|
+
[/\bborder-(?:indigo|violet)-(?:200|300)\b/g, 'border-brand/30'],
|
|
154
|
+
[/\bborder-(?:indigo|violet)-(?:400|500)\b/g, 'border-brand'],
|
|
155
|
+
[/\bborder-(?:indigo|violet)-(?:600)\b/g, 'border-brand'],
|
|
156
|
+
[/\bring-(?:indigo|violet)-(?:100|200)\b/g, 'ring-brand/20'],
|
|
157
|
+
[/\bring-(?:indigo|violet)-(?:500|600)\b/g, 'ring-brand-ring'],
|
|
158
|
+
[/\bhover:bg-(?:indigo|violet)-(?:500|600|700)\b/g, 'hover:bg-brand-hover'],
|
|
159
|
+
[/\bhover:text-(?:indigo|violet)-(?:300|400)\b/g, 'hover:text-brand-text'],
|
|
160
|
+
[/\bfocus:ring-(?:indigo|violet)-(?:500|600)\b/g, 'focus:ring-brand-ring'],
|
|
161
|
+
[/\bfocus:border-(?:indigo|violet)-(?:500|600)\b/g, 'focus:border-brand'],
|
|
162
|
+
// ── Alt brand (purple/fuchsia → brand-alt) ──
|
|
163
|
+
[/\bbg-(?:purple|fuchsia)-(?:50|100)\b/g, 'bg-brand-alt-subtle'],
|
|
164
|
+
[/\bbg-(?:purple|fuchsia)-(?:500|600)\b/g, 'bg-brand-alt'],
|
|
165
|
+
[/\bbg-(?:purple|fuchsia)-(?:700|800)\b/g, 'bg-brand-alt-hover'],
|
|
166
|
+
[/\bbg-(?:purple|fuchsia)-(?:900)(?:\/\d+)?\b/g, 'bg-brand-alt/10'],
|
|
167
|
+
[/\btext-(?:purple|fuchsia)-(?:400|500)\b/g, 'text-brand-alt-text'],
|
|
168
|
+
[/\btext-(?:purple|fuchsia)-(?:600|700)\b/g, 'text-brand-alt-text'],
|
|
169
|
+
[/\btext-(?:purple|fuchsia)-(?:800|900)\b/g, 'text-brand-alt-text'],
|
|
170
|
+
[/\bborder-(?:purple|fuchsia)-(?:200|300)\b/g, 'border-brand-alt/30'],
|
|
171
|
+
[/\bborder-(?:purple|fuchsia)-(?:500)\b/g, 'border-brand-alt'],
|
|
172
|
+
[/\bring-(?:purple|fuchsia)-(?:500|600)\b/g, 'ring-brand-alt'],
|
|
173
|
+
[/\bhover:bg-(?:purple|fuchsia)-(?:500|600|700)\b/g, 'hover:bg-brand-alt-hover'],
|
|
174
|
+
[/\bfocus:ring-(?:purple|fuchsia)-(?:500|600)\b/g, 'focus:ring-brand-alt'],
|
|
175
|
+
// ── Platform A: pink (Instagram, etc.) ──
|
|
176
|
+
[/\bbg-pink-(?:50|100)\b/g, 'bg-platform-a-subtle'],
|
|
177
|
+
[/\bbg-pink-(?:400|500|600)\b/g, 'bg-platform-a'],
|
|
178
|
+
[/\bbg-pink-(?:900)(?:\/\d+)?\b/g, 'bg-platform-a/10'],
|
|
179
|
+
[/\btext-pink-(?:400|500)\b/g, 'text-platform-a-text'],
|
|
180
|
+
[/\btext-pink-(?:600|700|800)\b/g, 'text-platform-a-text'],
|
|
181
|
+
[/\bborder-pink-(?:200|300)\b/g, 'border-platform-a/30'],
|
|
182
|
+
[/\bborder-pink-(?:500)\b/g, 'border-platform-a'],
|
|
183
|
+
// ── Platform B: teal (TikTok, etc.) ──
|
|
184
|
+
[/\bbg-teal-(?:50|100)\b/g, 'bg-platform-b-subtle'],
|
|
185
|
+
[/\bbg-teal-(?:400|500|600)\b/g, 'bg-platform-b'],
|
|
186
|
+
[/\bbg-teal-(?:900)(?:\/\d+)?\b/g, 'bg-platform-b/10'],
|
|
187
|
+
[/\btext-teal-(?:400|500)\b/g, 'text-platform-b-text'],
|
|
188
|
+
[/\btext-teal-(?:600|700|800)\b/g, 'text-platform-b-text'],
|
|
189
|
+
[/\bborder-teal-(?:200|300)\b/g, 'border-platform-b/30'],
|
|
190
|
+
[/\bborder-teal-(?:500)\b/g, 'border-platform-b'],
|
|
191
|
+
// ── Platform C: orange/amber (Reddit, warnings, etc.) ──
|
|
192
|
+
[/\bbg-(?:orange|amber)-(?:50|100)\b/g, 'bg-platform-c-subtle'],
|
|
193
|
+
[/\bbg-(?:orange|amber)-(?:400|500|600)\b/g, 'bg-platform-c'],
|
|
194
|
+
[/\bbg-(?:orange|amber)-(?:700|800|900)(?:\/\d+)?\b/g, 'bg-platform-c/10'],
|
|
195
|
+
[/\bbg-(?:orange|amber)-(?:950)(?:\/\d+)?\b/g, 'bg-platform-c/10'],
|
|
196
|
+
[/\btext-(?:orange|amber)-(?:400|500|600)\b/g, 'text-platform-c-text'],
|
|
197
|
+
[/\btext-(?:orange|amber)-(?:700|800)\b/g, 'text-platform-c-text'],
|
|
198
|
+
[/\bborder-(?:orange|amber)-(?:200|300|500)\b/g, 'border-platform-c/30'],
|
|
199
|
+
[/\bborder-(?:orange|amber)-(?:800)\b/g, 'border-platform-c'],
|
|
200
|
+
[/\bring-(?:orange|amber)-(?:300|400|500)(?:\/\d+)?\b/g, 'ring-platform-c/40'],
|
|
201
|
+
[/\bhover:bg-(?:orange|amber)-(?:200|700)\b/g, 'hover:bg-platform-c-subtle'],
|
|
202
|
+
[/\bhover:border-(?:orange|amber)-(?:500)(?:\/\d+)?\b/g, 'hover:border-platform-c'],
|
|
203
|
+
// ── Platform D: purple (decorative, Twitch, etc.) ──
|
|
204
|
+
[/\bborder-purple-(?:400|600)\b/g, 'border-platform-d'],
|
|
205
|
+
[/\bbg-purple-(?:200|400)\b/g, 'bg-platform-d-subtle'],
|
|
206
|
+
[/\bhover:bg-purple-(?:200)\b/g, 'hover:bg-platform-d-subtle'],
|
|
207
|
+
[/\bhover:text-purple-(?:200|300)\b/g, 'hover:text-platform-d-text'],
|
|
208
|
+
[/\bhover:border-purple-(?:400)\b/g, 'hover:border-platform-d'],
|
|
209
|
+
// ── Remaining: emerald borders/hovers ──
|
|
210
|
+
[/\bborder-emerald-(?:500)\b/g, 'border-success'],
|
|
211
|
+
[/\bhover:border-emerald-(?:500)(?:\/\d+)?\b/g, 'hover:border-success'],
|
|
212
|
+
[/\bhover:bg-emerald-(?:900)(?:\/\d+)?\b/g, 'hover:bg-success/10'],
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* ORPHAN dark: cleanup — remove dark: classes whose light counterpart was already replaced.
|
|
217
|
+
* These are now redundant because tokens handle dark mode automatically.
|
|
218
|
+
*/
|
|
219
|
+
const ORPHAN_DARK_RULES = [
|
|
220
|
+
// Backgrounds
|
|
221
|
+
[/\s*\bdark:bg-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
|
|
222
|
+
[/\s*\bdark:bg-(?:gray|slate|zinc|neutral)-(?:900|950)\/\d+\b/g, ''],
|
|
223
|
+
// Text
|
|
224
|
+
[/\s*\bdark:text-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
|
|
225
|
+
// Borders
|
|
226
|
+
[/\s*\bdark:border-(?:gray|slate|zinc|neutral)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/g, ''],
|
|
227
|
+
// Divide
|
|
228
|
+
[/\s*\bdark:divide-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
229
|
+
// Ring
|
|
230
|
+
[/\s*\bdark:ring-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
231
|
+
// Hover
|
|
232
|
+
[/\s*\bdark:hover:bg-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
233
|
+
[/\s*\bdark:hover:text-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
234
|
+
[/\s*\bdark:hover:border-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
235
|
+
// Focus
|
|
236
|
+
[/\s*\bdark:focus:ring-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
237
|
+
[/\s*\bdark:focus:border-(?:gray|slate|zinc|neutral)-\d+\b/g, ''],
|
|
238
|
+
// Semantic dark overrides
|
|
239
|
+
[/\s*\bdark:bg-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+(?:\/\d+)?\b/g, ''],
|
|
240
|
+
[/\s*\bdark:text-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+\b/g, ''],
|
|
241
|
+
[/\s*\bdark:border-(?:green|emerald|red|rose|amber|yellow|orange|blue|sky|cyan)-\d+\b/g, ''],
|
|
242
|
+
// Brand dark overrides
|
|
243
|
+
[/\s*\bdark:bg-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
|
|
244
|
+
[/\s*\bdark:text-(?:indigo|violet|purple|fuchsia)-\d+\b/g, ''],
|
|
245
|
+
[/\s*\bdark:border-(?:indigo|violet|purple|fuchsia)-\d+\b/g, ''],
|
|
246
|
+
[/\s*\bdark:ring-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
|
|
247
|
+
[/\s*\bdark:hover:bg-(?:indigo|violet|purple|fuchsia)-\d+(?:\/\d+)?\b/g, ''],
|
|
248
|
+
// Platform dark overrides
|
|
249
|
+
[/\s*\bdark:(?:bg|text|border|ring)-(?:pink|teal|orange|amber)-\d+(?:\/\d+)?\b/g, ''],
|
|
250
|
+
[/\s*\bdark:hover:(?:bg|text|border)-(?:pink|teal|orange|amber)-\d+(?:\/\d+)?\b/g, ''],
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
// ============================================================================
|
|
254
|
+
// APPLY
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
257
|
+
const frontendDir = join(projectRoot, 'frontend', 'src')
|
|
258
|
+
const files = [
|
|
259
|
+
...glob.sync('**/*.tsx', { cwd: frontendDir, ignore: ['**/node_modules/**', '**/.next/**'] }),
|
|
260
|
+
...glob.sync('**/*.jsx', { cwd: frontendDir, ignore: ['**/node_modules/**', '**/.next/**'] }),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
let totalReplacements = 0
|
|
264
|
+
let totalFiles = 0
|
|
265
|
+
const stats = { paired: 0, neutral: 0, semantic: 0, brand: 0, orphanDark: 0 }
|
|
266
|
+
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
const fullPath = join(frontendDir, file)
|
|
269
|
+
const original = readFileSync(fullPath, 'utf-8')
|
|
270
|
+
let content = original
|
|
271
|
+
|
|
272
|
+
// Phase 1: Paired replacements
|
|
273
|
+
for (const [re, token] of PAIRED_RULES) {
|
|
274
|
+
re.lastIndex = 0
|
|
275
|
+
content = content.replace(re, (match) => {
|
|
276
|
+
stats.paired++
|
|
277
|
+
if (VERBOSE) console.log(` [paired] ${file}: ${match} → ${token}`)
|
|
278
|
+
return token
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Phase 2: Unpaired neutrals
|
|
283
|
+
for (const [re, token] of UNPAIRED_NEUTRAL_RULES) {
|
|
284
|
+
re.lastIndex = 0
|
|
285
|
+
content = content.replace(re, (match) => {
|
|
286
|
+
stats.neutral++
|
|
287
|
+
if (VERBOSE) console.log(` [neutral] ${file}: ${match} → ${token}`)
|
|
288
|
+
return token
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Phase 3: Semantics
|
|
293
|
+
for (const [re, token] of SEMANTIC_RULES) {
|
|
294
|
+
re.lastIndex = 0
|
|
295
|
+
content = content.replace(re, (match) => {
|
|
296
|
+
stats.semantic++
|
|
297
|
+
if (VERBOSE) console.log(` [semantic] ${file}: ${match} → ${token}`)
|
|
298
|
+
return token
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Phase 3b: Brand colors
|
|
303
|
+
for (const [re, token] of BRAND_RULES) {
|
|
304
|
+
re.lastIndex = 0
|
|
305
|
+
content = content.replace(re, (match) => {
|
|
306
|
+
stats.brand++
|
|
307
|
+
if (VERBOSE) console.log(` [brand] ${file}: ${match} → ${token}`)
|
|
308
|
+
return token
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Phase 4: Orphan dark: cleanup
|
|
313
|
+
for (const [re, replacement] of ORPHAN_DARK_RULES) {
|
|
314
|
+
re.lastIndex = 0
|
|
315
|
+
content = content.replace(re, (match) => {
|
|
316
|
+
stats.orphanDark++
|
|
317
|
+
if (VERBOSE) console.log(` [orphan-dark] ${file}: removed ${match.trim()}`)
|
|
318
|
+
return replacement
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Clean up double/triple spaces left by removals
|
|
323
|
+
content = content.replace(/ +/g, ' ')
|
|
324
|
+
// Clean up space before closing quote
|
|
325
|
+
content = content.replace(/ "/g, '"').replace(/ '/g, "'")
|
|
326
|
+
// But restore intentional space-before-quote in template literals etc.
|
|
327
|
+
// Actually, be conservative — only clean within className attributes
|
|
328
|
+
// This simple approach works for most cases
|
|
329
|
+
|
|
330
|
+
if (content !== original) {
|
|
331
|
+
totalFiles++
|
|
332
|
+
const currentTotal = stats.paired + stats.neutral + stats.semantic + stats.brand + stats.orphanDark
|
|
333
|
+
const replacements = currentTotal - totalReplacements
|
|
334
|
+
totalReplacements = currentTotal
|
|
335
|
+
console.log(`${APPLY ? '✅' : '📋'} ${file} (${replacements} changes)`)
|
|
336
|
+
|
|
337
|
+
if (APPLY) {
|
|
338
|
+
writeFileSync(fullPath, content, 'utf-8')
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log('')
|
|
344
|
+
console.log(`${'='.repeat(60)}`)
|
|
345
|
+
const total = stats.paired + stats.neutral + stats.semantic + stats.brand + stats.orphanDark
|
|
346
|
+
console.log(`${APPLY ? 'APPLIED' : 'DRY RUN'} — ${totalFiles} files, ${total} total replacements`)
|
|
347
|
+
console.log(` Paired (light+dark → token): ${stats.paired}`)
|
|
348
|
+
console.log(` Neutral (single → token): ${stats.neutral}`)
|
|
349
|
+
console.log(` Semantic (color → token): ${stats.semantic}`)
|
|
350
|
+
console.log(` Brand (indigo/purple → token): ${stats.brand}`)
|
|
351
|
+
console.log(` Orphan dark: removed: ${stats.orphanDark}`)
|
|
352
|
+
console.log('')
|
|
353
|
+
if (!APPLY) {
|
|
354
|
+
console.log('Run with --apply to write changes. Make sure you are on a clean branch!')
|
|
355
|
+
}
|
package/bin/tetra-doctor.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: Dark Mode Compliance (HARD MODE)
|
|
3
|
+
*
|
|
4
|
+
* Zero-tolerance check that enforces Tetra design tokens for ALL color usage
|
|
5
|
+
* in frontend components. No hardcoded Tailwind colors allowed.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IT CATCHES:
|
|
8
|
+
* - CRITICAL: Hardcoded neutral colors (bg-white, text-gray-*, border-gray-*)
|
|
9
|
+
* - CRITICAL: Hardcoded semantic colors (bg-red-*, text-green-*, border-blue-*)
|
|
10
|
+
* - CRITICAL: hover:/focus: with hardcoded colors
|
|
11
|
+
* - CRITICAL: placeholder/ring/outline/divide with hardcoded neutrals
|
|
12
|
+
* - HIGH: Paired dark: overrides (works but must use tokens)
|
|
13
|
+
* - HIGH: Arbitrary color values (bg-[#fff], text-[#333])
|
|
14
|
+
* - HIGH: Gradient from/to/via with hardcoded neutrals
|
|
15
|
+
* - MEDIUM: Inline style= with hardcoded colors
|
|
16
|
+
*
|
|
17
|
+
* TOKEN MAPPING (what to use instead):
|
|
18
|
+
*
|
|
19
|
+
* NEUTRALS:
|
|
20
|
+
* bg-white / bg-gray-50 → bg-background
|
|
21
|
+
* bg-gray-100 → bg-secondary (or bg-muted)
|
|
22
|
+
* bg-gray-200 → bg-muted
|
|
23
|
+
* text-gray-900 / text-black → text-foreground
|
|
24
|
+
* text-gray-700/800 → text-foreground
|
|
25
|
+
* text-gray-500/600 → text-muted-foreground
|
|
26
|
+
* text-gray-300/400 → text-muted-foreground
|
|
27
|
+
* border-gray-200/300 → border-border
|
|
28
|
+
* hover:bg-gray-100 → hover:bg-accent
|
|
29
|
+
* ring-gray-* → ring-ring
|
|
30
|
+
* placeholder-gray-* → placeholder:text-muted-foreground
|
|
31
|
+
*
|
|
32
|
+
* SEMANTIC STATUS:
|
|
33
|
+
* bg-green-50/100 → bg-success-subtle
|
|
34
|
+
* text-green-600/700 → text-success-foreground
|
|
35
|
+
* bg-red-50/100 → bg-danger-subtle (or bg-destructive)
|
|
36
|
+
* text-red-600/700 → text-danger-foreground
|
|
37
|
+
* bg-amber-50/100 / bg-yellow-* → bg-warning-subtle
|
|
38
|
+
* text-amber-600/700 → text-warning-foreground
|
|
39
|
+
* bg-blue-50/100 → bg-info-subtle
|
|
40
|
+
* text-blue-600/700 → text-info-foreground
|
|
41
|
+
*
|
|
42
|
+
* BRAND/PLATFORM specific colors (indigo, purple, teal, etc.) must be
|
|
43
|
+
* whitelisted per-project in .tetra-audit.json → codeQuality.darkModeCompliance.whitelistClasses
|
|
44
|
+
*
|
|
45
|
+
* Severity: high — dark mode violations create visible UX issues
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { readFileSync, existsSync } from 'fs'
|
|
49
|
+
import { join } from 'path'
|
|
50
|
+
import { glob } from 'glob'
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// META
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
export const meta = {
|
|
57
|
+
id: 'dark-mode-compliance',
|
|
58
|
+
name: 'Dark Mode Compliance',
|
|
59
|
+
category: 'codeQuality',
|
|
60
|
+
severity: 'high',
|
|
61
|
+
description: 'Zero-tolerance enforcement of Tetra design tokens — no hardcoded Tailwind colors'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// NEUTRAL COLOR RULES
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/** Gray-scale families treated as "neutral" — all must use tokens */
|
|
69
|
+
const NEUTRAL_SCALES = '(?:gray|slate|zinc|neutral|stone)'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every hardcoded neutral class and its token replacement.
|
|
73
|
+
* The check scans for these in className strings and inline style strings.
|
|
74
|
+
*/
|
|
75
|
+
const NEUTRAL_RULES = [
|
|
76
|
+
// ── Backgrounds ──
|
|
77
|
+
{ re: `bg-white`, token: 'bg-background' },
|
|
78
|
+
{ re: `bg-${NEUTRAL_SCALES}-50`, token: 'bg-background' },
|
|
79
|
+
{ re: `bg-${NEUTRAL_SCALES}-100`, token: 'bg-secondary' },
|
|
80
|
+
{ re: `bg-${NEUTRAL_SCALES}-200`, token: 'bg-muted' },
|
|
81
|
+
{ re: `bg-${NEUTRAL_SCALES}-(?:300|400)`, token: 'bg-muted (or custom token)' },
|
|
82
|
+
{ re: `bg-${NEUTRAL_SCALES}-(?:500|600)`, token: 'bg-muted-foreground (via CSS var)' },
|
|
83
|
+
{ re: `bg-${NEUTRAL_SCALES}-(?:700|800)`, token: 'bg-foreground/10 (or inverse token)' },
|
|
84
|
+
{ re: `bg-${NEUTRAL_SCALES}-(?:900|950)`, token: 'bg-foreground (or inverse)' },
|
|
85
|
+
{ re: `bg-black`, token: 'bg-foreground' },
|
|
86
|
+
// ── Text ──
|
|
87
|
+
{ re: `text-${NEUTRAL_SCALES}-(?:900|950)`, token: 'text-foreground' },
|
|
88
|
+
{ re: `text-${NEUTRAL_SCALES}-(?:700|800)`, token: 'text-foreground' },
|
|
89
|
+
{ re: `text-${NEUTRAL_SCALES}-(?:500|600)`, token: 'text-muted-foreground' },
|
|
90
|
+
{ re: `text-${NEUTRAL_SCALES}-(?:300|400)`, token: 'text-muted-foreground' },
|
|
91
|
+
{ re: `text-${NEUTRAL_SCALES}-(?:100|200)`, token: 'text-muted-foreground' },
|
|
92
|
+
{ re: `text-black`, token: 'text-foreground' },
|
|
93
|
+
{ re: `text-white`, token: 'text-background (or text-primary-foreground)' },
|
|
94
|
+
// ── Borders ──
|
|
95
|
+
{ re: `border-${NEUTRAL_SCALES}-(?:200|300)`, token: 'border-border' },
|
|
96
|
+
{ re: `border-${NEUTRAL_SCALES}-(?:100|150)`, token: 'border-border' },
|
|
97
|
+
{ re: `border-${NEUTRAL_SCALES}-(?:400|500|600)`, token: 'border-border' },
|
|
98
|
+
{ re: `border-${NEUTRAL_SCALES}-(?:700|800)`, token: 'border-border' },
|
|
99
|
+
// ── Divide ──
|
|
100
|
+
{ re: `divide-${NEUTRAL_SCALES}-\\d+`, token: 'divide-border' },
|
|
101
|
+
// ── Ring ──
|
|
102
|
+
{ re: `ring-${NEUTRAL_SCALES}-\\d+`, token: 'ring-ring' },
|
|
103
|
+
// ── Outline ──
|
|
104
|
+
{ re: `outline-${NEUTRAL_SCALES}-\\d+`, token: 'outline-ring' },
|
|
105
|
+
// ── Placeholder ──
|
|
106
|
+
{ re: `placeholder-${NEUTRAL_SCALES}-\\d+`, token: 'placeholder:text-muted-foreground' },
|
|
107
|
+
// ── Shadow (color) ──
|
|
108
|
+
{ re: `shadow-${NEUTRAL_SCALES}-\\d+`, token: 'shadow (no color class needed)' },
|
|
109
|
+
// ── Gradient ──
|
|
110
|
+
{ re: `from-white`, token: 'from-background' },
|
|
111
|
+
{ re: `from-${NEUTRAL_SCALES}-\\d+`, token: 'from-background/muted/secondary' },
|
|
112
|
+
{ re: `to-white`, token: 'to-background' },
|
|
113
|
+
{ re: `to-${NEUTRAL_SCALES}-\\d+`, token: 'to-background/muted/secondary' },
|
|
114
|
+
{ re: `via-white`, token: 'via-background' },
|
|
115
|
+
{ re: `via-${NEUTRAL_SCALES}-\\d+`, token: 'via-background/muted/secondary' },
|
|
116
|
+
// ── Caret, fill, stroke, decoration ──
|
|
117
|
+
{ re: `caret-${NEUTRAL_SCALES}-\\d+`, token: 'caret-foreground' },
|
|
118
|
+
{ re: `fill-${NEUTRAL_SCALES}-\\d+`, token: 'fill-foreground (or fill-muted-foreground)' },
|
|
119
|
+
{ re: `stroke-${NEUTRAL_SCALES}-\\d+`, token: 'stroke-foreground (or stroke-border)' },
|
|
120
|
+
{ re: `decoration-${NEUTRAL_SCALES}-\\d+`, token: 'decoration-foreground' },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
// Compile to real regexes
|
|
124
|
+
const COMPILED_NEUTRAL_RULES = NEUTRAL_RULES.map(r => ({
|
|
125
|
+
regex: new RegExp(`\\b${r.re}\\b`),
|
|
126
|
+
token: r.token,
|
|
127
|
+
source: r.re,
|
|
128
|
+
}))
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// SEMANTIC COLOR RULES
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Semantic colors (red, green, amber, blue) should use Tetra semantic tokens.
|
|
136
|
+
* Other hues (indigo, purple, pink, teal, etc.) are "brand" and must be
|
|
137
|
+
* whitelisted per-project.
|
|
138
|
+
*/
|
|
139
|
+
const SEMANTIC_MAPPINGS = [
|
|
140
|
+
// ── Success (green/emerald) ──
|
|
141
|
+
{ scales: '(?:green|emerald)', shades: '(?:50|100)', util: 'bg', token: 'bg-success-subtle' },
|
|
142
|
+
{ scales: '(?:green|emerald)', shades: '(?:500|600)', util: 'bg', token: 'bg-success' },
|
|
143
|
+
{ scales: '(?:green|emerald)', shades: '(?:600|700)', util: 'text', token: 'text-success-foreground' },
|
|
144
|
+
{ scales: '(?:green|emerald)', shades: '(?:800|900)', util: 'text', token: 'text-success-foreground' },
|
|
145
|
+
{ scales: '(?:green|emerald)', shades: '(?:200|300)', util: 'border', token: 'border-success' },
|
|
146
|
+
// ── Danger (red/rose) ──
|
|
147
|
+
{ scales: '(?:red|rose)', shades: '(?:50|100)', util: 'bg', token: 'bg-danger-subtle (or bg-destructive/10)' },
|
|
148
|
+
{ scales: '(?:red|rose)', shades: '(?:500|600)', util: 'bg', token: 'bg-destructive' },
|
|
149
|
+
{ scales: '(?:red|rose)', shades: '(?:600|700)', util: 'text', token: 'text-danger-foreground (or text-destructive)' },
|
|
150
|
+
{ scales: '(?:red|rose)', shades: '(?:800|900)', util: 'text', token: 'text-danger-foreground' },
|
|
151
|
+
{ scales: '(?:red|rose)', shades: '(?:200|300)', util: 'border', token: 'border-destructive' },
|
|
152
|
+
// ── Warning (amber/yellow/orange) ──
|
|
153
|
+
{ scales: '(?:amber|yellow|orange)', shades: '(?:50|100)', util: 'bg', token: 'bg-warning-subtle' },
|
|
154
|
+
{ scales: '(?:amber|yellow|orange)', shades: '(?:500|600)', util: 'bg', token: 'bg-warning' },
|
|
155
|
+
{ scales: '(?:amber|yellow|orange)', shades: '(?:600|700)', util: 'text', token: 'text-warning-foreground' },
|
|
156
|
+
{ scales: '(?:amber|yellow|orange)', shades: '(?:800|900)', util: 'text', token: 'text-warning-foreground' },
|
|
157
|
+
{ scales: '(?:amber|yellow|orange)', shades: '(?:200|300)', util: 'border', token: 'border-warning' },
|
|
158
|
+
// ── Info (blue/sky/cyan) ──
|
|
159
|
+
{ scales: '(?:blue|sky|cyan)', shades: '(?:50|100)', util: 'bg', token: 'bg-info-subtle' },
|
|
160
|
+
{ scales: '(?:blue|sky|cyan)', shades: '(?:500|600)', util: 'bg', token: 'bg-info' },
|
|
161
|
+
{ scales: '(?:blue|sky|cyan)', shades: '(?:600|700)', util: 'text', token: 'text-info-foreground' },
|
|
162
|
+
{ scales: '(?:blue|sky|cyan)', shades: '(?:800|900)', util: 'text', token: 'text-info-foreground' },
|
|
163
|
+
{ scales: '(?:blue|sky|cyan)', shades: '(?:200|300)', util: 'border', token: 'border-info' },
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
const COMPILED_SEMANTIC_RULES = SEMANTIC_MAPPINGS.map(r => ({
|
|
167
|
+
regex: new RegExp(`\\b${r.util}-${r.scales}-${r.shades}\\b`),
|
|
168
|
+
token: r.token,
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* "Brand" color hues — these need project-specific whitelisting.
|
|
173
|
+
* If NOT whitelisted, they get flagged as MEDIUM (not critical, but suspicious).
|
|
174
|
+
*/
|
|
175
|
+
const BRAND_HUES = '(?:indigo|violet|purple|fuchsia|pink|teal|lime)'
|
|
176
|
+
const BRAND_REGEX = new RegExp(`\\b(?:bg|text|border)-${BRAND_HUES}-\\d+\\b`)
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// MODIFIERS — hover:/focus:/etc. with hardcoded colors
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
const MODIFIER_PREFIXES = [
|
|
183
|
+
'hover:', 'focus:', 'focus-within:', 'focus-visible:',
|
|
184
|
+
'active:', 'group-hover:', 'peer-hover:',
|
|
185
|
+
'dark:hover:', 'dark:focus:',
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// ARBITRARY VALUES — bg-[#fff], text-[#333]
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
const ARBITRARY_COLOR_RE = /\b(?:bg|text|border|ring|fill|stroke|from|to|via|outline|shadow|caret|decoration)-\[#[0-9a-fA-F]+\]/g
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// INLINE STYLE COLORS
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
const INLINE_COLOR_RE = /style=\{?\{[^}]*(?:color|background|backgroundColor|borderColor)\s*:\s*['"]?#[0-9a-fA-F]/
|
|
199
|
+
|
|
200
|
+
// ============================================================================
|
|
201
|
+
// CONFIG & WHITELIST
|
|
202
|
+
// ============================================================================
|
|
203
|
+
|
|
204
|
+
const IGNORE_PATTERNS = [
|
|
205
|
+
'**/node_modules/**',
|
|
206
|
+
'**/.next/**',
|
|
207
|
+
'**/dist/**',
|
|
208
|
+
'**/build/**',
|
|
209
|
+
'**/*.test.*',
|
|
210
|
+
'**/*.spec.*',
|
|
211
|
+
'**/__tests__/**',
|
|
212
|
+
'**/storybook/**',
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
function loadWhitelist(projectRoot, config) {
|
|
216
|
+
const dmConfig = config.codeQuality?.darkModeCompliance || {}
|
|
217
|
+
return {
|
|
218
|
+
dirs: dmConfig.whitelistDirs || [],
|
|
219
|
+
files: dmConfig.whitelistFiles || [],
|
|
220
|
+
/** Exact class names that are allowed (e.g. 'bg-indigo-500' for brand color) */
|
|
221
|
+
classes: new Set(dmConfig.whitelistClasses || []),
|
|
222
|
+
/** Regex patterns for whitelisted classes */
|
|
223
|
+
classPatterns: (dmConfig.whitelistClassPatterns || []).map(p => new RegExp(p)),
|
|
224
|
+
maxFindings: dmConfig.maxFindings || 80,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isWhitelistedClass(cls, whitelist) {
|
|
229
|
+
if (whitelist.classes.has(cls)) return true
|
|
230
|
+
return whitelist.classPatterns.some(re => re.test(cls))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// LINE SCANNER
|
|
235
|
+
// ============================================================================
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Extract class-containing strings from a line of JSX/TSX.
|
|
239
|
+
*/
|
|
240
|
+
function extractClassStrings(line) {
|
|
241
|
+
const results = []
|
|
242
|
+
let m
|
|
243
|
+
|
|
244
|
+
// className="..." or class="..."
|
|
245
|
+
const staticRe = /(?:className|class)=["']([^"']+)["']/g
|
|
246
|
+
while ((m = staticRe.exec(line)) !== null) {
|
|
247
|
+
results.push(m[1])
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// className={`...`}
|
|
251
|
+
const templateRe = /(?:className|class)=\{`([^`]+)`\}/g
|
|
252
|
+
while ((m = templateRe.exec(line)) !== null) {
|
|
253
|
+
results.push(m[1])
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// String literals containing color classes (ternaries, cn() args, etc.)
|
|
257
|
+
const stringRe = /['"]([^'"]*\b(?:bg-|text-|border-|ring-|divide-|from-|to-|via-|placeholder-|outline-|shadow-|fill-|stroke-|caret-|decoration-)[^'"]*)['"]/g
|
|
258
|
+
while ((m = stringRe.exec(line)) !== null) {
|
|
259
|
+
results.push(m[1])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return results
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Scan a single class string for all violations.
|
|
267
|
+
*/
|
|
268
|
+
function scanClassString(classStr, whitelist) {
|
|
269
|
+
const violations = []
|
|
270
|
+
|
|
271
|
+
// Split into individual classes (handles spaces, newlines, ${} gaps)
|
|
272
|
+
const classes = classStr.split(/\s+/).filter(Boolean)
|
|
273
|
+
|
|
274
|
+
for (const cls of classes) {
|
|
275
|
+
// Strip modifier prefixes for matching (hover:bg-gray-100 → bg-gray-100)
|
|
276
|
+
let baseCls = cls
|
|
277
|
+
let modifier = ''
|
|
278
|
+
for (const mod of MODIFIER_PREFIXES) {
|
|
279
|
+
if (cls.startsWith(mod)) {
|
|
280
|
+
modifier = mod
|
|
281
|
+
baseCls = cls.slice(mod.length)
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Handle dark: prefix (we flag the existence of dark: as needing removal)
|
|
286
|
+
if (cls.startsWith('dark:') && !modifier) {
|
|
287
|
+
// dark: classes are flagged separately as "should not exist"
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isWhitelistedClass(baseCls, whitelist) || isWhitelistedClass(cls, whitelist)) continue
|
|
292
|
+
|
|
293
|
+
// Check neutral rules
|
|
294
|
+
for (const rule of COMPILED_NEUTRAL_RULES) {
|
|
295
|
+
if (rule.regex.test(baseCls)) {
|
|
296
|
+
violations.push({
|
|
297
|
+
severity: modifier ? 'critical' : 'critical',
|
|
298
|
+
cls,
|
|
299
|
+
token: modifier ? `${modifier}${rule.token}` : rule.token,
|
|
300
|
+
type: 'neutral',
|
|
301
|
+
})
|
|
302
|
+
break // One match per class is enough
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check semantic rules
|
|
307
|
+
for (const rule of COMPILED_SEMANTIC_RULES) {
|
|
308
|
+
if (rule.regex.test(baseCls)) {
|
|
309
|
+
violations.push({
|
|
310
|
+
severity: 'critical',
|
|
311
|
+
cls,
|
|
312
|
+
token: rule.token,
|
|
313
|
+
type: 'semantic',
|
|
314
|
+
})
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check brand colors (not critical, but flagged)
|
|
320
|
+
if (BRAND_REGEX.test(baseCls)) {
|
|
321
|
+
violations.push({
|
|
322
|
+
severity: 'medium',
|
|
323
|
+
cls,
|
|
324
|
+
token: 'whitelist or create project token',
|
|
325
|
+
type: 'brand',
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return violations
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// MAIN CHECK
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
export async function run(config, projectRoot) {
|
|
338
|
+
const result = {
|
|
339
|
+
passed: true,
|
|
340
|
+
findings: [],
|
|
341
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
342
|
+
details: {
|
|
343
|
+
neutralViolations: 0,
|
|
344
|
+
semanticViolations: 0,
|
|
345
|
+
brandViolations: 0,
|
|
346
|
+
arbitraryViolations: 0,
|
|
347
|
+
inlineStyleViolations: 0,
|
|
348
|
+
darkPrefixCount: 0,
|
|
349
|
+
pairedDarkOverrides: 0,
|
|
350
|
+
filesScanned: 0,
|
|
351
|
+
filesWithViolations: 0,
|
|
352
|
+
topOffenders: [],
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const whitelist = loadWhitelist(projectRoot, config)
|
|
357
|
+
|
|
358
|
+
// Find frontend source files
|
|
359
|
+
const frontendDir = join(projectRoot, 'frontend', 'src')
|
|
360
|
+
if (!existsSync(frontendDir)) {
|
|
361
|
+
result.findings.push({
|
|
362
|
+
file: 'project',
|
|
363
|
+
line: 0,
|
|
364
|
+
severity: 'low',
|
|
365
|
+
message: 'No frontend/src directory found — skipping dark mode check',
|
|
366
|
+
})
|
|
367
|
+
return result
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let files = []
|
|
371
|
+
for (const pattern of ['**/*.tsx', '**/*.jsx']) {
|
|
372
|
+
const found = glob.sync(pattern, {
|
|
373
|
+
cwd: frontendDir,
|
|
374
|
+
ignore: IGNORE_PATTERNS,
|
|
375
|
+
absolute: false,
|
|
376
|
+
})
|
|
377
|
+
files.push(...found)
|
|
378
|
+
}
|
|
379
|
+
files = [...new Set(files)]
|
|
380
|
+
result.details.filesScanned = files.length
|
|
381
|
+
|
|
382
|
+
const fileStats = []
|
|
383
|
+
|
|
384
|
+
for (const file of files) {
|
|
385
|
+
const relPath = `frontend/src/${file}`
|
|
386
|
+
|
|
387
|
+
if (whitelist.files.some(w => relPath.includes(w))) continue
|
|
388
|
+
if (whitelist.dirs.some(d => relPath.includes(d))) continue
|
|
389
|
+
|
|
390
|
+
let content
|
|
391
|
+
try {
|
|
392
|
+
content = readFileSync(join(frontendDir, file), 'utf-8')
|
|
393
|
+
} catch {
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const lines = content.split('\n')
|
|
398
|
+
let fileViolationCount = 0
|
|
399
|
+
|
|
400
|
+
for (let i = 0; i < lines.length; i++) {
|
|
401
|
+
const line = lines[i]
|
|
402
|
+
|
|
403
|
+
// ── 1. Class string violations (neutrals + semantics + brand) ──
|
|
404
|
+
const classStrings = extractClassStrings(line)
|
|
405
|
+
for (const classStr of classStrings) {
|
|
406
|
+
const violations = scanClassString(classStr, whitelist)
|
|
407
|
+
for (const v of violations) {
|
|
408
|
+
fileViolationCount++
|
|
409
|
+
result.summary[v.severity]++
|
|
410
|
+
result.summary.total++
|
|
411
|
+
result.passed = false
|
|
412
|
+
|
|
413
|
+
if (v.type === 'neutral') result.details.neutralViolations++
|
|
414
|
+
if (v.type === 'semantic') result.details.semanticViolations++
|
|
415
|
+
if (v.type === 'brand') result.details.brandViolations++
|
|
416
|
+
|
|
417
|
+
if (result.findings.length < whitelist.maxFindings) {
|
|
418
|
+
result.findings.push({
|
|
419
|
+
file: relPath,
|
|
420
|
+
line: i + 1,
|
|
421
|
+
severity: v.severity,
|
|
422
|
+
message: `\`${v.cls}\` → use \`${v.token}\``,
|
|
423
|
+
fix: `Replace \`${v.cls}\` with \`${v.token}\``,
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── 2. Paired dark: overrides (have both light + dark: → should use token) ──
|
|
429
|
+
const hasDarkOverride = /\bdark:(?:bg|text|border)-/.test(classStr)
|
|
430
|
+
const hasLightNeutral = COMPILED_NEUTRAL_RULES.some(r => r.regex.test(classStr))
|
|
431
|
+
if (hasDarkOverride && hasLightNeutral) {
|
|
432
|
+
result.details.pairedDarkOverrides++
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── 3. Arbitrary color values ──
|
|
437
|
+
ARBITRARY_COLOR_RE.lastIndex = 0
|
|
438
|
+
let arbMatch
|
|
439
|
+
while ((arbMatch = ARBITRARY_COLOR_RE.exec(line)) !== null) {
|
|
440
|
+
fileViolationCount++
|
|
441
|
+
result.details.arbitraryViolations++
|
|
442
|
+
result.summary.high++
|
|
443
|
+
result.summary.total++
|
|
444
|
+
result.passed = false
|
|
445
|
+
|
|
446
|
+
if (result.findings.length < whitelist.maxFindings) {
|
|
447
|
+
result.findings.push({
|
|
448
|
+
file: relPath,
|
|
449
|
+
line: i + 1,
|
|
450
|
+
severity: 'high',
|
|
451
|
+
message: `Arbitrary color \`${arbMatch[0]}\` → use CSS variable or Tetra token`,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── 4. Inline style colors ──
|
|
457
|
+
if (INLINE_COLOR_RE.test(line)) {
|
|
458
|
+
fileViolationCount++
|
|
459
|
+
result.details.inlineStyleViolations++
|
|
460
|
+
result.summary.high++
|
|
461
|
+
result.summary.total++
|
|
462
|
+
result.passed = false
|
|
463
|
+
|
|
464
|
+
if (result.findings.length < whitelist.maxFindings) {
|
|
465
|
+
result.findings.push({
|
|
466
|
+
file: relPath,
|
|
467
|
+
line: i + 1,
|
|
468
|
+
severity: 'high',
|
|
469
|
+
message: 'Inline style with hardcoded color → use Tetra CSS variable: var(--tetra-*)',
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── 5. Count dark: prefixes (goal: 0) ──
|
|
475
|
+
const darkCount = (line.match(/\bdark:/g) || []).length
|
|
476
|
+
result.details.darkPrefixCount += darkCount
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (fileViolationCount > 0) {
|
|
480
|
+
result.details.filesWithViolations++
|
|
481
|
+
fileStats.push({ file: relPath, violations: fileViolationCount })
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Top offenders
|
|
486
|
+
fileStats.sort((a, b) => b.violations - a.violations)
|
|
487
|
+
result.details.topOffenders = fileStats.slice(0, 10)
|
|
488
|
+
|
|
489
|
+
// Summary finding
|
|
490
|
+
if (!result.passed) {
|
|
491
|
+
const d = result.details
|
|
492
|
+
result.findings.unshift({
|
|
493
|
+
file: 'project',
|
|
494
|
+
line: 0,
|
|
495
|
+
severity: 'high',
|
|
496
|
+
message: [
|
|
497
|
+
`Dark mode compliance FAILED:`,
|
|
498
|
+
`${d.neutralViolations} neutral color violations,`,
|
|
499
|
+
`${d.semanticViolations} semantic color violations,`,
|
|
500
|
+
`${d.brandViolations} brand color violations (whitelist or tokenize),`,
|
|
501
|
+
`${d.arbitraryViolations} arbitrary [#hex] colors,`,
|
|
502
|
+
`${d.inlineStyleViolations} inline style colors,`,
|
|
503
|
+
`${d.darkPrefixCount} dark: prefixes (goal: 0).`,
|
|
504
|
+
`Fix: replace ALL hardcoded colors with Tetra tokens.`,
|
|
505
|
+
`Docs: bg-background, text-foreground, text-muted-foreground, border-border,`,
|
|
506
|
+
`bg-success-subtle, text-danger-foreground, bg-warning-subtle, bg-info-subtle.`,
|
|
507
|
+
].join(' '),
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return result
|
|
512
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -24,6 +24,7 @@ import * as apiResponseFormat from './checks/codeQuality/api-response-format.js'
|
|
|
24
24
|
import * as fileSize from './checks/codeQuality/file-size.js'
|
|
25
25
|
import * as namingConventions from './checks/codeQuality/naming-conventions.js'
|
|
26
26
|
import * as routeSeparation from './checks/codeQuality/route-separation.js'
|
|
27
|
+
import * as darkModeCompliance from './checks/codeQuality/dark-mode-compliance.js'
|
|
27
28
|
import * as gitignoreValidation from './checks/security/gitignore-validation.js'
|
|
28
29
|
import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
|
|
29
30
|
import * as rlsLiveAudit from './checks/security/rls-live-audit.js'
|
|
@@ -124,7 +125,8 @@ const ALL_CHECKS = {
|
|
|
124
125
|
apiResponseFormat,
|
|
125
126
|
fileSize,
|
|
126
127
|
namingConventions,
|
|
127
|
-
routeSeparation
|
|
128
|
+
routeSeparation,
|
|
129
|
+
darkModeCompliance
|
|
128
130
|
],
|
|
129
131
|
supabase: [
|
|
130
132
|
rlsPolicyAudit,
|