24 KiB
UI Phase 0 — Design language & theme foundation
Mission: kill the default-MUI/starter look at the token + theme layer in one pass and establish the brand's visual system — a
theme.componentspass, a semantic palette, a real Persian type scale, one icon family, a designed Balinyaar logomark, extended tokens (elevation/motion/focus/rating/trust/money), and a purge of starter residue — so every later phase composes on a system that already looks designed. No page redesigns here: this phase changes what every screen inherits, not any screen's layout.Track: frontend · Depends on: — (first phase of the UI chain) · Unlocks: Phase 1, Phase 2, and every later phase — they all inherit the de-startered look built here. Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke the frontend-designer skill — both are mandatory.
1. Context — where this sits
Balinyaar's feature layer is unusually disciplined — 331 var(--bal-*) usages across 103 files, effectively
zero hard-coded hexes, correct RTL/Shamsi/BigInt-money plumbing — but the visual system underneath it is
still the karpolan react-mui starter. Diagnosed root causes (all verified in code):
createThemehas zerocomponentsoverrides —client/src/theme/theme.ts:19-36passes onlycssVariables/colorSchemes/typography/shape/direction; every Button/Card/TextField/AppBar/Chip/ Dialog/Alert renders stock Material with recolored primaries. The single biggest cause of the starter feel.- No semantic palette —
theme/colors.tsdefines only primary/secondary/background/text/divider, so inline<Alert severity>surfaces render stock MUI green/red while toasts (lib/toast/NotistackProvider.tsx) use the brand--bal-*feedback tokens — two feedback systems for one semantic state. Worse, a bare<AppAlert>defaults to a filled stock-red error (components/config.ts:10-11). - Persian typography is Roboto metrics —
TYPOGRAPHY_RTL(theme/typography.ts:43-52) sets onlyfontFamily+ weights: non-zero letter-spacing on a joined script, tight heading leading, and a requested weight 600 (h6, button) that the Mikhak loader (app/[locale]/layout.tsx:39-48, weights 400/500/700) renders as full Bold — repeated in 68fontWeight: 600sx usages across 42 files (verified). Space Grotesk (--font-space-grotesk) is declared for EN but never loaded. - The brand mark is the starter's Twemoji cartoon pencil —
AppIcon/config.ts:115registerslogo: PencilIcon;components/auth/BrandMark.tsx:21renders it at 56px on every auth screen and passescolor="var(--bal-primary)", which the hard-coded fills (#EA596E,#FFCC4D, …) silently ignore. - The icon system is broken and incoherent —
AppIcon.tsx:38-46passessizeas SVGwidth/heightattributes, which MUISvgIcon's1emclass CSS overrides, so everysize={14..56}call-site renders 24px. The ~87-entry registry mixes filled starter icons against*Outlinedfeature icons, carries eight dead entries (zero usages, verified), and has no back/chevron icon at all (onlyexpand). - Starter residue everywhere else:
AppButton'sDEFAULT_SX_VALUES = { margin: 1 }neutralized by 213m: 0occurrences across 82 files; deadtheme/light.ts/dark.tsexported fromtheme/index.tsincl.LIGHT_THEME as default(nothing else imports them); the 17-line starterglobals.css; the wrongCONTENT_MIN_WIDTHcomment (components/config.ts:5); unusedAppImage(all verified); a cookie-less OS-dark first visit paints light first (lib/cookies/server.ts:46returns'light', no pre-paint script exists — acknowledged doc drift inclient/CLAUDE.md);viewport.themeColorhard-coded to light teal (layout.tsx:50-52);public/site.webmanifeststill ships starter#000000/#ffffffand referencesimg/favicon/*.pngfiles that don't exist.
What already exists (do not rebuild):
- The two-layer token system —
theme/tokens.css(--bal-*, scheme-keyed ondata-mui-color-scheme) mirrored bytheme/colors.ts, sync rule in both headers. Extend it; never bypass it. - The MUI v9 RTL dual-theme setup (
APP_THEME_LTR/APP_THEME_RTLbuilt once at module load,stylis-plugin-rtlEmotion cache inThemeProvider.tsx) and the cookie-SSR color-scheme sync (getThemeMode()→data-mui-color-schemestamped server-side; explicitcolorSchemeSelector). - Per-locale font loading (Mikhak via
next/font/local,preload: false, attached only onfa), the AppIcon string registry with its snake_case domain names (the exact indirection that makes this phase's icon swap a config-level change), brand-styled notistack toasts, and the correct global decisions already made:shape.borderRadius: 10,textTransform: 'none', the lifted dark-teal palette.
2. Required reading (do this first)
- The audits: audit/theme-and-brand.md, audit/component-primitives.md, audit/cross-cutting-ux.md — the full file/line evidence and the Keep (do not regress) lists this phase must honor.
- ../../../.claude/skills/frontend-designer/SKILL.md — the design contract you are extending (invoke the skill; this phase also updates it — see 3.10).
client/CLAUDE.md— "Golden rules", "Theme System" (the cookie/no-flash machinery and the MUI v9 traps: explicitcolorSchemeSelector, neverInitColorSchemeScript, neverstorageWindow), and "Fonts".- Code, in this order: all of
client/src/theme/,components/config.ts,components/common/AppIcon/(+icons/PencilIcon.tsx),components/common/AppButton/AppButton.tsx,components/auth/BrandMark.tsx,lib/toast/NotistackProvider.tsx,app/[locale]/layout.tsx,app/globals.css,lib/cookies/server.ts(getThemeMode),public/site.webmanifest. product/overview/platform-summary.md— the tone (trust-first, calm, clinical-but-human) every visual decision here must serve.
3. Scope — build this
3.1 The theme.components pass (the highest-leverage change in the app)
Add a components block inside createAppTheme (theme/theme.ts) that encodes the brand once. Reference
colors as var(--bal-*) tokens (or theme.vars.palette.*) so every override is scheme-correct — never a
hex. Overrides must be direction-safe (logical properties only — both theme directions share them). Cover at
least: MuiButton (disableElevation, comfortable paddingInline, weight per the 3.3 decision, keep
textTransform: 'none'); MuiPaper/MuiCard (hairline 1px solid var(--bal-divider) border + soft
teal-tinted shadows from the 3.4 elevation tokens instead of MUI's grey stack on warm cream);
MuiAppBar (paper/cream surface + hairline bottom divider, not solid primary — shells are rebuilt in
phase 2, this stops the default bar screaming "starter" now); MuiOutlinedInput/MuiTextField (house
radius, calmer resting border, brand focus ring); MuiChip / MuiToggleButton(Group) / MuiListItemButton
(soft --bal-primary-soft/--bal-secondary-soft tonal fills; selected = primary-soft + primary text —
tokens already exist in both schemes); MuiDialog (radius 16, sane mobile margins); MuiTabs (thicker
indicator, comfortable min-height); MuiAlert (severities from the 3.2 semantic palette so inline alerts
match toasts); MuiStepper/MuiStepIcon (brand-colored active/completed steps — the trust flows live on
these); MuiSkeleton (warm tint per scheme); MuiTooltip (ink/cream inversion, house radius); and
MuiCssBaseline carrying a global :focus-visible treatment off --bal-focus-ring (2px ring + offset —
today exactly one :focus-visible style exists in the app, in NurseResultCard.tsx; after this,
keyboard focus is uniform everywhere).
3.2 Semantic palette — one feedback language
- Add
success/error/warning/info(main + contrastText, light and dark) toLIGHT_PALETTE/DARK_PALETTEincolors.ts, sourced from the existing--bal-*feedback values intokens.css(light:#1f6b50/#a8392a/#8a6418/#1d4a40; dark: the lifted set). Keep the two files mirror-synced. Alert,Chip color="success", Badge, LinearProgress become brand-harmonized automatically. - Fix the
AppAlertfootgun incomponents/config.ts: first make severity explicit at any call-site that genuinely means error, then change the defaults to a calm baseline (severity="info",variant="standard"on the new tinted severities — decide and document in the config file).
3.3 A real Persian type scale
In theme/typography.ts TYPOGRAPHY_RTL:
letterSpacing: 0on every variant — Persian is a joined script; tracking breaks Mikhak's glyph connections (MUI's Latin defaults set non-zero spacing on body1/body2/button/caption/overline).- Body line-height ≥ 1.7; headings ~1.4–1.5 (room for Persian ascenders/descenders) plus responsive heading sizes (MUI's default 6rem h1 is unusable; grep confirms zero h1/h2 usages today).
- Resolve the weight-600 problem. Mikhak loads 400/500/700 only, so every requested 600 silently renders
700. Decide: (a) adopt 500/700 semantics — map the theme's h6/button and the 68
fontWeight: 600sx usages across 42 files to loaded weights (recommended: 700 headings/buttons, 500 in-text emphasis; the sweep is mechanical) — or (b) add a genuine Mikhak DemiBold face tosrc/app/fonts/+ the loader. Pick one, do it fully, document the decision intypography.tsand the skill (3.10). - Settle the EN display font: wire Space Grotesk via
next/fontinapp/[locale]/layout.tsx(attached only onen, mirroring the Mikhak pattern,preload: false) or delete the deadBRAND_FONT_VARIABLE_ENvariable and its fallback stack. Decide and do it — no vaporware comment left.
3.4 Token extension — beyond color
Extend theme/tokens.css (both scheme blocks — always), mirroring palette-level values in colors.ts:
- Elevation/shadow tokens — 2–3 steps of teal-tinted shadows (light:
rgba(29,74,64,α)stacks; dark: black-teal), consumed by 3.1's Paper/Card/Dialog overrides. - Radius scale — document the house scale (e.g. 4 / 10 / 16: controls / cards / dialogs) as tokens or
constants next to
shape.borderRadius; stop inventing radii per component. - Motion tokens — durations (e.g. 120/200/300ms) + easings; defined once here, consumed by phase 12 —
and
--bal-focus-ring, the global focus ring used by 3.1's:focus-visibletreatment. --bal-rating+--bal-rating-empty— proper star gold + empty color per scheme (RatingInput's muddy--bal-warningstars are fixed in phase 1 using these);--bal-money-emphasis— a contrast-safe money-text emphasis per scheme, retiring terracotta as a small-text money color (#d98c6aon white fails AA contrast);--bal-trust+--bal-trust-soft— a distinct trust identity for verified marks (not generic primary/success), consumed by phases 1/4/8. All define-only here.- Update the stale
tokens.cssheader (it citesproduct/balinyaar.html, which no longer exists in the repo) to point at the frontend-designer skill as the brand source of truth (3.10).
3.5 A real brand mark — kill the pencil
- Design the Balinyaar logomark per the identity the skill records (the
product/balinyaar.htmlseed deck is gone from the repo — recreate from the tokens + the skill's description): deep-teal ground, cream lowercase glyph, a single terracotta dot. Build two SVGs underAppIcon/icons/: a monochromecurrentColorlogomark (socolor="var(--bal-primary)"finally works and it recolors in dark mode for free) and a full lockup (mark + wordmark) forBrandMark/auth. - Register the mark as
ICONS.logo(replacingPencilIcon), updatecomponents/auth/BrandMark.tsxto the new lockup, deleteicons/PencilIcon.tsx(the last hard-coded-hex SVG in the app), regeneratesrc/app/favicon.icofrom the mark, and fixpublic/site.webmanifest— brandtheme_color/background_color(currently starter#000000/#ffffff) and icon entries that actually exist (generate the referenced PNG sizes from the mark, or trim the manifest — today it points at missingimg/favicon/*.png).
3.6 Icon system — one family, working sizes, full vocabulary
- Fix the size bug in
AppIcon.tsx: drivefontSizeviastyle/sxinstead ofwidth/heightattributes (which MUISvgIcon's1emclass CSS beats) — all ~40 existingsize={14..56}call-sites start working simultaneously. Stop spreading the invalidsizeattribute onto the DOM<svg>; make the unknown-nameconsole.warndev-only; keep the custom-SVG path (the new logomark) scaling correctly. - Normalize the ~87-entry registry to ONE visual family — recommend the Rounded variants of
@mui/icons-material(zero new deps, warmer than the current filled/outlined mix, fits clinical-but-human). Aconfig.ts-only sweep thanks to the registry indirection. Delete the eight dead starter entries (daynight/night/day/visibilityon/visibilityoff/signup/login/settings— re-verify zero usages before each delete). - Add the missing vocabulary:
back/chevron_start(no directional nav glyph exists today exceptexpand),share,copy,phone(non-emergency call),navigate(directions),sort,attachment,star_half. Notecamera,calendar,wallet, andtune(filter) are already registered — they only need family normalization. Directional icons auto-mirror: registerback/chevron_startas the LTR glyph, mirrored via a[dir="rtl"]scaleX(-1)rule applied byAppIconfor a declaredDIRECTIONAL_ICONSset inconfig.ts; write the rule into the registry comment and the skill — later phases must not hand-roll flips.
3.7 AppButton de-startering
- Remove
DEFAULT_SX_VALUES(margin: 1), then sweep the now-no-op 213sx={{ m: 0 }}neutralizations across 82 files (mechanical: remove the key; dropsxwhen it becomes empty). Outer spacing becomes the parent's job (Stack/Box gaps). Clean the starter prop cruft: duplicatelabel/textprops (pick one, migrate the loser's call-sites), the false "Box around to specify margins" JSDoc, the// Missing propscomment, and theunderlinespread onto non-link buttons (AppButton.tsx:85). - Do not break the public API:
to/hrefauto-link composition, icon-namestartIcon/endIcon, and non-MUI-color-becomes-text-color all stay (238 call-sites). Leave thecolor='inherit'default as-is — changing every unspecified button's color is a per-screen decision for later phases. UpdateAppButton.test.tsx(and every other touched shared component's test) in the same change.
3.8 Starter residue purge
- Delete
theme/light.ts+theme/dark.tsand their exports fromtheme/index.ts(verified: nothing else imports them; dropLIGHT_THEME/DARK_THEME/APP_THEME/LIGHT_THEME as default— keepThemeProvider,getDirection,APP_THEME_LTR/RTL). Delete the deprecatedTYPOGRAPHYalias intypography.tsonce nothing imports it. - Rewrite
app/globals.cssas an intentional minimal base: keep thebox-sizingreset, dropmax-height: 100vhand themax-width: 100vw; overflow-x: hiddenmask, add::selectionin brand colors, and a commented decision on thea { color: inherit }reset (AppLink owns link affordance). - Fix the wrong
CONTENT_MIN_WIDTHcomment (components/config.ts:5), prune the starter commented-out-alternatives style there into owned decisions, and delete the unusedAppImage+ its test + barrel export (verified: zero product usages).
3.9 No-flash color-scheme boot
- A cookie-less OS-dark first visit paints light, then flips. Implement the
ColorSchemeScriptthatclient/CLAUDE.md's Theme System section already documents (acknowledged doc drift): an inline<head>script inapp/[locale]/layout.tsxthat reads thecolor-schemecookie, falls back tomatchMedia('(prefers-color-scheme: dark)')when absent, and setsdata-mui-color-schemebefore first paint — plus the documentedStorage.prototypepatch. Never MUI'sInitColorSchemeScript(banned inclient/CLAUDE.md— it reads localStorage, which diverges from the cookie). The script must agree withgetThemeMode()on every path. Switchviewport.themeColor(layout.tsx:50-52) to the media-query array form: light →BRAND.teal,(prefers-color-scheme: dark)→BRAND.tealDeep, so browser chrome matches the page in both schemes.
3.10 Keep the design contract honest
Update .claude/skills/frontend-designer/SKILL.md in the same change: the new tokens (elevation, radius,
motion, focus, rating, trust, money-emphasis) and when to use each; the type-scale + weight decision (its
"buttons weight 600" rule becomes wrong the moment 3.3 lands); the normalized icon family + directional-
mirroring rule + a corrected registered-icons note (it lists ~19 of ~87 icons and still names the dead
starter entries); and the brand-mark construction as the written source of truth now that
product/balinyaar.html is gone (fix its reference; tokens.css's header points here too).
(DEFERRED → phase 1): all new shared primitives — EmptyState/ErrorState, PageHeader, <Money>, Jalali
picker, StatusChip v2, RatingInput's star fix, skeleton twins, route-level loading.tsx/error.tsx/404.
(DEFERRED → phase 2): the shells — TopBar/SideBar chrome, UserInfo, nav grouping, SSR mobile-first flash.
(DEFERRED → phase 12): the app-wide motion pass that consumes 3.4's motion tokens.
4. Mocks & seams in this phase
None. This phase is pure client theming — no service seams, no mock flags. The chain's REQ posture: if a
backend gap surfaces (none is expected here), append a REQ entry to
../../shared-working-context/frontend/requests/for-backend.md
— REQ-001…038 are taken (verified: the tracker ends at REQ-038); number onward from REQ-039. UI stays
mock-tolerant behind the existing services/{domain} seams; never edit server/.
5. Critical rules you must not get wrong
- The two-layer token sync is law — every color added/changed lands in
tokens.css(both scheme blocks) andcolors.tsin the same commit — and zero hex regressions: feature code has effectively no hard-coded hexes; the theme pass must not reintroduce literals outside the theme layer (deletingPencilIconremoves the last offender). - Do not break the RTL dual-theme/Emotion setup (
APP_THEME_LTR/APP_THEME_RTLstay module-load-built;createTheme()never in a component; overrides use logical properties only) or the cookie-SSR color-scheme sync (colorSchemeSelectorstays the explicit'data-mui-color-scheme', never'data'; noInitColorSchemeScript; nostorageWindow). - The AppIcon registry indirection stays — keep the
<AppIcon icon="name">API, the snake_case domain names, the lowercase keys. AppButton's composition API stays (to/href→ AppLink, icon-namestartIcon/endIcon) — 238 call-sites; the margin removal is a default change, not an API change. - Keep the keep-lists. Notably:
textTransform: 'none', radius 10 as the house radius, the lifted dark-teal palette, per-locale font loading (Mikhak never ships to/en; mirror that discipline if you wire Space Grotesk), andNotistackProviderstaying token-driven. - Design-contract non-negotiables: any new user-facing string (e.g. the logo's aria/alt) goes in
both
messages/en.jsonandmessages/fa.json; MUI v9 API only; co-located*.test.tsxupdated for every touched shared component (AppIcon,AppButton,BrandMark, …); fetch/cookies rules untouched. - Update the skill and
client/CLAUDE.mdin the same change (3.10 + §8) — stale design docs are how the next phase reintroduces the starter.
6. Definition of Done
On top of the shared definition-of-done.md:
npm run checkgreen;npm run test:cigreen incl. updated tests for every touched shared component;en.json/fa.jsonin sync.theme.tshas acomponentsblock covering at least the 3.1 list; no override uses a raw hex or a physical direction property.LIGHT_PALETTE/DARK_PALETTEdefine success/error/warning/info; an inline<Alert severity="success">and a success toast are visibly the same color family in both schemes.TYPOGRAPHY_RTLhasletterSpacing: 0everywhere + the new line-heights and responsive heading sizes; nofontWeight: 600remains insrc(or a real 600 face is loaded — per the documented decision); the Space Grotesk question is resolved (wired or deleted).tokens.css(both blocks) carries elevation, radius, motion, focus-ring, rating, money-emphasis, and trust tokens, mirrored incolors.tswhere palette-level.ICONS.logois the new Balinyaar mark;PencilIcon.tsx,light.ts,dark.ts,AppImage, and the eight dead icon entries are deleted; favicon + webmanifest rebuilt with brand colors and only real files referenced.<AppIcon icon="verified" size={48} />actually renders 48px; the registry is one visual family;back/chevron_startmirror correctly underdir="rtl";AppButtonhas no default margin and zerosx={{ m: 0 }}neutralizations remain insrc.- First visit with OS dark preference and no cookie paints dark with no light flash; browser
theme-colormatches the scheme in both modes. - Visual verification on the four axes —
/fa+/en× light + dark — and mobile + desktop widths on the §7 walk, with screenshots in the report; skill +client/CLAUDE.mdupdated to match reality.
7. How to test (what a human can verify after this phase)
- Open
/fa/login(private window, OS set to dark, no cookies): the page paints dark immediately — no light flash — and shows the new Balinyaar logomark (not a pencil), recoloring correctly in dark mode. - On the customer home
/fa: buttons are flat (no elevation) with comfortable padding; cards show hairline borders + soft teal-tinted shadows (not grey); chips are soft-tinted; nothing renders stock-MUI grey/blue. Tab through: every focusable element shows the same 2px brand focus ring. - Visit
/fa/nurseand/fa/admin(seeded accounts): the top bar is a cream/paper surface with a hairline divider — not a solid teal slab; sidebar icons are one visual family (all Rounded); the selected nav item uses the soft-primary fill. - Trigger an inline alert (e.g. a form error state) and a toast on the same screen: both use the same brand-harmonized semantic colors, in light and dark.
- On
/fa, inspect Persian text: no letter-spacing gaps inside joined words, headings don't clip ascenders, bold shows a real weight hierarchy (not everything Bold). Compare/en: headings render per the 3.3 decision (Space Grotesk or the documented system stack). - Check the browser tab: the favicon is the new mark; toggling OS dark mode flips the browser chrome
(
theme-color) to the deep-teal value.
8. Hand off & document (close the phase)
- Update
client/CLAUDE.md: the Theme System section (ColorSchemeScriptnow real — remove the doc-drift note; new tokens; the weight decision), the theme entries in "Project Structure" (light.ts/dark.tsremoved, new icon assets), and the Fonts table if Space Grotesk was wired. Update.claude/skills/frontend-designer/SKILL.mdper 3.10 (part of the phase, not optional). - Write the frontend report at
dev/shared-working-context/reports/ui-phase-0-report.md: what changed at the theme layer, the type-scale + weight + icon-family decisions, before/after screenshots on the four axes, what later phases must know (new token names, directional-icon rule), and any REQs filed (expected: none). - Save a memory note per operating-rules §8: the theme/token/icon/brand decisions, the size-bug fix, the margin-default removal, and the no-flash boot mechanism — later UI phases build on all of them.