Review — Identity + States & feedback (5 Sep 2026)

Fifteen sub-components taken through the family pipeline: stories per section, browser validation of look and behaviour, usage + a11y docs, registry corrected against the code. Branch polish/ds-identity-states, preview key ds-identity-states.

Covered: avatar · entity-ref · entity-ref-mini · entity-ref-removable · empty-state · error-state · skeleton-line · skeleton-list · loader · list-loader · detail-state-screen · toast · notifications · pill · count-badge.

Everything below was measured in a browser against the built preview, not read out of the source.

A. Findings in the design system

A1 — Notification rows are mouse-only (significant)

.nitem is a <div> with an onClick, no role, no tabindex (measured: {tag: "DIV", role: null, tabindex: null}). The primary target of the whole notification panel cannot be reached or fired from the keyboard; only the small Zobrazit link inside it can.

This is the same defect already recorded for deal-card and the section-block collapsible header. The pattern to copy is StatTile: role="button" + tabIndex={0} + Enter/Space.

A2 — The notification filters are not tabs

.npanel-tab × 2 are plain buttons with an active class. Nothing exposes which filter is selected, so a screen reader hears two buttons rather than a filter with a state. role="tab" in a role="tablist" with aria-selected would fix it without touching the visuals.

A3 — The grey pill loses its shape in the dark theme

