Files
baya-monorepo/archive/docs/rules/client/components.md
T
2026-08-02 20:01:31 +03:30

17 KiB
Raw Blame History

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'), 35 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.tsAPP_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.

  • 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>.tsroutes.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.