Related Generic Skills
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 alsoprowler-test-ui)
Tech Stack (Versions)
textNext.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
CRITICAL: Component Library Rule
- ALWAYS: Use
shadcn/ui+ Tailwind (components/shadcn/) - NEVER: Add components to
components/ui/(temporary re-export shims for the prowler-cloud overlay only)
Design System Discipline (REQUIRED)
Applies to ALL UI work. The design system is the single source of truth — reuse it exactly, extend it deliberately.
- Reuse first, never reinvent. Before building anything, search
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. - Use exactly the defined variants/styles — no more, no less. At the call site, drive appearance through the component's
variant/size/toneprops. Never add ad-hoc visualclassName(color, opacity, hover/focus/disabled, spacing-for-looks) to shared controls (Button,SelectTrigger,SelectItem,Modal, badges…), and never skip the correct semantic variant. - Modals: only
@/components/shadcn/modal. Selects:components/shadcn/select. - Colors: reuse existing semantic tokens from
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. - Need a genuinely new variant/token? That is a design-system change: add it to the shared component API (with design sign-off), then consume it. It is never a call-site decision.
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.
DECISION TREES
Component Placement
textNew 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
Code Location
textServer 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.
Styling Decision
textTailwind class exists? → className Dynamic value? → style prop Conditional styles? → cn() Static only? → className (no cn()) Recharts/library? → CHART_COLORS constant + var()
Scope Rule (ABSOLUTE)
- Used 2+ places →
lib/ortypes/orhooks/(components go incomponents/{domain}/) - Used 1 place → keep local in feature directory
- This determines ALL folder structure decisions
Project Structure
textui/ ├── 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
Recharts (Special Case)
For Recharts props that don't accept className:
typescriptconst 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} />
Form + Validation Pattern
typescript"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> ); }
Commands
bash# 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
Batch vs Instant Component API (REQUIRED)
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.
typescript// ❌ 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.
Reuse Shared Display Utilities First (REQUIRED)
Before adding local display maps (labels, provider names, status strings, category formatters), search ui/types/* and ui/lib/* for existing helpers.
typescript// ✅ 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.
Derived State Rule (REQUIRED)
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.
typescript// ❌ 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:
typescript// Local state needed: user edits are buffered until "Apply" is clicked const [pending, setPending] = useState(initialValues);
Strict Key Typing for Label Maps (REQUIRED)
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.
typescript// ❌ 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>;
QA Checklist Before Commit
-
pnpm run typecheckpasses -
pnpm run lint:fixpasses -
pnpm run format:writepasses - Relevant E2E tests pass
- All UI states handled (loading, error, empty)
- No secrets in code (use
.env.local) - Error messages sanitized (no stack traces to users)
- Server-side validation present (don't trust client)
- Accessibility: keyboard navigation, ARIA labels
- Mobile responsive (if applicable)
Pre-Re-Review Checklist (Review Thread Hygiene)
Before requesting re-review from a reviewer:
- Every unresolved inline thread has been either fixed or explicitly answered with a rationale
- If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
- If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
- Re-request review only after all threads are in a clean state
Resources
- Documentation: See references/ for links to local developer guide

