Buttons & Context Menus
Which button component to reach for, the visible-edge rule that keeps a grid of icons from reading as decoration, and the shared ContextMenu primitive every right-click surface builds on — durable public reference distilled from the Editing Keys Polish epic (#6674).
Note
This page distills the "Buttons" and "Context menus" sections of. into a durable public reference. That file is the quick-lookup source of truth for agents working in the codebase; this page is the same rules for anyone reading the docs site.
Buttons
Four components cover every button in the app, picked by what the surface around them already reads as:
| Component | File | Use for |
|---|---|---|
Button | packages/ | Dialog/form actions. Variants primary | secondary | ghost | danger | danger-ghost, sizes sm | md, an active prop. |
IconButton | packages/ | A single icon action. toolbar variant is transparent — for a row that is itself already visibly a toolbar. square variant is bordered — for a standalone icon action that is NOT inside an obvious toolbar row. |
DialogCloseButton | packages/ | The dialog-header close (X) only. |
Keycap (appearance="keycap" on PaletteKeyButton) | tauri- | Touch key pads / grids of same-weight actions — the mobile More-tools panel. Backed by bg-keycap and the --spacing-keycap-edge bottom edge. |
The edge rule
A button must have a visible edge or fill at rest unless it sits inside a container that already reads as a toolbar row. A grid of bare glyphs is not a set of buttons — that gap (the mobile More tools panel, before the keycap treatment landed) is exactly the defect this rule exists to prevent.
Concretely: a lone IconButton sitting on a plain background needs variant="square" (or the keycap treatment on touch); it never sits there with variant="toolbar" and no surrounding chrome that already signals "this row is a toolbar".
Filled accent is reserved
Filled accent (bg-accent text-on-accent — Button's primary variant) is reserved for isolated primary controls (the launcher, draft-bar pills) — never for list selection. See the design-token skill's "Active State" section for the selection tokens (bg-active/text-active-fg); filled accent is a distinct, louder pattern, not an interchangeable one.
States and sizing
Every button state comes from the same tokens regardless of which component renders it:
| State | Tokens |
|---|---|
| Hover | bg-hover text-hover-fg |
| Pressed | bg-active text-active-fg |
| Disabled | opacity-40 |
| Focus | focusRingInsetCls (@takazudo/ui-components) |
Sizes come from the button size tokens: --spacing-icon-btn-square (32px square IconButton), --spacing-icon-btn-mini (22px table-helper buttons), --spacing-close-btn (28px DialogCloseButton), and --spacing-touch-min (44px floor on mobile). Buttons keep the radius scale (-) — only dialog/panel surfaces are square.
New toolbar buttons must be hideable
Register them in TOOLBAR_SURFACES (packages/) and guard them with useToolbarContextMenu — see root CLAUDE.md § "Feature Implementation Checklist".
Context menus
Every new interactive surface ships with a designed context menu. Right-click must never fall through to the native WebKit menu (Reload / Inspect Element) on app chrome. The desktop fallback described below only hides the native menu as a last resort — it is a safety net, not a substitute for designing one.
Use the shared ContextMenu + useContextMenu (packages/, use-context-menu.ts) for any menu that needs groups, checked rows, or a submenu. Never reach for ToolbarKebabMenu (packages/) for that case — it is a flat single-select list with no separators, labels, or checkbox rows.
Opening the menu
useContextMenu exposes two openers that share one item-builder function:
openFromEvent(event, context?)— right-click (and the ContextMenu key / Shift+F10, which browsers dispatch as the samecontextmenuevent).openAtPoint(x, y, context?, trigger?)— a…button. Read the trigger'sgetBoundingClientRect()and pass itsleft/bottom; pass the trigger element itself astriggerso focus returns to it on close. Seetauri-for the full pattern, including click-to-toggle viaapp/ renderer/ components/ settings/ device- management/ device- context- menu. tsx menuProps.open/close()andignoreRef.
Grouping
A { type: "label" } heading plus { type: "separator" } between groups. Actions of a different kind never share a group without a separator — for example a "Configure palette…" settings action is its own group, separate from placement actions. A current choice among alternatives (which placement is active, say) is a { type: "checkbox" } checked row via onToggle/checked — never a set of aria-pressed buttons standing in for a menu.
One ContextMenu per surface
The component is a single-instance registry (module-level — opening a second root menu evicts the first), so a list of rows (dashboard widgets, kanban cards, …) shares ONE ContextMenu instance rather than mounting one per row — see tauri-. A surface that already owns a right-click menu and also wants the toolbar customize entries merges visibility.menuItems from useToolbarContextMenu into its own item list instead of rendering that hook's menu (a second ContextMenu would evict the first).
Menu rows use menuItemCls (the shared Tier A class constant); the panel z-index is --z-context-menu.
Keep-native list
Deliberate, must stay native — do not add a custom menu here:
Every
<input>/<textarea>/<select>/[contenteditable].Any non-collapsed text selection, anywhere.
The editor when
AppSettings.editor.nativeContextMenuis on.Touch devices on the editor and preview panes — the native long-press callout is their only copy affordance.
The slide-deck preview.
The terminal idle area.
This is the epic #6674 keep-native inventory, enforced app-wide by the desktop fallback in tauri- (shouldSuppressNativeContextMenu / installNativeContextMenuSuppressor): one document-level bubble contextmenu listener that defers to event.defaultPrevented (so it never races a surface's own menu) and exempts the list above plus dev builds (so Inspect Element keeps working). A [data-native-context-menu] wrapper is the generic per-element opt-out for a surface that wants the native menu without owning a contextmenu handler.