Toolbar customization
Every toolbar, frame header and provider header-action row in the app lets the user hide individual buttons (the Note Tray utility bar pins and orders them instead; see The Note Tray utility bar). One registry describes which buttons are hideable, one settings key stores the choices, and one hook renders both the right-click customize menu and the Settings → Toolbars section. This page is the contributor-facing contract (epic #6562 topic 8); the end-user walkthrough is the Japanese manual's 設定項目リファレンス.
Where the code lives
| Piece | File |
|---|---|
Settings shape (AppSettings.toolbars), ToolbarSurfaceId | packages/ |
| Surface registry, read/write helpers | packages/ |
Default ({} — everything visible) | packages/ (defaultToolbarSettings) |
Validation + the menu-bar-right discard | packages/ |
| Runtime hook | tauri- |
| Right-click menu wrapper | tauri- |
| Settings section (Toolbars) | packages/ |
| Section registration + the open-to-section listener | tauri-, settings/ |
| Palette command / event | tauri- (open-toolbar-settings, OPEN_TOOLBAR_SETTINGS_EVENT) |
| Checkbox menu-row primitive | packages/ |
Settings shape
toolbars: Partial<Record<ToolbarSurfaceId, { hidden: string[] }>>;A missing surface key means everything on that surface is visible, so the default is the empty object
{}.withToolbarItemVisibledeletes a surface's entry once nothing on it is hidden, keeping the persisted document minimal.toolbarsis not part ofOverridableSettings: toolbar visibility is a workspace-wide preference, not a per-device one.There is no migration from the pre-#6562
layout.noteTrayToolbar.show*booleans — the app is pre-release, so those keys were deleted and the Note Tray bar starts fully visible again.
The surface registry
TOOLBAR_SURFACES in packages/ maps every ToolbarSurfaceId to a display label and its list of hideable items:
"efe-tab-strip": {
label: "External File Editor Tab Strip",
items: [
{ id: "tab-bar-position", label: "Tab bar position" },
{ id: "view-mode", label: "View mode" },
],
},HEADER_ACTIONS_SURFACE_BY_PROVIDER maps a ViewProvider.id to the *-header-actions surface its HeaderActions component renders, so the frame menu can fold the provider's own actions into its Header buttons submenu. The core.inbox mapping and the note-tray-header-actions surface no longer exist: History, Pile View and Find similar moved onto the utility bar.
Rule: only hideable items are registered
There is no "locked" or "required" flag. A control that must never disappear is simply absent from the registry — close, tabs, navigation, and any escape hatch out of a mode. Live examples:
dashboard-header-actionsregisters onlydashboard-reset; Edit widgets is unregistered, because it is the only way out of edit mode.frame-chrome-controlsregisters collapse, zoom, pop-out and empty — not the close button.
If a reviewer asks "can the user hide themselves into a corner here?", the answer is to drop the item from items, not to add a flag.
Rule: active state beats preference
useToolbarVisibility(surfaceId, { forceVisible }) forces ids on screen regardless of the stored preference, for an item whose current state would otherwise become undiscoverable. This affects rendering only — the menu checkbox keeps showing the stored preference, so toggling it stays meaningful and the item goes back to hidden once its state goes inactive.
kanban-header-actionsforce-showskanban-filterwhile a quick filter is active (kanban-board-provider.tsx).frame-chrome-controlsforce-showszoomwhile the frame is zoomed, since a zoomed frame covers everything else and would otherwise strand the user (isChromeControlVisibleinpackages/).frameset/ src/ frame- chrome. tsx
The menu-bar.rightIcons storage route
menu-bar-right is the one surface whose visibility does not live in AppSettings.toolbars. menuBar.rightIcons[] already carries both visible and display order, and two representations of the same fact would eventually disagree, so the registry entry declares:
"menu-bar-right": { label: "Menu Bar (Right)", storage: "menuBar.rightIcons", items: … },Consequences to keep in mind:
Read and write only through
isToolbarItemVisible/withToolbarItemVisible. They route bystorage, so a caller never needs to know which key backs a surface.validateSettingsdiscards anytoolbars["menu-bar-right"]entry it finds, so a hand-edited settings file cannot create a second source of truth.The Toolbars settings section renders this surface as a pointer to the Menu Bar section instead of a second editor, and the shared settings-search metadata skips it (its entries already exist under the
menu-barsection key).
The Note Tray utility bar
note-tray-utility is the second storage-routed surface. Its items are the pinnable utilities, and it stores them as the ordered list AppSettings.noteTrayUtility.pinned instead of a hidden list. Pinning appends; unpinning keeps the remaining order. An unpinned utility is not removed: it stays in the bar's permanent More menu. note-tray-bar keeps only the tab-rail controls (new-note, position, expand-toggle, note-view).
| Piece | File |
|---|---|
| Settings, defaults, validation, width helpers | packages/ |
| One action model feeding the pinned buttons, More, per-action menus and Customize | tauri- |
Bar component (role="toolbar", arrow / Home / End keys, overflow measurement) | tauri- |
| Persistence | tauri- (updateNoteTrayUtility) |
| Settings section group | packages/ |
Contracts worth knowing:
Placement. Horizontal sits directly under the tray scope row, above the tabs. Vertical is a sibling to the right of the content inside an always-present
note-tray-bodyrow, so orientation, label and width changes never remount the editor. The tab rail position is independent.Defaults. Horizontal, labels on, pinned
To AI,Sticker,Preview,Pile View.Vertical width per label mode. 144 px (96–240) with labels, 48 px (48–160) icons only, step 8. Rendered width is
max(40px × s, min(W px × s, 100% − 160px × s)): the display scale is applied once, and the render clamp is never written back. The settings UI edits one range and number input for the active mode; an invalid typed width stays an editable draft.Ellipsis. Labels use
min-w-0 overflow-hidden text-ellipsis whitespace-nowrap; icons areshrink-0.More. Groups Current note, Organize, Tray; unpinned and responsively overflowed actions appear exactly once. Overflow is measured with a
ResizeObserverand state is set only on change. Disabled actions stay listed with a real reason (aria-disabled,aria-description, tooltip, menu suffix). Child actions are flattened under a parent state label (for example published Publish becomes Publish again and Unpublish). The utility menus opt intoContextMenu'swrapLabelsso long labels wrap.Customize. Right-click the bar or open More → Customize toolbar…. One shared
ContextMenuinstance serves it (newBarMenuTargetkinds). Entries: Move earlier / Move later, pin checkboxes from the registry, Show labels, Horizontal / Vertical, Reset toolbar, Toolbar settings….Persistence.
updateNoteTrayUtilityreads, derives and writes insiderunSerializedSettingsWritefrom fresh base settings, rejects on a failed save (toast), and preserves unrelated preferences. The Settings dialog omits an untouchednoteTrayUtilitydraft from Save & Reload, the same narrowing astoolbarsandmenuBar(extend the list above only for such popout-writable keys).Commands.
Note Tray Utility Bar: Show/Hide LabelsandNote Tray Utility Bar: Horizontal/Verticalin the command palette. The help entry isnote-tray-utility-bar.Compact mobile hides the bar together with the editor chrome (
data-mobile-editor-chrome).
Runtime: the two hooks
useToolbarVisibility(surfaceId, options) returns { isVisible, setVisible, hiddenIds, menuItems }.
isVisible(itemId)is what a surface guards its buttons with.setVisiblepersists immediately. The read-modify-write runs insiderunSerializedSettingsWriteagainst freshly read settings — never a render-time closure — so two quick toggles both land.saveScopedcannot be used: it re-enqueues onto the same queue (deadlock), and its deep merge can never delete atoolbars[surfaceId]entry that the patch drops.hiddenIdsis reference-stable while its contents are unchanged, because memoized toolbar chrome consumes it (seetauri-§ Frontend Pitfalls).app/ CLAUDE. md menuItemsis one checkbox row per registered item, a separator, then Toolbar settings….
useToolbarContextMenu(surfaceId, options) wraps that in the shared useContextMenu + ContextMenu pair and returns { onContextMenu, menu, visibility }.
Three contracts that bite if ignored:
Attach
onContextMenuto the bar's container, not to individual buttons. The menu has to stay reachable once items disappear — including when every hideable item on the surface is hidden. The preview pane's floating launcher cluster is therefore always mounted: with both launchers hidden it drops its chip chrome and keeps a bare icon-sized hit area in the preview's top-right corner, purely so the right-click target survives.ContextMenuis a single-instance registry — a second menu evicts the first. A surface that already owns a right-click menu must mergevisibility.menuItemsinto its own item list rather than rendermenu. The frame header does exactly this, as a Header buttons ▸ submenu built inpackages/.frameset/ src/ frame- chrome- menu- items. ts Nested menus win by
preventDefault(). A descendant with its own context menu (a note pill, a header pin) callspreventDefault(), and the container handler ignores an already-handled event.
Checkbox rows toggle without closing the menu (ContextMenuCheckboxItem, added in #6565), so several buttons can be hidden in one visit.
Touch
No gesture is invented. WebKit's native long-press synthesizes the same contextmenu event the handler receives, and the ContextMenu key / Shift+F10 are handled by use-context-menu.ts. Where a surface swallows the press (a drag sensor claiming it), Settings → Toolbars is the route.
Adding a new surface
Add the id to the
ToolbarSurfaceIdunion inpackages/.app- defaults/ src/ types. ts Add the registry entry to
TOOLBAR_SURFACES, listing only the items a user may hide. If the surface is a provider'sHeaderActions, also add the provider id toHEADER_ACTIONS_SURFACE_BY_PROVIDER.In the component, call
useToolbarContextMenu("<surface-id>"), spreadonContextMenuon the container, render{menu}inside it, and guard each registered button withvisibility.isVisible("<item-id>").Nothing else. The Toolbars settings section, the settings-search entries and the Toolbar settings… escape hatch are all generated from the registry.
A component in a package that cannot import the renderer (DraftBar in @takazudo/ui-components, TimelineBoard) takes the hidden ids as a plain readonly string[] prop and a onToolbarContextMenu handler instead, and the renderer-side consumer resolves them from settings.
Surfaces deliberately left out
These were considered and rejected, each for a reason that still holds:
| Surface | Why not |
|---|---|
| Workspace-switcher rail | Data-driven — it shows the workspaces you have. |
| Editor status bar | Passive readout, not launchers. |
| Spreadsheet merge buttons | Selection-contextual; they only exist while a selection does. |
| Mind-map overlay | Owns its own menu already. |
| Quick Actions tiles | Already user-configurable. |
| Mobile header kebab | Its contents are the overflow of an already-configurable surface. |
| Edit key bar | Already has its own key catalog and ordering UI. |
| Header-left pins | Already user-managed through Manage header pins. |
| Kanban calendar / now-view toolbars | Navigation whose removal strands the view. |
Known limits
Settings do not propagate live across windows. No settings subscription exists in the app today, so a popped-out window picks up a toolbar change on its next load. This is out of scope by design — do not "fix" it locally in the hook.
OPEN_TOOLBAR_SETTINGS_EVENTdoes not open a closed Settings dialog. The listener lives insideSettingsDialog, so dispatching the event from outside the palette only navigates an already open dialog to the Toolbars section. Theopen-toolbar-settingspalette command works because it callsopenSettings()first. This matches the existingOPEN_LOCAL_AI_AGENT_SETTINGS_EVENTpattern.The Settings dialog's save is narrowed for these keys. A popped-out window can persist a
toolbars,menuBar.rightIconsornoteTrayUtilitywrite while this dialog sits open with a stale draft, sosettings-dialog.tsxomits each such key from the save payload when the draft is unchanged from the open-time snapshot. Do not extend that narrowing to other fields — unrelated draft edits must still be saved.