Rail

canon

The record panel: context actions, 34px key-value rows edited in place (several values = several rows), tags, description and relations.

Merged 14 September 2026 (AD, RD-1): Key-value row, Editable field and Multi-value were separate pages; they are parts of this one card. The app copy of the row (KeyValueRow, .pr-row) is being replaced by the rail row and deleted. A value with href/onOpen never opens the editor on click and keeps a permanently visible pencil (AD FA10-re); errors are the caller’s toast. The multi-value editor popover lives in the app (components/multivalue/*, ds/multi-value.css). Mobile sheet merged 15 September 2026 (MR !3539): below 720px `MultiValuePopover` renders as a bottom sheet instead of an anchored popover (`.phe-scrim`, `.phe-grab`, `.phe-sheethead`, `.phe-sheetfoot` in `ds/responsive.css`) so it can be closed and confirmed without a keyboard — see "Mobile" in rail.usage.md / rail.a11y.md. Stories rebuilt from the real person rail 15 September 2026 (acrm !3544): four quiet icon actions, Detail záznamu / Procesy / Seznamy, no empty section header; the rail has no shortcuts, so its „With icon & kbd" story and both its baselines are gone. Meeting attendees composition (owner 25.–27. Sep 2026, acrm !4014/!3985, all in `apps/web/src/ds/meeting-record.css` — not yet ported into record-rail.css): field `type: 'number'` for a small bounded count paired with `useConfirm` (speaker count, range 1–20 per the diarization service, raised from 10 on acrm !3985); a relation row's right-aligned talk-share bar (56px `--line-200` track, 4ch tabular-nums percentage) — candidate for a named "share bar" element; an unassigned-voice row with `--sp-3` icon-to-text / `--sp-4` name-to-action spacing; and a scoped exception (`.rr-list--attendees`) that keeps the removable reference's × contextual on touch instead of the global `ds.css` always-visible rule, so it never sits permanently over the share bar. Touch (AD 28 Sep 2026, acrm !4111): the description measures its real clamp (.rr-desc-v.is-clamped, ResizeObserver) and shows the chevron only then; on hover: none copy and the chevron are always visible with 44px hit areas, and copy ↔ pencil keep a 30px gap so two 44px targets never overlap (the rule beats --sp-2). Overrides live in apps/web/src/ds/responsive.css. Registry repaired 17 Oct 2026 (review F4/F11): attached css detail-field.css.

Used in 29 files · 20 areas · 79× — components/PersonaProfilePanel, components/clen-tymu, components/company, components/deal, components/decisions, components/dialer …

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: EditableField is 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-sec with header .rr-sec-h (title .t, count .cnt, action .a) and a hairline divider .rr-divh between sections. A header is drawn only when the section has a title — never an empty 26px bar. The count .cnt is the same element on both kinds of section: a relation passes RRRelation.count, the field section passes count on <RecordRail> (25 Sep 2026, FA198 — e.g. a meeting's Nahrá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 a ResizeObserver) and marks the value .rr-desc-v.is-clamped; the chevron .rr-desc-exp belongs only to a clamped (or opened) description — a short one never shows it.
  • „Zobrazit všechny hodnoty“ .rr-allvals: the first importantCount fields 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-dot before the reference (RRRelationItem.dot = { tone, title }, --sp-3 square, is-green = --ic-green, is-amber = --ic-amber, is-rose = --critical, no tone = --ink-300) and an optional icon slot .rr-status right after the name (RRRelationItem.status, e.g. the delivery ✓✓ ported 1:1 from Mail). Both are silent: the meaning is in the title tooltip and the aria-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 into record-rail.css): the share sits right-aligned at the end of the row (margin-left: auto), a fixed 56px --line-200 track 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; compact draws only the trigger's chevron, no label.
  • Removable reference .eref-x on 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 by MeetingLeftRail) — 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 to entity-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, --label token, ellipsis when long.
  • Value .v / .rr-ev: 400 13px, --ink-900; empty adds .ph and 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, a textarea with .ta, a select, or the canonical DateField for 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 019 would 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 with useConfirm around 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. onSave may 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/*, CSS apps/web/src/ds/multi-value.css): .phe-row grip · value · type · remove, grid 16px 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-addrow is 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 a calc(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-2 rhythm. 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.

  • fields or children. Pass fields (the same RRField objects as the rail) when the section is a plain list of values. Pass children — <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 onSave the rows are read-only: no pencil, no editor hover. Copy appears only on fields marked copyable, as in the rail. Add onSave only 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.
  • wide in 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-w on 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 720px wide falls 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 (unlike top, 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-min target, and on phones they move under the value, aligned right. Never pass a className to restyle a row — wrap, top and actions are 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 in title. The ordinary rail keeps a one-line 108px key. Keys in wide are 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: onSave rejecting means the value reverts, and the screen says why. Return a promise so the row waits for the real result.
  • Render references through renderRef when 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. Use display when the read mode is richer than the raw value.
  • Mark computed values editable: false rather than leaving an editor that saves nothing.
  • Use copyable for values people retype elsewhere (phone, IČO, address) and give copyValue when 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 editor for 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 surface or variant prop on RecordRail; 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 local KeyValueRow) — 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-dot and .rr-status are for; .meta is 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 → not Kontaktní 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…).
  • 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