# 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.tsx` drives size via `style.fontSize` (which MUI SvgIcon's
  `1em`-based CSS actually respects) instead of `width`/`height` *attributes* (which that
  same CSS silently beat) — all ~40 existing `size={14..56}` call-sites now render at the
  requested size. The invalid `size` DOM attribute is no longer spread onto the SVG; the
  unknown-icon `console.warn` is 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_start` are registered in a new
  `DIRECTIONAL_ICONS` set; `AppIcon` stamps `data-icon-directional` on 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 explicit `tokens.css` blocks match immediately.
- First-ever visitor (no cookie): `getThemeMode()` now returns `colorScheme: undefined`, so
  the server renders `<html>` **without** the attribute at all. `tokens.css` gained 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's
  `colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both),
  so a bare `color="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)
1. `npm run dev`, open `/fa/login` in 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.
2. `/fa` customer 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.
3. `/fa/nurse` and `/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.
4. Trigger an inline form-error `AppAlert` and a toast on the same screen — same
   brand-harmonized color family, both schemes.
5. `/fa` Persian 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`).
6. Browser tab: favicon is the new mark; toggling OS dark mode flips `theme-color`
   (deep-teal) in supporting browsers.
7. `npm run check` (type + lint) and `npm 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.html` is 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 at `AppIcon/config.ts` directly
  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-drift `ColorSchemeScript` description that never matched reality), the
  Fonts table (Space Grotesk now wired), the `theme/` entries in Project Structure
  (`light.ts`/`dark.ts` removed).

## Follow-ups for later phases
- Phase 1: wire `--bal-rating`/`--bal-rating-empty` into `RatingInput` (currently still uses
  `--bal-warning`), build EmptyState/ErrorState/PageHeader/`<Money>`/Jalali picker/skeleton
  twins/route-level `loading.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
  physical `paddingLeft`/`paddingRight` in `TopBarAndSideBarLayout.tsx` and 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-soft` are defined but not yet consumed by
  `TrustBadge`/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 invalid `size` DOM attribute; the pre-Rounded-swap
  `MoreHorizIcon` testid) — 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 existing `common.brand` key via `BrandMark`'s adjacent
  `<Typography>`; the SVG itself is `aria-hidden`).
