# UI Phase 1 — Shared primitives & app-wide states

> **Mission:** build the shared component kit the pages keep hand-rolling — state views, page header, confirm
> dialog, card anatomy, `<Money>`, locale formatting, a Jalali date picker, status/timeline/countdown/rating
> upgrades, skeleton twins — give the app real route-level chrome (loading / error / 404 / per-route metadata),
> and fix the error→false-empty defects the missing kit caused. This phase **owns every shared primitive**
> (see the [README ownership rules](README.md)); phases 3–11 only consume what ships here.
>
> **Track:** frontend · **Depends on:** [Phase 0](ui-phase-0-design-language.md) (theme pass, icon family,
> `--bal-money-emphasis` / `--bal-rating*` / elevation / motion / focus tokens) · **Unlocks:** the kit every
> area redesign (phases 3–11) composes from
> **Before you start, read [../../phases/_shared/agent-operating-rules.md](../../phases/_shared/agent-operating-rules.md) and invoke the `frontend-designer` skill — both are mandatory.**

## 1. Context — where this sits

Balinyaar's feature layer is disciplined (tokens, RTL, four-state data pattern), but the *page primitives*
were only ever built for the backoffice. Everything user-facing is hand-rolled per page, so the app reads as
a starter and every restyle is a 20-file change. Verified in code:

- **29 hand-rolled dashed-border `Paper` empty/error blocks across 23 files** (grep `border: '1px dashed'`),
  while `AdminEmptyState`/`AdminErrorState` sit unused outside `components/admin/`.
- **24 hand-assembled `formatIrrToToman(x) + t('currency_toman')` sites across 19 files**; `PriceBreakdown.tsx:62`
  colors the grand total terracotta (`--bal-secondary`, ≈2.7:1 on white — a WCAG failure on the most
  trust-critical number), a pattern repeated across five sibling money components.
- **23 copies of the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary across 15 files**, already drifting
  (`admin/partners/[id]/page.tsx` passes the raw locale); **no relative-time formatting anywhere**; the OTP
  resend clock renders Latin digits (`components/auth/OtpStep.tsx:27`).
- **Zero `loading.tsx` / `error.tsx` / `not-found.tsx` / `global-error.tsx`** under `client/src/app`; the only
  crash UI is the starter `ErrorBoundary` (English `<h2>`, raw `componentStack` dump); one static `<title>`
  serves ~60 routes; **no Jalali date input** — every date input is a native Gregorian `type="date"`.
- **The missing kit caused real defects.** A failed query renders as *success-with-no-data*:
  - `(customer)/page.tsx:57–68` — the page gate destructures only `data` from `usePatients()`; on error it
    returns `<AppLoading />` forever (spinner-hang). The `CategoryGrid` in the same file branches all four
    states correctly — the discipline exists, the gate doesn't.
  - `(customer)/patients/page.tsx:83` (`isEmpty = !isLoading && …length === 0` → error shows «no patients»);
    `nurse/requests/page.tsx:19` (error = empty inbox); `nurse/services/MyServicesList.tsx:37,42` (error =
    «add your first service» CTA).
  - `(customer)/profile/page.tsx:16` — an errored profile renders a **blank form** whose save could overwrite
    server truth; `nurse/services/VariantBuilder.tsx:384` — `optionGroupsQuery` has no error branch, so failed
    option groups are treated as "category has none", letting a nurse skip required options.
- **Silent mutations:** `nurse/profile/page.tsx:44` (avatar upload) and `:54–64` (profile save) pass only
  `onSuccess` — a failure gives zero feedback.

**What already exists (do not rebuild):**

- The admin primitives to *promote*, not rewrite (all in `client/src/components/admin/`, all tested):
  `AdminPageHeader` (title/subtitle/actions, caller-owned i18n), `AdminEmptyState`/`AdminErrorState`,
  `ConfirmDialog` (required-reason gating + busy-disable).
