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