Dialog

The window for one focused task. <Dialog> (packages/ui/src/components/Dialog.tsx) draws the scrim, the panel, the header with the close control and, when asked for, a footer with the actions; the shell classes .acrm-dialog* live in apps/web/src/ds.css, the window states in apps/web/src/ds/dialog-window.css.

One component, three variants — plain, confirm and window states. Until 14 September 2026 Confirm and Window states had cards of their own; measured in the DOM, all three are the same <Dialog> with the same shell, differing only in width and in whether the window can shrink (AD, OV-1). Nothing changed in the app — only how the canon files it.

One dialog, one job. Everything else about it — the size ladder, Escape, the scrim click, the focus trap, the window states — is decided by the component, so a screen never rebuilds a modal by hand.

Measured on 18 September 2026, that last clause now holds in the code as well. An audit of all 99 <Dialog> call sites found no hand-written confirm, no window.confirm and no second backdrop around a canonical dialog; the four places that still drew an overlay of their own were converted in the U2 wave (acrm !3757, !3758) — the notifications panel and the segment icon picker onto Popover, the mobile ⌘K palette onto this shell with the new scrim={false}. What each of them was missing is the argument for the rule: inert, the scroll lock, the Escape order and the focus returning to the trigger.

Variants

  • Plain (<Dialog>) — create or edit something, read a transcript, work through a form. 14 call sites: new deal, merge duplicates, file preview, accounting documents.
  • Confirm (useConfirm() → ask()) — the one way to ask "are you sure?". ConfirmProvider holds a single dialog for the whole app and the call site reads like a question: if (await ask(…)). Underneath it is <Dialog size="s" center>. 41 call sites — everywhere something is deleted or cannot be undone.
  • Window states (windowState + onWindowState) — the same dialog as expanded (modal over the scrim), compact (modeless in the corner while the app stays usable) or min (a bar in the dock at the bottom edge). 2 call sites: writing an e-mail and a note. The contract that makes it worth having: switching states never unmounts the content — a half-written e-mail, a scroll position and an open picker survive minimising and coming back.

When to use

  • A task that must be finished or abandoned before the screen continues: create a record, write a note, send an e-mail — plain.
  • A form that would drown the page it belongs to but is not worth a route of its own — plain.
  • Content that has to be read in one piece (a transcript, a thread) — plain with tall, so the window keeps its height.
  • Before an act that cannot be undone, when the answer is genuinely binary and fits in one or two sentences — confirm.
  • A window the user needs to keep open while doing something else (writing an e-mail, taking a note during a call), or work that is interrupted often — window states.

When not to use

  • Assigning or linking a record (person, company, both, project) when the only content would be a „+ Vybrat…" trigger and an „Přiřadit" button — open the picker directly instead (RULES §21, owner 13 Oct 2026). The pick itself is the confirmation.
  • Reading a record next to a listing — that is Record drawer: no scrim, the listing stays live.
  • A create form in a listing screen — that is Drawer form.
  • A menu, a picker or anything anchored to a control — that is Popover.
  • Choosing one record and nothing else (a picker plus a hint and footer buttons) — that is RecordSelect anchored to the trigger. ds:validate:strict refuses a Dialog whose only control is RecordSelect / EntityPicker / ProductPicker / PickerMenu / CheckMenu (rule dialog-only-picker, 1 Oct 2026); a deliberate exception carries ds-ok: dialog-only-picker — <reason> next to the Dialog.
  • A message about something that already happened — that is a toast.
  • Confirm for a question that needs a field, a choice or an explanation (that is a plain dialog with a form), for something harmless the user can simply undo (offer undo instead), or with three answers — confirm has two.
  • Window states for size="s" or for anything that must block the screen until it is finished — the type refuses the pair on s on purpose.

Anatomy

