@almadar/eslint-plugin 2.13.0 → 2.15.0

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/index.js CHANGED
@@ -30,6 +30,9 @@ const organismRequireEntityRow = require("./rules/organism-require-entity-row");
30
30
  // Atom/molecule rules
31
31
  const atomMoleculeNoEntityProp = require("./rules/atom-molecule-no-entity-prop");
32
32
 
33
+ // Theme rules
34
+ const noHardcodedColors = require("./rules/no-hardcoded-colors");
35
+
33
36
  // Behavior rules
34
37
  const behaviorNoOrganisms = require("./rules/behavior-no-organisms");
35
38
 
@@ -89,6 +92,9 @@ const plugin = {
89
92
  // Atom/molecule rules
90
93
  "atom-molecule-no-entity-prop": atomMoleculeNoEntityProp,
91
94
 
95
+ // Theme rules
96
+ "no-hardcoded-colors": noHardcodedColors,
97
+
92
98
  // Behavior rules
93
99
  "behavior-no-organisms": behaviorNoOrganisms,
94
100
 
@@ -136,6 +142,7 @@ plugin.configs.recommended = {
136
142
  "almadar/no-import-generated": "error",
137
143
  "almadar/no-use-navigate": "error",
138
144
  "almadar/atom-molecule-no-entity-prop": "error",
145
+ "almadar/no-hardcoded-colors": "error",
139
146
 
140
147
  // Warn-level — conventions that should be followed
141
148
  "almadar/require-event-bus": "warn",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/eslint-plugin",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "private": false,
5
5
  "description": "Custom ESLint rules for Almadar component architecture",
6
6
  "main": "index.js",
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Disallow hardcoded palette colors in Tailwind class strings.
5
+ *
6
+ * @almadar/ui components must read color from theme tokens
7
+ * (text-foreground, bg-card, border-border, bg-primary, text-muted-foreground,
8
+ * bg-error, ...) so every theme — light or dark — stays readable. Hardcoded
9
+ * utilities (text-white, bg-gray-800, border-indigo-300, text-[#1a2b3c])
10
+ * ignore the active theme and are the root cause of dark-on-dark /
11
+ * light-on-light text, including on hover states.
12
+ *
13
+ * Arbitrary values that reference a CSS variable (bg-[var(--color-card)])
14
+ * are token usage and allowed. Intentional theme-independent paint (e.g.
15
+ * white caption over a media scrim) needs an explicit eslint-disable.
16
+ */
17
+
18
+ const PALETTE_NAMES =
19
+ "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|" +
20
+ "teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
21
+
22
+ const COLOR_UTILITIES =
23
+ "text|bg|border|ring|fill|stroke|from|via|to|divide|outline|shadow|" +
24
+ "decoration|placeholder|caret|accent";
25
+
26
+ const PALETTE_RE = new RegExp(
27
+ `^(?:${COLOR_UTILITIES})-(?:${PALETTE_NAMES})-\\d{2,3}(?:\\/[\\d.]+)?$`
28
+ );
29
+ const WHITE_BLACK_RE = new RegExp(
30
+ `^(?:${COLOR_UTILITIES})-(?:white|black)(?:\\/[\\d.]+)?$`
31
+ );
32
+ const ARBITRARY_COLOR_RE = /\[(?:#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/;
33
+
34
+ const SUGGESTIONS = {
35
+ "text-white": "text-primary-foreground (or the matching *-foreground token)",
36
+ "text-black": "text-foreground",
37
+ "bg-white": "bg-card",
38
+ "bg-black": "bg-background",
39
+ "text-gray-400": "text-muted-foreground",
40
+ "text-gray-500": "text-muted-foreground",
41
+ "text-gray-600": "text-muted-foreground",
42
+ "text-gray-700": "text-foreground",
43
+ "text-gray-900": "text-foreground",
44
+ "bg-gray-50": "bg-muted",
45
+ "bg-gray-100": "bg-muted",
46
+ "bg-gray-800": "bg-card",
47
+ "bg-gray-900": "bg-background",
48
+ };
49
+
50
+ function findHardcodedToken(classString) {
51
+ for (const rawToken of classString.split(/\s+/)) {
52
+ if (!rawToken) continue;
53
+ // Strip variant prefixes (hover:, dark:, group-hover:, sm:, ...)
54
+ const token = rawToken.slice(rawToken.lastIndexOf(":") + 1);
55
+ // Strip important modifier
56
+ const bare = token.startsWith("!") ? token.slice(1) : token;
57
+ if (PALETTE_RE.test(bare) || WHITE_BLACK_RE.test(bare)) {
58
+ return { token: rawToken, bare };
59
+ }
60
+ if (ARBITRARY_COLOR_RE.test(bare)) {
61
+ return { token: rawToken, bare };
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function checkClassString(context, node, classString) {
68
+ const hit = findHardcodedToken(classString);
69
+ if (!hit) return;
70
+ const suggestion = SUGGESTIONS[hit.bare];
71
+ context.report({
72
+ node,
73
+ messageId: suggestion ? "hardcodedWithSuggestion" : "hardcoded",
74
+ data: { token: hit.token, suggestion: suggestion || "" },
75
+ });
76
+ }
77
+
78
+ function isClassNameAttribute(node) {
79
+ return (
80
+ node.type === "JSXAttribute" &&
81
+ node.name.type === "JSXIdentifier" &&
82
+ (node.name.name === "className" || node.name.name === "class")
83
+ );
84
+ }
85
+
86
+ const CLASS_HELPERS = new Set(["cn", "clsx", "twMerge", "cva"]);
87
+
88
+ module.exports = {
89
+ meta: {
90
+ type: "problem",
91
+ docs: {
92
+ description:
93
+ "Disallow hardcoded Tailwind palette colors — use theme tokens (text-foreground, bg-card, ...) so every theme stays readable",
94
+ category: "Almadar Architecture",
95
+ },
96
+ messages: {
97
+ hardcoded:
98
+ "Hardcoded color '{{token}}' ignores the active theme (breaks light/dark contrast). Use a theme token instead (text-foreground, text-muted-foreground, bg-card, bg-primary, text-primary-foreground, border-border, ...).",
99
+ hardcodedWithSuggestion:
100
+ "Hardcoded color '{{token}}' ignores the active theme (breaks light/dark contrast). Use {{suggestion}} instead.",
101
+ },
102
+ schema: [],
103
+ },
104
+
105
+ create(context) {
106
+ function inspectString(node, value) {
107
+ if (typeof value === "string") checkClassString(context, node, value);
108
+ }
109
+
110
+ function walk(node) {
111
+ if (!node || typeof node !== "object") return;
112
+ if (node.type === "Literal") {
113
+ inspectString(node, node.value);
114
+ return;
115
+ }
116
+ if (node.type === "TemplateLiteral") {
117
+ for (const quasi of node.quasis) inspectString(quasi, quasi.value.cooked);
118
+ for (const expr of node.expressions) walk(expr);
119
+ return;
120
+ }
121
+ // cn()/clsx()/cva() arguments are reported by the CallExpression
122
+ // visitor — skip them here to avoid double-reporting.
123
+ if (
124
+ node.type === "CallExpression" &&
125
+ node.callee.type === "Identifier" &&
126
+ CLASS_HELPERS.has(node.callee.name)
127
+ ) {
128
+ return;
129
+ }
130
+ for (const key of Object.keys(node)) {
131
+ if (key === "parent" || key === "loc" || key === "range") continue;
132
+ const child = node[key];
133
+ if (Array.isArray(child)) child.forEach(walk);
134
+ else if (child && typeof child.type === "string") walk(child);
135
+ }
136
+ }
137
+
138
+ return {
139
+ JSXAttribute(node) {
140
+ if (!isClassNameAttribute(node) || !node.value) return;
141
+ if (node.value.type === "Literal") {
142
+ inspectString(node.value, node.value.value);
143
+ } else if (node.value.type === "JSXExpressionContainer") {
144
+ walk(node.value.expression);
145
+ }
146
+ },
147
+ CallExpression(node) {
148
+ const callee = node.callee;
149
+ if (callee.type !== "Identifier" || !CLASS_HELPERS.has(callee.name)) return;
150
+ for (const arg of node.arguments) walk(arg);
151
+ },
152
+ };
153
+ },
154
+ };