Files
baya-monorepo/archive/post-phase/ui/audit/component-primitives.md
T
2026-08-02 18:48:32 +03:30

20 KiB
Raw Blame History

Shared component primitives + icon system (client/src/components/common/, components/config.ts, components/index.tsx, PlaceholderScreen, and the primitives pages are forced to hand-roll)

Current state

The client/src/components/common/ layer is eight wrappers inherited nearly verbatim from the karpolan react-mui starter: AppButton, AppIconButton, AppIcon (+ its config.ts string registry, icons/PencilIcon.tsx, utils.ts), AppLink (AppLinkNextNavigation.tsx), AppAlert, AppImage, AppLoading, and a class-based ErrorBoundary. Their defaults live as module constants in components/config.ts (APP_BUTTON_VARIANT='contained', APP_ALERT_SEVERITY='error', APP_ICON_SIZE=24, CONTENT_MAX_WIDTH=800) rather than in the theme — and theme/theme.ts has no components overrides at all, so everything MUI renders default-MUI. The wrappers carry starter DNA: AppButton ships a margin: 1 on-all-sides default plus label/text duplicate props and spreads a underline prop onto non-link buttons; AppIconButton wraps its output in a useMemo keyed on a fresh restOfProps object; AppImage is entirely unused in product code; ErrorBoundary's fallback is raw English <h2>{name} - Something went wrong</h2> plus a componentStack dump, and it is the app's ONLY error surface — there is no error.tsx/not-found.tsx/loading.tsx anywhere in the App Router tree.

The icon system is <AppIcon icon="name"> resolving through a ~85-entry lowercase registry (common/AppIcon/config.ts). Feature phases added coherent snake_case domain names (check_in, post_surgery, escrow-adjacent earnings/refunds/moderation icons — all MUI *Outlined), but they sit next to the starter's filled set (Home, Dashboard, EventNote, Groups, PeopleAlt, AccountBalanceWallet, MedicalServices, CheckCircle, Cancel, Star, Info, Settings, AdminPanelSettings) plus eight dead starter entries (daynight/night/day/visibilityon/visibilityoff/signup/login/settings — zero usages). Two structural defects: (1) AppIcon passes size as SVG width/height attributes, which MUI SvgIcon's class CSS (width:'1em'; height:'1em', SvgIcon.js:45) overrides — so every size={14..48} request across 40+ callsites silently renders at 24px; only the one custom SVG respects size, and that SVG is (2) the starter's Twemoji pencil with hard-coded cartoon fills (#EA596E, #FFCC4D…), registered as logo and used as the brand mark in the top bar and auth splash.

Above common/, components/index.tsx exports ~35 domain composites (StatusChip, TrustBadge, PriceDisplay, OtpInput, DocumentUpload, NurseResultCard…), several of which are genuinely well built (token-driven --bal-* colors, i18n-agnostic contracts, BigInt-safe money). But the reusable page primitives were built only for the backoffice — AdminPageHeader, AdminEmptyState, AdminErrorState, ConfirmDialog, AdminDataTable, AdminPager all live in components/admin/ and are used nowhere else — so 40+ customer/nurse pages hand-roll the same header (47 component="h1" occurrences), 24 files hand-roll dashed-border empty states, 12 page files assemble 57 raw MUI <Dialog> confirm flows, 81 files place 139 ad-hoc <Paper>s, 19 files hand-concatenate formatIrrToToman(x) + t('currency_toman'), and 17 files new up their own Intl.NumberFormat/DateTimeFormat. Loading language is split between bare AppLoading spinners (~18 screens) and per-page improvised Skeleton stacks.

