ui phase 12

This commit is contained in:
hamid
2026-07-19 21:31:59 +03:30
parent 87fa4cd497
commit d33568bf31
39 changed files with 1049 additions and 247 deletions
@@ -0,0 +1,269 @@
# UI Phase 12 — Copy, motion & final polish — Report (2026-07-19)
This is the closing phase of the UI build chain (phases 012). 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 (150200ms 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 ~150200ms 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.