269 lines
16 KiB
Markdown
269 lines
16 KiB
Markdown
# 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.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.
|