Prowler UI-specific patterns. For generic patterns, see: typescript, react-19, nextjs-15, tailwind-4...
typescript - Const types, flat interfacesreact-19 - No useMemo/useCallback, compilernextjs-16 - App Router, Server Actionstailwind-4 - cn() utility, styling ruleszod-4 - Schema validationzustand-5 - State managementai-sdk-5 - Chat/AI featuresplaywright - E2E testing (see also prowler-test-ui)Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8
NextAuth 5.0.0-beta.30 | Recharts 2.15.4
shadcn/ui + Tailwind (components/shadcn/)components/ui/ (temporary re-export shims for the prowler-cloud overlay only)Applies to ALL UI work. The design system is the single source of truth β reuse it exactly, extend it deliberately.
components/shadcn/ and existing usages in the codebase for an equivalent. Do NOT create a custom component, modal wrapper, or primitive when one already exists.variant/size/tone props. Never add ad-hoc visual className (color, opacity, hover/focus/disabled, spacing-for-looks) to shared controls (Button, SelectTrigger, SelectItem, Modal, badgesβ¦), and never skip the correct semantic variant.@/components/shadcn/modal. Selects: components/shadcn/select.ui/styles/globals.css. No raw Tailwind color utilities (e.g. bg-blue-950/40), no hex. If no token fits, STOP and ask the design owner β do not invent or near-duplicate tokens.When reviewing UI PRs, flag: custom modals/primitives that duplicate shadcn, call-site visual className on shared controls, raw color utilities, and new variants/tokens introduced without going through the shared component API.
New UI primitive? β components/shadcn/ (shadcn/ui + Tailwind)
Used by 1 domain? β components/{domain}/
Used by 2+ domains? β components/shared/
Needs state/hooks? β "use client"
Server component? β No directive needed
Server action β actions/{feature}/{feature}.ts
Data transform β actions/{feature}/{feature}.adapter.ts
Types (shared 2+) β types/{domain}.ts
Types (local 1) β {feature}/types.ts
Utils (shared 2+) β lib/
Utils (local 1) β {feature}/utils/
Hooks (shared 2+) β hooks/
Hooks (local 1) β {feature}/hooks.ts
UI primitive β components/shadcn/
Domain component β components/{domain}/
Deprecated:
components/ui/is a temporary re-export shim that maps legacy import paths tocomponents/shadcn/for the prowler-cloud overlay. HeroUI is fully removed. Never add or import components here β use@/components/shadcn(primitives) or@/components/{domain}instead. Delete the shim once the cloud repo migrates to@/components/shadcn.
Tailwind class exists? β className
Dynamic value? β style prop
Conditional styles? β cn()
Static only? β className (no cn())
Recharts/library? β CHART_COLORS constant + var()
lib/ or types/ or hooks/ (components go in components/{domain}/)ui/
βββ app/
β βββ (auth)/ # Auth pages (login, signup)
β βββ (prowler)/ # Main app
β βββ compliance/
β βββ findings/
β βββ providers/
β βββ scans/
β βββ services/
β βββ integrations/
βββ components/
β βββ shadcn/ # shadcn/ui primitives (USE THIS)
β βββ shared/ # Cross-domain composed components (2+ domains)
β βββ ui/ # DEPRECATED shim β re-exports shadcn (do not use)
β βββ {domain}/ # Domain-specific (compliance, findings, providers, etc.)
β βββ filters/ # Filter components
β βββ graphs/ # Chart components
β βββ icons/ # Icon components
βββ actions/ # Server actions
βββ types/ # Shared types
βββ hooks/ # Shared hooks
βββ lib/ # Utilities
βββ store/ # Zustand state
βββ tests/ # Playwright E2E
βββ styles/ # Global CSS
For Recharts props that don't accept className:
const CHART_COLORS = {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)",
text: "var(--color-text)",
gridLine: "var(--color-border)",
};
// Only use var() for library props, NEVER in className
<XAxis tick={{ fill: CHART_COLORS.text }} />
<CartesianGrid stroke={CHART_COLORS.gridLine} />
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.email(), // Zod 4 syntax
name: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
export function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await serverAction(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
# Development
cd ui && pnpm install
cd ui && pnpm run dev
# Code Quality
cd ui && pnpm run typecheck
cd ui && pnpm run lint:fix
cd ui && pnpm run format:write
cd ui && pnpm run healthcheck # typecheck + lint
# Testing
cd ui && pnpm run test:e2e
cd ui && pnpm run test:e2e:ui
cd ui && pnpm run test:e2e:debug
# Build
cd ui && pnpm run build
cd ui && pnpm start
When a component supports both batch (deferred, submit-based) and instant (immediate callback) behavior, model the coupling with a discriminated union β never as independent optionals. Coupled props must be all-or-nothing.
// β NEVER: Independent optionals β allows invalid half-states
interface FilterProps {
onBatchApply?: (values: string[]) => void;
onInstantChange?: (value: string) => void;
isBatchMode?: boolean;
}
// β
ALWAYS: Discriminated union β one valid shape per mode
type BatchProps = {
mode: "batch";
onApply: (values: string[]) => void;
onCancel: () => void;
};
type InstantProps = {
mode: "instant";
onChange: (value: string) => void;
// onApply/onCancel are forbidden here via structural exclusion
onApply?: never;
onCancel?: never;
};
type FilterProps = BatchProps | InstantProps;
This makes invalid prop combinations a compile error, not a runtime surprise.
Before adding local display maps (labels, provider names, status strings, category formatters), search ui/types/* and ui/lib/* for existing helpers.
// β
CHECK THESE FIRST before creating a new map:
// ui/lib/utils.ts β general formatters
// ui/types/providers.ts β provider display names, icons
// ui/types/findings.ts β severity/status display maps
// ui/types/compliance.ts β category/group formatters
// β NEVER add a local map that already exists:
const SEVERITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
// ...duplicating an existing shared map
};
// β
Import and reuse instead:
import { severityLabel } from "@/types/findings";
If a helper doesn't exist and will be used in 2+ places, add it to ui/lib/ or ui/types/ and reuse it. Keep local only if used in exactly one place.
Avoid useState + useEffect patterns that mirror props or searchParams β they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
// β NEVER: Mirror props into state via effect
const [localFilter, setLocalFilter] = useState(filter);
useEffect(() => { setLocalFilter(filter); }, [filter]);
// β
ALWAYS: Derive directly
const localFilter = filter; // or compute inline
If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
// Local state needed: user edits are buffered until "Apply" is clicked
const [pending, setPending] = useState(initialValues);
Avoid Record<string, string> when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
// β Loose β typos compile silently
const STATUS_LABELS: Record<string, string> = {
actve: "Active", // typo, no error
};
// β
Tight β union key
type Status = "active" | "inactive" | "pending";
const STATUS_LABELS: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
// actve: "Active" β compile error
};
// β
Also fine β const satisfies
const STATUS_LABELS = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
} as const satisfies Record<Status, string>;
pnpm run typecheck passespnpm run lint:fix passespnpm run format:write passes.env.local)Before requesting re-review from a reviewer: