Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise
# Conflicts: # src/lib/admin-theme-source-audit.test.ts
This commit is contained in:
commit
5ca15d3f81
16 files changed
+292
-168
No files matched your search
+3
-2
@@ -24,10 +24,11 @@
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"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"
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/no-hardcoded-colors.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",
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -304,9 +304,9 @@ const GROUPS: Group[] = [
|
||||
];
|
||||
|
||||
const METHOD_CLASS: Record<Method, string> = {
|
||||
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 (
|
||||
<span
|
||||
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]"
|
||||
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-primary/20 text-[var(--color-text)]"
|
||||
title="No authentication required"
|
||||
>
|
||||
Public
|
||||
|
||||
@@ -91,8 +91,8 @@ export default async function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{th("online", { count: online, hotel: "" }).trim()}
|
||||
</div>
|
||||
|
||||
@@ -232,8 +232,8 @@ export default async function Home() {
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{th("online", { count: online, hotel: hotelName })}
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-black/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<span className="text-sm font-bold text-white">→</span>
|
||||
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-foreground/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<span className="text-sm font-bold text-primary-foreground">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
|
||||
@@ -464,7 +466,7 @@ export default async function Home() {
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<h3 className="truncate text-sm sm:text-base font-bold text-white text-shadow-sm">
|
||||
<h3 className="truncate text-sm sm:text-base font-bold text-primary-foreground text-shadow-sm">
|
||||
{a.title}
|
||||
</h3>
|
||||
<p
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function RegisterPage() {
|
||||
"var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="w-2 h-2 rounded-full bg-primary" />
|
||||
{tpr("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
|
||||
@@ -164,7 +164,7 @@ function PrefixDialog({
|
||||
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50">
|
||||
<div className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
|
||||
@@ -228,7 +228,7 @@ export function ClientView({
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
className={`fixed z-[9999] backdrop-blur-xl bg-foreground/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function CatalogManagerDialog({
|
||||
{mounted && open
|
||||
? createPortal(
|
||||
<div className="fixed inset-0 z-[200]">
|
||||
<div className="absolute inset-0 bg-black/70" />
|
||||
<div className="absolute inset-0 bg-foreground/70" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { modalContentVariants, modalOverlayVariants } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConfirmDialogProps = {
|
||||
@@ -20,10 +19,6 @@ export type ConfirmDialogProps = {
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Portal-based confirm modal (not Dialog.Root) so it can safely stack
|
||||
* above Visual Manager / other Base UI dialogs without nested-root bugs.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -62,65 +57,47 @@ export function ConfirmDialog({
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const isOpen = open;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen ? (
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="confirm-overlay"
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||
variants={modalOverlayVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="absolute inset-0 bg-black/95"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (!isLoading) onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
key="confirm-content"
|
||||
key="confirm-popup"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-desc"
|
||||
className="relative z-10 w-full max-w-md rounded-xl border-2 border-primary/40 bg-surface p-6 text-card-foreground shadow-[0_0_60px_-10px_rgba(245,158,11,0.3)]"
|
||||
variants={modalContentVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="fixed left-1/2 top-1/2 z-[200] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -4 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{variant === "danger" && (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/15">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--admin-error)]/15">
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
id="confirm-dialog-title"
|
||||
className="text-lg font-semibold leading-none"
|
||||
className="text-sm font-semibold text-[var(--admin-text)]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<p
|
||||
id="confirm-dialog-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
className="text-xs text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
@@ -129,6 +106,7 @@ export function ConfirmDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant === "danger" ? "destructive" : "default"}
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={handleConfirm}
|
||||
className={cn(
|
||||
@@ -141,7 +119,6 @@ export function ConfirmDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
|
||||
@@ -94,7 +94,7 @@ export function MediaPicker({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close media picker"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
|
||||
@@ -180,7 +180,7 @@ export function RegisterForm({
|
||||
>
|
||||
{(error || state.error) && (
|
||||
<div
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-white"
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-primary-foreground"
|
||||
style={{ backgroundColor: "var(--color-danger)" }}
|
||||
>
|
||||
{error || state.error}
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function SiteHeader() {
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h1
|
||||
className="font-black tracking-tight leading-none text-white"
|
||||
className="font-black tracking-tight leading-none text-primary-foreground"
|
||||
style={{
|
||||
fontFamily: "var(--font-nunito)",
|
||||
fontSize: "clamp(2rem, 7vw, 5.5rem)",
|
||||
@@ -133,8 +133,8 @@ export async function SiteHeader() {
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2 sm:h-2.5 sm:w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 sm:h-2.5 sm:w-2.5 bg-green-500" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 sm:h-2.5 sm:w-2.5 bg-primary" />
|
||||
</span>
|
||||
<span
|
||||
className="text-xs sm:text-sm font-bold tracking-wide"
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
</form>
|
||||
<Link
|
||||
href="/messages"
|
||||
className="relative flex items-center gap-1 rounded-lg px-1.5 sm:px-2 py-1.5 transition-colors hover:bg-white/10"
|
||||
className="relative flex items-center gap-1 rounded-lg px-1.5 sm:px-2 py-1.5 transition-colors hover:bg-background/10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -376,7 +376,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
<Link
|
||||
key={u.id}
|
||||
href="/messages"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-background/10"
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
|
||||
@@ -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/ase-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