Review — Records & data (8 Sep 2026)

The last category to go through the family pipeline. Eleven families, fifteen sub-components: item-list · record-list · rail · key-value-row · editable-field · multi-value · segment-table · table-row · task-list · email-list · deal-card · stat-tile · section-block · timeline · note-card.

Source of truth: origin/main acrm (mirror /root/acrm-main, a129eaac0). Everything below was measured in a browser against the catalogue built from that mirror, or read out of the code — never inferred from a mockup.

0. Summary

The registry is clean. All 67 story ids resolve in the built catalogue, every file and CSS path recorded exists on main, every React export is exported, and no recorded class is without an emitter. After Button (where five of five sub-components were mis-recorded) that is worth saying plainly: this category was recorded honestly.

What the pass found is of three kinds, and only the first is about how the system looks — none of it is, in fact. All of it is about behaviour and adoption:

  1. Two mouse-only controls in the product, one of which is the exact defect fixed elsewhere three days earlier and left standing here.
  2. Two stories that draw a less accessible anatomy than the code has — the catalogue understating the product, which is the mirror image of the usual failure and just as misleading.
  3. A rejected pattern that is still in a third of the places — the largest single finding in the category, and not a design question at all.

1. Adoption: the rejected list pattern never finished migrating (significant)

RecordList is recorded deprecated, replacedBy: item-list — the AD rejected it as the list pattern on 20 July 2026 (docs/PLAN-listview.md; „Record list is the REJECTED predecessor of this pattern — do not migrate onto it and do not extend it", says item-list.stories.tsx itself).

Measured on main:

patternfilesusages
<ItemList (canon)46140
<RecordList (rejected)2435

So seven weeks after the ruling the rejected component is still mounted in 24 files — whole feature areas (clen-tymu/* alone accounts for nine), plus AktivityPage, KalendarPage, DialerPage, CompanyRecordPage, ProduktDetailPage, the assistant blocks and more.

This is not a design decision to make; it is unfinished work. It also quietly weakens every rule the canon states about listings, because a third of the listings in the product are not built on the canonical row. Worth a migration plan of its own — it is far too big for one MR.

⚠️ Counting note, because it bit this review: grep -c "<RecordList" and grep -cE "<RecordList[ >/]" disagree by an order of magnitude (35 vs 4). In 31 of the 35 usages the JSX is multi-line, so the component name ends the line and any „next character" pattern misses it. The first number is the true one.

2. Findings in the design system

A1 — Collapsing a rail section is mouse-only, and it is the defect already fixed next door

RecordRail.tsx:845 (and again at :1046) renders the section header as <div className="rr-sec-h" onClick={…}>. Measured in the catalogue on components--rail--default:

tag DIV · role null · tabindex null · aria-expanded null · cursor pointer · focusable inside: []

Nothing inside the header can take focus, so the section cannot be collapsed or expanded from the keyboard, and a screen reader is told nothing about the state.

The fix already exists in the system. SectionBlock had exactly this defect and it was repaired on 5 September 2026 (finding A1 of the Identity + States pass): the header keeps its onClick, and a real <button className="sb-toggle" aria-expanded> wraps the title and the caret, so mouse and keyboard travel one path. Its own code comment even explains why the button is inside rather than role="button" on the header — the header also carries an action, and a button inside a button is invalid ARIA. The rail header has the same shape (title · count · caret · optional „Add" button), so the same remedy transfers unchanged.

Control group, measured: sb/states and sb/composition — 3 collapsible headers, 3 with button.sb-toggle, all carrying aria-expanded.

Why this matters beyond one control: a repair that lands on one component and not on its twin is how a design system loses coherence. The pattern is fixed; the second site was never visited.

A2 — Sorting a table column cannot be done from the keyboard

SegmentScreen.tsx:845 renders the column header as <th onClick={…} aria-sort={…}>. The aria-sort attribute is right and useful — it announces the current sort. But there is no role, no tabindex and nothing focusable inside, so the sort can only be triggered with a mouse.

Measured on components--segment-table--default and --variants: th, role null, tabindex null, cursor pointer, nothing focusable inside.

The header already contains a focusable <input class="sgb-th-edit"> while a column is being renamed, so the element is not hostile to interactive children — a <button> around the label and sort mark would follow SectionBlock's remedy exactly.

A3 — The entity chip promises a click it does not always have

.eref-chip sets cursor: pointer on the base class (ds.css:391), but EntityRef only renders a <button> when onOpen is passed; without it the chip is a plain <span> (EntityRef.tsx:167–174). So a non-interactive chip still shows the hand cursor and invites a click that does nothing.

Minor, and a one-line fix (move cursor: pointer onto the button variant), but it is the kind of false affordance the canon otherwise avoids carefully.

3. Findings in the catalogue — FIXED and MERGED (!3225, 8 Sep 2026)

Both are the same shape: the story draws a poorer anatomy than the product has. This is worth separating from the usual failure — a story that invents a nicer composition hides a defect; a story that invents a worse one slanders working code and, worse, teaches the wrong markup to anyone copying from the catalogue.

Both were fixed in the same pass (they need no ruling — the product is right and the story was wrong). Measured in the rebuilt catalogue afterwards:

D1  .tl-rcheck  → button · aria-pressed=true · aria-label · focusable
D2b .phe-type   → select · aria-label · 4 options · focusable
D2a rail        → .chf-lbl ×2 · .phe-type in rail ×0

Visually identical: 12 timeline + 8 multi-value baseline snapshots pass unchanged at the 1 % threshold. That is the point — .phe-type strips the select down to quiet text (appearance: none, no border, no background), so the defect was invisible on screen and only the markup gave it away. A review that only looked at pictures would have missed both.

D1 — timeline draws the task checkbox as a <span>; the product has a real button

timeline.stories.tsx:246 emits <span className="tl-rcheck on">. The product (TimelineRow.tsx:141–150) emits:

<button type="button" className="tl-rcheck…" aria-pressed={done} aria-label={complete/reopen}
        onClick={…} onKeyDown={e => e.stopPropagation()}>

— with a comment explaining precisely why it must be focusable on its own. The catalogue therefore shows completing a task as a mouse-only span, which is both wrong and the opposite of the truth.

D2 — multi-value used one class for two different places, and drew a control as static text

The story emitted <span className="phe-type"> twice, and the two occurrences were wrong for different reasons. .phe-type belongs to the editor row only:

placeproductstory emitted
rail stack (.rr-vals)<span class="chf-lbl"> (ChannelsField.tsx → typeLabel).phe-type — wrong class
editor row (.phe-row)<select class="phe-type" aria-label> (ChannelsPopoverEditor.tsx)<span> — wrong element

The editor's markup is not incidental, it is pinned by a canon test: channels-popover-editor.test.tsx:206 asserts the row is „grip · input · select.phe-type · ✕". A story contradicting a test that guards the canon is a defect in the story.

The rail half is the more insidious of the two: .phe-type and .chf-lbl look nearly identical, so nothing on screen gave the mistake away — only reading ChannelsField.tsx did. That is also why the first draft of this review recorded the finding too broadly („the product has a <select>", said of both places); the rail has no select and never did.

4. What was measured

checkresult
story ids in registry resolving in the built catalogue67 / 67
files + CSS paths in registry existing on mainall
recorded classes with zero emittersnone
React exports missing from packages/ui/src/index.tsnone
console / page errors across all 67 stories0
mouse-only roots (pointer, no role/tabindex, nothing focusable)2 real (A1, A2) after filtering

Method note on that last row: the first sweep flagged 33 targets, nearly all false — cursor: pointer inherits, so every <span> inside a clickable row looked like a finding. Restricting the check to the root of a pointer region and excluding elements with a focusable ancestor or a focusable child cut it to a handful, and reading each one against the code cut it to two. Notably tr.sgb-rowclick is not a finding: the row carries a click for convenience, but the name inside it is a real <a href>, so the keyboard path exists.

5. Open for the AD

  1. A1 — apply SectionBlock's fix to the rail section header (a <button aria-expanded> around title and caret)? It is a like-for-like transfer of an already-approved remedy.
  2. A2 — same treatment for the sortable column header?
  3. A3 — move cursor: pointer off the base .eref-chip onto the interactive variant?
  4. §1 — the RecordList → ItemList migration (24 files) needs a plan and a sequence of MRs. Not a design question, but it needs a decision that it is worth the churn.

D1 and D2 are catalogue defects, not product decisions — they can be fixed without a ruling.

Source: docs/REVIEW-records-data-family.md · GitLab