--pill-grey-bg against the page: 1.19:1 light, 1.03:1 dark (#1f1f1f on #1b1c1f). In dark mode a grey pill has no perceptible ground and reads as loose text. Every other hue separates clearly. Either lighten the dark-theme grey ground or give the grey pill a hairline.

A4 — Attribute and functional pill tones are literally identical

positive = green, warning = amber, info = blue — same background, same ink, measured. The DS asks the two families to mean different things (an arbitrary tag vs. a colour that is the message) while rendering three of them indistinguishably. Adjacent pills from the two families cannot be told apart. Either the functional tones get their own values, or the docs must state that these three pairs collide (they now do).

A5 — Documented CSS knobs are not inheritable, and fail silently

--linf-w (Loader), --lh-scale and --lh-bg (List loader) are declared on the element itself, so a value set on an ancestor is overridden with no error. The ListLoader JSDoc invites exactly that ("na jiné ploše ho přenastav"), and the natural reading — set it on the wrapper — does nothing. Measured: three loaders under three different ancestor values all rendered at the default size.

Either move the defaults to a :root-level fallback (var(--lh-scale, 0.4) with no local declaration) or state in the JSDoc that the rule must target .acrm-loadhand / .acrm-loadinf. The stories now demonstrate both the working and the broken form.

A6 — count-badge geometry drift, still open (F246)

ds.css draws .acrm-count at 16px with no min-width; ds/section-block.css re-declares 17px / min-width: 18px to match the DS card for the record rail, and .acrm-tab .cnt and .rr-sec-h .cnt carry their own deltas. The same badge is therefore one pixel different depending on where it sits. The sheet documents this as a TODO; it is still true.

A7 — .alert count badge is below AA

White on --critical = 3.91:1 in both themes, on the loudest variant of the badge. Text under AA on the element whose job is to shout.

A8 — detail-state-screen is coupled to person-record.css

The component lives in @acrm/ui but emits .pr / .pr-top / .pr-ico / .pr-name-top, which are defined only in apps/web/src/ds/person-record.css. On a detail route that does not load that sheet it renders unstyled, and the guarantee it exists for — the back button not moving when the record arrives — silently does not hold. The registry entry previously claimed the classes came from ds.css; corrected.

A9 — The warning tone is invisible in the light theme

--warning on the canvas is 1.80:1 light (9.45:1 dark). It is used for the toast warning icon and the notification warning lead. Under the 3:1 non-text threshold by a wide margin, so those icons carry nothing on their own.

A10 — The full entity chip cannot be used in prose

.eref-chip carries vertical-align: -3px. Measured in a --cell line: the full chip raises the line box from 23px to 28px and offsets the chips by 4px, visibly breaking the rhythm; .eref-mini (vertical-align: baseline) keeps it at 23px with no offset. This is correct design — prose is the mini variant's job — but nothing in the API stops the wrong choice, and the previous story text claimed the opposite.

A11 — Motion is unconditional

Neither the skeleton shimmer (sk-sweep, 1.4s) nor the loader nor the drumming hand honours prefers-reduced-motion. The drumming hand is the liveliest animation in the system and repaints many times a second.

B. Registry records corrected against the code

The registry is meant to be the single description of what exists. These entries described something else:

EntryWasIs
status/pillclasses .pill, .pill.smNeither exists anywhere in apps/web//*.css.** The primitive is .acrm-pill (+ .sm, .dot, .x, .ti).
loading/loader"Indeterminate spinner"The lying-eight with a running segment; classes .linf-track / .linf-run added.
loading/list-loader"Sentinel row at the end of a lazily loaded list"The drumming hand (AD P93); full .lh-* class list added, knobs documented.
loading/detail-state-screencss ds.cssds/person-record.css; .pr added to the class list (see A8).
avatar/avatar"16, 24 or 32px"Four sizes — inline 16 · sm 24 · md 26 (the default) · lg 32, measured.
entity-reference/entity-ref"opens the entity preview"onOpen is supplied by the call site; without it the chip is plain text.
toast/toast(new) stacked bottom-rightBottom-center: .toast-host is fixed at left: 50% with translateX(-50%).
toast, notificationsfiles listed the CSS onlyThe React implementations (ToastProvider.tsx, NotifBell.tsx) added; both live in the app, not in @acrm/ui.

C. Behaviour verified in the browser

Not appearance — these were clicked and measured:

  • Empty state — default 155px vs sm 107px in the same slot; the action renders a real .acrm-btn.
  • Error state — role="alert", the component's own default wording, .est-btn primary for the retry; default 150px vs sm 129px.
  • Skeleton line — 8 / 11 / 13px for sm / default / lg, shimmer sk-sweep 1.4s.
  • Skeleton list — aria-busy="true"; rows maps 1:1 to rendered rows and circles (2/4/8).
  • Loader — clicking Save swaps the label for the loader, the button keeps its width (62px before, during, after), gains is-loading and the sibling button disables; role="status" with .acrm-sr and an aria-hidden svg. Sizes 20 / 28 / 40 px wide via --linf-w, height always half.
  • List loader — Načíst další → the loader takes the footer, then 3 rows become 6; scales 34×21 / 45×28 / 67×42; role="status", four fingers, hand aria-hidden.
  • Detail state screen — across loading / loaded / not-found the header is 49px, the back button 30×30 at 12px, the title starts at 54px. Identical in all three, which is the component's entire purpose.
  • Toast — 400px wide; role="status", role="alert" for errors; a fourth push drops the oldest (cap of three); the loading toast has no .toast-x and uses the canonical <Loader>; after 4s only the loading toast survives.
  • Notifications — clicking a row marks it read (dot, head badge 2 nové → 1 nové, tab count all follow); the unread filter narrows to the remainder; Označit vše empties both and swaps in the per-filter empty state, and the footer goes with it.
  • Pill — 22px default / 20px sm; the .x is a real button with aria-label="Odebrat VIP" and removing reflows the row; all twelve tones measured.
  • Count badge — 16px tall at every length; at zero the badge and the tab's .cnt both disappear entirely.
  • Avatar — 16/24/26/32, circle 9999px vs square 8px, ground --surface-75 in every case (the hue is the ring and the initials, never the fill).
  • Entity reference — the chip is <button type="button"> with tabIndex 0; the underline is 1px dashed #dcdbdd at rest and darkens to rgb(48,49,53) on hover, never turning solid; the removable token removes exactly its own chip (2 → 1 → 0, ending in the field's empty text).

D. Corrections made to my own stories during validation

Recorded because each was a claim the browser refuted:

  1. Loader "scales with the surrounding font" — it does not; 28px in --small, --p and --h2 alike. Rewritten around --linf-w.
  2. --lh-scale / --lh-bg set on a parent — no effect (A5). Rewritten to target the element, with the broken form kept as a labelled counter-example.
  3. var(--surface-200) — no such token (the scale is 100 / 75 / 50). It resolved to nothing, so --lh-bg became invalid and the punch-outs went transparent. ds:validate does not catch an unknown custom property, only raw values.
  4. var(--body) and var(--border-subtle) — no such tokens either; the real names are --p and --border. All uses across the batch replaced and re-audited.
  5. Toast host with position: static but the translateX(-50%) left in place — the stack rendered 200px to the left of its box and every toast was clipped. Only visible by looking at the page.
  6. Count badge tab still printed 0 after the count hit zero.
  7. Entity chip "sits on the line without breaking its rhythm" — it does break it (A10).
  8. Avatar and entity-ref-mini compositions used .il-*, whose sheet is not in this branch's Ladle provider, so both rendered as unstyled stacks. Rewritten to be self-contained rather than coupling this branch to the still-open records branch.

E. Not done

  • A1, A2, A3, A4, A6, A7, A9, A11 are recorded, not fixed. They are changes to shared primitives with a visual footprint across many screens, and each needs its own MR with before/after screenshots.
  • No visual baselines were regenerated for stories other than the fifteen added here.

Addendum — AI category (5 Sep 2026)

The last two subs of the scope, taken through the same pipeline. Branch polish/ds-ai-stories, preview key ds-ai-stories.

A12 — Three icon names in the AI panel do not exist, and the fallback is silent

Icon resolves an unknown name to ICONS['info-circle'] with no warning (ds-icons.tsx). thumb-up, thumb-down and player-stop are not in the set, and AssistantChat.tsx uses all three.

Verified in the browser: the 👍 and 👎 buttons draw the identical glyph (<circle cx="12" cy="12" r="9">), and the stop button during a run draws an info circle instead of a stop square.

The failure is inverted from the usual one: the aria-labels are correct, so a screen-reader user can tell the two buttons apart — a sighted user cannot.

sparkles-2 and robot have the same gap and are patched at runtime by apps/web/src/lib/ai-icons.ts, which its own header calls a stopgap and marks TODO(ds-promote). The three above were never added.

Not fixed here on purpose. Adding a glyph is an Art Director call, the canonical home is the DS icon set, and ai-icons.ts says plainly that the app must not write to the DS directly. Writing three Tabler paths from memory into the product is exactly the kind of unverified change this pass exists to catch. The story keeps the real names so it shows what the app shows, with the gap labelled.

A13 — The live-examples bundle did not run the provider's JS

ds-examples-build.mjs generated an entry that imports only the story files, never the Ladle provider. CSS still reached the page (the site serves app.css from the catalog snapshot), so the gap was invisible until a story needed a runtime side effect: the assistant's sparkle mark rendered as info-circle in the live examples while the catalog and the product both drew the sparkle. The bundle was quietly lying about both.

Fixed in the tool: the generated entry now imports apps/web/src/lib/ai-icons.ts for its side effect, and the ad-hoc vite config gained resolve.dedupe + an explicit @acrm/ui alias so stories (relative import) and app modules (package name) cannot end up on two instances of the mutable ICONS singleton.

Worth noting how it was nearly missed twice: the first attempt guarded the import with existsSync('…/ai-icons.js') while the file on disk is .ts, so the import was filtered out silently and the rebuild changed nothing. Only grepping the built bundle for the glyph's path data showed it had never been included.

B — Registry records corrected

EntryWasIs
ai-assistantfiles: ds/ai-chat.css onlyThe panel stands on two sheets: components/assistant/assistant.css (shell) + ds/ai-chat.css (thread content). Loading one without the other gives a half-styled panel. React files added.
discussionstatus beta, source mockupcandidate. Nothing in apps/web or packages/ui emits it — no component, no classes, no CSS. beta implies it is in the code and unfinished; this has never been built. Stories left deliberately empty.

C — Behaviour verified in the browser

  • Panel 398 × 518 at the app width, head 49px, composer pinned at 90px, body scrolls between them (overflow-y: auto).
  • The question is the only framed thing: .aichat-user has --surface-75, radius 15px, 85% max width, 12px from the right edge; .aichat-msg has transparent background, 0px border and 0px radius. The canon rule holds in the render.
  • Tool trace toggles aria-expanded false → true and reveals 3 tools.
  • Feedback buttons are aria-pressed toggles that add on; pressing the active thumb again clears it.
  • Running a question: the send button becomes Zastavit, the trace opens by itself, .aichat-run and .aichat-typing appear; when the answer lands both disappear, send returns to Odeslat and the action row appears on both answers.
  • Sizes 318 / 398 / 518 wide.

D — Corrections to my own story during validation

  1. The Composition example was 900px wide in a 673px slot and was clipped on the right — visible only in a screenshot. Narrowed to 620px and re-measured (clipped: false).
  2. The sparkle mark drew info-circle in the live examples until A13 was fixed.

E — Scope note

discussion gets no story. Writing one would mean inventing markup for something the code does not contain, which is the opposite of what this rebuild is for. Its docs say so plainly and point at note-card and timeline as the built neighbours to reach for meanwhile.


Rozhodnutí AD (5. 9. 2026)

Review provedeno se zadavatelem, jedno rozhodnutí po druhém. Dvě moje zjištění při dokládání čísel neobstála a jsou opravena níž — ne škrtnuta potichu.

#NálezRozhodnutí
A4Kolize tónů pilulekZrušit positive / warning / info jako tóny pilulky. Zbývají atributové hue + critical a accent.
A7.acrm-count.alert pod AATotéž pravidlo — varianta se nikde nevolá, takže se maže, ne přebarvuje.
A1 + A2Mouse-only prvky, filtry nejsou tabyOpravit všechny čtyři (.nitem, .sb-h, .sb-peek, kořen Deal cardu) vzorem StatTile + filtry panelu na role="tablist".
A3Šedá pilulka v darkVlasová linka jen v dark tématu. Barvy beze změny.
A9--warning 1,80:1 jako barva textuVyměnit všech 9 color: výskytů za --warning-ink (5,58:1). background/border zůstávají na --warning.
A12Chybějící glyfyRovnou do icon setu — a rozšířit ho na ~300 kurátorsky vybraných ikon, aby bylo z čeho brát. Ke generování napsat skript.
A5, A6, A8, A10—Zůstávají zapsané, bez zásahu.

Opravy mých vlastních nálezů

A11 — STAŽENO. Bylo chybné. Tvrdil jsem, že shimmer skeletonu, loader ani bubnující ruka nectí prefers-reduced-motion. Ctí, a promyšleně. ds.css:1558 vypíná animaci .lh-finger / .lh-joints / .lh-nail / .lh-thumb-item / .acrm-loadinf .linf-run s komentářem „ruka zůstane, jen přestane bubnovat — v klidové poloze je pořád čitelná; u osmičky zůstane stát běžící úsek na dráze, takže je pořád vidět, že se čeká"; skeleton.css:77 mění sweep na „calm tint". Celkem je prefers-reduced-motion v repu na 6 místech. Nález vznikl tím, že jsem hledal jen u definic animací a ne v celém sheetu.

A3 — přeformulováno. Psal jsem, že „šedá ztrácí tvar, kdežto ostatní hue se oddělují jasně". Změřeno proti tmavému plátnu: šedá 1,03 · amber 1,05 · purple 1,06 · blue 1,08 · rose 1,08 · lime 1,19. Žádná se neodděluje jasem — barevné jsou čitelné jen díky odstínu a šedá žádný nemá. Text je přitom čitelný (6,38:1); neviditelný je tvar pilulky.

A4 — přeformulováno. Psal jsem, že sousedící pilulky ze dvou rodin nejde rozlišit. V praxi vedle sebe nestojí: funkční polovina je mrtvá (positive 0 volání, info 0, warning 1). Vada není kolize barev, ale to, že DS popisuje dvourodinové pravidlo, které kód nikdy nezavedl.

Nové nálezy z review

A14 — icon set nemá kanon a jeho generátor neexistuje

ds-icons.tsx v hlavičce říká „generated verbatim from packages/ui/reference/screens/icons.js … do NOT hand-edit; re-generate from the DS". Přitom:

  • icons.js není v sync-manifest.json — ani v ports[], ani v unported[], takže ho ds:sync-check nehlídá vůbec;
  • kopie v legacy/icons.js se od zrcadla liší (jiný md5) a legacy/ od 2. 9. kanonem není;
  • žádný generátor v repu není — „re-generate" nemá implementaci, obě kopie se drží v souladu ručně.

Icon set je tedy zrcadlo bez kanonu za sebou. Rozhodnutí A12 to řeší: skript, který obojí generuje z @tabler/icons podle explicitního allowlistu.

A15 — mapa ikon se nedá tree-shakovat

Icon řeší jméno až za běhu (ICONS[name] ?? ICONS['info-circle']) nad jedním objektovým literálem. <Icon name="…" /> je tím pádem ze své podstaty dynamické a mapa se nikdy nezmenší podle skutečného použití: každá ikona v setu se posílá každému uživateli při každém načtení. Dnes 149 ikon = 23 kB (154 B/ikonu). Proto je rozšíření kurátorské (~300 ≈ 46 kB), ne „všechno z Tableru" (~5 900 ≈ 900 kB).


Provedení A1 + A2 (6. 9. 2026) — a co se při něm ukázalo

Vzor StatTile se nedal slepě zkopírovat

Rozhodnutí znělo „všechny čtyři prvky vzorem StatTile" (role="button" + tabIndex + Enter/Space na obalu). Při implementaci vyšlo najevo, že všechny čtyři mají uvnitř další tlačítko: NotifRow nese „Zobrazit", hlavička sekce svou SectionAction, Deal card statistiku v patičce. role="button" na obalu by tedy vyrobil vnořený interaktivní prvek — v ARIA zakázané a čtečky se na tom chovají nepředvídatelně. StatTile to má snadné jen proto, že uvnitř nic klikacího nemá.

Použitý tvar je u každého jiný, princip stejný — jeden opravdový <button> uvnitř, obal zůstává div, klik z Enter/Space probublá do stávajícího handleru, takže myš i klávesnice jdou jednou cestou a nic se nespouští dvakrát (ověřeno: klik na tlačítko přepne sekci právě jednou):

prvektvar
hlavička sekcetitulek + šipka zabalené do <button aria-expanded> bez handleru; SectionAction zůstává sourozenec
sbalený náhled .sb-peekcelý je <button> — uvnitř nic interaktivního není
řádek oznámenířádek = role="button" + tabIndex; „Zobrazit" klesá z <button> na <span aria-hidden> — nese tutéž akci, takže je jeden cíl místo dvou
Deal cardnázev .c-name je <button> bez handleru (jen na klikací kartě); kořen s DnD zůstává netknutý

Oprava nálezu A1 u NotifRow

Zapsal jsem, že „primární cíl panelu nejde z klávesnice dosáhnout". Přeceněno. Akce „otevřít" z klávesnice dosažitelná byla — přes to malé „Zobrazit", které bylo skutečné <button>. Funkčně tedy nic nechybělo; vada byla ergonomická (drobný cíl místo celého řádku) a sémantická (dva interaktivní prvky se stejným účinkem). Oprava to sjednocuje, ale nálezu patří slabší formulace.

Co zůstává

  • Klávesnicové přetahování karet na boardu — jiná, větší kapitola. Tady jen „otevřít".
  • Taby filtru mají role="tab" + aria-selected, ale bez šipek: mezi nimi se chodí Tabem. WAI-ARIA pro tablist doporučuje šipky; u dvou filtrů je to přijatelné, ne ideální.

Source: docs/REVIEW-identity-states-family.md · GitLab