Rail
The left panel of a record and the single source of truth for its fields. Three layers, always in this order: context actions · the key-value section (rows, tags, description) · relations („Procesy“, „Seznamy“). packages/ui/src/components/RecordRail.tsx, CSS apps/web/src/ds/record-rail.css (.rr-*).
Since 14 Sep 2026 this is one card (AD, RD-1). Key-value row, Editable field and Multi-value used to be separate pages; they are the parts the rail is made of and are described below — the row, editing a value in place, and a field with several values. They never appear without the rail's rhythm, so they do not get pages of their own.
The same rail is rendered on the record page and inside the drawer. The difference is the chrome around it, not the rail: the app's wrappers (PersonRail, CompanyRail, DealRail) take variant: 'page' | 'drawer' and decide the wrapper. RecordRail itself has no such prop and must not grow one.
When to use
- The field panel of any record: person, company, deal, meeting, invoice, document, order, asset, vehicle.
- Anywhere the answer to "what do we know about this record" belongs in one place.
- A single-line read-only pair of label and value in a detail — the rail row, with the field
editable: false. - The "click the value and type" behaviour outside the rail:
EditableFieldis exported on its own for that (the process list uses it). - A field that genuinely has several equal values (phones, e-mails, a person's companies), where the order may matter to the user.
When not to use
- A longer text under its label (a paragraph, a bulleted list) — that is
DetailField(.dfield), which puts the label above the value and cannot edit. - A list of records — that is a listing.
- A form. The rail edits one value at a time, in place; a form collects many at once and submits.
- A form field with its own label above the control — that is
Field+Input. - Two different fields squeezed into one: work and private phone are two values of one field, phone and e-mail are two fields.
- A set of options — that is Choice or Option chips. References to other records — that is a relation list in the rail.
Anatomy
Panel
- Actions
.rr-actions: in the app, four quiet icon buttons (e-mail, call, note, task); at most one primary if a record ever needs one. - Section
.rr-secwith header.rr-sec-h(title.t, count.cnt, action.a) and a hairline divider.rr-divhbetween sections. A header is drawn only when the section has a title — never an empty 26px bar. The count.cntis the same element on both kinds of section: a relation passesRRRelation.count, the field section passescounton<RecordRail>(25 Sep 2026, FA198 — e.g. a meeting'sNahrávky 2). Leave it out on an ordinary „Detail záznamu“ — a count only belongs where the section lists things of one kind. - Tags
.rr-tags: coloured pills on one line. - Description
.rr-desc: two-line clamp with an expander; the only rows taller than 34px. The rail measures whether the text is really cut (scrollHeight > clientHeight, re-measured by aResizeObserver) and marks the value.rr-desc-v.is-clamped; the chevron.rr-desc-expbelongs only to a clamped (or opened) description — a short one never shows it. - „Zobrazit všechny hodnoty“
.rr-allvals: the firstimportantCountfields stay, the rest fold away. - Relations
.rr-lists→.rr-list→.rr-relrow(32px), empty state.rr-relempty, „Zobrazit vše“.rr-relmore. - Relation row status (6 Sep 2026, meeting attendees): an optional dot
.rr-dotbefore the reference (RRRelationItem.dot = { tone, title },--sp-3square,is-green=--ic-green,is-amber=--ic-amber,is-rose=--critical, no tone =--ink-300) and an optional icon slot.rr-statusright after the name (RRRelationItem.status, e.g. the delivery ✓✓ ported 1:1 from Mail). Both are silent: the meaning is in thetitletooltip and thearia-label, never in text — text would fight.meta, which fades out on hover to uncover the remove ×. - Relation with a share (owner 25.–26. Sep 2026, meeting attendees' talk share —
apps/web/src/ds/meeting-record.css, not yet ported intorecord-rail.css): the share sits right-aligned at the end of the row (margin-left: auto), a fixed 56px--line-200track fills by percentage, and the percentage itself is a fixed 4ch right-aligned column (font-variant-numeric: tabular-nums) so every row's track starts on the same axis. Candidate for a named element ("share bar") — today it is meeting-only markup, not a DS primitive. - Unassigned-voice row (one line: "Řečník N · [icon] Jméno? · ⌄ · %" —
.mr-speaker-assign,SpeakerAssignTrigger compact): the assign action leads with its icon, spaced from its own text by--sp-3(the Button rhythm), and from the name/percentage that follows by--sp-4;compactdraws only the trigger's chevron, no label. - Removable reference
.eref-xon touch is normally shown permanently (ds.css, global@media (hover: none)), but the attendees list scopes an exception (.rr-list--attendees, owner 26.–27. Sep 2026, ACRM-372): the × stays contextual even on pure touch — hover, keyboard focus, or a tap on the row outside the name arms it (.is-armed, set byMeetingLeftRail) — because it must never sit permanently over the talk-share bar. The content it covers fades into the hover colour with a gradient rather than a hard clip. This is a local override of one relation list, not a change toentity-ref's own canon.
Row
Icon · label · value, 34px tall. The height and the 108px label column were measured on the canon and are fixed. The row is not an exported component: it is what <RecordRail fields> renders per field, so the rhythm cannot drift between records.
- Row
.rr-row:min-height: 34px, three columns. - Icon
.ri: 16px column; a row without an icon leaves it empty so values stay aligned. - Label
.k: fixed 108px,--labeltoken, ellipsis when long. - Value
.v/.rr-ev:400 13px,--ink-900; empty adds.phand shows the placeholder. - Pencil
.rr-ev-pen: appears on hover; permanently visible (.rr-ev.act) when the value leads somewhere, because on touch there is no hover and the click is taken by the action. - Copy
.rr-ev-copy/.phe-copy: inline after the value, revealed on row hover — and always visible on touch or below 720px (see Touch below for the spacing rule).
Until 14 Sep 2026 the app had a second copy of this row, KeyValueRow (.pr-row, 39 uses in 7 detail pages). Measured identical to .rr-row; it is being replaced by the rail row and deleted (RD-1).
Editing a value
Click — or Enter on the focused value — turns it into a field; Enter or blur saves; Escape cancels; a green check confirms for about a second. EditableField + useInlineEdit (packages/ui/src/lib/useInlineEdit.ts); classes .rr-ev, .rr-edit, .rr-saved.
- Edit mode
.rr-edit: an input, atextareawith.ta, aselect, or the canonicalDateFieldfor dates — never the browser's native date picker. Structured values (date, phone, IČO, amount) use their masked input. - Saved
.rr-saved: green check, fading over ~1.1 s. - Number editing groups thousands by default; turn grouping off for years and numeric identifiers, where
2 019would be a bug. type: 'number'(owner 27 Sep 2026, acrm !4014 — meeting "Počet řečníků"): a numeric field, not a picker, for a small bounded count. The meeting rail pairs it withuseConfirmaround the save, because changing the number re-runs diarization over the same audio (a real action with a cost, not a plain field edit); the range itself (1–20) is the diarization service's own limit, raised from 10 to 20 the same day (acrm !3985).
Two rules the component enforces so screens do not have to:
- A value that leads somewhere never opens the editor on click. A link, a dialer action or a composer takes the click, and the row keeps a permanently visible pencil — otherwise the field could not be edited at all on a touch device (AD FA10-re).
- Saving is the caller's promise.
onSavemay be async; a rejection reverts the value and the screen shows the toast. A 34px row has no room for an error message.
Several values
- In the rail every value is a full 34px row (
.rr-vals/.rr-val), not a densely stacked list. The icon, the label and the pencil are pinned to the first value; the second and further values are dimmed (--fg-muted) so the primary one reads first (AD 22 Aug 2026). - Value type (
mobil,práce) sits next to the value as.phe-type. Copy sits inline right after the value, never in the action zone (AD FA29-re). - The editor is a popover of rows in the app (
apps/web/src/components/multivalue/*, CSSapps/web/src/ds/multi-value.css):.phe-rowgrip · value · type · remove, grid16px max-content minmax(0,1fr) 20px, min-height 34px, so the editor and the rail line up; a row without a grip drops the grip column (.phe-nogrip). - The add row
.phe-addrowis bare and always last (F186/F187); with no values it is the only row and loses its divider (F188).
Touch (hover: none)
A phone has no hover, so nothing in the rail may exist only on hover. The overrides live in the app layer (apps/web/src/ds/responsive.css, @media (hover: none)), never in the ported canon CSS; desktop is unchanged. Delivered 28 Sep 2026 (AD, acrm !4111).
- Copy and pencil are two targets, 30 px apart — this is the rule, not a leftover. On a row with copy (
.rr-ev:has(.phe-copy)), copy sits in the flow of the row right after the value (margin-left: auto, no background) and the pencil keeps acalc(var(--sp-6) + var(--sp-1) + var(--sp-0))= 30 px gap before it. Each icon carries a 44 px hit area (::after), and two 44 px areas around 16 px glyphs only stop overlapping at that distance — so the 44 px targets win over the usual--sp-2rhythm. The value gives way with an ellipsis, never the gap. - Copy and the chevron of a clamped description are drawn permanently in
--fg-secondary(they have no hover to appear on); the chevron's hit area is 44 px. - The pencil does not light up on a tap's sticky hover — it stays reserved for rows where it is permanently visible anyway (
.rr-ev.act, action values.chf-actval, AD FA10-re). - Drag grips in and around the rail (milestones) are hidden: reordering on touch goes through the row's ⋮ (
Posunout nahoru/Posunout dolů, disabled at the ends with the reason shown as a second line — see Menu button).
Mobile (≤ 720px)
The multi-value editor popover (phones, e-mails, domains) becomes a bottom sheet at the same breakpoint every other dialog surface uses (useMediaQuery('(max-width: 720px)') in MultiValuePopover): grab handle .phe-grab, header .phe-sheethead naming the field (Phone / E-mail / Domain — channels.titlePhone / titleEmail / titleDomain), full-width 44px primary CTA "Done" in .phe-sheetfoot (.acrm-btn.primary, height: calc(var(--sp-7) + var(--sp-1))), and a scrim .phe-scrim that closes the sheet on tap. Below 720px the component sets no inline top/left at all — ds/responsive.css owns the whole placement (.phe-pop.phe-pop: position: fixed, bottom: 0, full width, rounded top corners only, max-height: 92dvh).
Opened from inside a modal Dialog (for example the New person dialog's channel rows), the sheet portals into that dialog's own scrim (anchor.closest('.acrm-dialog-scrim') ?? document.body) rather than <body>: the modal makes everything outside itself inert, and a sheet portalled to <body> would be inert too, so a tap on its own "Done" would fall through to whatever sits under it (measured with elementFromPoint at 390px — it landed on the dialog's "Zrušit"). The row that opens the editor guards its click with e.currentTarget.contains(e.target), because the portalled editor is a DOM sibling of the row but still a React child of it — without the guard, tapping "Done" bubbles back up through React and reopens the editor it just closed.
Delivered 15 September 2026 (MR !3539).
Rows outside the rail (acrm !4477, 3 Oct 2026)
The key–value row is one pattern: the rail row .rr-row (RD-1). Detail sections in the main column use the same row through RailRows, the exported row part of the rail. RecordRail draws its own rows through it, so both places stay identical. There is no second row component; the old KeyValueRow (.pr-row) is gone.
fieldsorchildren. Passfields(the sameRRFieldobjects as the rail) when the section is a plain list of values. Passchildren—<RailRow icon label>{value}</RailRow>— when rows are conditional, come from a loop, or the value is a link, a chip or an entity reference. Both paths render the same row shell (RowShell), the only place in the code that creates.rr-row; a test keeps it that way.- Read-only by default. Without
onSavethe rows are read-only: no pencil, no editor hover. Copy appears only on fields markedcopyable, as in the rail. AddonSaveonly when the section really edits the record. - Empty value. Leave the value empty (
null,undefined,false) and the row shows the rail placeholder „—“. Never type a dash by hand. widein the main column. The rail is narrow and keeps a 108px key column. A section in the main column of a detail uses<RailRows wide>: the key column (--rr-key-won the root) grows to 160px so longer keys — contract terms — are not cut off, and values still start in one straight column close to their keys. Below 720pxwidefalls back to 108px. Do not override the key width with a scoped selector; the variable on the root is the only switch.wrap(acrm !4483) — the value may wrap onto several lines instead of being cut off; the row aligns to the top with vertical padding. Use it for values like „old → new“ or short descriptions. The key stays where it is (unliketop, which also shifts the key for a multi-line description).actions(acrm !4483) — buttons that act on the row (accept, reject) go into this slot after the value, never into the value itself. They follow the contract of the rail's pencil and copy: hidden until the row is hovered or focused, with their space reserved so the row does not jump; always visible on touch and below 720px with a--tap-mintarget, and on phones they move under the value, aligned right. Never pass aclassNameto restyle a row —wrap,topandactionsare the only switches.- Long keys in
wide(acrm !4516): a key longer than the 160px column wraps to two lines (three on a phone) and only that row grows — key, icon and the first line of the value start on one top line; a key still cut after the limit carries its full text intitle. The ordinary rail keeps a one-line 108px key. Keys inwideare often data (terms extracted from a contract), so never assume they are short. - Not for forms. Rows show what a record says. Entering data is a form (fields with labels above them), not rail rows.
Best practices
Do
- Put the fields a person actually reads first and fold the rest behind
importantCount. - Give the rail
onSave; without it the panel is read-only and shows no editing affordances at all. - Let the caller own the toast:
onSaverejecting means the value reverts, and the screen says why. Return a promise so the row waits for the real result. - Render references through
renderRefwhen the app wants hover previews — the DS must not know the domain API. - Keep the drawer and the page rendering the same rail, so the two can never drift.
- Give a field a
type— it decides the keyboard and the parsing, not the look. Usedisplaywhen the read mode is richer than the raw value. - Mark computed values
editable: falserather than leaving an editor that saves nothing. - Use
copyablefor values people retype elsewhere (phone, IČO, address) and givecopyValuewhen the raw form differs from the shown one. - Give a value that leads somewhere an
openLabel— it becomes the title of the link or button. - Reach for a custom
editorfor pickers, and let it own its commit. - Keep the primary of several values first; offer the grip only where the order can actually be stored; let the editor stay open after a removal.
Don't
- A
surfaceorvariantprop onRecordRail; the wrapper decides, the rail does not. - A second panel of fields elsewhere on the record — the rail is the single source.
- A second implementation of the row (
.pr-row, a localKeyValueRow) — one renderer or the rhythm drifts. - An empty row hidden away: an empty field keeps its row and shows the placeholder.
- An empty section header — no title, no header.
- A text label for a relation-row state — that is what
.rr-dotand.rr-statusare for;.metais not a state slot. - More than one primary action in the action row; a row that is really an action.
- Custom row heights, or compressing a stack of values — 34px is the rhythm that makes the rail scannable.
- Two values as a comma-separated string in one row, or a count instead of the values („3 čísla“).
- Saving on every keystroke, showing an error inside the row, opening the editor from a value that is a link, or a native
<input type="date">. - Copy in the right-hand action zone; the zone belongs to "add" and "edit".
Content
- Section titles are nouns in sentence case (
Kontakt,Procesy,Seznamy). - Labels are short nouns (
Telefon,Vlastník,Další krok), no colon, and never repeat the section title (Kontakt→ notKontaktní telefon). - The value is what the user would say aloud; dates follow the year convention (
26. 8.,14. 11. 27). - The empty placeholder is an invitation (
Nastavit hodnotu…), never a dash. The empty option of a closed list is—. - Value types are lower-case single words (
mobil,práce,soukromé); the add-row placeholder names what is added (Další číslo…).
Related
record-drawer— the drawer chrome around the same rail.entity-ref— how relation rows are drawn.record-select— the picker behind owner and tag rows.date-field,select,input— the controls the editor uses.- Stories:
components--rail--*, with the row, editing and several-values examples as parts of this card. Rebuilt from the real person rail on 15 Sep 2026 (acrm !3544): four quiet icon actions, Detail záznamu / Procesy / Seznamy, no empty section header. The rail has no shortcuts, so it has no „With icon & kbd" story — only the row does.
Source: docs/record-rail/rail.usage.md · GitLab