frontend/packages/ui is the shared component library. It is plain source, not a built package: main points at index.ts and apps/saas/next.config.ts lists @repo/ui in transpilePackages. Tailwind picks up its classes through @source "../../../packages/ui/components" in globals.css.

Importing

Both forms work:
Server components should import from the specific file when they only need a non client export such as cn. Most component files start with "use client".

Components

Radix comes from the single radix-ui package, for example import { Dialog as DialogPrimitive } from "radix-ui". lib/index.ts exports cn(...inputs), which is twMerge(clsx(inputs)).

Button

Notes:
  • The default variant is secondary, not primary. State the variant on the main action.
  • secondary and outline render identically: card surface, border-input, dark text.
  • All sizes share one radius (rounded-lg).
  • A leading SVG gets me-1.5 automatically. A second SVG is hidden.
  • Focus uses a visible ring (focus-visible:ring-2).

Dialog

DialogContent is fixed and centered, with max-w-lg, a blurred overlay and a close button at top-4 end-4. The content grid is grid-cols-[minmax(0,1fr)]. That is deliberate. A grid column defaults to auto minimum width, so one long unbroken string (a URL, a file name) used to push the dialog wider than the viewport. minmax(0, 1fr) lets the column shrink. apps/saas/modules/shared/components/dialog-layout.test.tsx asserts the class is present on both DialogContent and AlertDialogContent. When you put a Select inside a dialog form, give the trigger w-full so it follows the column. DialogContent and PopoverContent carry shadow-lg and shadow-md. Floating layers keep a shadow. In page containers do not.

Popover

PopoverContent defaults to align="center", sideOffset={4} and w-72 p-4. It renders in a portal. Use the shared Popover for floating pickers instead of hand positioned absolute boxes. The folder colour picker in templates/FolderColorPicker.tsx is the reference: a Popover with a fixed track grid of swatches.

Toasts

Toaster is mounted once in the root layout at top-right with a 5 second duration. Toasts are rendered with sonnerToast.custom, so they use the app’s own card markup and tokens.

serverExplanation

lib/server-error.ts extracts a sentence worth showing from a failed core API call:
  • Returns undefined when the error has a digest. That is a production server action error whose message Next.js has replaced.
  • Reads the status from the core-api request failed: <status> prefix, or from error.status.
  • Only 4xx failures are quoted. A 5xx or a network failure says nothing useful to a coach and might say too much about the server.
  • Strips the prefix and returns the rest when it is between 1 and 400 characters.
It accepts an Error or an ApiFailure. toastAction calls it for you. Pass errorDetail: false to suppress the detail line.

Form primitives

form.tsx is the shadcn wrapper around react-hook-form. FormField provides the field name through context, FormItem generates ids, FormControl wires aria-describedby and aria-invalid, and FormMessage prints the field error. Usage is in Forms.

Adding a component

components.json configures the shadcn CLI for this package: style default, rsc: true, base colour slate, CSS variables on, Tailwind CSS file ../../tooling/tailwind/theme.css, aliases @/components and @/lib.
shadcn-ui is the package script for pnpm dlx shadcn@latest. After generating:
  1. Change Radix imports to the radix-ui package form used by the other files.
  2. Replace physical classes with logical ones (ms-, pe-, start-, end-).
  3. Replace raw colours with tokens (bg-card, text-muted-foreground, border-border).
  4. Export it from index.ts.
  5. Run pnpm check:theme from the repo root. packages/ui is one of the two themed roots the check scans.
Check that a component does not already exist before adding one. Feature level building blocks (trainee picker, upload box, phone field) live in apps/saas/modules/shared/components, not here. See Shared components.

Icons

lucide-react is the icon set, imported by about 250 files. globals.css sets svg.lucide { stroke-width: 1.5 } globally. react-icons is also installed in saas and used in four files, for brand marks lucide does not have (Apple, Android, WhatsApp) and a few Phosphor glyphs in the phone country picker. Two inline SVGs exist: the Logo and the Google mark in modules/auth/constants/oauth-providers.tsx.