Rail
canonThe 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 …
@acrm/ui <RecordRail> <EditableField> <useInlineEdit> <RailRows> <RailRow>.rr-row .rr-sec-h .rr-relrow .rr-dot +25SourceStoryLegacy card Record detail rail.htmlLegacy card Multi-value editor.htmlDefault
export const Default = () => (
<Col>
<QuickActions />
<LiveRail fields={FIELDS} importantCount={5} relations={[PROCESS_RELATION]} />
</Col>
);export const Default = () => (
<Labeled text="fields — the same RRField shape as RecordRail.fields; read-only without onSave">
<Rail>
<RailRows fields={FIELDS} />
</Rail>
</Labeled>
);export const Default = () => (
<Box>
<Rows fields={[{ key: 'role', icon: 'briefcase', label: 'Role', value: 'Majitel firmy' }]} />
</Box>
);export const Default = () => (
<Box>
<Rail
fields={[
railField([
{ text: '+420 605 123 456', type: 'mobil' },
{ text: '+420 541 220 111', type: 'práce' },
]),
{ key: 'email', icon: 'mail', label: 'E-mail', value: '[email protected]' },
]}
/>
</Box>
);Variants
export const Variants = () => (
<div style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Labeled text="page — plain column, no card (PersonRail.tsx:1315: variant „page“ returns the rail bez obalu)">
<Col>
<QuickActions />
<LiveRail fields={FIELDS.slice(0, 5)} importantCount={5} relations={[PROCESS_RELATION]} />
</Col>
</Labeled>
<Labeled text="drawer — same rail, wrapped in the preview drawer chrome (.pdr-rail); Procesy section is dropped here (F216) and Seznamy never reaches the drawer at all">
<aside className="pdr" style={{ width: 380 }} aria-label="Náhled — Petr Zeman">
<div className="pdr-top">
<div className="pdr-top-id">
<span className="pdr-top-name">Petr Zeman</span>
</div>
</div>
<div className="pdr-body">
<div className="pdr-rail">
<QuickActions />
<LiveRail fields={FIELDS.slice(0, 5)} importantCount={5} />
</div>
</div>
</aside>
</Labeled>
<Labeled text="řádek s více hodnotami — every value is a full 34px row (.rr-vals/.rr-val); label, icon and pencil belong to the first value, the rest are dimmed (port: ChannelsField.tsx:251-271, used e.g. PersonRail.tsx:531-541)">
<Col>
<LiveRail
fields={[
{
key: 'phones',
icon: 'phone',
label: 'Telefon',
display: (
<span className="rr-vals">
<span className="rr-val">
<span className="rr-ev">
<span className="rr-ev-tx">+420 800 737 373</span>
</span>
</span>
<span className="rr-val">
<span className="rr-ev">
<span className="rr-ev-tx">+420 541 220 111</span>
</span>
<span className="chf-lbl">práce</span>
</span>
</span>
),
},
{ key: 'email', icon: 'mail', label: 'E-mail', value: '[email protected]' },
]}
/>
</Col>
</Labeled>
<Labeled text="sekce polí s počtem — `count` kreslí `span.cnt` za titulkem, TÝŽ prvek jako u relace; tady sekce Nahrávky ze schůzky (MeetingLeftRail, FA198)">
<Col>
<RecordRail
title="Nahrávky"
count={2}
fields={[
{
key: 'rec',
icon: 'player-play',
label: 'Nahrávky',
value: '2 části · 48 min',
editable: false,
},
{
key: 'tr',
icon: 'file-text',
label: 'Přepis',
value: 'čeština · 3 řečníci',
editable: false,
},
]}
/>
</Col>
</Labeled>
<Labeled text="řádek s odkazem a trvalou tužkou — hodnota vede jinam, takže klik needituje; tužka je TRVALE vidět (.rr-ev.act), ne jen na hover (RecordRail.tsx:66-79). Dnešní raily appky tenhle tvar zrovna nemají na žádném poli (channels jdou přes display, ne href) — je to zdokumentovaná schopnost komponenty, ne vymyšlený prvek.">
<Col>
<LiveRail
fields={[
{
key: 'web',
icon: 'world',
label: 'Web',
value: 'zemanconsulting.cz',
href: 'https://zemanconsulting.cz',
openLabel: 'Otevřít web',
},
]}
/>
</Col>
</Labeled>
</div>
);export const Variants = () => (
<div style={{ display: 'grid', gap: 'var(--sp-5)' }}>
<Labeled text="wide — a section in the main column: key column 160 px, long keys are not cut">
<Main>
<RailRows wide>
<RailRow icon="discount" label="Podmínka zvýhodněné ceny">
Odběr min. 12 měsíců, platba předem
</RailRow>
<RailRow icon="calendar-event" label="Účinnost smlouvy">
1. 10. 2026
</RailRow>
<RailRow icon="receipt" label="Fakturační období">
Měsíčně
</RailRow>
</RailRows>
</Main>
</Labeled>
{/* port: apps/web/src/pages/DocumentDetailPage.tsx (KeyTermsSection — vytěžené parametry
smlouvy, nejdelší klíč na devu 35 znaků; DOC-2026-0265) */}
<Labeled text="wide — a data key longer than the column wraps (2 lines, phone 3); key and value start on one top line">
<Main>
<RailRows wide>
<RailRow icon="file-text" label="Doba trvání povinnosti mlčenlivosti">
5 let od skončení smlouvy
</RailRow>
<RailRow icon="file-text" label="Sankce za předčasné ukončení">
Zaplatit standardní cenu za poskytnuté služby zpětně za celou dobu trvání smlouvy
</RailRow>
<RailRow icon="file-text" label="Měsíční cena služby">
715,- Kč
</RailRow>
</RailRows>
</Main>
</Labeled>
<Labeled text="rail (default) — the same keys in the 108 px rail column cut with an ellipsis">
<Rail>
<RailRows>
<RailRow icon="discount" label="Podmínka zvýhodněné ceny">
Odběr min. 12 měsíců, platba předem
</RailRow>
<RailRow icon="calendar-event" label="Účinnost smlouvy">
1. 10. 2026
</RailRow>
</RailRows>
</Rail>
</Labeled>
</div>
);export const Variants = () => (
<div style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Labeled text="text, e-mail, phone — one-line editors; the type decides the keyboard and the validation, not the look">
<Box>
<Rows
fields={[
{ key: 'name', icon: 'user', label: 'Jméno', value: 'Josef Novák' },
{
key: 'email',
icon: 'mail',
label: 'E-mail',
value: '[email protected]',
type: 'email',
},
{
key: 'phone',
icon: 'phone',
label: 'Telefon',
value: '+420 605 123 456',
type: 'phone',
},
]}
/>
</Box>
</Labeled>
<Labeled text="number — grouped by thousands while editing; turn grouping off for years and numeric identifiers, where „2 019“ would be a bug">
<Box>
<Rows
fields={[
{ key: 'amount', icon: 'coin', label: 'Objem', value: '1250000', type: 'number' },
{
key: 'year',
icon: 'calendar',
label: 'Rok výroby',
value: '2019',
type: 'number',
grouping: false,
},
]}
/>
</Box>
</Labeled>
<Labeled text="select — a closed list; the empty value shows as „—“">
<Box>
<Rows
fields={[
{
key: 'stage',
icon: 'target',
label: 'Fáze',
value: 'jednani',
type: 'select',
options: [
{ value: '', label: '—' },
{ value: 'novy', label: 'Nový' },
{ value: 'jednani', label: 'Jednání' },
{ value: 'nabidka', label: 'Nabídka' },
],
display: 'Jednání',
},
]}
/>
</Box>
</Labeled>
<Labeled text="date — the canonical date field, never the browser's native picker">
<Box>
<Rows
fields={[
{ key: 'due', icon: 'calendar', label: 'Termín', value: '2026-08-26', type: 'date' },
]}
/>
</Box>
</Labeled>
<Labeled text="multi-line — a description edits in a textarea and keeps the two-line clamp when closed">
<Box>
<Rows
fields={[
{
key: 'note',
icon: 'note',
label: 'Poznámka',
desc: 'Preferuje volání odpoledne, v červnu řešil refinancování.',
},
]}
/>
</Box>
</Labeled>
</div>
);export const Variants = () => (
<div style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Labeled text="two values — the label, the icon and the pencil belong to the first row; the second value is dimmed">
<Box>
<Rail
fields={[
railField([
{ text: '+420 605 123 456', type: 'mobil' },
{ text: '+420 541 220 111', type: 'práce' },
]),
]}
/>
</Box>
</Labeled>
<Labeled text="one value — a multi-value field with a single value looks exactly like a plain row; nothing announces the difference until you edit it">
<Box>
<Rail fields={[railField([{ text: '+420 605 123 456', type: 'mobil' }])]} />
</Box>
</Labeled>
<Labeled text="the editor — grip to reorder, the type next to the value, ✕ to remove, and the bare add row at the bottom (canon F186/F187)">
<Editor
initial={[
{ text: '+420 605 123 456', type: 'mobil' },
{ text: '+420 541 220 111', type: 'práce' },
]}
/>
</Labeled>
</div>
);States
export const States = () => (
<div style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Labeled text="dlouhý popis — 2řádkový clamp; RecordRail měří skutečné zkrácení (.is-clamped). Na dotyku (hover: none) je u zkráceného textu chevron „Zobrazit celý popis“ vidět trvale s cílem 44 px, na desktopu na hover">
<Col>
<LiveRail
fields={[
{
key: 'description',
icon: 'notes',
label: 'Popis',
type: 'textarea',
value:
'Kancelář ve 2. patře: IT sklad a technika, archiv smluv 2019–2024 ve dvou uzamčených skříních, klíče u recepce. Šanony se zapůjčují přes knihu výpůjček.',
},
]}
/>
</Col>
</Labeled>
<Labeled text="prázdná hodnota — „Nastavit hodnotu…“ (labels.setValue default, RecordRail.tsx:252) je afordance ke kliknutí, ne mrtvá pomlčka">
<Col>
<LiveRail
fields={[
{ key: 'phone', icon: 'phone', label: 'Telefon', value: null },
{ key: 'email', icon: 'mail', label: 'E-mail', value: null },
{ key: 'city', icon: 'map-pin', label: 'Město', value: 'Praha' },
]}
/>
</Col>
</Labeled>
<Labeled text="úprava otevřená — klik do hodnoty aktivuje .rr-edit (rámeček + ring); zkuste kliknout na Pozici">
<Col>
<LiveRail
fields={[
{ key: 'position', icon: 'briefcase', label: 'Pozice', value: 'obchodní ředitel' },
]}
/>
</Col>
</Labeled>
<Labeled text="uloženo ✓ — Enter/blur potvrdí krátkou zelenou fajfkou (.rr-saved, fade ~1,1 s); upravte Město a potvrďte">
<Col>
<LiveRail fields={[{ key: 'city', icon: 'map-pin', label: 'Město', value: 'Praha' }]} />
</Col>
</Labeled>
<Labeled text="chyba uložení — řádek žádný vizuální stav chyby nemá (34px na to není místo); hodnota se vrátí na původní, chybu toastuje appka mimo rail (RecordRail.tsx:16-20)">
<Col>
<RecordRail
title="Detail záznamu"
fields={[{ key: 'phone', icon: 'phone', label: 'Telefon', value: '+420 800 737 373' }]}
onSave={() => {
throw new Error('save failed');
}}
onCopy={() => {}}
/>
</Col>
</Labeled>
<Labeled text="„Zobrazit vše“ — prvních importantCount polí je nejdůležitějších, zbytek za odkazem">
<Col>
<LiveRail fields={FIELDS} importantCount={5} />
</Col>
</Labeled>
<Labeled text="loading — rail nahradí skeleton, dokud záznam nedorazí (appka to dělá stejně)">
<Col>
<SkeletonList rows={4} avatar={false} />
</Col>
</Labeled>
</div>
);export const States = () => (
<div style={{ display: 'grid', gap: 'var(--sp-5)' }}>
<Labeled text="empty value — the canonical placeholder „—“ (.v.ph), never a hand-typed dash">
<Main>
<RailRows wide>
<RailRow icon="user" label="Přidělená osoba" />
<RailRow icon="calendar" label="Datum předání">
{null}
</RailRow>
</RailRows>
</Main>
</Labeled>
<Labeled text="wrap — the value may break onto more lines (old → new, descriptions); the row aligns to the top">
<Main>
<RailRows wide>
<RailRow icon="arrows-right" label="Změna podmínek" wrap>
Splatnost 14 dní → 30 dní od doručení faktury, sleva za platbu předem 2 % → 3 %
</RailRow>
</RailRows>
</Main>
</Labeled>
<Labeled text="actions — revealed on hover / focus of the row (always visible on touch); the space is reserved">
<Main>
<RailRows wide>
<RailRow
icon="link"
label="Navazuje na"
actions={<IconButton icon="x" title="Odebrat vazbu" size="sm" />}
>
<EntityRef type="document" name="SM-2026-031 · Rámcová smlouva" />
</RailRow>
</RailRows>
</Main>
</Labeled>
</div>
);export const States = () => {
const [slow, setSlow] = useState(false);
return (
<div
style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}
>
<Labeled text="idle → editing → saved: click the value, change it, press Enter — the green check confirms and fades">
<Box>
<Rows
fields={[{ key: 'role', icon: 'briefcase', label: 'Role', value: 'Majitel firmy' }]}
/>
</Box>
</Labeled>
<Labeled text="empty — the placeholder is the affordance; clicking it opens the editor on an empty value">
<Box>
<Rows fields={[{ key: 'email', icon: 'mail', label: 'E-mail', value: null }]} />
</Box>
</Labeled>
<Labeled text="slow save — the commit is awaited, so the value updates only when the caller's promise settles (switch it on and edit the row)">
<Box>
<div style={{ display: 'grid', gap: 'var(--sp-2)' }}>
<label className="acrm-check-label">
<input
className="acrm-check"
type="checkbox"
checked={slow}
onChange={(e) => setSlow(e.target.checked)}
/>
<span>Uložení trvá 1,5 s</span>
</label>
<Rows
fields={[{ key: 'owner', icon: 'user', label: 'Vlastník', value: 'Štěpán Svoboda' }]}
onSave={slow ? () => new Promise<void>((r) => setTimeout(r, 1500)) : undefined}
/>
</div>
</Box>
</Labeled>
<Labeled text="failed save — the value goes back to what it was; the row shows nothing, the screen is expected to toast (a 34px row has no room for an error)">
<Box>
<Rows
fields={[{ key: 'phone', icon: 'phone', label: 'Telefon', value: '+420 605 123 456' }]}
onSave={() => {
throw new Error('save failed');
}}
/>
</Box>
</Labeled>
<Labeled text="value that leads elsewhere — clicking it calls the action, so the pencil stays visible for the people who cannot hover">
<Box>
<Rows
fields={[
{
key: 'web',
icon: 'world',
label: 'Web',
value: 'brnotech.cz',
href: 'https://brnotech.cz',
openLabel: 'Otevřít web',
},
]}
/>
</Box>
</Labeled>
</div>
);
};export const States = () => (
<div style={{ display: 'flex', gap: 'var(--sp-6)', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Labeled text="many values — every one keeps its full 34px row; the stack is never compressed">
<Box>
<Rail
fields={[
railField([
{ text: '+420 605 123 456', type: 'mobil' },
{ text: '+420 541 220 111', type: 'práce' },
{ text: '+420 800 100 200', type: 'ústředna' },
{ text: '+420 777 654 321', type: 'soukromé' },
]),
]}
/>
</Box>
</Labeled>
<Labeled text="empty — a multi-value field with nothing in it is still a row with a placeholder">
<Box>
<Rail fields={[{ key: 'phones', icon: 'phone', label: 'Telefon', value: null }]} />
</Box>
</Labeled>
<Labeled text="editor with one row left — the add row stays, so there is always somewhere to type">
<Editor initial={[{ text: '+420 605 123 456', type: 'mobil' }]} />
</Labeled>
<Labeled text="editor, empty — only the add row; this is what a field with no values offers">
<Editor initial={[]} />
</Labeled>
</div>
);Composition
export const Composition = () => (
<div
style={{
display: 'flex',
alignItems: 'stretch',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}
>
{/* port: apps/web/src/ds/split-view.css (.sv-aside/.sv-pad) — SplitView je primitivum, které
na stránce rail hostí vedle obsahu; hairline mezi sloupci nese `.sv-div-line > .sv-aside`,
ne rail sám (docs/DS-SPLITVIEW.md). */}
<div className="sv-aside" style={{ width: 380, borderRight: '1px solid var(--border)' }}>
<div className="sv-pad">
<QuickActions />
<LiveRail fields={FIELDS} importantCount={5} relations={[PROCESS_RELATION]} />
</div>
</div>
<div style={{ flex: 1, minWidth: 320, padding: 'var(--sp-6)' }}>
<div style={{ ...label, marginBottom: 'var(--sp-3)' }}>
Přehled · E-maily · Schůzky · Produkty …
</div>
<div
style={{
height: 240,
border: '1px dashed var(--border)',
borderRadius: 'var(--radius-md)',
display: 'grid',
placeItems: 'center',
color: 'var(--fg-muted)',
}}
>
obsah stránky (taby, karty, osa aktivity — mimo rodinu Rail)
</div>
</div>
</div>
);export const Composition = () => (
<Labeled text="a detail's main column — SectionBlock with RailRows wide (asset handover, as in AssetDetailPage)">
<Main>
<Block title="Předání">
<RailRows wide>
<RailRow icon="checkbox" label="Stav předání">
Podepsáno
</RailRow>
<RailRow icon="calendar" label="Podepsáno dne">
2. 10. 2026
</RailRow>
<RailRow icon="file-text" label="Podepsaný protokol">
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--sp-2)' }}>
<Icon name="file-type-pdf" />
predavaci-protokol-NB-2026-014.pdf
</span>
</RailRow>
<RailRow icon="user" label="Převzal" />
</RailRows>
</Block>
</Main>
</Labeled>
);export const Composition = () => {
const [value, setValue] = useState('Jednání');
return (
<div style={{ display: 'grid', gap: 'var(--sp-4)' }}>
<Labeled text="used outside the rail — the same field in a process list row; behaviour and keys are identical">
<Box w={320}>
<div className="rr-row">
<span className="ri" />
<span className="k">Fáze</span>
<EditableField
f={{ key: 'stage', label: 'Fáze', value }}
commit={async (_k, v) => setValue(v)}
/>
</div>
</Box>
</Labeled>
</div>
);
};export const Composition = () => (
<Box>
<Rail
fields={[
railField([
{ text: '+420 605 123 456', type: 'mobil' },
{ text: '+420 541 220 111', type: 'práce' },
]),
{
key: 'emails',
icon: 'mail',
label: 'E-mail',
display: (
<Stack
values={[
{ text: '[email protected]', type: 'práce' },
{ text: '[email protected]', type: 'soukromé' },
]}
/>
),
},
{ key: 'owner', icon: 'user', label: 'Vlastník', value: 'Štěpán Svoboda' },
]}
/>
<div style={{ marginTop: 'var(--sp-3)', ...label }}>
In a real rail the multi-value rows sit among ordinary ones — the rhythm of 34px is what keeps
them from reading as a nested list.
</div>
</Box>
);