view-as-floating-controlbreakdown.mdscope.md behind
this batch.View As is the demo walkthrough's steering wheel, and today it is two separate surfaces. The way in
is a sidebar footer entry (ViewAsSwitcher), so starting or changing a persona means the presenter's
eye goes to the far left of the screen — and on the demo tenant the entry is a flat roster of the
tenant's people, which is a long list to open from a sidebar. The way out, and the only signal of who
you currently are, is a full-width strip pinned above the page content (ViewAsBanner), which eats a
row of vertical space on every page for the whole demo and reads as a system warning rather than a
presenter tool.
Jamie wants these collapsed into one floating control at the bottom right of the screen that is both the button and the display: it starts an emulation, it shows who is currently being viewed as, it switches to the next person, and it exits back to admin. The constraint is that it must stay discreet — a demo is a screen recording of the product, and a persistent chrome element competes with what the prospect is meant to be looking at — while the current identity must remain legible at a glance, because the presenter is narrating "now I'm the expert" and cannot be wrong about it.
This is presentation only. Nothing about how emulation is decided, gated or persisted changes: the
cookie, the admin-only gate in getViewAsSwitcherData, the enterViewAs/exitViewAs actions and the
presenter/non-presenter distinction all stay exactly as they are. It is one PR.
One correction to the framing the request came with: View As is admin-only but not demo-only.
enterViewAs gates on the real active role being admin and on an own-tenant lookup of the target, and
resolveSwitcherData hands a switcher to an admin on any tenant — tenant.isDemo only selects the
presenter shape over the standard one. So the new control is the single view layer for the feature on
every tenant, rendering the presenter shape on the demo tenant and the standard shape, in its warning
treatment, on a real one. The guards themselves are untouched.
Not a service-journey step — this is the demo environment's presentation layer
(technical/demo-environment) and the admin persona's own tooling. Touches
apps/web/components/view-as/, the sidebar footer in apps/web/components/sidebar/app-sidebar.tsx,
and the app shell in apps/web/app/(app)/layout.tsx.
getViewAs, the admin gate, enterViewAs /
exitViewAs, and what a given emulated identity may see. Presentation only.demo-roster-logins settled that; this control
renders the same ViewAsUserOption[] it is handed._done/view-as-floating-control.mdDriving a demo means starting an emulation, seeing who you currently are, moving to the next person and getting back to admin — and today those four things are split across two surfaces at opposite ends of the screen. The way in is a sidebar footer entry, so the presenter's cursor travels to the far left mid-sentence. The way out and the identity display is a full-width strip above the page content, which costs a row of vertical space on every page for the entire walkthrough and, in a screen share, reads as a system banner competing with the product being sold. Neither surface is where the presenter's attention is, and together they make a routine "let me show you this as the expert" more visible than the thing being shown.
One floating control, fixed to the bottom right of the app shell, that carries the whole View As interaction and nothing else. It has two resting states:
Being discreet is a property of the idle state and of the control's footprint; it is not a reason to hide the active identity. While an emulation is running the identity stays on screen.
The sidebar footer entry and the top banner both go away, so there is exactly one place View As lives and no second way in survives anywhere in the app.
This is the only view layer for the feature, on every tenant. View As is admin-only but not
demo-only: enterViewAs gates on the real active role being admin and on an own-tenant lookup of the
target, and resolveSwitcherData returns a switcher for any tenant — tenant.isDemo only chooses
which shape the menu takes and how the banner is styled. Those guards are not touched. What changes
is that both shapes are now rendered by the one floating control: the presenter shape on the demo
tenant, and on a real tenant the standard shape in its warning treatment and always expanded, because
that strip is an audit signal over real customer data rather than presenter chrome and must not be
softened into invisibility by a change made for the demo.
enter / exit audit
entries are still recorded on every tenant.getViewAsSwitcherData, the
enterViewAs / exitViewAs server actions, and what an emulated identity may see all stay as they
are.ViewAsUserOption[] it is handed.apps/demo, which is frozen (CLAUDE.md) and has no View As of its own.The three open questions the cut raised were settled by Jamie on 2026-08-21, before Define:
isDemo branch anywhere near the auth path.Ground already in place, so this is a re-wiring rather than new plumbing:
apps/web/app/(app)/layout.tsx resolves both getActiveViewAs() and getViewAsSwitcherData() on
every request already, and passes them to the banner and the sidebar respectively. The new control
needs both in one place, inside SidebarInset (positioned relative to the app shell, not the
sidebar) — the fetches themselves do not change.apps/web/components/view-as/presenter-persona-items.tsx is already the shared roster list behind
both surfaces, and its Radix constraint holds: the items must stay direct children of the menu
content for roving focus and typeahead.ViewAsBanner already carries the switch-failure handling, the pending state and role="status";
keep all three when the markup moves.position="top-right" (apps/web/app/layout.tsx), so the bottom right is
uncontested today.apps/web/lib/view-as-switcher-data.test.ts covers the data shaping, which this feature does not
touch — it should still pass untouched, which is a useful check that the change stayed in the view
layer.touches: apps/web/components/view-as/, apps/web/components/sidebar/app-sidebar.tsx, apps/web/app/(app)/layout.tsx.