feat: enforce no hardcoded colors across entire CMS
Added scripts/check-admin-colors.mjs — scans all src/ files for: - text-white, text-black (use theme text vars) - bg-white, bg-black (use theme background/overlay vars) - bg/text/border/ring with gray/slate/zinc/stone palette - bg/text/border/ring with red/green/blue/etc palette Fixed 21 violations across 11 files: - Overlays: bg-black/* → bg-foreground/* - Text: text-white → text-primary-foreground - Backgrounds: bg-white/10 → bg-background/10 - Green accents: bg-green-* → bg-primary - Red accents: bg-red-* → bg-destructive Integrated into: - lint-staged: runs on every *.ts/*.tsx commit - vitest: src/lib/no-hardcoded-colors.test.ts replaces old audit test - Allowlist: shadcn/ui primitives (button, badge, dialog) + 4 graphical files
This commit is contained in:
1 parent
0fa832d577
commit
58c35a2920
15 files changed
+276
-129
No files matched your search
@@ -1,107 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = [
|
||||
"src/app/admin",
|
||||
"src/components/admin",
|
||||
"src/app/admin-next",
|
||||
"src/features/housekeeping",
|
||||
];
|
||||
const GRAPHICAL_ALLOWLIST = [
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/components/admin/media-grid.tsx",
|
||||
];
|
||||
|
||||
const DATA_COLOR_ALLOWLIST = [
|
||||
"src/app/admin/alerts/page.tsx",
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"src/app/admin/events/events-table.tsx",
|
||||
"src/app/admin/events/types/event-types-manager.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/help-questions/new/page.tsx",
|
||||
"src/app/admin/help-questions/[id]/page.tsx",
|
||||
"src/app/admin/prefixes/prefixes-client.tsx",
|
||||
"src/app/admin/tags/page.tsx",
|
||||
"src/app/admin/teams/page.tsx",
|
||||
"src/app/admin/theme/page.tsx",
|
||||
];
|
||||
|
||||
const PUBLIC_STRUCTURAL_TOKEN =
|
||||
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
|
||||
const HARDCODED_UI_PALETTE =
|
||||
/(?:text|bg|border|ring|from|to|via)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g;
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(path)
|
||||
: /\.(?:ts|tsx)$/.test(entry.name) &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".test.tsx")
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin theme source audit", () => {
|
||||
it("keeps ordinary admin text theme-aware", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (GRAPHICAL_ALLOWLIST.includes(normalized)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = source.match(
|
||||
/(?:text-(?:gray|slate)-(?:400|500|600|700|800|900)|text-white(?:\/\d+)?|\)\]0)/g,
|
||||
);
|
||||
if (risky)
|
||||
violations.push(`${normalized}: ${[...new Set(risky)].join(", ")}`);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps admin chrome independent from public structural colors", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (normalized.endsWith("admin-theme-source-audit.test.ts")) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky = [
|
||||
...(source.match(PUBLIC_STRUCTURAL_TOKEN) ?? []),
|
||||
...(source.match(HARDCODED_UI_PALETTE) ?? []),
|
||||
];
|
||||
const filtered = [
|
||||
...DATA_COLOR_ALLOWLIST,
|
||||
...GRAPHICAL_ALLOWLIST,
|
||||
].includes(normalized)
|
||||
? risky.filter((match) => match.startsWith("var("))
|
||||
: risky;
|
||||
if (filtered.length)
|
||||
violations.push(
|
||||
`${normalized}: ${[...new Set(filtered)].join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every import workflow on semantic admin status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky =
|
||||
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
|
||||
return risky.length
|
||||
? [
|
||||
`${relative(process.cwd(), file).replaceAll("\\", "/")}: ${[...new Set(risky)].join(", ")}`,
|
||||
]
|
||||
: [];
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = [
|
||||
"src/app",
|
||||
"src/components",
|
||||
"src/lib",
|
||||
"src/features",
|
||||
"src/hooks",
|
||||
];
|
||||
|
||||
const ALLOWLIST = new Set([
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/components/admin/media-grid.tsx",
|
||||
// shadcn/ui primitives — hardcoded colors are intentional design tokens
|
||||
"src/components/ui/button.tsx",
|
||||
"src/components/ui/badge.tsx",
|
||||
"src/components/ui/dialog.tsx",
|
||||
]);
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
name: "no-text-white",
|
||||
pattern: /text-white(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
|
||||
},
|
||||
{
|
||||
name: "no-bg-white",
|
||||
pattern: /bg-white(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme background colors (e.g. bg-background, bg-card, bg-surface)",
|
||||
},
|
||||
{
|
||||
name: "no-text-black",
|
||||
pattern: /text-black(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
|
||||
},
|
||||
{
|
||||
name: "no-bg-black-hardcoded",
|
||||
pattern: /bg-black(?:\/\d+)?/g,
|
||||
message: "Use theme overlay vars (e.g. bg-foreground/50)",
|
||||
},
|
||||
{
|
||||
name: "no-gray-palette",
|
||||
pattern:
|
||||
/(?:text|bg|border|ring)-(?:gray|slate|zinc|neutral|stone)-(?:[1-9]00|50)(?:\/\d+)?/g,
|
||||
message: "Use theme CSS variables instead of Tailwind gray palette",
|
||||
},
|
||||
{
|
||||
name: "no-colored-palette",
|
||||
pattern:
|
||||
/(?:text|bg|border|ring)-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme CSS variables (e.g. text-destructive, bg-accent) instead",
|
||||
},
|
||||
];
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(path)
|
||||
: /\.(?:ts|tsx)$/.test(entry.name) &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".test.tsx")
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("no hardcoded colors in CMS source", () => {
|
||||
it("flags all hardcoded color utilities across the codebase", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of sourceFiles(root)) {
|
||||
const normalized = relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
if (ALLOWLIST.has(normalized)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const lines = source.split("\n");
|
||||
|
||||
for (const rule of RULES) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
)
|
||||
continue;
|
||||
|
||||
for (const match of line.matchAll(rule.pattern)) {
|
||||
violations.push(
|
||||
`${normalized}:${i + 1} ${match[0]} — ${rule.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user