Building client UI
The client is a Preact app (legacy widgets are jQuery BasicWidgets; new UI is Preact). Shared components live in apps/client/src/widgets/react/. Always reuse them instead of writing raw HTML elements or a custom implementation — every hand-rolled <input>, <select>, <button>, <a>, pill or overlay button is a second copy of styling, focus handling and accessibility that drifts.
The general styling rules (no inline styles, one .css file per component imported at the top, scope by root class + native CSS nesting) are in the repo CLAUDE.md; this skill is about which component and how to drive it.
CLAUDE.md still foregrounds the jQuery widget lifecycle (doRenderBody / refreshWithNote / this.$widget). That is for maintaining existing widgets. New UI is a .tsx component using the hooks below; reach for useLegacyWidget only to embed an existing jQuery widget into a Preact tree.
Don't reinvent froca-reactive state
The most common mistake on this surface is writing useState + server.get/froca + a hand-rolled entitiesReloaded listener for something a one-line hook already does — with the right reload filter, the right echo-suppression, and the stale-request guard. apps/client/src/widgets/react/hooks.tsx exports 60 use* hooks. Check the catalogue first: references/hooks-catalog.md.
| You need… |
Use |
It already wires |
a scalar FNote field (title, isProtected, type, mime) |
useNoteProperty(note, "title", componentId) |
loadResults.isNoteReloaded(noteId, componentId) |
| a label value (read/write) |
useNoteLabel / …Boolean / …Int / …WithDefault / …OptionalBool |
getAttributeRows() + attributes.isAffecting(attr, note) — handles inheritance and templates |
| a label the user named themselves |
useNoteLabelByName |
same; useNoteLabel delegates to it but types the name to the declared vocabulary |
| a relation (read/write, or resolve the target) |
useNoteRelation / useNoteRelationTarget |
the same attribute filter |
| note binary content / blob |
useNoteBlob(note, componentId, { reportLoadStateTo }) |
isNoteContentReloaded + an explicit delete check + a requestId stale-guard |
| child notes / subtree |
useChildNotes(parentNoteId) |
getBranchRows() parent match + frocaReloaded |
an FNote by id |
useNote / useNoteTitle / useNoteIcon / useNoteColorClass |
cache-first + the matching reload filter |
| a synced option (read/write) |
useTriliumOption / …Bool / …Int / …Json / useTriliumOptions |
getOptionNames() |
| the split's note context |
useNoteContext() |
setNoteContext / noteSwitched / frocaReloaded / hoisted / readOnly |
| the active (focused) context |
useActiveNoteContext() |
same, plus re-resolving notePath when the note is moved |
| read-only / temporarily-editable state |
useIsNoteReadOnly / useEffectiveReadOnly |
the readOnly label + readOnlyTemporarilyDisabled |
| editor autosave plumbing |
useEditorSpacedUpdate / useBlobEditorSpacedUpdate |
spaced-update + the provenance guard for #9614 |
| fullscreen state for an overlay button |
useFullscreen(element) |
the Fullscreen API + fullscreenchange |
| publish/consume cross-widget data |
useSetContextData / useGetContextData |
contextDataChanged |
| a raw Trilium event |
useTriliumEvent / useTriliumEvents |
registerHandler/removeHandler on ParentComponent |
| embed a legacy jQuery widget |
useLegacyWidget |
the child() / render() / activeContextChanged bridge |
The entitiesReloaded filter taxonomy
If you genuinely must hand-write useTriliumEvent("entitiesReloaded", ({ loadResults }) => …), pick the right predicate — the wrong one silently never fires:
| Changed |
Predicate |
| note row or its attributes |
loadResults.isNoteReloaded(noteId, componentId) |
| blob / content (not the note row) |
loadResults.isNoteContentReloaded(noteId, componentId) |
| a label or relation |
iterate loadResults.getAttributeRows(), keep attributes.isAffecting(attr, note) |
| children added/removed/moved |
loadResults.getBranchRows() (match parentNoteId) |
| options |
loadResults.getOptionNames() |
| the whole cache was swapped (e.g. protected session) |
the separate frocaReloaded event — re-read FNote refs, the old ones are orphaned |
Three things people get wrong:
isNoteReloaded ≠ isNoteContentReloaded. The note row/attributes and the blob are tracked in separate maps. Using one to refresh the other compiles, runs, and never updates.
- Attribute ownership is not
attr.noteId === note.noteId. attributes.isAffecting() walks getNotesToInheritAttributesFrom() and, for inheritable attributes, hasAncestor() — so a naive id check misses inherited and templated attributes.
- Pass
componentId when the same component both saves and listens. isNoteReloaded/isNoteContentReloaded skip the originating component, so without it the widget gets its own save echoed back and clobbers fresher user-typed input.
Reusable component catalogue
Form controls:
FormTextBox — text input with validation and controlled input handling; FormTextBoxWithUnit for a unit suffix (mm, px). FormTextArea, PasswordField, FormPasswordWithConfirmation for their cases.
FormSelect — dropdown/combobox taking an object array as data. FormDropdownList for a list of items with icons; FormAutocomplete / NoteAutocomplete for typeahead (the latter searches notes).
FormCheckbox, FormToggle, FormRadioGroup / FormInlineRadioGroup — boolean and exclusive choices. SegmentedChoice is a row of buttons acting as one exclusive choice (use where a dropdown would hide the alternatives).
Slider — range slider with label.
ColorPicker — preset swatches plus the browser's native <input type="color">; value is a CSS color string, onChange(null) clears. It is a controlled, flat swatch row: wrap it in a Dropdown for a popover or use it inline; don't hand-roll a palette. NoteColorPicker is the note-bound variant that reads/writes the note's color label.
IconPicker — boxicons picker.
FormGroup — label + control + hint row for settings panes; PropertySheet for a whole sheet of them.
FormFileUpload, FileDropZone — file input and drag-and-drop target.
Buttons and links:
Button — the general button (also carries the tooltip for a disabled control via a wrapper, since a disabled <button> emits no pointer events). ActionButton — icon button with consistent styling.
HelpButton, HelpTooltipButton, HelpDropdown — open in-app help pages; don't invent a new "?" affordance.
KeyboardShortcut — renders a shortcut as keycaps.
Never hand-roll <a className="tn-link">. Which link component to reach for depends on what the link is:
| What the link does |
Component |
| Runs an action, worded as a link rather than a button |
LinkButton (href="#", onClick or triggerCommand; already role="button" and Space-activated) |
| Goes to a note path with wording of your own — a summary sentence, "see the backup page" |
PageLink (in LinkButton.tsx; suppresses the note preview, since text that already says what is there gains nothing from one) |
| Goes to a note, named by the note's own title |
NoteLink (icon, color class, note path, hover preview) or NewNoteLink for the Preact-native variant |
| Opens a filesystem location in the OS file manager |
DirectoryLink / FileLink (degrade to plain selectable text off Electron) |
A raw <a> is right only for an external URL, which no component covers: write it as <a className="tn-link external" target="_blank" rel="noopener noreferrer"> (the theme appends the ↗ arrow from the external class or an http(s) href; no-arrow opts out). For a block of prose whose links come from elsewhere — rendered note content, a help page — put use-tn-links on the container instead of classing each anchor.
no-tooltip-preview is the class that keeps the note-hover preview away from a link into the note tree; PageLink and NoteLink's noPreview set it for you, so it should rarely be written by hand.
Display and layout:
NoItems — empty state placeholder with icon and message ("no results", "too many items", error states).
Badge — colored pill/label with optional icon, tooltip and onClick (counts, status flags). Set its color through the --color CSS variable on a wrapper class, not inline styles; pass outline for a colored-border/transparent-fill variant. BadgeWithDropdown pairs a badge with a dropdown menu. Don't hand-roll pill/badge markup.
Chip — one entry of a set, with a remove button.
Alert, InfoBar, Admonition, ContentErrorMessage, RenderErrorCard — inline notices and error surfaces of increasing weight.
Card — a titled group of sections (filter-aware under FilterProvider). Collapsible — animated, theme-styled expandable section with self-managed initiallyExpanded; ExternallyControlledCollapsible is the controlled variant (caller owns expanded/setExpanded).
TabStrip — a row of icon-only tabs named by tooltips, heading a panel divided into groups.
Modal, WizardModal — dialogs; Popover — a small surface anchored beside something (portaled to the body, so no scroll container clips it).
LoadingSpinner, LazyComponent, Icon, MaskedIcon, NoteList, SiblingNavigator, ImageViewer, CodeBlock, charts/DonutChart, charts/Treemap.
Data grids and calendars (outside widgets/react/, but equally generic):
Table — apps/client/src/widgets/collections/table/tabulator.tsx, a Tabulator wrapper (columns, data, events, modules, tabulatorRef; typed via TableProps<T>). Decoupled from the note/collection model — use it for any grid (SQL console results, the collection table view). Never instantiate tabulator-tables directly.
Calendar — apps/client/src/widgets/collections/calendar/calendar.tsx, a FullCalendar wrapper (any CalendarOptions plus calendarRef; typed via CalendarProps). Use it for any calendar rather than @fullcalendar/core directly.
Overlays over a note's content — OverlayControlGroup, OverlayToolbar, OverlayPanel — see the dedicated section below.
Two rules that apply to all of them:
- Do not use Bootstrap utility classes (
form-control-sm, form-select-sm, input-group, …) on these components — they manage their own styling. Adjust sizing or layout through the component's props or its CSS custom properties, not Bootstrap overrides.
- Before adding a prop or a variant, read the component's own doc comment; most already have the variant (outline badge, controlled collapsible, popover-wrapped picker).
Dropdown menus and the backdrop blur
Dropdown is the Bootstrap dropdown wrapper (toggle button + menu, with FormListItem / FormDropdownDivider as items). The Next theme frosts every .dropdown-menu with backdrop-filter, along two different paths, and only one is reliable:
::before layer (default for a menu without tn-dropdown-list) — the blur lives on a background-less pseudo-element at z-index: -1. Works everywhere.
- Element-level filter (what the
tn-dropdown-list class switches to) — the blur sits on the menu element itself, which also paints a translucent background. It exists only because a scrollable menu can't use the pseudo (it would scroll away with the content). Opened inside the note's scrolling content area, this filter silently does nothing and the menu degrades to its bare ~85 %-alpha background — see-through over anything dark. body.background-effects already forces such menus to an opaque fallback for the same reason (see the comment in theme-next/base.css).
Dropdown adds tn-dropdown-list by default, so a new menu opts into the fragile path unless told otherwise:
- Pass
noDropdownListStyle on any menu that doesn't scroll — nearly every action/[…] menu. NoteActions, the global menu, the note-icon picker and HelpDropdown all do.
- Pass
portalToBody instead when the menu is fine but an ancestor establishes a containment/backdrop root (container-type, transform, filter — e.g. the peeked right pane), which flattens the blur into a flat tint.
- If a menu looks transparent rather than frosted, check these two before reaching for CSS overrides.
A transformed ancestor also breaks placement and stacking
portalToBody has a second, unrelated reason to exist. body.mobile .dropdown-menu forces position: fixed, and the settings dialog's .modal-dialog carries a transform (the mobile master-detail slide between the page list and the page). A transformed ancestor is both:
- the containing block for
position: fixed, so a menu left to place itself lands adrift — measured at 160×160 at x=209, mid-page, on a Pixel 7 viewport; and
- a stacking context, so the menu's
z-index: 3000 is confined inside the modal's own z-index: 1055. #context-menu-cover (the mobileBackdrop) is a <body> child at z-index: 2500, so it paints above the whole modal — dimming the menu along with the page behind it.
Both symptoms read as CSS bugs rather than containing-block ones. A mobile menu inside a modal wants portalToBody plus dropdownContainerClassName="mobile-bottom-menu" — see CollapsedChoice in apps/client/src/widgets/react/SegmentedChoice.tsx. Diagnose by walking the menu's ancestors for transform / filter / container-type in the running app rather than reasoning about the stylesheets.
Controls floating over a note's content
Any control laid over a note's own content — a geo map, a mind map, an image, a video, a diagram, a presentation, and whatever comes next — goes on one of two shared components. Never hand-roll a <button> with tn-overlay-* classes: those classes are the components' business, and a site that writes them itself also has to write the ref, the tooltip, the aria-label and the type="button" that the component already owns.
OverlayControlGroup (OverlayControlGroup.tsx) — a run of buttons joined edge to edge into one segmented chip. This is the default, and specifically what zoom steps, a zoom/scale readout, next/previous navigation, fullscreen and "add a thing to this view" buttons are built from. Its buttons are OverlayControlButton; OverlayFullscreenButton is the ready-made fullscreen toggle (pass it isFullscreen + onToggle from useFullscreen in hooks.tsx).
OverlayToolbar (OverlayToolbar.tsx) — separate buttons spaced out on their own pane of frosted glass. Use it only where the controls are not one run of related steps (e.g. the mind map's four layout-direction choices).
OverlayPanel (OverlayPanel.tsx) — a panel over a dragged/zoomed canvas holding what can be done with the current selection (header row + dismiss button included).
Rules for OverlayControlButton, which cover every case seen so far:
title is the tooltip. A button wearing no words is also named by it; one that wears words is named by them. Do not add an aria-label — the component decides, and a title that says more at length would otherwise speak over the visible words. The one exception is a face that is neither words nor a mark (a keycap, a glyph standing for itself), which passes a plain aria-label of its own.
icon is a boxicons name (bx-plus-circle), text is what stands inside. Given both, the component renders the mark as a child span itself — never put a bx class on a button that also wears words, since the icon font would fall on the words too.
active/disabled are props, not classes concatenated into className.
- Pass
overCanvas when the group stands over something that is dragged (a map), so a press on a button is not taken for the start of a drag.
- Where the group stands is the group's own, via
placement (top/bottom × start/center/end): it pins itself over the nearest positioned ancestor, and its tooltips open away from that edge. Never write position: absolute plus insets at the call site. What the caller does hand over, in that widget's CSS, is the room to keep from the edges (--overlay-group-inset, or the per-edge --overlay-group-inset-top/-bottom/-start/-end where one edge differs — a fullscreen map clearing a notch, which spells that inset var(--safe-area-inset-top, env(safe-area-inset-top)), never a bare env(), since Android leaves env() at 0) and the z-index. titlePosition overrides the tooltip direction and is rarely right; only add a class to a button if a rule actually uses it.
- Overlay controls exist in every layout. Never gate a group on
isExperimentalFeatureEnabled("new-layout"), and when an action moves onto a group, delete its twin in FloatingButtonsDefinitions.tsx rather than keeping both: that bar only renders in the old layout (desktop_layout.tsx mounts it with !isNewLayout), so a layout-gated group plus a floating fallback means two implementations of one button that drift. Keep the underlying app commands (e.g. relationMapResetZoomIn) even once the group is their only caller — note scripts can fire them via api.triggerCommand.
- Reuse shared labels from the
common translation namespace (e.g. common.fullscreen) rather than adding a per-widget copy of a string the app already has.
Worked examples to read before adding a new one: apps/client/src/widgets/type_widgets/mind_map/MapToolbar.tsx (group + toolbar + fullscreen), type_widgets/relation_map/MapToolbar.tsx, collections/geomap/MapToolbar.tsx, type_widgets/helpers/SvgSplitEditor.tsx.
Dialogs
Dialogs are event-summoned and lazy-mounted. Worked example: apps/client/src/widgets/dialogs/sort_child_notes.tsx.
- State:
const [ shown, setShown ] = useState(false);
- Summon:
useTriliumEvent("yourEvent", (data) => { …; setShown(true); });
- Render a controlled
<Modal>:<Modal
className="your-dialog" // static literal — Bootstrap mutates classList (fade/show)
show={shown}
=> setShown(false)} // MANDATORY — see below
// optional: wraps the body in a form, Enter submits
title={t("…")} size="lg"
>…</Modal>
- Register it in
applyModals() in apps/client/src/layouts/layout_commons.tsx:.child(<LazyDialog triggerEvents={["yourEvent"]} loader={() => import("../widgets/dialogs/your.js")} />)
Skip this and nothing summons the dialog — the event has no listener and the modal never mounts.
- Eager (un-lazy) registration is only for the four documented exceptions at the end of
applyModals() — see the reference.
Two Modal footguns:
onHidden is required and must setShown(false). Bootstrap closing the modal (backdrop, close button, submit) does not touch React state; if show stays true, the next show=true is a no-op and the dialog can't reopen.
- Keep
className a static string. It is rendered as `modal fade mx-auto ${className}` and Bootstrap toggles fade/show on that same element, so a dynamic className fights it.
Full prop reference, LazyDialog mechanics and the eager exceptions: references/dialogs.md.
Footgun checklist
- Reinventing a hook —
useState + server.get + a manual entitiesReloaded listener that a hook already provides. Check the catalogue.
- Wrong reload predicate —
isNoteReloaded (row/attrs) vs isNoteContentReloaded (blob) vs getAttributeRows() + isAffecting (labels/relations, including inherited) vs getBranchRows() (children) vs getOptionNames().
- Omitting
componentId — the saving widget echoes its own change back and overwrites fresher input.
- Dialog registered but not wired — no
<LazyDialog triggerEvents={…}> in applyModals(), so the summon event has no listener and nothing happens.
- Modal can't reopen — a missing/empty
onHidden, or a dynamic className fighting Bootstrap's fade/show.
- Bootstrap utility classes or inline static styles on
Form* components — use the sibling per-component .css scoped under a root class.
- Hand-rolled overlay buttons —
tn-overlay-* classes at a call site instead of OverlayControlGroup / OverlayControlButton.
- Copying the jQuery lifecycle (
doRenderBody / refreshWithNote / this.$widget) into new .tsx — use useLegacyWidget only to embed an existing widget.
- Diagnosing a style or placement bug from the stylesheets — the load order in a hand-built test page does not match the app's, so the bug can fail to reproduce and send you after the wrong cause. Measure computed styles in a running instance: references/inspecting-the-running-app.md.
Not a footgun here: isElectron() / isMac() from apps/client/src/services/utils.ts are runtime checks safe at module load. The "call only after init" trap is a trilium-core concern (utils/index.ts → getPlatform()), not client UI.
Reference map
| File |
Read it for |
| references/hooks-catalog.md |
All 60 use* hooks grouped by purpose, one line each with the loadResults filter it wires — the anti-re-derivation asset. |
| references/dialogs.md |
Full Modal prop reference, the 5-step summon→register recipe, LazyDialog mechanics, the four eager exceptions, the sort_child_notes.tsx walkthrough. |
| references/inspecting-the-running-app.md |
Booting a login-free instance on the e2e fixture document (in memory, no build, no password) to measure real computed styles, plus how to read a cascade or stacking problem in the running app. Reach for it whenever the answer depends on what actually won, not on what the stylesheets say. |
Related skills: writing-unit-tests (rendering these components and testing hooks with the easy-froca fixtures), analyzing-coverage (measuring client coverage), working-with-translations (adding the English strings these components display).
1---2name: building-client-ui3description: Use when building or changing any UI in the Trilium client (`apps/client`) — a dialog, a settings pane, a form, a toolbar, a badge, a link, a dropdown menu, a type widget, a control floating over a note's content (map, mind map, image, diagram, presentation), or any component that reads/writes a note's title, label, relation, blob or option. Catalogues the froca-reactive hooks (useNoteProperty / useNoteLabel / useNoteBlob / useChildNotes / useTriliumOption / useNoteContext) and which `loadResults` filter each already wires, the reusable Preact components under `apps/client/src/widgets/react/` (which one to reach for instead of a hand-rolled `<input>`/`<button>`/`<a>`/pill), the `Dropdown` backdrop-blur rules (`noDropdownListStyle` / `portalToBody`), the `OverlayControlGroup` / `OverlayToolbar` contract for controls over a canvas, the event-summoned Modal/LazyDialog wiring, and how to boot a login-free fixture instance to inspect real computed styles when a style, placement or stacking bug cannot be read off t4---56# Building client UI78The client is a Preact app (legacy widgets are jQuery `BasicWidget`s; new UI is Preact). Shared components live in `apps/client/src/widgets/react/`. **Always reuse them instead of writing raw HTML elements or a custom implementation** — every hand-rolled `<input>`, `<select>`, `<button>`, `<a>`, pill or overlay button is a second copy of styling, focus handling and accessibility that drifts.910The general styling rules (no inline styles, one `.css` file per component imported at the top, scope by root class + native CSS nesting) are in the repo `CLAUDE.md`; this skill is about *which component* and *how to drive it*.1112> **CLAUDE.md still foregrounds the jQuery widget lifecycle** (`doRenderBody` / `refreshWithNote` / `this.$widget`). That is for maintaining existing widgets. New UI is a `.tsx` component using the hooks below; reach for `useLegacyWidget` only to embed an existing jQuery widget into a Preact tree.1314## Don't reinvent froca-reactive state1516The most common mistake on this surface is writing `useState` + `server.get`/`froca` + a hand-rolled `entitiesReloaded` listener for something a **one-line hook already does** — with the right reload filter, the right echo-suppression, and the stale-request guard. `apps/client/src/widgets/react/hooks.tsx` exports 60 `use*` hooks. Check the catalogue first: [references/hooks-catalog.md](references/hooks-catalog.md).1718| You need… | Use | It already wires |19|---|---|---|20| a scalar `FNote` field (title, isProtected, type, mime) | `useNoteProperty(note, "title", componentId)` | `loadResults.isNoteReloaded(noteId, componentId)` |21| a label value (read/write) | `useNoteLabel` / `…Boolean` / `…Int` / `…WithDefault` / `…OptionalBool` | `getAttributeRows()` + `attributes.isAffecting(attr, note)` — handles inheritance and templates |22| a label the user named themselves | `useNoteLabelByName` | same; `useNoteLabel` delegates to it but types the name to the declared vocabulary |23| a relation (read/write, or resolve the target) | `useNoteRelation` / `useNoteRelationTarget` | the same attribute filter |24| note binary content / blob | `useNoteBlob(note, componentId, { reportLoadStateTo })` | `isNoteContentReloaded` + an explicit delete check + a requestId stale-guard |25| child notes / subtree | `useChildNotes(parentNoteId)` | `getBranchRows()` parent match + `frocaReloaded` |26| an `FNote` by id | `useNote` / `useNoteTitle` / `useNoteIcon` / `useNoteColorClass` | cache-first + the matching reload filter |27| a synced option (read/write) | `useTriliumOption` / `…Bool` / `…Int` / `…Json` / `useTriliumOptions` | `getOptionNames()` |28| the split's note context | `useNoteContext()` | setNoteContext / noteSwitched / frocaReloaded / hoisted / readOnly |29| the *active* (focused) context | `useActiveNoteContext()` | same, plus re-resolving notePath when the note is moved |30| read-only / temporarily-editable state | `useIsNoteReadOnly` / `useEffectiveReadOnly` | the `readOnly` label + `readOnlyTemporarilyDisabled` |31| editor autosave plumbing | `useEditorSpacedUpdate` / `useBlobEditorSpacedUpdate` | spaced-update + the provenance guard for #9614 |32| fullscreen state for an overlay button | `useFullscreen(element)` | the Fullscreen API + `fullscreenchange` |33| publish/consume cross-widget data | `useSetContextData` / `useGetContextData` | `contextDataChanged` |34| a raw Trilium event | `useTriliumEvent` / `useTriliumEvents` | registerHandler/removeHandler on `ParentComponent` |35| embed a legacy jQuery widget | `useLegacyWidget` | the `child()` / `render()` / `activeContextChanged` bridge |3637## The `entitiesReloaded` filter taxonomy3839If you genuinely must hand-write `useTriliumEvent("entitiesReloaded", ({ loadResults }) => …)`, pick the **right predicate** — the wrong one silently never fires:4041| Changed | Predicate |42|---|---|43| note row or its attributes | `loadResults.isNoteReloaded(noteId, componentId)` |44| blob / content (**not** the note row) | `loadResults.isNoteContentReloaded(noteId, componentId)` |45| a label or relation | iterate `loadResults.getAttributeRows()`, keep `attributes.isAffecting(attr, note)` |46| children added/removed/moved | `loadResults.getBranchRows()` (match `parentNoteId`) |47| options | `loadResults.getOptionNames()` |48| the whole cache was swapped (e.g. protected session) | the separate `frocaReloaded` event — re-read `FNote` refs, the old ones are orphaned |4950Three things people get wrong:5152- **`isNoteReloaded` ≠ `isNoteContentReloaded`.** The note row/attributes and the blob are tracked in *separate* maps. Using one to refresh the other compiles, runs, and never updates.53- **Attribute ownership is not `attr.noteId === note.noteId`.** `attributes.isAffecting()` walks `getNotesToInheritAttributesFrom()` and, for inheritable attributes, `hasAncestor()` — so a naive id check misses inherited and templated attributes.54- **Pass `componentId`** when the same component both saves and listens. `isNoteReloaded`/`isNoteContentReloaded` skip the originating component, so without it the widget gets its own save echoed back and clobbers fresher user-typed input.5556## Reusable component catalogue5758Form controls:5960- `FormTextBox` — text input with validation and controlled input handling; `FormTextBoxWithUnit` for a unit suffix (`mm`, `px`). `FormTextArea`, `PasswordField`, `FormPasswordWithConfirmation` for their cases.61- `FormSelect` — dropdown/combobox taking an object array as data. `FormDropdownList` for a list of items with icons; `FormAutocomplete` / `NoteAutocomplete` for typeahead (the latter searches notes).62- `FormCheckbox`, `FormToggle`, `FormRadioGroup` / `FormInlineRadioGroup` — boolean and exclusive choices. `SegmentedChoice` is a row of buttons acting as one exclusive choice (use where a dropdown would hide the alternatives).63- `Slider` — range slider with label.64- `ColorPicker` — preset swatches plus the browser's native `<input type="color">`; value is a CSS color string, `onChange(null)` clears. It is a controlled, flat swatch row: wrap it in a `Dropdown` for a popover or use it inline; don't hand-roll a palette. `NoteColorPicker` is the note-bound variant that reads/writes the note's `color` label.65- `IconPicker` — boxicons picker.66- `FormGroup` — label + control + hint row for settings panes; `PropertySheet` for a whole sheet of them.67- `FormFileUpload`, `FileDropZone` — file input and drag-and-drop target.6869Buttons and links:7071- `Button` — the general button (also carries the tooltip for a *disabled* control via a wrapper, since a disabled `<button>` emits no pointer events). `ActionButton` — icon button with consistent styling.72- `HelpButton`, `HelpTooltipButton`, `HelpDropdown` — open in-app help pages; don't invent a new "?" affordance.73- `KeyboardShortcut` — renders a shortcut as keycaps.7475**Never hand-roll `<a className="tn-link">`.** Which link component to reach for depends on what the link *is*:7677| What the link does | Component |78| --- | --- |79| Runs an action, worded as a link rather than a button | `LinkButton` (`href="#"`, `onClick` or `triggerCommand`; already `role="button"` and Space-activated) |80| Goes to a note path with wording of your own — a summary sentence, "see the backup page" | `PageLink` (in `LinkButton.tsx`; suppresses the note preview, since text that already says what is there gains nothing from one) |81| Goes to a note, named by the note's own title | `NoteLink` (icon, color class, note path, hover preview) or `NewNoteLink` for the Preact-native variant |82| Opens a filesystem location in the OS file manager | `DirectoryLink` / `FileLink` (degrade to plain selectable text off Electron) |8384A raw `<a>` is right only for an **external** URL, which no component covers: write it as `<a className="tn-link external" target="_blank" rel="noopener noreferrer">` (the theme appends the ↗ arrow from the `external` class or an `http(s)` href; `no-arrow` opts out). For a block of prose whose links come from elsewhere — rendered note content, a help page — put `use-tn-links` on the container instead of classing each anchor.8586`no-tooltip-preview` is the class that keeps the note-hover preview away from a link into the note tree; `PageLink` and `NoteLink`'s `noPreview` set it for you, so it should rarely be written by hand.8788Display and layout:8990- `NoItems` — empty state placeholder with icon and message ("no results", "too many items", error states).91- `Badge` — colored pill/label with optional icon, tooltip and `onClick` (counts, status flags). Set its color through the `--color` CSS variable on a wrapper class, not inline styles; pass `outline` for a colored-border/transparent-fill variant. `BadgeWithDropdown` pairs a badge with a dropdown menu. Don't hand-roll pill/badge markup.92- `Chip` — one entry of a set, with a remove button.93- `Alert`, `InfoBar`, `Admonition`, `ContentErrorMessage`, `RenderErrorCard` — inline notices and error surfaces of increasing weight.94- `Card` — a titled group of sections (filter-aware under `FilterProvider`). `Collapsible` — animated, theme-styled expandable section with self-managed `initiallyExpanded`; `ExternallyControlledCollapsible` is the controlled variant (caller owns `expanded`/`setExpanded`).95- `TabStrip` — a row of icon-only tabs named by tooltips, heading a panel divided into groups.96- `Modal`, `WizardModal` — dialogs; `Popover` — a small surface anchored beside something (portaled to the body, so no scroll container clips it).97- `LoadingSpinner`, `LazyComponent`, `Icon`, `MaskedIcon`, `NoteList`, `SiblingNavigator`, `ImageViewer`, `CodeBlock`, `charts/DonutChart`, `charts/Treemap`.9899Data grids and calendars (outside `widgets/react/`, but equally generic):100101- `Table` — `apps/client/src/widgets/collections/table/tabulator.tsx`, a [Tabulator](https://tabulator.info/) wrapper (`columns`, `data`, `events`, `modules`, `tabulatorRef`; typed via `TableProps<T>`). Decoupled from the note/collection model — use it for any grid (SQL console results, the collection table view). Never instantiate `tabulator-tables` directly.102- `Calendar` — `apps/client/src/widgets/collections/calendar/calendar.tsx`, a [FullCalendar](https://fullcalendar.io/) wrapper (any `CalendarOptions` plus `calendarRef`; typed via `CalendarProps`). Use it for any calendar rather than `@fullcalendar/core` directly.103104Overlays over a note's content — `OverlayControlGroup`, `OverlayToolbar`, `OverlayPanel` — see the dedicated section below.105106Two rules that apply to all of them:107108- **Do not use Bootstrap utility classes** (`form-control-sm`, `form-select-sm`, `input-group`, …) on these components — they manage their own styling. Adjust sizing or layout through the component's props or its CSS custom properties, not Bootstrap overrides.109- Before adding a prop or a variant, read the component's own doc comment; most already have the variant (outline badge, controlled collapsible, popover-wrapped picker).110111## Dropdown menus and the backdrop blur112113`Dropdown` is the Bootstrap dropdown wrapper (toggle button + menu, with `FormListItem` / `FormDropdownDivider` as items). The Next theme frosts every `.dropdown-menu` with `backdrop-filter`, along **two different paths**, and only one is reliable:114115- **`::before` layer** (default for a menu *without* `tn-dropdown-list`) — the blur lives on a background-less pseudo-element at `z-index: -1`. Works everywhere.116- **Element-level filter** (what the `tn-dropdown-list` class switches to) — the blur sits on the menu element itself, which also paints a translucent background. It exists only because a **scrollable** menu can't use the pseudo (it would scroll away with the content). Opened inside the note's scrolling content area, this filter silently does nothing and the menu degrades to its bare ~85 %-alpha background — see-through over anything dark. `body.background-effects` already forces such menus to an opaque fallback for the same reason (see the comment in `theme-next/base.css`).117118`Dropdown` adds `tn-dropdown-list` **by default**, so a new menu opts into the fragile path unless told otherwise:119120- Pass **`noDropdownListStyle`** on any menu that doesn't scroll — nearly every action/`[…]` menu. `NoteActions`, the global menu, the note-icon picker and `HelpDropdown` all do.121- Pass **`portalToBody`** instead when the menu is fine but an *ancestor* establishes a containment/backdrop root (`container-type`, `transform`, `filter` — e.g. the peeked right pane), which flattens the blur into a flat tint.122- If a menu looks transparent rather than frosted, check these two before reaching for CSS overrides.123124### A transformed ancestor also breaks placement and stacking125126`portalToBody` has a second, unrelated reason to exist. `body.mobile .dropdown-menu` forces `position: fixed`, and the settings dialog's `.modal-dialog` carries a `transform` (the mobile master-detail slide between the page list and the page). A transformed ancestor is both:1271281. the **containing block** for `position: fixed`, so a menu left to place itself lands adrift — measured at 160×160 at x=209, mid-page, on a Pixel 7 viewport; and1292. a **stacking context**, so the menu's `z-index: 3000` is confined inside the modal's own `z-index: 1055`. `#context-menu-cover` (the `mobileBackdrop`) is a `<body>` child at `z-index: 2500`, so it paints above the *whole* modal — dimming the menu along with the page behind it.130131Both symptoms read as CSS bugs rather than containing-block ones. A mobile menu inside a modal wants `portalToBody` **plus** `dropdownContainerClassName="mobile-bottom-menu"` — see `CollapsedChoice` in `apps/client/src/widgets/react/SegmentedChoice.tsx`. Diagnose by walking the menu's ancestors for `transform` / `filter` / `container-type` in the running app rather than reasoning about the stylesheets.132133## Controls floating over a note's content134135Any control laid **over** a note's own content — a geo map, a mind map, an image, a video, a diagram, a presentation, and whatever comes next — goes on one of two shared components. **Never hand-roll a `<button>` with `tn-overlay-*` classes**: those classes are the components' business, and a site that writes them itself also has to write the ref, the tooltip, the `aria-label` and the `type="button"` that the component already owns.136137- **`OverlayControlGroup`** (`OverlayControlGroup.tsx`) — a run of buttons joined edge to edge into one segmented chip. **This is the default**, and specifically what zoom steps, a zoom/scale readout, next/previous navigation, fullscreen and "add a thing to this view" buttons are built from. Its buttons are `OverlayControlButton`; `OverlayFullscreenButton` is the ready-made fullscreen toggle (pass it `isFullscreen` + `onToggle` from `useFullscreen` in `hooks.tsx`).138- **`OverlayToolbar`** (`OverlayToolbar.tsx`) — separate buttons spaced out on their own pane of frosted glass. Use it only where the controls are *not* one run of related steps (e.g. the mind map's four layout-direction choices).139- **`OverlayPanel`** (`OverlayPanel.tsx`) — a panel over a dragged/zoomed canvas holding what can be done with the current selection (header row + dismiss button included).140141Rules for `OverlayControlButton`, which cover every case seen so far:142143- **`title` is the tooltip.** A button wearing no words is also named by it; one that wears words is named by them. Do not add an `aria-label` — the component decides, and a title that says more at length would otherwise speak over the visible words. The one exception is a face that is neither words nor a mark (a keycap, a glyph standing for itself), which passes a plain `aria-label` of its own.144- **`icon` is a boxicons name** (`bx-plus-circle`), **`text` is what stands inside**. Given both, the component renders the mark as a child span itself — never put a `bx` class on a button that also wears words, since the icon font would fall on the words too.145- **`active`/`disabled` are props**, not classes concatenated into `className`.146- **Pass `overCanvas`** when the group stands over something that is dragged (a map), so a press on a button is not taken for the start of a drag.147- **Where the group stands is the group's own**, via `placement` (`top`/`bottom` × `start`/`center`/`end`): it pins itself over the nearest positioned ancestor, and its tooltips open away from that edge. **Never write `position: absolute` plus insets at the call site.** What the caller does hand over, in that widget's CSS, is the room to keep from the edges (`--overlay-group-inset`, or the per-edge `--overlay-group-inset-top`/`-bottom`/`-start`/`-end` where one edge differs — a fullscreen map clearing a notch, which spells that inset `var(--safe-area-inset-top, env(safe-area-inset-top))`, never a bare `env()`, since Android leaves `env()` at `0`) and the `z-index`. `titlePosition` overrides the tooltip direction and is rarely right; only add a class to a *button* if a rule actually uses it.148- **Overlay controls exist in every layout.** Never gate a group on `isExperimentalFeatureEnabled("new-layout")`, and when an action moves onto a group, **delete its twin in `FloatingButtonsDefinitions.tsx`** rather than keeping both: that bar only renders in the old layout (`desktop_layout.tsx` mounts it with `!isNewLayout`), so a layout-gated group plus a floating fallback means two implementations of one button that drift. Keep the underlying app commands (e.g. `relationMapResetZoomIn`) even once the group is their only caller — note scripts can fire them via `api.triggerCommand`.149- Reuse shared labels from the `common` translation namespace (e.g. `common.fullscreen`) rather than adding a per-widget copy of a string the app already has.150151Worked examples to read before adding a new one: `apps/client/src/widgets/type_widgets/mind_map/MapToolbar.tsx` (group + toolbar + fullscreen), `type_widgets/relation_map/MapToolbar.tsx`, `collections/geomap/MapToolbar.tsx`, `type_widgets/helpers/SvgSplitEditor.tsx`.152153## Dialogs154155Dialogs are **event-summoned** and **lazy-mounted**. Worked example: `apps/client/src/widgets/dialogs/sort_child_notes.tsx`.1561571. **State:** `const [ shown, setShown ] = useState(false);`1582. **Summon:** `useTriliumEvent("yourEvent", (data) => { …; setShown(true); });`1593. **Render** a controlled `<Modal>`:160 ```tsx161 <Modal162 className="your-dialog" // static literal — Bootstrap mutates classList (fade/show)163 show={shown}164 onHidden={() => setShown(false)} // MANDATORY — see below165 onSubmit={onSubmit} // optional: wraps the body in a form, Enter submits166 title={t("…")} size="lg"167 >…</Modal>168 ```1694. **Register it** in `applyModals()` in `apps/client/src/layouts/layout_commons.tsx`:170 ```tsx171 .child(<LazyDialog triggerEvents={["yourEvent"]} loader={() => import("../widgets/dialogs/your.js")} />)172 ```173 Skip this and **nothing summons the dialog** — the event has no listener and the modal never mounts.1745. **Eager (un-lazy) registration is only for the four documented exceptions** at the end of `applyModals()` — see the reference.175176Two `Modal` footguns:177178- **`onHidden` is required and must `setShown(false)`.** Bootstrap closing the modal (backdrop, close button, submit) does **not** touch React state; if `show` stays `true`, the next `show=true` is a no-op and the dialog can't reopen.179- **Keep `className` a static string.** It is rendered as `` `modal fade mx-auto ${className}` `` and Bootstrap toggles `fade`/`show` on that same element, so a dynamic className fights it.180181Full prop reference, `LazyDialog` mechanics and the eager exceptions: [references/dialogs.md](references/dialogs.md).182183## Footgun checklist184185- **Reinventing a hook** — `useState` + `server.get` + a manual `entitiesReloaded` listener that a hook already provides. Check the catalogue.186- **Wrong reload predicate** — `isNoteReloaded` (row/attrs) vs `isNoteContentReloaded` (blob) vs `getAttributeRows()` + `isAffecting` (labels/relations, including inherited) vs `getBranchRows()` (children) vs `getOptionNames()`.187- **Omitting `componentId`** — the saving widget echoes its own change back and overwrites fresher input.188- **Dialog registered but not wired** — no `<LazyDialog triggerEvents={…}>` in `applyModals()`, so the summon event has no listener and nothing happens.189- **Modal can't reopen** — a missing/empty `onHidden`, or a dynamic `className` fighting Bootstrap's `fade`/`show`.190- **Bootstrap utility classes or inline static styles on `Form*` components** — use the sibling per-component `.css` scoped under a root class.191- **Hand-rolled overlay buttons** — `tn-overlay-*` classes at a call site instead of `OverlayControlGroup` / `OverlayControlButton`.192- **Copying the jQuery lifecycle** (`doRenderBody` / `refreshWithNote` / `this.$widget`) into new `.tsx` — use `useLegacyWidget` only to embed an existing widget.193- **Diagnosing a style or placement bug from the stylesheets** — the load order in a hand-built test page does not match the app's, so the bug can fail to reproduce and send you after the wrong cause. Measure computed styles in a running instance: [references/inspecting-the-running-app.md](references/inspecting-the-running-app.md).194195> Not a footgun here: `isElectron()` / `isMac()` from `apps/client/src/services/utils.ts` are runtime checks safe at module load. The "call only after init" trap is a **trilium-core** concern (`utils/index.ts` → `getPlatform()`), not client UI.196197## Reference map198199| File | Read it for |200|---|---|201| [references/hooks-catalog.md](references/hooks-catalog.md) | All 60 `use*` hooks grouped by purpose, one line each with the `loadResults` filter it wires — the anti-re-derivation asset. |202| [references/dialogs.md](references/dialogs.md) | Full `Modal` prop reference, the 5-step summon→register recipe, `LazyDialog` mechanics, the four eager exceptions, the `sort_child_notes.tsx` walkthrough. |203| [references/inspecting-the-running-app.md](references/inspecting-the-running-app.md) | Booting a login-free instance on the e2e fixture document (in memory, no build, no password) to measure real computed styles, plus how to read a cascade or stacking problem in the running app. Reach for it whenever the answer depends on what actually won, not on what the stylesheets say. |204205Related skills: **writing-unit-tests** (rendering these components and testing hooks with the easy-froca fixtures), **analyzing-coverage** (measuring client coverage), **working-with-translations** (adding the English strings these components display).