@almadar/eslint-plugin 2.14.0 → 2.16.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.14.0",
3
+ "version": "2.16.0",
4
4
  "private": false,
5
5
  "description": "Custom ESLint rules for Almadar component architecture",
6
6
  "main": "index.js",
@@ -9,6 +9,8 @@ const EXCEPTED_FILES = new Set([
9
9
  "DataGrid",
10
10
  "DataList",
11
11
  "TableView", // data-iteration table molecule (sibling of DataList/DataGrid)
12
+ "DocumentPanel", // single-record document surface; entity supplies the id its commit events carry
13
+ "DocumentDetails",// the document settings rail; entity supplies the id and property values its commits carry
12
14
  "ButtonGroup", // uses entity as string name for event context
13
15
  "FilterGroup", // uses entity as string name for filter events
14
16
  "SearchInput", // uses entity as string name for search events
@@ -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
+ };