# UI Phase 12 — Copy, motion & final polish

> **Mission:** the closing sweep of the UI chain — make the product read and move as one: a checked-in
> Persian style guide enforced across both catalogs, the verified copy defects on trust-critical strings
> fixed, a restrained motion language behind a single reduced-motion gate, an accessibility pass, and a final
> four-axes QA of everything the chain touched. Copy is the cheapest trust lever Balinyaar has — and today
> the brand name is spelled two ways, a nurse-facing EVV error reads as "there is no game-entrance", and one
> BNPL sentence states the inverse of the intended risk allocation.
>
> **Track:** frontend · **Depends on:** all prior phases — [0](ui-phase-0-design-language.md) →
> [11](ui-phase-11-admin-and-partner-console.md) (run this last) · **Unlocks:** ships the chain — one voice, one motion language, verified on all axes.
> **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 is a trust-first product in a market where written register carries weight: families decide whether
to let a stranger into a parent's home partly on whether the product *sounds* careful. The catalogs are
already hand-written — the weaknesses are craft, not tone. Verified in `client/messages/fa.json` / `en.json`
(line numbers spot-checked 2026-07-16; earlier phases may shift them — grep the key, not the line):

- **The brand name is spelled two ways:** «بالین یار» (plain space) in 5 keys — `common.brand` (53),
  `auth.customer_title` (625), `auth.select_role_title` (645), `auth.account_error_body` (654),
  `verification.start_body` (663) — vs «بالین‌یار» (ZWNJ) in 17: login and escrow copy disagree on the name.
- **تأیید — the most frequent word in a verification product — appears 38× without hamza and 26× with**, no
  pattern (`bank.status_verified_title` vs `admin.ver_pass`); ~64 occurrences to sweep.
- **Two grammar bugs read as nonsense:** `booking.evv_no_open_check_in` (542) «ورود بازی برای ثبت خروج وجود
  ندارد» and `admin.alert_empty` (1360) «هشدار بازی وجود ندارد» — the indefinite ی attached to the wrong word.
  And a typo sits on a trust chip: `bank.status_verified_chip` (180) reads «تاییدشد» (missing final ه) — the
  chip a nurse stares at while IBAN ownership verification runs.
- **One en arrow points the wrong way:** `booking.continue_payment` (en 445) is "Continue to payment ←" while
  its sibling `payment.cta_pay` (577) is "…→". Five keys per catalog embed ←/→ literally (445, 577, 632, 633,
  1337) — directional glyphs inside translatable strings are how this bug happens.
- **fa lacks ICU plurals where en has them:** `search.cta_view_results` (fa 350) is «مشاهده {count} پرستار» —
  the primary search CTA can render «مشاهده ۰ پرستار»; en has ICU but its `=0` case renders "View no nurses".
- **BNPL copy is banker's language with an ambiguous pronoun:** `bnpl.ownership_note` (857) «…ریسک نکول مشتری
  کاملاً با اوست…» — «نکول» is credit-desk jargon and «با اوست» leaves the reader unsure whether the
  *customer* carries the default risk (the intended meaning: the provider does).
- **Policy numbers are hard-coded into legally-sensitive copy** the admin config panel can change: the ۷۲-hour
  dispute window (`payouts.explainer_point_2`, 958), the ۲۴-hour cancellation tiers (`refunds.lead_gt_24h/
  lead_lt_24h`, 789–790), the ۷–۱۰-day refund ETA (`refunds.eta_business_days`, 850). One config edit makes
  the UI lie — and **no public config read exists** (`platform_config/*` in `services/admin/` is admin-only).
- **Motion and live-region a11y are absent, app-wide:** zero `prefers-reduced-motion` handling and zero
  `aria-live` anywhere in `client/src`; `AppIconButton` names icon-only actions solely via Tooltip `title`,
  dropped entirely when disabled (`AppIconButton.tsx:89–95`).

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

- Phase 0's token extension (motion/focus/elevation tokens in `theme/tokens.css`) and single-weight icon
  registry with a mirrored chevron — this phase *applies* motion tokens, it does not invent them.
- Phase 1's shared primitives: `utils/number.ts` `formatNumber` (replacing the 25+ inline `fa-IR` ternaries),
  the state-view kit, `CountdownTimer` v2, route-level `loading.tsx`/`error.tsx`.
- Full key parity between `fa.json` and `en.json` (28 namespaces) with complete loading/empty/error/confirm/
  toast coverage per namespace, and the trust copy that is already right (keep-list, §5):
  `payment.escrow_notice`, `payouts.explainer_point_1–3`, calming refund-status vocabulary, two-stage
  disclosure copy, the specific verification-failure reasons.
- Phases 4/6/8/9 may already have fixed items this phase lists conditionally (3.2 address hint, 3.5 pipeline
  naming, 3.7 cities, 3.10 desktop layouts) — **read their reports first** and skip what is done.

## 2. Required reading (do this first)

- [audit/microcopy.md](audit/microcopy.md) — the 16 copy problems + 7 opportunities this phase executes; its
  keep-list is binding. Then [audit/cross-cutting-ux.md](audit/cross-cutting-ux.md) — motion/a11y/responsive
  findings (AppIconButton, motion absence, desktop phone-column) and its keep-list.
- Prior phase reports in `../../shared-working-context/reports/` (`ui-phase-0` … `ui-phase-11`) — to learn
  which conditional items are already done and what naming phase 8 chose for the verification journey.
- `client/messages/fa.json` + `en.json` — read the namespaces you will sweep (booking, verification, payment,
  auth, common, bnpl, search, refunds, payouts, admin, shell, tickets, bank, address).
- Code (under `client/src/`): `components/common/AppIconButton/AppIconButton.tsx`,
  `components/CountdownTimer/`, `components/EscrowNotice/EscrowNotice.tsx`, `theme/tokens.css` (phase-0
  motion/focus tokens), `utils/number.ts` (phase-1 `formatNumber`), and for 3.10
  `app/[locale]/(private-routes)/(customer)/bookings/checkout/` + `…/(customer)/search/results/page.tsx`.
- Product: [../../../product/overview/platform-summary.md](../../../product/overview/platform-summary.md)
  §"Glossary" — consult it **before** deciding پرستار/مددجو/بیمار; the cancellation/refund docs under
  [../../../product/business/](../../../product/business/index.md) before touching policy-number copy.
- The design contract: [../../../.claude/skills/frontend-designer/SKILL.md](../../../.claude/skills/frontend-designer/SKILL.md)
  (§7 non-negotiables) and `client/CLAUDE.md` "Golden rules". REQ tracker:
  [../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
  — find the tail (REQ-038 at authoring time; earlier UI phases may have appended) and number onward.

## 3. Scope — build this

### 3.1 The Persian style guide — `client/messages/STYLE.md` + enforcement

Write a one-page style guide the catalogs are then swept against, fixing these decisions:

- **Brand = «بالین‌یار» (ZWNJ), always.** Sweep the 5 plain-space keys.
- **Hamza form «تأیید»** everywhere (~64 occurrences to normalize — the 38 hamza-less ones change).
- **One form of جستجو.** Standardize on «جستجو» (the 9-key majority; fold in the 2 «جست‌وجو» keys).
- **ZWNJ rules:** می‌ + verb, plural ها, compound adjectives («تأییدشده»); codify with examples.
- **Punctuation & quotes:** Persian «،» / «؛» / guillemets; en uses straight apostrophes (the admin
  namespace's curly 'don’t' normalizes to the file's straight-quote majority).
- **Domain glossary:** پرستار / بیمار vs مددجو — decide against the product glossary; «مددجو» appears exactly
  once (`booking.patient_label`), so adopt it consistently or drop the one-off. One status vocabulary shared
  by nurse-facing and admin-facing keys (see 3.5).
- **Shell naming system:** one metaphor per audience class instead of today's four (`shell.*`, fa 56–61:
  اپلیکیشن/نما/کنسول/پرتال). Recommended: end-user shells = «اپلیکیشن» (خانواده، پرستار), back-office
  shells = «کنسول» (مدیریت، همکار).
- **Register:** formal شما with polite imperatives (کنید) — already consistent; codify so it cannot drift.
- **Digits policy:** Persian digits in fa for both literals and interpolations (mechanics in 3.3).

**Enforcement:** add a ~30-line Node script (`client/scripts/check-copy.mjs`) that greps `fa.json` for the
banned variants (space-brand, hamza-less تایید, the «بازی» word-boundary trap, جست‌وجو, می‌گردد) and exits
non-zero; wire it as `npm run lint:copy` and into `npm run check`. 60+ scattered findings become a one-time
fix that cannot regress.

### 3.2 Verified copy-bug fixes (each independently shippable)

1. `booking.evv_no_open_check_in` → «ورودِ ثبت‌شده‌ای برای این ویزیت وجود ندارد؛ ابتدا ورود را ثبت کنید.»
   and `admin.alert_empty` → «هشداری برای رسیدگی نیست.»
2. `address.line_hint` «…برای یافتن در نیاز دارد» → «هر جزئیاتی که پرستار برای پیدا کردن منزل شما لازم
   دارد.» — **only if phase 9 didn't**. And `bank.status_verified_chip` «تاییدشد» → «تأییدشده».
3. Archaic passive «می‌گردد» → «می‌شود»: `refunds.confirm_restate` (814), `admin.mod_confirm_publish` (1314),
   `admin.cfg_save_confirm_body` (1333) — three occurrences, not one as the audit counted.
4. `tickets.thread_empty_body` comma splice → «هنوز پیامی نیست. هماهنگی را شروع کنید.»
5. en normalization: `auth.nurse_subtitle` "licence" → "license" (American throughout — the file already uses
   "center" 25×); straighten the admin namespace's curly apostrophes.
6. Introduce EVV in Persian once, then abbreviate: a first-occurrence key «ثبت حضور الکترونیکی (EVV) — ورود و
   خروج شما ثبت می‌شود تا ویزیت بدون اختلاف تأیید شود» on the nurse's visits surface (`booking.evv_*`, fa
   528–535), short chips («ثبت ورود») thereafter — EVV is why the nurse gets paid without arguments; sell it.

### 3.3 ICU plurals + designed zero cases

Every `{count}` key gets an ICU plural form in **both** catalogs with a designed `=0` case: fa
`search.cta_view_results` (350), `results_count` (352), `reviews_count` (364), `booking.session_count` (515)
plus a grep sweep for the rest. Zero states are copy, not numerals — «پرستاری یافت نشد» / "No nurses found";
also fix en's awkward `=0` label ("View no nurses"). Digits policy: fa interpolations render Persian digits —
use `{count, number}` / ICU `#` (locale-formatted by next-intl) in message keys, and phase 1's `formatNumber`
where a raw value is interpolated at a call site; verify one of each renders «۳» not "3".

### 3.4 Arrows out of strings

Remove the five literal ←/→ glyphs per catalog (`booking.continue_payment`, `payment.cta_pay`,
`auth.nurse_switch`, `auth.customer_switch`, `admin.cfg_history_change` — fa/en lines 445/577/632/633/1337)
and fix en 445's wrong-direction arrow by construction: direction moves into auto-mirroring `endIcon` slots
on the buttons involved (phase 0's chevron via the `AppIcon` registry; the RTL Emotion cache mirrors it).
`admin.cfg_history_change` «{old} ← {new}» becomes two interpolations joined by a component-rendered mirrored
arrow icon. Touch each call site; the strings become text-only.

### 3.5 Trust-moments copy pass

- **OTP screen reassurance:** `auth.customer_subtitle` (626) is just «با شماره موبایل خود وارد شوید» — add one
  warm trust line (verified nurses + escrow, e.g. «پرستاران تأییدشده، پرداخت امن نزد بالین‌یار»).
- **Checkout «چرا امن است»:** one line near the pay CTA linking escrow to the dispute process —
  *supplementing*, never replacing, `payment.escrow_notice` (§5). And **post-payment what-happens-next:** the
  confirmation states the next steps in order (nurse notified → coordination ticket → visit-day EVV).
- **BNPL de-jargon:** rewrite `bnpl.ownership_note` (857) reader-first, plainly allocating risk to the
  provider — e.g. «قسط‌ها را مستقیماً به {provider} می‌پردازید؛ بالین‌یار مبلغ کامل را همان ابتدا دریافت
  می‌کند و اگر قسطی پرداخت نشود، مسئولیت آن با ارائه‌دهنده است، نه شما و نه پرستار.» «نکول» disappears from
  the catalog.
- **Disambiguate the verification names:** «احراز هویت» names both the 7-step pipeline (`nav.verification`
  13, `verification.title` 658) *and* the KYC step inside it (`step_identity_kyc` 676, admin 1470). Rename
  the pipeline «تأیید صلاحیت»; the KYC step keeps «احراز هویت». **Coordinate with phase 8's journey naming**
  — if its report already renamed, adopt its term and only sweep stragglers.
- **One rejected-status vocabulary:** unify رد شد / ناموفق / ردشده into one nurse-facing and one
  admin-facing form (decide in STYLE.md; sweep `verification.*` and `admin.*` together — they cross-reference).

### 3.6 Config-served policy numbers (REQ + client single-sourcing)

Parameterize the hard-coded policy numbers: `payouts.explainer_point_2` takes `{hours}` (72),
`refunds.lead_gt_24h`/`lead_lt_24h` take `{hours}` (24), `refunds.eta_business_days` takes `{minDays}`/
`{maxDays}` (7–10). File a REQ (next free number) for a **public/authenticated policy-config read**
(dispute-window hours, cancellation tiers + percentages, refund ETA) — verify first that no existing endpoint
serves it (at authoring time only admin-scoped `platform_config/*` exists). Until served, single-source the
constants in `client/src/constants/policy.ts` with a comment linking the REQ, and feed the interpolations
from there. One config edit must never again silently make copy lie.

### 3.7 Honest search empty state

Replace `search.empty_suggest_city` (361, both catalogs) — «شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را
امتحان کنید» is nonsense advice for a Tehran-launch user — with honest relaxations already next to it (clear
the district, widen filters) or interpolated actually-covered cities. **Only if phase 4 didn't.**

### 3.8 Motion pass — one restrained language

Apply phase 0's motion tokens app-wide; introduce nothing springy — this is a calm clinical product:

- Page/content transitions: 150–200ms fade + small slide on route-group content; skeleton → content
  crossfade (the phase-1 skeleton twins).
- Dialog/bottom-sheet enter/exit via `theme.components` defaults (one place, not per-dialog); list-entrance
  restraint — at most a subtle stagger on first paint, never re-animating on refetch.
- **`prefers-reduced-motion` is respected via a single media-query gate in one place** (a token/theme-level
  switch that zeroes durations) — today the app has zero handling; do not scatter per-component checks.

### 3.9 A11y sweep

- `AppIconButton`: add an `aria-label` passthrough that defaults from `title` and survives `disabled` (today
  the Tooltip — the only name source — is dropped when disabled; `AppIconButton.tsx:89–95`). Update its test.
- `aria-live` on the moments that change without focus: `CountdownTimer` (polite), async error/retry regions,
  payment-status polling. Today `aria-live` appears **nowhere** in `client/src`.
- Disclosure semantics (`aria-expanded` + `aria-controls`) on collapsibles — the earnings `ExplainerCard`
  pattern and any phase-built equivalents.
- Form label association audit (MUI does this when `label` is used — audit the hand-rolled ones); focus-visible
  coverage check (phase 0 tokenized the ring — verify interactive elements show it); contrast re-check on
  remaining terracotta *text* usages in both schemes.

### 3.10 Desktop-aware layouts — the two worst phone-column offenders (scope-boxed)

**Only if phases 4/6 didn't already:** above ~900px, (a) checkout (`(customer)/bookings/checkout/`) becomes
two-column with a sticky order summary; (b) search results (`(customer)/search/results/page.tsx`) uses the
width (wider cards or list + detail). Nothing else — a full responsive pass is (DEFERRED → post-chain).

### 3.11 Final QA — the closing walkthrough

Write a per-shell walkthrough checklist (customer funnel, nurse day-of + verification, admin desks, partner
portal, auth/first-run) × `/fa`+`/en` × light+dark × mobile+desktop. Run it end-to-end; fix what it finds
(anything non-trivial becomes a report note, not silent scope expansion). Attach the completed checklist to
the phase report — it is the chain's shipping evidence.

## 4. Mocks & seams in this phase

None introduced. UI stays mock-tolerant behind the existing `services/{domain}` seams. The one backend gap is
3.6's policy-config read: a REQ appended to
[for-backend.md](../../shared-working-context/frontend/requests/for-backend.md) (REQ-001…038 taken at
authoring time — check the tail for numbers earlier UI phases claimed, and number onward). Until delivered,
the client single-sources the numbers in `constants/policy.ts`; no mock flag changes.

## 5. Critical rules you must not get wrong

- **`payment.escrow_notice` and product-mandated strings change only with a flagged product note.** The
  escrow sentence, `payouts.explainer_point_1–3` (especially point 3's BNPL-fee-never-deducted guarantee),
  the two-stage disclosure copy (`booking.notes_hint` / `booking.disclosure_note`), and the verification-failure
  reasons are model trust writing — sweeps may normalize their spelling, never their meaning. If a rewrite
  seems needed, flag it in the report instead.
- **Don't regress the keep-lists** (both audit files): calming money-status vocabulary («در راه»، «نیازمند
  بررسی»), culturally-tuned gender copy, formal شما, complete per-namespace state strings, admin
  confirm-dialog operational candor, token discipline, RTL `dir="ltr"` islands, Shamsi-first formatting.
- **en stays hand-written-idiomatic** — no machine-translation tone ("Queue clear — nothing to review" is the
  bar). **Key parity fa/en stays absolute:** every add/rename lands in both catalogs in the same change.
- **Don't rename stable message keys casually.** A key rename must grep and update every `t('…')` usage; if a
  key's meaning is unchanged, keep its name and change only the value.
- **Design-contract non-negotiables** that bite here: tokens not hexes (motion durations/easings are tokens,
  not inline magic numbers); RTL logical props (arrows mirror by construction); dark mode on every touched
  surface; MUI v9 API only; co-located tests for touched shared components (`AppIconButton`,
  `CountdownTimer`, `EscrowNotice` all have `.test.tsx`); fetch/cookies rules untouched — no data-layer changes.
- **Reduced motion is a hard gate, not a nice-to-have** — every animation collapses to no-motion under
  `prefers-reduced-motion: reduce` via the single gate (3.8). **Frontend lane only:** the policy-config gap
  is a REQ, never a `server/` edit.

## 6. Definition of Done

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

- [ ] `npm run check` green (now including `lint:copy`); `npm run test:ci` green for touched shared
      components (`AppIconButton`, `CountdownTimer`, any collapsible/state component touched).
- [ ] `client/messages/STYLE.md` exists; `scripts/check-copy.mjs` passes with **zero** banned variants —
      no plain-space brand, no hamza-less تایید, no «بازی» trap, no می‌گردد, one جستجو form.
- [ ] `en.json`/`fa.json` in key parity; every `{count}` key has ICU plural + designed `=0` in both catalogs;
      no literal ←/→ remains in either catalog (grep proves it).
- [ ] `bnpl.ownership_note` plainly allocates default risk to the provider; «نکول» absent from the catalog.
- [ ] Policy numbers interpolate from `constants/policy.ts`; the policy-config REQ is filed with a number.
- [ ] `prefers-reduced-motion: reduce` disables all chain-added motion; icon-only buttons have accessible
      names including when disabled; countdown/async-status regions have `aria-live`; touched collapsibles
      expose `aria-expanded`.
- [ ] Visual verification on the four axes — `/fa` + `/en` × light + dark — and mobile + desktop for 3.8/3.10
      surfaces; the completed 3.11 walkthrough checklist is attached to the phase report.

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

1. `cd client && npm run check` — passes, including the new copy lint. Temporarily add «بالین یار» to any fa
   value → `lint:copy` fails; revert.
2. Log in as a customer (`/fa`): login, checkout escrow copy, and refund screens all spell «بالین‌یار»
   identically; the OTP screen shows the new reassurance line.
3. Search with filters that match nothing → «پرستاری یافت نشد» (not «۰ پرستار»; not "View no nurses" on
   `/en`); counts render Persian digits on `/fa`.
4. As a nurse, attempt EVV check-out without a check-in → the corrected sentence (no «ورود بازی»); the bank
   screen's verified chip reads «تأییدشده»; the visits screen introduces «ثبت حضور الکترونیکی (EVV)» once.
5. On `/en` checkout, "Continue to payment" carries a mirrored end-icon pointing forward (→ in LTR, mirrored
   on `/fa`); grep both catalogs for `←|→` → zero hits.
6. Open the BNPL comparison → the ownership note reads plainly, no «نکول», risk clearly on the provider.
   Grep the catalogs for «۷۲ ساعته» → none; the keys take `{hours}` from `constants/policy.ts`; the REQ
   exists in the tracker.
7. Navigate between routes → 150–200ms calm fade/slide, skeletons crossfade into content; enable reduced
   motion in DevTools → everything appears instantly.
8. Inspect the a11y tree on a screen with icon-only buttons: each has a name, including a disabled one; a
   running countdown announces politely (`aria-live`).
9. Run the 3.11 checklist across all shells on the four axes + both widths; every row checked or fixed.

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

- Update `client/CLAUDE.md`: mention `messages/STYLE.md` + `lint:copy` in the i18n/Golden-rules sections and
  the reduced-motion gate location; update "Project Structure" if `scripts/` or `constants/policy.ts` are new.
- Write the frontend report at `../../shared-working-context/reports/ui-phase-12-report.md`: the style-guide
  decisions taken (hamza form, جستجو form, shell naming, status vocabulary, digits policy), swept-key counts,
  conditional items skipped because an earlier phase did them, the completed QA checklist, and anything the
  walkthrough found but deferred. List the REQ(s) filed with their numbers.
- Save a memory note per operating-rules §8: the chain is complete — style guide + copy lint now enforce
  Persian orthography; motion and a11y gates exist; record the STYLE.md decisions so future copy follows them.