Shell (all variants)

  • Scrim .acrm-dialog-scrim: fixed, z-index: 80, --ov-scrim fill, padding that shrinks with the window (clamp(var(--sp-3), 11vh, 11vh) on top) — the window docks near the top edge instead of floating in the middle.
  • Panel .acrm-dialog: --surface-100, radius-xl, --overlay-shadow, height driven by the content with the scrim padding as its ceiling; only the body scrolls.
  • Head .acrm-dialog-head: padding: var(--sp-4) var(--sp-5), hairline underneath; optional icon .acrm-dialog-ic or leading slot, title, optional subject, then headerActions, the window controls and the close button — in that order. The window controls and the close button are one row .acrm-dialog-ctl of canonical IconButton size="sm" (24 px box, 16 px glyph, gap --sp-1); a pseudo-element widens each target to 32 px for a mouse and 44 px on touch (pointer: coarse), so the quiet small look never shrinks the target (owner-approved variant C, 29 Sep 2026 — pattern of Linear new issue and Attio create/add-to-list dialogs). The header is 49 px (no min-height). No custom close or window button classes.
  • Body .acrm-dialog-body: padding: var(--sp-5), grid with gap: var(--sp-4), overflow: auto.
  • Foot .acrm-dialog-foot: actions right-aligned with gap: var(--sp-3), hairline above; .lead puts a status sentence on the left.
  • Sizes: s = 400px, m = 750px, l = fluid up to 1200px. width (a number) is the deprecated predecessor of size. Since the U1 wave (17 September 2026, acrm !3741) the ladder holds across the app, and the second, dead dialog shell (.usg-scrim / .usg-shell) that duplicated the scrim, the shadow and the motion of the canon in app CSS is gone. Two numeric widths remain, each waiting for its own decision rather than a mechanical swap: the ⌘K palette (width="min(800px, 94vw)", the acknowledged exception) and the segment-unlock receipt (452 px — three columns that s would wrap). The work-report dialog (640 px) became a form-less document window on 25 September 2026 (acrm !3885).
  • File preview is size="l" with className="work" (acrm !3786, 18 September 2026; it used to be 920 px + tall). l alone would not do: flexi takes its height from the content, and a PDF in an iframe or a DOCX has none, so the preview would collapse. work keeps the full height of the window — the same modifier the e-mail composer and the note use.
  • bare: the shell without head, body and foot — children go straight into .acrm-dialog, with no padding added. Everything else stays shared: the scrim, the focus trap, the scroll lock, the inert background, the Escape stack (Escape closes only the topmost window) and the scrim click. For a window with its own grid — the ⌘K palette (<Dialog bare width="min(800px, 94vw)">, acrm !3529). On mobile the same palette runs bare with scrim={false} (acrm !3758).
  • scrimZIndex: raises the scrim above the canonical 80 when a window must sit over other overlays — the palette runs at 90, under toasts and the P0 takeover at 95. A value at the call site, never an !important in app CSS.
  • scrim={false}: the window is drawn with no backdrop at all — .acrm-dialog-scrim is not rendered and the panel stands on its own. For a window that is the whole screen and therefore has nothing to dim: the ⌘K palette on a phone (acrm !3758). Everything else about the modal stays — aria-modal, the focus trap, the scroll lock, the inert background, the Escape stack and the focus returning to the trigger. Two consequences: closing by clicking the backdrop is gone (there is none), and the z-index has no carrier, so the panel must take it in its own app CSS layer (the palette runs at 90). Before this prop existed the mobile palette drew .acrm-dialog by hand and had none of the modal behaviour.

Confirm

  • <Dialog size="s" center>: 400px, centred rather than docked to the top edge.
  • Header icon by kind: alert-triangle for destructive (danger, the default), help-circle for neutral.
  • Body: the question text .acrm-dialog-confirm-text, one paragraph.
  • Footer: Zrušit and the confirming action — confirmLabel, otherwise the provider labels Smazat (destructive) or Potvrdit (neutral). Labels are injected by the host app, so the component carries no strings of its own.

Window states

  • Expanded: the ordinary modal — scrim z-index: 80, Escape closes, the page waits.
  • Compact: 380 × 460 in the corner, z-index: 78, no scrim (display: contents on the wrapper), the app behind stays live. Escape belongs to the page — the window swallows it so it cannot close a drawer underneath, unless a modal is open, which takes it.
  • Min: the panel is hidden (not removed) and the window portals a bar into the dock.
  • Window controls (IconButton sm in .acrm-dialog-wctl, inside the header row .acrm-dialog-ctl) switch the state; only target states are offered.
  • Bar .acrm-dialog-bar: icon .acrm-dialog-bar-ic, title .acrm-dialog-bar-t, close .acrm-dialog-bar-x; the whole left part is one button that restores the state the window came from.
  • Dock .acrm-dialog-dock: one fixed container in <body> (packages/ui/src/lib/dialog-dock.ts), bars stack upwards, created with the first bar and gone with the last; it publishes --acrm-dialog-dock-n so a compact window steps above it.
  • Grabber .acrm-dialog-grab: mobile only — drag the sheet down to close.

Best practices

Do

  • Give the dialog a title that names the task (Uložit segment, Nový obchod) and, when the task is about a record, put the record in subject or the leading slot.
  • Put exactly one primary action in the footer, on the right, with the verb of the task; Zrušit sits next to it.
  • Use size="s" for one decision or one field, m for a form, l for a work window.
  • Put the document state (Uloženo 12:04) into headerActions or the footer .lead, never into the title.
  • Let long content scroll in the body; the header and footer stay put by design.
  • Confirm: say what will disappear and whether it comes back (Segment se nevratně smaže.); keep the confirming label the verb of the act, so the button alone answers the question; use danger: false when nothing is destroyed; await the answer and do nothing on false.
  • Window states: keep the state in the host that owns the window, restore into the state the window came from, keep the compact window genuinely modeless, and give the window an icon — in the dock it is the only thing distinguishing two bars.

