# Client components, shells and icons

What to reach for before writing something new, and the layout system every screen lives in.

> Last verified: 2026-07-30 against commit `d3ec723`.

---

## 1. There is one layout: a phone

`AppFrame` (`src/layout/AppFrame.tsx`) renders **every** screen inside a centered
`APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas` backdrop, at **every viewport**. A wider
window gets more canvas, never a wider app — so you design one set of states and verify one set of states.

**Do not add a `≥md` branch that widens a shell, restores a sidebar, or lays a screen out in columns.**

`AppFrame` owns four structural guarantees, and is the only place any of them is solved:

1. **The width cap.** No shell stretches a header, a nav bar, or a content column across a monitor.
2. **The frame, not the document, owns the scroll.** A single scrolling `<main>` fills the frame; header and
   footer are pinned over it and reserve their own space through `<main>`'s padding, so **no page needs a
   top offset of its own**.
3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the column mean
   an over-wide child clips instead of dragging the whole app sideways. Genuinely wide content (a data
   table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
4. **Above `sm` the column floats** as a rounded, shadowed card with a gutter all round. On a phone it fills
   the viewport edge to edge — there is no canvas to float on.

Two mechanics that follow from (2) and are easy to get wrong:

- **The chrome is `position: absolute` against the frame, never `fixed`.** A viewport-fixed bar would break
  out of the centered column and span the whole window. The frame itself never scrolls (only `<main>` does),
  so on a phone — where the frame *is* the viewport — the two are visually identical.
- **`AppFrame` publishes `--bal-chrome-top` and `--bal-chrome-bottom`** on the scroll container, so any
  `position: sticky` element anywhere in the tree can clear the bars without importing a constant or knowing
  which shell it is in. Both already include `env(safe-area-inset-*)`, and both resolve to `0px` in a
  chrome-free shell — which is why a sticky consumer can read them unconditionally. `StickyActionBar` is the
  reference consumer.

### Shell dimensions are constants

`src/layout/config.ts` holds them, and they are measured rather than guessed:

| Constant | Value | Note |
| --- | --- | --- |
| `APP_FRAME_MAX_WIDTH` | 480 | Mirrored by `components/config.ts`'s `CONTENT_MAX_WIDTH` — a page column can never be wider than the frame containing it |
| `TOP_BAR_HEIGHT` | 56 | One height at every viewport; the frame never changes width, so the old mobile/desktop split had nothing to switch on |
| `TOP_CHROME_HEIGHT` | 72 | Total space the floating header occupies. Deliberately equal to `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored |
| `BOTTOM_NAV_HEIGHT` | 72 | Total space the floating nav occupies. `AppFrame` reserves exactly this as `<main>` padding, so nothing hides behind the bar. **Keep in sync with `BottomBar`** |
| `FLOATING_BAR_SX` | — | The ONE definition of the two bars' shared shape, so header and footer cannot drift apart |

---

## 2. The shells

**One authenticated shell.** `MobileShell` = `AppFrame` + a contextual `TopBar` + `BottomBar` +
`ErrorBoundary` + `RouteFadeIn` + `PageTitleProvider`. The four actor layouts — `CustomerLayout`,
`NurseLayout`, `AdminLayout`, `PartnerLayout`, each wrapped in `RoleGuard` — supply only `tabs` and
`headerActions`.

**Add a destination by adding a tab or a hub row — never by forking the shell.**

| Shell | For |
| --- | --- |
| `MobileShell` (via the four actor layouts) | Every authenticated screen |
| `PublicLayout` | Unauthenticated: the frame and **nothing else**, no top bar — so the login card's own `BrandMark` is the only mark on screen |
| `FocusedLayout` | Framed but chrome-free, for flows a user must not tab away from mid-setup: onboarding, `/select-role`. A slim logo strip and content, no bottom nav. The route group above it still applies `RoleGuard` |
| `PrivateLayout` | An authenticated passthrough wrapper; actor chrome lives in the shells above |

### Navigation is the bottom bar. There is no drawer.

- Tabs are `LinkToPage` arrays built with `useTranslations('nav')`, **3–5 of them**, and by convention the
  last is a settings/«بیشتر» hub.
- Active state comes from the shared **`matchActivePath`** (longest-prefix, winner-takes-all) run over each
  tab's own path **plus its `matchPaths` claims**. Use `matchPaths` when a tab owns a destination outside
  its own URL subtree — `/nurse/finance` owning `/nurse/earnings`. **Never hand-roll
  `pathname.startsWith`**, which lights up a sibling tab as often as the right one.
- `BottomBar` **floats**: inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated — not an
  edge-to-edge slab sealing off the bottom of a 480px screen. It is **icon-only**: at five tabs the caption
  was the widest thing in the bar and cost a whole line, so the label survives as `aria-label`/`title`. Each
  tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill,
  laid out `space-around` so the target keeps one size at any tab count.
- `TopBar` is **not** an `AppBar` — no filled surface, no rule, no elevation of its own. `AppFrame` wraps it
  in `FLOATING_BAR_SX`, so it is the bottom bar mirrored. It shows a brand lockup on a tab's own path and a
  back chevron + `useRouteTitle()` on anything deeper.

### Group roots are real pages

A nav group's root is a page, not a drawer section: a short summary of that domain — read **only off
queries that already answer it, never a fabricated figure** — over a `NavHubList` of its destinations.
`/nurse/practice`, `/nurse/finance`, `/admin/trust`, `/admin/system` are the references. A count that is
still in flight is **omitted, never faked**.

### Chrome carries no preferences, and no identity

Language and appearance live in `SettingsPanel` (`@/components/settings`), mounted in each actor's settings
hub **and nowhere else**. Identity lives in each actor's «بیشتر»/account hub, one tap away on the nav. The
top bar is for the page title and at most a notification bell.

`/admin/system` is always present in the admin nav even when every console inside it is denied, because it
is the only route out of the app (settings + sign-out).

### Navigation goes through `@/i18n/navigation`

**All chrome navigation** uses `Link` / `usePathname` / `useRouter` from `@/i18n/navigation`
(`createNavigation(routing)`). `usePathname` is locale-stripped, so unprefixed `ROUTES.*` compare directly,
and `Link`/`router` add the locale automatically — **no manual `` `/${locale}` `` prefixing, and no
middleware redirect hop.** Never a raw `next/link` for chrome.

Inside a *page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.

Prefer MUI breakpoints in `sx` for the little responsive branching that remains, over `useIsMobile()`
(`@/hooks`) — the hook is JS/post-hydration and caused a real SSR flash. Reach for it only for genuinely
non-structural, JS-only behaviour.

---

## 3. Reach for these before raw MUI

Shared primitives live in `src/components/`, barrel `@/components`. Prefer the `App*` wrapper over the bare
MUI component — the wrappers carry the house defaults.

| Component | Use for | Notes |
| --- | --- | --- |
| `AppButton` | all buttons and button-links | default `variant="contained"`; pass `to`/`href` to render as a link; `startIcon`/`endIcon` accept an **icon name string** or a node |
| `AppIconButton` | icon-only actions | takes an icon name, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§4); `size`, `color` |
| `AppLink` | internal/external links | locale-aware; default underline `hover` |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — a genuinely error-severity call site passes `severity="error"` explicitly |
| `AppLoading` | loading state | circular, `primary`, `3rem` |

Defaults live in `src/components/config.ts` — `APP_BUTTON_VARIANT`, `APP_ICON_SIZE` (24),
`APP_ICON_STROKE_WIDTH` (1.75), `APP_BUTTON_ICON_SIZE` (20), `CONTENT_MAX_WIDTH` (**480**),
`CONTENT_MIN_WIDTH` (320), the alert/link defaults. **Change a default there, not per call site.**

**Concrete MUI primitives stay MUI.** Use `Button`, `Avatar`, `Paper`, `TextField`, `Box`, `Stack`,
`Container`, `Grid`, `Card` directly (or the existing `App*` wrappers) — never invent a new root-level
Button or Avatar. Use the `spacing`/`sx` system (theme unit = 8px); never inline pixel margins for rhythm.

**Composite, shareable components** built from primitives and reused in more than one place belong at the
right *shared* level (`src/components/…`), not inline in a page and not buried in a leaf. Page-only,
never-reused composition can stay in the page.

### The state kit — one pattern per state, and they are not optional

| Primitive | The one pattern for |
| --- | --- |
| `EmptyState` | "nothing here" — icon + title + body + action. Replaces every hand-rolled dashed-border `Paper` |
| `ErrorState` | "this query failed" — `message` + a **required** `retryLabel` + `onRetry` |
| `QueryStateGate` | A query's branching, in the fixed **skeleton → error → empty → children** order. Also requires `retryLabel` |
| `PageHeader` | title + subtitle + `actions` (buttons) + `meta` (a chip row) + a back affordance (`backTo`, or `onBack` which takes precedence and pairs with `useAdminBackToList` for `router.back()`-with-fallback) |
| `ConfirmDialog` | Any destructive confirm. Required-reason gating, busy-disable, and `requireTypedConfirmation` (confirm stays disabled until the typed value matches) — the guard for an irreversible money-moving action, e.g. the admin payout run |
| `SurfaceCard` | A flat `Paper` wrapper; `padding: 'sm' \| 'md' \| 'lg'` |
| `AccentCard` | `SurfaceCard` + a semantic `tone` for a **stateful** panel |
| `Money` | The one money-rendering primitive (`amountIrr`, `size` incl. `xl`, `tone`, `deduction`, `hideUnit`, `strikethrough`) |
| `StatusTimeline` | An ordered `TimelineNode[]` (completed/current/pending/failed) with an animated pulse on `current` |
| `JalaliDatePicker` / `JalaliDateField` / `JalaliDateIntentPicker` | Any Persian-calendar date input. Never a native `type="date"` |
| `StickyActionBar` | A scrolling screen's primary CTA, offset off `--bal-chrome-bottom` |
| `Pager` | The shared prev/next "page X of Y" control. Never a per-screen inline pager |
| `NavHubList` | The grouped destination list a group-root page is built from |
| `InitialsAvatar` | A person with no photo — deterministic name hash → one of six `--bal-avatar-*` pairs, `aria-hidden` beside a visible name |
| `FormDialogShell` | A form dialog: full-screen below `sm`, with a dirty-gated discard confirm |
| `RouteFadeIn` | Route-content motion. Already mounted in all five shells |

**An error state is never an empty state.** A failed query renders `ErrorState`; a successful query with
no rows renders `EmptyState`. Collapsing the two hides outages.

Two `AccentCard` details worth knowing: its colored **edge stripe is gone** — a column of striped cards read
as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label
(reaching the DOM as `data-accent-tone`), and state is carried by the `StatusChip`, icon and copy inside the
card. **Do not reintroduce the stripe.**

### Presentational purity in `components/common`

`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens `next/jest`'s
`transformIgnorePatterns` to let them through, but that only fixes real imports — it doesn't make the
dependency free. **Any component at the top of the `@/components/common` barrel that imports `next-intl` at
module scope forces every test file that transitively imports the barrel to deal with it**, including tests
that never touch translations.

So `ErrorBoundary` and `ErrorState` are deliberately **caller-owned**: they take `title`/`body`/`retryLabel`/
`message` as required string props instead of calling `useTranslations` internally, specifically to stay
import-safe at the top of the barrel. `QueryStateGate` inherits the same `retryLabel` requirement by
composition. `Money` is the sanctioned exception — it already had 30+ call sites depending on its
locale-aware API before this was noticed, so the fix went the other way.

When adding a new `common` primitive: **prefer the caller-owned-copy pattern by default**, and reach for
`useTranslations` inside it only if the component is genuinely leaf-level. Keep next-intl-importing
primitives *below* the presentational ones in the barrel so the poisoning risk stays visible in review.

### New shared component

`src/components/<Name>/<Name>.tsx` + an `index.tsx` barrel + a **co-located `<Name>.test.tsx`** (mandatory
for anything imported in more than one place — see [testing.md](testing.md)). Follow the `App*`
prop-spreading and JSDoc style of `AppButton.tsx`.

---

## 4. Icons are a name registry

`src/components/common/AppIcon/config.ts` maps **lowercase** names → components. Render with
`<AppIcon icon="home" />`, or pass the name to `AppButton`/`AppIconButton` (`startIcon="search"`).

**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a contemporary outline
family on a 24px grid with round caps and joins, which reads far lighter than filled glyphs at the small
sizes a phone-width app actually uses. **`@mui/icons-material` is no longer a dependency; never reintroduce
it.** The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which competes with
Mikhak's lighter Persian strokes).

**The mapping is semantic, not incidental.** A name describes the domain concept ("verification",
"earnings", "coverage") and the glyph depicts *that*, so swapping the underlying glyph never leaks into call
sites. Related concepts share a visual root on purpose: trust names are shields, money names are coins or
cards, clinical names are a pulse or a cross. Around 110 names are registered — read `AppIcon/config.ts`
rather than duplicating the list.

- **`size` drives real `width`/`height`** (Lucide sizes off SVG attributes), so `size={48}` is 48px with no
  `fontSize`/`1em` indirection. Icons default to `flexShrink: 0` — an icon squashed by a flex sibling was
  the one layout bug this component kept quietly reintroducing on narrow rows.
- **Directional icons mirror automatically.** Names authored for LTR that must flip under RTL are listed in
  `DIRECTIONAL_ICONS` (`back`, `chevron_start`, `chevron_end`, `forward`, `send`). `AppIcon` stamps
  `data-icon-directional`, and one CSS rule in `globals.css` does
  `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a directional icon is a one-line
  registry addition — **never hand-roll a per-component flip.**
- **A new icon** is an import from `lucide-react` into `config.ts` plus a lowercase `ICONS` key. Custom SVGs
  (the brand mark) go in `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
  (`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls back to
  `default`. **Never pass a raw icon component where a name is expected.**

---

## 5. Constants, not magic values

Every magic string or configurable value is a named constant. A value is "magic" if its meaning isn't
obvious from the literal alone: cookie names, event names, route paths, query-param names, numeric
timeouts, API slugs, repeated dimensions.

| Kind | Home |
| --- | --- |
| Cookie names and options | `src/lib/cookies/constants.ts` |
| Feature-scope | a `constants.ts` co-located with that feature |
| App-wide | `src/constants/<concern>.ts` — `routes.ts`, `roles.ts`, `headers.ts`, `policy.ts` |
| Shell dimensions | `src/layout/config.ts` |
| Component defaults | `src/components/config.ts` |

`constants/policy.ts` is the pattern applied to legally-sensitive numbers that trust-critical copy states
in plain language — the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day
range. They are real server config with no public read yet, single-sourced here and fed into message keys
as ICU params rather than baked into a string. See [i18n.md](i18n.md).

Import the constant; **never copy-paste the literal.** When renaming, change the definition and the rest
follows.

---

## 6. Toasts

| From | Use |
| --- | --- |
| A component or hook | `useSnackbar()` → `enqueueSnackbar('…', { variant: 'success' })` |
| Outside React (a plain function, the fetch layer) | `dispatchToast('…', 'error')` from `@/lib/toast` — it fires an `app:toast` window CustomEvent that `ToastBridge` picks up |

`ToastBridge` is already rendered in the root layout. **Do not add another instance.**

**Every mutation whose failure isn't already surfaced inline or by the fetch layer needs an `onError`
toast.** A mutation that only handles `onSuccess` is a defect. But don't toast 401/403/5xx in a hook —
`clientFetch` already does. See [services.md](services.md).
