# 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, ``, 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 `

`, raw `componentStack` dump); one static `` 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.