dslinter 0.3.0 → 0.5.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.
@@ -12,7 +12,7 @@ export function Section({
12
12
  id: string;
13
13
  className?: string;
14
14
  children: ReactNode;
15
- title: string;
15
+ title?: string;
16
16
  description?: string;
17
17
  actions?: ReactNode;
18
18
  }) {
@@ -18,7 +18,7 @@ const badgeVariants = cva(
18
18
  outline: "text-foreground",
19
19
  },
20
20
  size: {
21
- default: "px-3 py-0.5 text-xs",
21
+ default: "px-2 py-0.5 text-xs",
22
22
  sm: "px-2 py-0.5 font-mono text-xs font-normal leading-tight",
23
23
  },
24
24
  },
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useMemo } from "react";
2
2
  import {
3
3
  Table,
4
4
  TableBody,
@@ -9,10 +9,8 @@ import {
9
9
  } from "../components/ui/table";
10
10
  import type { UsageLocation, WorkspaceReport } from "../types/report";
11
11
  import { usageMap } from "./aggregate";
12
- import { openSourceFile } from "./editorLink";
13
- import { resolveReportAbsolutePath, shortPath } from "./paths";
14
12
  import { EmptyCard } from "../components/EmptyCard";
15
- import { TruncatedPath } from "../components/TruncatedPath";
13
+ import { SourceLocationLink } from "./SourceLocationLink";
16
14
 
17
15
  function formatCallSiteProps(loc: UsageLocation): string {
18
16
  if (!loc.props.length) return "—";
@@ -33,40 +31,6 @@ function sortedLocations(
33
31
  return list;
34
32
  }
35
33
 
36
- function UsageLocationLink({
37
- root,
38
- loc,
39
- }: {
40
- root: string;
41
- loc: UsageLocation;
42
- }) {
43
- const fileText = shortPath(root, loc.path);
44
- const locationText = `${fileText}:${loc.line}`;
45
- const absolutePath = resolveReportAbsolutePath(root, loc.path);
46
-
47
- const handleClick = useCallback(() => {
48
- void openSourceFile(absolutePath, loc.line).catch((err) => {
49
- const message = err instanceof Error ? err.message : String(err);
50
- window.alert(`Could not open file: ${message}`);
51
- });
52
- }, [absolutePath, loc.line]);
53
-
54
- return (
55
- <button
56
- type="button"
57
- onClick={handleClick}
58
- className="block min-w-0 w-full text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
59
- title={locationText}
60
- >
61
- <TruncatedPath
62
- path={`${fileText}:${loc.line}`}
63
- className="text-xs"
64
- title={undefined}
65
- />
66
- </button>
67
- );
68
- }
69
-
70
34
  export function ComponentUsageDetails({
71
35
  report,
72
36
  componentId,
@@ -117,7 +81,11 @@ export function ComponentUsageDetails({
117
81
  return (
118
82
  <TableRow key={`${loc.path}-${loc.line}-${i}`}>
119
83
  <TableCell className="min-w-0">
120
- <UsageLocationLink root={report.root} loc={loc} />
84
+ <SourceLocationLink
85
+ root={report.root}
86
+ path={loc.path}
87
+ line={loc.line}
88
+ />
121
89
  </TableCell>
122
90
  <TableCell className="min-w-0">
123
91
  <span
@@ -79,11 +79,7 @@ export function DashboardBody({
79
79
 
80
80
  <GovernanceInventoryTabs value={tab} onChange={setTab} counts={counts} />
81
81
 
82
- <Section
83
- id={section.id}
84
- title={section.title}
85
- description={section.description}
86
- >
82
+ <Section id={section.id}>
87
83
  {tab === "unused" ? (
88
84
  <UnusedComponentsList
89
85
  components={unusedComponents}
@@ -103,8 +99,7 @@ export function DashboardBody({
103
99
  className="font-medium text-foreground underline decoration-dotted underline-offset-2 transition hover:decoration-solid"
104
100
  >
105
101
  View all components
106
- </button>
107
- {" "}
102
+ </button>{" "}
108
103
  for prop usage and app reference details.
109
104
  </p>
110
105
  ) : null}
@@ -7,11 +7,11 @@ import {
7
7
  TableHeader,
8
8
  TableRow,
9
9
  } from "../components/ui/table";
10
- import { shortPath } from "./paths";
11
10
  import type { LintFinding, Severity } from "../types/report";
12
11
  import { Badge } from "../components/ui/badge";
12
+ import { EmptyCard } from "../components/EmptyCard";
13
13
  import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
14
- import { TruncatedPath } from "../components/TruncatedPath";
14
+ import { SourceLocationLink } from "./SourceLocationLink";
15
15
 
16
16
  type Filter = "all" | Severity;
17
17
 
@@ -24,6 +24,12 @@ function isFilter(value: string): value is Filter {
24
24
  );
25
25
  }
26
26
 
27
+ function emptyFilterMessage(filter: Exclude<Filter, "all">): string {
28
+ if (filter === "error") return "No errors in these findings.";
29
+ if (filter === "warning") return "No warnings in these findings.";
30
+ return "No info findings in these findings.";
31
+ }
32
+
27
33
  export function FindingsList({
28
34
  findings,
29
35
  root,
@@ -44,16 +50,15 @@ export function FindingsList({
44
50
  }, [findings]);
45
51
 
46
52
  const filtered = useMemo(
47
- () => (filter === "all" ? findings : findings.filter((f) => f.severity === filter)),
53
+ () =>
54
+ filter === "all"
55
+ ? findings
56
+ : findings.filter((f) => f.severity === filter),
48
57
  [findings, filter],
49
58
  );
50
59
 
51
60
  if (findings.length === 0) {
52
- return (
53
- <p className="rounded-lg border border-dashed border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
54
- No findings
55
- </p>
56
- );
61
+ return <EmptyCard>No findings</EmptyCard>;
57
62
  }
58
63
 
59
64
  return (
@@ -107,55 +112,58 @@ export function FindingsList({
107
112
  </ToggleGroupItem>
108
113
  </ToggleGroup>
109
114
 
110
- <Table className="mt-4">
111
- <TableHeader>
112
- <TableRow>
113
- <TableHead scope="col">Severity</TableHead>
114
- <TableHead scope="col">Rule</TableHead>
115
- <TableHead scope="col">Message</TableHead>
116
- <TableHead scope="col">File</TableHead>
117
- </TableRow>
118
- </TableHeader>
119
- <TableBody className="align-top text-foreground">
120
- {filtered.map((f, i) => (
121
- <TableRow
122
- key={`${f.rule_id}-${f.path}-${f.line ?? "x"}-${i}`}
123
- className="border-border hover:bg-transparent"
124
- >
125
- <TableCell className="px-3 py-2">
126
- <Badge
127
- variant={
128
- f.severity === "error"
129
- ? "destructive"
130
- : f.severity === "warning"
131
- ? "secondary"
132
- : "outline"
133
- }
134
- >
135
- {f.severity}
136
- </Badge>
137
- </TableCell>
138
- <TableCell className="px-3 py-2 font-mono text-xs text-muted-foreground">
139
- {f.rule_id}
140
- </TableCell>
141
- <TableCell className="whitespace-normal px-3 py-2 text-sm">
142
- {f.message}
143
- </TableCell>
144
- <TableCell className="min-w-0 px-3 py-2 font-mono text-xs text-muted-foreground">
145
- <div className="flex min-w-0 items-baseline">
146
- <TruncatedPath
147
- path={shortPath(root, f.path)}
148
- className="min-w-0 flex-1 text-xs"
149
- />
150
- <span className="shrink-0">
151
- :{f.line != null ? f.line : "—"}
152
- </span>
153
- </div>
154
- </TableCell>
115
+ {filtered.length === 0 ? (
116
+ <EmptyCard className="mt-4">
117
+ {filter === "all" ? "No findings" : emptyFilterMessage(filter)}
118
+ </EmptyCard>
119
+ ) : (
120
+ <Table className="mt-4">
121
+ <TableHeader>
122
+ <TableRow>
123
+ <TableHead scope="col">Severity</TableHead>
124
+ <TableHead scope="col">Rule</TableHead>
125
+ <TableHead scope="col">File</TableHead>
155
126
  </TableRow>
156
- ))}
157
- </TableBody>
158
- </Table>
127
+ </TableHeader>
128
+ <TableBody className="align-top text-foreground">
129
+ {filtered.map((f, i) => (
130
+ <TableRow
131
+ key={`${f.rule_id}-${f.path}-${f.line ?? "x"}-${i}`}
132
+ className="border-border hover:bg-transparent"
133
+ >
134
+ <TableCell className="px-3 py-2">
135
+ <Badge
136
+ variant={
137
+ f.severity === "error"
138
+ ? "destructive"
139
+ : f.severity === "warning"
140
+ ? "secondary"
141
+ : "outline"
142
+ }
143
+ >
144
+ {f.severity}
145
+ </Badge>
146
+ </TableCell>
147
+ <TableCell className="min-w-0 whitespace-normal px-3 py-2">
148
+ <div className="font-mono text-xs text-muted-foreground">
149
+ {f.rule_id}
150
+ </div>
151
+ <div className="mt-0.5 text-xs text-pretty text-foreground">
152
+ {f.message}
153
+ </div>
154
+ </TableCell>
155
+ <TableCell className="min-w-0 px-3 py-2">
156
+ <SourceLocationLink
157
+ root={root}
158
+ path={f.path}
159
+ line={f.line}
160
+ />
161
+ </TableCell>
162
+ </TableRow>
163
+ ))}
164
+ </TableBody>
165
+ </Table>
166
+ )}
159
167
  </div>
160
168
  );
161
169
  }
@@ -5,6 +5,7 @@ import {
5
5
  HoverCardTrigger,
6
6
  } from "../components/ui/hover-card";
7
7
  import { cn } from "../lib/utils";
8
+ import { EmptyCard } from "../components/EmptyCard";
8
9
  import { TruncatedPath } from "../components/TruncatedPath";
9
10
  import {
10
11
  filterTokenRows,
@@ -20,6 +21,14 @@ const filterTabs: { id: TokenUsageFilter; label: string }[] = [
20
21
  { id: "unused", label: "Unused" },
21
22
  ];
22
23
 
24
+ function emptyFilterMessage(filter: TokenUsageFilter): string {
25
+ if (filter === "used") return "No used theme tokens match this filter.";
26
+ if (filter === "unused") {
27
+ return "No unused theme tokens — every scanned token is referenced in the workspace.";
28
+ }
29
+ return "No theme tokens found.";
30
+ }
31
+
23
32
  function TokenSection({
24
33
  title,
25
34
  subtitle,
@@ -215,31 +224,37 @@ export function ScannedTokenWall({ view }: { view: MergedTokenView }) {
215
224
  </div>
216
225
  </div>
217
226
 
218
- <ColorSection rows={filtered} />
219
- <ListSection
220
- title="Spacing"
221
- subtitle="--spacing-* custom properties."
222
- rows={filtered}
223
- category="spacing"
224
- />
225
- <ListSection
226
- title="Radius"
227
- subtitle="--radius-* custom properties."
228
- rows={filtered}
229
- category="radius"
230
- />
231
- <ListSection
232
- title="Typography"
233
- subtitle="--font-* custom properties."
234
- rows={filtered}
235
- category="typography"
236
- />
237
- <ListSection
238
- title="Other"
239
- subtitle="Additional CSS variables."
240
- rows={filtered}
241
- category="other"
242
- />
227
+ {filtered.length === 0 ? (
228
+ <EmptyCard>{emptyFilterMessage(filter)}</EmptyCard>
229
+ ) : (
230
+ <>
231
+ <ColorSection rows={filtered} />
232
+ <ListSection
233
+ title="Spacing"
234
+ subtitle="--spacing-* custom properties."
235
+ rows={filtered}
236
+ category="spacing"
237
+ />
238
+ <ListSection
239
+ title="Radius"
240
+ subtitle="--radius-* custom properties."
241
+ rows={filtered}
242
+ category="radius"
243
+ />
244
+ <ListSection
245
+ title="Typography"
246
+ subtitle="--font-* custom properties."
247
+ rows={filtered}
248
+ category="typography"
249
+ />
250
+ <ListSection
251
+ title="Other"
252
+ subtitle="Additional CSS variables."
253
+ rows={filtered}
254
+ category="other"
255
+ />
256
+ </>
257
+ )}
243
258
  </section>
244
259
  );
245
260
  }
@@ -0,0 +1,40 @@
1
+ import { useCallback } from "react";
2
+ import { TruncatedPath } from "../components/TruncatedPath";
3
+ import { openSourceFile } from "./editorLink";
4
+ import { resolveReportAbsolutePath, shortPath } from "./paths";
5
+
6
+ export function SourceLocationLink({
7
+ root,
8
+ path,
9
+ line,
10
+ }: {
11
+ root: string;
12
+ path: string;
13
+ line?: number | null;
14
+ }) {
15
+ const fileText = shortPath(root, path);
16
+ const locationText = line != null ? `${fileText}:${line}` : fileText;
17
+ const absolutePath = resolveReportAbsolutePath(root, path);
18
+
19
+ const handleClick = useCallback(() => {
20
+ void openSourceFile(absolutePath, line ?? undefined).catch((err) => {
21
+ const message = err instanceof Error ? err.message : String(err);
22
+ window.alert(`Could not open file: ${message}`);
23
+ });
24
+ }, [absolutePath, line]);
25
+
26
+ return (
27
+ <button
28
+ type="button"
29
+ onClick={handleClick}
30
+ className="block min-w-0 w-full text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
31
+ title={locationText}
32
+ >
33
+ <TruncatedPath
34
+ path={locationText}
35
+ className="text-xs"
36
+ title={undefined}
37
+ />
38
+ </button>
39
+ );
40
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findColorTokenForHex, normalizeHex, parseCssColor } from "./css-color";
3
+ import type { CssTokenDefinition } from "../types/report";
4
+
5
+ function colorDef(name: string, value: string): CssTokenDefinition {
6
+ return {
7
+ name,
8
+ value,
9
+ category: "color",
10
+ scope: "theme",
11
+ path: "theme.css",
12
+ line: 1,
13
+ };
14
+ }
15
+
16
+ describe("css-color", () => {
17
+ it("normalizes shorthand hex", () => {
18
+ expect(normalizeHex("#abc")).toBe("#aabbcc");
19
+ expect(normalizeHex("#DC2626")).toBe("#dc2626");
20
+ });
21
+
22
+ it("parses rgb to hex", () => {
23
+ expect(parseCssColor("rgb(220, 38, 38)")).toBe("#dc2626");
24
+ expect(parseCssColor("rgba(220, 38, 38, 0.5)")).toBe("#dc2626");
25
+ });
26
+
27
+ it("finds token by normalized hex when value is rgb", () => {
28
+ const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
29
+ const token = findColorTokenForHex(defs, "#dc2626");
30
+ expect(token?.name).toBe("--color-danger");
31
+ });
32
+
33
+ it("finds token when value is hex and finding uses shorthand", () => {
34
+ const defs = [colorDef("--color-danger", "#dc2626")];
35
+ const token = findColorTokenForHex(defs, "#dc2626");
36
+ expect(token?.name).toBe("--color-danger");
37
+ });
38
+
39
+ it("resolves var() chains before matching", () => {
40
+ const defs = [
41
+ colorDef("--primary", "#93c5fd"),
42
+ colorDef("--color-primary", "var(--primary)"),
43
+ ];
44
+ const token = findColorTokenForHex(defs, "#93c5fd");
45
+ expect(token?.name).toBe("--color-primary");
46
+ });
47
+
48
+ it("returns undefined when no token matches", () => {
49
+ const defs = [colorDef("--color-danger", "rgb(220, 38, 38)")];
50
+ expect(findColorTokenForHex(defs, "#ff0066")).toBeUndefined();
51
+ });
52
+ });
@@ -0,0 +1,73 @@
1
+ import type { CssTokenDefinition } from "../types/report";
2
+
3
+ /** Expand `#abc` → `#aabbcc` (lowercase). */
4
+ export function normalizeHex(raw: string): string | null {
5
+ const s = raw.trim().replace(/^#/, "");
6
+ if (s.length === 3 && /^[0-9a-fA-F]{3}$/.test(s)) {
7
+ return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`.toLowerCase();
8
+ }
9
+ if (s.length === 6 && /^[0-9a-fA-F]{6}$/.test(s)) {
10
+ return `#${s}`.toLowerCase();
11
+ }
12
+ return null;
13
+ }
14
+
15
+ /** Parse hex or `rgb()`/`rgba()` into normalized `#rrggbb`. */
16
+ export function parseCssColor(raw: string): string | null {
17
+ const trimmed = raw.trim();
18
+ if (trimmed.startsWith("#")) {
19
+ return normalizeHex(trimmed);
20
+ }
21
+ const rgb = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
22
+ if (rgb) {
23
+ const [, r, g, b] = rgb;
24
+ const toHex = (n: string) => Number(n).toString(16).padStart(2, "0");
25
+ return `#${toHex(r!)}${toHex(g!)}${toHex(b!)}`;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const VAR_REF_RE = /var\(\s*(--[a-zA-Z0-9][a-zA-Z0-9_-]*)/;
31
+ const MAX_VAR_DEPTH = 8;
32
+
33
+ function resolveTokenValue(
34
+ value: string,
35
+ varMap: Map<string, string>,
36
+ depth = 0,
37
+ ): string | null {
38
+ if (depth > MAX_VAR_DEPTH) return null;
39
+ const trimmed = value.trim();
40
+ const varMatch = trimmed.match(VAR_REF_RE);
41
+ if (varMatch?.[1]) {
42
+ const next = varMap.get(varMatch[1]);
43
+ if (!next) return null;
44
+ return resolveTokenValue(next, varMap, depth + 1);
45
+ }
46
+ return trimmed;
47
+ }
48
+
49
+ /** Find a color token whose resolved value equals the given hex (after normalization). */
50
+ export function findColorTokenForHex(
51
+ definitions: CssTokenDefinition[] | undefined,
52
+ hex: string,
53
+ ): CssTokenDefinition | undefined {
54
+ const target = parseCssColor(hex);
55
+ if (!target || !definitions?.length) return undefined;
56
+
57
+ const varMap = new Map(definitions.map((d) => [d.name, d.value]));
58
+
59
+ const matches = definitions.filter((def) => {
60
+ if (def.category !== "color") return false;
61
+ const resolved = resolveTokenValue(def.value, varMap);
62
+ if (!resolved) return false;
63
+ return parseCssColor(resolved) === target;
64
+ });
65
+
66
+ if (matches.length === 0) return undefined;
67
+
68
+ return matches.sort((a, b) => {
69
+ const aPref = a.name.startsWith("--color-") ? 0 : 1;
70
+ const bPref = b.name.startsWith("--color-") ? 0 : 1;
71
+ return aPref - bPref;
72
+ })[0];
73
+ }
@@ -45,15 +45,15 @@
45
45
  "rule_id": "token-hardcoded-color",
46
46
  "pillar": "token",
47
47
  "default_severity": "warning",
48
- "description": "Hardcoded hex/rgb color instead of design tokens.",
48
+ "description": "Hardcoded hex/rgb color with no matching value in scanned CSS design tokens (className, style, or literals).",
49
49
  "fix_hint": "Replace with a CSS variable or Tailwind theme utility from css_tokens."
50
50
  },
51
51
  {
52
52
  "rule_id": "token-tailwind-arbitrary",
53
53
  "pillar": "token",
54
54
  "default_severity": "warning",
55
- "description": "Tailwind arbitrary value (e.g. bg-[#fff]) bypasses the token system.",
56
- "fix_hint": "Use a named theme utility or CSS custom property."
55
+ "description": "Tailwind arbitrary color or redundant size that duplicates the default scale or @theme spacing tokens.",
56
+ "fix_hint": "Use a named theme utility (e.g. w-20, p-layout-md) instead of an arbitrary bracket value."
57
57
  },
58
58
  {
59
59
  "rule_id": "token-unused-css-var",
@@ -115,8 +115,8 @@
115
115
  "rule_id": "code-inline-style",
116
116
  "pillar": "code",
117
117
  "default_severity": "warning",
118
- "description": "Inline JSX style={{}} bypasses token/Tailwind conventions.",
119
- "fix_hint": "Use className with theme utilities."
118
+ "description": "Inline JSX style uses hardcoded colors not in scanned CSS design tokens.",
119
+ "fix_hint": "Use className with theme utilities or CSS variables instead of inline color values."
120
120
  },
121
121
  {
122
122
  "rule_id": "code-empty-catch",
@@ -46,4 +46,28 @@ describe("verify-loop", () => {
46
46
  const fix = suggestFix(report, { rule_id: "token-hardcoded-color" });
47
47
  expect(fix?.fix_hint).toBeTruthy();
48
48
  });
49
+
50
+ it("matches rgb token values when suggesting hardcoded color fix", () => {
51
+ const report = loadDemoReport();
52
+ const fix = suggestFix(report, {
53
+ rule_id: "token-hardcoded-color",
54
+ message: "Hardcoded color `#dc2626` — no matching design token",
55
+ });
56
+ // Demo theme defines --color-danger as hex; rgb form should also resolve.
57
+ const rgbReport = {
58
+ ...report,
59
+ css_tokens: {
60
+ ...report.css_tokens!,
61
+ definitions: report.css_tokens!.definitions.map((d) =>
62
+ d.name === "--color-danger" ? { ...d, value: "rgb(220, 38, 38)" } : d,
63
+ ),
64
+ },
65
+ };
66
+ const rgbFix = suggestFix(rgbReport, {
67
+ rule_id: "token-hardcoded-color",
68
+ message: "Hardcoded color `#dc2626` — no matching design token",
69
+ });
70
+ expect(rgbFix?.token).toBe("--color-danger");
71
+ expect(fix?.fix_hint).toBeTruthy();
72
+ });
49
73
  });
@@ -1,4 +1,5 @@
1
1
  import { catalogSummary, componentSpec, findingsForPaths } from "./agent-query";
2
+ import { findColorTokenForHex } from "./css-color";
2
3
  import { ruleById } from "./rule-catalog";
3
4
  import type { LintFinding, WorkspaceReport } from "../types/report";
4
5
 
@@ -13,7 +14,7 @@ export type FixSuggestion = {
13
14
 
14
15
  export function suggestFix(
15
16
  report: WorkspaceReport,
16
- opts: { rule_id: string; path?: string; component?: string },
17
+ opts: { rule_id: string; path?: string; component?: string; message?: string },
17
18
  ): FixSuggestion | null {
18
19
  const entry = ruleById(opts.rule_id);
19
20
  const fix_hint = entry?.fix_hint ?? "Review the finding and align with design system conventions.";
@@ -42,19 +43,39 @@ export function suggestFix(
42
43
  }
43
44
 
44
45
  if (opts.rule_id === "token-hardcoded-color") {
45
- const colorToken = report.css_tokens?.definitions.find(
46
- (d) => d.category === "color",
47
- );
46
+ const hexMatch = opts.message?.match(/`(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))`/);
47
+ const hex = hexMatch?.[1]?.toLowerCase();
48
+ const colorToken =
49
+ hex != null
50
+ ? findColorTokenForHex(report.css_tokens?.definitions, hex)
51
+ : report.css_tokens?.definitions.find((d) => d.category === "color");
48
52
  return {
49
53
  rule_id: opts.rule_id,
50
54
  fix_hint,
51
55
  suggestion: colorToken
52
- ? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of a hardcoded color.`
53
- : "Replace hardcoded color with a CSS variable or Tailwind theme utility.",
56
+ ? `Use theme token \`${colorToken.name}\` or Tailwind utility bound to it instead of hardcoded \`${hex ?? "color"}\`.`
57
+ : "Replace hardcoded color with a CSS variable or Tailwind theme utility from css_tokens.",
54
58
  token: colorToken?.name,
55
59
  };
56
60
  }
57
61
 
62
+ if (opts.rule_id === "token-tailwind-arbitrary") {
63
+ const utilityMatch = opts.message?.match(/Use `([^`]+)` instead of arbitrary/);
64
+ if (utilityMatch?.[1]) {
65
+ return {
66
+ rule_id: opts.rule_id,
67
+ fix_hint,
68
+ suggestion: `Replace the arbitrary value with \`${utilityMatch[1]}\` from the Tailwind or @theme spacing scale.`,
69
+ };
70
+ }
71
+ return {
72
+ rule_id: opts.rule_id,
73
+ fix_hint,
74
+ suggestion:
75
+ "Replace the arbitrary bracket value with a named theme utility or CSS custom property.",
76
+ };
77
+ }
78
+
58
79
  if (opts.rule_id === "a11y-img-alt" && opts.path) {
59
80
  const decorative =
60
81
  /avatar|icon|logo|decorative|spacer/i.test(opts.path) ||
@@ -141,6 +162,7 @@ export function findingsWithSuggestions(
141
162
  rule_id: f.rule_id,
142
163
  path: f.path,
143
164
  component: componentMatch?.[1],
165
+ message: f.message,
144
166
  });
145
167
  return suggestion ? { ...f, suggestion } : f;
146
168
  });