# 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 `
` fills the frame; header and footer are pinned over it and reserve their own space through `
`'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 `
` 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 `
` 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//.tsx` + an `index.tsx` barrel + a **co-located `.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 ``, 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/.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).