- `utils/money.ts` (BigInt IRR↔Toman, fa digits) and `utils/date.ts` (Shamsi via `Intl` `fa-IR-u-ca-persian`,
  no date library); `CountdownTimer`'s architecture — server-frozen UTC deadline prop, self-owned 1s tick
  (only it re-renders), single `onElapsed` fire, `dir="ltr"` tabular-nums locale digits (`CountdownTimer.tsx:84`).
- `StatusChip` as the single status color+icon source seven components delegate to; the exhaustive typed
  enum→chip mappings; `TrustBadge`'s honest three states; the four-state pattern itself (~95 `isError`
  branches, ~124 retries) — restyle and fill gaps, don't rebuild; the `dispatchToast → notistack` pipeline.
- Phase 0's output: `theme.components` pass, one icon family, the new tokens. Build on them.

## 2. Required reading (do this first)

- [audit/component-primitives.md](audit/component-primitives.md) (kit inventory, hand-roll counts),
  [audit/feature-components.md](audit/feature-components.md) (card-anatomy drift, rating/terracotta/timeline
  defects), [audit/cross-cutting-ux.md](audit/cross-cutting-ux.md) (route chrome / metadata / formatting) —
  each carries the full evidence and a keep-list.
- [.claude/skills/frontend-designer/SKILL.md](../../../.claude/skills/frontend-designer/SKILL.md) — the design
  contract; and `client/CLAUDE.md` — Golden rules, Project Structure, Unit Testing, Toast Notifications, i18n.
- Code to read before writing: `client/src/components/admin/{AdminPageHeader,AdminEmptyState,AdminErrorState,ConfirmDialog}.tsx`;
  `client/src/components/{StatusChip,CountdownTimer,RatingInput,StepperHeader,RefundStatusCard,PriceBreakdown}/`;
  `client/src/components/booking/BookingStatusTimeline/`; `client/src/utils/{money,date}.ts`;
  `client/src/lib/api/client.ts` (which errors the fetch layer already toasts — 3.14 builds on it);
  `client/src/theme/tokens.css` as phase 0 left it. Product framing: `product/overview/platform-summary.md`
  (money display and Shamsi correctness are product rules, not preferences).

## 3. Scope — build this

New shared components live under `client/src/components/common/<Name>/` (component + `index.tsx` barrel +
co-located `.test.tsx`), exported from `@/components`, presentational with **caller-owned i18n** (already-
translated strings in, like `AdminPageHeader` documents). Strings a component *does* own (route chrome,
default retry labels) go in **both** `messages/en.json` and `messages/fa.json`.

### 3.1 State kit — `EmptyState`, `ErrorState`, `QueryStateGate` + the false-empty fixes

- Promote/generalize `AdminEmptyState`/`AdminErrorState` into shared `EmptyState` and `ErrorState` with a
  branded look (phase 0 surface/radius tokens; registry icon slot; calm — retire the dashed border).
  `EmptyState`: icon, title, body, optional CTA. `ErrorState`: **`onRetry` is required** («تلاش دوباره»).
  `components/admin` keeps thin wrappers or its call sites migrate — either way no duplicated implementation
  remains. (Custom illustration set: DEFERRED → phase 12.)
- Add a small `QueryStateGate` (props: `isLoading`, `isError`, `onRetry`, `isEmpty`, `skeleton`, `empty`,
  `children`) rendering skeleton → error → empty → data in that order, **plus** write the convention into
  `client/CLAUDE.md`: *an errored query must never render as empty; every error branch has a retry.* Pages
  with naturally inline branching (infinite lists) may follow the convention without the component.
- Fix the six defects from §1 with it. The profile fix must render `ErrorState` instead of the blank form;
  VariantBuilder's option-group error must block progression, not pretend "no options".
- Mechanical sweep: replace the 29 dashed-`Paper` blocks in 23 files with `EmptyState`/`ErrorState`. Copy
  stays per-page (existing keys); only the shell unifies.

