Files
baya-monorepo/dev/shared-working-context/reports/ui-phase-12-report.md
T
2026-07-19 21:31:59 +03:30

21 KiB
Raw Blame History

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_hintalready 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 Typographys 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 deliveredAuthCard/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-4search.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.