cleanup phase 1

This commit is contained in:
hamid
2026-07-30 02:26:52 +03:30
parent d3ec723119
commit c889c46110
36 changed files with 4251 additions and 2552 deletions
+184
View File
@@ -0,0 +1,184 @@
# Client auth
Cookies, the session lifecycle, silent refresh, `RoleGuard`, and the middleware gate — plus an explicit
statement of what is *not* a security boundary.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The credential is phone-OTP
There is **no username/password anywhere**, and email is never a login key. The flow lives in
`src/components/auth/` (`LoginFlow``PhoneStep``OtpStep`) at `/login`, over the `services/auth` domain
(`requestOtp` / `verifyOtp` / `refresh` / `logout` / `getMe` / `selectRole`).
`useWebOtp` is the WebOTP autofill seam — on a supporting browser the code fills itself from the SMS.
---
## 2. The two cookies
| Cookie | Constant | TTL | Written by |
| --- | --- | --- | --- |
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same |
Lifecycle:
- **Written** by `persistAuthTokens` after verify / refresh / select-role, which also dispatches `LOG_IN` so
`AuthContext` stays in sync without a reload.
- **Deleted** by `useLogout()` — the single logout path: revoke the server session, clear both cookies,
`LOG_OUT`, drop the `/me` cache, redirect. Also cleared by `clientFetch` when a 401 can't be recovered.
- **Read** server-side by `serverFetch` / `getServerAuthState` via `getServerCookie`; client-side by
`clientFetch` via `getClientCookie`, to attach `Authorization: Bearer`.
The `refresh_token` cookie's 7-day TTL is **shorter** than the server session default (30 days). Aligning the
cookie `maxAge` to the server's `refreshExpiresAt` is a known follow-up, not a bug to fix blind.
All cookie access goes through the manager — see [services.md](services.md) §6.
---
## 3. Session state
`AuthContext` (`src/context/auth/`) carries `SessionUser { id?, phone, roles: AppRole[] }`.
The root layout resolves the session **on the server** with `getServerAuthState()` (`lib/auth/server.ts`),
which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive`
(`lib/auth/token.ts`), and passes it to `<AuthProvider initialState={…}>`. So the very first render already
knows whether the user is authenticated.
**Roles are not derivable from the opaque JWE token server-side.** The server therefore seeds
`isAuthenticated` only; `useSessionRoleSync()` — mounted in the `(private-routes)` layout — hydrates
`currentUser.roles` from `/me`. That is the single source the shells read via `useActorRole()`.
`invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.
---
## 4. Where the user lands: the role router
After a successful verify, `RoleRouter` reads `/me` and navigates — customer → the family app, nurse → the
nurse app, admin → the console, empty roles → `/select-role`. It shows the branded splash while `/me` loads,
**so the wrong shell never flashes.**
The decision itself is the **pure, unit-tested** `resolveRoleDestination(me, intendedRole)` in
`services/auth/routing.ts`. That function is the single "which app" source — every other place that needs to
send a user to their home calls it rather than re-deriving.
The middleware owns the auth *gate*; the router only decides which app.
---
## 5. `RoleGuard`: resolved vs. pending
Every private shell — `(customer)`, `nurse`, `admin`, `partner` — wraps its layout in **`RoleGuard`**.
It exists because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
nurse/admin role"**. A `/me` in flight used to fall through the `DEFAULT_ROLE = customer` fallback and flash
a nurse the customer app — or strand them there if `/me` failed.
`RoleGuard` reads **`useRoleHydration()`** (`services/auth`), a discriminated `loading | error | ready` over
`useMe`:
| State | Behaviour |
| --- | --- |
| **loading** | A neutral brand splash. **Never the customer shell as a stand-in** |
| **error** (`/me` failed — API down) | `AuthAccountError` with retry. **Never a silent customer fallback** — a transient error must not downgrade a nurse or an admin |
| **role mismatch** | Redirect to the caller's real app via `resolveRoleDestination`, with a `guard_denied` toast — rather than rendering a shell they lack the role for |
A shell passes `expected={APP_ROLES.*}`. **The partner portal passes no `expected`** — a partner-centre admin
is not an `AppRole`. It self-gates on `useMyPartnerCenter` (a 403/404 renders a non-leaking access-denied
state, never a raw id), so `RoleGuard` there only hardens hydration.
`useActorRole()`'s `DEFAULT_ROLE` fallback is now a last resort only — the guard ensures roles are hydrated
before a shell renders — never the loading state.
**`RoleGuard` is UX and chrome, not security.** The server authorizes every endpoint. A dual customer+nurse
session holds both roles and moves freely between the two apps (`ActorSwitcher`).
---
## 6. Silent refresh
`clientFetch` attempts one **single-flight** `attemptTokenRefresh` (`lib/api/refresh.ts`) on a 401 and
retries the request once. A failed refresh — unknown, expired, or reused token, at which point the server
revokes the session — clears tokens and redirects to `/login`.
The refresh and OTP endpoints are **excluded** from this retry, or a failing refresh would recurse.
This mirrors the server's rotation + reuse-detection: a replayed refresh token revokes **all** the user's
sessions.
---
## 7. Middleware
`middleware.ts` runs in this order, and the order is load-bearing:
1. **next-intl locale normalization.** If it is issuing a 307/308, return immediately.
2. **The guest front door.** An **unauthenticated** exact-match on `/` is `NextResponse.rewrite()`d to
`/{locale}/welcome`**never a redirect**, so the URL and the SEO canonical stay `/`.
3. An **authenticated** hit on `/welcome` redirects to `/`.
4. **The auth gate.** A non-public path without a live token redirects to `/login`, appending the attempted
locale-stripped path + query as **`?next=`** (`RETURN_URL_PARAM`) so a deep link — an SMS booking link, a
shared nurse profile — survives the round trip.
5. Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
response headers (the `Link: alternate` hreflang set).
`LoginFlow` reads `?next=` and `RoleRouter` resolves it via **`resolvePostLoginDestination`**
(`services/auth/routing.ts`), which accepts **same-origin-relative and role-permitting destinations only**
and otherwise falls back to `resolveRoleDestination`. **Never an open redirect.**
### Two traps in this file
**The matcher must list bare `'/'` explicitly** alongside the catch-all regex:
```ts
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']
```
This Next 16 / Turbopack build does **not** reliably invoke middleware for the literal root through the
negative-lookahead pattern alone — `/` skipped middleware entirely and 404'd, while every other path matched.
It is load-bearing for the guest front door, which only fires on an exact `/` match. (Verified in dev; a
production `next build && next start` confirmed the intended behaviour end to end, so the underlying quirk is
dev-server-only — but the explicit entry stays.)
**Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`.** `PUBLIC_PATHS` is matched with `startsWith`, so
`'/'` would silently make **every route public**. The guest-facing root is handled by the exact-match rewrite
above instead. To add a genuinely public route, append it to `PUBLIC_PATHS` and the middleware picks it up
automatically.
---
## 8. Security posture — what is and isn't a boundary
The design above is deliberate, and some of its hardening needs *server* coordination. **Don't silently "fix"
these client-only.**
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header. That
trades XSS hardening for the bearer pattern. Real hardening — httpOnly cookies set by the server plus a
same-origin proxy — spans both projects.
- **The middleware check is UX-only, not a security boundary.** It decodes the JWT and checks `exp`; it does
**not** verify the signature. The API is the only authority. **Never gate real authorization on the
middleware or on `isTokenAlive`.**
- **Role gating is coarse for chrome, fine for the backoffice.** Shells pick chrome from the collapsed
`currentUser.roles` (`useActorRole`). `useAdminCapabilities()` (`@/hooks`) is a memoized selector over the
session's **fine-grained** `roleCodes` (`super_admin` / `admin` / `support` / `finance` / `moderation`)
returning per-console booleans — `canVerify`, `canRefund`, `canPayout`, `canModerate`, `canConfig`,
`canManageAlerts`, `canManageTickets`, `canManagePartners`, `canViewAudit`, `canManageRoles`. `AdminLayout`'s
nav and every admin action hide or disable on it **so a role never sees a control that will 403** — but it
is a **display convenience only; the server authorizes every command.** Never gate real authz on it.
- **Cross-actor route access is not hard-guarded client-side.** Add route guards when a feature needs them.
- **The partner portal is a separate scope** — its pages resolve the caller's own centre via
`useMyPartnerCenter()`, never a raw id.
- **Signed URLs are fetched on demand, never cached long-lived.** Verification documents load via a
short-lived signed URL from `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry: false`);
`DocumentViewer` re-requests on expiry or error rather than reading an embedded URL out of the long-lived
case query. **Reuse this pattern for any short-lived signed asset** — invoice PDFs included.
- **Refresh-token rotation is wired** client-side (the fetch-layer silent refresh plus `useRefresh`), matching
the server's rotation and reuse-detection.
Admin sub-roles are **server-granted and never self-selectable**; `POST me/select_role` accepts only
`customer` / `nurse` and returns 403 for anything else. Don't build a UI that implies otherwise.
+266
View File
@@ -0,0 +1,266 @@
# 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).
+116
View File
@@ -0,0 +1,116 @@
# Client forms
Every form with more than one field is a react-hook-form form. This is how you build one.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule, and why it exists
**Any form with more than one field uses react-hook-form.** A single-field control — a search box, a filter
select, a message composer — does not: that is state, not a form.
This is not a style preference. The pattern it replaced was one `useState` per input **plus** a parallel
`useState` per error flag. That meant every keystroke re-rendered the whole screen — including the
query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?"
spread across ad-hoc `if` blocks at the top of each submit handler.
react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one
input.
28 files currently import it. Any multi-field form that still holds its state in `useState` is a defect to
be migrated when that screen is next substantially touched.
---
## 2. How to build one
1. **`useForm<Values>({ mode: 'onTouched', defaultValues })`.** `onTouched` is the house default: an error
appears once a field has been visited, never while it is first being typed into.
2. **Wrap the subtree in `<FormProvider {...form}>` and bind fields with the `@/components/common/form`
wrappers.** They read `control` off the provider, so it is threaded once.
**Never call `register` or `useController` at a call site.**
3. **Put the rule on the field it governs**`rules={{ validate: … }}` — returning the **translated**
message. Cross-field rules read `validate`'s second argument (all values); that is how the C4 request
form's past-date guard reads the chosen start time.
4. **Render `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>`** and make the primary
button `type="submit"`. Enter-to-submit then works for free.
5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values depend on
a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading
branch in the *parent* and mount the form component only once the data has resolved, so `defaultValues`
**is** the server state instead of being copied into it later.
Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the
form has two sources of truth for a moment, and a user who types during that moment loses the keystroke.
---
## 3. The wrappers
| Wrapper | For |
| --- | --- |
| `RhfTextField` | any `TextField`, including `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the *stored* value is canonical, not just the displayed one. A rule message replaces `helperText` |
| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible |
| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string, or `null` |
| `RhfControlGroup` | **any** non-input control — `GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`. Gives it the same label/hint/error shell the text fields get |
Every wrapper falls back to the enclosing `FormProvider`'s `control`, so a form wires it once. All four are
tested.
### Two conventions worth knowing
- **A control that renders its own error text gets a message-less rule** — `validate: (v) => cond`, no
string. `RhfControlGroup` then flags the field without printing a second identical line. `AddressForm`'s
region and pin fields are the reference.
- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Exactly two cases exist
and both are commented at the call site: the variant builder's display-name (stored = the override only;
blank means the server names it — shown = the live auto-generated name) and the admin refund channel
(stored = `""` until explicitly overridden; shown = the server's resolved channel).
---
## 4. Structure: `FormSection`
A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is being
asked for, and an optional/status marker.
The point is that **an optional group reads as skippable and a blocked submit has somewhere to attribute
itself.** A flat run of ten `TextField`s makes everything look equally mandatory, which is how a nurse ends
up abandoning a verification form over a field that was never required.
Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصص‌ها), the verification identity and credentials
screens, and the variant builder.
---
## 5. Making a gate honest
Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form
that *validates* from a form a user can actually finish.
- **The submit gate is a real form field, not a caption near the bottom.** Verification B4's three asks
(national id / card photo / selfie) each became a `FormSection` with the card marked optional and the
selfie marked required — so the requirement is attached to the thing that satisfies it.
- **Disable Next with the unanswered required groups *named* under it.** Not "always enabled, error after
the tap".
- **Derive a wizard's step list from the loaded data.** A category with no option groups skips straight to
pricing rather than showing an empty middle step.
- **Recap the chosen values on the final step**, so the last step doubles as a review.
- **Never dead-end a returning user on a disabled button with no explanation.** If server-side state (an
already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must
consider it — and a value the server won't read back by design should lock into a "recorded" row rather
than re-prompting for it blank.
## 6. Unsaved work
- A form hosted in `FormDialogShell` reports `dirty` via an **`onDirtyChange`** prop, which drives the
shell's discard-confirm on close, backdrop and escape.
- A **staged-but-unsaved upload** gets a `beforeunload` guard — the nurse-profile avatar is the reference.
- A destructive confirm goes through `ConfirmDialog`, whose destructive and dismiss labels must not be
swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.)
+215
View File
@@ -0,0 +1,215 @@
# Client i18n and Persian copy
next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is
enforced by `npm run lint:copy`.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule
**No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`,
button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two
files stay in sync.
The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash
and therefore renders its own `<html>` and cannot use next-intl. Keep it minimal and bilingual.
Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the
default locale.
| Context | API |
| --- | --- |
| Client component | `const t = useTranslations('nav'); t('home')` |
| Server component | `const t = await getTranslations('nav'); t('home')` |
| Structured (array/object) values | `t.raw('terms_sections')` |
| Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` |
Top-level keys are namespaces. Adding a translation means adding the key to both files — never one.
---
## 2. The namespace map
MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only
with a genuinely new surface, and seed it in both files at once.
| Namespace | Owns |
| --- | --- |
| `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark |
| `nav` | The actor shells' tab labels — every shell builds its nav from here |
| `shell` | Actor-shell titles |
| `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line |
| `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values**`terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` |
| `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** |
| `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) |
| `home` | The family home — greeting, search entry, category grid, nudges |
| `profile` | Customer profile and emergency contact |
| `patients` | The care-circle list and CRUD |
| `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed |
| `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor |
| `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides |
| `services` | The nurse services surface and the variant builder |
| `nurseProfile` | The nurse profile bootstrap and the public-profile preview |
| `activation` | The shared `ActivationChecklist` rows and its collapsed live state |
| `bank` | Nurse payout bank settings and the three ownership states |
| `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels |
| `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels |
| `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list |
| `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states |
| `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy |
| `bnpl` | Installment checkout D1D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list |
| `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer |
| `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count |
| `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces |
| `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** |
| `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes |
| `partner` | The partner-centre portal — a separate authz scope |
### Enum labels
**A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`,
`pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client
constant, not something read off a response. Shared enum labels are **reused** across namespaces, never
re-keyed.
This is what lets the server rename a display string without a client deploy, and lets the client show a
Persian label for a code it has never seen without falling back to raw English.
---
## 3. Numbers and interpolation
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated
numbers.
| Case | Do |
| --- | --- |
| A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically |
| A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** |
| A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out |
| Money | `<Money>` or the `@/utils` money helpers. Never a float, never a raw digit run |
### Policy numbers are never hard-coded into a string
Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours,
the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message
key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`src/constants/policy.ts`, which single-sources them.
A config edit must never again silently make the UI copy lie. (These are real server config with no public
read yet; the constants file is the interim single source.)
---
## 4. Persian style — binding for `fa.json`
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in
`fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate
immediately rather than needing to be re-discovered by a human.
`en.json` is hand-written and reviewed for idiom, not linted.
### 4.1 Brand name — **linted**
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name
appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst
place to be inconsistent.
### 4.2 تأیید — hamza, always — **linted**
Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never
تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no
exceptions, in every namespace.
### 4.3 جستجو — one form — **linted**
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو», not «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
### 4.4 ZWNJ (نیم‌فاصله)
Use ZWNJ — never a plain space, never nothing — in:
- **می‌ + verb stem** — می‌شود، می‌کند، می‌پردازید، می‌ماند. Never میشود or می شود.
- **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread
(`شب‌ها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچه‌ها`).
- **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده. One ZWNJ-joined
word: not two spaced words («تایید شده»), not fused with no separator.
- The brand name (§4.1).
### 4.5 Two other linted rules
- **The archaic passive می‌گردد is banned** — use می‌شود. (The check anchors on a leading space so the
entirely legitimate «برمی‌گردد», which fuses «بر» directly on, is never flagged.)
- **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word.
### 4.6 Punctuation and quotes
- Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new
multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit
those**, just don't add more.
- English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (``). Don't
reintroduce curly quotes when editing English copy.
### 4.7 Domain glossary
| Term | Means | Never |
| --- | --- | --- |
| **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form |
| **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender |
| **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts |
| **درخواست رزرو** | a pre-payment request | conflating it with رزرو |
| **ویزیت** | one scheduled visit/session within a booking | — |
| **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere |
The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's
`Bookings` vs `Booking` areas. It is a money boundary, not a synonym.
### 4.8 Shell naming — one metaphor per audience class
- **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name.
(`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared
screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.)
### 4.9 Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its
start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside
it (national ID + civil registry + liveness selfie), on both the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`).
A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav
read that as a contradiction. They no longer share a name — keep it that way.
### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen
for the *same underlying concept* (a verification step's outcome):
| Audience | Form | Matches its siblings |
| --- | --- | --- |
| Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») |
| Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` |
This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and
`admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a
document being *rejected*, and conflating them would blur a real distinction.
### 4.11 Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare
imperative stems (نکن, برو).
---
## 5. Reference data with two names
Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale.
Don't ask the server for a locale-specific name, and don't translate a data row into a message key —
categories, provinces and cities are rows an admin can add, not vocabulary.
+221
View File
@@ -0,0 +1,221 @@
# Client services and data
The fetch layer, the `services/{domain}` pattern, caching, and the money rules that make the UI honest.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Fetch only through the two primitives
| File | Use from | Behaviour |
| --- | --- | --- |
| `lib/api/client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error; silent-refreshes and retries once on 401 |
| `lib/api/server.ts` | RSCs, Server Actions | `serverFetch<T>` — throws `ApiError` on error |
| `lib/api/errors.ts` | anywhere | the `ApiError` class (`status`, `message`, `code`) |
| `lib/api/types.ts` | anywhere | `ApiEnvelope<T>` + `unwrap()`, `Paginated<T>`, `PageParams` |
| `lib/api/refresh.ts` | internal | `attemptTokenRefresh` — the single-flight refresh `clientFetch`'s 401 branch uses |
**Never call `fetch()` directly** in a component, hook, or service. Domain calls live in
`src/services/{domain}/apis/`.
### The `clientFetch` error contract
| Status | What happens |
| --- | --- |
| **401** | Toast "session expired", clear cookies, redirect to login. **No throw** — the page navigates away |
| **403** | Toast "forbidden", throw `ApiError` |
| **5xx** | Toast "server error", throw `ApiError` |
| Other **4xx** | Throw `ApiError`, **no toast** — the calling hook owns the user-facing message |
| Network failure | Toast "network error", throw `ApiError` |
So: **never toast 401/403/5xx inside a hook.** Only a domain-specific 4xx earns an `onError` toast. A
mutation that handles only `onSuccess` is still a defect — see [components.md](components.md) §6.
`serverFetch` throws on every error and toasts nothing (the server can't fire browser events). The RSC
caller decides whether to `notFound()`, `redirect()`, or let it reach an error boundary.
**Never mix `clientFetch` and `serverFetch` in one file.** Keep `clientApi.ts` and `serverApi.ts` separate;
Next enforces the environment boundary at build time.
### The wire envelope
The server wraps every response in `ApiEnvelope<T>``{ isSuccess, statusCode, message, requestId, data }`,
camelCase. `clientFetch` returns the raw body, so a real `clientApi` reads the payload via `unwrap()`.
Types mirror the wire **exactly** and are derived from the published contract in
[`docs/integration/`](../../integration/index.md) — never guessed. If a shape you need doesn't exist, say so
and mock behind the seam meanwhile (§3).
---
## 2. The `services/{domain}` pattern
Every one of the 22 domains has the same shape. Copy `auth` or `patients`.
```
services/{domain}/
├── types.ts wire types + the domain's `Api` interface — this interface IS the seam
├── keys.ts the React Query key factory, hierarchical
├── constants.ts the mock toggle + staleTime values (when the domain has a mock)
├── apis/
│ ├── clientApi.ts real, wraps clientFetch, unwraps the envelope
│ ├── mockApi.ts in-memory, same interface
│ ├── serverApi.ts serverFetch — only when an RSC needs it
│ └── index.ts selects real vs mock by config — the one line hooks import
├── hooks/
│ └── use{Action}.ts one hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
└── index.ts the barrel: re-exports HOOKS ONLY
```
Two hard boundaries on the barrels:
- **No top-level `src/services/index.ts`.** An import must name its domain:
`import { useLogin } from '@/services/auth'`, never `from '@/services'`.
- **A domain barrel exports hooks only** — never `types`, `keys`, or `apis/*`. Reaching past the hooks is
how a component ends up depending on a mock's internals.
### Caching is deliberate, not incidental
- Set a **`staleTime`** on reads, so revisiting a screen doesn't refetch.
- Mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData`
never leave the cache stale. See `services/patients/hooks/*`.
- **Reference data is cached for the whole session.** Rarely-changing lookups use an **Infinite
`staleTime`** plus a shared hierarchical key factory, so each level is fetched **once** and served from
cache across every consumer — never refetched on a dropdown open. Two domains do this: `geography` (the
province→city→district hierarchy, `geographyKeys`) and `catalog` (admin-seeded categories and a category's
option groups, `CATALOG_REFERENCE_*`). **Reuse the pattern; do not reinvent per-consumer fetching.**
- Contrast with mutable lists — addresses, coverage areas, the nurse's own variant list — which invalidate on
every mutation.
- **The filter object IS the query key.** `search` canonicalizes its filter set into the key
(`canonicalizeSearchFilters`), so identical or reverted filters reuse cache with zero network;
`keepPreviousData` avoids flashing. Filters and page belong **in the URL**, which is what makes the cache
key shareable and the back button work.
- Admin and partner queue pages use **`useAdminListState`** (`@/hooks`) for URL-synced worklist state:
draft-vs-applied filters plus page, with `apply`/`applyFilters`/`clear`/`goToPage`. It is
`useSearchParams`-based, so a caller needs a `<Suspense>` boundary.
- Prefer RSC prefetch or `initialData` where it removes a client round-trip.
### Re-render cost is part of correctness
Stable references (`useCallback`/`useMemo` only where it pays), `select` to subscribe to a slice rather than
a whole query, state colocated as low as it can go and lifted only when genuinely shared. **Don't put
fast-changing state in a high context provider** — a 1-second countdown belongs inside the component that
displays it, which is exactly what `CountdownTimer` does.
---
## 3. The mock seam
When a backend endpoint isn't live, implement the domain's `Api` interface **twice** — a real `clientApi.ts`
and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks
import the selected `api`; **the swap is one line and touches no caller.** Record every mock in
`docs/status/` per [code-quality.md](../shared/code-quality.md) §4.
### Current state — 15 real, 7 mocked
**Real** (`USE_*_MOCK = false`): `auth`, `geography`, `patients`, `profiles`, `nurse` (bank), `addresses`,
`serviceAreas`, `catalog`, `search`, `bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`,
`tickets`.
**Still mocked**, each blocked on a named contract gap:
| Domain | Blocked on |
| --- | --- |
| `verification` | the admin verification queue |
| `refunds` | the admin refund preview |
| `payouts` | the admin payout preview |
| `admin` | the RBAC role endpoints |
| `bnpl` | provider options / schedule / wallet installments |
| `partnerCenter` | the portal split reads + a `/me` centre signal |
| `patientRecords` | endpoints exist, but the client family-record `id` model is `string` vs the wire's `int` — the customer-edit PUT is **write-unsafe** until reconciled |
The open REQ numbers behind these live in `docs/status/backlog.md`. Before flipping a domain to real, check
that its `clientApi.ts` actually consumes the fields the server serves — flipping the flag is necessary but
not sufficient.
One coupled seam: **`EVV_GPS_MODE` auto-selects `off`** (real `navigator.geolocation`) once
`USE_BOOKINGS_MOCK` is `false`, so you don't get mock coordinates against real bookings.
---
## 4. Money and time on the client
The client **displays** money. It does not compute it.
| Rule | Why |
| --- | --- |
| Money crosses the wire as an **IRR digit string** and is parsed with integer-safe `BigInt` helpers (`formatIrrToToman` / `formatIrr` / `parseIrr` in `@/utils`). **Money is never a float** | IRR aggregates exceed JS's safe integer range, and float coercion on money is a correctness bug, not a rounding one |
| A **breakdown reconciles by construction**`PriceBreakdown` dev-guards `console.error` when rows don't sum to the total | A total the user can't derive from the rows they were shown is a trust failure |
| The client **never computes** a rate, an aggregate, a payout date, or a holiday shift | These are server truth. A commission rate is snapshotted server-side at compute time; a review aggregate is recomputed from source; a payout date shifts off the bank-closure calendar |
| A **server-frozen deadline is rendered, never recomputed**`CountdownTimer` takes the UTC instant and counts down to it | A client that recomputes a deadline from a config value will disagree with the server the moment the config changes |
| The **signed net payable balance is never clamped** — a negative reads as an explicit "owed back" state (magnitude only, never a bare minus) | Clamping to zero tells a nurse they owe nothing when they do |
| Toman is **display-only**; the boundary conversion happens once, at the field | Mixing units in the middle is how a price ends up 10× off |
| Dates arrive as **UTC ISO** and display through `formatShamsiDate(Time)`. Shamsi is a client concern | Except bank-closure math, which the server owns |
### Money-path mechanics
- **The caller owns the per-attempt `Idempotency-Key`** on payment initiate. Per *attempt*, not per booking.
- **Poll only while non-terminal**, with backoff and bounded attempts. `usePaymentOutcome`, `useBnplOrder`,
`useRefundStatus` and `useBookingRequest` all stop at a terminal state.
- **A 409 on the money path is benign convergence, never a toast.** It means the server already did what you
asked.
- **`invalidations.ts` is the one post-capture cache transition** per money domain — an explicit list of the
request/booking/summary/outcome keys that change. **Never a blanket refetch.**
---
## 5. Non-negotiable data rules
These encode business invariants, not preferences. Breaking one leaks data or misreports money.
**Clinical data**
- **`is_internal` is never modelled in the user-app ticket types.** Both mappers drop an internal message —
a server-strip mimic — and there is no internal affordance anywhere in a user-facing screen. The admin
ticket types carry `isInternal`; the user types deliberately do not.
- **The customer must never fire the care-instructions query.** The two-stage disclosure gate is proved by a
test on `BookingDetailView`.
- **A nurse's care-record access is append-only.** The nurse surface never wires the customer-edit mutation.
- **Access-denied is a first-class, non-leaking state, gated *before* any clinical fetch** — not an error
rendered after a 403 came back with a body in it.
- **Clinical text is never logged, never put in `localStorage`, never put in a query string.**
**Visibility and trust**
- **A `pending_moderation` review is never injected into a public list or aggregate**, and the client never
computes the aggregate.
- **Every search result is verified-by-invariant** — the server's index only contains searchable rows, so
the UI never re-filters. If an unverified nurse appears, that is a server bug, not something to paper over
client-side.
- **`districtId = null` means whole-city** — a real coverage choice, not missing data. Treating it as absent
drops a nurse's entire coverage.
- **A notification's `data_json` is a typed contract.** `parseNotificationData(type, dataJson)` returns a
discriminated union, tolerates snake/camel, and degrades to `{ kind: 'none' }` on anything malformed,
unknown, or missing an id. Never trust the blob; never index into it directly.
---
## 6. Cookies
**App and auth state goes through the cookie manager only** — never `document.cookie`, never `js-cookie`
directly, never `localStorage` or `sessionStorage`.
| File | Import from | Holds |
| --- | --- | --- |
| `lib/cookies/constants.ts` | anywhere, via the barrel | `COOKIE_NAMES`, `CookieOptions`, `AUTH_*_COOKIE_OPTIONS`, `COLOR_SCHEME_COOKIE_OPTIONS` |
| `lib/cookies/server.ts` | RSCs, Server Actions, Route Handlers **only** | `getServerCookie`, `getThemeMode`, `setServerCookie` |
| `lib/cookies/client.ts` | client components / effects **only** | `getClientCookie`, `setClientCookie`, `deleteClientCookie`, `getColorSchemeCookie` |
| `lib/cookies/index.ts` | anywhere | re-exports `constants.ts` **only** — a safe barrel |
Import constants via the barrel (`import { COOKIE_NAMES } from '@/lib/cookies'`) and the server/client
utilities **directly** from their file. Never import `server.ts` in a client component or `client.ts` in an
RSC.
`COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` is the single source of truth for the theme cookie name — do not
redeclare it anywhere. `CookieOptions.maxAge` is in **seconds** (converted to an `expires: Date` internally).
**Never read `localStorage` or `document.cookie` in a render function** — use an effect, or read server-side
via `next/headers`.
See [auth.md](auth.md) for the token cookies and the session lifecycle.
+185
View File
@@ -0,0 +1,185 @@
# Client structure
The route tree, the server/client boundary, and the page pattern every screen follows.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. `src/` at a glance
| Folder | Holds |
| --- | --- |
| `app/` | The App Router tree. Everything under `[locale]/` |
| `components/` | Shared UI — `common/` primitives plus one folder per domain composite family |
| `constants/` | App-wide named constants (`routes.ts`, `roles.ts`, `headers.ts`, `policy.ts`) |
| `context/` | React context providers — `auth/` (AuthContext + reducer) |
| `hooks/` | Cross-cutting hooks (`auth.ts`, `capabilities.ts`, `layout.ts`, `useAdminListState.ts`) |
| `i18n/` | next-intl wiring: `routing.ts`, `request.ts`, `navigation.ts` |
| `layout/` | The one mobile app shell: `AppFrame`, `MobileShell`, the four actor layouts, chrome components |
| `lib/` | Infrastructure: `api/` (fetch), `auth/` (token/session), `cookies/`, `query/`, `toast/` |
| `services/` | 22 domain services, one folder each — the data layer |
| `theme/` | Palette, tokens, typography, the pre-built themes |
| `utils/` | Money, dates, numbers, CSV, text helpers |
Plus `messages/{en,fa}.json`, `middleware.ts`, and `next.config.mjs` (which only wires the next-intl
plugin and `reactStrictMode`) outside `src/`.
This is a **pattern map, not a file listing**. `git ls-files client/src` enumerates files for free and
never goes stale; what follows is the shape those files have to fit.
---
## 2. The one absolute rule: no layout above `[locale]`
**`src/app/[locale]/layout.tsx` IS the root layout.** It renders `<html>` and `<body>`. There is no
`src/app/layout.tsx`, and adding one — or any layout above the `[locale]` segment — breaks both locales.
**Why**, because this is worth understanding rather than obeying: a layout above `[locale]` is *shared*
between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` (`fa`) and never
re-renders it on a client-side locale switch, because the segment it is keyed on doesn't change. Its
`lang`, `dir`, messages, providers, and fonts therefore **freeze on `fa`/`rtl` for every route, including
`/en`**. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place
`<html lang dir>` can reliably track the active locale.
### What the root layout owns
- The locale, sourced from the **URL param** (`params.locale`), validated against `routing.locales` with a
fallback to `defaultLocale`. **No header reads.**
- `<html lang dir>``dir` from `getDirection(locale)` — plus `data-mui-color-scheme` from
`getThemeMode()`.
- The per-locale font class (Mikhak on `fa` only — see [theme.md](theme.md)).
- `setRequestLocale(locale)`, so server components deeper in the tree can call `getLocale()` /
`getTranslations()` reliably. **Never remove it** — without it, deeper RSCs always see `defaultLocale`.
- `getMessages({ locale })` with the locale passed **explicitly**, so `getRequestConfig` receives it via
`Promise.resolve(locale)` rather than through the `React.cache` read — which avoids a cache-ordering
race. **Never call `getMessages()` bare.**
- The providers: `NextIntlClientProvider`, `AuthProvider` (seeded with `getServerAuthState()`),
`ThemeProvider`, `NotistackProvider` + `ToastBridge`.
- `generateStaticParams`, so Next can enumerate locale routes at build time.
- `generateMetadata` — the `'%s | بالین‌یار'` / `'%s | Balinyaar'` title template, the default title and
description, and `metadataBase: new URL(SITE_URL)` so child pages' relative OG/canonical URLs resolve
absolute. `SITE_URL` comes from `src/config.ts`, never a hard-coded origin.
**Never add `notFound()` to the `[locale]` layout.** Unknown locales are handled by middleware; a hard 404
there breaks the fallback.
### The two files that legitimately sit above `[locale]`
| File | Why it's allowed |
| --- | --- |
| `app/global-error.tsx` | It *replaces* the root layout on a root-level crash, so it renders its own `<html>` — which means it **cannot use next-intl**. It is the one sanctioned static-string exception: keep it minimal and bilingual (fa + en) |
| `app/robots.ts`, `app/sitemap.ts` | Route handlers, not layouts. They enumerate the public surface across both locales |
---
## 3. The server/client boundary
| Never import | From |
| --- | --- |
| `next/headers` | a client component |
| `next-intl/server` | a client component |
| `@/lib/cookies/server` | a client component |
| `@/lib/cookies/client` | an RSC |
The build fails on the first three. The fourth fails at runtime, quietly, which is worse.
Route-group layouts (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they
only wrap a layout component and need no server capabilities.
Never mix `clientFetch` and `serverFetch` in the same file; keep `clientApi.ts` and `serverApi.ts`
separate. Next enforces the environment boundary at build time.
---
## 4. The route tree, by shape
Everything lives under `src/app/[locale]/`. Route groups add no URL segment.
```
[locale]/
├── layout.tsx error.tsx not-found.tsx [...rest]/page.tsx
├── (private-routes)/ layout.tsx mounts useSessionRoleSync
│ ├── _chrome/ shared loading skeleton (private, not a route)
│ ├── select-role/ first-use role picker, own FocusedLayout
│ ├── (customer)/ the family app — no URL segment
│ ├── (customer-focused)/ chrome-free counterpart, same URL space (onboarding)
│ ├── nurse/ the nurse app
│ ├── admin/ the backoffice
│ └── partner/ the partner-centre portal — a SEPARATE authz scope
└── (public-routes)/ login · terms · privacy · welcome
```
| Convention | Meaning |
| --- | --- |
| `(parenthesised)` | A route group. Adds no URL segment; exists to attach a layout and a `RoleGuard` |
| `_`-prefixed folder | Private, **not a route**`_chrome/`, `admin/_hub/` |
| `[...rest]/page.tsx` | The catch-all. Calls `notFound()` so any unmatched path under a locale renders `not-found.tsx` — next-intl's recommended 404 pattern |
| `loading.tsx` | A route-group skeleton shaped like that group's content area. The `MobileShell` chrome is already rendered by the enclosing layout, so a skeleton shapes the content only |
Each private group's `layout.tsx` is `'use client'` and wraps `RoleGuard` → that actor's layout:
`(customer)``CustomerLayout`, `nurse``NurseLayout`, `admin``AdminLayout`, `partner`
`PartnerLayout`. `(customer-focused)` and `select-role` wrap `FocusedLayout` instead, for flows a user must
not be able to tab away from mid-setup.
The **partner portal is a separate authorization scope**: a centre admin is not a Balinyaar admin. Its
`RoleGuard` passes no `expected` role (it isn't an `AppRole`) and each page resolves the caller's *own*
centre via `useMyPartnerCenter`. See [auth.md](auth.md).
**When you add, remove, or rename a route group, a provider, or a top-level `src/` folder, update the
"Project structure" section in [client/CLAUDE.md](../../../client/CLAUDE.md) and §1 above in the same
change.**
---
## 5. The page pattern
The root layout owns a title *template*; a route supplies the `%s`. So a route that wants its own tab
title splits in two:
```tsx
// page.tsx — a thin RSC. No 'use client'.
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'shell' });
return { title: t('customer_app') };
}
export default function Page() {
return <HomeScreen />;
}
```
```tsx
// HomeScreen.tsx — 'use client'. All the logic and JSX.
```
Rules that fall out of it:
- The screen component is **co-located** with `page.tsx` and named `<PageName>Screen.tsx`, so its existing
relative imports keep working unchanged.
- `page.tsx` never renders `<title>` and never touches `document.title`. Assigning `document.title` in a
render body throws `ReferenceError: document is not defined` during build-time prerendering.
- A static `metadata` export is fine when the title needs no translation lookup.
- Page bodies stay **composition + content**. Reusable visuals move to `src/components/`; page-only,
never-reused composition can stay in the page.
Adoption is partial by design: the landing pages (customer home, `/login`, `/search`, `/bookings`,
`/nurse`, `/admin`, `/partner`) plus `/welcome` use it. The rest still render directly and gain it when
the page is next substantially touched.
`metadataBase` makes the pattern extend to OG: `/welcome` sets `alternates.canonical` + `openGraph` in its
`generateMetadata` and supplies `og:image` from a co-located `opengraph-image.tsx` (`next/og`'s
`ImageResponse`).
---
## 6. This is not a static export
The app relies on server components, middleware, and server-side cookies. `next.config.mjs` wires the
next-intl plugin and `reactStrictMode` and nothing else — don't add `output: 'export'`, and don't assume a
page can be prerendered without its request context.
+120
View File
@@ -0,0 +1,120 @@
# Client testing, lint and types
What is tested and how, plus the two gate tools and their traps.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. What is tested
**Every shared component has a co-located test file.** A component is "shared" if it is imported from more
than one place — a page, a layout, or another component.
There are **125 test files** across `src/` (114 `*.test.tsx`, 11 `*.test.ts`). Location:
`src/components/<Name>/<Name>.test.tsx`, next to the component.
### Coverage baseline for a shared component
1. It renders without crashing.
2. Every documented prop produces the correct HTML attribute or CSS class.
3. User interactions (click, change) call the expected callbacks.
That is a floor, not a ceiling. Where a component encodes a rule, test the rule: `BookingDetailView`'s test
proves the customer **never fires the care-instructions query** (the two-stage disclosure gate), and
`matchActivePath`'s test proves a nested route lights up its parent tab and never a sibling. Those are the
tests worth writing.
### The two rules about the test itself
- **Wrap with `<ThemeProvider>`** if the component uses MUI theming.
- **Do NOT mock MUI components.** Test against the rendered DOM. A test that mocks `Button` proves nothing
about what a user sees.
### Before removing or renaming a shared component
Check whether any `src/**/*.test.{ts,tsx}` imports it. If so, update or delete those tests in the same
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.
### Deliberate coverage gaps
`NeshanMap` is **not** unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
unreachable in CI anyway, because `NEXT_PUBLIC_NESHAN_KEY` is unset there so `AddressMapPicker` falls back to
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.
---
## 2. Jest configuration
`jest.config.ts` **replaces** `next/jest`'s `transformIgnorePatterns` array outright rather than appending to
it. This is deliberate and easy to undo by accident:
`next/jest`'s default pattern already broadly matches all of `node_modules` (its negative-lookahead allowlist
carves out only a couple of Next-internal packages), and Jest's array semantics are **OR-based** — a file is
ignored if *any* pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
earlier pattern already caught. The array has to be replaced, by post-processing the async config `next/jest`
returns.
What it lets through: `next-intl`, `use-intl`, `@formatjs`, `intl-messageformat` — all ESM-only builds.
That fixes real imports; it does not make the dependency free. See
[components.md](components.md) "Presentational purity" for why `ErrorBoundary`/`ErrorState` are caller-owned
and `Money` is the one sanctioned exception.
---
## 3. The gate
```
npm run check → npm run type && npm run lint && npm run lint:copy
```
Plus `npm run test:ci` when you touched a component with a co-located test.
Both gate tools are plain CLI tools. **There is no `next lint`** — it was removed in Next 16, and calling it
silently does nothing.
| Script | Runs |
| --- | --- |
| `type` | `tsc --noEmit`. `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*` |
| `lint` | `eslint .`, driven by **flat config** in `eslint.config.mjs` |
| `lint:copy` | `node scripts/check-copy.mjs` — see [i18n.md](i18n.md) §4 |
`eslint.config.mjs` spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks +
jsx-a11y + import) and applies `eslint-config-prettier` **last**, so ESLint never fights Prettier on
formatting.
---
## 4. Lint rules for this project
- **Flat config only.** Do not add `.eslintrc*` files — put any rule change in `eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
eslint-config-next's default `warn` to **`error`**, so dead code fails `npm run check`. Delete it rather
than disabling the rule; prefix a deliberately-unused binding with `_` (`_event`, `catch (_err)`) to opt
out.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — the real example
here is a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
scoped `// eslint-disable-next-line <rule>` with a one-line reason. **Never a file-wide disable.**
### Two pinned constraints
- **ESLint is pinned to 9.** ESLint 10 currently crashes with this Next 16 toolchain
(`scopeManager.addGlobals is not a function`). Don't bump it as a housekeeping change.
- **`import/no-cycle` is disabled** — its TypeScript resolver has an interface mismatch here. The reason is
noted in `eslint.config.mjs`; don't re-enable it without checking that note.
---
## 5. MUI v9 API only
The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.
- Use `sx={{ mb: 4 }}`, **not** `mb={4}` as a direct prop.
- **Do not pass `flexWrap` or `useFlexGap` as direct props to `Stack`.** Neither is a valid v9 `Stack` prop;
both cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}`. `useFlexGap` was a v5 opt-in and
does not exist in v9.
- No other v5/v6-era props: `storageWindow`, `InitColorSchemeScript`. See [theme.md](theme.md) for the
color-scheme ones specifically, which fail *silently* rather than at compile time.
- Avoid deprecated MUI APIs that throw at runtime.
+268
View File
@@ -0,0 +1,268 @@
# Client theme
Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the
[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) skill's job; this file is the
mechanism it runs on.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Brand identity
Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm,
clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is
secondary.
| Role | Light | Dark |
| --- | --- | --- |
| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) |
| Secondary — terracotta | `#d98c6a` | `#e6a98a` |
| Page surface | `#faf9f5` cream | `#0f1c19` deep teal |
| Paper / card | `#ffffff` | `#16302a` teal surface |
| Text primary | `#1b2521` ink | `#f3efe9` cream |
**Teal ground, cream glyph, terracotta accent** is the whole identity. Use terracotta sparingly as the
single accent; teal carries everything else.
The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and
one terracotta dot. Two SVGs under `components/common/AppIcon/icons/`: `LogoMark.tsx` (monochrome
`currentColor` glyph only, registered as `ICONS.logo`) and `LogoLockup.tsx` (full colour, token-driven so
it tracks the scheme, used by `BrandMark`). **The wordmark beside it stays a real, translated
`<Typography>`** — never bake locale text into an SVG. The favicon and `public/img/favicon/*.png` are
rasterized from the same construction with fixed brand hex, which is the one place a literal hex is
correct; regenerate with a `sharp`-based script rather than hand-editing the binaries.
---
## 2. Two mirrored color homes
Colors exist in two places that must stay in sync. Pick the right one.
| Home | File | Reach it via | Use for |
| --- | --- | --- | --- |
| **MUI palette** | `theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) | `color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}` | **The default** for styling a MUI component |
| **`--bal-*` CSS variables** | `theme/tokens.css`, under `[data-mui-color-scheme='light'\|'dark']` | `var(--bal-primary)` | Custom CSS outside MUI's palette, and every semantic feedback color |
- Styling a MUI component → palette keys.
- Need success / error / warning / info → **`--bal-*`, not MUI's defaults.** The MUI palette defines no
semantic colors, and these tokens are brand-harmonized.
- Need a custom color in raw CSS → add a `--bal-*` token **in both scheme blocks**, then `var(--…)`.
- **Never hard-code a hex or rgb** in `sx`, `styled`, or a component.
- Adding or changing a color means editing `tokens.css` **and** `colors.ts` together. Both file headers
call out the sync requirement.
### The token catalogue
Every token is defined under both `[data-mui-color-scheme]` blocks unless noted.
| Group | Tokens | Notes |
| --- | --- | --- |
| Brand | `--bal-primary`, `-light`, `-dark`, `-contrast`, `-soft`; same five for `--bal-secondary` | |
| Surfaces | `--bal-bg-default`, `--bal-bg-paper`, `--bal-frame-canvas` | `frame-canvas` is the backdrop `AppFrame` paints *outside* the phone-width column — **never a surface a component draws on** |
| Text | `--bal-text-primary`, `--bal-text-secondary`, `--bal-divider` | |
| Semantic | `--bal-{success,error,warning,info}` + each `-contrast` + each `-soft` | `-contrast` is the text color on that fill; `-soft` is the tinted background variant |
| Elevation | `--bal-shadow-1/2/3` | Teal-tinted (black-teal in dark). They back `theme.ts`'s `shadows` array, so every MUI elevation resolves through them — never MUI's grey stack |
| Radius | `--bal-radius-sm` 6px (controls), `-md` 8px (cards/paper, `= theme.shape.borderRadius`), `-lg` 12px (dialogs), `-pill` 999px | Reference the token, **never a numeric `sx={{ borderRadius: n }}`** — that multiplies the shape unit, which is how the login card once became a 30px pill. `MuiPaper` pins the `md` step so a Paper can't drift past it. `-pill` is for shapes that genuinely *are* pills (the floating nav, a segmented control's active chip), never a card |
| Motion | `--bal-motion-fast/base/slow` (120/200/300ms), `--bal-easing-standard` | `theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the same numbers, in one place, instead of MUI's per-variant defaults |
| Focus | `--bal-focus-ring` | The 2px ring `MuiCssBaseline`'s global `:focus-visible` override uses. **Don't hand-roll a focus style** — it is already uniform everywhere |
| Rating | `--bal-rating`, `--bal-rating-empty` | `RatingInput` uses these, **not** `--bal-warning` |
| Trust | `--bal-trust`, `--bal-trust-soft` | A distinct identity for verified marks — not primary, not success. `TrustBadge` and any future verification UI |
| Money | `--bal-money-emphasis` | AA-contrast-safe emphasized money text. `--bal-secondary` (terracotta) **fails AA at small sizes on light backgrounds** — never use it for money text |
| Avatar | `--bal-avatar-1..6` + each `-contrast` | Six warm pairs `InitialsAvatar` picks from by a deterministic name hash |
| Map | `--bal-pin-shadow` | The address-picker pin |
`--bal-chrome-top` / `--bal-chrome-bottom` are **not** in `tokens.css``AppFrame` publishes them at
runtime on its scroll container. See [components.md](components.md).
---
## 3. Dark mode, and the no-flash boot
The mechanism is **pure CSS. There is no boot script**, no inline `<script>`, and no
`Storage.prototype` patching — matching how every other color decision in this app is made.
**Returning visitor (cookie present)**
1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → `{ colorScheme, defaultMode: colorScheme }`.
2. The root layout sets `data-mui-color-scheme={colorScheme}` on `<html>`, server-side.
3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match immediately — correct on
the very first paint, before any JS runs.
**First-ever visitor (no cookie)**
1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`.
2. The root layout renders `<html>` **without** the attribute at all (React omits `undefined`).
3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to
`:root:not([data-mui-color-scheme])` — it applies only while the attribute is absent, and paints the
OS-preferred scheme immediately with zero JS.
4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same* media query and
stamps the attribute itself. The painted values already match, so nothing visibly flips.
5. `ColorSchemeCookieSync` (inside `ThemeProvider.tsx`) writes the cookie from
`useColorScheme().colorScheme` in an effect, so the next visit is a "returning visitor" — even before
the user ever touches the control.
### The known gap, by design
This covers the dominant visual surface — every `--bal-*` token — because that is what the media-query
fallback drives. MUI's own generated `--mui-palette-*` variables (consumed by a bare `color="primary"`
fill: a contained Button, the default `MuiTabs` indicator) do **not** get the same free fallback: MUI's
`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both at once. So on a
cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value
until hydration. It self-corrects in the same frame, and `disableTransitionOnChange` means it snaps rather
than animating.
**Prefer sourcing colors from `var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides`** — most
of `theme.ts`'s `components` block already does — to keep this gap as small as possible.
### MUI v9 traps in this area
**`colorSchemeSelector` must be the explicit attribute name.**
```ts
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG
}
```
The shorthand `'data'` generates `[data-%s]` → boolean `data-dark=""` / `data-light=""` attributes. Our
`tokens.css` selects on `[data-mui-color-scheme="dark"]`, which never matches a boolean attribute, so the
whole token layer silently stops switching.
**Never use MUI's `InitColorSchemeScript`.** It reads localStorage, which diverges from our cookie
(especially in `system` mode), and it is a script — this app's no-flash boot is CSS-only. Don't add *any*
pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the `tokens.css`
media-query fallback instead.
**Never use `storageWindow={null}`.** In MUI v9's `localStorageManager` the check is
`if (!storageWindow && typeof window !== 'undefined')``null` is falsy, so it silently overrides to
`window`. The prop is a no-op in browsers.
**MUI v9's localStorage key defaults differ from v5/v6** — mode key `'mode'` (was `'mui-mode'`), color
scheme key `'color-scheme'` (was `'mui-color-scheme'`), HTML attribute `'data-color-scheme'` (was
`'data-mui-color-scheme'`). We override the attribute via `colorSchemeSelector`; the cookie is ours and is
named by `COOKIE_NAMES.COLOR_SCHEME`.
### `mode` vs `colorScheme`
Use **`colorScheme`** for an "is dark active" check. `mode` can be `'system'` even when dark is active.
The one exception is the control itself, which must read `mode`: that is the user's *choice*, while
`colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it
(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render, and
pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected".
### The one appearance control
`components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()`
and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`,
`/admin/system`, `/partner/more`, the customer profile hub) **and nowhere else** — the old top-bar toggle
spent a permanent slot of chrome in three shells on a preference set once.
It is a **three-way segmented control (light / dark / system), never a boolean switch.** `system` is the
app's real default on a cookie-less first visit, so an on/off control cannot represent the current state
and would silently misreport it.
The write path: `setMode('dark')``ColorSchemeCookieSync`'s effect writes the `'color-scheme'` cookie →
MUI sets `data-mui-color-scheme` on `<html>` → CSS variables resolve → the browser repaints. No React
re-render above the control.
### Pre-built themes
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. **Never call `createTheme()` inside a
component or hook** — pass the appropriate pre-built theme to `MuiThemeProvider`.
---
## 4. Direction
`getDirection(locale)` (`theme/direction.ts`) returns `'rtl'` for `fa`, `ar`, `he`, `ur`; `'ltr'` for
everything else. `ThemeProvider` takes a `dir` prop and selects the matching pre-built theme; the RTL
Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
The root layout sets `dir` on `<html>` and passes it to `ThemeProvider`. Because that layout is keyed on
the `[locale]` URL param, a locale change re-renders it with a fresh `dir` on both hard and soft
navigation, with no client state. Do **not** move the `<html dir>` render above `[locale]` — see
[structure.md](structure.md) §2.
**RTL correctness is a rule, not a nicety.** Never use directional hard-coding for layout flow —
`marginLeft`, `left:`, `textAlign: 'left'`. Use logical or MUI-flipped properties: `ml` (MUI flips it),
`marginInlineStart`, `insetInline`, `start`/`end`. Verify the layout visually at `/fa`, then `/en`.
Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose
goes in a `dir="ltr"` span. `SessionCard` and `BookingRequestSummaryCard` are the references.
---
## 5. Fonts
Loaded **per locale**, so the Persian face is never shipped to English pages.
| Locale | Font | CSS variable | Source |
| --- | --- | --- | --- |
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 in `src/app/fonts/` |
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time |
Rules:
- Both are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for
its own locale** — never both, never neither. A `next/font` loader called unconditionally would preload
on every route; `preload: false` ensures the file downloads only when its locale actually renders.
- Mikhak's woff2 files live in `src/app/fonts/`, **not** `public/``next/font/local` resolves paths
relative to the calling file at build time. Space Grotesk needs no local files.
- **Never load a font inside a component or page.** All font loading lives in
`src/app/[locale]/layout.tsx`.
- To add a local font: add the woff2 files, declare via `localFont` in the root layout, attach its
`.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants
in `typography.ts`.
### Typography
`TYPOGRAPHY_LTR` (Space Grotesk headings, system-stack body) and `TYPOGRAPHY_RTL` (Mikhak for *all* text,
for full Persian glyph coverage) share one size/line-height scale (`SIZE_SCALE`), wrapped in
`responsiveFontSizes()` in `theme.ts` for per-breakpoint heading scaling. **There is no `TYPOGRAPHY`
alias** — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly, and not into components: use
`<Typography variant=…>` and let the theme apply the direction-aware family.
**Never write `fontWeight: 600`.** Neither face loads a 600 weight, so a requested 600 silently renders
full Bold. The system is **700** for headings (`h1``h6`), buttons and strong emphasis, **500** for lighter
in-text emphasis (subtitles, row labels, chip text), **400** body. It is enforced globally in
`typography.ts`; match it in any new `sx`.
Buttons are `textTransform: 'none'` at weight 700, set globally — never re-uppercase button text.
The Persian scale sets `letterSpacing: 0` on **every** variant (Persian is a joined script; tracking breaks
glyph connections), body line-height ≥1.7, heading line-height ~1.41.5 for ascender/descender room. Don't
hand-roll per-breakpoint `fontSize` overrides — `responsiveFontSizes()` already wraps both themes.
---
## 6. Motion and the reduced-motion gate
`RouteFadeIn` (`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive. It wraps
`{children}`, keyed on the locale-stripped pathname so it remounts (and replays the CSS `bal-fade-in`
keyframe from `globals.css`) on navigation but never on an in-place re-render. It is mounted inside the
`ErrorBoundary` in **all five shells**, so a new page gets the motion for free with no per-page wiring.
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
media-query block.
This is deliberately a global CSS reset rather than token-only zeroing. `tokens.css` *also* zeroes the
duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse
transitions, which don't read CSS custom properties. **Never add a second, component-local reduced-motion
branch** — extend this one rule if a new motion primitive needs the same treatment.
---
## 7. Toast colors
`NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose
`backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `-contrast`) tokens. Because
those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme
automatically. **Never hard-code a toast color** — adjust the tokens.
Direction is inherited too: the Portal mounts under `<body>` and picks up `dir` from `<html dir>`. Do
**not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary.