diff --git a/package.json b/package.json index 1c293e18c4..ec02d92177 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts" }, "lint-staged": { - "*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched" + "*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched", + "*.{ts,tsx}": "node scripts/check-admin-colors.mjs" }, "dependencies": { "@base-ui/react": "1.7.0", diff --git a/scripts/check-admin-colors.mjs b/scripts/check-admin-colors.mjs new file mode 100644 index 0000000000..cfda9d9ae6 --- /dev/null +++ b/scripts/check-admin-colors.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * Checks admin source files for hardcoded color utilities that should use + * theme CSS variables instead. Exits 1 on violations. + * + * Usage: node scripts/check-admin-colors.mjs [file ...] + * When called with file arguments (by lint-staged), only those files are + * checked. Without arguments, scans all ROOTS. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +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", +]); + +// Patterns that indicate hardcoded theme colors +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) or documented overrides", + }, + { + 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) { + 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] + : []; + }); +} + +const cwd = process.cwd(); +const files = + process.argv.length > 2 + ? process.argv.slice(2).map((f) => resolve(f)) + : ROOTS.flatMap((r) => sourceFiles(r)); + +const violations = []; + +for (const file of files) { + const normalized = relative(cwd, file).replaceAll("\\", "/"); + if (ALLOWLIST.has(normalized)) continue; + if (normalized.endsWith(".test.ts") || normalized.endsWith(".test.tsx")) + continue; + if (!/\.(?:ts|tsx)$/.test(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]; + // skip comments + const trimmed = line.trimStart(); + if ( + trimmed.startsWith("//") || + trimmed.startsWith("*") || + trimmed.startsWith("/*") + ) + continue; + + for (const match of line.matchAll(rule.pattern)) { + violations.push({ + file: normalized, + line: i + 1, + match: match[0], + rule: rule.name, + message: rule.message, + }); + } + } + } +} + +if (violations.length > 0) { + console.error("\n🚫 Hardcoded color violations found in admin files:\n"); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.match}`); + console.error(` → ${v.message}`); + } + console.error( + `\n${violations.length} violation(s) found. Use theme CSS variables instead.\n`, + ); + process.exit(1); +} else { + console.log("āœ… No hardcoded color violations found."); +} diff --git a/src/app/(site)/developers/page.tsx b/src/app/(site)/developers/page.tsx index 35c3209d3f..7b17ebb266 100644 --- a/src/app/(site)/developers/page.tsx +++ b/src/app/(site)/developers/page.tsx @@ -304,9 +304,9 @@ const GROUPS: Group[] = [ ]; const METHOD_CLASS: Record = { - GET: "bg-green-600/20 text-[var(--color-text)]", + GET: "bg-primary/20 text-[var(--color-text)]", POST: "bg-[var(--color-primary)]/18 text-[var(--color-text)]", - DELETE: "bg-red-500/22 text-[var(--color-text)]", + DELETE: "bg-destructive/22 text-[var(--color-text)]", }; function AuthTag({ endpoint }: { endpoint: Endpoint }) { @@ -332,7 +332,7 @@ function AuthTag({ endpoint }: { endpoint: Endpoint }) { } return ( Public diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 029916e181..0cc720b1b3 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -91,8 +91,8 @@ export default async function LoginPage() { }} > - - + + {th("online", { count: online, hotel: "" }).trim()} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 898cdc5bd6..98e968ebea 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -232,8 +232,8 @@ export default async function Home() { }} > - - + + {th("online", { count: online, hotel: hotelName })} @@ -454,8 +454,10 @@ export default async function Home() { sizes="(max-width: 640px) 100vw, 400px" className="rounded-xl object-cover transition-all duration-300 group-hover:scale-105" /> -
- → +
+ + → +
-

+

{a.title}

- + {tpr("usersOnline", { count: online })}

1 && new Set(colors).size > 1; return ( -
+

diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index 656c020e02..aa14ba0582 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -228,7 +228,7 @@ export function ClientView({ // biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
-
+
{ if (e.target === e.currentTarget) setOpen(false); }} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 67151cab3c..e92c920749 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -180,7 +180,7 @@ export function RegisterForm({ > {(error || state.error) && (
{error || state.error} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 00edbb0f08..81c510953f 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -96,7 +96,7 @@ export async function SiteHeader() { ) : (

- - + + { - 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([]); - }); -}); diff --git a/src/lib/no-hardcoded-colors.test.ts b/src/lib/no-hardcoded-colors.test.ts new file mode 100644 index 0000000000..6c1485cea6 --- /dev/null +++ b/src/lib/no-hardcoded-colors.test.ts @@ -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([]); + }); +});