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

269 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Client theme
Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the
[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) 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.css``AppFrame` publishes them at
runtime on its scroll container. See [components.md](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.**
```ts
// 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](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 (`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.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.