Files
baya-monorepo/.claude/skills/frontend-designer/SKILL.md
T
2026-07-27 22:27:04 +03:30

21 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. This skill is the design contract; the engineering contract (providers, fetch, cookies, routing) lives in client/CLAUDE.md — read it before touching layout/provider/data code, don't restate it, and never violate it.

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.

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 default severity="error", variant="filled"
AppImage images wrapper around next/image conventions
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, CONTENT_MAX_WIDTH = 800, CONTENT_MIN_WIDTH = 320, alert/link/ loading defaults). Change a default there, not per-call-site.

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 CLAUDE.md "Unit Testing"; wrap with <ThemeProvider>, never mock MUI).


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 three structural guarantees, and is the only place any of them is solved: the width cap; the frame, not the document, owns the scroll (header / <main> / footer are flex siblings, so a top bar is position: static and no page needs a top offset); and overflowX: hidden + minWidth: 0, so an over-wide child clips rather than dragging the app sideways. Genuinely wide content (a data table) scrolls inside its own container — see AdminDataTable's TableContainer.
  • 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; it sits on the page background. The bottom bar floats: 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.
  • 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) 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 (CLAUDE.md "Constants").
  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.

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 CLAUDE.md).
  7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (serverFetch/clientFetch, @/lib/cookies/*, dispatchToast/useSnackbar). Don't reinvent these.

9. Anti-patterns (design-specific — CLAUDE.md 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].

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
Engineering contract client/CLAUDE.md