Skip to Content

← All archived runs

Run: view-as-floating-control

run.md

Run: view-as-floating-control

  • branch: claude/pipeline-view-as-floating-control-xbtyaj
  • pr: #874

02_define/output/spec.md

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

  • slug: view-as-floating-control
  • personas: Admin
  • touches: apps/web/app/(app)/layout.tsx, apps/web/components/view-as/, apps/web/components/sidebar/app-sidebar.tsx
  • complexity: standard

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 (ViewAsSwitcher), so the presenter's cursor travels to the far left mid-sentence. The way out and the identity display is ViewAsBanner, 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. This is the demo walkthrough's steering wheel, and the walkthrough is how we prove the product to vendor partners — the initiative is Refine the bridge, objective Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners.

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 (data.presenter), 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 strip 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 (the existing amber border-amber-500/40 bg-amber-500/15 register) 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.

Behavioural detail settled for this spec, on top of what the stub already settled:

  • Placement. The control mounts inside SidebarInset in apps/web/app/(app)/layout.tsx, fixed to the bottom right of the app shell — positioned against the shell, not the sidebar, and outside the page's scroll region so it does not travel with the content. Both getActiveViewAs() and getViewAsSwitcherData() are already resolved in that layout on every request; the control takes both. The fetches do not change.
  • Onboarding pages. The control renders on /onboarding/* in both states, like every other page of the app shell. It does not inherit the sidebar's isOnboarding suppression — that condition stays where it is, governing the nav items the concierge hides, and the View As entry simply stops being one of them.
  • Narrow viewports. Below a breakpoint the control stays compact — the same control, smaller, not a second layout. The active identity remains readable.
  • Menu internals. PresenterPersonaItems stays the shared roster list and its Radix constraint holds: items remain direct children of the menu content, so roving focus and typeahead survive the move.
  • What moves with the markup. ViewAsBanner's switch-failure handling (a returned enterViewAs result is a failure, since success redirects), its isPending disabling, and its role="status" live announcement all move into the control unchanged in behaviour.

Acceptance criteria

  • 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, including /onboarding and its sub-pages.
  • 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.
  • Idle, the control is icon-only and quiet, and reveals its label on hover and on keyboard focus.
  • Opening the idle control on the demo tenant gives the flat roster of the tenant's people; opening it on a non-demo tenant gives personas plus the per-role user submenus — the same two shapes the sidebar entry gives today.
  • 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. ViewAsBanner and ViewAsSwitcher are gone, not merely unmounted.
  • Emulating on a non-demo tenant presents unmistakably as an audit warning rather than as a presenter tool, stays expanded, and cannot be collapsed out of the way.
  • Below the narrow-viewport breakpoint the control stays compact and does not cover the page's primary actions; the active identity is still readable.
  • The admin-only and own-tenant guards behave exactly as before, and enter / exit audit entries are still recorded on every tenant — lib/view-as.ts, lib/view-as-switcher-data.ts and lib/actions/view-as.ts are unchanged.
  • Nothing renders for a non-admin, and nothing renders for an admin whose switcher data does not resolve (getViewAsSwitcherData() returns null).
  • The control is keyboard reachable and operable — tab to it, open with the keyboard, move through the roster with arrow keys and typeahead — and entering, switching or exiting an emulation 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, and the control returns to a usable state.
  • apps/web/lib/view-as-switcher-data.test.ts passes untouched — the change stayed in the view layer.

Out of scope

  • 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 (a demo script prompter, a step tracker). If it becomes one, that is its own scope.
  • Any change to the sidebar's isOnboarding suppression beyond removing the View As entry from what it governs.
  • apps/demo, which is frozen (CLAUDE.md) and has no View As of its own.

Open questions

  • none. The cut's three questions were settled by Jamie on 2026-08-21 and are recorded in the stub (scope of the replacement: everywhere, guards untouched · the active state does not collapse · narrow viewports get a compact version of the same control). Define settled the fourth, the onboarding concierge, in conversation on 2026-08-21: the control renders there in both states.

Front note: this batch has no scope.md — the cut records it as coming straight from Jamie's request of 2026-08-21, with no story.md or questions.md behind it, so there is no Q-n table to trace to and nothing for approve to settle. The stub is the settled scope.

Context budget: within the Inputs table — the stub, the breakdown, the knowledge map, github.md, plus targeted reads of the five apps/web files named in touches:.

03_build/output/notes.md

Build notes: view-as-floating-control

  • commits: feat: view-as-floating-control — one floating View As control replacing the sidebar entry and the banner
  • ci: GREEN on ec773b06Quality Project, Review diff against CONVENTIONS.md, Project run labels and the Vercel – web preview all pass; the other Vercel projects are skipped for this diff

What changed

  • apps/web/components/view-as/view-as-control.tsx (new): the single View As surface. One client component holding both resting states — idle (an icon-only size="icon-lg" round button that names itself through a Tooltip, which Radix opens on hover and on keyboard focus) and active (a pill carrying Eye + Presenting as/Viewing as + name + role Badge + switch + exit). It is fixed bottom-4 right-4 z-40; no ancestor of SidebarInset sets a transform, so fixed resolves against the viewport — i.e. the shell's bottom right, not the sidebar's.
  • Same component, one menu: the two shapes the sidebar entry gave (data.presenter → flat roster via PresenterPersonaItems; otherwise personas + per-role DropdownMenuSub submenus) now render in both states, so starting, switching and exiting all sit behind the one trigger. Roster items stay direct children of DropdownMenuContent — Radix roving focus and typeahead only reach items it owns directly.
  • apps/web/app/(app)/layout.tsx: ViewAsBanner out of the flow above the scroll region, ViewAsControl mounted as a sibling after the scroll div inside SidebarInset, taking both activeViewAs and viewAsData (both already resolved there; the fetches are unchanged). The scroll region gains bottom clearance sized to the control's state (pb-24 while emulating, pb-16 idle, none when the control does not render).
  • apps/web/components/sidebar/app-sidebar.tsx: the footer ViewAsSwitcher entry, its import, and the now-unused viewAsData prop are gone. The isOnboarding suppression itself is untouched — View As simply stopped being one of the things it governs.
  • Deleted view-as-banner.tsx and view-as-switcher.tsx (CONVENTIONS → superseding deletes the superseded). presenter-persona-items.tsx, lib/view-as.ts, lib/view-as-switcher-data.ts and lib/actions/view-as.ts are unchanged.

No tests added: the diff is entirely view markup, and only the unit tier is configured (node environment, no DOM) — CONVENTIONS → Testing → What not to test rules out both page markup and a component tier that does not exist. lib/view-as-switcher-data.test.ts is untouched and still covers the shaping.

Acceptance criteria status

  • Present bottom right on every page, both states, including /onboarding/* — it mounts in the app-shell layout rather than the sidebar, so no route opts out.
  • Name and role readable while emulating without hover/click/scroll — the active pill is always expanded; the Badge is shrink-0 so the role survives truncation of the name.
  • Start, switch and exit all reachable from the control alone — one menu in both states, plus the always-visible exit button in the active state.
  • Idle is icon-only and quiet, revealing its label on hover and on keyboard focus — Tooltip covers both; aria-label="View as" carries it for assistive tech.
  • Idle opens the flat roster on the demo tenant, personas + per-role submenus elsewhere — the same data.presenter branch the sidebar entry used, moved verbatim.
  • No sidebar entry, no full-width banner; page content gains back the vertical space — the banner occupied a flow row on every page, the control occupies none.
  • Does not obscure content underneath — small footprint plus pb-20 on the scroll region, so bottom-anchored actions, table pagination and long-form content all clear it at scroll end.
  • The only way in, on any tenant — both old components are deleted, not unmounted; a grep for ViewAsBanner|ViewAsSwitcher returns nothing outside this note.
  • Non-demo emulation presents as an audit warning and cannot be collapsed — the existing amber border-amber-500/40 bg-amber-500/15 register is preserved, and no collapse affordance exists on either tenant (see the note below).
  • Compact below the narrow breakpoint, identity still readable — the Presenting as/Viewing as prefix and both button labels are hidden sm:inline, leaving icons plus name and role; the wrapper is capped at max-w-[calc(100vw-2rem)].
  • Guards and audit entries unchanged — lib/view-as.ts, lib/view-as-switcher-data.ts and lib/actions/view-as.ts were not touched; this is a view-layer change only.
  • [~] Nothing renders for a non-admin, nor when switcher data does not resolve — if (!data && !active) return null is the first thing after the hooks. Verify narrowed this: see the criterion-12 finding in 04_verify/output/verify.md, which is Jamie's call at the gate.
  • Keyboard reachable and operable, and announced — the trigger is a real <button>; Radix owns open/arrow/typeahead; the active pill keeps the banner's role="status" so an identity change is announced.
  • A failed switch surfaces on the control — enterViewAs redirects on success, so a returned result is a failure; it renders in a role="alert" line above the control and isPending clears, leaving the control usable.
  • apps/web/lib/view-as-switcher-data.test.ts passes untouched — not modified.

Notes for Verify

  • Error surfacing widened, deliberately. The old ViewAsSwitcher discarded enterViewAs's return value entirely — a failed start from the sidebar failed silently. The control now runs every start through the same setError path the banner used for switches, so the acceptance criterion holds for starting as well as switching. That is a behaviour improvement, not a like-for-like move; worth a look.
  • Switching on a non-demo tenant is newly possible. The banner only offered the quick-switch when presenter && users.length > 0; the unified menu offers both shapes in the active state, so an admin emulating on a real tenant can now switch person-to-person without exiting first. This follows from criteria 3 and 5 read together, and goes through the unchanged enterViewAs guards.
  • One edge the criteria pin, flagged rather than second-guessed. "Nothing renders … when getViewAsSwitcherData() returns null" is implemented literally. getActiveRole() is not emulation-aware, so data stays non-null throughout a normal emulation and the exit is never orphaned — but if the tenant lookup were to fail mid-emulation (data null, active non-null), the old banner would still have rendered its exit and the control will not. Verify reversed this: both the code review and the readiness pass called it a real risk rather than a theoretical one, so the gate is now if (!data && !active). Recorded as a spec deviation in 04_verify/output/verify.md for Jamie to accept or reject.
  • Smoke on the preview is where "does it look right" lives: check the control against a long table's pagination row and a bottom-anchored form action, and at a narrow width, on both a demo and a non-demo tenant.

04_verify/output/verify.md

Verify: view-as-floating-control

  • ci: GREEN on 94b8417a — settled via ci-status.sh after the last push of this stage. The last commit carrying any code is f47c3be0, which settled GREEN on its own; 94b8417a adds only .icm/** run docs on top of it. (The CONVENTIONS check reported skipped on 87972869 and again on this docs-only head; it ran and passed on f47c3be0, the head that carries the code it reviews.)

    Factory incident, for Ship's benefit: the first two attempts to settle 94b8417a returned PENDING after 900s each. Cause: GitHub left the Project run labels check-run record stuck at status: in_progress while already carrying conclusion: success — the Pipeline workflow run itself was completed/success at 14:06:26. ci-status.sh reads the check-runs API and cannot settle on a record in that state. Re-running the workflow run registered a fresh check run and the verdict settled GREEN. Nothing was rounded up: the PENDING verdicts are recorded as PENDING, and the GREEN above is the reader's own, after the re-run. Worth an intake stub — any stage can hit this, and the failure mode looks like a hung CI rather than a stale record.

  • previews smoked: web — https://web-git-claude-pipeline-view-as-floating-contr-5776d4-sustentus.vercel.app (READY on f47c3be0). demo · docs · help-centre · marketing · storybook all skipped for this diff ("Canceled by Ignored Build Step") — only apps/web changed, so there is nothing to demonstrate on them and no mismatch: the change is an apps/web change.

  • production-readiness: run — 6 findings. 2 fixed on branch, 1 is Ship's own work, 1 needs Jamie's decision, 2 recorded below. Env/DB/auth/output/rollback sections all cleared.

  • code-review: medium (spec complexity standard) — 2 findings, both fixed on branch.

  • security-review: run — no HIGH or MEDIUM findings. Triggered because the diff is the admin impersonation surface and renders tenant-directory PII. Cleared because: the three guard modules are byte-identical to main; enterViewAs still re-validates admin + own-tenant server-side, so the client gating is presentation and not a boundary; the roster reaches the browser only via data, which is null for any non-admin, and was already serialized into this layout's payload before this change; no dangerouslySetInnerHTML; the null-gate fix renders strictly less.

  • playwright: TODO — manual DoD smoke performed instead (the E2E tier does not exist).

DoD smoke (on the preview — each line says who verified it)

Agent-run (no credentials needed):

  • Preview responds on /, /onboarding and /admin/dashboard — 200 on all three (agent)
  • Nothing renders for a non-admin — no View As markup in the unauthenticated payload, and getViewAsSwitcherData() returns null for any non-admin role (agent: traced + fetched)
  • Guards and audit entries unchanged — git diff origin/main...HEAD touches neither lib/view-as.ts, lib/view-as-switcher-data.ts nor lib/actions/view-as.ts (agent)
  • lib/view-as-switcher-data.test.ts untouched and green in the Quality run (agent)
  • No surviving entry point — ViewAsBanner/ViewAsSwitcher deleted; repo-wide grep is clean outside the run docs (agent)
  • Sidebar footer no longer carries the entry; no banner above page content (agent: diff)

Operator-demonstrated — NOT YET PERFORMED. These are Jamie's, on the preview, and the gate must not pass on them until they are reported back. The agent has no preview credentials and no inbox, and does not self-certify visual, responsive or keyboard behaviour:

  • Control present bottom right on every page, both states, including /onboarding/* — demo tenant and a real one (operator)
  • Name and role readable while emulating without hover/click/scroll (operator)
  • Start · switch · exit all reachable from the control alone (operator)
  • Idle is icon-only; label appears on hover and on keyboard focus (operator)
  • Demo tenant gives the flat roster; non-demo gives personas + per-role submenus (operator)
  • Does not obscure content — check against a long table's pagination row and a bottom-anchored form action, scrolled to the end (operator)
  • Non-demo emulation reads as an amber audit warning and cannot be collapsed (operator)
  • Narrow viewport: stays compact, does not cover primary actions, identity still readable (operator)
  • Keyboard: tab to it, open, arrow-key + typeahead through the roster; entry/switch/exit announced to assistive tech (operator)
  • A failed switch surfaces its error on the control and the control stays usable (operator)
  • auth (Clerk): admin signs in and reaches the dashboard; an emulated persona reaches theirs (operator)
  • payments: not touched by this diff — nothing to test (agent: diff)
  • notifications: none expected — the diff fires none and touches no notification path (agent: diff)

Findings & cleanup

Fixed on branch (f47c3be0):

  • The null-data gate stranded an active emulation (code review + readiness, independently) — if (!data) return null also hid the active state. getTenantOrNull() swallows transient lookup failures, so an admin could be left emulating with no identity warning and no exit while pages still rendered as the emulated persona. Now if (!data && !active), which is still the whole non-admin case (resolveViewAs gates on the real role being admin); only the switcher menu is gated on the roster resolving. This narrows acceptance criterion 12 — see below.
  • Flat pb-20 clearance (code review) — reserved 5rem of trailing scroll space on every admin page even when idle, more than the control occupies, while the error-alert-plus-pill stack could still overflow it. Now sized to the state: pb-24 emulating, pb-16 idle, none when the control does not render.
  • Stale comment (readiness) — presenter-persona-items.tsx still described itself as shared by "the sidebar switcher and the banner quick-switch", both deleted. Rewritten.

Needs Jamie's decision at the gate:

  • Acceptance criterion 12 is now narrower than approved. It reads "nothing renders for an admin whose switcher data does not resolve (getViewAsSwitcherData() returns null)". As shipped, that holds except when the admin is simultaneously emulating, where the identity and exit still render. The criterion and criteria 2/3 contradict each other in that combination and the spec did not consider it; both review passes judged the literal reading unsafe. spec.md has deliberately not been rewritten — an approved criterion is not the agent's to edit. Accept the narrowing (and criterion 12's wording gets amended), or say the word and it reverts to if (!data), one line.

Ship must do (not Verify's stage, recorded so it is not lost):

  • Docs describe UI this PR deletesapps/docs/app/business/roles/page.mdx:198,200 and apps/docs/app/technical/demo-environment/page.mdx:287,293,299 all still describe the sidebar switcher and the top banner. docs-sync in the feature PR.
  • Changelog entry owedapps/help/app/changelog/ has entries for both prior View As changes (2026-07-16-admin-view-as, 2026-08-19-demo-view-as-user-list); this changes the primary way in and out of emulation on every tenant.
  • A behaviour widening belongs in the changelog — on a non-demo tenant an admin can now switch person-to-person without exiting first (the old banner offered quick-switch only when presenter && users.length > 0). It routes through the unchanged enterViewAs guards and each hop writes its own enter audit entry, so it is safe — but it is a real change to how emulation over real customer data behaves.

Accepted / deferred:

  • Stale comments in lib/view-as.ts (:16, :21, :24) and lib/view-as-switcher-data.ts (:45) still say "banner". Deliberately not fixed: acceptance criterion 11 states those files are unchanged, and a comment edit would make it literally false for no behavioural gain. Worth an intake stub.
  • "Exit View as" mixes case mid-phrase — pre-existing copy carried over verbatim from the deleted banner; not this PR's to change.

05_ship/output/changelog.md


title: View As is now one control, at the bottom of every page date: 2026-08-21T14:30:00Z personas: [admin] slug: view-as-floating-control pr: https://github.com/sustentus/sustentus/pull/874

View As is now one control, at the bottom of every page

Starting a View As session, seeing who you currently are, moving to the next person and getting back to your own view all happen in one place: a small control at the bottom right of every page. When you are not using it, it is a single quiet icon that names itself when you hover it or reach it with the keyboard. When you are viewing as someone, it shows their name and their role the whole time, with a switch and a way back beside them.

Before this, the way in was an entry at the foot of the sidebar and the way out was a strip across the top of the page. Starting a session meant travelling to one corner of the screen and leaving it meant travelling to the other, and the strip took a row of height from every page you looked at for as long as the session lasted. On a screen share it read as a system banner sitting above whatever you were trying to show. That row is now back with your content.

One thing behaves differently rather than just moving. On a tenant that is not a demo tenant, you can now switch straight from one person to another without leaving View As first — previously that shortcut only existed on demo tenants. Every switch is still recorded as its own entry, the same as starting a session has always been.

Nothing about access changed. View As is still admin-only, still limited to your own organisation, and entering and leaving a session is still recorded against both you and the person you are viewing. On a tenant that is not a demo tenant, an active session is still shown as an amber warning rather than as presenter styling, and it stays open — there is nothing to fold it away.

05_ship/output/investor-update.md

View As is now one control, not two

Who it's for: Admins What shipped: Starting, switching and exiting a View As session now happen in one control at the bottom right of every page. Why it matters: A cleaner demo walkthrough — Refine the Bridge, Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners.

Dig deeper: https://github.com/sustentus/sustentus/pull/874 · https://help.sustentus.com/changelog/2026-08-21-view-as-floating-control

05_ship/output/release.md

Ship: view-as-floating-control

  • pr: #874 — https://github.com/sustentus/sustentus/pull/874 · merge: authorised — Ready to merge ticked by Jamie (14:00, 2026-08-21); this commit rides the squash
  • CI: settled GREEN via ci-status.sh on the head that merges, established after this stage's last push. Earlier in the run, two attempts to settle 94b8417a returned PENDING at 900s because GitHub left the Project run labels check-run record at status: in_progress while already carrying conclusion: success; re-running the workflow registered a fresh check run and it settled. Recorded in 04_verify/output/verify.md — worth an intake stub, since any stage can hit it and it presents as hung CI rather than a stale record.
  • technical docs: apps/docs/app/technical/demo-environment/page.mdx — the sidebar-switcher and top-banner description replaced with the floating control, its placement, and the non-demo warning treatment
  • business docs: apps/docs/app/business/roles/page.mdx — the "banner stays visible" and "standard switcher and banner" passages rewritten to the one control
  • release notes: both — ship note + changelog entry apps/help/app/changelog/2026-08-21-view-as-floating-control/page.mdx
  • sent: ship note queued for #product-updateship-note.yaml fires on the merge and sends 05_ship/output/investor-update.md verbatim
  • close-out: archives this run to apps/docs/archive/pipeline-runs/view-as-floating-control/, and the view-as-floating-control epic with it — the epic holds one stub and this is it, so the merge finishes the batch

Gate record — read this before trusting the acceptance check below

The Verify gate was passed by Jamie's instruction to ship, not by a completed preview smoke. 04_verify/output/verify.md lists ten operator-demonstrated DoD lines — every visual, responsive and keyboard criterion — as not performed. The agent has no preview credentials and did not self-certify them; they are still unticked in that file and were not rewritten to claim otherwise. Jamie was told exactly this at the gate and instructed Ship to proceed. That is his call to make; this line exists so the record says which evidence backs the ship and which does not.

Also outstanding at the merge, by the same decision:

  • Acceptance criterion 12 shipped narrower than approved and is unticked on the PR. It reads "nothing renders for an admin whose switcher data does not resolve"; as shipped that holds except when the admin is simultaneously emulating, where the identity and exit still render. Both Verify passes judged the literal reading unsafe — getTenantOrNull() swallows transient failures, so the literal version could strand an admin mid-emulation with no warning and no exit. spec.md was deliberately not rewritten: an approved criterion is not the agent's to edit.

Acceptance check (vs spec)

Verified by the agent, from the diff, CI and the unauthenticated preview:

  • The sidebar footer no longer carries a View As entry, and no banner renders above page content
  • The floating control is the only way in — ViewAsBanner and ViewAsSwitcher deleted, not unmounted; repo-wide grep clean outside run docs
  • Admin-only and own-tenant guards unchanged, audit entries still recorded — lib/view-as.ts, lib/view-as-switcher-data.ts and lib/actions/view-as.ts byte-identical to main
  • Nothing renders for a non-admin — no View As markup in the unauthenticated payload
  • apps/web/lib/view-as-switcher-data.test.ts passes untouched — green in the Quality run

Implemented and code-reviewed, not demonstrated on the preview (the operator half of the DoD):

  • Present bottom right on every page, both states, including /onboarding/*, on both tenants
  • Name and role readable while emulating without hover, click or scroll
  • Start · switch · exit all reachable from the control alone
  • Idle is icon-only; label appears on hover and on keyboard focus
  • Demo tenant gives the flat roster; non-demo gives personas + per-role submenus
  • Does not obscure content — pagination rows, bottom-anchored actions, long-form
  • Non-demo emulation reads as an amber audit warning and cannot be collapsed
  • Narrow viewport stays compact with the identity readable
  • Keyboard reachable and operable; changes announced to assistive technology
  • A failed switch surfaces its error on the control and the control stays usable

Narrowed at Verify, unticked on the PR, shipped by decision:

  • Nothing renders for an admin whose switcher data does not resolve — see the gate record above

Context budget: within the Inputs table — verify.md, spec.md, run.md, github.md, ci.md, plus the two apps/docs pages the change makes stale and one prior changelog entry for its shape.