ui phase 0
This commit is contained in:
@@ -11,9 +11,11 @@ import { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
|
||||
/**
|
||||
* Writes the resolved color scheme to our cookie whenever MUI's state changes.
|
||||
* Acts as a safety net for the first-visit / system-mode case where the
|
||||
* Storage.prototype intercept in ColorSchemeScript has nothing to intercept yet
|
||||
* (no setItem call fires until the user explicitly toggles).
|
||||
* On a cookie-less first visit the server never learns the OS-resolved scheme
|
||||
* (tokens.css's `@media (prefers-color-scheme: dark)` fallback paints it, but
|
||||
* that's CSS-only — nothing tells the server) — this is what turns that visit
|
||||
* into a "returning visitor" with an explicit cookie next time, even before
|
||||
* the user ever manually toggles.
|
||||
*/
|
||||
function ColorSchemeCookieSync() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -26,6 +26,26 @@ export const BRAND = {
|
||||
tealSurface: '#16302a',
|
||||
} as const;
|
||||
|
||||
/*
|
||||
* Semantic feedback colors — mirrors tokens.css --bal-{success,error,warning,info}.
|
||||
* Feeds MUI's palette.{success,error,warning,info} so Alert/Chip/Badge/LinearProgress
|
||||
* render brand-harmonized instead of MUI's stock green/red — the same colors the
|
||||
* notistack toasts already use (NotistackProvider reads the CSS tokens directly).
|
||||
*/
|
||||
const LIGHT_FEEDBACK: PaletteOptions = {
|
||||
success: { main: '#1f6b50', contrastText: '#f3efe9' },
|
||||
error: { main: '#a8392a', contrastText: '#f3efe9' },
|
||||
warning: { main: '#8a6418', contrastText: '#f3efe9' },
|
||||
info: { main: '#1d4a40', contrastText: '#f3efe9' },
|
||||
};
|
||||
|
||||
const DARK_FEEDBACK: PaletteOptions = {
|
||||
success: { main: '#257659', contrastText: '#f3efe9' },
|
||||
error: { main: '#b5402f', contrastText: '#f3efe9' },
|
||||
warning: { main: '#97701f', contrastText: '#f3efe9' },
|
||||
info: { main: '#2f6b5e', contrastText: '#f3efe9' },
|
||||
};
|
||||
|
||||
export const LIGHT_PALETTE: PaletteOptions = {
|
||||
primary: {
|
||||
main: BRAND.teal,
|
||||
@@ -48,6 +68,7 @@ export const LIGHT_PALETTE: PaletteOptions = {
|
||||
secondary: '#5b655f',
|
||||
},
|
||||
divider: 'rgba(29, 74, 64, 0.14)',
|
||||
...LIGHT_FEEDBACK,
|
||||
};
|
||||
|
||||
export const DARK_PALETTE: PaletteOptions = {
|
||||
@@ -72,4 +93,5 @@ export const DARK_PALETTE: PaletteOptions = {
|
||||
secondary: '#9fb0a9',
|
||||
},
|
||||
divider: 'rgba(243, 239, 233, 0.14)',
|
||||
...DARK_FEEDBACK,
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { DARK_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY } from './typography';
|
||||
|
||||
export const DARK_THEME: ThemeOptions = {
|
||||
palette: { mode: 'dark', ...DARK_PALETTE },
|
||||
typography: TYPOGRAPHY,
|
||||
shape: { borderRadius: 10 },
|
||||
};
|
||||
|
||||
export default DARK_THEME;
|
||||
@@ -1,17 +1,10 @@
|
||||
import AppThemeProvider from './ThemeProvider';
|
||||
import { LIGHT_THEME } from './light';
|
||||
import { DARK_THEME } from './dark';
|
||||
import APP_THEME, { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
import { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
import { getDirection } from './direction';
|
||||
|
||||
|
||||
export {
|
||||
APP_THEME,
|
||||
APP_THEME_LTR,
|
||||
APP_THEME_RTL,
|
||||
LIGHT_THEME,
|
||||
DARK_THEME,
|
||||
LIGHT_THEME as default,
|
||||
AppThemeProvider as ThemeProvider,
|
||||
getDirection,
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { LIGHT_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY } from './typography';
|
||||
|
||||
export const LIGHT_THEME: ThemeOptions = {
|
||||
palette: { mode: 'light', ...LIGHT_PALETTE },
|
||||
typography: TYPOGRAPHY,
|
||||
shape: { borderRadius: 10 },
|
||||
};
|
||||
|
||||
export default LIGHT_THEME;
|
||||
+162
-3
@@ -1,7 +1,22 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { createTheme, responsiveFontSizes, Shadows } from '@mui/material/styles';
|
||||
import { LIGHT_PALETTE, DARK_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY_LTR, TYPOGRAPHY_RTL } from './typography';
|
||||
|
||||
/*
|
||||
* Teal-tinted elevation — replaces MUI's default grey shadow stack across
|
||||
* EVERY component that reads theme.shadows[n] (Paper, Card, Dialog, Menu,
|
||||
* Popover, AppBar elevation, Autocomplete…), not just the ones this file
|
||||
* overrides directly. Tiered onto the three --bal-shadow-* steps (tokens.css)
|
||||
* so low elevations (cards) stay subtle and high ones (dialogs, menus) read
|
||||
* clearly raised without ever going stock-MUI grey.
|
||||
*/
|
||||
const TEAL_SHADOWS = Array.from({ length: 25 }, (_, index) => {
|
||||
if (index === 0) return 'none';
|
||||
if (index <= 4) return 'var(--bal-shadow-1)';
|
||||
if (index <= 12) return 'var(--bal-shadow-2)';
|
||||
return 'var(--bal-shadow-3)';
|
||||
}) as unknown as Shadows;
|
||||
|
||||
/*
|
||||
* Theme factory — called twice at module load (ltr + rtl) so components can
|
||||
* pick the right theme without re-creating it on every render.
|
||||
@@ -15,9 +30,13 @@ import { TYPOGRAPHY_LTR, TYPOGRAPHY_RTL } from './typography';
|
||||
* → MUI flips inline-start/end padding for components that care.
|
||||
* → ThemeProvider pairs this with a direction-aware Emotion cache that
|
||||
* runs stylis-plugin-rtl so all generated CSS is mirrored correctly.
|
||||
*
|
||||
* Every override below references `var(--bal-*)` tokens (tokens.css) — never
|
||||
* a raw hex — and only logical CSS properties, so the same override is
|
||||
* correct in both APP_THEME_LTR and APP_THEME_RTL.
|
||||
*/
|
||||
function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
return createTheme({
|
||||
const theme = createTheme({
|
||||
cssVariables: {
|
||||
// MUI v9: 'data' produces boolean attrs (data-dark / data-light) which our
|
||||
// CSS never matches. An explicit attribute name produces the keyed form:
|
||||
@@ -30,9 +49,149 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
},
|
||||
defaultColorScheme: 'light',
|
||||
typography: direction === 'rtl' ? TYPOGRAPHY_RTL : TYPOGRAPHY_LTR,
|
||||
shape: { borderRadius: 10 },
|
||||
shape: { borderRadius: 10 }, // house radius — mirrors --bal-radius-md
|
||||
direction,
|
||||
shadows: TEAL_SHADOWS,
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
// Today exactly one hand-rolled :focus-visible rule exists in the app
|
||||
// (NurseResultCard). This makes keyboard focus uniform everywhere.
|
||||
'*:focus-visible': {
|
||||
outline: '2px solid var(--bal-focus-ring)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
paddingInline: 20,
|
||||
paddingBlock: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
border: '1px solid var(--bal-divider)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
defaultProps: { color: 'transparent', elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: 'var(--bal-bg-paper)',
|
||||
backgroundImage: 'none',
|
||||
color: 'var(--bal-text-primary)',
|
||||
borderBottom: '1px solid var(--bal-divider)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--bal-divider)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'var(--bal-primary)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { fontWeight: 500, borderRadius: 'var(--bal-radius-sm)' },
|
||||
outlined: { borderColor: 'var(--bal-divider)' },
|
||||
filled: ({ ownerState }: { ownerState: { color?: string } }) =>
|
||||
!ownerState.color || ownerState.color === 'default'
|
||||
? { backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
border: '1px solid var(--bal-divider)',
|
||||
textTransform: 'none',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
color: 'var(--bal-primary)',
|
||||
},
|
||||
'&.Mui-selected:hover': { backgroundColor: 'var(--bal-primary-soft)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
color: 'var(--bal-primary)',
|
||||
},
|
||||
'&.Mui-selected:hover': { backgroundColor: 'var(--bal-primary-soft)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 'var(--bal-radius-lg)',
|
||||
margin: 16,
|
||||
width: 'calc(100% - 32px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
height: 3,
|
||||
borderRadius: '3px 3px 0 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: { minHeight: 48, textTransform: 'none' },
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 'var(--bal-radius-sm)' },
|
||||
},
|
||||
},
|
||||
MuiStepIcon: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: 'var(--bal-divider)',
|
||||
'&.Mui-active': { color: 'var(--bal-primary)' },
|
||||
'&.Mui-completed': { color: 'var(--bal-primary)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSkeleton: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: 'var(--bal-primary-soft)' },
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
backgroundColor: 'var(--bal-text-primary)',
|
||||
color: 'var(--bal-bg-paper)',
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
arrow: { color: 'var(--bal-text-primary)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return responsiveFontSizes(theme);
|
||||
}
|
||||
|
||||
export const APP_THEME_LTR = createAppTheme('ltr');
|
||||
|
||||
+130
-8
@@ -1,23 +1,57 @@
|
||||
/*
|
||||
* Balinyaar brand color tokens — source of truth for all colors.
|
||||
*
|
||||
* Palette extracted from the seed-deck proposal (balinyaar.html):
|
||||
* Brand identity (construction + rationale): .claude/skills/frontend-designer/SKILL.md.
|
||||
* #1d4a40 deep teal / forest-green → brand primary
|
||||
* #d98c6a terracotta → brand accent (secondary)
|
||||
* #f3efe9 soft cream → text-on-dark / glyph fill
|
||||
* #faf9f5 cream → page surface
|
||||
*
|
||||
* MUI sets data-mui-color-scheme="dark" on <html> when dark mode is active
|
||||
* (colorSchemeSelector: 'data' in createTheme). The same attribute drives
|
||||
* every custom CSS rule in this file. You never need to read or write
|
||||
* this attribute manually — MUI's useColorScheme() does it for you.
|
||||
* (colorSchemeSelector: 'data-mui-color-scheme' in createTheme). The same
|
||||
* attribute drives every custom CSS rule in this file. You never need to
|
||||
* read or write this attribute manually — MUI's useColorScheme() does it for
|
||||
* you at runtime.
|
||||
*
|
||||
* No-flash boot (no JS): when a returning visitor's `color-scheme` cookie is
|
||||
* present, the server stamps the attribute directly and the explicit
|
||||
* `[data-mui-color-scheme]` blocks below apply immediately — zero flash. On a
|
||||
* cookie-less FIRST visit the server renders <html> WITHOUT the attribute
|
||||
* (see lib/cookies/server.ts getThemeMode()), so the `@media
|
||||
* (prefers-color-scheme: dark)` block below — scoped to the attribute being
|
||||
* absent — paints the OS-preferred scheme immediately, with no script. Once
|
||||
* MUI hydrates (ThemeProvider defaultMode="system") it resolves the same
|
||||
* media query and stamps the attribute itself; the values already match, so
|
||||
* there is nothing to visibly flip. The dark values therefore appear twice
|
||||
* below (the media-query fallback + the explicit attribute block) — keep
|
||||
* both in sync when changing a dark-scheme value.
|
||||
*
|
||||
* Usage in custom CSS outside MUI:
|
||||
* color: var(--bal-primary); ← resolves to the active scheme value
|
||||
* background: var(--bal-bg-default);
|
||||
*
|
||||
* Sync rule: any color added/changed here also lands in colors.ts (LIGHT_PALETTE/
|
||||
* DARK_PALETTE) when it's palette-level (success/error/warning/info, primary,
|
||||
* secondary…). Non-palette tokens (elevation, radius, motion, focus, rating,
|
||||
* trust, money-emphasis) are define-only here — colors.ts never needs them.
|
||||
*/
|
||||
|
||||
/* ── Light scheme (default) ────────────────────────────────────────────── */
|
||||
/* ── Scheme-independent tokens (radius scale, motion) ──────────────────── */
|
||||
:root {
|
||||
/* Radius scale — controls / cards / dialogs. shape.borderRadius (theme.ts)
|
||||
mirrors --bal-radius-md; never invent a radius per component. */
|
||||
--bal-radius-sm: 4px;
|
||||
--bal-radius-md: 10px;
|
||||
--bal-radius-lg: 16px;
|
||||
|
||||
/* Motion — durations + easing, consumed by the phase-12 motion pass */
|
||||
--bal-motion-fast: 120ms;
|
||||
--bal-motion-base: 200ms;
|
||||
--bal-motion-slow: 300ms;
|
||||
--bal-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Light scheme — the unconditional default ──────────────────────────── */
|
||||
:root,
|
||||
[data-mui-color-scheme='light'] {
|
||||
/* Primary — deep teal */
|
||||
@@ -56,16 +90,86 @@
|
||||
--bal-warning-contrast: #f3efe9;
|
||||
--bal-info: #1d4a40;
|
||||
--bal-info-contrast: #f3efe9;
|
||||
|
||||
/* Elevation — teal-tinted shadow steps (replaces MUI's grey default stack).
|
||||
Consumed by theme.ts's `shadows` array + the MuiPaper/MuiDialog overrides. */
|
||||
--bal-shadow-1: 0 1px 2px rgba(29, 74, 64, 0.06), 0 1px 1px rgba(29, 74, 64, 0.04);
|
||||
--bal-shadow-2: 0 4px 12px rgba(29, 74, 64, 0.10), 0 2px 4px rgba(29, 74, 64, 0.06);
|
||||
--bal-shadow-3: 0 12px 32px rgba(29, 74, 64, 0.14), 0 4px 8px rgba(29, 74, 64, 0.08);
|
||||
|
||||
/* Focus ring — global :focus-visible treatment (MuiCssBaseline, theme.ts) */
|
||||
--bal-focus-ring: #1d4a40;
|
||||
|
||||
/* Rating stars — RatingInput's star fill/empty (retires the muddy --bal-warning use) */
|
||||
--bal-rating: #c8971e;
|
||||
--bal-rating-empty: rgba(29, 74, 64, 0.20);
|
||||
|
||||
/* Trust — a distinct identity for verified marks, separate from primary/success */
|
||||
--bal-trust: #2b6ca8;
|
||||
--bal-trust-soft: rgba(43, 108, 168, 0.12);
|
||||
|
||||
/* Money emphasis — AA-contrast-safe money-text color (terracotta itself fails
|
||||
AA at small sizes on white: #d98c6a ≈ 2:1). Never use --bal-secondary for
|
||||
small money text; use this instead. */
|
||||
--bal-money-emphasis: #8a4a2f;
|
||||
}
|
||||
|
||||
/* ── Dark scheme ────────────────────────────────────────────────────────── */
|
||||
/* ── Dark scheme — OS-preference fallback for the cookie-less first visit ── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-mui-color-scheme]) {
|
||||
--bal-primary: #6fc0ac;
|
||||
--bal-primary-light: #8fd2c1;
|
||||
--bal-primary-dark: #3f8a78;
|
||||
--bal-primary-contrast: #06120f;
|
||||
--bal-primary-soft: rgba(111, 192, 172, 0.16);
|
||||
|
||||
--bal-secondary: #e6a98a;
|
||||
--bal-secondary-light: #f0bfa3;
|
||||
--bal-secondary-dark: #d98c6a;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
--bal-secondary-soft: rgba(230, 169, 138, 0.18);
|
||||
|
||||
--bal-bg-default: #0f1c19;
|
||||
--bal-bg-paper: #16302a;
|
||||
|
||||
--bal-text-primary: #f3efe9;
|
||||
--bal-text-secondary: #9fb0a9;
|
||||
|
||||
--bal-divider: rgba(243, 239, 233, 0.14);
|
||||
|
||||
--bal-success: #257659;
|
||||
--bal-success-contrast: #f3efe9;
|
||||
--bal-error: #b5402f;
|
||||
--bal-error-contrast: #f3efe9;
|
||||
--bal-warning: #97701f;
|
||||
--bal-warning-contrast: #f3efe9;
|
||||
--bal-info: #2f6b5e;
|
||||
--bal-info-contrast: #f3efe9;
|
||||
|
||||
--bal-shadow-1: 0 1px 2px rgba(6, 18, 15, 0.24), 0 1px 1px rgba(6, 18, 15, 0.16);
|
||||
--bal-shadow-2: 0 4px 12px rgba(6, 18, 15, 0.32), 0 2px 4px rgba(6, 18, 15, 0.20);
|
||||
--bal-shadow-3: 0 12px 32px rgba(6, 18, 15, 0.40), 0 4px 8px rgba(6, 18, 15, 0.24);
|
||||
|
||||
--bal-focus-ring: #6fc0ac;
|
||||
|
||||
--bal-rating: #e0b542;
|
||||
--bal-rating-empty: rgba(243, 239, 233, 0.20);
|
||||
|
||||
--bal-trust: #7fb8e6;
|
||||
--bal-trust-soft: rgba(127, 184, 230, 0.18);
|
||||
|
||||
--bal-money-emphasis: #f0bfa3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark scheme — explicit (cookie present, or after MUI hydrates/toggles).
|
||||
Keep every value identical to the @media block above. ──────────────── */
|
||||
[data-mui-color-scheme='dark'] {
|
||||
/* Primary — lifted teal (readable on dark surfaces) */
|
||||
--bal-primary: #6fc0ac;
|
||||
--bal-primary-light: #8fd2c1;
|
||||
--bal-primary-dark: #3f8a78;
|
||||
--bal-primary-contrast: #06120f;
|
||||
/* Soft primary tint — selected chips, subtle info panels */
|
||||
--bal-primary-soft: rgba(111, 192, 172, 0.16);
|
||||
|
||||
/* Secondary — warm terracotta-light */
|
||||
@@ -73,7 +177,6 @@
|
||||
--bal-secondary-light: #f0bfa3;
|
||||
--bal-secondary-dark: #d98c6a;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
/* Soft terracotta tint — the "نمای پرستار" nurse-view chip + EVV/financial affordances */
|
||||
--bal-secondary-soft: rgba(230, 169, 138, 0.18);
|
||||
|
||||
/* Surfaces — deep teal */
|
||||
@@ -96,4 +199,23 @@
|
||||
--bal-warning-contrast: #f3efe9;
|
||||
--bal-info: #2f6b5e;
|
||||
--bal-info-contrast: #f3efe9;
|
||||
|
||||
/* Elevation — black-teal shadow steps (deeper + darker than light scheme) */
|
||||
--bal-shadow-1: 0 1px 2px rgba(6, 18, 15, 0.24), 0 1px 1px rgba(6, 18, 15, 0.16);
|
||||
--bal-shadow-2: 0 4px 12px rgba(6, 18, 15, 0.32), 0 2px 4px rgba(6, 18, 15, 0.20);
|
||||
--bal-shadow-3: 0 12px 32px rgba(6, 18, 15, 0.40), 0 4px 8px rgba(6, 18, 15, 0.24);
|
||||
|
||||
/* Focus ring */
|
||||
--bal-focus-ring: #6fc0ac;
|
||||
|
||||
/* Rating stars */
|
||||
--bal-rating: #e0b542;
|
||||
--bal-rating-empty: rgba(243, 239, 233, 0.20);
|
||||
|
||||
/* Trust */
|
||||
--bal-trust: #7fb8e6;
|
||||
--bal-trust-soft: rgba(127, 184, 230, 0.18);
|
||||
|
||||
/* Money emphasis — lifted warm tone, readable on dark surfaces */
|
||||
--bal-money-emphasis: #f0bfa3;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TypographyVariantsOptions } from '@mui/material';
|
||||
|
||||
/** Space Grotesk CSS variable. Not currently wired to a font loader; the LTR
|
||||
* stack falls back to the system fonts until it is added to the locale layout. */
|
||||
/** Space Grotesk CSS variable, wired via next/font/google in
|
||||
* src/app/[locale]/layout.tsx, attached to <html> only on `en` routes
|
||||
* (mirrors the Mikhak/`fa` pattern — never loaded in a component). */
|
||||
export const BRAND_FONT_VARIABLE_EN = '--font-space-grotesk';
|
||||
|
||||
/** CSS variable injected by next/font/local (Mikhak), attached to <html> only
|
||||
@@ -24,32 +25,82 @@ const DISPLAY_FONT_LTR = `var(${BRAND_FONT_VARIABLE_EN}), "Space Grotesk", ${SYS
|
||||
/** Persian display + body font with graceful fallbacks. */
|
||||
const DISPLAY_FONT_RTL = `var(${BRAND_FONT_VARIABLE_FA}), "Mikhak", "Tahoma", "Arial Unicode MS", sans-serif`;
|
||||
|
||||
const displayHeadingLtr = { fontFamily: DISPLAY_FONT_LTR, fontWeight: 700 } as const;
|
||||
const displayHeadingRtl = { fontFamily: DISPLAY_FONT_RTL, fontWeight: 700 } as const;
|
||||
/*
|
||||
* Weight system: both Mikhak (fa) and Space Grotesk (en) load only 400/500/700
|
||||
* (see the font declarations in app/[locale]/layout.tsx) — there is no 600
|
||||
* face in either family, so a theme/sx `fontWeight: 600` silently renders 700
|
||||
* (CSS font-matching resolves upward to the nearest loaded weight). This theme
|
||||
* therefore never requests 600: headings + buttons use 700, in-text emphasis
|
||||
* (subtitles, chip labels, stat emphasis) uses 500. Follow this convention in
|
||||
* new component styles instead of reintroducing 600.
|
||||
*/
|
||||
const WEIGHT_HEADING = 700;
|
||||
const WEIGHT_EMPHASIS = 500;
|
||||
const WEIGHT_BODY = 400;
|
||||
|
||||
/** LTR typography — Space Grotesk headings, system font body. */
|
||||
/*
|
||||
* Shared size/line-height scale (both directions). Replaces MUI's Latin-tuned
|
||||
* defaults (e.g. a 6rem h1, tight h4 line-height of 1.235) with sizes actually
|
||||
* used in the app and headroom for Persian ascenders/descenders. Multiplied
|
||||
* across breakpoints by responsiveFontSizes() in theme.ts — do not hand-roll
|
||||
* per-breakpoint sizes here.
|
||||
*/
|
||||
const SIZE_SCALE = {
|
||||
h1: { fontSize: '2.5rem', lineHeight: 1.45 },
|
||||
h2: { fontSize: '2.125rem', lineHeight: 1.45 },
|
||||
h3: { fontSize: '1.75rem', lineHeight: 1.45 },
|
||||
h4: { fontSize: '1.5rem', lineHeight: 1.45 },
|
||||
h5: { fontSize: '1.25rem', lineHeight: 1.45 },
|
||||
h6: { fontSize: '1.125rem', lineHeight: 1.4 },
|
||||
subtitle1: { fontSize: '1rem', lineHeight: 1.6 },
|
||||
subtitle2: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||
body1: { fontSize: '1rem', lineHeight: 1.7 },
|
||||
body2: { fontSize: '0.875rem', lineHeight: 1.7 },
|
||||
button: { fontSize: '0.9375rem', lineHeight: 1.5 },
|
||||
caption: { fontSize: '0.75rem', lineHeight: 1.6 },
|
||||
overline: { fontSize: '0.75rem', lineHeight: 1.6 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @param headingFamily font for h1–h6 + button (the display/brand voice)
|
||||
* @param bodyFamily font for subtitle/body/caption/overline
|
||||
* @param extra shared overrides (e.g. letterSpacing) applied to every variant
|
||||
*/
|
||||
function buildScale(headingFamily: string, bodyFamily: string, extra?: { letterSpacing: number }) {
|
||||
const heading = extra ? { fontFamily: headingFamily, ...extra } : { fontFamily: headingFamily };
|
||||
const body = extra ? { fontFamily: bodyFamily, ...extra } : { fontFamily: bodyFamily };
|
||||
return {
|
||||
h1: { ...heading, ...SIZE_SCALE.h1, fontWeight: WEIGHT_HEADING },
|
||||
h2: { ...heading, ...SIZE_SCALE.h2, fontWeight: WEIGHT_HEADING },
|
||||
h3: { ...heading, ...SIZE_SCALE.h3, fontWeight: WEIGHT_HEADING },
|
||||
h4: { ...heading, ...SIZE_SCALE.h4, fontWeight: WEIGHT_HEADING },
|
||||
h5: { ...heading, ...SIZE_SCALE.h5, fontWeight: WEIGHT_HEADING },
|
||||
h6: { ...heading, ...SIZE_SCALE.h6, fontWeight: WEIGHT_HEADING },
|
||||
subtitle1: { ...body, ...SIZE_SCALE.subtitle1, fontWeight: WEIGHT_EMPHASIS },
|
||||
subtitle2: { ...body, ...SIZE_SCALE.subtitle2, fontWeight: WEIGHT_EMPHASIS },
|
||||
body1: { ...body, ...SIZE_SCALE.body1, fontWeight: WEIGHT_BODY },
|
||||
body2: { ...body, ...SIZE_SCALE.body2, fontWeight: WEIGHT_BODY },
|
||||
button: { ...heading, ...SIZE_SCALE.button, fontWeight: WEIGHT_HEADING, textTransform: 'none' as const },
|
||||
caption: { ...body, ...SIZE_SCALE.caption, fontWeight: WEIGHT_BODY },
|
||||
overline: { ...body, ...SIZE_SCALE.overline, fontWeight: WEIGHT_EMPHASIS },
|
||||
};
|
||||
}
|
||||
|
||||
/** LTR typography — Space Grotesk headings, system font body (unchanged split;
|
||||
* MUI's Latin letter-spacing defaults are fine as-is, so `extra` is omitted). */
|
||||
export const TYPOGRAPHY_LTR: TypographyVariantsOptions = {
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
h1: displayHeadingLtr,
|
||||
h2: displayHeadingLtr,
|
||||
h3: displayHeadingLtr,
|
||||
h4: displayHeadingLtr,
|
||||
h5: displayHeadingLtr,
|
||||
h6: { ...displayHeadingLtr, fontWeight: 600 },
|
||||
button: { fontWeight: 600, textTransform: 'none' },
|
||||
...buildScale(DISPLAY_FONT_LTR, SYSTEM_FONT_STACK),
|
||||
};
|
||||
|
||||
/** RTL typography — Mikhak for all text (headings + body) to ensure full Persian glyph coverage. */
|
||||
/**
|
||||
* RTL typography — Mikhak for all text (headings + body) to ensure full Persian
|
||||
* glyph coverage. `letterSpacing: 0` on every variant: Persian is a joined
|
||||
* (cursive) script and any tracking breaks Mikhak's glyph connections — MUI's
|
||||
* Latin defaults set non-zero spacing on body1/body2/button/caption/overline,
|
||||
* which is wrong here.
|
||||
*/
|
||||
export const TYPOGRAPHY_RTL: TypographyVariantsOptions = {
|
||||
fontFamily: DISPLAY_FONT_RTL,
|
||||
h1: displayHeadingRtl,
|
||||
h2: displayHeadingRtl,
|
||||
h3: displayHeadingRtl,
|
||||
h4: displayHeadingRtl,
|
||||
h5: displayHeadingRtl,
|
||||
h6: { ...displayHeadingRtl, fontWeight: 600 },
|
||||
button: { fontWeight: 600, textTransform: 'none' },
|
||||
...buildScale(DISPLAY_FONT_RTL, DISPLAY_FONT_RTL, { letterSpacing: 0 }),
|
||||
};
|
||||
|
||||
/** @deprecated use TYPOGRAPHY_LTR or TYPOGRAPHY_RTL */
|
||||
export const TYPOGRAPHY = TYPOGRAPHY_LTR;
|
||||
|
||||
Reference in New Issue
Block a user