eetr-auth
Contributing

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:

  • Buttonvariant of primary | secondary | destructiveConfirm, optional loading (swaps the leading icon to Loader2 and disables) and icon (leading lucide icon).
  • IconButton — icon-only per-row action; variant of default | danger; requires aria-label.
  • Bannervariant of error | success | info | warning; renders nothing when message is falsy.
  • SectionCard (titled, icon heading) and Card (bare wrapper), both with a padding of sm | md | none.
  • PageHeader — the icon + title row every page starts with, plus an optional right-aligned action.
  • Table / THead / TBody / Th / Td — table chrome (see Directory surfaces).
  • Input, Select, Label, FormField.
  • Spinner and FullPageSpinner.
  • InlineDeleteConfirm — the inline destructive-confirmation control (see below).
  • SidePanel and ConfirmDialog — 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:

  1. Palette — raw values (--gray-*, --brand-*, --red-*, …). Referenced only by tier 2.
  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:

TokenSizeUse
rounded-chip4pxbadges, tags, checkboxes
rounded-control6pxinputs, selects
rounded-card8pxcards, 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 deletingUserId and 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

VariantUsageClasses
PrimaryThe 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 / ghostNeutral 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 confirmThe "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-onlyPer-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:

  1. Page headerPageHeader with the icon, title and a right-aligned <Button icon={Plus}>New …</Button>. No second "Manage X" card; the page title is the heading.
  2. Toolbar — filter controls only, using Select.
  3. Table — one bordered rounded-card container that owns the edge; rows separated by divide-y, never a border each; thead on --surface-sunken.
  4. Row actionsIconButton only, in a fixed order: surface-specific icons first, then Pencil, then Trash2. No pills, no "View" link, no navigation. While confirming, InlineDeleteConfirm replaces the whole group.
  5. Clicking a row opens its edit panel. The actions cell calls stopPropagation, so pressing a row action never also opens the panel. Keep the Pencil anyway: 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.
  6. Empty vs filteredEmptyState with 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.
  7. Create and edit — one SidePanel, titled "New X" / "Edit X", footer "Add X" / "Save X" plus Cancel.
  8. Errors — page-level Banner for list errors, in-panel Banner for save errors. Render only one at a time; a page-level banner is invisible behind the scrim.
  9. StatepanelOpen, 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.

  1. 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.
  2. 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.
  3. 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 ... with focus: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.
  • FullPageSpinner is for the first load only. A refetch after a mutation must not unmount the page: pass a silent flag 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:

ConceptIcon
User / profileUserCircle
EditPencil
DeleteTrash2
Confirm / doneCheck
Cancel / dismissX
LoadingLoader2 with animate-spin
UploadUpload
Passkey / biometricFingerprint
VerifiedBadgeCheck (green)
Not verifiedBadgeX (amber)
Reset / retryRotateCcw
Audit log / listClipboardList (audit), ListTodo (token activity)
PasswordLock
Test client / test userFlaskConical (with the warning role, not accent)
Add / createPlus
General settingsSlidersHorizontal
Image placeholderImageIcon

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.

PurposeToken
Page / raised / recessed backgroundbg-background, bg-surface, bg-surface-sunken
Hover fillbg-surface-hover
Texttext-foreground, text-muted-foreground
Edgesborder-border (default hairline), border-border-strong (emphasis)
Brandbg-brand, hover:bg-brand-hover, text-brand-fg
Destructivebg-danger-bg, hover:bg-danger-bg-hover, text-danger-fg, border-danger-border
Successbg-success-bg, text-success-fg, border-success-border
Warningbg-warning-bg, text-warning-fg, border-warning-border
Notice (blue)bg-accent-bg, text-accent-fg
Status glyphs on a plain backgroundtext-success-icon, text-danger-icon
Overlay scrimbg-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 a dark: counterpart, remembering to carry hover: 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 a foreground-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) => newState typed with ReducerAction<ActionType>.
  • Build the context with bootstrapProvider(reducer, initialState) and export the Provider plus a useContextAccessors hook (e.g. useAdminState).
  • Components read const { state, dispatch } = useXState(), and the subtree must be wrapped in the Provider.

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-css is the sanctioned animation utility layer.
  • New third-party UI component libraries. Tailwind + lucide-react is the stack.
  • Raw Tailwind color ramps, rounded-xl, and border-brand-muted as a default border — all three fail npm 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.

On this page