Build the React consumer over the fail-closed view-mode logic so an admin
can view the app as each of the three homes (User/Admin/SysAdmin) and step
down to a plain User-Side home.
- ViewModeProvider holds the raw requested home (seeded from localStorage,
shape-checked only) and resolves it live against is_admin on every render,
so a demotion or transient /me failure collapses to the User home with no
flash, while an unentitled value is never stored or applied.
- RoleSwitcher renders only for admins (availableViewModes > 1); switching
re-gates the choice and navigates to the chosen home's root.
- AppShell drives its sidebar from sectionsForView(view, isAdmin), which only
ever narrows visibleSections — an admin viewing as a user sees a plain
user's sidebar and lands on the Dashboard at /.
- landingPathForView / viewModeLabelKey added to the logic layer (tested);
view_* and view_switch_label i18n keys added for en-US and zh-CN.
Placement note: the spec calls for a top-right avatar control, but the panel
has no desktop top bar, so the switcher lives in the sidebar foot beside the
user strip. Functionally complete; placement is not yet spec-parity.
Pure logic layer for the top-right avatar role-switcher: derive the home
a principal is in and may switch into, mirroring nav.ts/auth.ts so the
decision is unit-tested without a React renderer.
- ViewMode is derived from NavSection["id"], so the three switchable
homes (User/Admin/SysAdmin) can never drift from the nav sections.
- availableViewModes / effectiveViewMode resolve a requested view against
the live is_admin flag, failing closed: a non-admin or a demoted admin
always collapses to the User home.
- restoreViewMode re-gates a persisted (localStorage) choice on every
read, never trusting the stored value over the live flag, closing the
one escalation vector a client-side persona could open.
- sectionsForView composes the view ceiling on top of visibleSections, so
the switcher only ever narrows the sidebar, never widens access.
The avatar dropdown UI that consumes this lands as a separate increment.