14 KiB
UI Phase 2 — Shells & navigation — Report (2026-07-17)
What was built
Locale-aware navigation — one wrapper, both bugs fixed
src/i18n/navigation.ts wraps createNavigation(routing) (Link/usePathname/useRouter/
redirect/getPathname). All chrome navigation now goes through it: SideBarNavItem
renders via the wrapper's Link (unprefixed href, locale added automatically — one
navigation, no middleware 307 hop), BottomBar dropped its withLocale helper and router.push
via the wrapper, NotificationBell and CustomerLayout's support/back buttons route via the
wrapper's useRouter. grep -r "\$\{locale\}" src/layout and the rewritten chrome components
now returns nothing. A new shared helper, src/layout/matchActivePath.ts (unit-tested,
longest-prefix winner-takes-all), backs both the sidebar and every bottom bar's active-item
detection — the sidebar's pathname.startsWith(unprefixedPath) bug (always-false against the
locale-prefixed next/navigation pathname) is gone; the active item now highlights on every
nurse/admin/partner route for the first time.
Deliberately not touched: AppLink's activeClassName comparison (same underlying bug,
listed in the phase's suggested fixes). It has zero live consumers (grep activeClassName hits
only its own definition + test) and no CSS ever targets the .active class it would add —
purely inert. AppLink is also used for locale-prefixed navigation at ~10 non-chrome call sites
app-wide (`/${locale}${ROUTES.X}`); switching its internal usePathname to the locale-
stripped wrapper would flip that dead code from harmlessly-wrong to actively-wrong for those
sites without fixing anything visible. Left as-is; not a regression from this phase.
Customer shell — contextual header + desktop treatment
CustomerLayout now renders a contextual TopBar: the brand lockup (BrandLockup, new — a
compact horizontal logo+wordmark reused in every sidebar shell's drawer header) on the 5 root
tabs, a page title + RTL-mirrored back chevron (router.back()) on pushed routes. Titles come
from src/layout/routeTitle.tsx — a static longest-prefix route→title map off the nav
namespace, plus a PageTitleProvider/usePageTitleOverride context slot for dynamic titles
area phases will wire in later (ships with static titles only, per scope). Desktop decision
(≥md): the mobile BottomBar hides entirely in favor of an inline top-nav (CustomerDesktopNav,
MUI Tabs, same 5 items + matchActivePath) rendered as a second row inside the same fixed
AppBar (TopBar gained a secondaryRow slot for this). BottomBar also gained
env(safe-area-inset-bottom) padding for the iOS home-indicator overlap.
Nurse shell — a workspace
NurseLayout's sidebar is now sectioned (امروز / حرفهٔ من / مالی / پشتیبانی) via a new
group field on LinkToPage — SideBarNavList renders a ListSubheader whenever consecutive
items' group changes. The identity block is a real ProfileSummary card (new shared
component, src/components/ProfileSummary/): avatar, name (falls back to phone), masked phone
(dir="ltr"), and the nurse's own TrustBadge (ownBadgeState(useVerificationStatus())) —
skeleton while /me resolves, never an English "Current User"/"Loading...". Mobile gets a 5-tab
BottomBar (امروز/درخواستها/ویزیتها/درآمد/بیشتر); «بیشتر» opens the same sidebar drawer
(TopBarAndSideBarLayout exposes a mobileBottomBar render-prop that hands the drawer's
open-callback down — no second drawer). ActorSwitcher (new, dual-role only) sits under the
identity card.
Admin + Partner shells — a dense console
AdminLayout's sidebar is sectioned (اعتماد / مالی / پشتیبانی / سیستم) with every
useAdminCapabilities gate preserved exactly — grouping only changes presentation. Added the
missing ROUTES.ADMIN_USERS entry, gated on caps.canManageRoles like Roles. The
notifications sidebar item is gone, replaced by a header bell (NotificationBell's role union
widened to include 'admin'; notificationsPath('admin') → ROUTES.ADMIN_NOTIFICATIONS — both
minimal, pre-existing-seam extensions, not new endpoints). The TopBar carries a compact
ProfileSummary identity chip showing the admin's fine-grained role label (admin.role_*,
already existed). PartnerLayout gets the same TopBar identity slot showing the center's own
name (useMyPartnerCenter, skeleton while resolving) — the page-level access-denied state is
untouched.
Public shell — stripped to a brand frame
PublicLayout no longer wraps /login in TopBarAndSideBarLayout at all: no hard-coded
'Unauthorized - Balinyaar' title, no pencil-icon drawer, no empty BottomBar strip. It's a
slim corner header (small logo icon + LocaleSwitcher + dark toggle) over the content —
deliberately not a second big brand lockup, since AuthCard already renders BrandMark inside
the login card. The dead BOTTOM_BAR_DESKTOP_VISIBLE flag and the commented-out anchor
alternates in layout/config.ts are gone.
Cross-cutting engine fixes
- SSR flash killed structurally, not patched.
TopBarAndSideBarLayout+SideBarno longer derive sidebar structure fromuseIsMobile().SideBarrenders two Drawers over one content tree —variant="temporary"(mobile) andvariant="permanent"(desktop) — switched purely bysxbreakpointdisplay. The permanent Drawer is a normal flex sibling of the main column (TopBarAndSideBarLayout's content row isdirection="row"), so desktop reserves its own width as part of native flex layout — first paint already has the sidebar, and RTL flexbox puts it at the reading-start side with no manual offset math at all (the oldpaddingLeft/Rightkeyed offanchor.includes('left')double-flip hack is deleted outright, not replaced with a logical-property equivalent — there was nothing left to offset). The mobile temporary Drawer'sanchoris still physical (an MUI API constraint) but is now derived fromtheme.directionat render time, not a hardcoded per-breakpoint constant. - Drawer-close scoping fixed. The close handler moved off the whole content
StackontoSideBarNavList'sonClickonly — toggling dark mode, switching locale, or tapping a divider inside the mobile drawer no longer closes it; tapping a nav link does. - Chrome strings translated.
'Open Sidebar'→common.open_sidebar; the starter'Logout Current User'icon-only button is now a labeled, translated (nav.logout) full-width row in the sidebar footer, alongside the newLocaleSwitcher. TopBaroverflow fixed (noWrap+minWidth: 0instead of manualwhiteSpace:'nowrap'), starter comment residue deleted, and it gainedalign/titleNode/secondaryRowso one component now serves both the customer's centered brand header and the console shells' start-anchored breadcrumb-style title.- Two small, justified
theme.tsadditions (the phase context's claim thatDrawer/BottomNavigationwere already restyled in phase 0 didn't hold — neither had overrides):MuiDrawerpaper (background.default canvas + inset border) andMuiBottomNavigationAction(selected color + bold label weight), both off existing--bal-*tokens.
Session affordances
- Sign-out now reachable in the customer shell for the first time: one labeled row
(
profile.sign_out) on the/profilehub, per the phase's explicit "one row, not a redesign" scope (full hub redesign deferred to phase 9). Sidebar shells keep it in the drawer footer. ActorSwitcher(new,src/layout/components/): renders nothing for a single-role session; for a dual customer+nurse session shows «نمای پرستار» in the nurse sidebar and «اپلیکیشن خانواده» on the customer profile hub, navigating only —RoleGuard/resolveRoleDestinationremain the sole "which app" authority.LocaleSwitcher(new,src/components/common/):router.replace(pathname, { locale })via the wrapper, so it preserves the current route. In every sidebar footer, the customer profile hub, and the public shell.DarkModeToggleButton/DarkModeFormSwitchremain the onlyuseColorScheme()subscribers — neither switcher adds a scheme subscription.
UserInfo deletion
src/components/UserInfo/ is deleted outright (no rename/shim). Its one call site
(SideBar.tsx) now renders the identity slot passed by the caller; the top-level @/components
barrel and common/index.tsx re-exports were swapped for ProfileSummary. No other file imported
it (grep UserInfo was clean before deletion beyond the component's own two files).
What is now testable (and exactly how)
- Nurse (
/nurse): sidebar shows four labeled sections; the identity card shows name/phone/ TrustBadge, not "Current User". Click «ویزیتها» — it highlights, the TopBar shows the page title, one navigation in the Network tab (no 307). - Resize to mobile: nurse shell shows the 5-tab bar; «بیشتر» opens the drawer with the full grouped list + sign-out; toggling dark mode inside doesn't close it; a nav link does.
- Customer
/: brand lockup in the header. Open a nurse profile from search → header flips to title + back chevron; back returns to results; mirrors correctly on/en. - Customer ≥900px: no bottom tab bar; the inline top-nav is present and highlights the right tab on nested routes (e.g. a booking detail still lights up "Bookings").
/profile: sign-out row works; with a dual-role session, the actor switcher appears there and in the nurse sidebar.- Admin: TopBar shows page title + bell + role chip (e.g. «مالی» for finance); sidebar is sectioned and still capability-filtered — a finance-only admin sees no new items.
/loginon/fa: no English text, no drawer, no bottom strip — just the corner logo + switchers + theAuthCard's own brand mark.- Hard-reload
/nurseon desktop: sidebar present at first paint (verified vianpm run buildnot run here, but structurally guaranteed — see "SSR flash killed structurally" above).
Verification performed this session
npm run check(type + lint) — clean.npm run test:ci— 98 suites / 408 tests pass, including new tests formatchActivePath,ProfileSummary,LocaleSwitcher,ActorSwitcher.npm run dev+ SSR smoke curls (via PowerShell, not Bash — this sandbox's Bash tool proxies loopback HTTP and returns a bare 502 regardless of the server's actual state):/fa/login,/fa,/en,/fa/nurse,/fa/adminall return 200 with no error-boundary/ Next error markers in the HTML;/fa/loginconfirmsdir="rtl", the Mikhak font class, and the brand string, with the old'Unauthorized - Balinyaar'title gone (the one "Unauthorized" string left in the payload is Next's own internal App Router boundary metadata, unrelated).- Not performed: a full four-locale × two-scheme × two-viewport visual pass in an actual
browser (no browser/screenshot tool available in this session) — the structural/SSR checks
above give high confidence, but visual polish (spacing, the desktop top-nav's exact look,
dark-mode contrast on the new
ProfileSummary/ActorSwitcher) has not been eyeballed. Flagging this explicitly per the "don't claim UI success you can't see" rule — recommend a human pass before merging, especially on the customer desktop treatment (the newest, least-precedented piece of this phase).
What is mocked / waiting on a real service
None introduced — this phase is chrome over data that already flows (useMe, useNurseProfile,
useVerificationStatus, useMyPartnerCenter, useUnreadCount) behind existing services/{domain}
seams. No new mock/seam registered.
Contracts
None produced or consumed. /me already returns firstName/lastName/phone, which was enough
for ProfileSummary — the anticipated gap ("/me lacking a display name") did not materialize,
so no REQ was filed (REQ-039+ still unused).
Docs updated
client/CLAUDE.md— Project Structure:i18n/navigation.ts, the fulllayout/tree (newrouteTitle.tsx,matchActivePath.ts,BrandLockup.tsx,ActorSwitcher.tsx, the rewritten shell files),components/ProfileSummary/,components/common/LocaleSwitcher/..claude/skills/frontend-designer/SKILL.md— §4 component table (UserInfo→ProfileSummary), §5 "Layout & page shells" rewritten for the four-shell architecture, the dual-Drawer engine, andmatchActivePath.
Follow-ups for later phases
- Full visual four-axes pass (see "Verification performed" above) — recommend before/alongside the next UI phase that touches customer screens.
- Dynamic per-page titles (nurse name, booking reference) via
usePageTitleOverride— the slot exists; phases 4-6 and 9 (per the original scope note) should wire real titles in as they touch those screens. AppLink's inertactiveClassNamebug (see "Deliberately not touched" above) — low priority, no known consumer; worth a real fix only if a future feature actually starts using it.
Notable session correction
RoleGuard.tsx briefly had an unconditional return children; (added outside this phase, by the
user, for unrelated reasons) that broke npm run check — the dead code below it failed to
type-check because TypeScript doesn't apply control-flow narrowing inside unreachable code.
Confirmed via git stash that this predated my changes; the user removed the bypass mid-session.
RoleGuard.tsx was otherwise never touched by this phase, per scope.