Problems (16)

  • [high] client/src/components/common/AppIcon/AppIcon.tsx — The size prop is silently broken for every MUI-registry icon: size is passed as SVG width/height attributes (propsToRender, lines 38-46), but MUI SvgIcon's emotion class sets width:'1em'; height:'1em' (node_modules/@mui/material/SvgIcon/SvgIcon.js:45) and class CSS beats presentation attributes — so all ~40 callsites requesting 14-56px (PlaceholderScreen size={48}, SelectRole size={28}, VisitNoteCard size={14}, TicketInboxScreen size={36}) render at the default 24px. Icon hierarchy across the whole app is flattened to one size; only the custom PencilIcon actually scales.
    • evidence: AppIcon.tsx:38-46 propsToRender = { height: size, ... size, width: size }; SvgIcon.js:45 width: '1em', height: '1em' in the styled root
  • [high] client/src/components/common/AppIcon/icons/PencilIcon.tsx — The brand mark of a trust-first nursing marketplace is the starter's Twemoji cartoon pencil with hard-coded fills (#D99E82 #EA596E #FFCC4D #292F33 #CCD6DD #99AAB5) that ignore the color prop. It is registered as logo and rendered in the top bar (TopBarAndSideBarLayout.tsx:70) and on the auth splash (auth/BrandMark.tsx:21, which passes color="var(--bal-primary)" to no effect). First-impression trust surface = a writing-tool emoji in off-brand colors.
    • evidence: PencilIcon.tsx:8-24 hard-coded Twemoji palette; BrandMark.tsx:21 <AppIcon icon="logo" size={56} color="var(--bal-primary)" />
  • [high] client/src/components/common/ErrorBoundary.tsx — The app's only crash UI is the starter ErrorBoundary: an unstyled, untranslated, LTR English <h2>{name} - Something went wrong</h2> plus a <details> block that dumps error.toString() and the full React componentStack to end users. No retry affordance, no brand styling, and it's what a Persian-speaking family sees if anything throws. There are also zero error.tsx / not-found.tsx / loading.tsx files in the entire App Router tree (glob over client/src/app returns nothing), so route-level failures and 404s fall through to framework defaults.
    • evidence: ErrorBoundary.tsx:44-53 renders <h2>… Something went wrong</h2> + {this.state?.errorInfo?.componentStack}; mounted at layout/TopBarAndSideBarLayout.tsx:104 and layout/CustomerLayout.tsx:78
  • [high] client/src/components/common/AppButton/AppButton.tsx — Starter default margin: 1 on all sides (DEFAULT_SX_VALUES, lines 9-11) fights every real layout: 63+ callsites across 38 files pass sx={{ m: 0 }} just to neutralize it, and because the default only applies when NO sx is given, any button that passes other sx silently loses the margin — outer button spacing is therefore inconsistent app-wide. Default color='inherit' (line 45) instead of primary also forces color="primary" boilerplate on every CTA.
    • evidence: grep <AppButton ... sx={{ m: 0 → 63 occurrences / 38 files (e.g. patients/page.tsx:97, admin/ConfirmDialog.tsx:96,105); 238 total AppButton usages
  • [medium] client/src/components/common/AppIcon/config.ts — The registry mixes three visual generations: ~13 filled starter icons (Home, Dashboard, EventNote, Groups, PeopleAlt, AccountBalanceWallet, MedicalServices, Settings, Info, AdminPanelSettings, CheckCircle, Cancel, Star) against ~55 *Outlined feature icons — nav rails and status chips read as an incoherent default-MUI grab bag (this is the 'ugly icons' the owner senses). It also still registers eight dead starter entries with zero usages: daynight, night, day, visibilityon, visibilityoff, signup, login, settings — violating the repo's own no-dead-code rule.
    • evidence: config.ts:5-33 filled imports vs :35-98 Outlined imports; usage grep: daynight/night/day/visibilityon/visibilityoff/signup/login/settings each = 0 hits outside the registry
  • [medium] client/src/components/common/AppIcon/config.ts — Missing icons for a full app: there is no back/forward/chevron-start navigation arrow anywhere (grep for ArrowBack|ChevronLeft|ChevronRight|KeyboardArrow across client/src = 0 matches), so detail pages (booking, nurse profile, request tracker) cannot render an RTL-flippable back affordance; also absent: share, copy, receipt/invoice, help/FAQ, kebab (more-vert) for card action menus, attach, phone (only emergency=LocalPhone), and a plain non-circled check. expand (ExpandMore) is the sole directional glyph.
    • evidence: grep ArrowBack|ChevronLeft|ChevronRight|arrow_back|KeyboardArrow → 'No matches found'; only 1 file in the app uses router.back()
  • [medium] client/src/components/UserInfo/UserInfo.tsx — Pure starter leftover shipped into the product shell: user?: any prop, hard-coded English fallbacks 'Current User', 'Loading...', 'User Avatar', and name/email fields that don't match the phone-OTP identity model — and SideBar renders <UserInfo showAvatar /> with NO user prop at all, so every authenticated sidebar permanently shows an empty avatar with English 'Current User / Loading...' in the fa-default app.
    • evidence: UserInfo.tsx:6 user?: any, :34 'Current User', :36 'Loading...'; layout/components/SideBar.tsx:56 <UserInfo showAvatar /> (plus :76 hardcoded English tooltip 'Logout Current User')
  • [medium] client/src/components/admin/ConfirmDialog.tsx — The four page-level primitives that exist — ConfirmDialog, AdminPageHeader, AdminEmptyState, AdminErrorState — are exiled under components/admin/ and used only there, so customer/nurse pages hand-roll the identical patterns: patients/page.tsx builds its own confirm Dialog + title header + skeleton + dashed empty state; addresses/page.tsx has 7 raw usages, nurse/coverage 4, nurse/services/MyServicesList 4, bookings/request/[id] 4; 47 hand-rolled variant="h5" component="h1" headers across 44 files; 30 border: '1px dashed' empty-states across 24 files.
    • evidence: grep <Dialog under app/ → 57 occurrences /12 files; grep component="h1" → 47/44 files; patients/page.tsx:87-101 (header), 103-108 (skeleton), 110-120 (dashed empty state)
  • [medium] client/src/utils/money.ts — Excellent BigInt money utils exist but there is no <Money> display primitive, so 19 files hand-assemble {formatIrrToToman(x)} {tc('currency_toman')} and 17 files construct their own Intl.NumberFormat/DateTimeFormat — money rendering (grouping, Persian digits, signed negative styling on earnings/refunds) and date rendering are re-decided per page on the most trust-sensitive surfaces (checkout, invoice, earnings, refund status). PriceDisplay only covers catalog unit-rates.
    • evidence: grep currency_toman → 24 occurrences / 19 files (checkout/page.tsx, invoice/page.tsx, EarningsBalanceHeader…); grep Intl.(NumberFormat|DateTimeFormat) → 26 / 17 files outside utils
  • [medium] client/src/components/config.ts — Starter config as app-wide defaults: a bare <AppAlert> renders a FILLED ERROR alert (APP_ALERT_SEVERITY='error'), CONTENT_MAX_WIDTH=800 with the nonsensical starter comment 'CONTENT_MIN_WIDTH = 320 // CONTENT_MAX_WIDTH - Sidebar width'. These component defaults belong in createTheme({ components }) — and theme/theme.ts (lines 20-35) defines NO components overrides at all, which is exactly why the whole app renders as default MUI.
    • evidence: components/config.ts:10 APP_ALERT_SEVERITY = 'error', :4-5 starter comment; theme/theme.ts:20-35 createTheme with no components key
  • [low] client/src/components/common/AppButton/AppButton.tsx — Starter prop cruft: duplicate label/text props ('Alternate to .text'), a // Missing props comment block, jsdoc claiming a 'Box around to specify margins' that doesn't exist, and line 85 {...{ ...restOfProps, underline }} spreads underline="none" onto plain (non-link) MUI Buttons as an invalid DOM attribute.
    • evidence: AppButton.tsx:16-19 label/text + ':19 // Missing props'; :28 jsdoc 'with Box around'; :85 underline spread
  • [low] client/src/components/common/AppIcon/AppIcon.tsx — Unknown icon names console.warn in production and silently render MoreHoriz ('…') — a wrong icon name in a trust surface degrades to an ellipsis nobody notices; the invalid size attribute is also spread onto the DOM , and the documented title tooltip does nothing (title attribute on inline SVG is not a tooltip; MUI wants titleAccess).
    • evidence: AppIcon.tsx:33-35 warn + ICONS.default fallback; :8-13 Props documents title as hover hint
  • [low] client/src/components/common/AppImage/AppImage.tsx — Dead starter component: zero product usages (only its own test imports it) yet still exported from the common barrel; hard-codes unoptimized={true} citing a 'custom loader' that doesn't exist, defaults 256x256, English alt='Image' fallback.
    • evidence: grep <AppImage → matches only AppImage.test.tsx; AppImage.tsx:19 comment 'Uses custom loader + unoptimized'
  • [low] client/src/components/common/AppLoading/AppLoading.tsx — The only shared loading primitive is a bare centered CircularProgress ('3rem', starter constant) used as the full-page loading state on ~18 screens, while other screens improvise their own Skeleton stacks (e.g. patients/page.tsx [0,1].map(<Skeleton variant="rounded" height={96}>)) — two competing loading languages and no reusable ListSkeleton/CardSkeleton/DetailSkeleton.
    • evidence: AppLoading used in 18 page files (search, checkout, bnpl, profile…); 40+ files import MUI Skeleton directly with per-page layouts
  • [low] client/src/components/common/AppIconButton/AppIconButton.tsx — Cargo-cult useMemo wraps the rendered IconButton keyed on restOfProps — a new object every render — so it never memoizes anything; alpha() hover hack for non-MUI colors is starter residue.
    • evidence: AppIconButton.tsx:62-85 useMemo deps include restOfProps
  • [low] client/src/app/[locale]/(private-routes)/nurse/page.tsx — The nurse's post-login home is still a PlaceholderScreen ('dashboard' icon + generic placeholder body) — for the supply side of the marketplace, the landing screen is literally the empty-state scaffold; admin/users and admin/notifications are also placeholders.
    • evidence: nurse/page.tsx:7 return <PlaceholderScreen icon="dashboard" ...>; grep PlaceholderScreen under app/ → 4 files

Opportunities (10)

  • One-file icon-system swap to a single coherent family (impact: high, effort: medium) — The string-registry indirection means the entire app's iconography can be replaced by editing only AppIcon/config.ts: pick ONE family (all MUI *Rounded for warmth, or an inlined open set like Phosphor/Solar with softer strokes that suits 'clinical-but-human'), map all 85 names to it, add the missing names (back — RTL-flippable via a wrapper that rotates in rtl, receipt, copy, share, help, kebab, phone, plain check), delete the 8 dead starter entries, and type IconName strictly so unknown names fail at compile time instead of console.warn + '…'.
  • Fix AppIcon sizing via fontSize, not attributes (impact: high, effort: small) — Change AppIcon to drive MUI icons with style={{ fontSize: size }} (or sx) instead of width/height attributes. This single fix restores the intended 14-56px hierarchy at every existing callsite simultaneously — the highest leverage-per-line change available in the codebase.
  • Real Balinyaar brand mark (impact: high, effort: medium) — Replace the Twemoji pencil: design a simple symbol (e.g. a home + pulse/leaf motif in deep teal with a cream counter, terracotta only as micro-accent) as a currentColor SVG so BrandMark's color="var(--bal-primary)" actually works, and use it in the top bar, auth splash, favicon, and the future email/invoice header. For a trust-first healthcare product the mark is a functional trust cue, not decoration.
  • Promote the admin primitives + build the missing shared kit (impact: high, effort: large) — Move ConfirmDialog, PageHeader, EmptyState, ErrorState out of components/admin into common/ and add the primitives pages provably keep hand-rolling: Section/AppCard (one Paper recipe — 139 ad-hoc Papers today), ListSkeleton/CardSkeleton, Money (signed coloring, Persian digits, Toman label — 19 hand-rolled sites), DateText (Shamsi via existing utils/date.ts), DescriptionList (61 hand-rolled label/value rows), StatCard, FormSection, BackLink. Then sweep pages onto them. This is the difference between 'restyled starter' and 'design system'.
  • Theme components layer instead of wrapper constants (impact: high, effort: medium) — Add a components section to createTheme: MuiButton (defaultProps color='primary', disableElevation, no default margin — retire DEFAULT_SX_VALUES and the 63 m:0 patches), MuiAlert (standard severity semantics, soft brand-tinted backgrounds), MuiPaper (outlined-by-default with --bal tokens), MuiChip radius, MuiTextField shape, focus-visible rings in teal. Wrappers then shrink to genuine additions (icon-by-name, link composition) instead of re-defaulting MUI per instance.
  • Branded error / 404 / loading route files (impact: high, effort: medium) — Add locale-aware error.tsx, global-error.tsx, not-found.tsx and per-shell loading.tsx with calm Persian copy, the brand mark, and a retry CTA; give ErrorBoundary the same visual fallback and stop printing componentStack to users (log it instead). Crash surfaces are trust surfaces in healthcare.
  • Nurse home dashboard (currently a placeholder) (impact: high, effort: large) — Replace the nurse PlaceholderScreen with a real home: today's visits with check-in shortcuts, pending request countdowns, earnings snapshot (reusing EarningsBalanceHeader), verification/credential-expiry nudges, and unread support tickets. The supply side currently lands on an empty scaffold every login.
  • Richer trust presentation built on TrustBadge (impact: high, effort: medium) — TrustBadge is a small chip; trust is the product. Add a VerificationPanel primitive for nurse profile/search detail: what was verified (identity, license, Shahkar), when, by whom, with an expandable 'how Balinyaar verifies' explainer — turning the existing honest badge state into a persuasive, inspectable trust story.
  • Warm empty-state illustration set (impact: medium, effort: medium) — Replace the 24 dashed-border Paper empty states with a shared EmptyState primitive that accepts a small branded SVG illustration (cream/teal line style, terracotta accent) per domain — patients, addresses, bookings, earnings, search-no-results — moving the tone from 'unconfigured dashboard' to 'calm, human product'.
  • Fix the sidebar identity block (impact: medium, effort: small) — Replace starter UserInfo with a typed ProfileSummary fed by the /me query (display name, masked phone with Persian digits, role label, TrustBadge for nurses) — the current always-'Loading...' English block undermines every authenticated screen.

Keep (do not regress)

  • The AppIcon string-registry indirection itself (<AppIcon icon="verified"> + one config.ts) — it concentrates the entire icon system into a single swap point, and the snake_case domain names (check_in, post_surgery, escrow-era earnings/refunds/moderation) are well-chosen and consistently used.
  • StatusChip and TrustBadge (components/StatusChip, components/TrustBadge): fully token-driven via --bal-* semantic vars (auto dark-scheme), data-status/data-badge-state test hooks, and TrustBadge's honest-by-construction states (verified only when aggregate approved; expired visually distinct from never-verified, unverified deliberately non-alarming) — the right foundation for trust UI.
  • utils/money.ts + PriceDisplay discipline: BigInt integer-safe Rial↔Toman, Persian digit formatting, totals only ever price×count at the field boundary — never regress this on any new Money primitive.
  • components/admin/ConfirmDialog's interaction contract: required-reason gating, loading state that disables both buttons and prevents double-submit, caller-owns-the-mutation separation — promote it, don't rewrite it.
  • The 'already-translated props' presentational contract (PlaceholderScreen, AdminPageHeader, StepperHeader document it explicitly) keeping primitives i18n-agnostic, and their RTL-safe logical flex layouts with no directional CSS.
  • booking/format.ts and utils/date.ts: locale-aware clocks and Shamsi dates via Intl (fa-IR-u-ca-persian) with no date library, with honest null returns for open check-ins.
  • AppLink's Next.js+MUI composition: external links auto-get target=_blank + rel='noopener noreferrer', internal links go through NextLink, active-class support — works and is RTL-neutral.
  • The consistent @/components barrel import pattern — every page already imports primitives from one place, which makes the coming design-system sweep mechanical.