18 KiB
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(formerlypage.tsx) — the patients gate no longer spinner-hangs on error.(customer)/patients/page.tsx—isEmptynow 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 rendersErrorState, never a blank, save-capable form.nurse/services/VariantBuilder.tsx— a failedoptionGroupsQuerynow blocks progression viaErrorStateinstead 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 names — jalaali-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
StatusChiprewritten to a soft-tint hierarchy: neutral/info/pending states use--bal-primary-soft, verified/active use--bal-success-soft, and solid--bal-errorfill is reserved for the one true "alarm" state (rejected) — added alongside new--bal-{success,error,warning,info}-softtokens (both color schemes).StatusTimeline(orderedTimelineNode[]: completed/current/pending/failed, animated pulse oncurrentrespectingprefers-reduced-motion) now backsRefundStatusCardandBookingStatusTimeline(3 call sites) — wizards (StepperHeader) are untouched, per the phase's own "wizards still use StepperHeader" boundary.CountdownTimerv2 adds awindowStartprogress ring,warnThresholdSeconds/urgentThresholdSecondsurgency tiers (replacing the old binaryurgentprop, kept as a legacy override), and an opt-incoarseThresholdSeconds/coarseLabelhumanized mode — the core architecture (server-frozen UTC deadline, self-owned 1s tick, singleonElapsed,dir="ltr"tabular-nums digits) is unchanged.RatingInputv2 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 oldMath.roundworkaround removed from the nurse profile page) and switched its fill colors from--bal-warningto the phase-0--bal-rating/--bal-rating-emptytokens.
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'underclient/src/app→ zero.currency_tomanoutsideutils/money.ts+<Money>/PriceDisplayinternals → zero.locale === 'fa' ? 'fa-IR'outsideutils/number.ts→ zero.
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 incomponents/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 anonErrortoast" line under Toast Notifications.messages/en.json/fa.json—routeChromenamespace, ~8 new error-copy keys for the six defect fixes,confirm_restateconverted tot.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:
- Force a query to fail (devtools offline) on
/fahome,/patients,/profile,/nurse/requests,/nurse/services— confirmErrorState+ working retry, never a spinner or false-empty state. /fa/xyz→ branded 404; throw inside a page in dev → branded error screen; production build → confirm no stack trace renders.JalaliDatePickergrid + chips variants on/fa(RTL arrow-key direction, month names) and/en(plain Gregorian) — both color schemes.- The nurse profile's rating display — confirm a non-integer average renders a genuinely half-filled star, not a rounded one.
- 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
formatRelativeTimeships 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 outsideStatusTimeline's pulse animation andCountdownTimer'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.