# UI Phase 12 — Copy, motion & final polish — Report (2026-07-19)

This is the closing phase of the UI build chain (phases 0–12). Scope: a checked-in Persian style guide
+ lint, the verified copy defects from the microcopy/cross-cutting audits, ICU plurals, arrows out of
strings, a trust-copy pass, config-served policy numbers, a restrained app-wide motion language behind a
single reduced-motion gate, an a11y sweep, two desktop-aware layouts, and this closing QA pass.

## What was built

### 3.1 — Persian style guide + enforcement
- `client/messages/STYLE.md` — the binding one-pager: brand spelling (ZWNJ), تأیید hamza, one جستجو
  form, ZWNJ rules, punctuation, domain glossary (بیمار not مددجو), the shell-naming system
  (اپلیکیشن for end-user shells, کنسول for back-office), the verification-pipeline-vs-KYC-step naming
  split, a nurse-facing/admin-facing status-vocabulary rule, digits policy, register, and the
  policy-number-interpolation rule.
- `client/scripts/check-copy.mjs` — a ~100-line dependency-free Node script that flattens `fa.json` and
  greps every leaf string against 5 banned-variant rules (plain-space brand, hamza-less تایید, جست‌وجو/جست
  و جو, the «بازی» word-boundary trap, the archaic می‌گردد passive — with a leading-space anchor so the
  legitimate verb «برمی‌گردد» is never a false positive). Wired as `npm run lint:copy`, folded into
  `npm run check`. Currently: **1919 strings checked, 0 banned variants.**

### 3.2/hamza/جستجو/می‌گردد sweep — ~90 keys swept across `fa.json`
- Brand name: 5 keys (`common.brand`, `auth.customer_title`/`select_role_title`/`account_error_body`,
  `verification.start_body`) — plain-space → ZWNJ.
- تأیید hamza: ~65 occurrences normalized to the hamza form across `home`, `profile`, `nurseProfile`,
  `bank`, `activation`, `search`, `booking`, `payment`, `auth`, `verification`, `refunds`, `bnpl`,
  `payouts`, `admin`, `partner`, `legal`. `bank.status_verified_chip`'s typo («تاییدشد» missing the final
  ه) is fixed to «تأییدشده» in the same edit.
- جستجو: 3 keys folded (`coverage.empty_warning`, `booking.missing_nurse_body`, one `legal` section body).
- Archaic می‌گردد → می‌شود: `refunds.confirm_restate`, `admin.mod_confirm_publish`,
  `admin.cfg_save_confirm_body` (3 occurrences, matching the audit's own recount, not the "1" a naive grep
  would find).
- Grammar bugs: `booking.evv_no_open_check_in` («ورود بازی» → «ورودِ ثبت‌شده‌ای برای این ویزیت وجود
  ندارد؛ ابتدا ورود را ثبت کنید.») and `admin.alert_empty` («هشدار بازی» → «هشداری برای رسیدگی نیست.»).
- `tickets.thread_empty_body` comma splice → two sentences.
- `address.line_hint` — **already fixed by ui-phase-9** (verified in its report); not re-touched.
- EVV introduced once: `booking.evv_visits_subtitle` now spells out «ثبت حضور الکترونیکی (EVV) — ورود و
  خروج شما ثبت می‌شود تا ویزیت بدون اختلاف تأیید شود» (this subtitle renders once per visits-page
  load); the short chips (`evv_check_in`/`evv_check_out`) stay abbreviated.
- en: `auth.nurse_subtitle` "licence" → "license"; 6 curly-apostrophe holdouts in the `admin` namespace
  (`no_permission`, `doc_error`, `payout_run_confirm_body`, `mod_confirm_publish`, `access_denied`,
  `invoice_pdf_error`) → straight quotes. Zero curly quotes remain (verified by grep).

### Shell naming + verification-pipeline naming (fa only — en had no overload)
- `shell.nurse_app`/`switch_to_nurse`: «نمای پرستار» → «اپلیکیشن پرستار» (end-user shells share one
  metaphor); `shell.partner_console`: «پرتال همکار» → «کنسول همکار» (back-office shells share the other).
  `booking.evv_nurse_view` ("نمای پرستار" as a perspective chip label, not a shell name) is untouched —
  it's a different concept and STYLE.md calls out the distinction explicitly.
