Files
baya-monorepo/.claude/skills/frontend-designer/SKILL.md
T
2026-08-02 20:01:31 +03:30

27 KiB
Raw Blame History

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 monochrome currentColor version of just the glyph (no ground square), registered as ICONS.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) for BrandMark (auth splash). The wordmark next to it stays real, translated <Typography> — never bake locale text into an SVG.
  • The favicon (src/app/favicon.ico) and public/img/favicon/*.png are 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 a sharp-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:

  1. MUI palettesrc/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.

  2. --bal-* CSS variablessrc/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 as var(--bal-primary), var(--bal-success-contrast), etc.

Rules:

  • Styling a MUI component → use palette keys (color="primary", sx palette 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), then var(--…). Never hard-code a hex in sx, styled, or a component.
  • Adding/changing a color means editing tokens.css and colors.ts together (the file headers call out the sync requirement).

Beyond colortokens.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 numeric sx={{ borderRadius: n }} — that multiplies the shape unit, which is how the login card once ended up a 30px pill. MuiPaper pins 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 backdrop AppFrame paints 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 back theme.ts's shadows array — 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 ring MuiCssBaseline's global :focus-visible override uses. Don't hand-roll a focus style; it's already uniform everywhere.
  • Rating--bal-rating / --bal-rating-empty (filled/empty star colors) — RatingInput uses these, not --bal-warning.
  • Trust--bal-trust / --bal-trust-soft, a distinct identity (not primary/success) for verified marks — TrustBadge and 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 -soft variant (--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 pairs InitialsAvatar picks 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 in src/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 (h1h6) and buttons/strong emphasis, 500 for lighter in-text emphasis (subtitles, row labels, chip text). This is enforced globally in typography.ts; match it in any new sx you write.
  • Buttons: textTransform: 'none', weight 700 (set globally in typography.ts). Never re-uppercase button text.
  • Persian type scale (TYPOGRAPHY_RTL): letterSpacing: 0 on every variant (Persian is a joined script — tracking breaks glyph connections), body line-height ≥1.7, heading line-height ~1.41.5 (room for ascenders/descenders), and responsiveFontSizes() wraps both themes (theme.ts) so heading sizes scale down on small viewports — don't hand-roll per-breakpoint fontSize overrides.
  • Fonts are loaded per-locale in src/app/[locale]/layout.tsx only — Mikhak (--font-mikhak) for fa, Space Grotesk (--font-space-grotesk, via next/font/google, self-hosted at build time) for en. Both preload: false with a conditional .variable className 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 kitEmptyState, 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.

  • AppFrame owns 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 pinned position: absolute over it — never fixed, 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, above sm, 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 — see AdminDataTable's TableContainer.
  • AppFrame also publishes --bal-chrome-top / --bal-chrome-bottom on the scroll container (already including env(safe-area-inset-*), and 0px in a chrome-free shell), so any position: sticky element can clear the bars without importing a constant. StickyActionBar is the reference consumer — don't recompute an offset.
  • One authenticated shell: MobileShell = AppFrame + a contextual TopBar (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 in RoleGuard) supply only tabs and headerActions. 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; AppFrame wraps both bars in the shared FLOATING_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 as aria-label/ title), each tab a fixed 44px circle laid out space-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. tone survives as the semantic label (reaching the DOM as data-accent-tone); the StatusChip, icon and copy inside carry the state. Do not reintroduce the stripe.
  • Navigation is the bottom bar. There is no drawer. Tabs are LinkToPage arrays (@/utils) built with useTranslations('nav'), 35 of them, and by convention the last is a settings/«بیشتر» hub. Active state comes from the shared matchActivePath (longest-prefix, winner-takes-all) over each tab's own path **plus its matchPaths claims — use matchPaths when a tab owns a destination outside its own URL subtree (/nurse/finance owning /nurse/earnings). Never hand-roll pathname.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 NavHubList of 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. FocusedLayout is 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 raw next/link or a manual `/${locale}` prefix. (Inside a page, AppLink/AppButton's to is a plain next/link and still needs the prefix.)
  • Page content is auto-wrapped in ErrorBoundary inside 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 into src/components/.
  • CONTENT_MAX_WIDTH mirrors the frame width — a page column can never be wider than the frame containing it.
  • Prefer MUI breakpoints in sx for the little responsive branching that remains over useIsMobile() (@/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:

  1. i18n — no hard-coded user-facing strings. Add the key to both messages/en.json and messages/fa.json (keep them in sync; top-level keys are namespaces). Client: useTranslations(ns). Server: getTranslations(ns).
  2. RTL-safe. fa is 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; prefer marginInlineStart, start/end, sx shorthand that the RTL Emotion cache mirrors). Test the layout visually at /fa. Do not pass flexWrap/useFlexGap as Stack props — use sx={{ flexWrap: 'wrap' }}.
  3. 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.
  4. Tokens, not hexes. No raw color literals in sx/styled/components (§2).
  5. Constants, not magic values. Cookie names, routes, repeated dimensions, event names → named constants (components.md §5).
  6. Use the wrappers (§4) and the icon registry (§6) before bare MUI.
  7. Shared component ⇒ co-located test (§4).
  8. MUI v9 API only. No v5/v6-era props (e.g. Stack useFlexGap, storageWindow). Avoid deprecated APIs that throw.
  9. Persian copy follows the style guide — «بالین‌یار» with a ZWNJ, تأیید with a hamza, جستجو in one form, formal شما. npm run lint:copy fails the gate on a banned variant. Glossary and the full rules: i18n.md §4.
  10. 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

  1. Locate & scope. Decide private vs public route; confirm the layout shell. Identify which existing App* components and tokens already cover the design.
  2. Tokens first. If the design needs a color/spacing not in the system, add the --bal-* token (both schemes) + mirror in colors.ts if it's palette-level.
  3. Compose with primitives. Build with Box/Stack/Grid/Paper + App* wrappers. Keep raw MUI to layout/structural components.
  4. Wire copy through i18n. Every label/placeholder/aria string → both message files.
  5. Verify the four axes: /fa (RTL) and /en (LTR) × light and dark. The default route is /fa — start there.
  6. Tests for any new shared component; never add a layout above [locale] (breaks locale/dir — see structure.md).
  7. 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 → use APP_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 ≥md branch 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-motion branch → there is exactly one, in globals.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/