27 KiB
name, description
| name | description |
|---|---|
| frontend-designer | Design and build UI for the Balinyaar client (Next.js 16 + MUI v9). Use when creating or restyling any screen, page, component, layout, or visual in client/ — turning a feature, mockup, or Figma design into branded, RTL-aware, dark-mode-ready, i18n-complete React/MUI code. Covers the brand palette, design tokens, typography, the App* component library, layout shells, icons, and the hard rules every Balinyaar UI must follow. |
Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both color schemes on the first try.
Precedence. This skill is the design contract — brand, tone, and the visual
decisions. The engineering contract is client/CLAUDE.md
(hard rules) plus docs/rules/client/ (one reference file
per area). Where the two overlap — tokens, typography, the component library, shells,
icons — docs/rules/client/ is authoritative and this skill defers to it. Read the
relevant one before touching layout, provider, or data code; don't restate it here, and
never violate it.
| For | Read |
|---|---|
| Tokens, palette, dark mode, RTL, fonts, motion | docs/rules/client/theme.md |
The App* library, shells, navigation, icons |
docs/rules/client/components.md |
| Copy and Persian orthography | docs/rules/client/i18n.md |
| Forms | docs/rules/client/forms.md |
Stack: Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (@mui/material) ·
Emotion (RTL via stylis-plugin-rtl) · next-intl v4 · notistack. Everything below
lives under client/src/.
1. Brand identity
Balinyaar is a trust-first home-nursing marketplace in Iran. The visual tone is calm, warm, clinical-but-human — not a cold medical dashboard. Default audience is Persian (RTL); English is secondary.
Logo mark — this skill is the construction source of truth (the original
product/balinyaar.html seed deck no longer exists in the repo): a deep-teal
rounded-square ground (var(--bal-primary)), a cream lowercase "b" glyph built
from a stem + a ring bowl (var(--bal-primary-contrast)), and a single
terracotta dot accent (var(--bal-secondary)). That trio — teal ground, cream
glyph, terracotta accent — is the whole identity. Use terracotta sparingly as
the single accent; teal carries everything else. Implemented as two SVGs under
components/common/AppIcon/icons/:
LogoMark.tsx— a monochromecurrentColorversion of just the glyph (no ground square), registered asICONS.logo. Use via<AppIcon icon="logo">anywhere an inline, recolorable brand glyph is needed.LogoLockup.tsx— the full-color mark (ground + glyph + dot, token-driven so it tracks the color scheme) forBrandMark(auth splash). The wordmark next to it stays real, translated<Typography>— never bake locale text into an SVG.- The favicon (
src/app/favicon.ico) andpublic/img/favicon/*.pngare rasterized from the same construction (fixed brand hex, not CSS vars — static binary assets are the one place a literal hex is correct). Regenerate with asharp-based script if the mark ever changes; don't hand-edit the PNGs/ICO.
| Role | Light | Dark |
|---|---|---|
| Primary — deep teal | #1d4a40 |
#6fc0ac (lifted, readable on dark) |
| Secondary — terracotta | #d98c6a |
#e6a98a |
| Page surface | #faf9f5 cream |
#0f1c19 deep teal |
| Paper / card | #ffffff |
#16302a teal surface |
| Text primary | #1b2521 ink |
#f3efe9 cream |
2. Design tokens — the two-layer system (read this before styling anything)
Colors exist in two mirrored places that must stay in sync. Pick the right one:
-
MUI palette —
src/theme/colors.ts(BRAND,LIGHT_PALETTE,DARK_PALETTE). Drives--mui-palette-*and all MUI component coloring. Reach it through MUI APIs:color="primary",sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}. This is the default for component styling. It auto-switches with the color scheme. -
--bal-*CSS variables —src/theme/tokens.css, defined under[data-mui-color-scheme='light'|'dark']. The source of truth for custom CSS outside MUI's palette and for semantic feedback colors MUI doesn't define:--bal-success,--bal-error,--bal-warning,--bal-info(each+ -contrast). Reference asvar(--bal-primary),var(--bal-success-contrast), etc.
Rules:
- Styling a MUI component → use palette keys (
color="primary",sxpalette refs). - Need success/error/warning/info → use
--bal-*tokens, not MUI defaults — the MUI palette has no semantic colors, and these tokens are brand-harmonized. - Need a custom color in raw CSS → add a
--bal-*token (in both scheme blocks), thenvar(--…). Never hard-code a hex insx,styled, or a component. - Adding/changing a color means editing
tokens.cssandcolors.tstogether (the file headers call out the sync requirement).
Beyond color — tokens.css also defines non-palette tokens (colors.ts never needs
these; they're define-only in CSS):
- Radius —
--bal-radius-sm(6px, controls: buttons/inputs),--bal-radius-md(8px =theme.shape.borderRadius, the house default: cards/paper),--bal-radius-lg(12px: dialogs). Reference the token, never a numericsx={{ borderRadius: n }}— that multiplies the shape unit, which is how the login card once ended up a 30px pill.MuiPaperpins the md step so a Paper can't drift past it.--bal-radius-pill(999px) is for shapes that genuinely are pills — the floating bottom nav, a segmented control's active chip — never for a card. - Frame canvas —
--bal-frame-canvas, the backdropAppFramepaints outside the phone-width app column. Never a surface a component draws on. - Elevation —
--bal-shadow-1/2/3, teal-tinted (black-teal in dark mode) shadow steps that backtheme.ts'sshadowsarray — every MUI elevation (Paper, Dialog, Menu, Popover, AppBar) resolves through these, never MUI's default grey stack. - Motion —
--bal-motion-fast/base/slow(120/200/300ms) +--bal-easing-standard. Consumed by the phase-12 app-wide motion pass; use them for any transition you add now. - Focus —
--bal-focus-ring, the 2px ringMuiCssBaseline's global:focus-visibleoverride uses. Don't hand-roll a focus style; it's already uniform everywhere. - Rating —
--bal-rating/--bal-rating-empty(filled/empty star colors) —RatingInputuses these, not--bal-warning. - Trust —
--bal-trust/--bal-trust-soft, a distinct identity (not primary/success) for verified marks —TrustBadgeand any future verification UI. - Money emphasis —
--bal-money-emphasis, an AA-contrast-safe color for emphasized money text.--bal-secondary(terracotta) fails AA contrast at small sizes on light backgrounds — never use it for money text, use this token instead. - Soft fills — every brand and semantic color has a
-softvariant (--bal-primary-soft,--bal-warning-soft, …) for a tinted background. Reach for it before hand-mixing an alpha over a surface. - Avatar —
--bal-avatar-1..6(+ each-contrast), the six warm pairsInitialsAvatarpicks from by a deterministic name hash. Add a seventh to both scheme blocks or don't add one. - Map —
--bal-pin-shadow, the address-picker pin.
Full catalogue, with what each group backs: docs/rules/client/theme.md §2.
3. Typography & fonts
shape.borderRadius: 8(set insrc/theme/theme.ts) — the house corner radius (=--bal-radius-md). Don't override per-component unless deliberate; the radius scale is--bal-radius-sm(6, controls) /-md(8, cards) /-lg(12, dialogs).- Weight system — never write
fontWeight: 600. Mikhak and Space Grotesk both load only 400/500/700 (no 600 face), so a requested 600 silently renders full Bold. Use 700 for headings (h1–h6) and buttons/strong emphasis, 500 for lighter in-text emphasis (subtitles, row labels, chip text). This is enforced globally intypography.ts; match it in any newsxyou write. - Buttons:
textTransform: 'none', weight 700 (set globally intypography.ts). Never re-uppercase button text. - Persian type scale (
TYPOGRAPHY_RTL):letterSpacing: 0on every variant (Persian is a joined script — tracking breaks glyph connections), body line-height ≥1.7, heading line-height ~1.4–1.5 (room for ascenders/descenders), andresponsiveFontSizes()wraps both themes (theme.ts) so heading sizes scale down on small viewports — don't hand-roll per-breakpointfontSizeoverrides. - Fonts are loaded per-locale in
src/app/[locale]/layout.tsxonly — Mikhak (--font-mikhak) forfa, Space Grotesk (--font-space-grotesk, vianext/font/google, self-hosted at build time) foren. Bothpreload: falsewith a conditional.variableclassName so neither ships to the other locale. Never load a font in a component or page. - Use
<Typography variant=…>for text — it inherits the correct direction-aware family (TYPOGRAPHY_RTL= Mikhak everywhere for full Persian glyph coverage;TYPOGRAPHY_LTR= Space Grotesk headings + system-stack body). Import neither directly in components; let the theme apply them.
4. The component library — reach for these before raw MUI
Shared primitives live in src/components/ (barrel: @/components). Prefer the App*
wrapper over the bare MUI component — the wrappers carry the house defaults.
| Component | Use for | Notes |
|---|---|---|
AppButton |
all buttons & button-links | default variant="contained"; pass to/href to render as a link automatically; startIcon/endIcon accept an icon name string (e.g. startIcon="search") or a node; non-MUI color strings become text color |
AppIconButton |
icon-only actions | takes an icon name, title, to/onClick |
AppIcon |
any icon | icon="home" by registered name (§6); size, color props |
AppLink |
internal/external links | locale-aware Next navigation; default underline hover |
AppAlert |
inline alerts | defaults to a calm severity="info", variant="standard" — pass severity="error" explicitly when it really is an error |
AppLoading |
loading state | default circular, primary, 3rem |
ErrorBoundary |
wrapping fault-prone subtrees | already wraps page content in the shell |
ProfileSummary |
the identity card in chrome | avatar+name+masked phone+role label+optional TrustBadge; vertical or compact horizontal chip |
Defaults for these live in src/components/config.ts (APP_BUTTON_VARIANT,
APP_ICON_SIZE = 24, APP_ICON_STROKE_WIDTH = 1.75, APP_BUTTON_ICON_SIZE = 20,
CONTENT_MAX_WIDTH = 480, CONTENT_MIN_WIDTH = 320, alert/link/loading defaults).
Change a default there, not per-call-site.
Beyond the App* wrappers there is a state kit — EmptyState, ErrorState,
QueryStateGate, PageHeader, ConfirmDialog, SurfaceCard, AccentCard, Money,
StatusTimeline, the Jalali* date inputs, StickyActionBar, Pager, NavHubList,
InitialsAvatar, FormDialogShell — with one pattern per state. Never hand-roll a
dashed-border "nothing here" block or a per-screen pager; and an error state is never an
empty state. Catalogue in
docs/rules/client/components.md.
For layout/spacing use MUI primitives directly: Box, Stack, Container, Grid,
Paper, Card. Use the spacing/sx system (theme spacing unit = 8px) — never inline
pixel margins for rhythm.
New shared component? Put it in src/components/<Name>/<Name>.tsx with an
index.tsx barrel, follow the App* prop-spreading + JSDoc style of AppButton.tsx,
and add a co-located .test.tsx (mandatory for anything imported in >1 place — see
docs/rules/client/testing.md; wrap with
<ThemeProvider>, never mock MUI). If it goes at the top of the @/components/common
barrel, prefer caller-owned copy (required title/body/retryLabel string props)
over calling useTranslations inside it — next-intl is ESM-only and poisons every test
that transitively imports the barrel. ErrorBoundary/ErrorState are the model;
components.md has the why.
Any form with more than one field is a react-hook-form form, bound through the
@/components/common/form wrappers (RhfTextField, RhfChipSelect,
RhfJalaliDateField, RhfControlGroup) and grouped into FormSections. A single-field
control is state, not a form. Full pattern:
docs/rules/client/forms.md.
5. Layout & page shells
There is one layout: a phone. AppFrame (src/layout/AppFrame.tsx) renders every
screen inside a centered APP_FRAME_MAX_WIDTH (480px) column on a --bal-frame-canvas
backdrop, at every viewport. A wider window gets more canvas, never a wider app —
design one set of states, verify one set of states. Do not add a ≥md branch that widens
a shell, restores a sidebar, or lays a screen out in columns.
AppFrameowns four structural guarantees, and is the only place any of them is solved: the width cap; the frame, not the document, owns the scroll (a single scrolling<main>fills the frame, with the bars pinnedposition: absoluteover it — neverfixed, which would break out of the centered column — and<main>reserving each bar's exact height as padding, so no page needs a top offset);overflowX: hidden+minWidth: 0, so an over-wide child clips rather than dragging the app sideways; and, abovesm, the column floats as a rounded shadowed card with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data table) scrolls inside its own container — seeAdminDataTable'sTableContainer.AppFramealso publishes--bal-chrome-top/--bal-chrome-bottomon the scroll container (already includingenv(safe-area-inset-*), and0pxin a chrome-free shell), so anyposition: stickyelement can clear the bars without importing a constant.StickyActionBaris the reference consumer — don't recompute an offset.- One authenticated shell:
MobileShell=AppFrame+ a contextualTopBar(brand lockup on a tab's own path, back chevron +useRouteTitle()on anything deeper) +BottomBar+ErrorBoundary+RouteFadeIn. The four actor layouts (CustomerLayout/NurseLayout/AdminLayout/PartnerLayout, each wrapped inRoleGuard) supply onlytabsandheaderActions. Add a destination by adding a tab or a hub row — never by forking the shell. - The chrome is light, not structural. The top bar is not an
AppBar— no filled surface, no rule, no elevation of its own;AppFramewraps both bars in the sharedFLOATING_BAR_SX, so the header is the bottom bar mirrored: inset from the frame edges, fully rounded (--bal-radius-pill), elevated. Neither should read as a slab sealing off an edge of a 480px screen. The bottom bar is icon-only (at five tabs the caption was the widest thing in it and cost a whole line — the label survives asaria-label/title), each tab a fixed 44px circle laid outspace-around. - A stateful card carries its state in its content, not a stripe.
AccentCard's colored edge stripe was removed — a column of them read as a row of loose vertical rules down the RTL edge of the screen.tonesurvives as the semantic label (reaching the DOM asdata-accent-tone); theStatusChip, icon and copy inside carry the state. Do not reintroduce the stripe. - Navigation is the bottom bar. There is no drawer. Tabs are
LinkToPagearrays (@/utils) built withuseTranslations('nav'), 3–5 of them, and by convention the last is a settings/«بیشتر» hub. Active state comes from the sharedmatchActivePath(longest-prefix, winner-takes-all) over each tab's own path **plus itsmatchPathsclaims — usematchPathswhen a tab owns a destination outside its own URL subtree (/nurse/financeowning/nurse/earnings). Never hand-rollpathname.startsWith. - A nav group's root is a real page, not a drawer section: a short summary of that
domain (read only off queries that already answer it — never a fabricated figure) over a
NavHubListof its destinations. See/nurse/practice,/nurse/finance,/admin/trust,/admin/system. - Chrome carries no preferences. Language and appearance live in
SettingsPanel(@/components/settings), mounted in each actor's settings hub and nowhere else. The top bar is for identity, the page title, and at most a notification bell. Appearance is a three-way segmented control (light/dark/system) — never a boolean switch, which cannot express the app's own default. - Public screens use
PublicLayout— the frame and nothing else, no top bar; the step content (AuthCard) carries the only brand mark on screen.FocusedLayoutis the framed chrome-free shell for can't-tab-away flows (onboarding,/select-role). - All chrome navigation goes through
@/i18n/navigation(Link/usePathname/useRouter) — never a rawnext/linkor a manual`/${locale}`prefix. (Inside a page,AppLink/AppButton'stois a plainnext/linkand still needs the prefix.) - Page content is auto-wrapped in
ErrorBoundaryinside every shell. - Shell dimensions are constants in
src/layout/config.ts(APP_FRAME_MAX_WIDTH,TOP_BAR_HEIGHT). Respect them; don't hard-code. - A page is
src/app/[locale]/(private|public-routes)/…/page.tsx. Keep page bodies to composition + content; push reusable visuals intosrc/components/. CONTENT_MAX_WIDTHmirrors the frame width — a page column can never be wider than the frame containing it.- Prefer MUI breakpoints in
sxfor the little responsive branching that remains overuseIsMobile()(@/hooks) — the latter is JS/post-hydration and caused a real SSR flash; reach for it only for genuinely non-structural, JS-only behavior.
6. Icons
Icons are a name registry, not free imports. src/components/common/AppIcon/config.ts
maps lowercase names → components. Render with <AppIcon icon="home" /> or pass the name
to AppButton/AppIconButton (icon="search").
One visual family: Lucide. Every registered icon comes from lucide-react — a
contemporary outline family on a 24px grid with round caps/joins, which reads far lighter
than the filled glyphs this registry used to carry at the small sizes a phone-width app
actually uses. @mui/icons-material is no longer a dependency; never reintroduce it.
The house stroke weight is APP_ICON_STROKE_WIDTH (1.75 — Lucide ships at 2, which
competes with Mikhak's lighter Persian strokes).
The mapping is semantic, not incidental. A name describes the domain concept
("verification", "earnings", "coverage") and the glyph depicts that, so swapping the
underlying glyph never leaks into call sites. Related concepts share a visual root on
purpose: trust/verification names are shields, money names are coins or cards, clinical
names are a pulse or a cross. ~110 names are registered — read AppIcon/config.ts for the
list rather than duplicating it here; the structural rules below are what won't drift.
size drives real width/height. Lucide sizes off SVG attributes, so
<AppIcon icon="verified" size={48} /> is 48px with no fontSize/1em indirection.
Icons also default to flexShrink: 0 — an icon squashed by a flex sibling was the one
layout bug this component kept quietly reintroducing on narrow rows.
Directional icons mirror automatically. Icons authored for LTR that must flip under
RTL (back, chevron_start, chevron_end, forward, send) are registered in AppIcon/config.ts's
DIRECTIONAL_ICONS set. AppIcon stamps data-icon-directional on those, and one CSS
rule (app/globals.css) does
[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }. Adding a new directional
icon is a one-line registry addition — never hand-roll a per-component flip.
Need a new icon: import it from lucide-react into config.ts, add a lowercase
key to ICONS, then reference by that name. Custom SVGs (the brand mark) go in
AppIcon/icons/ and must accept the same size/color/strokeWidth contract
(AppIcon/utils.ts's IconProps). An unregistered name logs a dev-only warning and falls
back to default — never pass a raw icon component where a name is expected.
7. Non-negotiable rules for every Balinyaar UI
Every screen/component you produce must satisfy all of these:
- i18n — no hard-coded user-facing strings. Add the key to both
messages/en.jsonandmessages/fa.json(keep them in sync; top-level keys are namespaces). Client:useTranslations(ns). Server:getTranslations(ns). - RTL-safe.
fais the default locale and is RTL. Never use directional hard-coding (marginLeft,left:,textAlign: 'left') for layout flow — use logical/MUI-flipped props (ml→ MUI flips; prefermarginInlineStart,start/end,sxshorthand that the RTL Emotion cache mirrors). Test the layout visually at/fa. Do not passflexWrap/useFlexGapasStackprops — usesx={{ flexWrap: 'wrap' }}. - Dark-mode correct. Pull every color from the palette or
--bal-*tokens so it switches automatically. Verify on both schemes — never assume a light background. - Tokens, not hexes. No raw color literals in
sx/styled/components (§2). - Constants, not magic values. Cookie names, routes, repeated dimensions, event names → named constants (components.md §5).
- Use the wrappers (§4) and the icon registry (§6) before bare MUI.
- Shared component ⇒ co-located test (§4).
- MUI v9 API only. No v5/v6-era props (e.g.
StackuseFlexGap,storageWindow). Avoid deprecated APIs that throw. - Persian copy follows the style guide — «بالینیار» with a ZWNJ, تأیید with a hamza,
جستجو in one form, formal شما.
npm run lint:copyfails the gate on a banned variant. Glossary and the full rules: i18n.md §4. - A screen never fabricates a figure. A summary reads only off a query that already answers it; a count still in flight is omitted, never faked or defaulted.
8. Workflow: turning a design or feature into a screen
- Locate & scope. Decide private vs public route; confirm the layout shell. Identify
which existing
App*components and tokens already cover the design. - Tokens first. If the design needs a color/spacing not in the system, add the
--bal-*token (both schemes) + mirror incolors.tsif it's palette-level. - Compose with primitives. Build with
Box/Stack/Grid/Paper+App*wrappers. Keep raw MUI to layout/structural components. - Wire copy through i18n. Every label/placeholder/aria string → both message files.
- Verify the four axes:
/fa(RTL) and/en(LTR) × light and dark. The default route is/fa— start there. - Tests for any new shared component; never add a layout above
[locale](breaks locale/dir — see structure.md). - Data/fetch/auth/cookies/toasts → follow
services.md and
auth.md (
serverFetch/clientFetch,@/lib/cookies/*,dispatchToast/useSnackbar). Don't reinvent these.
9. Anti-patterns (design-specific — docs/rules/client/ has the full engineering list)
- Hard-coded hex/rgb in components → use palette keys or
--bal-*tokens. - MUI default success/error colors for feedback → use
--bal-*semantic tokens. marginLeft/left/textAlign:'left'for flow → breaks RTL; use logical props.- Hard-coded English (or any) UI string → add to both message files.
- Loading a font in a component/page → fonts live only in
src/app/[locale]/layout.tsx. createTheme()in a component → useAPP_THEME_LTR/APP_THEME_RTL.- Raw MUI icon where a registry name is expected → register it in
AppIcon/config.ts. - New shared component without a
.test.tsx, or mocking MUI in tests. - Re-introducing
src/app/layout.tsx/ any layout above[locale]. - A
≥mdbranch that widens a shell, restores a sidebar, or goes multi-column → there is one layout, and it is a phone (§5). - A numeric
sx={{ borderRadius: n }}→ it multiplies the shape unit; use the radius token. fontWeight: 600→ neither face loads it, so it silently renders full Bold. 700/500/400.- Reintroducing
AccentCard's edge stripe, a drawer, a top-bar theme/locale toggle, or a caption under a bottom-nav icon → each was deliberately removed. - A hand-rolled empty/error/loading block, or a per-screen pager → use the state kit (§4).
- A second
prefers-reduced-motionbranch → there is exactly one, inglobals.css.
10. Design ↔ Figma (optional)
A Figma MCP is connected. When the user provides a figma.com URL or asks to implement a
Figma frame, use the Figma tools (get_design_context, get_screenshot, get_metadata)
to pull the design, then map it onto this system: translate Figma colors to the
nearest --bal-*/palette token (don't introduce new hexes unless the design truly adds a
brand color), Figma type to <Typography> variants, and Figma components to the App*
library. Follow the Figma plugin skills (/figma-use, /figma-generate-design) when
pushing code back into Figma.
Key files
| Concern | File |
|---|---|
| Brand color tokens (CSS) | client/src/theme/tokens.css |
| MUI palette (mirror) | client/src/theme/colors.ts |
| Typography & fonts | client/src/theme/typography.ts |
| Theme factory (shape, schemes, dir) | client/src/theme/theme.ts |
| Theme provider / RTL cache | client/src/theme/ThemeProvider.tsx |
| Component library | client/src/components/ (@/components) |
| Component defaults | client/src/components/config.ts |
| Icon registry | client/src/components/common/AppIcon/config.ts |
| Layout shells | client/src/layout/ |
| Layout dimensions | client/src/layout/config.ts |
| Messages (i18n) | client/messages/{en,fa}.json |
| Persian copy lint | client/scripts/check-copy.mjs |
| Engineering hard rules | client/CLAUDE.md |
| Engineering reference (per area) | docs/rules/client/ |