# 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`. ### `` 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 ``, 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 `` couldn't drop in directly. Fixed by switching `confirm_restate` to next-intl's `t.rich()` with ``/`` tags in both message files, rendering `` 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` + ``/`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 ``, 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 `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. `` 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 ``-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 | | --- | --- | | `` | 21 | | `` | 15 | | `` | 19 | | `` / `` | 16 | | `` | 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` + ``/`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.