16 KiB
Balinyaar Client — Claude Code Guidelines
Project Structure
client/
├── messages/ # Translation files (add keys to BOTH files)
│ ├── en.json
│ └── fa.json
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
└── src/
├── app/
│ ├── layout.tsx # Root RSC: reads locale + cookie → sets HTML attrs
│ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
│ └── [locale]/
│ ├── layout.tsx # RSC: setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider
│ ├── (private-routes)/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
│ │ └── page.tsx
│ └── (public-routes)/
│ └── layout.tsx # 'use client' — wraps PublicLayout
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
├── layout/
│ ├── PrivateLayout.tsx # 'use client' — authenticated shell; uses useTranslations('nav')
│ ├── PublicLayout.tsx # unauthenticated shell
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition
│ ├── config.ts
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx
│ ├── SideBar.tsx
│ ├── SideBarNavList.tsx
│ ├── SideBarNavItem.tsx
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
│ └── index.tsx
├── lib/
│ └── cookies/ # Cookie manager — strict server/client separation
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client)
├── store/ # AppStore (Redux-like client state)
├── theme/
│ ├── ColorSchemeScript.tsx # Inline <script> in <head> — sets attr + patches Storage
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeCookieSync
│ ├── colors.ts # LIGHT_PALETTE, DARK_PALETTE, BRAND
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
│ └── index.ts # Public re-exports
├── constants/ # App-wide constants (routes, events, etc.)
├── hooks/
├── utils/
└── config.ts
Server / Client Component Boundaries
Root layout (src/app/layout.tsx) is a lean Server Component (RSC). It:
- Reads locale from the
x-next-intl-localerequest header set by the middleware (with fallback todefaultLocalefor build-time pre-rendering where no request context exists). - Calls
getThemeMode()from@/lib/cookies/server(onlycolorSchemeis used). - Renders
<html>withdata-mui-color-scheme,lang, anddirfrom the locale/cookie. - Does NOT wrap the tree with
NextIntlClientProvider,ThemeProvider, orAppStoreProvider— those live in[locale]/layout.tsxwhere the locale is reliably sourced from URL params.
WHY providers are NOT in root layout: getThemeMode() and headers() are both called inside try/catch blocks that swallow DYNAMIC_SERVER_USAGE. Next.js therefore never observes a dynamic API error propagating from the root layout and treats it as a statically renderable component. At build time the locale falls back to defaultLocale ('fa'), the static HTML is cached, and all subsequent requests — including /en — are served the pre-rendered 'fa' messages. Moving providers to [locale]/layout.tsx sidesteps this entirely: that layout always has the correct locale from URL params.
Locale layout (src/app/[locale]/layout.tsx) is an RSC that owns all i18n and theme context. It:
- Calls
setRequestLocale(locale)so server components deeper in the tree can callgetLocale()/getTranslations()reliably. - Calls
getMessages({ locale })with the locale passed explicitly sogetRequestConfigreceives it viaPromise.resolve(locale)(not through the React.cache read), avoiding any cache-ordering race. - Calls
getThemeMode()fordefaultModeneeded byThemeProvider. - Wraps children with
NextIntlClientProvider,AppStoreProvider, andThemeProvider. - Exports
generateStaticParamsso Next.js can enumerate locale routes at build time.
Route-group layouts ((private-routes)/layout.tsx, (public-routes)/layout.tsx) are 'use client' — they only wrap a layout component and need no server capabilities.
Never import from next/headers, next-intl/server, or @/lib/cookies/server in a client component. The build will fail.
i18n (next-intl v4)
Adding translations:
- Add the key to
messages/en.jsonANDmessages/fa.json. Both files must always be in sync. - Top-level keys are namespaces:
"nav","common", etc.
Using translations in client components:
import { useTranslations } from 'next-intl';
function MyComponent() {
const t = useTranslations('nav'); // namespace
return <span>{t('home')}</span>; // key
}
Using translations in Server Components:
import { getTranslations } from 'next-intl/server';
async function MyServerComponent() {
const t = await getTranslations('nav');
return <span>{t('home')}</span>;
}
Established namespaces and where they're used:
'nav'—PrivateLayout.tsx(sidebar nav items)'common'—DarkModeButton.tsx(dark/light mode labels)
Never hard-code UI strings in English. Any user-visible text must have a translation key in both locale files.
Cookie Manager
The cookie manager in src/lib/cookies/ is split into three files to prevent cross-environment bundling:
| File | Use from | Purpose |
|---|---|---|
constants.ts |
anywhere | COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS |
server.ts |
Server Components, Server Actions, Route Handlers only | getServerCookie, getThemeMode, setServerCookie |
client.ts |
client components / useEffect only |
getClientCookie, setClientCookie, deleteClientCookie |
index.ts |
anywhere | Re-exports constants.ts only — safe barrel |
Rules:
- Import constants via the barrel:
import { COOKIE_NAMES } from '@/lib/cookies' - Import server utils directly:
import { getThemeMode } from '@/lib/cookies/server' - Import client utils directly:
import { setClientCookie } from '@/lib/cookies/client' - Never import
server.tsin a client component; never importclient.tsin an RSC. COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'— the single source of truth for the theme cookie name. Do not redeclare it anywhere.
Constants
Rule: every magic string or configurable value must be a named constant — never inline.
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
Where to define:
- Cookie names / options:
src/lib/cookies/constants.ts - Feature-scope constants: co-locate in a
constants.tsnext to that feature's files - App-wide constants (used across multiple features):
src/constants/— one file per concern (routes.ts,events.ts, etc.)
Rules:
- Import the constant; never copy-paste the string value.
- When renaming, update the constant definition — the rest of the codebase follows automatically.
Theme System
How it works (end-to-end, no-flash)
- Request arrives →
getThemeMode()reads'color-scheme'cookie → returns{ colorScheme, defaultMode } - Root layout sets
data-mui-color-scheme={colorScheme}on<html>server-side <ColorSchemeScript />in<head>runs before any paint:- Reads the same cookie, sets
data-mui-color-scheme(handles edge cases where server attr might differ) - Patches
Storage.prototype— routes MUI'slocalStoragewrites for key'mode'to our cookie; reads returnnullso MUI always trusts thedefaultModeprop
- Reads the same cookie, sets
<MuiThemeProvider defaultMode={defaultMode}>mounts — uses the server-derived mode, not localStorageColorSchemeCookieSyncin ThemeProvider writes the cookie viauseColorScheme().colorSchemeon mount (safety net for first-visit system mode)
Critical MUI v9 rules
colorSchemeSelector must be the explicit attribute name:
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light
},
The shorthand 'data' in MUI v9 generates [data-%s] → data-dark="" / data-light="" (boolean attributes). Our tokens.css uses [data-mui-color-scheme="dark"] which never matches boolean attributes. Always use the explicit attribute name.
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. This prop is a no-op in browsers. The Storage.prototype patch in ColorSchemeScript is the correct intercept.
Never use MUI's InitColorSchemeScript:
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use ColorSchemeScript from @/theme instead.
MUI v9 localStorage key defaults (different 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 all of these via colorSchemeSelector: 'data-mui-color-scheme' in the theme and the Storage.prototype patch.
Color tokens
All theme-aware colors live in src/theme/tokens.css under [data-mui-color-scheme] selectors. Do not add color values to inline sx props or component styles — add a CSS variable to tokens.css and reference it via var(--my-token).
Pre-built theme objects
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.
Toggle components
DarkModeToggleButton and DarkModeFormSwitch in src/layout/components/DarkModeButton.tsx are the only components that subscribe to useColorScheme(). When the user toggles:
setMode('dark')is calledStorage.prototype.setItemintercept fires → writes'color-scheme'='dark'cookie synchronously- MUI sets
data-mui-color-scheme="dark"on<html> - CSS variables resolve → browser repaints. No React re-render above the button.
Use colorScheme (not mode) for the isDark check — mode can be 'system' even when dark is active.
Direction (RTL / LTR)
Derived from locale via getDirection(locale) in src/theme/direction.ts:
- RTL locales:
fa,ar,he,ur - All others:
ltr
ThemeProvider accepts a dir prop and selects the matching pre-built theme (APP_THEME_RTL for RTL). The RTL Emotion cache uses stylis-plugin-rtl to mirror all generated CSS.
The root layout sets dir={dir} on <html> and passes dir to ThemeProvider. Changing locale → new request → new dir without any client-side state.
Default locale is fa (RTL). The middleware redirects bare / to /fa/. English is explicitly accessed at /en/.
Fonts
Two fonts are loaded on every request (both CSS variables are always defined on <html>):
| Locale | Font | CSS variable | Source |
|---|---|---|---|
fa (RTL) |
Mikhak | --font-mikhak |
next/font/local — woff2 files in src/app/fonts/ |
en (LTR) |
Space Grotesk | --font-space-grotesk |
next/font/google |
Typography exports:
TYPOGRAPHY_LTR— Space Grotesk headings, system font body (used byAPP_THEME_LTR)TYPOGRAPHY_RTL— Mikhak for all text including body (used byAPP_THEME_RTL, ensures full Persian glyph coverage)TYPOGRAPHY— alias forTYPOGRAPHY_LTR(deprecated, prefer the explicit exports)
Rules:
- Both font variables are loaded unconditionally so the CSS stacks can always reference them.
- Font files live in
src/app/fonts/(notpublic/). next/font/local resolves paths relative to the calling file at build time. - Never load fonts inside components — all font loading lives in
src/app/layout.tsx. - To add a new font, add woff2 files to
src/app/fonts/, declare vialocalFontin layout.tsx, and updateBRAND_FONT_VARIABLE_*constants intypography.ts.
Unit Testing
Rule: every shared component must have a co-located test file.
A component is "shared" if it is imported from more than one place (page, layout, or other component).
Coverage baseline for shared components:
- It renders without crashing.
- Every documented prop produces the correct HTML attribute or CSS class.
- User interactions (click, change) call the expected callbacks.
Test location: src/components/ComponentName/ComponentName.test.tsx next to the component.
Test wrapper: wrap with <ThemeProvider> if the component uses MUI theming.
Do NOT mock MUI components — test against the rendered DOM.
Enforcement: before removing or renaming a shared component, check whether src/**/*.test.{ts,tsx} files import it. If so, update or delete those tests too.
Anti-patterns (do not do these)
- Do not read
localStorageordocument.cookiein render functions — useuseEffector server-sidecookies()fromnext/headers. - Do not call
createTheme()inside a component or hook — useAPP_THEME_LTR/APP_THEME_RTL. - Do not use
storageWindow={null}onMuiThemeProvider— it is silently ignored in MUI v9. - Do not use
InitColorSchemeScriptfrom MUI — useColorSchemeScriptfrom@/theme. - Do not set
colorSchemeSelector: 'data'— use'data-mui-color-scheme'. - Do not check
mode === 'dark'for "is dark active" — usecolorScheme === 'dark'. - Do not hard-code UI strings — add translation keys to both
messages/en.jsonandmessages/fa.json. - Do not put
NextIntlClientProvider,ThemeProvider, orAppStoreProviderin the root layout — bothheaders()andgetThemeMode()swallowDYNAMIC_SERVER_USAGE, so Next.js statically caches the root layout at build time withdefaultLocale('fa'). All/enrequests then receive the cached 'fa' messages. These providers belong in[locale]/layout.tsxwhere locale is sourced from URL params. - Do not call
getMessages()without passing{ locale }explicitly —getMessages({ locale })passes the locale directly togetRequestConfigviaPromise.resolve(locale), bypassing potential React.cache ordering issues. - Do not remove
setRequestLocale(locale)fromsrc/app/[locale]/layout.tsx— without it,getLocale()called by deeper server components always returnsdefaultLocale. - Do not add
notFound()tosrc/app/[locale]/layout.tsx— unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior. - Do not import
TYPOGRAPHY— useTYPOGRAPHY_LTRorTYPOGRAPHY_RTLexplicitly. - Do not load fonts inside components or pages — all next/font declarations belong in
src/app/layout.tsx. - Do not import
@/lib/cookies/serverin client components or@/lib/cookies/clientin RSCs. - Do not
document.title = titlein the render body of any component — it causesReferenceError: document is not definedduring build-time prerendering.