Changing a menu label used to cost a full release: edit a provider (or strings.en.json, which is
compiled into the hub assembly), wait ~20 min for CI, ~18 min for the image publish, then a
rollout. Half an hour to rename a button β which is why the node menu accumulated entries nobody
pruned and wording nobody fixed.
Menu presentation is now data. A MenuPresentation node per menu context carries the text, icon,
order, grouping and visibility of each entry. Editing it is a node edit: it takes effect on the next
render, for every renderer, with no build.
The split that makes this safe
π¨ Presentation is data. Applicability is code. Whether an entry may appear at all β the viewer's permission, the node's type, whether sync is configured, whether this is a protected partition root β stays in the compiled providers. The catalog only re-dresses or removes what a provider already decided to show.
This is not a simplification, it is the security boundary. Node-menu permission filtering is enforced
by providers not emitting an item: NodeMenuItemDefinition.RequiredPermission is carried for
display and equality, but nothing filters on it (the settings menu does filter on it β the node
menu does not). An entry conjured from data would therefore have no permission gate at all. So
the catalog is deliberately override-only: it can re-word, re-icon, re-order, group and hide, and
it can never introduce an entry. A catalog edit cannot widen access, by construction.
| Concern | Lives in | Why |
|---|---|---|
| Label, tooltip (per locale) | Data | The thing people actually iterate on |
| Icon, order, grouping / sub-menu nesting | Data | "The menu is messy" is a layout problem |
| Hiding an entry | Data | Subtractive β cannot widen access |
| Permission gate | Code | Enforced by non-emission; the security decision |
| Node-type gate (Markdown / Deck / Space / User) | Code | Reads live node content |
| Sync configured, plugin installed, credential present | Code | Reads live hub state and services |
| Protected partition root, viewer's own home | Code | Reads identity and guard state |
That last group is the honest answer to "can it all be data?" β no, and it should not be. Those predicates read live streams, and expressing them as data would mean inventing a predicate mini-language whose failure modes are worse than the problem it solves. They stay compiled, they stay CI-tested, and they are a named handful rather than an open-ended escape hatch.
Why data rather than plugin code
Moving menu definitions into plugin C# stored in the mesh would also give fast iteration, but in-mesh
source is never compiled by CI β it compiles at runtime in the portal. A framework rename would
silently break it, and a NodeType left at CompileError can hold a new pod out of readiness. The
node menu is core navigation; the failure mode "the menu does not compile" is unacceptable where the
failure mode "one entry keeps its compiled label" is merely untidy. Data has no compile step, so that
state cannot exist.
The shape
One node per menu context at Admin/Menu/{Context} β Admin/Menu/Node, Admin/Menu/Mesh,
Admin/Menu/AI, Admin/Menu/GitHub. It lives on the Admin partition because editing it changes
navigation for every viewer: that is a platform-admin act, not a per-space one.
{
"$type": "MenuPresentation",
"context": "Node",
"entries": [
{ "area": "Delete", "order": 90, "icon": "ποΈ" },
{ "area": "_group:Export", "labels": { "en": "Share", "de": "Teilen" } },
{ "area": "ExportDocx", "hidden": true },
{ "area": "Files", "parent": "Data" },
{ "area": "StopSync", "hidden": true }
]
}
Entries are matched to menu items by area β the stable identity every NodeMenuItemDefinition
already carries (it is the navigation target, so it cannot drift without the navigation drifting
too). Every field is optional and null means leave the compiled value alone, so an entry is a
patch rather than a replacement β an entry that sets nothing is a no-op, not a way to blank a label.
| Field | Effect |
|---|---|
area |
Match key (case-insensitive). Required. |
labels |
Locale tag β text. de-CH β de β en β the compiled label. |
tooltips |
Locale tag β hover text. Same fallback chain. |
icon |
Replacement emoji or SVG URL. |
order |
Replacement sort position β the menu is re-sorted after the overlay. |
hidden |
Drops the entry β at any depth. Hiding a group drops the group and everything in it. |
parent |
area of an existing top-level entry this one nests under, rendered as a sub-menu. |
Entries are addressable at every depth. The overlay descends into sub-menus a provider already
emitted, so a nested entry can be re-worded, re-icon'd, re-ordered or hidden by its own area exactly
like a top-level one β ExportDocx above is inside the compiled π¦ Export group and is hidden from
there. Without that, moving entries into a group in code would quietly make them un-editable.
A group is an entry like any other. A provider ships a group with a
NodeMenuItemDefinition.GroupArea(name) area β _group:Export for the export block β and that area is
its stable key here: re-word it, re-icon it, re-order it or hide it wholesale. Each group gets its own
key rather than one shared sentinel precisely so it stays individually addressable.
π¨ parent moves entries; it never creates one. The named parent must be an entry a provider
already emits at top level. The overlay is override-only for the same reason it never introduces an
entry: nothing on this menu filters on RequiredPermission, so a data-added item would carry no gate.
To ship a new group, a provider emits it. Re-parenting also applies to top-level entries only, which
is what keeps parent nesting exactly one level deep and a parent cycle structurally impossible.
Per-locale text is the point
labels is why a label change needs no build. A menu label today lives in LabelKey β
strings.{en,de}.json, and those catalogs are embedded resources in the hub assembly β so
re-wording an entry is a code change. Carrying the text per locale on the entry moves that edit into
data, and it closes a real gap: an entry with no LabelKey (the Deck export items, for one) is
English for every viewer today, and a catalog entry can translate it without touching the provider.
Changing a label, end to end
- Open
Admin/Menu/Node. The first time, it does not exist yet β create it as aMenuPresentationnode (there is no seeded catalog, deliberately; see above). The area keys are theAreavalues in Node Menu Items βEdit,Pin,Move,Copy,Delete,Files,Data,Versions,StopSync,Recycle,ExportPdf,ExportDocx, β¦ - Add or edit the entry for the area β e.g.
{ "area": "Delete", "labels": { "de": "LΓΆschen" } }. - Save.
The catalog is a live stream combined into the menu render, so the next render picks it up β the same
path a permission change takes. Seconds, no build, no image, no rollout, and it reaches Blazor,
React, React Native and the native shell at once because all four read the same $Menu:{context}
slot.
Fail-safe: a bad edit cannot cost you the menu
The catalog is an overlay over the compiled items, so every failure reduces to "no override applied" β and every dropped input is named in the log rather than silently swallowed:
| What went wrong | What the viewer sees | What the log says |
|---|---|---|
| No catalog node at all | The compiled menu | nothing (this is the normal state) |
| Catalog node deleted | The compiled menu | nothing β absence is indistinguishable from never-created, by design |
Content unreadable / wrong $type |
The compiled menu | the path and the raw JSON, at Error |
Entry with no area |
The compiled menu | entry #N β¦ has no Area |
Two entries for one area |
The first entry wins | entry #N β¦ repeats Area 'X' |
parent naming an unknown area |
Entry stays top-level | names Parent 'X', which no visible menu item provides |
parent pointing at itself |
Entry stays top-level | names itself as Parent |
| Every child of a group hidden | The group disappears too | nothing β an empty group would open onto nothing |
| A label that is blank in every locale | The compiled label, still translated | nothing β an unusable override is simply not applied |
There is no state in which a catalog edit produces an empty menu, because the catalog never sources the menu β it only dresses it.
The catalog is authored once and never reconciled. Nothing rewrites it on boot. That is deliberate: the last time menu/access shape was materialised per instance and reconciled by a background pass, the churn drove one node to version 254,760 and left writer and reader disagreeing about what the fields meant. A catalog absent an entry for some area is the normal state, not drift to be repaired β which is also why a framework version that adds a new menu entry needs no migration: the new entry simply renders with its compiled presentation until someone chooses to override it.
Where it plugs in
The overlay runs in RenderMenus (NodeMenuItemsExtensions), the single point where every provider's
items are already merged, sorted and translated:
providers β CombineLatest β sorted set
β
βββ β¨― MenuPresentation stream β the catalog node, live
β patch Β· hide Β· nest Β· re-sort
β
βββ Localized(access)
β
βββ WithSectionDividers β MenuControl β $Menu:{context}
Order matters: the overlay runs before Localized(access) and clears LabelKey wherever it
supplies a label, so the translation pass cannot resolve an override back to the compiled text. And it
re-sorts, because the providers sorted the compiled order before any override existed.
Section dividers come last, derived from the finished list rather than emitted by a provider β which is what lets you hide the final entry of a section without stranding the rule that followed it. See The Menu Contribution Boundary.
See Also
- Node Menu Items β the provider contract, contexts, and how the menu renders
- The Menu Contribution Boundary β what an entry may be as data, and which built-ins stay compiled
- Localization β the key catalog and
[Translation] - Access Control β permissions and the Admin partition
- NodeType Compilation β why in-mesh code is a different risk class