- «احراز هویت» renamed to «تأیید صلاحیت» for the **pipeline** (11 keys: `nav.verification`,
  `nurseProfile.unverified_body`/`unverified_cta`, `verification.title`/`load_error`/`start_title`/
  `start_cta`/`progress_title`/`approved_title`/`credentials_needs_start`/`review_approved_title`,
  `admin.ver_title`/`ver_case_title`); `verification.explainer_not_verified` rephrased to «صلاحیت این
  پرستار هنوز تأیید نشده است.» for the same reason. The **KYC step** keeps «احراز هویت» everywhere
  (`verification.step_identity_kyc`, `admin.step_identity_kyc`, `activation.row_identity`) — the two no
  longer share a name, so a nurse who passed KYC no longer sees the pipeline nav item read as
  contradictorily incomplete under the same word.
- Status vocabulary: `admin.step_failed` («ناموفق» → «ردشده») now matches admin's own
  `agg_rejected`/`rstatus_rejected`/`mstatus_rejected` pattern (all «ردشده»); nurse-facing
  `verification.status_failed` keeps «رد شد» (STYLE.md §9 — one nurse-facing form, one admin-facing
  form). Money-failure vocabulary (`ناموفق` for payouts/refunds/batches) is untouched — a payment
  *failing* is a different concept from a document being *rejected*.

### 3.3 — ICU plurals with designed `=0` cases
- fa gained ICU plural + a real zero-state on the 4 keys the audit flagged:
  `search.cta_view_results`/`results_count`/`reviews_count`, `booking.session_count`. Zero renders
  «پرستاری یافت نشد» / «بدون نظر» / «ویزیتی نیست», never «۰ پرستار».
  `search/SearchScreen.tsx` doesn't need a call-site change — `cta_view_results` is only reached
  when `count > 0` (the sibling `cta_zero_title` branch handles the zero state), so the ICU `=0` case is
  a defensive designed fallback, not the primary render path.
- en's own `cta_view_results`/`results_count` `=0` case fixed from the audit's flagged "View no nurses" /
  "No nurses" to "No nurses found" (`cta_view_results` restructured so "View" only prefixes the non-zero
  cases — `{count, plural, =0 {No nurses found} one {View # nurse} other {View # nurses}}` — rather than
  gluing "View" onto a full sentence).

### 3.4 — Arrows out of strings
- 4 keys per catalog had a literal ←/→ (not 5 — `payment.cta_pay` already lost its arrow before this
  phase): `booking.continue_payment`, `auth.nurse_switch`/`customer_switch`, `admin.cfg_history_change`.
  All four are now plain text; direction moves into a mirrored `AppIcon icon="forward"` (registered in
  `DIRECTIONAL_ICONS`, auto-flips under `[dir='rtl']` regardless of host component — verified by reading
  `AppIcon.tsx`, no per-call-site mirroring logic needed):
  - `bookings/request/[id]/page.tsx`'s continue-to-payment button: `endIcon="payment"` → `endIcon="forward"`
    (fixes the en wrong-direction arrow **by construction** — the icon mirrors, the string never carried
    direction to begin with).
  - `PhoneStep.tsx`'s role-switch link: an inline `<AppIcon icon="forward" size={14}>` after the text.
  - `admin/config/page.tsx`'s config-history diff: `cfg_history_change` split into
    `cfg_history_change_old`/`_new` (each holding one interpolation), rendered as two `Typography`s joined
    by a mirrored `AppIcon icon="forward"` — the old defect (a translator hand-mirroring `{old} ← {new}`)
    can't recur.
- Grep for `←|→` across both catalogs → zero hits (STYLE.md prose itself has none either).

### 3.5 — Trust-moments copy pass
- **BNPL de-jargon**: `bnpl.ownership_note` rewritten reader-first, «نکول» removed entirely — "you pay
  installments directly to the provider; Balinyaar receives the full amount up front, and if an
  installment goes unpaid, that risk sits with the provider — never you or the nurse" (both catalogs).
  Kept provider-agnostic (no `{provider}` interpolation) because the call site (`MethodStep.tsx`) shows
  this line **before** a provider is chosen, ahead of the provider list.
- **OTP-screen reassurance** and **checkout "why is this safe"**: found **already delivered** —
  `AuthCard`/`TrustBullets` (ui-phase-3) already renders three trust bullets (verified nurses, escrow
  payment, support) directly under the phone-entry card on both the customer and nurse login steps, and
  `EscrowExplainer` (ui-phase-6) already supplements `EscrowNotice` with a 3-step visual + the
  cancellation-policy implication at checkout. Neither needed new copy; verified by reading `AuthCard.tsx`/
  `PhoneStep.tsx`/`EscrowExplainer.tsx` rather than assumed from the audit's older snapshot.
- Verification pipeline naming + status vocabulary: see above.

### 3.6 — Config-served policy numbers
- New `client/src/constants/policy.ts`: `DISPUTE_WINDOW_HOURS=72`, `CANCELLATION_LEAD_HOURS=24`,
  `REFUND_ETA_MIN_BUSINESS_DAYS=7`, `REFUND_ETA_MAX_BUSINESS_DAYS=10` — re-exported via
  `src/constants/index.ts`.
- `payouts.explainer_point_2` takes `{hours}`; `refunds.lead_gt_24h`/`lead_lt_24h` take `{hours}`;
  `refunds.eta_business_days` takes `{minDays}`/`{maxDays}` (both catalogs). Wired at the 3 call sites:
  `nurse/earnings/page.tsx`'s `ExplainerCard`, `CancellationPolicyDisclosure.tsx`, `RefundEtaBanner.tsx`.
  `services/refunds/constants.ts`'s `BNPL_REFUND_ETA_BUSINESS_DAYS` now sources from
  `REFUND_ETA_MAX_BUSINESS_DAYS` instead of its own hardcoded `10`, so the mock's projected refund date and
  the displayed ETA window can never drift apart.
- **REQ-065** filed (`dev/shared-working-context/frontend/requests/for-backend.md`) — a
  public/authenticated policy-config read; verified first that only admin-scoped `platform_config/*`
  exists (per the contract docs), so this is a genuine gap, not a guess.

### 3.7 — Honest search empty state
- **Already fixed by ui-phase-4** — `search.empty_suggest_city` (the Mashhad/Isfahan/Shiraz nonsense
  suggestion) was already deleted and replaced with `empty_suggest_date` in both catalogs. Verified by
  grep (`مشهد|اصفهان|شیراز` → no matches) rather than re-implemented.

### 3.8 — Motion pass
- `globals.css`: one `bal-fade-in` keyframe (150–200ms fade + 4px slide, `var(--bal-motion-base)` +
  `var(--bal-easing-standard)`) applied via a `data-bal-route-fade` attribute selector, and **the single
  reduced-motion gate for the whole app** — a universal `*, *::before, *::after { animation-duration:
  0.01ms !important; transition-duration: 0.01ms !important; … }` under
  `@media (prefers-reduced-motion: reduce)`. This is the one rule that makes MUI's own JS-driven
  Dialog/Drawer/Menu/Collapse/Fade transitions collapse too (they don't read CSS custom properties, so a
  token-only gate wouldn't reach them) — no component anywhere needs its own reduced-motion branch.
- `tokens.css` additionally zeroes `--bal-motion-fast/base/slow` under the same media feature, for any
  future consumer that reads the token value directly.
- New shared component `components/common/RouteFadeIn/` (tested) — wraps `{children}`, keyed on the
  locale-stripped pathname (`@/i18n/navigation`'s `usePathname`) so it remounts (replaying the fade) on
  navigation but never on an in-place re-render. Mounted inside the `ErrorBoundary` in **all five** shells
  (`CustomerLayout`, `TopBarAndSideBarLayout` — nurse/admin/partner, `FocusedLayout`, `PublicLayout`) —
  every route in the app gets the fade, not a hand-picked subset.
- `theme.ts`: `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu` all get an explicit
  `defaultProps.transitionDuration: { enter: 200, exit: 120 }` in **one place** (matching
  `--bal-motion-base`/`-fast`) instead of MUI's per-variant defaults, so every dialog/bottom-sheet/menu in
  the app now shares one calm timing.
- Skeleton→content crossfade: the `data-bal-route-fade` primitive is reusable for this (any screen can
  attach it to its populated-state branch); demonstrated on `search/results/page.tsx`'s results grid
  (§3.10) as a worked example rather than retrofitted to every list screen in the app — flagged as a
  mechanical follow-up below, not a silent scope cut.

### 3.9 — A11y sweep
- **`AppIconButton`**: the underlying `IconButton` now always receives `aria-label={title}` directly (not
  only via the `Tooltip` wrap), so a **disabled** icon-only button — where the Tooltip isn't rendered at
  all — keeps an accessible name. A caller-supplied `aria-label` still wins (spread order). New test:
  "keeps an accessible name from `.title` even when disabled". `useMemo` dependency array fixed
  (`react-hooks/exhaustive-deps`) as part of the change.
- **`aria-live`**: `CountdownTimer`'s one-time elapsed transition now carries `aria-live="polite"` (the
  coarse-mode label already had it from an earlier phase; the fine per-second clock deliberately still
  doesn't — spam risk, unchanged). `ErrorState` (the one "query failed" pattern, ~55 sites) gets
  `role="alert"` — one change covers every error/retry region in the app. `ErrorBoundary` gets the same.
  `PaymentStateCard` (the one terminal/wait-state card for card + BNPL checkout, including the
  pending→succeeded/failed poll) gets `aria-live="polite"`.
- **`aria-expanded`/`aria-controls`**: `EscrowExplainer`'s toggle gained `aria-controls` (it already had
  `aria-expanded`); the admin ticket thread's refund-panel toggle (`admin/tickets/[id]/page.tsx`) gained
  both (it had neither). `nurse/earnings`'s `ExplainerCard` and `AuditLogRow`/`EmergencyPlaybookRow`
  already had both from earlier phases — verified, not re-touched.
- **Contrast**: `NurseDashboardScreen.tsx`'s unread-notifications label was the one real terracotta-on-text
  finding (`color: 'var(--bal-secondary)'` on a `body2` unread count) — swapped to
  `--bal-secondary-dark`. Every other `--bal-secondary` text usage found by grep was already
  `--bal-secondary-dark` (BNPL screens) or an icon/background (non-text contrast rules, not the AA text
  threshold the audit flagged).

### 3.10 — Desktop-aware layouts (scope-boxed to the two named surfaces)
- **Checkout** (`bookings/checkout/page.tsx`): above `md` (~900px), a two-column layout — the
  summary/total/countdown/breakdown/escrow content on the reading side (flex `62%`), a `position: sticky`
  order-summary panel with the pay CTA on the other. The pay-action JSX (total, CTA, secure-gateway note,
  BNPL button, inline error) is a single `payActions` value rendered in **both** the desktop panel and the
  mobile `StickyActionBar` — same handlers, same state, no duplicated logic; only one is visible at a time
  via `sx={{ display: { xs: …, md: … } }}`, the same pattern the codebase already uses for
  `BottomBar`/`CustomerDesktopNav`.
- **Search results** (`search/results/page.tsx`): the populated/skeleton lists switch from a single-column
  `Stack` to a `display: grid` with `gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }` — two columns above
  `md`. Bounded by the customer shell's existing `CONTENT_MAX_WIDTH` (800px, unchanged, shared across the
  whole customer app) — each card gets meaningfully more width than the old full-width single column, but
  a true full-bleed desktop layout or list+detail split would need a per-route shell override, which is
  exactly the "full responsive pass" the phase doc explicitly defers post-chain. Flagged, not silently cut.

### 3.11 — Final QA walkthrough

**Static verification performed** (all green): `npm run check` (type + lint + the new `lint:copy`),
`npm run test:ci` (115 suites / 526 tests, including 2 new/extended ones — `RouteFadeIn.test.tsx`,
`AppIconButton.test.tsx`'s new disabled-name case), a key-parity script (`fa.json`/`en.json` — 1891 leaf
keys each, zero one-sided keys, matching array lengths on `legal.terms_sections`/`privacy_sections`), and
a manual grep pass for stray hex literals in every changed file (none found — every new color reference is
a `var(--bal-*)` token).

**Could not perform a live in-browser visual pass.** This sandbox has no headless-browser tool
(`chromium-cli` is not installed) and direct HTTP access to the dev server's `localhost` port returns a
proxy `502` rather than reaching Next.js (confirmed: the dev server itself started cleanly — `✓ Ready in
5.8s` in its log — the request never reached it). Per this repo's own verification guidance, I'm stating
this explicitly rather than claiming a visual check that didn't happen. **A human should run `npm run dev`
and spot-check, on `/fa` and `/en` × light and dark × a narrow and a ≥1100px viewport:**
- [ ] Login (`/login`): brand reads «بالین‌یار» identically everywhere it appears (header, trust bullets,
      account-error retry copy); trust bullets render under the phone-entry card.
- [ ] Search (`/search` → results): filters producing zero results show «پرستاری یافت نشد» (fa) / "No
      nurses found" (en) — never «۰ پرستار»/"View no nurses"; at ≥900px, result cards render two-up.
- [ ] Nurse EVV (`/nurse/visits`): the visits subtitle spells out EVV once; attempting a check-out with no
      open check-in shows the corrected sentence (no «ورود بازی»).
- [ ] Bank (`/nurse/bank`): the verified chip reads «تأییدشده» (not «تاییدشد»).
- [ ] Checkout (`/bookings/checkout?request_id=…`): at ≥900px, a two-column layout with a sticky right-side
      pay panel; at <900px, the original bottom sticky bar; the "continue to payment" button's chevron
      points the reading-forward direction on both locales (mirrored, not baked into the string).
  - Currently only reachable via the request-flow with a real/mocked `accepted_awaiting_payment` request.
- [ ] BNPL comparison (`/bookings/checkout/bnpl`): the ownership note reads plainly, no «نکول».
- [ ] Any route transition: a calm ~150–200ms fade/slide plays once; with DevTools' "Emulate CSS media
      feature `prefers-reduced-motion: reduce`" enabled, everything appears instantly (no fade, no MUI
      dialog/menu transition either).
- [ ] Any icon-only disabled button (e.g. a paused/loading admin action): inspect the accessibility tree —
      it still has a name.
- [ ] Config history (`/admin/config`, open a row's history drawer): the old→new diff shows a mirrored
      arrow icon between two values, not a baked-in `←`/`→` character.

## What is now testable (and exactly how)

1. `cd client && npm run check` — passes, including `lint:copy`. Temporarily add «بالین یار» (plain
   space) to any `fa.json` value → `npm run lint:copy` fails with the exact key path; revert.
2. `npm run test:ci` — 115 suites / 526 tests pass, including the two touched/added shared-component tests.
3. Grep both catalogs for `←|→` → zero hits. Grep for `تایید` (hamza-less) → zero hits (only `تأیید` and
   its compounds remain). Grep for `جست‌وجو`/`جست و جو` → zero hits.
4. The four visual-axis items above, once a human can reach a browser.

## What is mocked / waiting on a real service

- **REQ-065** (new, this phase) — public/authenticated policy-config read (dispute-window hours,
  cancellation lead hours, refund ETA days). Until delivered, `client/src/constants/policy.ts` is the
  single source; every message key that used to hardcode a policy number now takes it as an ICU param from
  this file. No mock-registry entry needed — this isn't a seam behind a DI interface, it's a constants file
  standing in for a config read that doesn't exist yet for a non-admin caller.
- No other new mocks this phase; no `services/{domain}` seam was touched.

## Contracts

- Consumed: none new. REQ-065 filed against the existing `platform_config` admin-only surface
  (`dev/contracts/domains/` — verified no public projection exists before filing, per the phase's own
  instruction not to file speculatively).

## Docs updated

- `client/messages/STYLE.md` — new, the Persian style guide (this phase's own deliverable).
- `client/CLAUDE.md` — see the diff in this change: mentions `messages/STYLE.md` + `npm run lint:copy` in
  the i18n section, the reduced-motion gate location (`globals.css`), `RouteFadeIn` in the component-library
  table, and `constants/policy.ts` in the constants section.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-065 appended.

## Follow-ups for later phases (post-chain)

- **Skeleton→content crossfade beyond the one worked example.** `data-bal-route-fade` is reusable
  (a one-line addition per screen) but wasn't retrofitted onto every list/detail page in the app — a
  mechanical, low-risk follow-up, not a defect.
- **A true desktop search layout** (list+detail split, or breaking out of the 800px `CONTENT_MAX_WIDTH`
  for this one route) — the phase doc explicitly defers "a full responsive pass" post-chain; the 2-column
  grid shipped here is the scope-boxed interim step.
- **REQ-065** — once delivered, replace `constants/policy.ts`'s hardcoded numbers with a fetched config
  read (the ICU-param call sites don't change, only where the numbers come from).
- A live in-browser visual confirmation of the walkthrough checklist above — this environment couldn't run
  one; flag to the human reviewer.

## Memory

A `project`-type memory note was saved (`ui_phase_12_copy_motion_polish.md`) summarizing the STYLE.md
decisions, the reduced-motion gate's location and mechanism, and the sandbox's browser-verification gap, so
a future agent doesn't have to rediscover any of it.
