UI patterns
Primebrick standardizes detail pages (any non-table page: form pages, settings sub-pages, entity edit/create pages) around two layouts. Following these layouts keeps validation UX, button hierarchy, and card wrapping consistent across the app.
What is a detail page?
A detail page is any page that is not a table/list page. Examples:
/system/settings/profile— profile form/system/settings/credentials— change password + passkeys + MFA/system/settings/organizations/create— org create form/system/settings/organizations/{uuid}— org edit form
Table pages (entity lists) use EntityTablePage → EntityListTable and are covered in
Entity list table. This page
only covers detail pages.
The two layouts
Layout 1 — Pure form (TUTTO FORM)
The entire content is a single 2-column form (grid grid-cols-2 gap-6)
with no other sections (no in-card lists, no extra cards, no info boxes).
Rules:
- Use
FormPageLayout— it provides the outer card wrapper (rounded-md border bg-background) and the audit footer. - The DEFAULT primary button lives in the footer via the
footerActionssnippet. - No other primary button may appear inside the content — the footer already holds the primary action.
- The footer MAY contain multiple CTAs (primary + secondary) if needed (e.g. Cancel + Save, Delete + Save).
- The form is NOT wrapped in an extra
<Card>—FormPageLayoutalready provides the wrapper. - The form keeps the 2-column grid, validation (
use:enhance,FormField/FormLabel/FormControl), andPasswordChecklist/custom components as needed.
Example — the Profile settings page:
Code
Layout 2 — Mixed content (UN PO' FORM + UN PO' ALTRO)
The page mixes a form with other content — in-card lists, info boxes, multiple cards, etc.
Rules:
- Use
AppPageScaffold(NOTFormPageLayout) — there is no single footer primary to render. - No primary button in the page footer.
- Each card that needs an action puts its own DEFAULT primary button
in
CardFooter(variant="default"with defaulttone="primary"— the full sky-to-indigo gradient, white text). - The form keeps ALL its characteristics: 2-column grid, validation,
use:enhance,FormField/FormLabel/FormControlblocks, etc. - The form MUST be wrapped in a
<Card>—AppPageScaffolddoes not provide a per-section card wrapper, only the outer page shell.
Example — the Credentials settings page (3 cards: Change Password, Passkeys, MFA):
Code
Each card component encapsulates its own CardHeader, CardContent,
and CardFooter. The page itself stays under 50 lines.
Button variants recap
The Button component (src/lib/components/ui/button/) supports these
variants relevant to detail pages:
| Variant | Tone | Look | Use on detail pages |
|---|---|---|---|
default | primary (default) | Full sky→indigo gradient, white text | DEFAULT PRIMARY — footer CTA in Layout 1, in-card CTA in Layout 2 |
soft | primary | Gradient border + subtle background, dark text | In-card CTA only in Layout 1 when a footer primary already exists |
outline | — | Border with gradient, hover background | Secondary action (Cancel, etc.) |
destructive | — | Rose→red gradient | Delete / irreversible action |
ghost | — | Transparent, hover background | Icon-only actions (trash, settings) |
Soft primary vs default primary
variant="soft" tone="primary" (gradient border, subtle background, dark text)
is used for in-card CTAs only when a DEFAULT primary already exists in the
footer (Layout 1 with extra in-card actions). When there is no footer primary
(Layout 2), in-card CTAs are DEFAULT primary — never soft primary.
Card Content Layout Standard
Every <Card> with interactive content follows a 3-zone vertical
structure:
The 4 CardContent types
| Type | Internal layout | Column management |
|---|---|---|
| FORM | At least 2 columns (grid grid-cols-2 gap-6) | Managed by the form grid |
| LIST | Single column | Managed by each list item |
| TABLE | Single column | Managed by the table component |
| KANBAN | Single column | Managed by the kanban component |
CTA placement in CardFooter
- All CTAs go in
CardFooter, never inCardContent. - All CTAs are right-aligned (
justify-end), grouped together. Secondary first (leftmost in group), primary last (rightmost). - Cards do NOT use
justify-between— that is dialog-only for strong secondary/primary separation. - CardFooter standard class:
bg-muted/50 border-t p-4 -mb-6 flex justify-end gap-2 rounded-b-xl. The-mb-6cancels the card root'spy-6bottom padding so the footer bleeds to the card's bottom edge.rounded-b-xlmatches the card'srounded-xl. This mirrorsDialogFooter's-mb-4pattern. - If a card has no action,
CardFooteris omitted entirely.
When NOT to use CardFooter
CardFooter is the correct footer for cards only. Other contexts have their own footer components:
BorderedDialog→ useDialogFooter(needs-mx-4 -mb-4 rounded-b-xlto bleed to dialog content edges).FormPageLayout→ use the built-in 50/50 grid footer (audit box left, actions right).
FORM type — field ordering
When CardContent is a FORM:
- The form uses
grid grid-cols-2 gap-6. - Fields are distributed across the 2 columns following reading flow (top-to-bottom, col 1 before col 2).
- Validation feedback (e.g.
PasswordChecklist) is placed in the column opposite to its related input, aligned via CSS gridrow-start/row-spanplacement — not fixed-height spacers.
Example — the change-password card form layout:
Code
- Col 1: current password (row 1), new password (row 2), confirm password (row 3).
- Col 2:
PasswordChecklistaligned with new password row, spanning rows 2-3 viarow-start-2 row-span-2.
FORM type — validation scoping
Each card that contains a form uses its own superForm() instance with
SPA: true mode — completely independent from the page and from other
cards. This ensures:
- Zod schemas are defined inside the card component, not at page level.
- Validation triggers only on user interaction (tainted fields) — errors do NOT appear on page load or when interacting with other cards.
- The
onUpdatecallback handles the API call and error mapping locally.
Password input categories
All password-type inputs use Password.PasswordInput (which includes an
eye/eye-off visibility toggle). But there are three categories with
different validation requirements:
Category 1 — Access password (login/auth): The user types a password to authenticate. The password is NOT saved, it is sent for verification only.
Password.PasswordInput(eye toggle).- No
PasswordChecklist, no policy.refine(). - Validation:
requiredonly. - Example: login dialog (
LoginForm.svelte).
Category 2 — Data-entry password (save): The user types a password that will be saved to the BE (create account, change password, set password on first login, admin setting a user's password).
Password.PasswordInput(eye toggle).- MANDATORY:
PasswordChecklistin the opposite column of the form grid, aligned viarow-start/row-span. - MANDATORY: Async
.refine()on thenew_passwordZod field that checkspasswordPolicy.regexand usespasswordPolicy.state.errorLabelKeyas the error message. - The refine MUST be async — it reads
passwordPolicy.stateat validation time (when the user types), not at schema creation time. - Load the policy via
onMount(() => { void passwordPolicy.load(); }). - Examples:
ChangePasswordCard.svelte,ChangePasswordDialog.svelte,users/create/+page.svelte,welcome/+page.svelte.
Category 3 — Secrets / API keys: API keys, client secrets, tokens — NOT user passwords, NOT subject to the password policy.
Password.PasswordInput(eye toggle).- No
PasswordChecklist, no policy.refine(). - Validation:
requiredor format-specific as needed. - Examples:
security/+page.svelte(OIDC client secret),email-providers/+page.svelte(API key).
OTP/code inputs (6-digit verification codes) are NOT password fields —
use OtpInput + useOtpInput from @primebrick/ui (digits-only cells, paste, auto-submit;
never a plain Input).
In-card list pattern
When a card contains a list of records (e.g. enrolled passkeys, MFA factors), use this shared structure so all in-card lists look consistent:
- Card title with an icon (
CardTitle class="flex items-center gap-2"+ a Lucide™ icon atsize-5). - Card subtitle via
CardDescription. - List items with
rounded-md border-primary-gradient px-3 py-2(the gradient border utility fromsrc/app.css). - Item icon at
size-5 text-muted-foreground shrink-0 mt-0.5. - Item title at
text-sm font-medium truncate. - Item meta lines at
text-xs text-muted-foreground. - Delete button at
variant="ghost" size="sm"with aTrash2icon atsize-4and ansr-onlylabel for accessibility. - Empty state using the huge-icon pattern (see below).
- Card-level CTA as a DEFAULT primary button in
CardFooter(works both when the list is empty and when it has items).
Empty state (huge icon)
When the list is empty, render the moduli-style empty state instead of a plain text paragraph:
Code
The pb-watermark-empty class (defined in src/app.css) applies a bounce +
opacity-pulse animation to the icon. The empty-state text uses two lines:
a primary line (emptyTitle, e.g. "No passkey found") and a hint line
(emptyHint, e.g. "Add a passkey to sign in faster without a password.").
Both are distinct in weight and size from the card subtitle.
The card-level CTA in CardFooter renders below the empty state, so
the user sees the "no record found" message and the primary action to add
one in the same card.
Decision checklist
Before adding any button to a detail page, answer these questions:
-
Is the entire content a single 2-col form with no other sections?
- Yes → Layout 1. Put the DEFAULT primary in
footerActions. Do not add any other primary inside the content. Do not wrap the form in a<Card>. - No → Layout 2. Use
AppPageScaffold. Do not put a primary in the page footer. Put a DEFAULT primary inCardFooterof each card that needs an action. Wrap any form in a<Card>.
- Yes → Layout 1. Put the DEFAULT primary in
-
Do I need a secondary action (Cancel, Delete) alongside the primary?
- Layout 1 → put both in
footerActions(primary + secondary). - Layout 2 → put both in the card's
CardFooter, grouped right (justify-end), secondary first, primary last.
- Layout 1 → put both in
-
Is the in-card action in a Layout 1 page (extra action inside a card while the footer already has the primary)?
- Use
variant="soft" tone="primary"for that in-card action. - Never use DEFAULT primary inside a Layout 1 card.
- Use
-
Is the in-card action in a Layout 2 page (no footer primary)?
- Use DEFAULT primary (
variant="default", defaulttone="primary"). - Never use soft primary as the main card CTA in Layout 2.
- Use DEFAULT primary (
Real examples in the codebase
| Page | Layout | Scaffold | Primary CTA location |
|---|---|---|---|
/system/settings/profile | 1 (pure form) | FormPageLayout | Footer (Save) |
/system/settings/credentials | 2 (mixed) | AppPageScaffold | CardFooter of each of the 3 cards |
/system/settings/organizations/create | 1 (pure form) | FormPageLayout | Footer (Save) |
/system/settings/organizations/{uuid} | 1 (pure form) | FormPageLayout | Footer (Save) |
/system/settings/configurations | 2 (mixed) | AppPageScaffold | Footer (Save OIDC) + Delete Account |
Next steps
- UI components — custom components built on Shadcn-Svelte™
- FormPageLayout — the Layout 1 scaffold with audit footer
- AppPageScaffold — the Layout 2 scaffold for mixed-content pages
- Entity list table — table pages (not covered by this page)
- Settings — the settings module overview