Files
baya-monorepo/dev/shared-working-context/reports/ui-phase-1-report.md
T
2026-07-17 17:10:39 +03:30

18 KiB
Raw Blame History

UI Phase 1 — Shared primitives & app-wide states — Report (2026-07-17)

What was built

State kit — EmptyState, ErrorState, QueryStateGate

components/common/EmptyState and ErrorState promote/replace AdminEmptyState/ AdminErrorState (now thin re-exports) with a branded, calm look (phase-0 surface/radius tokens, registry icon slot, soft-primary icon roundel) — retiring the dashed-border Paper pattern hand-rolled across the app. Both are presentational with caller-owned copy; ErrorState requires retryLabel/onRetry so a query failure can never render without a working retry. QueryStateGate wraps the isLoading/isError/isEmpty branching in one fixed order (skeleton → error → empty → children) for pages that want the component instead of hand-branching. The mechanical sweep replaced the dashed-Paper blocks with these primitives; border: '1px dashed' under client/src/app is now zero (the one remaining hit in client/src, DocumentUpload's upload dropzone, is an interactive drop-target affordance, not an empty state, and is outside app/ — a deliberate, not missed, exception).

The six error→false-empty defects from the audit are fixed and verified, each now renders ErrorState with a working retry:

  • (customer)/HomeScreen.tsx (formerly page.tsx) — the patients gate no longer spinner-hangs on error.
  • (customer)/patients/page.tsxisEmpty now excludes the error case.
  • nurse/requests/page.tsx — an errored inbox no longer reads as "no requests."
  • nurse/services/MyServicesList.tsx — an errored variant list no longer shows the first-service CTA.
  • (customer)/profile/page.tsx — an errored profile renders ErrorState, never a blank, save-capable form.
  • nurse/services/VariantBuilder.tsx — a failed optionGroupsQuery now blocks progression via ErrorState instead of silently treating the category as having no required options.

Both silent mutations from the audit now toast on failure: nurse/profile/page.tsx's avatar upload and profile save each carry an onError toast. The convention — an errored query must never render as empty; every mutation needs an onError toast — is written into client/CLAUDE.md (Unit Testing / Toast Notifications sections).

PageHeader + ConfirmDialog promotions

PageHeader generalizes AdminPageHeader (title/subtitle/actions/backTo/backLabel, RTL-flippable back chevron via the new forward/back icon pair) and was adopted mechanically wherever pages hand-rolled the identical h5 + subtitle block. ConfirmDialog moved from components/admin/ to components/common/, preserving its exact contract (required-reason gating, busy-state disabling both buttons) — now usable by any actor, not just the backoffice.

Card kit — SurfaceCard + AccentCard

SurfaceCard (flat Paper, house radius, a padding scale sm/md/lg) and AccentCard (SurfaceCard + a borderInlineStart accent at one standardized 4px width — ending the 3px/4px drift between EarningsBalanceHeader/PayoutHistoryRow and BankStatusPanel/ DocumentUpload) replace the ~12 named hand-rollers (EarningsRow, EarningsBalanceHeader, PayoutHistoryRow, PatientCard, VariantCard, VisitNoteCard, InstallmentScheduleRow, PriceBreakdown, BankStatusPanel, DocumentUpload's panels, BookingRequestSummaryCard, the customer-home nudge card) — visual unification only, no content/behavior change. A gap found during the final sweep (BookingDetailView's customer-side "care record locked" notice, still on a dashed Paper) was migrated onto AccentCard tone="neutral" in this pass. 16 call sites now render through SurfaceCard/AccentCard.

<Money> primitive

One component for every displayed IRR amount (components/common/Money): takes a served IRR digit-string, formats via utils/money.ts (never computes), with size/tone variants — emphasis renders in --bal-money-emphasis, not terracotta (--bal-secondary fails AA contrast at small sizes on light backgrounds) — and an explicit deduction treatment (a dir="ltr"-held prefix, never a bare Unicode minus floating in RTL text). 19 call sites now render through <Money>, including the flagship terracotta-total defects (PriceBreakdown, RefundStatusCard, BnplPlanCard, InstallmentScheduleRow, CancellationPolicyDisclosure). PriceDisplay (catalog unit rates) was deliberately left on its own formatIrrToToman call per the phase's own scope note — its contract is tested, correct, and has different unit/estimate-label needs than a bare amount.

One site — bookings/[id]/cancel/page.tsx's confirmation restatement — needed a different shape: the amounts are interpolated into a translated sentence ("مبلغ {refund} به شما بازپرداخت می‌شود…"), not rendered as an isolated node, so <Money> couldn't drop in directly. Fixed by switching confirm_restate to next-intl's t.rich() with <refund>/<fee> tags in both message files, rendering <Money> inside the sentence rather than hand-formatting a string — the first t.rich() usage in the codebase; a precedent worth reusing for any future money-inside-a-sentence case instead of falling back to string concatenation.

currency_toman outside utils/money.ts + <Money>/PriceDisplay internals is now zero (DoD-exact grep).

Formatting utils — utils/number.ts

localeTag(locale), formatNumber(value, locale, options?), formatRelativeTime(iso, locale, absoluteFormatter) (7-day decay to the absolute Shamsi date — documented in JSDoc, no live consumer yet per the phase's own scope: lands in phases 10/11), formatClock(totalSeconds, locale) (migrated CountdownTimer's inline Intl pad + fixed OtpStep.tsx's Latin-digit resend clock). Swept the locale === 'fa' ? 'fa-IR' : 'en-US' ternary onto localeTag/ formatNumber at every remaining site found by a final consolidated grep pass (run after the metadata-pattern file splits, since those physically relocated code the first sweep had already covered): bookings/[id]/invoice/page.tsx, search/nurse/[nurseId]/page.tsx (×2), NurseResultCard.tsx (×2), PriceDisplay.tsx, and JalaliDatePicker.tsx (×2, the picker's own new code). locale === 'fa' ? 'fa-IR' now appears only in utils/number.ts itself (DoD-exact grep) — JalaliDatePicker's separate locale === 'fa' ? 'fa-IR-u-ca-persian' : 'en-US' (the Jalali-calendar-system Intl tag, a different decision than the plain locale tag) is intentionally untouched by this rule.

JalaliDatePicker + JalaliDateField

Shamsi-native date selection that always emits/accepts ISO Gregorian — the wire never sees a Jalaali date. calendarEngine.ts defines one CalendarEngine shape with two implementations (jalaaliEngine for fa, a plain Gregorian one for en) so the picker's grid/navigation logic is calendar-agnostic.

Arithmetic decision: Intl (fa-IR-u-ca-persian, already used by utils/date.ts) is sufficient to display a Shamsi date but has no reverse direction — there's no way to ask Intl "what Gregorian date is Jalaali 1404/5/15," which is exactly what a picker (as opposed to a label) needs for month navigation and emitting the selected day. Rather than hand-roll the Borkowski conversion algorithm, the phase adds jalaali-js (MIT, zero runtime dependencies, documented in-repo as ~9KB unminified / low single-digit KB gzipped) for the reverse conversion (toGregorian) plus month-length/leap-year math; Intl still supplies locale-correct month/weekday namesjalaali-js only supplies numbers. JalaliDatePicker has a grid variant (month calendar with keyboard roving nav, RTL-flipped arrow keys) and a chips variant (horizontal day-chip strip for near dates, the C4 booking-form shape). JalaliDateField wraps it in a read-only TextField + Popover.

StatusChip v2, StatusTimeline, CountdownTimer v2, RatingInput v2

  • StatusChip rewritten to a soft-tint hierarchy: neutral/info/pending states use --bal-primary-soft, verified/active use --bal-success-soft, and solid --bal-error fill is reserved for the one true "alarm" state (rejected) — added alongside new --bal-{success,error,warning,info}-soft tokens (both color schemes).
  • StatusTimeline (ordered TimelineNode[]: completed/current/pending/failed, animated pulse on current respecting prefers-reduced-motion) now backs RefundStatusCard and BookingStatusTimeline (3 call sites) — wizards (StepperHeader) are untouched, per the phase's own "wizards still use StepperHeader" boundary.
  • CountdownTimer v2 adds a windowStart progress ring, warnThresholdSeconds/ urgentThresholdSeconds urgency tiers (replacing the old binary urgent prop, kept as a legacy override), and an opt-in coarseThresholdSeconds/coarseLabel humanized mode — the core architecture (server-frozen UTC deadline, self-owned 1s tick, single onElapsed, dir="ltr" tabular-nums digits) is unchanged.
  • RatingInput v2 renders a fractional fill via a clip-path overlay in read-only mode (a 4.5 average now shows a genuinely half-filled fifth star, replacing the old Math.round workaround removed from the nurse profile page) and switched its fill colors from --bal-warning to the phase-0 --bal-rating/--bal-rating-empty tokens.

Route chrome + per-page metadata

loading.tsx added for all five route groups ((customer), nurse, admin, partner, public-routes) — the three sidebar shells (nurse/admin/partner) share one _chrome/SidebarShellSkeleton.tsx. error.tsx and not-found.tsx under [locale]/ are branded and localized (the not-found.tsx is reached via a [...rest]/page.tsx catch-all calling notFound(), next-intl's recommended pattern). global-error.tsx sits above [locale]/ and is the one sanctioned static-string, no-MUI exception — it replaces the root layout on a root-level crash and renders its own <html>, so it structurally cannot reach NextIntlClientProvider.

ErrorBoundary was rewritten to take title/body/retryLabel as required caller-owned props instead of calling useTranslations internally — kept presentational, no next-intl import, specifically so it stays safe at the top of the components/common barrel (see the Jest note below). Both CustomerLayout and TopBarAndSideBarLayout pass the copy via useTranslations('routeChrome').

Every route now has its own browser tab title via generateMetadata (locale-aware '%s | بالین‌یار'/'%s | Balinyaar' template, set once in [locale]/layout.tsx). Seven pages that needed a Client Component body were split into a thin Server Component page.tsx + co-located <Name>Screen.tsx: home, login, search, bookings, admin overview, partner home (nurse dashboard was already a Server Component and got generateMetadata added in place, no split needed). The pattern is documented in client/CLAUDE.md under "Per-page metadata (the client-page pattern)."

The Jest / next-intl transform fix (the one non-mechanical problem this phase hit)

ErrorBoundary/ErrorState were deliberately kept free of next-intl because any component near the top of the components/common barrel that imports it at module scope forces every test file that transitively imports the barrel to deal with next-intl's ESM-only build — even tests that never touch translations. <Money> couldn't take the same fix: it already had ~20 call sites depending on its locale-aware, self-contained API before this was noticed, so stripping next-intl from it would have meant threading a formatted string through every caller instead.

The actual fix was at the root: next/jest's createJestConfig returns a transformIgnorePatterns that already matches nearly all of node_modules (a narrow negative-lookahead allowlist for a couple of Next.js-internal packages), and appends whatever custom pattern you pass rather than letting it override — combined with Jest's OR-based array semantics (a file is ignored if any pattern matches), a more permissive pattern appended after the restrictive one can never "un-ignore" a package the first pattern already caught. jest.config.ts now replaces the array outright, in an async wrapper around createJestConfig, allowlisting next-intl/use-intl/@formatjs/intl-messageformat (each transitive ESM dependency surfaced its own parse error until the allowlist was broad enough). This is a repo-wide fix, not a <Money>-specific one: any future shared component is now free to call useTranslations without risking the same barrel-poisoning failure mode. The trade-off and the "prefer caller-owned by default" convention are both documented in client/CLAUDE.md's new "Presentational purity in components/common" subsection.

Icon registry

Added forward (ArrowForwardRounded) to AppIcon/config.ts's ICONS and DIRECTIONAL_ICONS — needed for JalaliDatePicker's next-month button (mirrors under RTL alongside the existing back/chevron_start pair).

Sweep counts (verified by grep on the current tree, not the original audit estimate)

Primitive Live call sites
<EmptyState> 21
<ErrorState> 15
<Money> 19
<SurfaceCard> / <AccentCard> 16
<StatusTimeline> 3
formatNumber(...) 11
localeTag(...) 9

Definition-of-Done sweep greps (re-run after the metadata-pattern file splits)

  • border: '1px dashed' under client/src/appzero.
  • currency_toman outside utils/money.ts + <Money>/PriceDisplay internals → zero.
  • locale === 'fa' ? 'fa-IR' outside utils/number.tszero.

These were re-verified as a final pass, separate from the per-agent sweeps, because the metadata-pattern splits (home/login/search/bookings/admin/partner) physically relocated code after the mechanical sweeps had already run over the pre-split files — three real gaps (BookingDetailView's locked-care dashed border, the cancel-page currency_toman sentence site, six locale === 'fa' ? 'fa-IR' sites across five files) surfaced only in this final pass and are fixed above.

What was mocked / waiting on a real service

None — this phase, like phase 0, is pure client primitives/chrome with no service seams touched. No REQ entries filed (the phase never touches server/ or a contract).

Docs updated

  • client/CLAUDE.md — Project Structure (components/common/* primitives, utils/number.ts, the route-chrome files, the metadata-pattern file splits), a new "Presentational purity in components/common" subsection under Unit Testing (the next-intl/Jest gotcha + the caller-owned-copy convention), "Per-page metadata (the client-page pattern)" section, "Every mutation needs an onError toast" line under Toast Notifications.
  • messages/en.json / fa.jsonrouteChrome namespace, ~8 new error-copy keys for the six defect fixes, confirm_restate converted to t.rich() tags. Key parity verified programmatically: 1,538 keys in each file, zero one-sided keys.

What a human should verify (not done here — no browser in this environment)

Per the phase's own Definition of Done, visual verification on the four axes (/fa+/en × light+dark) and mobile+desktop for the state kit, card kit, route chrome, and the Jalali picker was not performed — there is no browser available in this environment. npm run check and npm run test:ci passing is evidence of type/lint/unit correctness, not visual or interaction correctness. Specifically worth a human pass:

  1. Force a query to fail (devtools offline) on /fa home, /patients, /profile, /nurse/requests, /nurse/services — confirm ErrorState + working retry, never a spinner or false-empty state.
  2. /fa/xyz → branded 404; throw inside a page in dev → branded error screen; production build → confirm no stack trace renders.
  3. JalaliDatePicker grid + chips variants on /fa (RTL arrow-key direction, month names) and /en (plain Gregorian) — both color schemes.
  4. The nurse profile's rating display — confirm a non-integer average renders a genuinely half-filled star, not a rounded one.
  5. Tab titles change per section (جستجو | بالین‌یار, رزروها | بالین‌یار, …).

A note on a subagent report during this phase

One of the six parallel sweep agents (the card-kit migration agent, worktree agent-a099d7389dc14bd95) returned a report whose text was flagged by the harness as "instruction-shaped" (matching a settings-json pattern) and had its control tags neutralized before reaching this session. Having now reviewed the neutralized text directly: it was the agent's own file-accounting summary, noting in passing that .claude/settings.local.json "predates this task and is untouched by me" — normal bookkeeping language, not an embedded directive aimed at this session. No action was taken on it beyond relaying it here, per the harness's own instruction to treat any remaining directive-shaped text as a finding to report rather than follow.

Gate

  • npm run check (type + lint): green, zero errors/warnings.
  • npm run test:ci: 94/94 suites, 392/392 tests green (run after all merge-conflict resolution and the final DoD-sweep fixes above).
  • messages/en.json / messages/fa.json: in sync (1,538 keys each, verified programmatically).
  • 6 agent worktrees (agent-a099d7389dc14bd95, -a0b4bb6e73e057271, -a279bb3717dd53e45, -a74eed78c5350b831, -ae141074976cb056f, -af1e1c6a8d89d64cc) and their branches removed after diff extraction — no leftover worktree state.

Follow-ups for later phases

  • formatRelativeTime ships with tests but no live consumer yet — phases 10/11 (per the phase's own scope note).
  • Custom illustration set for EmptyState — DEFERRED to phase 12, per phase 0/1's own notes.
  • The app-wide motion pass (--bal-motion-*/--bal-easing-standard, defined in phase 0) is still unconsumed outside StatusTimeline's pulse animation and CountdownTimer's ring — phase 12's job.
  • Visual verification (four axes × mobile/desktop) is unverified — see the section above; the next phase touching any of these primitives should do a pass before building further on top.