@soulbatical/tetra-dev-toolkit 1.20.17 → 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.
- package/bin/tetra-dark-mode-fix.js +355 -0
- package/bin/tetra-doctor.js +117 -0
- package/lib/audits/doctor-audit.js +905 -0
- package/lib/checks/codeQuality/dark-mode-compliance.js +512 -0
- package/lib/runner.js +3 -1
- package/package.json +3 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Doctor — checks a project for common Tetra integration issues.
|
|
5
|
+
*
|
|
6
|
+
* Scans layout.tsx, providers.tsx, globals.css, package.json, app-config.tsx
|
|
7
|
+
* to find missing or outdated Tetra setup before they become runtime bugs.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* tetra-doctor # Check current project
|
|
11
|
+
* tetra-doctor --path /path # Check specific project
|
|
12
|
+
* tetra-doctor --json # JSON output for CI
|
|
13
|
+
* tetra-doctor --fix # Auto-fix safe issues
|
|
14
|
+
* tetra-doctor --ci # GitHub Actions annotations
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 = all critical checks pass
|
|
18
|
+
* 1 = one or more critical checks fail
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { program } from 'commander'
|
|
22
|
+
import chalk from 'chalk'
|
|
23
|
+
import { readFileSync, existsSync } from 'fs'
|
|
24
|
+
import { join } from 'path'
|
|
25
|
+
import {
|
|
26
|
+
runDoctorAudit,
|
|
27
|
+
applyFixes,
|
|
28
|
+
formatDoctorReport,
|
|
29
|
+
formatDoctorReportJSON,
|
|
30
|
+
formatDoctorCIAnnotations,
|
|
31
|
+
} from '../lib/audits/doctor-audit.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Re-read the files that are subject to auto-fix, so applyFixes has
|
|
35
|
+
* fresh content after a potential prior fix in the same session.
|
|
36
|
+
*/
|
|
37
|
+
function readFixableFiles(projectRoot, report) {
|
|
38
|
+
function tryRead(relativePath) {
|
|
39
|
+
if (!relativePath) return null
|
|
40
|
+
const fullPath = join(projectRoot, relativePath)
|
|
41
|
+
if (!existsSync(fullPath)) return null
|
|
42
|
+
return { path: relativePath, content: readFileSync(fullPath, 'utf-8') }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
layout: tryRead(report.files.layout),
|
|
47
|
+
globalsCss: tryRead(report.files.globalsCss),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.name('tetra-doctor')
|
|
53
|
+
.description('Check a Tetra project for integration issues (versions, layout, CSS tokens, etc.)')
|
|
54
|
+
.version('1.0.0')
|
|
55
|
+
.option('--path <dir>', 'Project root directory (default: cwd)')
|
|
56
|
+
.option('--json', 'JSON output')
|
|
57
|
+
.option('--fix', 'Auto-fix safe issues (suppressHydrationWarning, dark-mode.css)')
|
|
58
|
+
.option('--ci', 'GitHub Actions annotations for failures')
|
|
59
|
+
.action(async (options) => {
|
|
60
|
+
try {
|
|
61
|
+
const projectRoot = options.path || process.cwd()
|
|
62
|
+
|
|
63
|
+
if (!options.json) {
|
|
64
|
+
console.log(chalk.gray('\n Running checks...'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let report = await runDoctorAudit(projectRoot)
|
|
68
|
+
|
|
69
|
+
// Apply fixes if requested
|
|
70
|
+
if (options.fix && !options.json) {
|
|
71
|
+
const fixableChecks = report.checks.filter(c => !c.pass && c.fixable)
|
|
72
|
+
if (fixableChecks.length === 0) {
|
|
73
|
+
console.log(chalk.gray(' Nothing to auto-fix.\n'))
|
|
74
|
+
} else {
|
|
75
|
+
const fixableFiles = readFixableFiles(projectRoot, report)
|
|
76
|
+
const fixes = applyFixes(projectRoot, report.checks, fixableFiles)
|
|
77
|
+
|
|
78
|
+
for (const fix of fixes) {
|
|
79
|
+
if (fix.success) {
|
|
80
|
+
console.log(chalk.green(` Fixed: ${fix.fix} in ${fix.file}`))
|
|
81
|
+
} else {
|
|
82
|
+
console.log(chalk.red(` Failed to fix ${fix.fix}: ${fix.error}`))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.log('')
|
|
86
|
+
|
|
87
|
+
// Re-run after fixes to show updated state
|
|
88
|
+
report = await runDoctorAudit(projectRoot)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Output
|
|
93
|
+
if (options.json) {
|
|
94
|
+
console.log(formatDoctorReportJSON(report))
|
|
95
|
+
} else {
|
|
96
|
+
console.log(formatDoctorReport(report, chalk, projectRoot))
|
|
97
|
+
|
|
98
|
+
if (options.ci) {
|
|
99
|
+
const annotations = formatDoctorCIAnnotations(report)
|
|
100
|
+
if (annotations) {
|
|
101
|
+
console.log(annotations)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Exit code: fail only on critical checks
|
|
107
|
+
process.exit(report.summary.criticalFailed > 0 ? 1 : 0)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
110
|
+
if (!options.json) {
|
|
111
|
+
console.error(chalk.gray(` ${err.stack}`))
|
|
112
|
+
}
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
program.parse()
|