@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
+ }
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.18",
3
+ "version": "1.20.19",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },