# 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.