The portal's home reveals everything the signed-in viewer can read: the catalog lists every space, "last accessed" names whatever they touched this morning, the Pinned and Apps tabs carry names and icons, and @ completions offer node names as you type. Share that screen with an external audience and you have shared your engagement list.

Presentation mode is the privacy screen for that moment: a per-viewer, display-only mode that keeps marked paths off those surfaces while it is on.

🚨 It is not access control, and it must never become access control. It grants nothing and denies nothing. A marked node stays readable, stays reachable by its URL, stays in the viewer's own search results, and is completely unchanged for every other user. The moment a screen gates a read it is a second permission system that can disagree with the real one β€” and the one that disagrees quietly is the one that ships a bug nobody can see.


The seam

User.PresentationMode  +  User.HiddenPaths      (the VIEWER's own profile β€” never the node)
  └─► PresentationScreen                        (pure: Active + MarkedPaths, one predicate Hides(path))
        └─► screen.Filter(items, pathOf)        (applied where a surface PAINTS, never to a query)

PresentationScreenExtensions.ViewerScreen(hub) is the one resolver. It reads WHO is asking exactly the way DisplayTimes reads the viewer's zone β€” off the live AccessContext, request-scoped first, then per-circuit β€” and then binds the value to the viewer's own User node through the shared node-stream handle.

Why the identity comes from the context but the value does not. A time zone changes about never, so projecting it onto the AccessContext when the circuit opens is always right. A presentation toggle is flipped in the seconds before a screen share, and a context snapshot taken at circuit open would still say "off" β€” the viewer would toggle, watch the header light up, and share a portal that is still listing everything. A stale zone is a cosmetic lag; a stale screen is the leak. So the value is read live, and the toggle re-renders every bound surface with no reload.


The two facts, and why a mark alone does nothing

Fact Where it lives On its own
the mode is on User.PresentationMode nothing is hidden until something is marked
a path is marked User.HiddenPaths hides nothing while the mode is off

That is what makes the feature reversible in one click: turn the mode off and every mark is inert, with no restore step and nothing to clean up. It is also what the rejected workaround could not do β€” renaming a node's display fields is global, so it hides the name from everybody and has to be undone afterwards.

Marking a space covers its subtree. The path IS the name, so listing Acme/Q3-Renewal under "last edited" would leak exactly what marking Acme was meant to keep off the screen. Containment is by path segment, so Acme never hides AcmeCorp.


Where the filter goes

Filter where a surface PAINTS β€” never by narrowing a query.

Three surfaces are the exception that proves it, and they filter earlier, before the query is built. The home's Pinned, Shared with me and Apps tabs each interpolate the viewer's own paths straight INTO their control's query string, which the search view exposes in its options editor and carries in the hq= parameter of "open in search". A marked name reaching the address bar mid-presentation is the leak, whether or not a card for it is ever drawn.

A tab the screen empties is dropped, not shown empty: a tab labelled "Pinned" with nothing under it says something all by itself.

Everywhere else the query is left alone. A -path:Acme clause would put the marked name into that same URL and would make the screen a query-engine concern β€” the first step towards it becoming the second permission system above.

Surface Where the screen is applied
Spaces tab, search results, node catalogs, tree levels, graph navigator MeshSearchView β€” one filter over the results every MeshSearchControl renders
Pinned tab UserActivityLayoutAreas.BuildPinnedItems β€” before the query string is built
Shared with me tab UserActivityLayoutAreas.BuildHome / BuildCatalog β€” same reason
Apps tab UserActivityLayoutAreas.BuildApps β€” same reason. A ~/ entry is a system AREA, not a mesh path: it names no node, so there is nothing to screen and it passes through
@ completions ChatCompletionOrchestrator.Screened (one seam for every producer) and MeshNodeAutocompleteProvider

Rules for a new surface

  1. Resolve the screen ONCE, on the render turn, and pass it down as a value β€” host.ViewerScreen() in a layout area, Access.ViewerScreen(hub) elsewhere. Reading the ambient AccessContext from inside a later emission lands after a scheduler hop with the AsyncLocal gone: it resolves to "nobody", whose screen hides nothing. That is the silent-failure shape.
  2. Gate only what would LEAK; seed everything else. A surface that joins the screen into a CombineLatest renders nothing until that leg produces β€” and the leg is a subscription to the viewer's own User node, which need not exist (a test identity, a caller mid-onboarding). A stream that errors is answered by the last-known-screen rule below; a stream that merely never produces is not, and the join then stalls silently and forever, outside any test's method timeout. The symptom is a wall-clock hang with no failing test to point at. So: use ViewerScreen() where painting early leaks (tiles, cards, completions), and ViewerScreen().Seeded() where the screen decides only how something is labelled β€” the node menu's Hide/Show entry, for instance, describes the node the viewer is already looking at, so a briefly wrong label is cosmetic while a menu that never renders is not. "This viewer has no profile" is a defined, screened-safe answer (they have marked nothing); it belongs in the stream as a value, never as something to wait for.
  3. Do not paint before the screen is known (on the surfaces that gate). "Hidden items never appear or flash" is the requirement; a view that renders neutral and filters a beat later shows the audience exactly what the mode was turned on to hide. Gate the first paint on the first emission β€” an anonymous or system caller resolves synchronously, so the gate costs those views nothing.
  4. Ask HidesAnything, never == PresentationScreen.Off. They are different values: a viewer who marked things and then turned the mode off holds a screen that is not Off and yet hides nothing. Filter is a no-op for them either way β€” but anything ELSE your fast-path's other branch does is not, and that is how an empty completion category came to be suppressed for someone who was not presenting at all.
  5. Never widen the screen on a fault. The resolver holds the last known screen across a faulting profile stream (and logs it) rather than resetting to "nothing hidden" mid-presentation. Do not add a timeout that falls back to the neutral screen β€” that fails open.
  6. Never consult it for anything but rendering. No read, no write, no route, no permission.

Not yet covered

The screen is a portal feature and covers the Blazor surfaces above. Still open:

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.