17 KiB
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:
- The width cap. No shell stretches a header, a nav bar, or a content column across a monitor.
- 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. - Horizontal scroll is structurally impossible.
overflowX: hidden+minWidth: 0on 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 — seeAdminDataTable'sTableContainer. - Above
smthe 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: absoluteagainst the frame, neverfixed. 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. AppFramepublishes--bal-chrome-topand--bal-chrome-bottomon the scroll container, so anyposition: stickyelement anywhere in the tree can clear the bars without importing a constant or knowing which shell it is in. Both already includeenv(safe-area-inset-*), and both resolve to0pxin a chrome-free shell — which is why a sticky consumer can read them unconditionally.StickyActionBaris 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
LinkToPagearrays built withuseTranslations('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 itsmatchPathsclaims. UsematchPathswhen a tab owns a destination outside its own URL subtree —/nurse/financeowning/nurse/earnings. Never hand-rollpathname.startsWith, which lights up a sibling tab as often as the right one. BottomBarfloats: 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 asaria-label/title. Each tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill, laid outspace-aroundso the target keeps one size at any tab count.TopBaris not anAppBar— no filled surface, no rule, no elevation of its own.AppFramewraps it inFLOATING_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). 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.
sizedrives realwidth/height(Lucide sizes off SVG attributes), sosize={48}is 48px with nofontSize/1emindirection. Icons default toflexShrink: 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).AppIconstampsdata-icon-directional, and one CSS rule inglobals.cssdoes[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-reactintoconfig.tsplus a lowercaseICONSkey. Custom SVGs (the brand mark) go inAppIcon/icons/and must accept the samesize/color/strokeWidthcontract (AppIcon/utils.ts'sIconProps). An unregistered name logs a dev-only warning and falls back todefault. 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.
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.