/** * Rule: prefer-tailwind * * Encourages using Tailwind className over inline style attributes. * - Detects files with a high ratio of inline `style` vs `className` usage * - Warns at each element using style without className when ratio exceeds threshold * - When preferSemanticColors is enabled, warns against hard-coded colors * - When useLlmSuggestions is enabled, uses Ollama to suggest semantic replacements */ import { readFileSync } from "fs"; import { dirname } from "path"; import { createRule, defineRuleMeta } from "../../utils/create-rule.js"; import type { TSESTree } from "@typescript-eslint/utils"; import { getColorSuggestions, formatSuggestionsForMessage, } from "./lib/color-suggester.js"; type MessageIds = | "preferTailwind" | "preferSemanticColors" | "preferSemanticColorsWithSuggestion" | "preferSemanticClassGroups" | "semanticOpacityModifier" | "componentVariantLeakage"; type Options = [ { /** Minimum ratio of style-only elements before warnings trigger (0-1). Default: 0.3 */ styleRatioThreshold?: number; /** Don't warn if file has fewer than N JSX elements with styling. Default: 3 */ minElementsForAnalysis?: number; /** Style properties to ignore (e.g., ["transform", "animation"] for dynamic values). Default: [] */ allowedStyleProperties?: string[]; /** Component names to skip (e.g., ["motion.div", "animated.View"]). Default: [] */ ignoreComponents?: string[]; /** Prefer semantic colors (bg-destructive) over hard-coded (bg-red-500). Default: true */ preferSemanticColors?: boolean; /** Hard-coded color names to allow when preferSemanticColors is enabled. Default: [] */ allowedHardCodedColors?: string[]; /** Use LLM (Ollama) to suggest semantic color replacements. Default: false */ useLlmSuggestions?: boolean; /** Prefer semantic component classes over dense visual utility clusters. Default: true */ preferSemanticClassGroups?: boolean; /** Number of visual utilities on one element/class string before warning. Default: 4 */ visualUtilityThreshold?: number; /** Minimum distinct visual utility groups before warning. Default: 2 */ visualUtilityMinGroups?: number; /** Disallow opacity modifiers on semantic color tokens. Default: true */ disallowSemanticOpacityModifiers?: boolean; /** Exact classes to allow even when they use semantic opacity modifiers. Default: [] */ allowedOpacityModifierClasses?: string[]; /** Exact classes to allow in visual utility cluster detection. Default: [] */ allowedVisualUtilityClasses?: string[]; /** Prefer component variants over bespoke visual className overrides. Default: true */ preferComponentVariants?: boolean; /** Optional component names to inspect; empty means any custom JSX component. */ componentVariantComponents?: string[]; /** Prop names that indicate the component already exposes styling variants. */ componentVariantProps?: string[]; /** Number of styling override classes before warning on a variant component. Default: 4 */ componentVariantClassThreshold?: number; /** Exact classes to allow in component variant leakage detection. Default: [] */ allowedComponentVariantClasses?: string[]; }? ]; /** * Rule metadata - colocated with implementation for maintainability */ export const meta = defineRuleMeta({ id: "prefer-tailwind", version: "1.3.0", name: "Prefer Tailwind", description: "Encourage Tailwind className over inline style attributes", defaultSeverity: "warn", category: "static", icon: "🎨", hint: "Prefers className over inline styles", defaultEnabled: true, isDirectoryBased: true, defaultOptions: [ { styleRatioThreshold: 0.3, minElementsForAnalysis: 3, allowedStyleProperties: [], ignoreComponents: [], preferSemanticColors: true, allowedHardCodedColors: [], useLlmSuggestions: false, preferSemanticClassGroups: true, visualUtilityThreshold: 4, visualUtilityMinGroups: 2, disallowSemanticOpacityModifiers: true, allowedOpacityModifierClasses: [], allowedVisualUtilityClasses: [], preferComponentVariants: true, componentVariantComponents: [], componentVariantProps: ["variant", "size"], componentVariantClassThreshold: 4, allowedComponentVariantClasses: [], }, ], optionSchema: { fields: [ { key: "styleRatioThreshold", label: "Style ratio threshold", type: "number", defaultValue: 0.3, description: "Minimum ratio (0-1) of style-only elements before warnings trigger", }, { key: "minElementsForAnalysis", label: "Minimum elements", type: "number", defaultValue: 3, description: "Don't warn if file has fewer styled elements than this", }, { key: "allowedStyleProperties", label: "Allowed style properties", type: "text", defaultValue: "", description: "Comma-separated list of style properties to allow (e.g., transform,animation)", }, { key: "ignoreComponents", label: "Ignored components", type: "text", defaultValue: "", description: "Comma-separated component names to skip (e.g., motion.div,animated.View)", }, { key: "preferSemanticColors", label: "Prefer semantic colors", type: "boolean", defaultValue: true, description: "Warn against hard-coded colors (bg-red-500) in favor of semantic theme colors (bg-destructive)", }, { key: "allowedHardCodedColors", label: "Allowed hard-coded colors", type: "text", defaultValue: "", description: "Comma-separated color names to allow when preferSemanticColors is enabled (e.g., gray,slate)", }, { key: "useLlmSuggestions", label: "Use LLM suggestions", type: "boolean", defaultValue: false, description: "When enabled, uses Ollama to suggest semantic color replacements based on your project's theme", }, { key: "preferSemanticClassGroups", label: "Prefer semantic class groups", type: "boolean", defaultValue: true, description: "Warn when one element uses many low-level visual utilities that should be captured by a semantic class", }, { key: "visualUtilityThreshold", label: "Visual utility threshold", type: "number", defaultValue: 4, description: "Minimum number of visual utilities in one class string before warning", }, { key: "visualUtilityMinGroups", label: "Visual utility group threshold", type: "number", defaultValue: 2, description: "Minimum number of distinct visual utility groups before warning", }, { key: "disallowSemanticOpacityModifiers", label: "Disallow semantic opacity modifiers", type: "boolean", defaultValue: true, description: "Warn on token opacity like text-foreground/80 or border-border/40", }, { key: "allowedOpacityModifierClasses", label: "Allowed opacity modifier classes", type: "text", defaultValue: "", description: "Comma-separated exact classes to allow with semantic opacity modifiers", }, { key: "allowedVisualUtilityClasses", label: "Allowed visual utility classes", type: "text", defaultValue: "", description: "Comma-separated exact visual utility classes to ignore in cluster detection", }, { key: "preferComponentVariants", label: "Prefer component variants", type: "boolean", defaultValue: true, description: "Warn when design-system components with variant or size props also carry bespoke styling classes", }, { key: "componentVariantComponents", label: "Component variant components", type: "text", defaultValue: "", description: "Optional comma-separated component names to inspect; empty means any custom JSX component with variant props", }, { key: "componentVariantProps", label: "Component variant props", type: "text", defaultValue: "variant,size", description: "Comma-separated prop names that indicate a component already exposes styling variants", }, { key: "componentVariantClassThreshold", label: "Component variant class threshold", type: "number", defaultValue: 4, description: "Minimum number of styling override classes before warning on a variant component", }, { key: "allowedComponentVariantClasses", label: "Allowed component variant classes", type: "text", defaultValue: "", description: "Comma-separated exact classes to ignore when checking component variant leakage", }, ], }, docs: ` ## What it does Detects files with a high ratio of inline \`style\` attributes versus \`className\` usage in JSX elements. Reports warnings on elements that use \`style\` without \`className\`, but only when the file exceeds a configurable threshold ratio. ## Why it's useful - **Consistency**: Encourages using Tailwind's utility classes for styling - **Maintainability**: Tailwind classes are easier to read and maintain than inline styles - **Performance**: Tailwind generates optimized CSS; inline styles can't be deduplicated - **Theming**: Tailwind classes work with dark mode and responsive variants ## Examples ### ❌ Incorrect (when file exceeds threshold) \`\`\`tsx // Many elements using style without className
Red text
Spaced

Paragraph

\`\`\` ### ✅ Correct \`\`\`tsx // Using Tailwind className
Red text
Spaced

Paragraph

// Both style and className (acceptable for dynamic values)
Mixed
\`\`\` ## Configuration \`\`\`js // eslint.config.js "uilint/prefer-tailwind": ["warn", { styleRatioThreshold: 0.3, // Warn when >30% of elements are style-only minElementsForAnalysis: 3, // Need at least 3 styled elements to analyze allowedStyleProperties: ["transform", "animation"], // Skip these properties ignoreComponents: ["motion.div", "animated.View"], // Skip animation libraries preferSemanticColors: true, // Warn on hard-coded colors like bg-red-500 allowedHardCodedColors: ["gray", "slate"], // Allow specific color palettes preferSemanticClassGroups: true, visualUtilityThreshold: 4, visualUtilityMinGroups: 2, disallowSemanticOpacityModifiers: true }] \`\`\` ## Semantic Colors When \`preferSemanticColors\` is enabled, the rule warns against hard-coded Tailwind color classes in favor of semantic theme colors: ### ❌ Hard-coded colors (when enabled) \`\`\`tsx
Error
\`\`\` ### ✅ Semantic colors (preferred) \`\`\`tsx
Error
\`\`\` Semantic colors like \`bg-background\`, \`text-foreground\`, \`bg-primary\`, \`bg-destructive\`, \`bg-muted\`, etc. work better with theming and dark mode. Colors that are always allowed: \`white\`, \`black\`, \`transparent\`, \`inherit\`, \`current\`. ## Semantic Class Groups When \`preferSemanticClassGroups\` is enabled, the rule warns when a single class string combines many low-level visual utilities such as background, border, radius, shadow, gradient, ring/outline, blur, and decoration classes. This catches generated component styling that should usually become a semantic project class such as \`brand-panel\`, \`ui-cell\`, or \`surface-card\`. ### ❌ Dense visual utility cluster \`\`\`tsx
\`\`\` ### ✅ Semantic class \`\`\`tsx
\`\`\` ## Semantic Opacity Modifiers When \`disallowSemanticOpacityModifiers\` is enabled, semantic color tokens with opacity suffixes are reported: \`\`\`tsx

\`\`\` Prefer a fully semantic token such as \`text-muted-foreground\`, or define a new theme token/class when the opacity represents a reusable state. ## Component Variant Leakage When \`preferComponentVariants\` is enabled, the rule checks custom JSX components that already use style-like variant props such as \`variant\` or \`size\`. It ignores lowercase HTML elements by default. When \`className\` adds several bespoke styling overrides across static strings, template chunks, and common class combiners, the rule warns because the styling probably belongs in a component variant or semantic project class. ### ❌ Bespoke overrides on a variant component \`\`\`tsx