@soulbatical/tetra-dev-toolkit 1.20.23 → 1.20.25

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.
@@ -29,6 +29,58 @@ const SKIP_PATTERNS = [
29
29
  /\/landing\//,
30
30
  ]
31
31
 
32
+ /**
33
+ * Inline element tags whose padding classes are always legitimate UI-level
34
+ * padding (badges, inputs, buttons, tabs, links, labels).
35
+ */
36
+ const INLINE_ELEMENT_TAGS = ['span', 'button', 'input', 'textarea', 'select', 'label', 'a']
37
+
38
+ // ============================================================================
39
+ // HELPERS — page-root detection
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Find the line index of the `return` statement inside the default export
44
+ * function body. Returns -1 if not found (non-page file).
45
+ *
46
+ * Strategy: scan for `export default function` or `export default (`, then
47
+ * find the first `return` keyword that follows it.
48
+ */
49
+ function findReturnLineIndex(lines) {
50
+ let inDefaultExport = false
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i]
53
+ if (!inDefaultExport) {
54
+ if (/export\s+default\s+(function|\()/.test(line) || /export\s+default\s+\w/.test(line)) {
55
+ inDefaultExport = true
56
+ }
57
+ }
58
+ if (inDefaultExport) {
59
+ // Match `return (` or `return <` (start of JSX return)
60
+ if (/\breturn\s*[\(<]/.test(line) || (/\breturn\b/.test(line) && /<[A-Za-z]/.test(lines[i + 1] ?? ''))) {
61
+ return i
62
+ }
63
+ }
64
+ }
65
+ return -1
66
+ }
67
+
68
+ /**
69
+ * Return true when the given line contains a JSX tag that is one of the
70
+ * known inline/form element tags (whitelisted for padding).
71
+ */
72
+ function lineHasInlineElementTag(line) {
73
+ return INLINE_ELEMENT_TAGS.some((tag) => new RegExp(`<${tag}[\\s/>]`).test(line))
74
+ }
75
+
76
+ /**
77
+ * Return true when the line looks like a legitimate prose width constraint:
78
+ * element has both a prose/article context AND a max-w-* class.
79
+ */
80
+ function lineIsProseWidth(line) {
81
+ return /\bprose\b/.test(line) || /<article[\s>]/.test(line)
82
+ }
83
+
32
84
  // ============================================================================
33
85
  // RULE DEFINITIONS
34
86
  // ============================================================================
