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

267 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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).