diff --git a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts index b3e806056b..247a1e1fff 100644 --- a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts +++ b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, relative, resolve, sep } from "node:path"; import { describe, expect, it } from "vitest"; const componentsDir = resolve(__dirname, "..", "components"); @@ -8,27 +8,72 @@ function stripVarFallbackRgba(content: string): string { return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, ""); } -describe("component CSS color token hygiene", () => { - it("contains no raw rgb/rgba calls outside var() fallbacks", () => { - const cssFiles = readdirSync(componentsDir) - .filter((name) => name.endsWith(".css")) - .sort(); +function findComponentCssFiles(dir = componentsDir): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = entries.flatMap((entry) => { + const entryPath = join(dir, entry.name); - const violations: string[] = []; - - for (const fileName of cssFiles) { - const filePath = join(componentsDir, fileName); - const source = readFileSync(filePath, "utf8"); - const withoutFallbacks = stripVarFallbackRgba(source); - const lines = withoutFallbacks.split(/\r?\n/); - - for (let index = 0; index < lines.length; index += 1) { - if (/rgba?\(/.test(lines[index])) { - violations.push(`${fileName}:${index + 1}:${lines[index].trim()}`); - } - } + if (entry.isDirectory()) { + return findComponentCssFiles(entryPath); } - expect(violations).toEqual([]); + return entry.isFile() && entry.name.endsWith(".css") ? [entryPath] : []; + }); + + return files.sort((left, right) => + relative(componentsDir, left).localeCompare(relative(componentsDir, right)) + ); +} + +function formatComponentCssPath(filePath: string): string { + return relative(componentsDir, filePath).split(sep).join("/"); +} + +function findRawRgbViolations(source: string, fileName: string): string[] { + const withoutFallbacks = stripVarFallbackRgba(source); + const lines = withoutFallbacks.split(/\r?\n/); + + return lines.flatMap((line, index) => + /rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : [] + ); +} + +function buildRawRgbFailureMessage(violations: string[]): string { + return [ + "Raw rgb/rgba() found in component CSS.", + "Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead:", + ...violations, + ].join("\n"); +} + +describe("component CSS color token hygiene", () => { + it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => { + const source = [ + ".clean { color: var(--color-text); }", + ".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }", + ".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ].join("\n"); + + const violations = findRawRgbViolations(source, "fixture.css"); + + expect(violations).toEqual([ + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ]); + expect(buildRawRgbFailureMessage(violations)).toContain( + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }" + ); + expect(buildRawRgbFailureMessage(violations)).toContain( + "color-mix(in srgb, var(--color-X) N%, transparent)" + ); + }); + + it("contains no raw rgb/rgba calls outside var() fallbacks", () => { + const cssFiles = findComponentCssFiles(); + + const violations = cssFiles.flatMap((filePath) => + findRawRgbViolations(readFileSync(filePath, "utf8"), formatComponentCssPath(filePath)) + ); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); }); });