@@ -40,8 +92,19 @@ const CRITICAL_RULES = [
40
92
  label: 'hardcoded page width',
41
93
  fixHint: 'Use AppShell config.layout.pageMaxWidth or fullWidthPaths instead',
42
94
  pageOnly: true,
43
- check(line) {
44
- // max-w-* in className
95
+ /**
96
+ * @param {string} line
97
+ * @param {object|null} ctx
98
+ * @param {{ returnLineIndex: number, lineIndex: number }} pageCtx
99
+ */
100
+ check(line, ctx, pageCtx) {
101
+ // Only flag within first 30 lines after return (page-root area)
102
+ if (pageCtx && pageCtx.returnLineIndex >= 0) {
103
+ if (pageCtx.lineIndex > pageCtx.returnLineIndex + 30) return null
104
+ }
105
+ // Whitelist prose / article contexts
106
+ if (lineIsProseWidth(line)) return null
107
+
45
108
  if (/className\s*=/.test(line) && /\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/.test(line)) {
46
109
  return line.match(/\bmax-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-\S+)\b/)?.[0] ?? 'max-w-*'
47
110
  }
@@ -57,7 +120,14 @@ const CRITICAL_RULES = [
57
120
  label: 'hardcoded page padding',
58
121
  fixHint: 'Use AppShell config.layout.pagePadding preset instead',
59
122
  pageOnly: true,
60
- check(line) {
123
+ check(line, ctx, pageCtx) {
124
+ // Only flag within first 30 lines after return (page-root area)
125
+ if (pageCtx && pageCtx.returnLineIndex >= 0) {
126
+ if (pageCtx.lineIndex > pageCtx.returnLineIndex + 30) return null
127
+ }
128
+ // Whitelist inline/form element tags — their padding is component-level
129
+ if (lineHasInlineElementTag(line)) return null
130
+
61
131
  if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
62
132
  return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
63
133
  }
@@ -86,7 +156,9 @@ const CRITICAL_RULES = [
86
156
  label: 'raw Tailwind color class',
87
157
  fixHint: 'Replace with token classes (bg-background, text-foreground, border-border) or var(--tetra-*) tokens',
88
158
  check(line) {
89
- // Whitelist: transparent, current, inherit
159
+ // Whitelist: transparent, current, inherit, and Tetra semantic tokens like text-primary-foreground
160
+ if (/\btext-primary-foreground\b/.test(line)) return null
161
+
90
162
  const COLOR_NAMES = [
91
163
  'white', 'black',
92
164
  'gray', 'slate', 'zinc', 'neutral', 'stone',
@@ -180,6 +252,23 @@ const WARNING_RULES = [
180
252
  return null
181
253
  },
182
254
  },
255
+ {
256
+ id: 'nested-padding-consider-component',
257
+ label: 'nested padding — consider extracting to a component',
258
+ fixHint: 'This padding is deep inside a page; consider extracting to a reusable component',
259
+ strictOnly: true,
260
+ check(line, ctx, pageCtx) {
261
+ // Only fires on page files, for lines BEYOND the first 30 lines after return
262
+ if (!pageCtx || pageCtx.returnLineIndex < 0) return null
263
+ if (pageCtx.lineIndex <= pageCtx.returnLineIndex + 30) return null
264
+ // Skip inline/form elements — their padding is always legitimate
265
+ if (lineHasInlineElementTag(line)) return null
266
+ if (/className\s*=/.test(line) && /\b(p|px|py|pt|pb|pl|pr)-\d+\b/.test(line)) {
267
+ return line.match(/\b(p|px|py|pt|pb|pl|pr)-\d+\b/)?.[0] ?? 'p-*'
268
+ }
269
+ return null
270
+ },
271
+ },
183
272
  ]
184
273
 
185
274
  // ============================================================================
@@ -286,13 +375,21 @@ function buildCssContext(lines, lineIndex) {
286
375
  /**
287
376
  * Audit the content of a single file. Returns array of findings.
288
377
  * Each finding: { id, label, severity, line, lineNumber, match, fixHint }
378
+ *
379
+ * @param {string} content
380
+ * @param {string} filePath
381
+ * @param {boolean} relaxed
382
+ * @param {{ strict?: boolean }} options
289
383
  */
290
- function auditFileContent(content, filePath, relaxed) {
384
+ function auditFileContent(content, filePath, relaxed, options = {}) {
291
385
  const findings = []
292
386
  const lines = content.split('\n')
293
387
  const css = isCssFile(filePath)
294
388
  const page = isPageFile(filePath)
295
389
 
390
+ // Find where the default export's return statement is (for page-root scoping)
391
+ const returnLineIndex = page ? findReturnLineIndex(lines) : -1
392
+
296
393
  for (let i = 0; i < lines.length; i++) {
297
394
  const line = lines[i]
298
395
  const lineNumber = i + 1
@@ -302,6 +399,7 @@ function auditFileContent(content, filePath, relaxed) {
302
399
  if (/tetra-style-audit-disable-next-line/.test(line)) continue
303
400
 
304
401
  const ctx = css ? buildCssContext(lines, i) : null
402
+ const pageCtx = page ? { returnLineIndex, lineIndex: i } : null
305
403
 
306
404
  // Determine which rules apply
307
405
  const criticalRules = CRITICAL_RULES.filter((r) => {
@@ -316,11 +414,14 @@ function auditFileContent(content, filePath, relaxed) {
316
414
  if (r.cssOnly && !css) return false
317
415
  if (!r.cssOnly && css) return false
318
416
  if (relaxed) return false // skip warnings for relaxed paths
417
+ if (r.strictOnly && !options.strict) return false
418
+ // nested-padding-consider-component only applies to page files
419
+ if (r.id === 'nested-padding-consider-component' && !page) return false
319
420
  return true
320
421
  })
321
422
 
322
423
  for (const rule of criticalRules) {
323
- const match = rule.check(line, ctx)
424
+ const match = rule.check(line, ctx, pageCtx)
324
425
  if (match) {
325
426
  findings.push({
326
427
  id: rule.id,
@@ -335,7 +436,7 @@ function auditFileContent(content, filePath, relaxed) {
335
436
  }
336
437
 
337
438
  for (const rule of warningRules) {
338
- const match = rule.check(line, ctx)
439
+ const match = rule.check(line, ctx, pageCtx)
339
440
  if (match) {
340
441
  findings.push({
341
442
  id: rule.id,
@@ -361,7 +462,7 @@ function auditFileContent(content, filePath, relaxed) {
361
462
  * Run the full style compliance audit.
362
463
  *
363
464
  * @param {string} projectRoot
364
- * @param {{ filesGlob?: string }} options
465
+ * @param {{ filesGlob?: string, strict?: boolean }} options
365
466
  * @returns {Promise<{ results: FileResult[], summary: Summary }>}
366
467
  */
367
468
  export async function runStyleComplianceAudit(projectRoot, options = {}) {
@@ -391,7 +492,7 @@ export async function runStyleComplianceAudit(projectRoot, options = {}) {
391
492
  continue
392
493
  }
393
494
 
394
- const findings = auditFileContent(content, filePath, relaxed)
495
+ const findings = auditFileContent(content, filePath, relaxed, options)
395
496
  const criticalCount = findings.filter((f) => f.severity === 'critical').length
396
497
  const warningCount = findings.filter((f) => f.severity === 'warning').length
397
498
 
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Code Quality Check: Tailwind @source Path Validation
3
+ *
4
+ * Tailwind v4 uses @source directives to tell it which files to scan for
5
+ * utility classes. Invalid @source paths are silently ignored — Tailwind
6
+ * emits no error and just skips them. This means a single wrong `..` can
7
+ * cause every class from a package to vanish at build time.
8
+ *
9
+ * WHAT IT CATCHES:
10
+ * - FAIL: @source path does not exist on disk (wrong number of `..`, typo, etc.)
11
+ * - FAIL: @source points to a node_modules package that is not in package.json
12
+ * - WARN: CSS file imports tetra-ui tokens but has no @source for tetra-ui dist
13
+ * (Tailwind will not pick up tetra-ui utility classes)
14
+ * - WARN: @source path exists on disk but the package is absent from package.json
15
+ * (orphaned path — will break after a clean install)
16
+ *
17
+ * HOW IT WORKS:
18
+ * 1. Scans project for CSS files containing `@import 'tailwindcss'` (v4 entry files).
19
+ * 2. For each file, extracts all @source directives (single-line, any quote style).
20
+ * 3. Resolves each path relative to the CSS file's own directory.
21
+ * 4. For glob paths (e.g. `../src/**\/*.{ts,tsx}`), strips the glob to find the
22
+ * base directory and checks that base exists.
23
+ * 5. For node_modules paths, also cross-checks package.json dependencies.
24
+ *
25
+ * Severity: high — broken @source paths cause silent layout/theming failures
26
+ * that are extremely hard to diagnose.
27
+ */
28
+
29
+ import { readFileSync, existsSync } from 'fs'
30
+ import { join, dirname, resolve, normalize } from 'path'
31
+ import { glob } from 'glob'
32
+
33
+ // ============================================================================
34
+ // META
35
+ // ============================================================================
36
+
37
+ export const meta = {
38
+ id: 'tailwind-source-paths',
39
+ name: 'Tailwind @source path validation',
40
+ category: 'codeQuality',
41
+ severity: 'high',
42
+ description: 'Validates that all @source directives in Tailwind v4 CSS entry files point to paths that exist on disk'
43
+ }
44
+
45
+ // ============================================================================
46
+ // HELPERS
47
+ // ============================================================================
48
+
49
+ const IGNORE_PATTERNS = [
50
+ '**/node_modules/**',
51
+ '**/.next/**',
52
+ '**/dist/**',
53
+ '**/build/**',
54
+ '**/.netlify/**',
55
+ ]
56
+
57
+ /**
58
+ * Read package.json from projectRoot and return a Set of all declared
59
+ * dependency names (dependencies + devDependencies + peerDependencies).
60
+ */
61
+ function loadDeclaredPackages(projectRoot) {
62
+ const pkgPath = join(projectRoot, 'package.json')
63
+ if (!existsSync(pkgPath)) return new Set()
64
+ try {
65
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
66
+ return new Set([
67
+ ...Object.keys(pkg.dependencies || {}),
68
+ ...Object.keys(pkg.devDependencies || {}),
69
+ ...Object.keys(pkg.peerDependencies || {}),
70
+ ])
71
+ } catch {
72
+ return new Set()
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Also check workspace-level package.json (monorepos where the lockfile lives
78
+ * at the root but the frontend package.json is in a sub-directory).
79
+ */
80
+ function loadAllDeclaredPackages(projectRoot, cssFilePath) {
81
+ const declared = loadDeclaredPackages(projectRoot)
82
+
83
+ // Walk up from the CSS file's directory to projectRoot, collecting all
84
+ // package.json files we encounter (stops at projectRoot).
85
+ let dir = dirname(cssFilePath)
86
+ while (dir.startsWith(projectRoot) && dir !== projectRoot) {
87
+ const pkgPath = join(dir, 'package.json')
88
+ if (existsSync(pkgPath)) {
89
+ try {
90
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
91
+ for (const dep of Object.keys(pkg.dependencies || {})) declared.add(dep)
92
+ for (const dep of Object.keys(pkg.devDependencies || {})) declared.add(dep)
93
+ for (const dep of Object.keys(pkg.peerDependencies || {})) declared.add(dep)
94
+ } catch { /* ignore */ }
95
+ }
96
+ dir = dirname(dir)
97
+ }
98
+
99
+ return declared
100
+ }
101
+
102
+ /**
103
+ * Given a raw @source path (may contain glob patterns), return the base
104
+ * directory to test for existence. We strip everything from the first glob
105
+ * metacharacter onwards.
106
+ *
107
+ * Examples:
108
+ * "../../node_modules/@soulbatical/tetra-ui/dist" → same (no glob)
109
+ * "../src/**\/*.{ts,tsx}" → "../src"
110
+ * "./components/**" → "./components"
111
+ */
112
+ function getBaseDir(rawPath) {
113
+ // Find first glob metacharacter: * ? { } [ ]
114
+ const globIndex = rawPath.search(/[*?{}\[\]]/)
115
+ if (globIndex === -1) return rawPath
116
+
117
+ // Strip back to the last path separator before the glob
118
+ const beforeGlob = rawPath.slice(0, globIndex)
119
+ const lastSep = Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
120
+ return lastSep === -1 ? '.' : beforeGlob.slice(0, lastSep) || '.'
121
+ }
122
+
123
+ /**
124
+ * Given a resolved absolute path, extract a package name if it sits inside
125
+ * a node_modules directory. Handles scoped packages (@scope/name).
126
+ *
127
+ * Returns null when the path is not inside node_modules.
128
+ */
129
+ function extractPackageName(resolvedPath) {
130
+ const nm = '/node_modules/'
131
+ const idx = resolvedPath.lastIndexOf(nm)
132
+ if (idx === -1) return null
133
+
134
+ const afterNm = resolvedPath.slice(idx + nm.length)
135
+ const parts = afterNm.split('/')
136
+
137
+ if (parts[0].startsWith('@') && parts.length >= 2) {
138
+ return `${parts[0]}/${parts[1]}`
139
+ }
140
+ return parts[0] || null
141
+ }
142
+
143
+ /**
144
+ * Parse all @source directives from CSS content.
145
+ * Returns array of { path, line } objects.
146
+ *
147
+ * Matches:
148
+ * @source "some/path";
149
+ * @source 'some/path';
150
+ * @source "../../../node_modules/@scope/pkg/dist";
151
+ */
152
+ function parseSourceDirectives(content) {
153
+ const results = []
154
+ const lines = content.split('\n')
155
+ const re = /^\s*@source\s+["']([^"']+)["']\s*;?\s*$/
156
+
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const m = lines[i].match(re)
159
+ if (m) {
160
+ results.push({ path: m[1], line: i + 1 })
161
+ }
162
+ }
163
+
164
+ return results
165
+ }
166
+
167
+ /**
168
+ * Check whether a CSS file imports tetra-ui tokens/dark-mode (indicating
169
+ * tetra-ui is in use) but has no @source pointing at tetra-ui's dist.
170
+ */
171
+ function importsTetraUi(content) {
172
+ return (
173
+ content.includes('@soulbatical/tetra-ui/styles/tokens.css') ||
174
+ content.includes('@soulbatical/tetra-ui/styles/dark-mode.css') ||
175
+ content.includes('@soulbatical/tetra-ui')
176
+ )
177
+ }
178
+
179
+ function hasTetraUiSource(sources) {
180
+ return sources.some(s => s.path.includes('@soulbatical/tetra-ui'))
181
+ }
182
+
183
+ /**
184
+ * Produce a human-readable relative path for display (relative to projectRoot).
185
+ */
186
+ function rel(projectRoot, absPath) {
187
+ return absPath.startsWith(projectRoot)
188
+ ? absPath.slice(projectRoot.length).replace(/^\//, '')
189
+ : absPath
190
+ }
191
+
192
+ // ============================================================================
193
+ // MAIN CHECK
194
+ // ============================================================================
195
+
196
+ export async function run(config, projectRoot) {
197
+ const result = {
198
+ passed: true,
199
+ findings: [],
200
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
201
+ details: {
202
+ cssFilesScanned: 0,
203
+ sourceDirectivesChecked: 0,
204
+ missingPaths: 0,
205
+ orphanedPackages: 0,
206
+ missingTetraUiSource: 0,
207
+ }
208
+ }
209
+
210
+ // Locate all CSS files in the project (excluding node_modules and build dirs)
211
+ const cssFiles = glob.sync('**/*.css', {
212
+ cwd: projectRoot,
213
+ ignore: IGNORE_PATTERNS,
214
+ absolute: true,
215
+ })
216
+
217
+ // Filter down to Tailwind v4 entry files (contain @import "tailwindcss")
218
+ const tailwindEntryFiles = []
219
+ for (const absPath of cssFiles) {
220
+ let content
221
+ try {
222
+ content = readFileSync(absPath, 'utf-8')
223
+ } catch {
224
+ continue
225
+ }
226
+ if (/@import\s+['"]tailwindcss['"]/.test(content)) {
227
+ tailwindEntryFiles.push({ absPath, content })
228
+ }
229
+ }
230
+
231
+ if (tailwindEntryFiles.length === 0) {
232
+ // No Tailwind v4 entry files found — check is not applicable
233
+ result.findings.push({
234
+ file: 'project',
235
+ line: 0,
236
+ severity: 'low',
237
+ message: 'No Tailwind v4 CSS entry file found (no file with @import "tailwindcss") — @source check skipped'
238
+ })
239
+ result.summary.low++
240
+ result.summary.total++
241
+ return result
242
+ }
243
+
244
+ result.details.cssFilesScanned = tailwindEntryFiles.length
245
+
246
+ for (const { absPath, content } of tailwindEntryFiles) {
247
+ const cssDir = dirname(absPath)
248
+ const relCssPath = rel(projectRoot, absPath)
249
+ const declaredPackages = loadAllDeclaredPackages(projectRoot, absPath)
250
+
251
+ const sources = parseSourceDirectives(content)
252
+ result.details.sourceDirectivesChecked += sources.length
253
+
254
+ // ── Check 1: each @source path must exist on disk ──────────────────────
255
+
256
+ for (const { path: sourcePath, line } of sources) {
257
+ const baseDir = getBaseDir(sourcePath)
258
+ const resolvedBase = resolve(cssDir, baseDir)
259
+ const normalizedBase = normalize(resolvedBase)
260
+ const exists = existsSync(normalizedBase)
261
+
262
+ const pkgName = extractPackageName(normalizedBase)
263
+ const isNodeModulesPath = pkgName !== null
264
+
265
+ if (!exists) {
266
+ result.passed = false
267
+ result.details.missingPaths++
268
+ result.summary.high++
269
+ result.summary.total++
270
+
271
+ // Build a "likely fix" hint for node_modules paths with too many `..`
272
+ let likelyFix = ''
273
+ if (isNodeModulesPath) {
274
+ // Try removing one `..` at a time and find the shortest working path
275
+ const parts = sourcePath.split('/')
276
+ for (let skip = 1; skip < parts.length; skip++) {
277
+ if (parts[skip - 1] !== '..') break
278
+ const candidate = parts.slice(skip).join('/')
279
+ const candidateResolved = resolve(cssDir, candidate)
280
+ if (existsSync(candidateResolved)) {
281
+ likelyFix = `\n Likely fix: change to "${candidate}" (one fewer ".." — count directory levels from the CSS file)`
282
+ break
283
+ }
284
+ }
285
+ if (!likelyFix) {
286
+ likelyFix = `\n Likely fix: count how many directories separate the CSS file from node_modules and adjust the "../" prefix accordingly`
287
+ }
288
+ }
289
+
290
+ result.findings.push({
291
+ file: relCssPath,
292
+ line,
293
+ severity: 'high',
294
+ message: [
295
+ `Tailwind @source path does not exist:`,
296
+ ` CSS file: ${relCssPath}:${line}`,
297
+ ` @source: ${sourcePath}`,
298
+ ` Resolved to: ${normalizedBase} ← MISSING`,
299
+ ``,
300
+ ` Tailwind v4 silently ignores invalid @source paths.`,
301
+ ` If this path targets a UI package, its utility classes will NOT be`,
302
+ ` picked up — causing layout, theming, and component breakage.`,
303
+ likelyFix,
304
+ ].filter(l => l !== undefined).join('\n'),
305
+ fix: likelyFix.trim() || 'Correct the relative path so it resolves to the actual directory.',
306
+ })
307
+ continue
308
+ }
309
+
310
+ // Path exists — now check package.json registration for node_modules paths
311
+ if (isNodeModulesPath) {
312
+ if (!declaredPackages.has(pkgName)) {
313
+ result.details.orphanedPackages++
314
+ result.summary.medium++
315
+ result.summary.total++
316
+
317
+ result.findings.push({
318
+ file: relCssPath,
319
+ line,
320
+ severity: 'medium',
321
+ message: [
322
+ `Tailwind @source references an undeclared package:`,
323
+ ` CSS file: ${relCssPath}:${line}`,
324
+ ` @source: ${sourcePath}`,
325
+ ` Package: ${pkgName}`,
326
+ ` Status: present on disk but NOT in any package.json (dependencies/devDependencies)`,
327
+ ``,
328
+ ` This path will break after a clean install. Add "${pkgName}" to package.json`,
329
+ ` or remove the @source directive if it is no longer needed.`,
330
+ ].join('\n'),
331
+ fix: `Add "${pkgName}" to package.json dependencies or devDependencies.`,
332
+ })
333
+ }
334
+ }
335
+ }
336
+
337
+ // ── Check 2: tetra-ui import without matching @source ──────────────────
338
+
339
+ if (importsTetraUi(content) && !hasTetraUiSource(sources)) {
340
+ result.details.missingTetraUiSource++
341
+ result.summary.medium++
342
+ result.summary.total++
343
+
344
+ result.findings.push({
345
+ file: relCssPath,
346
+ line: 0,
347
+ severity: 'medium',
348
+ message: [
349
+ `CSS file imports @soulbatical/tetra-ui styles but has no @source for tetra-ui dist:`,
350
+ ` CSS file: ${relCssPath}`,
351
+ ``,
352
+ ` Without a @source directive, Tailwind v4 will not scan tetra-ui's built`,
353
+ ` CSS/JS for utility classes. AppShell layout, theming tokens, and all`,
354
+ ` tetra-ui components will render without their Tailwind classes.`,
355
+ ``,
356
+ ` Fix: add this line after @import "tailwindcss":`,
357
+ ` @source "../../node_modules/@soulbatical/tetra-ui/dist";`,
358
+ ` (adjust the "../" prefix to match the depth of this CSS file)`,
359
+ ].join('\n'),
360
+ fix: 'Add @source "../../node_modules/@soulbatical/tetra-ui/dist"; (adjust path depth).',
361
+ })
362
+ }
363
+ }
364
+
365
+ return result
366
+ }
@@ -24,6 +24,7 @@ export * as uiTheming from './codeQuality/ui-theming.js'
24
24
  export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
25
25
  export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
26
26
  export * as mcpToolDocs from './codeQuality/mcp-tool-docs.js'
27
+ export * as tailwindSourcePaths from './codeQuality/tailwind-source-paths.js'
27
28
 
28
29
  // Health checks (project ecosystem)
29
30
  export * as health from './health/index.js'
package/lib/runner.js CHANGED
@@ -25,6 +25,7 @@ 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
27
  import * as darkModeCompliance from './checks/codeQuality/dark-mode-compliance.js'
28
+ import * as tailwindSourcePaths from './checks/codeQuality/tailwind-source-paths.js'
28
29
  import * as gitignoreValidation from './checks/security/gitignore-validation.js'
29
30
  import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
30
31
  import * as rlsLiveAudit from './checks/security/rls-live-audit.js'
@@ -126,7 +127,8 @@ const ALL_CHECKS = {
126
127
  fileSize,
127
128
  namingConventions,
128
129
  routeSeparation,
129
- darkModeCompliance
130
+ darkModeCompliance,
131
+ tailwindSourcePaths
130
132
  ],
131
133
  supabase: [
132
134
  rlsPolicyAudit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.23",
3
+ "version": "1.20.25",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },