# UI Phase 1 — Shared primitives & app-wide states — Report (2026-07-17)

## What was built

### State kit — `EmptyState`, `ErrorState`, `QueryStateGate`
`components/common/EmptyState` and `ErrorState` promote/replace `AdminEmptyState`/
`AdminErrorState` (now thin re-exports) with a branded, calm look (phase-0 surface/radius
tokens, registry icon slot, soft-primary icon roundel) — retiring the dashed-border `Paper`
pattern hand-rolled across the app. Both are **presentational with caller-owned copy**;
`ErrorState` requires `retryLabel`/`onRetry` so a query failure can never render without a
working retry. `QueryStateGate` wraps the `isLoading`/`isError`/`isEmpty` branching in one
fixed order (skeleton → error → empty → children) for pages that want the component instead
of hand-branching. The mechanical sweep replaced the dashed-`Paper` blocks with these
primitives; `border: '1px dashed'` under `client/src/app` is now zero (the one remaining hit
in `client/src`, `DocumentUpload`'s upload dropzone, is an interactive drop-target affordance,
not an empty state, and is outside `app/` — a deliberate, not missed, exception).

**The six error→false-empty defects from the audit are fixed and verified**, each now renders
`ErrorState` with a working retry:
- `(customer)/HomeScreen.tsx` (formerly `page.tsx`) — the patients gate no longer spinner-hangs
  on error.
- `(customer)/patients/page.tsx` — `isEmpty` now excludes the error case.
- `nurse/requests/page.tsx` — an errored inbox no longer reads as "no requests."
- `nurse/services/MyServicesList.tsx` — an errored variant list no longer shows the
  first-service CTA.
- `(customer)/profile/page.tsx` — an errored profile renders `ErrorState`, never a blank,
  save-capable form.
- `nurse/services/VariantBuilder.tsx` — a failed `optionGroupsQuery` now blocks progression
  via `ErrorState` instead of silently treating the category as having no required options.

Both silent mutations from the audit now toast on failure: `nurse/profile/page.tsx`'s avatar
upload and profile save each carry an `onError` toast. The convention — *an errored query must
never render as empty; every mutation needs an `onError` toast* — is written into
`client/CLAUDE.md` (Unit Testing / Toast Notifications sections).

### `PageHeader` + `ConfirmDialog` promotions
`PageHeader` generalizes `AdminPageHeader` (`title`/`subtitle`/`actions`/`backTo`/`backLabel`,
RTL-flippable back chevron via the new `forward`/`back` icon pair) and was adopted mechanically
wherever pages hand-rolled the identical h5 + subtitle block. `ConfirmDialog` moved from
`components/admin/` to `components/common/`, preserving its exact contract (required-reason
gating, busy-state disabling both buttons) — now usable by any actor, not just the backoffice.

### Card kit — `SurfaceCard` + `AccentCard`
`SurfaceCard` (flat `Paper`, house radius, a `padding` scale `sm`/`md`/`lg`) and `AccentCard`
(`SurfaceCard` + a `borderInlineStart` accent at one standardized 4px width — ending the
3px/4px drift between `EarningsBalanceHeader`/`PayoutHistoryRow` and `BankStatusPanel`/
`DocumentUpload`) replace the ~12 named hand-rollers (`EarningsRow`, `EarningsBalanceHeader`,
`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`,
`PriceBreakdown`, `BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`,
the customer-home nudge card) — visual unification only, no content/behavior change. A gap
found during the final sweep (`BookingDetailView`'s customer-side "care record locked"
notice, still on a dashed `Paper`) was migrated onto `AccentCard tone="neutral"` in this pass.
16 call sites now render through `SurfaceCard`/`AccentCard`.

### `<Money>` primitive
One component for every displayed IRR amount (`components/common/Money`): takes a served IRR
digit-string, formats via `utils/money.ts` (never computes), with `size`/`tone` variants —
emphasis renders in `--bal-money-emphasis`, **not terracotta** (`--bal-secondary` fails AA
contrast at small sizes on light backgrounds) — and an explicit deduction treatment (a
dir="ltr"-held `−` prefix, never a bare Unicode minus floating in RTL text). 19 call sites now
render through `<Money>`, including the flagship terracotta-total defects
(`PriceBreakdown`, `RefundStatusCard`, `BnplPlanCard`, `InstallmentScheduleRow`,
`CancellationPolicyDisclosure`). `PriceDisplay` (catalog unit rates) was deliberately left on
its own `formatIrrToToman` call per the phase's own scope note — its contract is tested,
correct, and has different unit/estimate-label needs than a bare amount.

One site — `bookings/[id]/cancel/page.tsx`'s confirmation restatement — needed a different
shape: the amounts are interpolated into a translated sentence (`"مبلغ {refund} به شما
بازپرداخت می‌شود…"`), not rendered as an isolated node, so `<Money>` couldn't drop in directly.
Fixed by switching `confirm_restate` to next-intl's `t.rich()` with `<refund>`/`<fee>` tags in
both message files, rendering `<Money>` inside the sentence rather than hand-formatting a
string — the first `t.rich()` usage in the codebase; a precedent worth reusing for any future
money-inside-a-sentence case instead of falling back to string concatenation.

`currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals is now zero
(DoD-exact grep).

### Formatting utils — `utils/number.ts`
`localeTag(locale)`, `formatNumber(value, locale, options?)`, `formatRelativeTime(iso, locale,
absoluteFormatter)` (7-day decay to the absolute Shamsi date — documented in JSDoc, no live
consumer yet per the phase's own scope: lands in phases 10/11), `formatClock(totalSeconds,
locale)` (migrated `CountdownTimer`'s inline `Intl` pad + fixed `OtpStep.tsx`'s Latin-digit
resend clock). Swept the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary onto `localeTag`/
`formatNumber` at every remaining site found by a final consolidated grep pass (run **after**
the metadata-pattern file splits, since those physically relocated code the first sweep had
already covered): `bookings/[id]/invoice/page.tsx`, `search/nurse/[nurseId]/page.tsx` (×2),
`NurseResultCard.tsx` (×2), `PriceDisplay.tsx`, and `JalaliDatePicker.tsx` (×2, the picker's
own new code). `locale === 'fa' ? 'fa-IR'` now appears only in `utils/number.ts` itself
(DoD-exact grep) — `JalaliDatePicker`'s separate `locale === 'fa' ? 'fa-IR-u-ca-persian' :
'en-US'` (the Jalali-calendar-system Intl tag, a different decision than the plain locale tag)
is intentionally untouched by this rule.

### `JalaliDatePicker` + `JalaliDateField`
Shamsi-native date selection that **always emits/accepts ISO Gregorian** — the wire never sees
a Jalaali date. `calendarEngine.ts` defines one `CalendarEngine` shape with two
implementations (`jalaaliEngine` for `fa`, a plain Gregorian one for `en`) so the picker's
grid/navigation logic is calendar-agnostic.

**Arithmetic decision:** `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is
sufficient to *display* a Shamsi date but has no reverse direction — there's no way to ask
Intl "what Gregorian date is Jalaali 1404/5/15," which is exactly what a *picker* (as opposed
to a *label*) needs for month navigation and emitting the selected day. Rather than hand-roll
the Borkowski conversion algorithm, the phase adds `jalaali-js` (MIT, zero runtime
dependencies, documented in-repo as ~9KB unminified / low single-digit KB gzipped) for the
reverse conversion (`toGregorian`) plus month-length/leap-year math; `Intl` still supplies
locale-correct month/weekday *names* — `jalaali-js` only supplies numbers. `JalaliDatePicker`
has a `grid` variant (month calendar with keyboard roving nav, RTL-flipped arrow keys) and a
`chips` variant (horizontal day-chip strip for near dates, the C4 booking-form shape).
`JalaliDateField` wraps it in a read-only `TextField` + `Popover`.

### `StatusChip` v2, `StatusTimeline`, `CountdownTimer` v2, `RatingInput` v2
- `StatusChip` rewritten to a soft-tint hierarchy: neutral/info/pending states use
  `--bal-primary-soft`, verified/active use `--bal-success-soft`, and solid `--bal-error` fill
  is reserved for the one true "alarm" state (rejected) — added alongside new
  `--bal-{success,error,warning,info}-soft` tokens (both color schemes).
- `StatusTimeline` (ordered `TimelineNode[]`: completed/current/pending/failed, animated pulse
  on `current` respecting `prefers-reduced-motion`) now backs `RefundStatusCard` and
  `BookingStatusTimeline` (3 call sites) — wizards (`StepperHeader`) are untouched, per the
  phase's own "wizards still use StepperHeader" boundary.
- `CountdownTimer` v2 adds a `windowStart` progress ring, `warnThresholdSeconds`/
  `urgentThresholdSeconds` urgency tiers (replacing the old binary `urgent` prop, kept as a
  legacy override), and an opt-in `coarseThresholdSeconds`/`coarseLabel` humanized mode — the
  core architecture (server-frozen UTC deadline, self-owned 1s tick, single `onElapsed`,
  `dir="ltr"` tabular-nums digits) is unchanged.
