Files
baya-monorepo/docs/rules/client/theme.md
T
2026-07-30 02:26:52 +03:30

16 KiB
Raw Blame History

Client theme

Colors, tokens, dark mode, direction, fonts, motion. The brand's look is the frontend-designer skill's job; this file is the mechanism it runs on.

Last verified: 2026-07-30 against commit d3ec723.


1. Brand identity

Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm, clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is secondary.

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

Teal ground, cream glyph, terracotta accent is the whole identity. Use terracotta sparingly as the single accent; teal carries everything else.

The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and one terracotta dot. Two SVGs under components/common/AppIcon/icons/: LogoMark.tsx (monochrome currentColor glyph only, registered as ICONS.logo) and LogoLockup.tsx (full colour, token-driven so it tracks the scheme, used by BrandMark). The wordmark beside it stays a real, translated <Typography> — never bake locale text into an SVG. The favicon and public/img/favicon/*.png are rasterized from the same construction with fixed brand hex, which is the one place a literal hex is correct; regenerate with a sharp-based script rather than hand-editing the binaries.


2. Two mirrored color homes

Colors exist in two places that must stay in sync. Pick the right one.

Home File Reach it via Use for
MUI palette theme/colors.ts (BRAND, LIGHT_PALETTE, DARK_PALETTE) color="primary", sx={{ color: 'text.secondary', bgcolor: 'background.paper' }} The default for styling a MUI component
--bal-* CSS variables theme/tokens.css, under [data-mui-color-scheme='light'|'dark'] var(--bal-primary) Custom CSS outside MUI's palette, and every semantic feedback color
  • Styling a MUI component → palette keys.
  • Need success / error / warning / info → --bal-*, not MUI's defaults. The MUI palette defines 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 or rgb in sx, styled, or a component.
  • Adding or changing a color means editing tokens.css and colors.ts together. Both file headers call out the sync requirement.

The token catalogue

Every token is defined under both [data-mui-color-scheme] blocks unless noted.

Group Tokens Notes
Brand --bal-primary, -light, -dark, -contrast, -soft; same five for --bal-secondary
Surfaces --bal-bg-default, --bal-bg-paper, --bal-frame-canvas frame-canvas is the backdrop AppFrame paints outside the phone-width column — never a surface a component draws on
Text --bal-text-primary, --bal-text-secondary, --bal-divider
Semantic --bal-{success,error,warning,info} + each -contrast + each -soft -contrast is the text color on that fill; -soft is the tinted background variant
Elevation --bal-shadow-1/2/3 Teal-tinted (black-teal in dark). They back theme.ts's shadows array, so every MUI elevation resolves through them — never MUI's grey stack
Radius --bal-radius-sm 6px (controls), -md 8px (cards/paper, = theme.shape.borderRadius), -lg 12px (dialogs), -pill 999px Reference the token, never a numeric sx={{ borderRadius: n }} — that multiplies the shape unit, which is how the login card once became a 30px pill. MuiPaper pins the md step so a Paper can't drift past it. -pill is for shapes that genuinely are pills (the floating nav, a segmented control's active chip), never a card
Motion --bal-motion-fast/base/slow (120/200/300ms), --bal-easing-standard theme.ts points MuiDialog/MuiDrawer/MuiPopover/MuiMenu's defaultProps.transitionDuration at the same numbers, in one place, instead of MUI's per-variant defaults
Focus --bal-focus-ring The 2px ring MuiCssBaseline's global :focus-visible override uses. Don't hand-roll a focus style — it is already uniform everywhere
Rating --bal-rating, --bal-rating-empty RatingInput uses these, not --bal-warning
Trust --bal-trust, --bal-trust-soft A distinct identity for verified marks — not primary, not success. TrustBadge and any future verification UI
Money --bal-money-emphasis AA-contrast-safe emphasized money text. --bal-secondary (terracotta) fails AA at small sizes on light backgrounds — never use it for money text
Avatar --bal-avatar-1..6 + each -contrast Six warm pairs InitialsAvatar picks from by a deterministic name hash
Map --bal-pin-shadow The address-picker pin

--bal-chrome-top / --bal-chrome-bottom are not in tokens.cssAppFrame publishes them at runtime on its scroll container. See components.md.


3. Dark mode, and the no-flash boot

The mechanism is pure CSS. There is no boot script, no inline <script>, and no Storage.prototype patching — matching how every other color decision in this app is made.

Returning visitor (cookie present)

  1. getThemeMode() (lib/cookies/server.ts) reads the 'color-scheme' cookie → { colorScheme, defaultMode: colorScheme }.
  2. The root layout sets data-mui-color-scheme={colorScheme} on <html>, server-side.
  3. tokens.css's explicit [data-mui-color-scheme='light'|'dark'] blocks match immediately — correct on the very first paint, before any JS runs.

First-ever visitor (no cookie)

  1. getThemeMode() returns { colorScheme: undefined, defaultMode: 'system' }.
  2. The root layout renders <html> without the attribute at all (React omits undefined).
  3. tokens.css has a @media (prefers-color-scheme: dark) block scoped to :root:not([data-mui-color-scheme]) — it applies only while the attribute is absent, and paints the OS-preferred scheme immediately with zero JS.
  4. Once React hydrates, <MuiThemeProvider defaultMode="system"> resolves the same media query and stamps the attribute itself. The painted values already match, so nothing visibly flips.
  5. ColorSchemeCookieSync (inside ThemeProvider.tsx) writes the cookie from useColorScheme().colorScheme in an effect, so the next visit is a "returning visitor" — even before the user ever touches the control.

The known gap, by design

This covers the dominant visual surface — every --bal-* token — because that is what the media-query fallback drives. MUI's own generated --mui-palette-* variables (consumed by a bare color="primary" fill: a contained Button, the default MuiTabs indicator) do not get the same free fallback: MUI's colorSchemeSelector supports attribute-based or 'media'-based generation, not both at once. So on a cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value until hydration. It self-corrects in the same frame, and disableTransitionOnChange means it snaps rather than animating.

Prefer sourcing colors from var(--bal-*) over theme.vars.palette.* in new styleOverrides — most of theme.ts's components block already does — to keep this gap as small as possible.

MUI v9 traps in this area

colorSchemeSelector must be the explicit attribute name.

// theme.ts
cssVariables: {
  colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
  // colorSchemeSelector: 'data',               // WRONG
}

The shorthand 'data' generates [data-%s] → boolean data-dark="" / data-light="" attributes. Our tokens.css selects on [data-mui-color-scheme="dark"], which never matches a boolean attribute, so the whole token layer silently stops switching.

Never use MUI's InitColorSchemeScript. It reads localStorage, which diverges from our cookie (especially in system mode), and it is a script — this app's no-flash boot is CSS-only. Don't add any pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the tokens.css media-query fallback instead.

Never use storageWindow={null}. In MUI v9's localStorageManager the check is if (!storageWindow && typeof window !== 'undefined')null is falsy, so it silently overrides to window. The prop is a no-op in browsers.

MUI v9's localStorage key defaults differ from v5/v6 — mode key 'mode' (was 'mui-mode'), color scheme key 'color-scheme' (was 'mui-color-scheme'), HTML attribute 'data-color-scheme' (was 'data-mui-color-scheme'). We override the attribute via colorSchemeSelector; the cookie is ours and is named by COOKIE_NAMES.COLOR_SCHEME.

mode vs colorScheme

Use colorScheme for an "is dark active" check. mode can be 'system' even when dark is active.

The one exception is the control itself, which must read mode: that is the user's choice, while colorScheme is only the resolved result. mode is undefined until MUI mounts, so default it (mode ?? 'system') rather than rendering an unselected control — server, first client render, and pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected".

The one appearance control

components/settings/ThemeModeSetting.tsx is the only component that subscribes to useColorScheme() and the app's only appearance control. It lives in each actor's settings hub (/nurse/more, /admin/system, /partner/more, the customer profile hub) and nowhere else — the old top-bar toggle spent a permanent slot of chrome in three shells on a preference set once.

It is a three-way segmented control (light / dark / system), never a boolean switch. system is the app's real default on a cookie-less first visit, so an on/off control cannot represent the current state and would silently misreport it.

The write path: setMode('dark')ColorSchemeCookieSync's effect writes the 'color-scheme' cookie → MUI sets data-mui-color-scheme on <html> → CSS variables resolve → the browser repaints. No React re-render above the control.

Pre-built themes

APP_THEME_LTR and APP_THEME_RTL are created once at module load. Never call createTheme() inside a component or hook — pass the appropriate pre-built theme to MuiThemeProvider.


4. Direction

getDirection(locale) (theme/direction.ts) returns 'rtl' for fa, ar, he, ur; 'ltr' for everything else. ThemeProvider takes a dir prop and selects the matching pre-built theme; the RTL Emotion cache uses stylis-plugin-rtl to mirror all generated CSS.

The root layout sets dir on <html> and passes it to ThemeProvider. Because that layout is keyed on the [locale] URL param, a locale change re-renders it with a fresh dir on both hard and soft navigation, with no client state. Do not move the <html dir> render above [locale] — see structure.md §2.

RTL correctness is a rule, not a nicety. Never use directional hard-coding for layout flow — marginLeft, left:, textAlign: 'left'. Use logical or MUI-flipped properties: ml (MUI flips it), marginInlineStart, insetInline, start/end. Verify the layout visually at /fa, then /en.

Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose goes in a dir="ltr" span. SessionCard and BookingRequestSummaryCard are the references.


5. Fonts

Loaded per locale, so the Persian face is never shipped to English pages.

Locale Font CSS variable Source
fa (RTL) Mikhak --font-mikhak next/font/local — woff2 in src/app/fonts/
en (LTR) Space Grotesk --font-space-grotesk next/font/google — self-hosted at build time

Rules:

  • Both are declared with preload: false, and each .variable class is attached to <html> only for its own locale — never both, never neither. A next/font loader called unconditionally would preload on every route; preload: false ensures the file downloads only when its locale actually renders.
  • Mikhak's woff2 files live in src/app/fonts/, not public/next/font/local resolves paths relative to the calling file at build time. Space Grotesk needs no local files.
  • Never load a font inside a component or page. All font loading lives in src/app/[locale]/layout.tsx.
  • To add a local font: add the woff2 files, declare via localFont in the root layout, attach its .variable class conditionally on the matching locale, and update the BRAND_FONT_VARIABLE_* constants in typography.ts.

Typography

TYPOGRAPHY_LTR (Space Grotesk headings, system-stack body) and TYPOGRAPHY_RTL (Mikhak for all text, for full Persian glyph coverage) share one size/line-height scale (SIZE_SCALE), wrapped in responsiveFontSizes() in theme.ts for per-breakpoint heading scaling. There is no TYPOGRAPHY alias — import TYPOGRAPHY_LTR/TYPOGRAPHY_RTL explicitly, and not into components: use <Typography variant=…> and let the theme apply the direction-aware family.

Never write fontWeight: 600. Neither face loads a 600 weight, so a requested 600 silently renders full Bold. The system is 700 for headings (h1h6), buttons and strong emphasis, 500 for lighter in-text emphasis (subtitles, row labels, chip text), 400 body. It is enforced globally in typography.ts; match it in any new sx.

Buttons are textTransform: 'none' at weight 700, set globally — never re-uppercase button text.

The Persian scale sets 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 for ascender/descender room. Don't hand-roll per-breakpoint fontSize overrides — responsiveFontSizes() already wraps both themes.


6. Motion and the reduced-motion gate

RouteFadeIn (components/common/RouteFadeIn/) is the one route-content fade/slide primitive. It wraps {children}, keyed on the locale-stripped pathname so it remounts (and replays the CSS bal-fade-in keyframe from globals.css) on navigation but never on an in-place re-render. It is mounted inside the ErrorBoundary in all five shells, so a new page gets the motion for free with no per-page wiring.

prefers-reduced-motion: reduce has exactly one gate, in src/app/globals.css: a universal *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … } media-query block.

This is deliberately a global CSS reset rather than token-only zeroing. tokens.css also zeroes the duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse transitions, which don't read CSS custom properties. Never add a second, component-local reduced-motion branch — extend this one rule if a new motion primitive needs the same treatment.


7. Toast colors

NotistackProvider maps every notistack variant to a styled(MaterialDesignContent) whose backgroundColor/color come from the --bal-{success,error,warning,info} (+ -contrast) tokens. Because those tokens are defined on <html>, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens.

Direction is inherited too: the Portal mounts under <body> and picks up dir from <html dir>. Do not pass a dir prop to SnackbarProvider — it is not a valid prop (TS error) and is unnecessary.