UX guidelines
Conventions for the apps/auth admin dashboard and sign-in flows — components, confirmations, buttons, banners, icons, theming, and state.
Conventions for the apps/auth admin dashboard and sign-in flows. Follow these when adding or
changing UI so the experience stays consistent. All UI is Next.js + React + Tailwind, with icons from
lucide-react.
Shared component library
Reusable primitives live in apps/auth/src/components/ui (import via @/components/ui). Reach for
these instead of re-typing the class strings below — the class strings here remain the underlying
spec, but the primitives are the canonical way to apply them:
Button—variantofprimary|secondary|destructiveConfirm, optionalloading(swaps the leading icon toLoader2and disables) andicon(leadinglucideicon).IconButton— icon-only per-row action;variantofdefault|danger; requiresaria-label.Banner—variantoferror|success|info|warning; renders nothing whenmessageis falsy.SectionCard(titled, icon heading) andCard(bare wrapper), both with apaddingofsm|md|none.PageHeader— the icon + title row every page starts with, plus an optional right-alignedaction.Table/THead/TBody/Th/Td— table chrome (see Directory surfaces).Input,Select,Label,FormField.SpinnerandFullPageSpinner.InlineDeleteConfirm— the inline destructive-confirmation control (see below).SidePanelandConfirmDialog— the overlays (see Overlays).EmptyState— icon, title, description and the create CTA for an empty list.
SidePanel and ConfirmDialog are built on three hooks that live beside them — use-presence,
use-focus-trap, use-scroll-lock — which are deliberately not re-exported from the barrel.
Nothing in components/ui declares "use client"; every consumer is a _components/ child of a
client page, so a Server Component must not import the barrel.
Page-specific sub-components are co-located in a _components/ folder next to the route's page.tsx.
State stays in the page; _components/ children are presentational (props in, callbacks out) and do
not declare "use client". Purely ephemeral UI state (which row is asking for delete confirmation)
may stay local to the child.
Theme
The theme is apps/auth/src/app/theme.css, in two tiers:
- Palette — raw values (
--gray-*,--brand-*,--red-*, …). Referenced only by tier 2. - Roles — semantic names. The only thing components may reference.
Roles cover surfaces (--background, --surface, --surface-sunken, --surface-hover), text
(--foreground, --muted-foreground), edges (--border, --border-strong), brand (--brand,
--brand-hover, --brand-fg), status (--danger-*, --success-*, --warning-*, --accent-*) and
--scrim. Adding a theme is one more selector block remapping tier 2 — no component changes.
Every role needs every theme
A role defined in :root but missing from .dark silently keeps its light value in dark mode. The
@media (prefers-color-scheme: dark) fallback — which covers the moment before the inline theme
script runs — has to remap the same roles too.
Because roles flip on their own, component code carries no dark: variants. Write
bg-danger-bg text-danger-fg, not a light class plus a dark: counterpart.
node scripts/check-theme.mjs runs as part of npm run lint and fails the build on raw color ramps,
rounded-xl, or border-brand-muted outside theme.css. Without it these creep back within a few
PRs, and the nested-border look the visual system exists to prevent comes back with them.
Visual system
Depth comes from surface, not outline.
One border per boundary
A boundary gets exactly one edge, drawn by the container. Its children must not draw their own:
never nest a bordered container inside another bordered container, and separate list rows with
divide-y divide-border rather than a border each.
A card does legitimately pair border-border with bg-surface: in light mode surface and
background are the same white, so the hairline is what makes the card visible at all, while in dark
mode the raised surface does most of the work and the border only defines the edge.
Radius scale, named for intent so Tailwind's own rounded-sm|md|lg stays untouched:
| Token | Size | Use |
|---|---|---|
rounded-chip | 4px | badges, tags, checkboxes |
rounded-control | 6px | inputs, selects |
rounded-card | 8px | cards, panels, tables |
Buttons stay rounded-full. Never use sharp-cornered buttons.
Spacing, owned by the primitives rather than remembered per page: page gutter p-6; section gap
gap-6; card padding p-4 dense / p-6 for forms; table cell px-4 py-2.5; control gap gap-2.
Destructive actions
Never use browser dialogs
Do not use window.confirm() or any other browser dialog. Confirmations must be inline, in the
same row or card as the action that triggered them.
Inline row-confirmation pattern
State: hold the id of the row currently asking for confirmation, plus (optionally) the id of the row whose request is in flight.
confirmingDeleteUserId: string | null;
deletingUserId: string | null;Flow:
- First click (trash icon) → set
confirmingDeleteUserId. No request yet. - Second click ("Delete") → run the mutation. Track in-flight state on
deletingUserIdand show a spinner on the confirm button. - "Cancel" or successful completion clears
confirmingDeleteUserId.
While a row is in the confirming state, hide the other action buttons for that row so there is only one decision to make.
Use the shared InlineDeleteConfirm component (@/components/ui) to render the confirmation — the
page owns the confirmingDeleteXId / deletingXId state and renders
<InlineDeleteConfirm label="Delete X?" busy={deletingXId === row.id} onConfirm={…} onCancel={…} />
in place of the row's normal actions. The markup it produces is:
{confirmingDeleteUserId === user.id ? (
<>
<span className="text-xs text-danger-fg">Delete {label}?</span>
<button
type="button"
onClick={() => confirmDelete(user)}
disabled={deletingUserId === user.id}
className="inline-flex items-center gap-1 rounded-full border border-danger-border bg-danger-bg px-3 py-1 text-xs font-medium text-danger-fg hover:bg-danger-bg-hover disabled:opacity-50"
>
{deletingUserId === user.id
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Check className="h-3.5 w-3.5" />}
Delete
</button>
<button
type="button"
onClick={cancelDelete}
disabled={deletingUserId === user.id}
className="inline-flex items-center gap-1 rounded-full border border-border-strong px-3 py-1 text-xs hover:bg-surface-hover disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</>
) : (
/* regular action buttons including the trash icon */
)}For full-page destructive actions, use the same logic but render the confirmation as an inline card/banner above the action area rather than a modal.
The one exception
The unsaved-changes guard when dismissing a SidePanel uses ConfirmDialog, because what is
being confirmed is the dismissal itself — the surface an inline confirmation would attach to is the
very thing going away. Deleting a record is never a dialog.
Buttons
| Variant | Usage | Classes |
|---|---|---|
| Primary | The main call-to-action on a form or page. | rounded-full bg-brand px-5 py-2 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50 |
| Secondary / ghost | Neutral actions, dismissals, tertiary options. | rounded-full border border-border-strong px-4 py-2 text-sm font-medium hover:bg-surface-hover disabled:opacity-50 |
| Destructive confirm | The "yes, do it" button in a confirmation. | rounded-full border border-danger-border bg-danger-bg px-3 py-1 text-xs font-medium text-danger-fg hover:bg-danger-bg-hover disabled:opacity-50 |
| Icon-only | Per-row actions (edit, trash). Always include aria-label. | rounded-full p-1.5 text-muted-foreground hover:bg-surface-hover hover:text-foreground |
All buttons are pill-shaped (rounded-full). Never use sharp-cornered buttons.
Banners
Error and success messages appear as inline banners inside the section they relate to, not as toasts or modals.
// error
<p className="mb-3 rounded-card bg-danger-bg px-3 py-2 text-sm text-danger-fg">{message}</p>
// success
<p className="mb-3 rounded-card bg-success-bg px-3 py-2 text-sm text-success-fg">{message}</p>Clear the message when the user starts a new attempt at the same action so stale errors do not linger.
Cards and sections
Wrap related controls in a card:
<section className="rounded-card border border-border bg-surface p-6">
<h2 className="mb-4 flex items-center gap-2 text-lg font-medium">
<Icon className="h-5 w-5" />
{title}
</h2>
{children}
</section>Cards use rounded-card (not rounded-full). Every card heading takes a leading lucide-react icon
at h-5 w-5.
Directory surfaces
Users and Clients are directory surfaces and must stay identical to each other. This is the contract to review a change against:
- Page header —
PageHeaderwith the icon, title and a right-aligned<Button icon={Plus}>New …</Button>. No second "Manage X" card; the page title is the heading. - Toolbar — filter controls only, using
Select. - Table — one bordered
rounded-cardcontainer that owns the edge; rows separated bydivide-y, never a border each;theadon--surface-sunken. - Row actions —
IconButtononly, in a fixed order: surface-specific icons first, thenPencil, thenTrash2. No pills, no "View" link, no navigation. While confirming,InlineDeleteConfirmreplaces the whole group. - Clicking a row opens its edit panel. The actions cell calls
stopPropagation, so pressing a row action never also opens the panel. Keep thePencilanyway: a<tr>cannot carry button semantics cleanly, so the icon button remains the keyboard-reachable, labelled affordance and the row click is a pointer convenience on top of it. - Empty vs filtered —
EmptyStatewith the header's CTA when the collection is genuinely empty; a plain muted line when filters merely exclude everything. These are different messages: the fix for one is to create a record, for the other to change the filter. - Create and edit — one
SidePanel, titled "New X" / "Edit X", footer "Add X" / "Save X" plus Cancel. - Errors — page-level
Bannerfor list errors, in-panelBannerfor save errors. Render only one at a time; a page-level banner is invisible behind the scrim. - State —
panelOpen,editingId,draft,baseline,saving,confirmingDeleteId,deletingId.
A new directory surface should need no new layout class strings. If it does, the primitive is wrong — fix the primitive.
Overlays
SidePanel is for multi-field create/edit forms on a list surface. A genuinely single-field
entity keeps a compact inline add-row instead: a full-screen overlay to capture one text input costs
more screen than it saves.
The test is the entity, not the surface. Environments and scopes were single-field and used the inline row; once they grew a display name and consent copy they moved to a panel, because a stack of inputs wedged into a list row stops reading as a list and leaves no room to explain a field. When a field needs a caveat — "this name is used by live tokens" — it needs a panel.
A card-scoped listing puts its create CTA in the action slot on SectionCard, which mirrors the
CTA slot on PageHeader; don't hand-roll a header row.
Controlled, always. The consumer owns open. onRequestClose fires from the X, the scrim and
Escape, and the panel never closes itself — that is precisely what allows the dirty guard to
interpose, and what lets the client panel refuse to dismiss while a one-time secret is on screen.
Motion contract. Enter/exit use tw-animate-css (animate-in / animate-out), the sanctioned
animation layer. The JS duration constant must match the duration-* class on the animated node.
Never unmount an overlay directly on the open flag — that skips the exit animation; use usePresence,
which keeps the node mounted for the exit and unmounts on a timer, because under
prefers-reduced-motion no animationend would ever fire.
Accessibility contract. role="dialog" aria-modal="true", aria-labelledby the title,
tabIndex={-1} on the container, focus trapped, initial focus on [data-autofocus] (else the first
focusable), focus restored to the trigger on close, and body scroll locked with scrollbar-gutter
compensation so the page does not shift. The scroll lock is reference-counted so a nested dialog
closing does not unlock the page under an open panel.
Stacking. Panel z-50, nested dialog z-[60].
The panel is a containing block
An animated panel is a transformed element, so position: fixed inside its children resolves
against the panel, not the viewport. Nested overlays must render into their own portal as a sibling
of the panel — never inside its children, or they are clipped into the panel's width.
Dirty guard. Capture a baseline draft when the panel opens and compare the persisted projection
— trim text, sort unordered id arrays — so reformatting and checkbox order do not read as edits. Show
ConfirmDialog with emphasis="cancel" so Enter cannot discard the work. closePanel must not reset
the draft: the panel keeps rendering its children while it animates out, so clearing them slides out
an empty form.
Long forms pin their actions in the panel footer and link the submit button to the form with the
HTML form attribute.
Mobile is not a design target for the admin surface; w-full below sm is the whole story.
File uploads
Uploads are staged, never written straight to their final location.
- The upload endpoint validates the file and writes it under
staging/<uuid>.<ext>, returning that key. It does not touch the live asset or the record. - The form holds the staged key in its draft, previews the file from a local object URL, and counts it as an unsaved change like any other field.
- Saving passes the staged key to the service, which promotes it — copy to the final key, then delete the staged object — and records the result.
Cancelling therefore leaves the current avatar or logo exactly as it was, and a rejected file never overwrites a good one.
Staging is a form pattern, not an API change
Two steps only make sense where there is a Save button to press. The public
API stays one call: POST /api/users/avatar sets the avatar and answers with
{ ok, avatarKey, picture }, exactly as it always has — it stages and
promotes inside the request, so the hop is an implementation detail rather
than something callers must learn.
Forms use the session-only POST /api/users/avatar/stage, which returns
{ ok, stagedKey, contentType } and applies nothing. Never change an existing
endpoint's response shape to add a form pattern: an integration that was
reading picture would break with no error, and the picture would silently
stop updating.
Only promote from staging
promoteStagedUpload refuses any key outside the staging/ prefix, and any
key containing a path separator or ... The key comes from the client, so
without that check a request could name another record's asset and have it
copied over its own target. Final keys are always derived server-side from the
record id — never from anything the caller sent.
Staged objects that are never promoted are orphans. Configure an R2 lifecycle
rule expiring the staging/ prefix after a day; a browser that closes mid-edit
cannot be relied on to clean up.
Testing uploads locally
In production the bucket sits behind a CDN on its own domain, so every asset
load is cross-origin. npm run dev therefore starts a second server
(scripts/dev-cdn.mjs, port 8788) that serves the bucket with CORS headers, so
that split exists locally too. Point Setup → Site identity → CDN URL at
http://localhost:8788. It proxies to a dev-only route that reads the R2
binding, and that route is disabled when NODE_ENV is production.
Empty states
Use EmptyState when a list can be empty and there is a create affordance; it owns the CTA. Copy
names the action, never the layout — never write "Add one above", which stops being true the
moment the form moves into a panel.
Forms
- Labels:
mb-1 block text-sm text-muted-foreground. - Inputs:
w-full rounded-control border border-border bg-background px-3 py-2 ...withfocus:border-brand focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50. - Read-only fields: keep the same input classes and add
readOnly disabled. Do not hide them; show them disabled so the user sees the value. - Two-column layouts:
grid gap-4 sm:grid-cols-2. Collapse to one column on mobile.
Loading states
- Use
<Loader2 className="h-4 w-4 animate-spin" />(size adjusted to context) for in-flight feedback. - Full-page loaders center the spinner:
flex min-h-screen items-center justify-center p-6. - Button loading: keep the button mounted, swap its icon to
Loader2, and disable it. Do not replace the button with a bare spinner. - Track per-row loading with an id field (
uploadingAvatarUserId,deletingUserId, etc.), not a boolean, so concurrent actions on different rows remain independent. FullPageSpinneris for the first load only. A refetch after a mutation must not unmount the page: pass asilentflag so the section, its in-flight controls, and any open overlay stay mounted. Give the acting control its own in-flight state — do not let a full-page spinner double as the thing that prevents a second submit.
Icons
Icons come from lucide-react. Match the established vocabulary:
| Concept | Icon |
|---|---|
| User / profile | UserCircle |
| Edit | Pencil |
| Delete | Trash2 |
| Confirm / done | Check |
| Cancel / dismiss | X |
| Loading | Loader2 with animate-spin |
| Upload | Upload |
| Passkey / biometric | Fingerprint |
| Verified | BadgeCheck (green) |
| Not verified | BadgeX (amber) |
| Reset / retry | RotateCcw |
| Audit log / list | ClipboardList (audit), ListTodo (token activity) |
| Password | Lock |
| Test client / test user | FlaskConical (with the warning role, not accent) |
| Add / create | Plus |
| General settings | SlidersHorizontal |
| Image placeholder | ImageIcon |
Inline icons in flow text: h-3.5 w-3.5. Row action icons: h-4 w-4. Section-heading icons:
h-5 w-5. Avatars / large affordances: h-6 w-6 or larger.
Colors
Always use role tokens; raw Tailwind color ramps are rejected by the lint step.
| Purpose | Token |
|---|---|
| Page / raised / recessed background | bg-background, bg-surface, bg-surface-sunken |
| Hover fill | bg-surface-hover |
| Text | text-foreground, text-muted-foreground |
| Edges | border-border (default hairline), border-border-strong (emphasis) |
| Brand | bg-brand, hover:bg-brand-hover, text-brand-fg |
| Destructive | bg-danger-bg, hover:bg-danger-bg-hover, text-danger-fg, border-danger-border |
| Success | bg-success-bg, text-success-fg, border-success-border |
| Warning | bg-warning-bg, text-warning-fg, border-warning-border |
| Notice (blue) | bg-accent-bg, text-accent-fg |
| Status glyphs on a plain background | text-success-icon, text-danger-icon |
| Overlay scrim | bg-scrim |
--brand-muted is a pale brand tint for fills, not a hover state for a brand-filled control —
white label text is unreadable on it. Use --brand-hover, which darkens in light mode and lightens
in dark.
The -icon roles are a step lighter than the matching -fg, which is fine for a glyph (its shape
also carries the meaning) but not for text. Status text uses -fg.
Adding a new role means adding it to every theme block in theme.css. Do not introduce new raw ramps.
Light and dark theme
The app supports light, dark, and system themes, chosen with the ThemeSwitcher and persisted to
localStorage. The choice resolves to a light/dark class on <html>; an inline script applies it
before paint to avoid a flash. Tailwind's dark: variant is wired to that .dark class.
Rules when adding UI:
- Role tokens adapt automatically, so component code carries no
dark:variants at all. This is the point of the theme: the old rule — write the light class, then repeat it as adark:counterpart, remembering to carryhover:into the dark variant too — was a standing source of dark-mode bugs, because the two halves drift. - A portalled overlay still inherits the theme, since it mounts under
document.body, inside the themed<html>. - The scrim is
bg-scrim, not aforeground-derived fill — a--foreground-based scrim inverts to white in dark mode.
State management
Complex pages use a reducer via @eetr/react-reducer-utils with a typed action enum and a flat state
shape:
- Define an action enum, a state interface, the
initialState, and a reducer(state, action) => newStatetyped withReducerAction<ActionType>. - Build the context with
bootstrapProvider(reducer, initialState)and export theProviderplus auseContextAccessorshook (e.g.useAdminState). - Components read
const { state, dispatch } = useXState(), and the subtree must be wrapped in theProvider.
New client-state domains add a reducer module under src/context/ (or src/store/); no ad-hoc global
state outside this pattern. When you add a new interaction: add an action to the enum; add the field to
the state interface and initialState; add the reducer case; destructure the field in the component.
For simple pages (single form, no cross-cutting state), plain useState is fine.
Server actions
User-facing mutations run through server actions ("use server" files in apps/auth/src/app/actions/)
wrapped with onServerAction. Call them from client components; do not hit API routes directly from
admin UI unless there is a reason (e.g. fetch with a file upload). See
Layer conventions.
What to avoid
window.confirm,window.alert,window.prompt.- Toast libraries, and third-party dialog/drawer/overlay component libraries (Radix, Headless UI,
framer-motion). Status messaging is inline banners; overlays are the first-party
SidePanel/ConfirmDialog.tw-animate-cssis the sanctioned animation utility layer. - New third-party UI component libraries. Tailwind +
lucide-reactis the stack. - Raw Tailwind color ramps,
rounded-xl, andborder-brand-mutedas a default border — all three failnpm run lint. - Emojis in UI copy.
- Sharp-cornered buttons and bright primary colors outside the brand tokens.
- Nesting a bordered container inside another bordered container.