Skip to Content

← All archived intake epics

Intake: view-as-floating-control

breakdown.md

Breakdown: One floating View As control for the demo walkthrough

  • scope-slug: view-as-floating-control · source: Jamie's request (2026-08-21); no scope.md behind this batch.
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • personas: Admin (the presenter driving the demo tenant) — the personas being emulated (CSM, SDM, Expert, Vendor, Customer) see nothing of this control

What I understood

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.

Settled before Define (Jamie, 2026-08-21)

  1. The floating control replaces both surfaces everywhere, not only on the demo tenant; existing guards stay as they are, and no second entry point survives.
  2. The active state does not collapse — it stays expanded and small (initials, short name, role badge). Discretion comes from the footprint, not from hiding the identity.
  3. Narrow viewports get a compact version of the same control, not a second layout.

Where it sits

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.

Build order

  1. view-as-floating-control — replace the sidebar entry and the top banner with one floating bottom-right control that starts, displays, switches and exits View As — depends-on: none

Out of scope (whole scope)

  • The emulation mechanism itself — the cookie, getViewAs, the admin gate, enterViewAs / exitViewAs, and what a given emulated identity may see. Presentation only.
  • Who appears in the roster and how it is ordered. demo-roster-logins settled that; this control renders the same ViewAsUserOption[] it is handed.
  • The demo storyline, the seeder and the nightly reset.
  • Any other floating or persistent chrome (a demo script prompter, a step tracker). If the bottom right becomes a home for more than this, that is its own scope.

_done/view-as-floating-control.md

Stub: One floating View As control that is both the button and the banner

  • feature-slug: view-as-floating-control
  • scope: view-as-floating-control
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 1 of 1

Problem

Driving 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.

Proposed change

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:

  • Idle (admin, not emulating): compact and quiet — an icon-only affordance that is legible but does not pull the eye, and that reveals itself properly on hover or focus. Opening it gives the same picker the sidebar entry gives today (the flat roster on the demo tenant; personas plus per-role user submenus elsewhere).
  • Active (emulating): the control shows who — name and role, always, without a hover or a click — and opens to the roster for a one-click switch to the next person plus the exit back to admin.

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.

Acceptance criteria (rough)

  • As an admin — on the demo tenant and on a real one — the control is present at the bottom right on every page of the app shell, in both the emulating and the non-emulating state.
  • While emulating, the emulated person's name and role are readable without hovering, clicking or scrolling, on every page.
  • Starting an emulation, switching to another person, and exiting back to admin are all reachable from the control alone.
  • The sidebar footer no longer carries a View As entry, and no full-width banner is rendered above the page content; page content gains back that vertical space.
  • The control does not obscure page content it would sit on top of — bottom-anchored page actions, table pagination and long-form content all remain usable underneath it.
  • The floating control is the only way into View As anywhere in the app — no other entry point, on any tenant, for any admin.
  • Emulating on a non-demo tenant still presents unmistakably as an audit warning rather than as a presenter tool, and cannot be collapsed out of the way.
  • The admin-only and own-tenant guards behave exactly as before, and enter / exit audit entries are still recorded on every tenant.
  • Nothing renders for a non-admin, and nothing renders for an admin whose switcher data does not resolve.
  • The control is keyboard reachable and operable, and a persona change is announced to assistive technology.
  • A failed switch (the person was removed or re-roled since the list rendered) surfaces its error on the control instead of failing silently.

Out of scope (this feature)

  • Any change to the emulation mechanism: the cookie, the admin gate in getViewAsSwitcherData, the enterViewAs / exitViewAs server actions, and what an emulated identity may see all stay as they are.
  • Roster contents and ordering — the control renders the ViewAsUserOption[] it is handed.
  • Dragging, repositioning or persisting a user-chosen position for the control.
  • Making the bottom-right corner a general home for other floating chrome.
  • apps/demo, which is frozen (CLAUDE.md) and has no View As of its own.

Notes for Define

The three open questions the cut raised were settled by Jamie on 2026-08-21, before Define:

  1. Scope of the replacement — settled: everywhere, guards untouched. The premise that View As is demo-tenant-only does not hold (see Proposed change), so the instruction stands in its conditional form: leave the guards that are in place, and make this new view layer the only one available for the feature. Practically that means one control rendering both shapes, with the non-demo case in the warning treatment and always expanded, and no isDemo branch anywhere near the auth path.
  2. What the active state collapses to — settled: nothing. It stays expanded and gets its discretion from being small: an avatar or initials, a short name, a role badge. "Discreet" is a property of the footprint, not of hiding the identity, which is the one thing the presenter cannot afford to be wrong about mid-sentence.
  3. Narrow viewports — settled: stay compact below a breakpoint. No second layout. Demos are given on a laptop; the phone case only has to not be broken.

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.
  • Toasts are mounted 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.