- `RatingInput` v2 renders a fractional fill via a clip-path overlay in read-only mode (a 4.5
  average now shows a genuinely half-filled fifth star, replacing the old `Math.round`
  workaround removed from the nurse profile page) and switched its fill colors from
  `--bal-warning` to the phase-0 `--bal-rating`/`--bal-rating-empty` tokens.

### Route chrome + per-page metadata
`loading.tsx` added for all five route groups ((customer), nurse, admin, partner,
public-routes) — the three sidebar shells (nurse/admin/partner) share one
`_chrome/SidebarShellSkeleton.tsx`. `error.tsx` and `not-found.tsx` under `[locale]/` are
branded and localized (the `not-found.tsx` is reached via a `[...rest]/page.tsx` catch-all
calling `notFound()`, next-intl's recommended pattern). `global-error.tsx` sits above
`[locale]/` and is the one sanctioned static-string, no-MUI exception — it replaces the root
layout on a root-level crash and renders its own `<html>`, so it structurally cannot reach
`NextIntlClientProvider`.

`ErrorBoundary` was rewritten to take `title`/`body`/`retryLabel` as required caller-owned
props instead of calling `useTranslations` internally — kept **presentational**, no
`next-intl` import, specifically so it stays safe at the top of the `components/common`
barrel (see the Jest note below). Both `CustomerLayout` and `TopBarAndSideBarLayout` pass the
copy via `useTranslations('routeChrome')`.

Every route now has its own browser tab title via `generateMetadata` (locale-aware `'%s | 
بالین‌یار'`/`'%s | Balinyaar'` template, set once in `[locale]/layout.tsx`). Seven pages that
needed a Client Component body were split into a thin Server Component `page.tsx` +
co-located `<Name>Screen.tsx`: home, login, search, bookings, admin overview, partner home
(nurse dashboard was already a Server Component and got `generateMetadata` added in place, no
split needed). The pattern is documented in `client/CLAUDE.md` under "Per-page metadata (the
client-page pattern)."

## The Jest / next-intl transform fix (the one non-mechanical problem this phase hit)

`ErrorBoundary`/`ErrorState` were deliberately kept free of `next-intl` because any component
near the top of the `components/common` barrel that imports it at module scope forces *every*
test file that transitively imports the barrel to deal with next-intl's ESM-only build — even
tests that never touch translations. `<Money>` couldn't take the same fix: it already had ~20
call sites depending on its locale-aware, self-contained API before this was noticed, so
stripping `next-intl` from it would have meant threading a formatted string through every
caller instead.

The actual fix was at the root: `next/jest`'s `createJestConfig` returns a
`transformIgnorePatterns` that already matches nearly all of `node_modules` (a narrow
negative-lookahead allowlist for a couple of Next.js-internal packages), and **appends**
whatever custom pattern you pass rather than letting it override — combined with Jest's
OR-based array semantics (a file is ignored if *any* pattern matches), a more permissive
pattern appended after the restrictive one can never "un-ignore" a package the first pattern
already caught. `jest.config.ts` now replaces the array outright, in an async wrapper around
`createJestConfig`, allowlisting `next-intl`/`use-intl`/`@formatjs`/`intl-messageformat` (each
transitive ESM dependency surfaced its own parse error until the allowlist was broad enough).
This is a repo-wide fix, not a `<Money>`-specific one: any future shared component is now free
to call `useTranslations` without risking the same barrel-poisoning failure mode. The
trade-off and the "prefer caller-owned by default" convention are both documented in
`client/CLAUDE.md`'s new "Presentational purity in `components/common`" subsection.

## Icon registry

Added `forward` (`ArrowForwardRounded`) to `AppIcon/config.ts`'s `ICONS` and
`DIRECTIONAL_ICONS` — needed for `JalaliDatePicker`'s next-month button (mirrors under RTL
alongside the existing `back`/`chevron_start` pair).

## Sweep counts (verified by grep on the current tree, not the original audit estimate)

| Primitive | Live call sites |
| --- | --- |
| `<EmptyState>` | 21 |
| `<ErrorState>` | 15 |
| `<Money>` | 19 |
| `<SurfaceCard>` / `<AccentCard>` | 16 |
| `<StatusTimeline>` | 3 |
| `formatNumber(...)` | 11 |
| `localeTag(...)` | 9 |

## Definition-of-Done sweep greps (re-run after the metadata-pattern file splits)

