21 KiB
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 flattensfa.jsonand 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 asnpm run lint:copy, folded intonpm 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, onelegalsection 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(«ورود بازی» → «ورودِ ثبتشدهای برای این ویزیت وجود ندارد؛ ابتدا ورود را ثبت کنید.») andadmin.alert_empty(«هشدار بازی» → «هشداری برای رسیدگی نیست.»). tickets.thread_empty_bodycomma 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_subtitlenow 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 theadminnamespace (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_verifiedrephrased 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 ownagg_rejected/rstatus_rejected/mstatus_rejectedpattern (all «ردشده»); nurse-facingverification.status_failedkeeps «رد شد» (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.tsxdoesn't need a call-site change —cta_view_resultsis only reached whencount > 0(the siblingcta_zero_titlebranch handles the zero state), so the ICU=0case is a defensive designed fallback, not the primary render path. - en's own
cta_view_results/results_count=0case fixed from the audit's flagged "View no nurses" / "No nurses" to "No nurses found" (cta_view_resultsrestructured 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_payalready 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 mirroredAppIcon icon="forward"(registered inDIRECTIONAL_ICONS, auto-flips under[dir='rtl']regardless of host component — verified by readingAppIcon.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_changesplit intocfg_history_change_old/_new(each holding one interpolation), rendered as twoTypographys joined by a mirroredAppIcon 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_noterewritten 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, andEscrowExplainer(ui-phase-6) already supplementsEscrowNoticewith a 3-step visual + the cancellation-policy implication at checkout. Neither needed new copy; verified by readingAuthCard.tsx/PhoneStep.tsx/EscrowExplainer.tsxrather 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 viasrc/constants/index.ts. payouts.explainer_point_2takes{hours};refunds.lead_gt_24h/lead_lt_24htake{hours};refunds.eta_business_daystakes{minDays}/{maxDays}(both catalogs). Wired at the 3 call sites:nurse/earnings/page.tsx'sExplainerCard,CancellationPolicyDisclosure.tsx,RefundEtaBanner.tsx.services/refunds/constants.ts'sBNPL_REFUND_ETA_BUSINESS_DAYSnow sources fromREFUND_ETA_MAX_BUSINESS_DAYSinstead of its own hardcoded10, 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-scopedplatform_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 withempty_suggest_datein both catalogs. Verified by grep (مشهد|اصفهان|شیراز→ no matches) rather than re-implemented.
3.8 — Motion pass
globals.css: onebal-fade-inkeyframe (150–200ms fade + 4px slide,var(--bal-motion-base)+var(--bal-easing-standard)) applied via adata-bal-route-fadeattribute 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.cssadditionally zeroes--bal-motion-fast/base/slowunder 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'susePathname) so it remounts (replaying the fade) on navigation but never on an in-place re-render. Mounted inside theErrorBoundaryin 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/MuiMenuall get an explicitdefaultProps.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-fadeprimitive is reusable for this (any screen can attach it to its populated-state branch); demonstrated onsearch/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 underlyingIconButtonnow always receivesaria-label={title}directly (not only via theTooltipwrap), so a disabled icon-only button — where the Tooltip isn't rendered at all — keeps an accessible name. A caller-suppliedaria-labelstill wins (spread order). New test: "keeps an accessible name from.titleeven when disabled".useMemodependency array fixed (react-hooks/exhaustive-deps) as part of the change.aria-live:CountdownTimer's one-time elapsed transition now carriesaria-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) getsrole="alert"— one change covers every error/retry region in the app.ErrorBoundarygets the same.PaymentStateCard(the one terminal/wait-state card for card + BNPL checkout, including the pending→succeeded/failed poll) getsaria-live="polite".aria-expanded/aria-controls:EscrowExplainer's toggle gainedaria-controls(it already hadaria-expanded); the admin ticket thread's refund-panel toggle (admin/tickets/[id]/page.tsx) gained both (it had neither).nurse/earnings'sExplainerCardandAuditLogRow/EmergencyPlaybookRowalready 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 abody2unread count) — swapped to--bal-secondary-dark. Every other--bal-secondarytext 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): abovemd(~900px), a two-column layout — the summary/total/countdown/breakdown/escrow content on the reading side (flex62%), aposition: stickyorder-summary panel with the pay CTA on the other. The pay-action JSX (total, CTA, secure-gateway note, BNPL button, inline error) is a singlepayActionsvalue rendered in both the desktop panel and the mobileStickyActionBar— same handlers, same state, no duplicated logic; only one is visible at a time viasx={{ display: { xs: …, md: … } }}, the same pattern the codebase already uses forBottomBar/CustomerDesktopNav. - Search results (
search/results/page.tsx): the populated/skeleton lists switch from a single-columnStackto adisplay: gridwithgridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }— two columns abovemd. Bounded by the customer shell's existingCONTENT_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_paymentrequest.
- Currently only reachable via the request-flow with a real/mocked
- 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)
cd client && npm run check— passes, includinglint:copy. Temporarily add «بالین یار» (plain space) to anyfa.jsonvalue →npm run lint:copyfails with the exact key path; revert.npm run test:ci— 115 suites / 526 tests pass, including the two touched/added shared-component tests.- Grep both catalogs for
←|→→ zero hits. Grep forتایید(hamza-less) → zero hits (onlyتأییدand its compounds remain). Grep forجستوجو/جست و جو→ zero hits. - 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.tsis 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_configadmin-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: mentionsmessages/STYLE.md+npm run lint:copyin the i18n section, the reduced-motion gate location (globals.css),RouteFadeInin the component-library table, andconstants/policy.tsin 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-fadeis 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_WIDTHfor 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.