### 3.2 `PageHeader` + `ConfirmDialog` promotions

- Generalize `AdminPageHeader` into a shared `PageHeader`: `title`, `subtitle?`, `actions?`, plus a `backTo?`
  affordance (RTL-flippable chevron via the phase 0 icon family; if phase 0 shipped no `back` icon, extend
  `AppIcon/config.ts` minimally and note it — ownership rule). Adopt it **mechanically** where pages already
  hand-roll the identical h5/h1 + subtitle block (patients, addresses, bookings, earnings, …); per-page header
  design is each area phase's job (DEFERRED → 3–11). `AdminPageHeader` becomes a re-export or its call sites migrate.
- Move `components/admin/ConfirmDialog` to `components/common/ConfirmDialog` **preserving its contract**:
  required-reason gating, busy state disabling both buttons, caller-owns-the-mutation. Migrate the admin
  imports and the hand-rolled confirm flows only where mechanical (patients archive, addresses delete); the
  remaining raw `<Dialog>` confirm sites are area-phase work (DEFERRED → 3–11).

### 3.3 Card kit — `SurfaceCard` + `AccentCard`

Encode the de-facto anatomy once: `SurfaceCard` = flat `Paper` (`elevation={0}`), 1px `divider` border, house
radius, a padding scale (`sm`/`md`/`lg` ≈ today's p:2 / 2.5 / 3); `AccentCard` = `SurfaceCard` + a
`borderInlineStart` accent taking a **semantic token name**, at one standardized width (pick one; end the
3px/4px drift). Migrate the ~12 hand-rollers mechanically — `EarningsRow`, `EarningsBalanceHeader`,
`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`, `PriceBreakdown`,
`BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`, the customer-home `NudgeCard` —
visual unification only, zero content/behavior change.

### 3.4 `<Money>` primitive

One component for every displayed amount: takes a **served IRR digit-string**, renders Toman + unit label via
`utils/money.ts` (never computes); `size`/`tone` variants with emphasis driven by `--bal-money-emphasis`
(phase 0) — **not terracotta**; deduction rendering (muted/error tone with an explicit visual sign treatment,
never a bare minus inside RTL text); optional strikethrough. Replace the 24 hand-assembled sites in 19 files —
this fixes the `PriceBreakdown` total-contrast defect and the sibling terracotta-money sites (`RefundStatusCard`,
`BnplPlanCard`, `InstallmentScheduleRow`, `CancellationPolicyDisclosure`) in one pass. `PriceDisplay` (catalog
unit rates) stays; refactor it onto `<Money>` internals only if trivial — its contract is tested and correct.

### 3.5 Formatting utils — `utils/number.ts`

- `localeTag(locale)` → `'fa-IR' | 'en-US'` and `formatNumber(value, locale, options?)` — kill the 23
  copy-pasted ternaries in 15 files (including the drifted `admin/partners/[id]/page.tsx` raw-locale call).
- `formatRelativeTime(iso, locale)` via `Intl.RelativeTimeFormat`; **rule (document in JSDoc): relative up to
  ~7 days («۲ ساعت پیش»، «دیروز»), then decay to the absolute Shamsi date** via `formatShamsiDate`. Consumers
  land in phases 10/11; ship the util + tests now.
- `formatClock(totalSeconds, locale)` — locale-digit `mm:ss`/`hh:mm:ss`; migrate `CountdownTimer`'s inline
  `Intl` pad onto it and fix `OtpStep.tsx:27`'s Latin-digit resend clock.

### 3.6 `JalaliDatePicker` + `JalaliDateField`

Shamsi-native date selection that **emits ISO Gregorian** (the wire stays Gregorian; display stays Shamsi):
a month-grid calendar (Persian month/weekday names, today marked, min/max props), a compact **day-chip-strip**
variant for near dates (next N days as tappable chips — the C4 booking form's shape), and `JalaliDateField`
(read-only input + picker popover) to replace native `type="date"` inputs. **Investigate and DECIDE the
arithmetic layer:** prefer `Intl` `fa-IR-u-ca-persian` (already proven in `utils/date.ts`) for display + a
minimal conversion for month math; a tiny, well-maintained jalali lib (e.g. `jalaali-js`, ~2 KB) is acceptable
if `Intl`-only arithmetic gets awkward — document the choice and measured bundle cost in the report; no
heavyweight date library. Locale-aware (`fa` Persian calendar, `en` may show Gregorian — same ISO output),
keyboard-navigable, RTL-correct, both schemes. Consumed by phases 4, 5, 8, 11 — consumer migration is theirs
(DEFERRED → 4/5/8/11).

### 3.7 `StatusChip` v2

Soft-tint default: `-soft` token backgrounds + strong text (extend `tokens.css` + `colors.ts` with any missing
`-soft` pairs, both schemes); solid fill reserved for high-alarm states; a documented status weight hierarchy
(neutral < info < progress < success < warning < alarm). `StatusChip` is the delegation point, so this is
mostly one file; update direct chip consumers mechanically and keep the exhaustive enum→chip mappings intact.

### 3.8 `StatusTimeline`

A designed vertical timeline: node = label + optional timestamp + optional note; states completed / current
(animated within the phase 0 motion tokens, reduced-motion-safe) / pending / terminal-failure. Migrate the
two StepperHeader-as-status-display misuses: `RefundStatusCard.tsx:76` (3-step refund progress) and
`components/booking/BookingStatusTimeline` (7-status booking lifecycle). **StepperHeader remains for real
wizards only** (onboarding, verification, BNPL) — write that into its JSDoc.

### 3.9 `CountdownTimer` v2

Keep the do-not-regress architecture (server-frozen deadline, isolated tick, single `onElapsed`, `dir="ltr"`
tabular digits). Add: a **progress-ring variant** (given `windowStart`); **urgency tiers** — calm teal →
amber → terracotta — with threshold props, replacing the binary `urgent`; **humanized coarse mode** above
~10 minutes («۲۵ دقیقه مانده», minute-grain updates) switching to the ticking clock below it; keep the `label`
slot; `aria-live="polite"` at coarse intervals (never per second). Swap the hourglass `pending` glyph for the
clock; drop the hard-coded `1.5rem` for size variants. Existing consumers (C5 tracker, nurse inbox, checkout,
EarningsRow dispute window) keep working unchanged — new props optional.

### 3.10 `RatingInput` v2

Switch fill colors to phase 0's `--bal-rating` / `--bal-rating-empty`; **fractional fill** (dual-layer or clip)
for read-only averages so 4.5 renders as 4.5 — then remove the `Math.round` workaround at
`search/nurse/[nurseId]/page.tsx:260` (it shows a 4.5 average as 5 stars — an honesty bug on the core trust
surface); size variants; keep the radiogroup/readOnly `role="img"` semantics and `data-*` hooks. Swap the star
color in `NurseResultCard` and `BookingRequestSummaryCard` to the same tokens.

### 3.11 Skeleton twins + `AppLoading` rebrand

Co-located `<X.Skeleton />` statics matching real anatomy (avatar disc, chip row, price line) for
`NurseResultCard`, `EarningsRow`, `PayoutHistoryRow`, `PatientCard`, `VisitNoteCard`, `TicketListCard`; adopt
at the loading branches that hand-guess heights (e.g. `search/results/page.tsx:75`). Rebrand `AppLoading` into
a branded splash (brand mark + subtle motion, reduced-motion-safe) used **only** for true full-page waits
(auth splash, gateway return). Of the 18 screens using it today, switch the ones with a shaped skeleton
available to skeletons; the rest keep the splash until their area phase.

### 3.12 Route chrome — loading / error / 404

Per-route-group `loading.tsx` with shell-shaped skeletons (`(customer)` header + card stack; `nurse`/`admin`/
`partner` sidebar-shell content; `(public-routes)` auth card). Branded `error.tsx` (per `[locale]` segment)
and `not-found.tsx` + the next-intl catch-all 404 pattern — calm Persian-first copy, brand mark, retry
(`reset()`) and «بازگشت به خانه» CTAs, keys in both catalogs. `global-error.tsx` (sits above `[locale]`,
renders its own `<html>` — it cannot use next-intl): minimal static **bilingual** fa+en copy — the one
sanctioned hard-coded-string exception; note it in `client/CLAUDE.md`. Rewrite `components/common/ErrorBoundary`
to the same design: i18n'd, branded, retry affordance, **stack/componentStack rendered only in development**
(logged, never shown, in prod).

### 3.13 Per-route metadata

Title template via one `generateMetadata` in `app/[locale]/layout.tsx`: `'%s | بالین‌یار'` (fa) /
`'%s | Balinyaar'` (en) + localized description. Establish and document the client-page pattern — `page.tsx`
becomes a thin RSC exporting `generateMetadata` and rendering the co-located `'use client'` body — and apply
it to the main landing pages now (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`,
`/partner`). Full per-page adoption continues in the area phases (DEFERRED → 3–11).

### 3.14 Mutation-error convention

Read `lib/api/client.ts` to confirm exactly which failures the fetch layer already toasts; then: **every
mutation whose failure is not surfaced inline or by the fetch layer gets an `onError` toast.** Fix the two
known silent ones — nurse profile save (`nurse/profile/page.tsx:54–64`) and avatar upload (`:44`) — and write
the convention into `client/CLAUDE.md` (Toast Notifications section).

## 4. Mocks & seams in this phase

None — pure UI over existing `services/{domain}` seams; no new service, mock, or flag. Expected REQ count:
zero. If a genuine backend gap surfaces anyway, append it to
[../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
numbered **REQ-039 onward** (001–038 are taken), keep the UI mock-tolerant behind the seams, never touch `server/`.

## 5. Critical rules you must not get wrong

1. **Presentational purity + caller-owned i18n stays.** Shared primitives take already-translated strings and
   stable codes; no data-fetching, no wire-derived labels inside this layer.
2. **Money invariants stay.** Amounts are served IRR digit-strings through `utils/money.ts` (BigInt); `<Money>`
   formats, never computes; `PriceBreakdown`'s dev reconciliation guard survives the card-kit migration.
3. **CountdownTimer architecture stays** — server-frozen deadline, isolated self-tick, single `onElapsed`;
   deadlines never recomputed client-side. **ConfirmDialog's contract stays** through the move. **An error is
   never an empty** — the defect class this phase kills; every error state keeps a retry.
4. **Don't break what delegates.** `StatusChip` is the single status source and the enum→chip mappings are
   exhaustively typed — keep both; `StepperHeader` keeps serving the real wizards untouched; honest-copy
   components (EscrowNotice verbatim fa, RefundStatusCard's no-success-framing-on-failure,
   EarningsBalanceHeader's "owed back") keep their copy and state logic — restyle shells only.
5. **Design contract non-negotiables:** tokens not hexes (extend `tokens.css` + `colors.ts` together, both
   schemes); logical/RTL-safe props only (keep the deliberate `dir="ltr"` islands for clocks/IBANs/phones);
   both message catalogs in sync; MUI v9 API only; co-located tests for every shared component;
   fetch/cookies/toast rules per `client/CLAUDE.md` untouched.
6. **Never add a layout above `[locale]`** — `global-error.tsx` is a special file, not a layout, and is the
   only file allowed static copy.

## 6. Definition of Done

On top of the shared [definition-of-done.md](../../phases/_shared/definition-of-done.md):

- [ ] `npm run check` green; `npm run test:ci` green (many shared components are touched — run it); every new
      shared component has a co-located `.test.tsx`; `en.json`/`fa.json` in sync.
- [ ] All six error→false-empty/spinner defects from §1 render `ErrorState` with a working retry (verify by
      forcing the query to fail); the profile error never shows an editable blank form.
- [ ] Sweep greps clean: `border: '1px dashed'` under `client/src/app` → zero; `currency_toman` outside
      `utils/money.ts` + `<Money>`/`PriceDisplay` internals → zero; `locale === 'fa' ? 'fa-IR'` → only
      `utils/number.ts`. The checkout total renders in `--bal-money-emphasis`, not terracotta.
- [ ] `loading.tsx`, `error.tsx`, `not-found.tsx`, `global-error.tsx` exist and render branded, localized
      (404/error verified on `/fa` and `/en`); ErrorBoundary shows no stack in a production build; browser
      tab titles differ per section using the `%s | بالین‌یار` template.
- [ ] `JalaliDatePicker`/`JalaliDateField` render Shamsi, emit ISO Gregorian, pass keyboard/RTL tests; the
      arithmetic decision + bundle cost is documented in the report.
- [ ] A 4.5 rating average renders a half-filled fifth star on the nurse profile (no `Math.round`);
      `RefundStatusCard` and the booking timeline render `StatusTimeline` (wizards still use `StepperHeader`).
- [ ] Nurse profile save and avatar upload toast on failure; the convention is in `client/CLAUDE.md`.
- [ ] Visual verification on the four axes — `/fa` + `/en` × light + dark — and mobile + desktop for the state
      kit, card kit, route chrome, and the Jalali picker.

## 7. How to test (what a human can verify after this phase)

1. Stop the backend (or force queries to reject via devtools offline mode) and load `/fa`: customer home shows
   a branded error state with «تلاش دوباره» — not an infinite spinner. Restore, retry → home renders. Repeat on
   `/patients`, `/profile`, `/nurse/requests`, `/nurse/services`: an error state, never «empty» copy or a blank form.
2. Visit a garbage URL (`/fa/xyz`) → branded Persian 404 with a home CTA. Throw inside a page in dev → branded
   error screen with retry; build production and confirm no stack is rendered.
3. Navigate between sections and watch the tab title change («جستجو | بالین‌یار», «رزروها | بالین‌یار», …).
4. Open search results while loading: skeletons match the card anatomy — no layout jump when data lands. Open
   checkout (mock path): the total renders in the money-emphasis color with correct contrast in both schemes;
   a deduction row (earnings commission) reads visually as a deduction.
5. Open the C5 request tracker: progress ring, coarse «X دقیقه مانده» above 10 minutes, ticking Persian-digit
   clock below, escalating tint as thresholds pass; the `/login` OTP resend clock shows Persian digits on `/fa`.
6. Open a nurse profile with a fractional average: stars render the fraction. Open a refund status page:
   vertical timeline with node states, not a form stepper.
7. Mount `JalaliDateField` (the phase's test page or the first consumer): pick «۱۵ مرداد» → the emitted value
   is the correct ISO Gregorian date; keyboard navigation works; `/en` shows the Gregorian view.
8. Kill the network mid-save on the nurse profile → an error toast appears; the form keeps the draft.

## 8. Hand off & document (close the phase)

- Update `client/CLAUDE.md`: Project Structure (new `components/common/*` primitives, route-chrome files,
  `utils/number.ts`), the QueryStateGate/error-never-empty convention, the mutation-error toast convention,
  the metadata pattern, and the `global-error.tsx` exception.
- Write the report at `dev/shared-working-context/reports/ui-phase-1-report.md`: what shipped, the Jalali
  decision + bundle cost, icon-registry additions (if any), the sweep counts, any REQ entries appended to
  [for-backend.md](../../shared-working-context/frontend/requests/for-backend.md) (REQ-039+; expected none),
  and any foundation gaps left for phase 2+.
- Save a memory note per operating-rules §8: the primitives kit now exists and later phases must consume it
  (never fork local variants), the error-never-empty and mutation-toast conventions, and the Jalali decision.
