14 KiB
UI Phase 0 — Design language & theme foundation — Report (2026-07-17)
What was built
theme.components pass (the highest-leverage change)
client/src/theme/theme.ts now carries a components block covering MuiCssBaseline
(global :focus-visible → 2px var(--bal-focus-ring)), MuiButton (disableElevation,
--bal-radius-sm, comfortable padding), MuiPaper (hairline var(--bal-divider) border,
backgroundImage: none), MuiAppBar (cream/paper surface + hairline bottom border,
color="transparent" default — no more solid teal slab), MuiOutlinedInput (house radius +
calmer resting border + primary hover), MuiChip (soft --bal-primary-soft fill for
default-color filled chips only — ownerState-scoped so color="success"/"error" chips
are untouched), MuiToggleButton/MuiListItemButton (.Mui-selected → primary-soft fill +
primary text), MuiDialog (--bal-radius-lg, tighter mobile margins), MuiTabs/MuiTab
(3px indicator, 48px min-height), MuiAlert (house radius — severities now come from the
palette, see below), MuiStepIcon (brand active/completed), MuiSkeleton (warm
primary-soft tint), MuiTooltip (ink/cream inversion). Every value is var(--bal-*) or a
logical CSS property — zero raw hex, zero physical direction props.
A theme.shadows array (TEAL_SHADOWS, 25 entries tiered onto --bal-shadow-1/2/3)
replaces MUI's default grey stack globally — every component that reads
theme.shadows[n] (Dialog, Menu, Popover, AppBar elevation, Autocomplete…) gets teal-tinted
shadows for free, not just the ones this file overrides directly. Both themes are wrapped in
responsiveFontSizes() for per-breakpoint heading scaling.
Semantic palette
LIGHT_PALETTE/DARK_PALETTE (colors.ts) now define success/error/warning/info
(main + contrastText), sourced from the same values as the --bal-* feedback tokens — an
inline <Alert severity="success"> and a success toast are now the same color family in
both schemes. AppAlert's defaults flipped from the footgun (severity="error" variant="filled") to a calm baseline (severity="info" variant="standard") — every
existing call-site already passed an explicit severity+variant, so this was a safe default
change with zero call-site migration needed (verified by grep before changing).
Persian type scale
typography.ts rewritten: letterSpacing: 0 on every TYPOGRAPHY_RTL variant, a shared
size/line-height scale (SIZE_SCALE — body line-height 1.7, headings 1.4–1.5, no more
6rem default h1), and the weight decision: 700 for headings/buttons, 500 for in-text
emphasis, 400 body — never 600 (neither Mikhak nor Space Grotesk loads a 600 face; a
requested 600 silently rendered full Bold). Swept 64 fontWeight: 600 sx call-sites
across 42 files to 500/700 by this rule (9→700, 55→500; judgment calls documented in the
commit — e.g. selection-card titles → 700, chip/row labels → 500). LTR keeps its original
split (Space Grotesk headings, system-font body); RTL keeps Mikhak everywhere (full glyph
coverage). Space Grotesk is now genuinely wired via next/font/google in
app/[locale]/layout.tsx (self-hosted at build time, preload: false, attached only on
en — mirrors the Mikhak/fa discipline exactly); the dead "not currently wired" comment
is gone.
Token extension (tokens.css + colors.ts mirror where palette-level)
Added, both scheme blocks: --bal-radius-sm/md/lg (4/10/16 — controls/cards/dialogs),
--bal-motion-fast/base/slow + --bal-easing-standard (for phase 12), --bal-shadow-1/2/3
(teal-tinted light, black-teal dark), --bal-focus-ring, --bal-rating/--bal-rating-empty
(retiring the muddy --bal-warning star fill — phase 1 wires this into RatingInput),
--bal-trust/--bal-trust-soft (a distinct blue identity for verified marks, separate from
primary/success — phases 1/4/8 consume this), --bal-money-emphasis (AA-contrast-safe;
--bal-secondary fails AA at small sizes on white, ~2:1). tokens.css's header now points
at the frontend-designer skill instead of the deleted product/balinyaar.html.
Brand mark — the pencil is gone
Two new SVGs under AppIcon/icons/: LogoMark.tsx (monochrome currentColor "b" glyph
— stem + a true evenodd-ring bowl, no background square — registered as ICONS.logo, used
anywhere via <AppIcon icon="logo">, recolors for free in dark mode) and LogoLockup.tsx
(the full-color mark: deep-teal rounded-square ground, cream glyph, terracotta dot, all
var(--bal-*)-driven) used directly by BrandMark.tsx next to the (still translated,
never-baked-into-SVG) wordmark. PencilIcon.tsx (the Twemoji starter pencil, the app's last
hard-coded-hex SVG) is deleted. Favicon + webmanifest regenerated from the same construction
via a one-off sharp script (fixed brand hex — the one place a literal hex is correct, since
it's a static binary asset, not app code): src/app/favicon.ico (real multi-size PNG-in-ICO,
16/32/48) and public/img/favicon/{16,32,48,180,192,512}.png all now render the actual mark;
site.webmanifest's theme_color/background_color are the real brand teal/cream (were
starter #000000/#ffffff), and every referenced icon file now genuinely exists.
Icon system
- Size bug fixed:
AppIcon.tsxdrives size viastyle.fontSize(which MUI SvgIcon's1em-based CSS actually respects) instead ofwidth/heightattributes (which that same CSS silently beat) — all ~40 existingsize={14..56}call-sites now render at the requested size. The invalidsizeDOM attribute is no longer spread onto the SVG; the unknown-iconconsole.warnis dev-only. - One visual family: the ~90-entry registry (
AppIcon/config.ts) is now 100% MUI*Rounded— every filled/outlined starter icon was swapped. The 8 dead starter entries (daynight/night/day/visibilityon/visibilityoff/signup/login/settings) are deleted (re-verified zero usages before deleting). - New vocabulary:
back/chevron_start,share,copy,phone,navigate,sort,attachment,star_half. - Directional mirroring:
back/chevron_startare registered in a newDIRECTIONAL_ICONSset;AppIconstampsdata-icon-directionalon those, and one CSS rule (app/globals.css) does the flip ([dir='rtl'] [data-icon-directional] { transform: scaleX(-1) }) — adding a future directional icon is a one-line registry addition, never a per-component flip.
AppButton de-startering
Removed DEFAULT_SX_VALUES = { margin: 1 } and the dead underline/label/text prop
cruft (text/label duplicate props stay — 238 call-sites use them — only the invalid
underline spread onto non-link buttons and the false "Box around to specify margins" JSDoc
were removed). Swept the now-redundant sx={{ m: 0 }} neutralization at 192 call sites
across ~68 files (6 parallel batches, verified against the original 213-occurrence/82-file
audit count — the ~21 difference is re-count timing, not missed sites); 3 genuine
non-AppButton m: 0 uses (on FormControlLabel, an unrelated real layout decision) were
correctly left alone. Outer spacing is now the parent's job (Stack/Box gaps), as intended.
Starter residue purge
Deleted theme/light.ts + theme/dark.ts (+ their theme/index.ts exports:
APP_THEME/LIGHT_THEME/DARK_THEME/LIGHT_THEME as default — nothing imported them) and
the deprecated TYPOGRAPHY alias. Deleted the unused AppImage component + its test +
barrel exports (zero product usages, verified). Rewrote globals.css as an intentional
minimal base: kept the box-sizing reset, dropped max-height: 100vh and the
max-width: 100vw; overflow-x: hidden mask, added ::selection in brand colors + the
directional-icon CSS rule, and a commented decision on the a { color: inherit } reset.
Fixed the false CONTENT_MIN_WIDTH comment and the starter commented-alternatives style in
components/config.ts.
No-flash color-scheme boot — CSS only, no boot script
The original phase spec called for an inline pre-paint <script> (a ColorSchemeScript,
matching what client/CLAUDE.md's Theme System section had — inaccurately — already
documented). Built, then deliberately reverted per explicit user direction mid-session:
a hand-rolled script (cookie parsing + Storage.prototype patching) didn't fit a codebase
where every other theme decision is CSS-variable-driven. The shipped mechanism is pure CSS:
- Returning visitor (cookie present): unchanged — server stamps
data-mui-color-scheme, the explicittokens.cssblocks match immediately. - First-ever visitor (no cookie):
getThemeMode()now returnscolorScheme: undefined, so the server renders<html>without the attribute at all.tokens.cssgained a@media (prefers-color-scheme: dark)block scoped to:root:not([data-mui-color-scheme])that paints the OS-preferred scheme immediately, zero JS. Once React hydrates,defaultMode="system"resolves the same media query and MUI stamps the attribute itself — the CSS values already match, so nothing visibly flips. - Known trade-off (documented in
client/CLAUDE.md): this covers every--bal-*token (the dominant visual surface — page/paper background, text, dividers, all brand colors). MUI's own--mui-palette-*variables don't get the same free fallback (MUI'scolorSchemeSelectorsupports attribute-based or'media'-based generation, not both), so a barecolor="primary"fill (e.g. a contained Button) can very briefly show the light value on a cookie-less dark-OS first visit before hydration — self-corrects same frame, never animates (disableTransitionOnChange). Acceptable trade-off given the alternative was a hand-rolled script the codebase's own conventions argue against.
viewport.themeColor is now the media-query array form (light → BRAND.teal, dark →
BRAND.tealDeep) so browser chrome matches the page in both schemes.
What is now testable (and exactly how)
npm run dev, open/fa/loginin a private window with OS dark on and no cookies: dark paint from first frame (no light flash on the--bal-*surface), the new Balinyaar mark (not a pencil) on the auth splash, recoloring correctly if you toggle dark/light./facustomer home: flat buttons with comfortable padding, hairline-bordered cards with soft teal shadows (not grey), soft-tinted chips. Tab through the page — every focusable element shows the same 2px brand focus ring./fa/nurseand/fa/admin(seeded accounts): top bar is a cream/paper surface with a hairline divider, not a solid teal slab; sidebar icons are one Rounded family; selected nav item uses the soft-primary fill.- Trigger an inline form-error
AppAlertand a toast on the same screen — same brand-harmonized color family, both schemes. /faPersian text: no letter-spacing gaps inside joined words, headings don't clip; compare/en— Space Grotesk headings render (network tab shows the self-hosted font file only on/en, never on/fa).- Browser tab: favicon is the new mark; toggling OS dark mode flips
theme-color(deep-teal) in supporting browsers. npm run check(type + lint) andnpm run test:ci— both green (see Gate below).
What is mocked / waiting on a real service
None — this phase is pure client theming, no service seams, no mock flags (per the phase's own §4).
Docs updated
.claude/skills/frontend-designer/SKILL.md— brand-mark construction (now the source of truth,product/balinyaar.htmlis gone), the new token categories + when to use each, the weight-500/700 decision (the old "buttons weight 600" line was wrong the moment this phase landed), the Rounded icon family + directional-mirroring rule + the size-bug fix, dropped the stale ~19-icon "currently registered" list (pointed atAppIcon/config.tsdirectly instead — it drifts too fast for a skill doc to enumerate reliably).client/CLAUDE.md— Theme System section rewritten for the CSS-only no-flash mechanism (replacing the doc-driftColorSchemeScriptdescription that never matched reality), the Fonts table (Space Grotesk now wired), thetheme/entries in Project Structure (light.ts/dark.tsremoved).
Follow-ups for later phases
- Phase 1: wire
--bal-rating/--bal-rating-emptyintoRatingInput(currently still uses--bal-warning), build EmptyState/ErrorState/PageHeader/<Money>/Jalali picker/skeleton twins/route-levelloading.tsx/error.tsx/404 — all deferred here per the phase's own scope boundary. - Phase 2: TopBar/SideBar chrome,
UserInfo, nav grouping, SSR mobile-first flash — the physicalpaddingLeft/paddingRightinTopBarAndSideBarLayout.tsxand the hard-coded English "Open Sidebar" tooltip are pre-existing shell issues, deliberately untouched here (no page/shell redesigns in phase 0). - Phase 4/8:
--bal-trust/--bal-trust-softare defined but not yet consumed byTrustBadge/verification UI — that's a content decision for those phases, not a token gap. - Phase 12: the app-wide motion pass consumes
--bal-motion-*/--bal-easing-standard, defined but unused here (define-only per this phase's scope). - No REQs filed — this phase never touches
server/or any contract.
Gate
npm run check(type + lint): green, zero errors/warnings.npm run test:ci: 329/329 tests, 79/79 suites green. Three pre-existing shared-component tests (AppIcon.test.tsx,AppIconButton.test.tsx,AppButton.test.tsx) asserted the exact bugs this phase fixes (an invalidsizeDOM attribute; the pre-Rounded-swapMoreHorizIcontestid) — updated in the same change to assert the corrected behavior (style.fontSize,MoreHorizRoundedIcon) rather than the old bug.en.json/fa.json: untouched — this phase added no new user-facing strings (the brand mark's alt text reuses the existingcommon.brandkey viaBrandMark's adjacent<Typography>; the SVG itself isaria-hidden).