267 lines
17 KiB
Markdown
267 lines
17 KiB
Markdown
# 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).
|