16 KiB
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, thenvar(--…). - Never hard-code a hex or rgb in
sx,styled, or a component. - Adding or changing a color means editing
tokens.cssandcolors.tstogether. 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.css — AppFrame 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)
getThemeMode()(lib/cookies/server.ts) reads the'color-scheme'cookie →{ colorScheme, defaultMode: colorScheme }.- The root layout sets
data-mui-color-scheme={colorScheme}on<html>, server-side. 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)
getThemeMode()returns{ colorScheme: undefined, defaultMode: 'system' }.- The root layout renders
<html>without the attribute at all (React omitsundefined). tokens.csshas 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.- 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. ColorSchemeCookieSync(insideThemeProvider.tsx) writes the cookie fromuseColorScheme().colorSchemein 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.variableclass is attached to<html>only for its own locale — never both, never neither. Anext/fontloader called unconditionally would preload on every route;preload: falseensures the file downloads only when its locale actually renders. - Mikhak's woff2 files live in
src/app/fonts/, notpublic/—next/font/localresolves 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
localFontin the root layout, attach its.variableclass conditionally on the matching locale, and update theBRAND_FONT_VARIABLE_*constants intypography.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 (h1–h6), 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.4–1.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.