Files
baya-monorepo/dev/post-phase/ui/ui-phase-12-copy-motion-and-polish.md
T
2026-07-17 13:22:04 +03:30

288 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, 789790), 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:8995`).
**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_13`, 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 'dont' 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 5661:
اپلیکیشن/نما/کنسول/پرتال). 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
528535), 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}` (710). 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: 150200ms 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:8995`). 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_13` (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 → 150200ms 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.