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
- 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 ambientAccessContextfrom inside a later emission lands after a scheduler hop with theAsyncLocalgone: it resolves to "nobody", whose screen hides nothing. That is the silent-failure shape. - Gate only what would LEAK; seed everything else. A surface that joins the screen into a
CombineLatestrenders nothing until that leg produces β and the leg is a subscription to the viewer's ownUsernode, 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: useViewerScreen()where painting early leaks (tiles, cards, completions), andViewerScreen().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. - 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.
- 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 notOffand yet hides nothing.Filteris 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. - 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.
- 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:
- link-preview / OG cards are no longer in this repo β
MeshWeaver.OgCardleft the platform in #1975 β so screening them is a follow-up wherever it lives now. The rule to carry across is that a screened target is dropped, not redacted: a card whose title reads "hidden" still tells the room that something is being hidden; - the React client's
MeshSearchrenderer does not apply it yet; - the strict variant #1803 mentions β hiding all recent-activity regions wholesale, rather than the marked paths β is not built;
- there is no keyboard shortcut for the toggle; it is the user-menu entry and the header indicator.