/** * `styles/no-raw-color` — fires when a color-bearing CSS declaration or JSX * inline style uses a raw color literal (hex, rgb/rgba, hsl/hsla, named) and * the global `style.rawColors.forbid` policy is active. Token references * (`var(--...)`) and policy-supplied exemptions (`transparent`, etc.) pass. * * The rule looks at any `style_declaration` or `usage_inline_style` whose * value contains a recognizable color literal, not just declarations whose * property name suggests a color — backgrounds use `background:` shorthand, * borders use `border-color:`, and SVG uses `fill:`/`stroke:`. Detecting on * the *value* keeps the rule property-agnostic. */ import type { FactIndex, TokenDefinitionFact } from "../facts/index.js"; import { colorDistance, parseOpaqueColor } from "./color-math.js"; import { makeFinding } from "./finding.js"; import type { Finding, FindingReplaceStyleValueFix } from "./types.js"; import { detectRawColor, indexComponentByNodeId, isApplicableTokenReference, isExemptColor, readUsageNode, tokenReference, } from "./utils.js"; export const RULE_ID = "styles/no-raw-color"; export const RULE_VERSION = "1"; export function ruleStylesNoRawColor(ix: FactIndex): Finding[] { const policy = ix.policy.rawColorPolicy(); if (!policy) return []; const colorTokens = ix.tokens.byCategory("color"); const preferLabel = policy.prefer === "token" ? "design token" : "CSS variable"; const findings: Finding[] = []; for (const decl of ix.byKind("style_declaration")) { if (decl.property.startsWith("--") && decl.declaredTokenSource === true) continue; const color = detectRawColor(decl.value); if (!color) continue; if (isExemptColor(color, policy.except)) continue; const resolution = resolveColorToken(decl.property, color, colorTokens); const evidenceIds = resolution ? [decl.id, policy.id, resolution.token.id] : [decl.id, policy.id]; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: colorMessage(color, `\`${decl.property}\``, resolution, preferLabel), location: decl.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "style_declaration", file: decl.file, selector: decl.selector, declarationPath: decl.declarationPath, property: decl.property, value: color, }, fix: resolution?.kind === "exact" ? buildTokenFix(decl.property, decl.value, color, resolution) : undefined, attributes: { property: decl.property, rawValue: decl.value, color, source: "css", suggestedToken: resolution?.token.name, ...(resolution ? { tokenMatch: resolution.kind } : {}), }, }) ); } const componentByNode = indexComponentByNodeId(ix); for (const inline of ix.byKind("usage_inline_style")) { if (inline.valueKind === "css-variable") continue; const color = detectRawColor(inline.value); if (!color) continue; if (isExemptColor(color, policy.except)) continue; const node = readUsageNode(ix, inline.nodeId); if (!node) continue; const componentEvidenceId = componentByNode.get(node.id)?.id; const resolution = resolveColorToken(inline.property, color, colorTokens); const baseEvidence = componentEvidenceId ? [node.id, componentEvidenceId, inline.id, policy.id] : [node.id, inline.id, policy.id]; const evidenceIds = resolution ? [...baseEvidence, resolution.token.id] : baseEvidence; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: colorMessage(color, `inline \`${inline.property}\``, resolution, preferLabel), location: node.location, evidence: ix.evidence(evidenceIds), fingerprintIdentity: { source: "usage_inline_style", file: node.file, nodePath: node.nodePath, element: node.element, property: inline.property, value: color, }, fix: resolution?.kind === "exact" ? buildTokenFix(inline.property, inline.value, color, resolution) : undefined, attributes: { property: inline.property, rawValue: inline.value, color, source: "jsx", suggestedToken: resolution?.token.name, ...(resolution ? { tokenMatch: resolution.kind } : {}), }, }) ); } return findings; } interface TokenMatch { token: TokenDefinitionFact; /** True when several tokens share the value and none matches the property's * role — the swap is a guess, so the fix must not be applied automatically. */ ambiguous: boolean; } /** * How a raw color resolves against the project's tokens (mirrors the * `styles/no-raw-dimensions` resolution semantics): * - `exact` — value-identical token; deterministic fix unless role-ambiguous. * - `nearest` — no token has this exact value, but one is perceptually close * (an off-by-a-shade `#7c3acc` next to a `#7c3aed` accent token). Surfaced as * a `suggestedToken` hint on the message + attribute with NO fix object: * snapping changes the rendered color, and the conform engine auto-applies * token-backed fixes, so attaching one would silently rewrite the value. */ type ColorResolution = | { kind: "exact"; token: TokenDefinitionFact; ambiguous: boolean } | { kind: "nearest"; token: TokenDefinitionFact }; function resolveColorToken( property: string, raw: string, tokens: readonly TokenDefinitionFact[] ): ColorResolution | null { const exact = findTokenMatch(property, raw, tokens); if (exact) return { kind: "exact", token: exact.token, ambiguous: exact.ambiguous }; const nearest = findNearestColorToken(raw, tokens); return nearest ? { kind: "nearest", token: nearest } : null; } function colorMessage( color: string, propertyLabel: string, resolution: ColorResolution | null, preferLabel: string ): string { if (resolution?.kind === "nearest") { return `Raw color ${color} on ${propertyLabel}. Closest token is \`${resolution.token.name}\` (${String(resolution.token.value)}); use a ${preferLabel} instead.`; } return `Raw color ${color} on ${propertyLabel}. Use a ${preferLabel} instead.`; } /** * Redmean-weighted RGB distance cutoff for a "nearest" suggestion. Redmean * ranges 0–~765; ~60 keeps suggestions within the same visual family (an * off-by-one-shade brand color) while never bridging genuinely different hues. * Both sides parse through `parseOpaqueColor` (hex, rgb/rgba, hsl/hsla, * oklch), so the raw value and the token may be authored in different * notations and still match. */ const NEAREST_COLOR_MAX_DISTANCE = 60; function findNearestColorToken( raw: string, tokens: readonly TokenDefinitionFact[] ): TokenDefinitionFact | null { const target = parseOpaqueColor(raw); if (!target) return null; let best: TokenDefinitionFact | null = null; let bestDistance = Infinity; for (const token of tokens) { if (typeof token.value !== "string") continue; const candidate = parseOpaqueColor(token.value); if (!candidate) continue; const distance = colorDistance(target, candidate); // Strict `<` plus stable iteration keeps the pick deterministic on ties. if (distance < bestDistance) { best = token; bestDistance = distance; } } return bestDistance <= NEAREST_COLOR_MAX_DISTANCE ? best : null; } /** * Find a color token whose value matches the offending raw color exactly. * Comparison is normalized — short hex (`#abc`) is expanded to full hex * (`#aabbcc`) so `#fff` and `#ffffff` collapse to the same key. * * When multiple tokens share the value (e.g. `--color-bg` and `--color-on-x` * both `#ffffff`), prefer the one whose name matches the declaration's semantic * role (`color:` → foreground, `background:` → surface, `border:` → border) so * a foreground color is never auto-rewritten to a background token. If no token * matches the role, the swap is flagged ambiguous and emitted as a non-applied * suggestion rather than a deterministic fix. */ function findTokenMatch( property: string, raw: string, tokens: readonly TokenDefinitionFact[] ): TokenMatch | null { const target = normalizeColor(raw); const matches = tokens.filter((token) => normalizeColor(token.value) === target); if (matches.length === 0) return null; const role = colorRoleForProperty(property); if (matches.length === 1) { // A single value-match is safe to apply automatically *unless* the token's // own name signals a role that contradicts the property — e.g. a background // token (`--color-bg`) swapped into `color:` would make text track the page // surface and disappear on dark themes. In that case suggest, never apply. const conflicts = role ? tokenRoleConflicts(matches[0].name, role) : false; return { token: matches[0], ambiguous: conflicts }; } const roleMatch = role ? matches.find((token) => tokenNameMatchesRole(token.name, role)) : undefined; if (roleMatch) return { token: roleMatch, ambiguous: false }; return { token: matches[0], ambiguous: true }; } /** * True when a token's name matches a color role *other than* the property's and * not the property's own role — applying it would cross semantic roles (the * white-text → background-token trap). Role-neutral names (e.g. * `--color-brand-500`) never conflict, so a brand color used as text still gets * a deterministic swap. */ function tokenRoleConflicts(name: string, role: ColorRole): boolean { if (tokenNameMatchesRole(name, role)) return false; const others: ColorRole[] = (["fg", "bg", "border"] as ColorRole[]).filter((r) => r !== role); return others.some((other) => tokenNameMatchesRole(name, other)); } type ColorRole = "fg" | "bg" | "border"; function colorRoleForProperty(property: string): ColorRole | null { const p = property.toLowerCase(); if (p === "color" || p === "fill" || p === "stroke" || p.endsWith("text-color")) return "fg"; if (p === "background" || p.startsWith("background") || p === "fill") return "bg"; if (p.startsWith("border") || p.startsWith("outline") || p === "box-shadow") return "border"; return null; } function tokenNameMatchesRole(name: string, role: ColorRole): boolean { const n = name.toLowerCase(); if (role === "fg") return /\b(text|fg|foreground|content|ink|on-|label)\b|text|foreground/.test(n); if (role === "bg") return /\b(bg|background|surface|fill|elevated|canvas)\b|background|surface/.test(n); return /\b(border|outline|stroke|divider|ring)\b|border|outline/.test(n); } function normalizeColor(value: string): string { const lower = value.trim().toLowerCase(); const hex = /^#([0-9a-f]{3})$/.exec(lower); if (hex) { const [r, g, b] = hex[1]; return `#${r}${r}${g}${g}${b}${b}`; } const hex4 = /^#([0-9a-f]{4})$/.exec(lower); if (hex4) { const [r, g, b, a] = hex4[1]; return `#${r}${r}${g}${g}${b}${b}${a}${a}`; } return lower; } function buildTokenFix( property: string, rawValue: string, rawColor: string, match: TokenMatch ): FindingReplaceStyleValueFix { const reference = tokenReference(match.token.name); const value = replaceRawColor(rawValue, rawColor, reference); return { kind: "replaceStyleValue", title: `Replace ${property} with ${value}`, property, value, // Apply automatically only when the swap is unambiguous AND the reference // resolves verbatim. An ambiguous match (several tokens share the value, // none role-matching) or a token that can't be referenced from CSS (a Sass // *map* member, or a DTCG JSON token with no emitted custom property) is a // suggestion — applying it would cross roles or write non-compiling code. deterministic: !match.ambiguous && isApplicableTokenReference(match.token.referenceFormat), }; } function replaceRawColor(rawValue: string, rawColor: string, replacement: string): string { if (rawValue === rawColor) return replacement; if (rawValue.includes(rawColor)) return rawValue.replace(rawColor, replacement); const pattern = new RegExp(escapeRegExp(rawColor), "i"); return rawValue.replace(pattern, replacement); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }