view-as-floating-controlrun.md02_define/output/spec.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 (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.
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:
data.presenter),
personas plus per-role user submenus elsewhere.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:
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/* 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.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.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./onboarding and its sub-pages.ViewAsBanner and ViewAsSwitcher are gone, not merely unmounted.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.getViewAsSwitcherData() returns null).apps/web/lib/view-as-switcher-data.test.ts passes untouched — the change stayed in the view
layer.getViewAsSwitcherData, the
enterViewAs / exitViewAs server actions, and what an emulated identity may see all stay as they
are.ViewAsUserOption[] it is handed.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.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.mdfeat: view-as-floating-control — one floating View As control replacing the sidebar entry and the bannerec773b06 — Quality Project, Review diff against CONVENTIONS.md, Project run labels and the Vercel – web preview all pass; the other Vercel projects are skipped for this diffapps/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.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.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.
/onboarding/* — it mounts in the
app-shell layout rather than the sidebar, so no route opts out.Badge is shrink-0 so the role survives truncation of the name.Tooltip
covers both; aria-label="View as" carries it for assistive tech.data.presenter branch the sidebar entry used, moved verbatim.pb-20 on the scroll region, so
bottom-anchored actions, table pagination and long-form content all clear it at scroll end.ViewAsBanner|ViewAsSwitcher returns nothing outside this note.border-amber-500/40 bg-amber-500/15 register is preserved, and no collapse affordance exists
on either tenant (see the note below).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)].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.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.<button>; Radix owns
open/arrow/typeahead; the active pill keeps the banner's role="status" so an identity change
is announced.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.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.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.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.04_verify/output/verify.mdci: 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).
Agent-run (no credentials needed):
/, /onboarding and /admin/dashboard — 200 on all three (agent)getViewAsSwitcherData() returns null for any non-admin role (agent: traced + fetched)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)ViewAsBanner/ViewAsSwitcher deleted; repo-wide grep is clean
outside the run docs (agent)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:
/onboarding/* — demo
tenant and a real one (operator)Fixed on branch (f47c3be0):
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.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.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:
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):
apps/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.apps/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.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:
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.05_ship/output/changelog.mdStarting 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.mdWho 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.mdGREEN 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.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 treatmentapps/docs/app/business/roles/page.mdx — the "banner stays visible" and
"standard switcher and banner" passages rewritten to the one controlapps/help/app/changelog/2026-08-21-view-as-floating-control/page.mdx#product-update — ship-note.yaml fires on the merge and sends
05_ship/output/investor-update.md verbatimapps/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 batchThe 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:
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.Verified by the agent, from the diff, CI and the unauthenticated preview:
ViewAsBanner and ViewAsSwitcher deleted, not
unmounted; repo-wide grep clean outside run docslib/view-as.ts,
lib/view-as-switcher-data.ts and lib/actions/view-as.ts byte-identical to mainapps/web/lib/view-as-switcher-data.test.ts passes untouched — green in the Quality runImplemented and code-reviewed, not demonstrated on the preview (the operator half of the DoD):
/onboarding/*, on both tenantsNarrowed at Verify, unticked on the PR, shipped by decision:
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.