Form-less dialog
canonCreates or edits one record without form fields: an icon carries each row and the placeholder names it. Shape A writes text plus properties as chips in the body; shape B asks what · when · with whom · how · where.
Promoted to a rule by the product owner on 9 September 2026 ("promote form-less to a rule and review the other form dialogs with the aim of unifying them"); the pattern itself was approved by the AD on 7 September and shape B shipped to dev on 8 September (ACRM-132, MRs !3163–!3166). The code is the source of truth. THE RULE: form-less when the dialog creates or edits one record whose properties can be named by a placeholder; a form when it is data entry with many typed values and the label carries the meaning — the test is whether the label can be replaced by a placeholder without losing the field identity. Shape B (`.fl-*`) is the documented engine and the only one with a shared sheet. Shape A still runs as three private copies — `.tk-*` (task-dialog.css), `.dl-*` (deal-dialog.css), `.nd-*` + `.dlg-title` (note-dialog.css, dialog-doc.css) — with MetaChip declared twice in TSX; merging them onto `.fl-*` is wave 0 and until it lands shape A is a named shape, not a catalogued one. Adoption measured at main 06fb6b236: 2 dialogs on shape B, 3 on shape A, 43 forms — of which 12 are correctly forms (accounting document 13 fields, Spravovat segment 37, payroll relation, VAT period, mailbox) and 12 are conversion candidates with their questions listed in docs/REVIEW-form-dialogy.md. No stories yet — the pattern lives in apps/web, which packages/ui must not import, so they will be ported markup like Filter bar and Command palette. Three defects open in the shipped code (all in the review): the RSVP dot has no accessible name because aria-label sits on a role="generic" span (EntityPicker.tsx:191); --ic-amber 2.14:1 light and --ic-grey 2.97:1 dark fail the 3:1 non-text minimum while colour is the state’s only carrier; the availability day is mouse-only and role="presentation" (MeetingAvailability.tsx:233). A row may carry several values stacked in `.fl-vals` with the icon on the first: the video-call link sits under the meeting type rather than asking its own question (AD 9 September 2026), which also ended the `link` glyph naming two different rows; the in-person meeting type took `coffee` the same day, so `users` names only the attendees and no glyph in the dialog carries two meanings. The rule is one meaning per AXIS, not per app: the *Typy událostí* filter keeps `users` for its Schůzky row because it switches categories of event rather than describing one meeting (AD, 9 September 2026). The EDIT footer deliberately has no Zrušit — over an existing record the word reads as "cancel the meeting", and the close control already closes the window (product owner, 8 September 2026). The CREATE dialog still has it, because with nothing saved yet the word is unambiguous; whether create should drop it too is open. Delivered 13 Sep 2026 (FL-5; ACRM-223/224/226; acrm !3466–!3476): shape A in all of task, deal and note; header = primary record (EntityRef) then classification, separated by --sp-5 rhythm without a slash, classification and ID in --fg-muted (.head-muted); links chip names records instead of counting them and never repeats the primary; the title is a growing textarea. The note keeps its 1128 × 828 work window. Person dialog (shape B, on main since 15 September 2026, acrm !3561 — `apps/web/src/components/person/NewContactDialog.tsx`, channel editor draft mode in acrm !3548, wiring into the people list and palette in !3575): reuses the record rail's own multi-value channel editor in draft mode (`local-channels.ts`, `draft`/`plainValues`) for e-mail and phone, adds `.fl-dup`/`.fl-chan`/`.fl-chan-v`, and is the first shape-B dialog to use `masked-date-input` for a typed field. `.fl-dup`/`.fl-chan`/`.fl-chan-v` ship in `dialog-formless.css`. See the "person dialog" subsection in formless-dialog.usage.md. Person dialog, custom fields (acrm !3699, 16 Sep 2026, ACRM-277): behind the same "More details" chip, after the tag row, the create dialog draws the tenant's custom fields of a person from the SAME registry the record rail reads (`api.customFields('contact')`) — no drift, a deactivated definition is not drawn. The definition's type picks the input: date → dd.mm.rrrr mask, select and yes/no → PickerMenu (free text would put a value outside the code list into the jsonb, where no filter finds it), number → numeric keyboard but not type="number". Two items per row (like title prefix/suffix), leading glyph `clipboard-list` as in the rail. Values are written only after the record exists, via `setCustomValues` (the only path that merges jsonb; PATCH would overwrite keys from import, enrichment and the agent) and only the filled ones. Task dialog, suggestions from the typed title (acrm !3771, ACRM-213, approved 17 Sep 2026 — Linear "Apply all quick suggestions"): in a NEW task the existing `.flc-navrhy` group also offers what the title says — due date, priority, assignee only, only for an empty property (the default "Ty" assignee counts as empty), assignee only when the name fits exactly one colleague. Chip = canonical `.tk-chip` with the glyph of the property it sets (calendar · flag · user) and the sentence's own word ("zítra", "v pátek"); a quiet `IconButton` bolt (sm, 24px) BEFORE the chips applies all, drawn from two suggestions up. No new CSS. Computed in the browser (`lib/task-suggest.ts`), no model, no server call. ACRM-355 (acrm !3775): the note `.fl-note` is drawn by MentionInput (`.mi-editor.mi-multiline` = form-field frame, 88px); the form-less selectors carry `.mi-editor.mi-multiline.fl-note` so the note stays borderless at rest, on focus and while typing, whatever the CSS order. Relation dialog (shape B, acrm !4011, 27 Sep 2026 — `apps/web/src/components/relations/RelationDialog.tsx`, data/rules in `relations-data.ts`): the „New relation"/„Edit relation" dialog on a person or company card. The anchor (the card's own subject) is fixed as the `LeadEntity` in the header and is never repicked — a relation is an edge between two subjects, and `PATCH /relations/:id` does not accept `from`/`to` — so on edit the anchor never shows as an editable row. Icon+value rows carry no labels. The other side is a **generic entity** — company OR person, one `EntityPicker` (`types={['organization','contact']}`, `max={1}`) — because the product owner asked for the picker to stay general ("vztah můžu mít i s osobou", 27 Sep 2026) rather than splitting into a company dialog and a person dialog. Relation type is a `PickerMenu` grouped by the chosen entity's kind, with a `sep: true` separator row between the two groups; before an entity is picked, both groups show at once (the entity picked after the type discards a type that belongs to the other kind). "Valid from" is optional and typed into the `dd.mm.rrrr` mask (`MaskedDateInput`): the placeholder reads `field.validFromToday` while creating with nothing chosen (empty submits today) and `field.validFrom` on edit (empty keeps the stored date — clearing the field is never a silent delete of the start date). Position and share % rows draw only when the selected relation type's `allowsPosition`/`allowsShare` say so; a field outside the allowed set is always sent as `null` on submit, so switching type (e.g. „Společník" → „Zaměstnanec") clears it server-side instead of leaving a stale value. Footer: create = Cancel `Esc` + „Add relation" `⌘↵`; edit = „Save" only, no Cancel (the family rule — over an existing record `Zrušit` reads as "cancel the relation", and the close control already closes the window). Relationship type catalogue, person↔person (acrm !4010, migration `0416_relation_types_chot_prarodic.sql`, same 27 Sep 2026 change): „Manžel/ka" renamed to „Choť" (one word for both spouses), „Partner/ka" renamed to „Partner", and a new „Prarodič / Vnouče" (Grandparent/Grandchild) type was added after Parent/Child. The migration only rewrites rows still holding the old default name — an admin rename made in Nastavení → Data → Vztahy is left alone. Shape B — the add-to-list dialog (acrm !4019, 27 Sep 2026): `AddToListDialog` (`apps/web/src/components/segment/AddToListDialog.tsx`) is a multi-select-from-search instance of the pattern — "Multi-select from search in a dialog". The card's own record (company or person) is the `LeadEntity` anchor in the header, unpickable, same as the relation dialog's anchor. The body is ONE field, "Hledat nebo založit seznam" (search or create a list), filtering the tenant's manual lists (`segment kind='manual'`) by normalized, diacritic-insensitive match. Each list is a row with the anatomy of `check-menu` — `.acrm-popover-item` holding the list's own segment tile (`.sg`, 18px, reused from the left menu — see "Segment tile debt" below) then the name and member count, with `.acrm-checkbox` on the right (`aria-pressed` on the row) — and a click toggles membership immediately, so the footer carries only "Hotovo". A "Založit seznam „…"" row is appended only when nothing in the list matches the typed text exactly (no row for a name that already exists, so re-adding it is a toggle, not a duplicate). Enter is overloaded on the match count, not just an exact-match shortcut: with exactly one row still shown — even from a partial match like "fin" → "Fintech — sledovat" — Enter toggles that row; otherwise it creates the typed name (verified in `add-to-list-dialog.test.tsx`, case "Enter při jediné ČÁSTEČNÉ shodě list přepne"). Dynamic (filter) segments are excluded from this dialog on purpose — membership in them can't be set by hand, so an earlier version that listed them read-only was pure noise; the old `.atl-row`/`.atl-ic` row shape this dialog used before 27 Sep 2026 is now only used by the Trash page and the duplicates panel, not by any form-less dialog. "Change role" on the company card's person row (acrm !4020, 27 Sep 2026) now opens this same `RelationDialog` with `presetOther` set to the person — not a separate dialog: the older `PersonRoleDialog` component is REMOVED (`company/PersonRoleRelationDialog.tsx` is the new, thin wrapper that resolves the existing relation row, or passes `presetOther` when the person has no relation yet, and renders `RelationDialog`). `presetOther` (a `Mention`) fixes the picked-but-not-yet-saved other side of a NEW relation the same way `anchor` fixes the card's own side, so the entity picker does not show when the context already names both ends. Contact log dialog (shape A, `apps/web/src/components/timeline/ContactLogDialog.tsx`) — ONE dialog for „Zaznamenat kontakt“ on person, company, deal AND project (product owner 27 Sep 2026: „to je ten stejný dialog… použij ten samý prvek z DS“). Project as the main entity (acrm !4084, 28 Sep 2026): `entityType="project"` writes the 4th slot via `POST /timeline/entry` (`projectId`, from !4048) rather than a picked link; the old, project-only `ProjectEntryDialog` is REMOVED (279-line component + its test deleted, `ProjectMeetingsTab` now opens `ContactLogDialog`). Recording chip (acrm !4072, 28 Sep 2026, approved „doplň možnost přidání nahrávky všude“): a `MetaChip` (glyph `microphone`) driven by `PickerMenu` in the chip row — items „Z Plaudu“ (only when `plaudQ.data?.configured`) and „Nahrát soubor“, plus a clear row „Odebrat nahrávku“ once something is picked. The chip's VALUE is the SOURCE (`t('contactLog.recordingPlaud')` / „N z Plaudu“ / „Soubor“), never the file or dictaphone item's name — those run long and the chip must not truncate (source: `apps/web/src/components/meetings/recording-sources.ts`, `RECORDING_SOURCE_ICON`). A log with a recording is submitted as a proběhlá SCHŮZKA (only a meeting can hold a recording): `POST /timeline/entry` returns `meetingId`, then the binary/Plaud import goes as a second call; a failed second call closes the dialog like a success with a warning toast rather than reopening a half-filled form (review !4072). `.fl-note.fl-note-open`: the writing area is the dialog's CORE (REVIEW-form-dialogy.md §3.1) and must hold its three-line height even after focus leaves it for a chip — before this the area collapsed on every chip click and the dialog jumped 38 px (28 Sep 2026). Pitfall, already hit once: the fix must NOT be a bare `.open` class on `.fl-note` — `ds:sync-check` then reads `.x.open` elsewhere in the DS sheet as adopted-and-missing and broke the person-record golden; the CSS/TSX carry the dialog-specific `.fl-note-open` name instead. Header centring: `.acrm-dialog-lead .flc { align-items: center }` plus, in a row with more than the entity alone, `margin-top: -1px` on the wrapper around the entity's `.eref-chip` (its 1px dashed name underline extends the box 1px down, which without the correction put the entity's optical centre 2.8px above the classification chip and the × — product owner 28 Sep 2026: „položky v záhlaví nejsou zarovnané vertikálně na střed“). The single-entity header next to the title alone (ACRM-319) is untouched — the rule only fires with a second flex child. 14 Oct 2026 (owner "26 souhlas", proto/schuzka-ticho@19b777808, acrm !5041): Nová schůzka is the quiet reference — row placeholders are one or two words (Účastníci · Místo nebo odkaz · Cíl · Poznámka pro účastníky), a property of a row (internal goal) is an icon with tooltip, never a sentence in the placeholder. Dev check 14 Oct 1440 + 390: rows single-line (40 / 44 px). Registry repaired 17 Oct 2026 (review F2): removed classes with no CSS definition and no TSX use on main: .fl-copy, .tk-att-w, .tk-att-x. Registry repaired 17 Oct 2026 (review F4/F11): attached css share-dialog-formless.css.
Used in 29 files · 17 areas · 31× — components/communication, components/company, components/deal, components/meetings, components/notes, components/person …
apps/web <NewMeetingForm> <EditMeetingForm> <TaskDialog> <DealDialog> <NoteDialog> <LeadEntity> <RelationDialog> <AddToListDialog> <ContactLogDialog>.acrm-dialog.formless .fl-body .fl-stack .fl-title-row +40SourceStoryA label-less dialog puts its whole accessibility burden on the accessible name of each control. The icon is decoration to assistive technology; what a screen reader hears is the aria-label, so every row must carry one and it must say what the icon says.
Keyboard
⌘↵/Ctrl+↵runs the primary action from anywhere in the body — the handler sits on.fl-body, and it is guarded by the same conditions as the button (a non-empty subject, no recurrence error, a save in flight blocks it), so the shortcut can never do something the button would refuse.- Escape closes the window through the shell's
escStack, and only the topmost dialog answers. - Every row is a real control, so the body is fully reachable by Tab:
.fl-whenand.fl-in.menuare<button>s, the closed note is abutton.fl-in, and the rest areinput/textarea. Rows are not divs with click handlers. - The value inputs are borderless but focusable, and
.fl-row:has(input.fl-text:focus)tints the whole row, so the caret's position is visible even without a field box. - Missing: the availability panel is mouse-only.
.fla-trackpicks a time from a click and is markedrole="presentation", so there is no keyboard path to choose a slot from the day and the axis is hidden from assistive technology altogether. The same time can still be typed into the when-row, which is why this is a limit and not a blocker. - Enter in the name moves to the description (shape A) — a field change, like Tab, so the reader keeps both hands on the keyboard. Only a bare Enter:
⌘/Ctrl+↵still creates the record and Shift/Alt stay free, otherwise the record could not be created from the keyboard at all. When the description already has text, the caret lands at its end, not before it. - Escape belongs to the layer that is on top. A menu opened from a chip consumes Escape in the capture phase, so it closes the menu and the half-written record stays. Without that the dialog's own handler answered first — both listen on
windowin the same phase and the browser calls them in registration order. - Add-to-list dialog (acrm !4019): Enter is overloaded on the visible match count, not just on an exact match — with exactly one list row shown (including from a partial match) Enter toggles it, otherwise it creates the typed name. There is no separate confirm step for either path, so a keyboard-only reader reaches every outcome (toggle an existing list, or create one) from the same field without ever touching a mouse.
- Contact log dialog: one keyboard path for all four entities.
ContactLogDialogis the same component instance for person, company, deal and project (acrm !4084) — there is no separate project variant to fall out of sync, so a keyboard fix made once (Tab order,⌘↵, Escape) holds on every entity screen that opens it. The recording chip's "Nahrát soubor" item drives a hidden native<input type="file">(fileRef.current.click()) from aPickerMenurow, so the file picker opens the same way from Enter/Space as from a mouse click. - The overflow
⋯is a real button (.acrm-icobtn,aria-haspopup="menu",aria-expanded), and choosing a property from it opens that property's menu, so the keyboard path is one decision, not two. - Both halves of a dual chip are separate buttons with their own menus; the wrapper is a
role="group"with the name of the pair. A divider is a border, not a separate element. - The attachment's remove button is a sibling of the tile, not its child — a button inside a link cannot be reached predictably by keyboard. It is revealed on
:focus-visibleas well as on hover, so it is not mouse-only. - Missing: no focus trap — inherited from
dialog. Tab can walk out of the panel into the page behind the scrim.
ARIA
- Panel:
role="dialog",aria-modal="true",aria-label= the title — all from the shell. - Each row's control names itself, because there is no label to point at: subject
field.subject, whenfield.when, locationfield.location, noteformless.note, linksfield.links(throughEntityPicker'slabel+hideLabel), the availability panelformless.availability. - A select trigger names the field and its value together — `
${t('field.type')}: ${value}` — because a trigger whose name is only "Type" tells the reader nothing about what is currently chosen, and a trigger whose name is only the value tells them nothing about what it is. - Icon-only controls inside a row carry both
titleandaria-label: copy (common:copy), open in maps (formless.openMap), the footer kebab (common:moreActions). - The long "add" prompt is truncated visually with an ellipsis, and the full sentence stays in the button's
titleandaria-label. - Add-to-list dialog (acrm !4019): rows are checkbox-shaped, not menu items. Each list row is a
<button>witharia-pressedreflecting membership (the.acrm-checkboxglyph isaria-hidden, purely visual) — so the row's own accessible name is just the list's name, and its pressed state is what says whether the record is a member, not a separate label. The list block isrole="group"witharia-label="Seznamy"("Lists"). - Contact log dialog: the recording chip names the SOURCE, not the file.
MetaChip label= {t('contactLog.recording')} value={recordingValue}follows the standard select-trigger naming above (field + value together — "Nahrávka: Z Plaudu" / "Nahrávka: Soubor"), never the picked file's or Plaud item's own name; those run long and a truncated accessible name would be worse than a short, stable one. The clear item is the family's standardclearLabel/onClearrow ("Odebrat nahrávku"), reachable and announced the same way as every other chip's clear action. - Broken: the participant's status dot has no accessible name. It is a bare
<span class="eref-dot">withtitleandaria-label(EntityPicker.tsx:191). Aspanwith no role maps torole="generic", which does not support naming, so thearia-labelis dropped by conformant assistive technology and the RSVP state reaches a screen reader only when the neighbouring.eref-metatext happens to be rendered. Colour is then the sole carrier of the state. Fix: give the dotrole="img"(or render the state as visually-hidden text).
Focus
- Foundation ring for every control in the dialog:
outline: 2px solid var(--accent)withoutline-offset: 1pxon:focus-visible, declared explicitly on.fl-in.menuand.fl-whenbecause they have no border of their own to thicken. - The borderless text inputs suppress the ring (
.fl-in > input.fl-text:focus { outline: none }) and rely on the row tint instead — deliberate, so a focused row does not look like a box has appeared. The tint is a background change, so it is not exposed to a high-contrast or forced-colors mode; the caret remains the only indicator there. - The note's writing surface grows from one line to three on focus, which is a visible focus cue in its own right, and it is suppressed under
prefers-reduced-motion. - On open, focus moves to the first field — the subject line — so the record can be named immediately.
Contrast
Computed from packages/ui/src/tokens/tokens.css on origin/main with tools/ds-contrast.mjs, light / dark:
- Row text
--fgon--surface-100— 17.59:1 / 14.53:1. - Row icon and placeholder
--fg-mutedon--surface-100— 2.85:1 / 3.63:1. Below AA for text in the light theme, and it applies to every icon and every placeholder in the pattern — which is where the meaning of a label-less row lives. This is the system-wide--fg-mutedlimit, not a choice of this pattern, but it costs more here than elsewhere. - Link value and focus ring
--accenton--surface-100— 4.44:1 / 3.83:1. Passes the 3:1 non-text minimum. - Status dots on
--surface-100: accepted--ic-green3.10:1 / 5.50:1 · declined--ic-rose3.81:1 / 4.47:1 · waiting--ic-grey5.74:1 / 2.97:1 · tentative--ic-amber2.14:1 / 7.96:1. Amber in the light theme and grey in the dark theme fall below the 3:1 minimum for a meaningful non-text mark. - Row qualifier
.fl-hint.ok--positive-ink5.33:1 / 7.55:1 ·.fl-hint.errand.fl-err--critical-ink5.37:1 / 5.80:1 — both pass AA. - Hairline between the form and the day panel,
--borderon--surface-100— 1.15:1 / 1.22:1; a hairline, below the non-text minimum (system-wide limit).
Known limits
- The status dot's name is dropped (
aria-labelon arole="generic"span), so RSVP state is colour-only for a screen reader — and two of its four tones also fail 3:1. - The availability day cannot be operated from the keyboard and is
role="presentation". --fg-mutedat 2.85:1 carries the row icons and placeholders, which is the pattern's only labelling.- No focus trap and no inert background — inherited from
dialog. - The focused-row tint is a background colour, so it disappears in forced-colors mode.
- The focused-row tint is
--hover-surface(rgb 251,251,251 on white) — practically invisible (measured 26 Sep 2026, session DS consolidation). For text fields (.fl-text,.fl-mask-in) the blinking caret is the focus indicator; any NON-text control inside a row (chip, icon button, picker trigger) must therefore keep its own:focus-visiblering and never rely on the row tint. The owner decided on 27 Sep 2026 to KEEP the quiet tint (the caret marks text focus; non-text controls carry their own ring). - Touch targets are 44px on phones but 40px rows on desktop pointers, and the icon-only controls inside a row are 24px — under the 44px guidance, in line with the rest of the app's row actions.
Source: docs/dialog/formless-dialog.a11y.md · GitLab