PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
Backend
Frontend
    Developer Guide
      OverviewGetting startedUI stackApp shell & sidebarAuthentication & sessionsSystem settingsUI componentsUI patternsIcon ConventionsInput AnatomyField Hints & TooltipsButton Style StandardsEntity metadatai18n — Translations on the FrontendThe AI Tool Loop (caller-driven)Error HandlingComposables (use* hooks)
      Components
      API Reference
Microservices
powered by Zudoku
Developer Guide

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 footerActions snippet.
  • 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> — FormPageLayout already provides the wrapper.
  • The form keeps the 2-column grid, validation (use:enhance, FormField/FormLabel/FormControl), and PasswordChecklist/custom components as needed.

Example — the Profile settings page:

Code
<FormPageLayout entity="user_profiles" rowUuid={userUuid} meta={metadata.state.meta} auditData={auditData} auditingColumns={auditingColumns} isCreatePage={isCreatePage} > {#snippet header()} <AppPageBreadcrumb segments={[...]} /> <h1 class="truncate text-xl font-semibold leading-tight"> {$t('shell.settings.profile.title')} </h1> {/snippet} {#snippet children()} <div class="flex-1 overflow-auto p-4"> <form id="profile-form" use:enhance> <div class="grid grid-cols-2 gap-6"> <div class="space-y-4"> <!-- Column 1 fields --> </div> <div class="space-y-4"> <!-- Column 2 fields --> </div> </div> </form> </div> {/snippet} {#snippet footerActions()} <Button type="submit" form="profile-form" disabled={!canSave}> {$t('common.save')} </Button> {/snippet} </FormPageLayout>

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 (NOT FormPageLayout) — 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 default tone="primary" — the full sky-to-indigo gradient, white text).
  • The form keeps ALL its characteristics: 2-column grid, validation, use:enhance, FormField/FormLabel/FormControl blocks, etc.
  • The form MUST be wrapped in a <Card> — AppPageScaffold does not provide a per-section card wrapper, only the outer page shell.

Example — the Credentials settings page (3 cards: Change Password, Passkeys, MFA):

Code
<AppPageScaffold> {#snippet header()} <AppPageBreadcrumb segments={[...]} /> <h1 class="truncate text-xl font-semibold leading-tight"> {$t('shell.settings.credentials.title')} </h1> {/snippet} {#snippet children()} <div class="flex-1 overflow-auto p-4"> <div class="space-y-6"> <!-- Card 1: Change password (self-contained component) --> <ChangePasswordCard /> <!-- Card 2: Passkeys (in-card list) --> <PasskeyEnrollment /> <!-- Card 3: MFA (in-card list) --> <MfaManagement /> </div> </div> {/snippet} </AppPageScaffold>

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:

VariantToneLookUse on detail pages
defaultprimary (default)Full sky→indigo gradient, white textDEFAULT PRIMARY — footer CTA in Layout 1, in-card CTA in Layout 2
softprimaryGradient border + subtle background, dark textIn-card CTA only in Layout 1 when a footer primary already exists
outline—Border with gradient, hover backgroundSecondary action (Cancel, etc.)
destructive—Rose→red gradientDelete / irreversible action
ghost—Transparent, hover backgroundIcon-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

TypeInternal layoutColumn management
FORMAt least 2 columns (grid grid-cols-2 gap-6)Managed by the form grid
LISTSingle columnManaged by each list item
TABLESingle columnManaged by the table component
KANBANSingle columnManaged by the kanban component

CTA placement in CardFooter

  • All CTAs go in CardFooter, never in CardContent.
  • 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-6 cancels the card root's py-6 bottom padding so the footer bleeds to the card's bottom edge. rounded-b-xl matches the card's rounded-xl. This mirrors DialogFooter's -mb-4 pattern.
  • If a card has no action, CardFooter is omitted entirely.

When NOT to use CardFooter

CardFooter is the correct footer for cards only. Other contexts have their own footer components:

  • BorderedDialog → use DialogFooter (needs -mx-4 -mb-4 rounded-b-xl to 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 grid row-start / row-span placement — not fixed-height spacers.

Example — the change-password card form layout:

Code
┌──────────────┐ ┌──────────────┐ │ Current │ │ │ │ Password │ │ (empty) │ │ │ │ │ │ New Password │ │ Checklist: │ │ │ │ ✓ 8+ chars │ │ Confirm │ │ ✓ 1 letter │ │ Password │ │ ✓ 1 number │ └──────────────┘ └──────────────┘
  • Col 1: current password (row 1), new password (row 2), confirm password (row 3).
  • Col 2: PasswordChecklist aligned with new password row, spanning rows 2-3 via row-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 onUpdate callback 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: required only.
  • 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: PasswordChecklist in the opposite column of the form grid, aligned via row-start / row-span.
  • MANDATORY: Async .refine() on the new_password Zod field that checks passwordPolicy.regex and uses passwordPolicy.state.errorLabelKey as the error message.
  • The refine MUST be async — it reads passwordPolicy.state at 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: required or 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 at size-5).
  • Card subtitle via CardDescription.
  • List items with rounded-md border-primary-gradient px-3 py-2 (the gradient border utility from src/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 a Trash2 icon at size-4 and an sr-only label 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
<div class="grid min-h-56 place-items-center p-3" data-testid="<scope>-empty"> <div class="relative flex flex-col items-center gap-2 text-center"> <div class="pb-watermark-empty"> <Icon class="size-20 text-muted-foreground" /> </div> <div class="text-sm font-medium text-muted-foreground"> {$t("<scope>.emptyTitle")} </div> <div class="text-xs text-muted-foreground"> {$t("<scope>.emptyHint")} </div> </div> </div>

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:

  1. 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 in CardFooter of each card that needs an action. Wrap any form in a <Card>.
  2. 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.
  3. 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.
  4. Is the in-card action in a Layout 2 page (no footer primary)?

    • Use DEFAULT primary (variant="default", default tone="primary").
    • Never use soft primary as the main card CTA in Layout 2.

Real examples in the codebase

PageLayoutScaffoldPrimary CTA location
/system/settings/profile1 (pure form)FormPageLayoutFooter (Save)
/system/settings/credentials2 (mixed)AppPageScaffoldCardFooter of each of the 3 cards
/system/settings/organizations/create1 (pure form)FormPageLayoutFooter (Save)
/system/settings/organizations/{uuid}1 (pure form)FormPageLayoutFooter (Save)
/system/settings/configurations2 (mixed)AppPageScaffoldFooter (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
Last modified on October 28, 2026
UI componentsIcon Conventions
On this page
  • What is a detail page?
  • The two layouts
    • Layout 1 — Pure form (TUTTO FORM)
    • Layout 2 — Mixed content (UN PO' FORM + UN PO' ALTRO)
  • Button variants recap
    • Soft primary vs default primary
  • Card Content Layout Standard
    • The 4 CardContent types
    • CTA placement in CardFooter
    • When NOT to use CardFooter
    • FORM type — field ordering
    • FORM type — validation scoping
    • Password input categories
  • In-card list pattern
    • Empty state (huge icon)
  • Decision checklist
  • Real examples in the codebase
  • Next steps