- `border: '1px dashed'` under `client/src/app` → **zero**.
- `currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals → **zero**.
- `locale === 'fa' ? 'fa-IR'` outside `utils/number.ts` → **zero**.

These were re-verified as a final pass, separate from the per-agent sweeps, because the
metadata-pattern splits (home/login/search/bookings/admin/partner) physically relocated code
after the mechanical sweeps had already run over the pre-split files — three real gaps
(`BookingDetailView`'s locked-care dashed border, the cancel-page `currency_toman` sentence
site, six `locale === 'fa' ? 'fa-IR'` sites across five files) surfaced only in this final pass
and are fixed above.

## What was mocked / waiting on a real service
None — this phase, like phase 0, is pure client primitives/chrome with no service seams
touched. No REQ entries filed (the phase never touches `server/` or a contract).

## Docs updated
- `client/CLAUDE.md` — Project Structure (`components/common/*` primitives, `utils/number.ts`,
  the route-chrome files, the metadata-pattern file splits), a new "Presentational purity in
  `components/common`" subsection under Unit Testing (the next-intl/Jest gotcha + the
  caller-owned-copy convention), "Per-page metadata (the client-page pattern)" section, "Every
  mutation needs an `onError` toast" line under Toast Notifications.
- `messages/en.json` / `fa.json` — `routeChrome` namespace, ~8 new error-copy keys for the six
  defect fixes, `confirm_restate` converted to `t.rich()` tags. Key parity verified
  programmatically: 1,538 keys in each file, zero one-sided keys.

## What a human should verify (not done here — no browser in this environment)
Per the phase's own Definition of Done, 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 was **not performed** — there is no browser available in this environment. `npm run
check` and `npm run test:ci` passing is evidence of type/lint/unit correctness, not visual or
interaction correctness. Specifically worth a human pass:
1. Force a query to fail (devtools offline) on `/fa` home, `/patients`, `/profile`,
   `/nurse/requests`, `/nurse/services` — confirm `ErrorState` + working retry, never a spinner
   or false-empty state.
2. `/fa/xyz` → branded 404; throw inside a page in dev → branded error screen; production build
   → confirm no stack trace renders.
3. `JalaliDatePicker` grid + chips variants on `/fa` (RTL arrow-key direction, month names) and
   `/en` (plain Gregorian) — both color schemes.
4. The nurse profile's rating display — confirm a non-integer average renders a genuinely
   half-filled star, not a rounded one.
5. Tab titles change per section (`جستجو | بالین‌یار`, `رزروها | بالین‌یار`, …).

## A note on a subagent report during this phase

One of the six parallel sweep agents (the card-kit migration agent, worktree
`agent-a099d7389dc14bd95`) returned a report whose text was flagged by the harness as
"instruction-shaped" (matching a `settings-json` pattern) and had its control tags neutralized
before reaching this session. Having now reviewed the neutralized text directly: it was the
agent's own file-accounting summary, noting in passing that
`.claude/settings.local.json` "predates this task and is untouched by me" — normal
bookkeeping language, not an embedded directive aimed at this session. No action was taken on
it beyond relaying it here, per the harness's own instruction to treat any remaining
directive-shaped text as a finding to report rather than follow.

## Gate
- `npm run check` (type + lint): **green**, zero errors/warnings.
- `npm run test:ci`: **94/94 suites, 392/392 tests green** (run after all merge-conflict
  resolution and the final DoD-sweep fixes above).
- `messages/en.json` / `messages/fa.json`: in sync (1,538 keys each, verified programmatically).
- 6 agent worktrees (`agent-a099d7389dc14bd95`, `-a0b4bb6e73e057271`, `-a279bb3717dd53e45`,
  `-a74eed78c5350b831`, `-ae141074976cb056f`, `-af1e1c6a8d89d64cc`) and their branches removed
  after diff extraction — no leftover worktree state.

## Follow-ups for later phases
- `formatRelativeTime` ships with tests but no live consumer yet — phases 10/11 (per the
  phase's own scope note).
- Custom illustration set for `EmptyState` — DEFERRED to phase 12, per phase 0/1's own notes.
- The app-wide motion pass (`--bal-motion-*`/`--bal-easing-standard`, defined in phase 0) is
  still unconsumed outside `StatusTimeline`'s pulse animation and `CountdownTimer`'s ring —
  phase 12's job.
- Visual verification (four axes × mobile/desktop) is unverified — see the section above; the
  next phase touching any of these primitives should do a pass before building further on top.