Don't

  • Build a modal from .acrm-dialog markup by hand — that loses Escape, the scrim click and the focus trap. The last exception, the ⌘K palette in GlobalSearch.tsx, moved onto <Dialog bare> in acrm !3529.
  • Open a dialog from a dialog when a single window would do; the stack is legal but each layer costs the reader.
  • Two emphasised buttons in the footer, or a dialog with no way out.
  • A fixed pixel width for new work — the ladder s/m/l exists so windows across screens match.
  • Confirm: chain two confirmations for one act, ask twice about something the user just explicitly asked for, or put a form, a list or a link into the question.
  • Window states: unmount the content on minimise or "move" the panel into the dock (both throw away what the user typed), give a modeless window Escape-to-close, or leave finished windows in the dock.

Content

  • Title in sentence case, no trailing period, no verb-less noun heaps: Nová poznámka, not Poznámka — vytvoření.
  • The footer button says the act: Vytvořit, Odeslat, Uložit, Smazat. Never OK.
  • Confirm question: one or two sentences naming the consequence, not the mechanism — Smazáním firmy zmizí i 12 kontaktů a jejich historie.
  • Window states: the dock bar shows the window title unchanged; window control tooltips are verbs (Rozbalit okno, Zmenšit, Minimalizovat, Zavřít).
  • Explanatory sentences belong in the body, and only when the UI cannot say it by itself.
  • multi-step-dialog — morphKey: one window reshaping between the steps of a flow.
  • formless-dialog — the rule for what goes into a create/edit dialog's body.
  • drawer-form, record-drawer — the drawer alternatives that keep the page usable.
  • popover — anchored layers; a destructive record-menu item (.acrm-popover-item.danger) typically opens the confirm variant.
  • toast — a message about something that already happened.
  • Stories: components--dialog--*, components--confirm--*, components--window-states--*.

Phone (≤720 px) — every dialog is a sheet (owner approved 1. 10. 2026, acrm !4338, verified on dev)

  • Every Dialog with a header renders as a bottom sheet: grab handle, top corners rounded only, backdrop tap and Escape close, the grab handle drags it down. Exceptions: bare palettes (global search) and the non-modal .compact window.
  • Height follows the content, capped at 92 % of the visible viewport (visualViewport, i.e. above the keyboard). Only work windows (.work: note, e-mail) are fixed at 92 %. A long form scrolls inside; header and footer stay.
  • Footer: two buttons side by side, full width, 50/50, 44 px, primary on the right; three or more stacked full width, primary on top and Zrušit at the bottom under a divider (thumb). A footer hint gets its own row above the buttons. Keyboard shortcuts (Esc, ⌘↵) are not drawn on touch. Side margin --sp-5 (16 px).
  • Menus opened from the dialog are panels inside the sheet (InSheet, see popover usage); the calendar stays a sheet above.
  • A floating bulk-action bar hides while a dialog is open.
  • Contract test: apps/web/src/ds/__tests__/dialog-telefon.test.ts.

Rendering layer — Dialog is portalled (AD decision 1. 10. 2026, FA241)

  • Problem: Dialog renders where it sits in JSX and relies on position: fixed. Any ancestor with transform, filter, perspective or contain: paint becomes the containing block, so a dialog opened from a hover bar, popover, animated panel or card collapses into that ancestor (measured 168×0 px in Komunikace › row hover › Štítek › Nový štítek).
  • Rule: Dialog (scrim + panel) is always portalled into ONE stable host directly under <body> (#acrm-overlay-root, created once, never swapped). The portal target never changes between renders, so switching size (s/m/l/flexi, expanded/compact) does NOT remount the content — the reason for the old „render in place“ comment is preserved.
  • inert is then applied to the siblings of the overlay root (the app root), not along the JSX path; a dialog stacked on a dialog keeps the previous behaviour (the lower modal stays inert, the top one owns focus via the existing escTop() stack).
  • Focus: on open, focus moves into the panel as today; on close it returns to the element that opened it (stored on open, also when that element was inside a hover bar that has since unmounted → fall back to the nearest stable ancestor / page heading).
  • Z-index stays the existing band (.acrm-dialog-scrim 80, dialog panel above, popover 101 above dialogs); DOM order inside the overlay root decides between two dialogs.
  • Overlays opened FROM a dialog (menus, pickers, calendar) keep their rules: InSheet panel inside the dialog on the phone, portalled popover layer on desktop.
  • Popover / Menu / Tooltip already portal; they must not depend on ancestors either — any remaining in-place overlay is a bug.
  • Pages never work around this with local transform-free hacks once the Dialog portal lands; the K4e-2 local fix in Komunikace is temporary.

Never a dialog around a picker (AD 1. 10. 2026)

  • Picking a record (assignee, deal, project, participants, recipient) is a RecordSelect anchored on its trigger (button, row, chip) — never a Dialog whose body is just the picker plus a grey hint and Cancel/Confirm. It adds a step and a second surface, no function.
  • A dialog is justified only when the pick is part of more: other fields, a preview of the result, or an irreversible step with its own confirmation.
  • Enforced by ds:validate (rule "dialog-only-picker"); existing cases are in the baseline and only shrink.

Source: docs/dialog/dialog.usage.md · GitLab