prime-ui-kit 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/dist/chunks/{chunk-4KONMO7Z.js → chunk-6L7VWEY6.js} +1 -1
  3. package/dist/chunks/{chunk-3PY52JP3.js → chunk-S27VR4ON.js} +5828 -5596
  4. package/dist/chunks/{chunk-W6EGTNG6.js → chunk-S4H2ZKWJ.js} +1099 -661
  5. package/dist/color-picker.js +2 -2
  6. package/dist/components/badge/Badge.d.ts +19 -2
  7. package/dist/components/card/Card.d.ts +10 -3
  8. package/dist/components/composer/Composer.d.ts +49 -1
  9. package/dist/components/crossfade/Crossfade.d.ts +6 -4
  10. package/dist/components/data-table/DataTable.d.ts +10 -2
  11. package/dist/components/data-table/Row.d.ts +12 -1
  12. package/dist/components/data-table/SelectionMenu.d.ts +20 -0
  13. package/dist/components/data-table/useRowSelection.d.ts +2 -0
  14. package/dist/components/empty-page/EmptyPage.d.ts +5 -2
  15. package/dist/components/hint/Hint.d.ts +1 -1
  16. package/dist/components/index.d.ts +1 -1
  17. package/dist/components/input/Input.d.ts +15 -3
  18. package/dist/components/message/Message.d.ts +6 -3
  19. package/dist/components/segmented-control/SegmentedControl.d.ts +5 -1
  20. package/dist/components/smart-filter/SmartFilter.d.ts +4 -0
  21. package/dist/components/spinner/Spinner.d.ts +3 -2
  22. package/dist/components/tabs/Tabs.d.ts +10 -1
  23. package/dist/hooks/useModalLayer.d.ts +15 -8
  24. package/dist/index.css +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/internal/RegionSwap.d.ts +18 -10
  27. package/dist/internal/RollingNumber.d.ts +7 -5
  28. package/dist/internal/ToolRow.d.ts +6 -0
  29. package/dist/internal/mediaSlot.d.ts +29 -0
  30. package/dist/internal/overlay/DialogParts.d.ts +30 -6
  31. package/dist/internal/overlay/focus.d.ts +18 -3
  32. package/dist/internal/partPresence.d.ts +19 -2
  33. package/dist/layout/sidebar/Sidebar.d.ts +3 -2
  34. package/dist/skill/anti-slop.md +35 -37
  35. package/dist/skill/api-contract.md +50 -2
  36. package/dist/skill/cheatsheet.md +15 -10
  37. package/dist/skill/checklist.md +19 -6
  38. package/dist/skill/choosing.md +79 -6
  39. package/dist/skill/components.md +1 -1
  40. package/dist/skill/composition.md +211 -51
  41. package/dist/skill/foundations.md +17 -6
  42. package/dist/skill/layouts.md +64 -14
  43. package/dist/skill/motion.md +9 -3
  44. package/dist/skill/reference/components/avatar/COMPONENT.md +1 -0
  45. package/dist/skill/reference/components/badge/COMPONENT.md +29 -5
  46. package/dist/skill/reference/components/badge/examples/examples.module.css +14 -0
  47. package/dist/skill/reference/components/badge/examples/overflow-count.tsx +32 -0
  48. package/dist/skill/reference/components/banner/COMPONENT.md +1 -1
  49. package/dist/skill/reference/components/card/COMPONENT.md +8 -4
  50. package/dist/skill/reference/components/card/examples/person-header.tsx +38 -0
  51. package/dist/skill/reference/components/composer/COMPONENT.md +35 -5
  52. package/dist/skill/reference/components/composer/examples/bot-keyboard.tsx +43 -0
  53. package/dist/skill/reference/components/confirm-dialog/COMPONENT.md +1 -1
  54. package/dist/skill/reference/components/crossfade/COMPONENT.md +18 -10
  55. package/dist/skill/reference/components/data-table/COMPONENT.md +17 -11
  56. package/dist/skill/reference/components/data-table/examples/screen.tsx +6 -9
  57. package/dist/skill/reference/components/data-table/examples/selection.tsx +60 -18
  58. package/dist/skill/reference/components/dnd/COMPONENT.md +3 -3
  59. package/dist/skill/reference/components/drawer/COMPONENT.md +41 -19
  60. package/dist/skill/reference/components/drawer/examples/footer-actions.tsx +54 -0
  61. package/dist/skill/reference/components/drawer/examples/header-actions.tsx +46 -25
  62. package/dist/skill/reference/components/drawer/examples/link-loading.tsx +57 -0
  63. package/dist/skill/reference/components/drawer/examples/screen.tsx +3 -5
  64. package/dist/skill/reference/components/empty-page/COMPONENT.md +2 -2
  65. package/dist/skill/reference/components/field-list/COMPONENT.md +7 -7
  66. package/dist/skill/reference/components/field-list/examples/row-menu.tsx +1 -1
  67. package/dist/skill/reference/components/hint/COMPONENT.md +1 -1
  68. package/dist/skill/reference/components/input/COMPONENT.md +16 -3
  69. package/dist/skill/reference/components/input/examples/password-policy.tsx +65 -0
  70. package/dist/skill/reference/components/list/COMPONENT.md +1 -1
  71. package/dist/skill/reference/components/login-form/COMPONENT.md +13 -11
  72. package/dist/skill/reference/components/login-form/examples/overview.tsx +5 -5
  73. package/dist/skill/reference/components/login-form/examples/states.tsx +7 -6
  74. package/dist/skill/reference/components/message/COMPONENT.md +5 -5
  75. package/dist/skill/reference/components/message/examples/bot-actions.tsx +47 -28
  76. package/dist/skill/reference/components/modal/COMPONENT.md +31 -12
  77. package/dist/skill/reference/components/modal/examples/structure.tsx +2 -4
  78. package/dist/skill/reference/components/segmented-control/COMPONENT.md +11 -6
  79. package/dist/skill/reference/components/segmented-control/examples/router.tsx +22 -0
  80. package/dist/skill/reference/components/select/COMPONENT.md +4 -1
  81. package/dist/skill/reference/components/sheet/COMPONENT.md +31 -12
  82. package/dist/skill/reference/components/sheet/examples/header-actions.tsx +5 -3
  83. package/dist/skill/reference/components/skeleton/COMPONENT.md +3 -2
  84. package/dist/skill/reference/components/smart-filter/COMPONENT.md +2 -1
  85. package/dist/skill/reference/components/smart-filter/examples/ranges.tsx +8 -2
  86. package/dist/skill/reference/components/spinner/COMPONENT.md +1 -1
  87. package/dist/skill/reference/components/tabs/COMPONENT.md +7 -4
  88. package/dist/skill/reference/components/tabs/examples/in-drawer.tsx +18 -2
  89. package/dist/skill/reference/components/thumbnail/COMPONENT.md +1 -0
  90. package/dist/skill/reference/components/tooltip/COMPONENT.md +3 -3
  91. package/dist/skill/reference/foundation.md +15 -9
  92. package/dist/skill/reference/layout/page/COMPONENT.md +1 -0
  93. package/dist/skill/reference/layout/sidebar/COMPONENT.md +4 -2
  94. package/dist/skill/reference/layout/sidebar/examples/account-profile.tsx +66 -0
  95. package/dist/skill/responsive.md +28 -6
  96. package/dist/skill/ux.md +97 -2
  97. package/dist/tokens/semantic.d.ts +2 -1
  98. package/dist/tokens/themes/dark.d.ts +1 -0
  99. package/package.json +1 -1
  100. package/src/components/avatar/COMPONENT.md +1 -0
  101. package/src/components/badge/COMPONENT.md +29 -5
  102. package/src/components/badge/examples/examples.module.css +14 -0
  103. package/src/components/badge/examples/overflow-count.tsx +32 -0
  104. package/src/components/banner/COMPONENT.md +1 -1
  105. package/src/components/card/COMPONENT.md +8 -4
  106. package/src/components/card/examples/person-header.tsx +38 -0
  107. package/src/components/composer/COMPONENT.md +35 -5
  108. package/src/components/composer/examples/bot-keyboard.tsx +43 -0
  109. package/src/components/confirm-dialog/COMPONENT.md +1 -1
  110. package/src/components/crossfade/COMPONENT.md +18 -10
  111. package/src/components/data-table/COMPONENT.md +17 -11
  112. package/src/components/data-table/examples/screen.tsx +6 -9
  113. package/src/components/data-table/examples/selection.tsx +60 -18
  114. package/src/components/dnd/COMPONENT.md +3 -3
  115. package/src/components/drawer/COMPONENT.md +41 -19
  116. package/src/components/drawer/examples/footer-actions.tsx +54 -0
  117. package/src/components/drawer/examples/header-actions.tsx +46 -25
  118. package/src/components/drawer/examples/link-loading.tsx +57 -0
  119. package/src/components/drawer/examples/screen.tsx +3 -5
  120. package/src/components/empty-page/COMPONENT.md +2 -2
  121. package/src/components/field-list/COMPONENT.md +7 -7
  122. package/src/components/field-list/examples/row-menu.tsx +1 -1
  123. package/src/components/hint/COMPONENT.md +1 -1
  124. package/src/components/input/COMPONENT.md +16 -3
  125. package/src/components/input/examples/password-policy.tsx +65 -0
  126. package/src/components/list/COMPONENT.md +1 -1
  127. package/src/components/login-form/COMPONENT.md +13 -11
  128. package/src/components/login-form/examples/overview.tsx +5 -5
  129. package/src/components/login-form/examples/states.tsx +7 -6
  130. package/src/components/message/COMPONENT.md +5 -5
  131. package/src/components/message/examples/bot-actions.tsx +47 -28
  132. package/src/components/modal/COMPONENT.md +31 -12
  133. package/src/components/modal/examples/structure.tsx +2 -4
  134. package/src/components/segmented-control/COMPONENT.md +11 -6
  135. package/src/components/segmented-control/examples/router.tsx +22 -0
  136. package/src/components/select/COMPONENT.md +4 -1
  137. package/src/components/sheet/COMPONENT.md +31 -12
  138. package/src/components/sheet/examples/header-actions.tsx +5 -3
  139. package/src/components/skeleton/COMPONENT.md +3 -2
  140. package/src/components/smart-filter/COMPONENT.md +2 -1
  141. package/src/components/smart-filter/examples/ranges.tsx +8 -2
  142. package/src/components/spinner/COMPONENT.md +1 -1
  143. package/src/components/tabs/COMPONENT.md +7 -4
  144. package/src/components/tabs/examples/in-drawer.tsx +18 -2
  145. package/src/components/thumbnail/COMPONENT.md +1 -0
  146. package/src/components/tooltip/COMPONENT.md +3 -3
  147. package/src/layout/page/COMPONENT.md +1 -0
  148. package/src/layout/sidebar/COMPONENT.md +4 -2
  149. package/src/layout/sidebar/examples/account-profile.tsx +66 -0
  150. package/src/styles/theme-dark.css +1 -0
  151. package/src/styles/theme-light.css +1 -0
@@ -13,10 +13,10 @@ playground page. What each screen teaches is in [Screen rules](#screen-rules) at
13
13
 
14
14
  | Screen | Main component | File | Shows |
15
15
  |---|---|---|---|
16
- | [List page](#list-page) | data-table | [screen.tsx](reference/components/data-table/examples/screen.tsx) | the title, a line of figures, SmartFilter search and filters, export and one primary action in the page's top bar; statuses, row menu, pages, filtered empty state |
16
+ | [List page](#list-page) | data-table | [screen.tsx](reference/components/data-table/examples/screen.tsx) | the title, a line of figures, export and one primary action in the page's top bar; the table's SmartFilter search and filters in its own `toolbar`, the chips under it, the column chooser over the row menu; statuses, pages, filtered empty state |
17
17
  | [Detail page](#detail-page) | description-list | [screen.tsx](reference/components/description-list/examples/screen.tsx) | a nested record: the path ending in the title, status and due date and the record's actions by weight in the top bar; main column + side column of Cards, Timeline |
18
18
  | [Settings page](#settings-page) | page | [screen.tsx](reference/layout/page/examples/screen.tsx) | the path in a collapsing top bar, the large title in the flow, a card with its own header per section, form with its own save, Switches that apply at once, danger zone with a confirm |
19
- | [Form in a Drawer](#form-in-a-drawer) | drawer | [screen.tsx](reference/components/drawer/examples/screen.tsx) | the title, counts, search and the Drawer trigger in the top bar; Drawer form: groups, errors on submit, focus on the first invalid field, loading, toast |
19
+ | [Form in a Drawer](#form-in-a-drawer) | drawer | [screen.tsx](reference/components/drawer/examples/screen.tsx) | the title, counts and the Drawer trigger in the top bar, the search in the table's toolbar; Drawer form: groups, errors on submit, focus on the first invalid field, loading, toast |
20
20
  | [Record editor](#record-editor) | field-list | [screen.tsx](reference/components/field-list/examples/screen.tsx) | a document page (a long form): the title and a line of description in `Page.Heading`, «Сохранить тариф» in `Page.Actions`, both in a `Page.Header` in the flow; a plan card edited in card sections: name and description, specs in a `FieldList` (icon-only `Select` and `ColorPresets` in compact columns, pinned rows, reorder), the price and its changes by month in one list with the base row pinned, a setup fee in three currencies in one row under one heading, save with «Сохранено» on the button |
21
21
  | [Dashboard](#dashboard) | card | [screen.tsx](reference/components/card/examples/screen.tsx) | the large title + a line of data in the flow, period switch and report in a collapsing `Page.TopBar`; KPI row that cross-fades to the new period, a Sparkline revenue trend, invoiced vs paid in a ChartFrame, panels with progress and a split, short table + link to the list |
22
22
  | [Screen states](#screen-states) | crossfade | [screen.tsx](reference/components/crossfade/examples/screen.tsx) | title + the statement date in `Page.TopBar`, page error in a Banner with retry, region loading with a Skeleton, Crossfade between states, table error in place, first-run EmptyPage |
@@ -66,7 +66,7 @@ AppShell.Root once per app (layouts.md#app-frame-once-per-a
66
66
  │ │ ├─ Breadcrumb.Root only on a nested record: the levels above this page
67
67
  │ │ ├─ Page.Title the one h1, the path's last level
68
68
  │ │ └─ Page.Description a line of data
69
- │ ├─ Page.Tools what acts on the whole page — sections, a period, view; a one-list page's search
69
+ │ ├─ Page.Tools what acts on the whole page — sections, a period, view; a filter over several views
70
70
  │ ├─ Page.Actions every button of the page, each with an icon; the primary last
71
71
  │ └─ Page.Chips SmartFilter.Chips: a line under everything, gone while empty
72
72
  └─ Page.Body blocks 24 apart, the first 24 under the bar's controls
@@ -99,10 +99,10 @@ order is Heading, Tools, Actions, Chips whatever the JSX order.
99
99
  `Page.Description` under the whole line. The title is never a row of its own under the path, and
100
100
  the path never repeats the title as its last item.
101
101
  - **`Page.Tools`** — what acts on the whole page, as kit controls in the order a person reads them:
102
- sections (a `SegmentedControl` with counts), the view, a period that drives several blocks; the
103
- search and filter (`SmartFilter.Toolbar`) only when the page is one list
104
- ([where the filters go](#where-the-filters-go)). Named with `aria-label`; no buttons and no
105
- wrappers inside.
102
+ sections (a `SegmentedControl` with counts), the view, a period that drives several blocks; a
103
+ search and filter (`SmartFilter.Toolbar`) only when it governs more than one table — a board and
104
+ its list, a media grid ([where the filters go](#where-the-filters-go)). Named with `aria-label`;
105
+ no buttons and no wrappers inside.
106
106
  - **`Page.Actions`** — the one place for the page's buttons: secondary ones (`soft` + `neutral`)
107
107
  first, the primary (`solid`) last. Every Button carries a `Button.Icon` beside its label: the bar
108
108
  shows it as the icon alone when the line runs short.
@@ -287,20 +287,63 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
287
287
 
288
288
  - **One primary per area** (the page, card, dialog, form): `solid`, last in its row. Secondary:
289
289
  `soft` + `tone="neutral"`. Dismiss / revert: `ghost` + `neutral` in pages and cards, `outline` +
290
- `neutral` in Modal / Drawer footers. Rare actions: a `Dropdown` behind an `action.more` icon button —
291
- `soft` + `neutral` in a header row (it has a shape beside the other buttons), `ghost` size `s` in a
292
- table cell.
290
+ `neutral` in Modal / Drawer footers.
293
291
  - Placement: every button of the page in one `Page.Actions` — of `Page.TopBar` on a work screen, of the flow `Page.Header` on a document page — the primary last; card actions in `Card.Footer`; dialog actions in
294
- `Modal.Footer` / `Drawer.Footer`; row actions in the last column; bulk actions in the table toolbar.
292
+ `Modal.Footer` / `Drawer.Footer`; row actions in the last column; actions on the selected rows of a
293
+ table in its selection menu ([controls of a table](#controls-of-a-table)).
295
294
  - A dialog footer is the dialog's answer: Cancel and the primary. Deleting the record the dialog
296
- edits is not a third footer button: it is the last `tone="danger"` item of a ⋯ `Dropdown` in
297
- `Modal.Actions` / `Drawer.Actions`, confirmed by a `ConfirmDialog`.
295
+ edits is not a third footer button: it stands in `Modal.Actions` / `Drawer.Actions` of the header
296
+ by the menu threshold below, confirmed by a `ConfirmDialog`.
298
297
  - An empty state's action replaces the header's primary, it does not duplicate it.
299
298
  - Destructive: `tone="danger"`; a standalone trigger is `outline`, in a menu it is the last item, the
300
299
  confirm is a `ConfirmDialog` (its `onConfirm` returns the request's Promise).
301
300
  - Going somewhere is a link (`LinkButton`, `Button.Root asChild` + `<a>`), doing something is a Button.
302
301
  One action — one label on the whole screen.
303
302
 
303
+ ### A menu from two actions
304
+
305
+ An action that is alone stays in sight; a «⋯» only gathers. Count the secondary actions of one
306
+ place — a page or dialog header, a table row, a card, a list row:
307
+
308
+ | Actions | Shape |
309
+ |---|---|
310
+ | one | an icon-only `Button.Root` (`aria-label`) in a `Tooltip.Trigger` (`ghost` in a dialog header), or a labelled button where its row has room |
311
+ | two or more | one `Dropdown` behind an `action.more` icon button — `soft` + `neutral` in a page header (a shape beside its neighbours), `ghost` in a dialog header, `ghost` size `s` in a table cell; destructive last, `tone="danger"` |
312
+
313
+ A ⋯ with one item hides the only action behind a second press and a guess. Nothing else decides:
314
+ not the density of the row, not the number of its columns, not the room left for later actions. A
315
+ row of a `FieldList` follows the same count ([forms](#7-forms)).
316
+
317
+ ```tsx partial
318
+ // bad — a menu for the one thing this header can do
319
+ <Drawer.Actions>
320
+ <Dropdown.Root>
321
+ <Dropdown.Trigger>
322
+ <Button.Root variant="ghost" tone="neutral" aria-label="Другие действия">…</Button.Root>
323
+ </Dropdown.Trigger>
324
+ <Dropdown.Content>
325
+ <Dropdown.Item tone="danger">Удалить приглашение</Dropdown.Item>
326
+ </Dropdown.Content>
327
+ </Dropdown.Root>
328
+ </Drawer.Actions>
329
+ ```
330
+
331
+ ```tsx good
332
+ // good — one action in sight, named by its tooltip
333
+ <Drawer.Actions>
334
+ <Tooltip.Root>
335
+ <Tooltip.Trigger>
336
+ <Button.Root variant="ghost" tone="danger" aria-label="Удалить приглашение">
337
+ <Button.Icon>
338
+ <Icon name="action.delete" />
339
+ </Button.Icon>
340
+ </Button.Root>
341
+ </Tooltip.Trigger>
342
+ <Tooltip.Content>Удалить приглашение</Tooltip.Content>
343
+ </Tooltip.Root>
344
+ </Drawer.Actions>
345
+ ```
346
+
304
347
  ## 7. Forms
305
348
 
306
349
  - Labels above every field (`label`), placeholder is an example value, help in `hint`, problems in
@@ -310,10 +353,10 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
310
353
  columns. Never a label in every row, never numbered labels, never one record split over lines.
311
354
  FieldList draws every row's actions itself — the remove «×» and the handle after it, a pinned
312
355
  row's remove off with its reason (`labels.lockedReason`) — so never add your own.
313
- - The row's actions (`rowActions`): `"buttons"` (default) for short lists edited often on a desktop,
314
- where moving rows is a main task — remove and handle in sight on every row; `"menu"` — one «⋯» with
315
- «Выше», «Ниже», «Удалить» — for dense rows (three or more columns), touch-heavy screens, or a row
316
- that would carry more than remove and move.
356
+ - The row's actions (`rowActions`) follow [the menu threshold](#a-menu-from-two-actions): a row
357
+ that only removes shows its «×» (the default `"buttons"`, and the grip after it with
358
+ `reorderable`), however many columns it has; `"menu"` — one «⋯» with «Выше», «Ниже», «Удалить» —
359
+ only for a `reorderable` list on a touch-first screen, where moving by the menu replaces a drag.
317
360
  - The look (`variant`): `"plain"` (default) when the list is the content of its card or section;
318
361
  `"surface"` when the list stands among other fields of a form or card, so its rows read as one
319
362
  block apart from the fields around it.
@@ -334,40 +377,46 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
334
377
  section, the whole `Card.Root` inside `<form>`, buttons in `Card.Footer`.
335
378
  - Submit: `noValidate`, validate all, set `error`s, focus the first invalid field, `loading` on the
336
379
  submit button and the fields disabled (one `Fieldset disabled` per group of fields), then the
337
- result: the form stays on screen → the button says «Сохранено»; the form goes away (closes,
338
- navigates) → a `Notification`; a failure → a `Notification` or the field's `error`.
380
+ result: the save button stays on screen → it says «Сохранено»; the button goes away (the dialog
381
+ closes, the page navigates, the button shows only while dirty) → a `Notification`; a failure → a `Notification` or the field's `error`.
339
382
  - A footer button outside the form submits it with `form={formId}`.
340
- - Choice controls: Checkbox when the value is submitted later, Switch when it applies at once (no Save
341
- in that section), `Radio.Group label` for one of a few visible options
342
- ([choosing.md](choosing.md#2-inside-a-category)).
383
+ - Choice controls are picked by what the value means, not by when it saves:
384
+ [choosing.md](choosing.md#a-flag-a-level-a-set).
343
385
 
344
386
  ## 8. Tables and lists
345
387
 
346
388
  ### Where the filters go
347
389
 
348
- A filter stands next to what it filters.
349
-
350
- - `Page.TopBar` holds only what acts on the whole page: the heading with its line of data, a period
351
- or a date range that drives several blocks (the KPI cards and the table), the page's sections or
352
- view, the primary action.
353
- - A page that holds more than its table — KPI cards, a chart, another block: the table's own
354
- controls (search and filter, the chips, the column chooser, a table / cards view, bulk actions)
355
- stand in the table's tools row. `SmartFilter.Root` wraps the `DataTable`; `SmartFilter.Toolbar`
356
- and `DataTableColumnsMenu` go in its `toolbar`, `SmartFilter.Chips` in its `toolbarChips`
357
- ([server.tsx](reference/components/data-table/examples/server.tsx)).
358
- - A page that is one list — the table, the board or the schedule is the whole page: «next to the
359
- list» and «on the page» are one place, so its search and filter stay in the bar. `SmartFilter.Root`
360
- wraps `Page.Root`, `SmartFilter.Toolbar` in `Page.Tools`, `SmartFilter.Chips` in `Page.Chips`
361
- ([the list page](#list-page)). A search alone is the same SmartFilter with `fields={[]}`.
362
- - What picks the place: does the control change everything under the bar? A period that recomputes
363
- the cards and the table does — the bar. A search that narrows only the rows does not — the
364
- table's row.
390
+ A control stands with what it changes. Ask of each one: what does it narrow or switch?
391
+
392
+ - **One table** — its search and filter fields, their chips, its column chooser and the actions on
393
+ its selected rows belong to the `DataTable`, on every page: the page that is only this table, a
394
+ page with KPI cards above it, a page whose sections switch between tables. `SmartFilter.Root`
395
+ wraps the `DataTable`; `SmartFilter.Toolbar` goes in its `toolbar` (with a `DataTableColumnsMenu`
396
+ when the table has no row-actions column to carry `columnsMenu`), `SmartFilter.Chips` in its
397
+ `toolbarChips`, the selection menu comes from `renderSelectionActions`
398
+ ([server.tsx](reference/components/data-table/examples/server.tsx), [the list page](#list-page)).
399
+ The tools then stay put when the page grows a block or another section, and every table of the
400
+ app reads the same.
401
+ - **The whole page** — `Page.TopBar`: the heading with its line of data, the page's sections (a
402
+ `SegmentedControl`; of links, `SegmentedControl.Item asChild`, when each section is a route of its
403
+ own — «Сотрудники» / «Роли»), a view switch, a period that drives several blocks, the page's
404
+ buttons.
405
+ - **Several views of one set** — a search and filter that narrows a board and its list alike, a
406
+ media grid, a schedule without a tools row of its own: `SmartFilter.Root` wraps `Page.Root`,
407
+ `SmartFilter.Toolbar` in `Page.Tools`, `SmartFilter.Chips` in `Page.Chips`
408
+ ([the board](#sales-pipeline-board)).
409
+ - **A table section filters by its categories.** Every column with a closed set of values (a role, a
410
+ status, a type, an owner, a city) is an `options` field of the table's `SmartFilter`, a date
411
+ column a `date-range` field (its `calendar` with the presets of the domain), free text the
412
+ search. A search alone (`fields={[]}`) only for records with no such column. The value lives in
413
+ the URL ([nothing to guess](ux.md#6-nothing-to-guess)).
365
414
  - Both rows are one tool row of the kit: written in reading order, a search takes the free room,
366
415
  switchers and buttons keep their width at the end, and a short row wraps by itself — pass the
367
416
  controls as they are, never inside a flex wrapper of your own.
368
417
 
369
418
  ```tsx partial
370
- // bad — the table's search, filter, chips and chooser in the bar, above KPI cards they do not filter
419
+ // bad — the table's search, filter, chips and chooser in the bar, away from the rows they filter
371
420
  <SmartFilter.Root fields={FIELDS}>
372
421
  <Page.Root>
373
422
  <Page.TopBar>
@@ -463,12 +512,53 @@ A filter stands next to what it filters.
463
512
  chooser is `<DataTableColumnsMenu>` beside the table's search ([where the filters go](#where-the-filters-go)),
464
513
  bound to the same `hiddenColumns` / `columnOrder` state the table reads. Give the key column and
465
514
  the actions column `hideable: false`.
466
- - The DataTable `toolbar` holds two or more controls of the table itself — its search and filter,
467
- the chooser, a view, a bulk bar of the selected rows — as they are, in reading order; its chips go
468
- to `toolbarChips`.
469
- - One control never gets a row of its own. A lone «Экспорт» or «Создать» is the page's
470
- (`Page.Actions`); a lone chooser joins the table's search or the page's tools; a count or a total
471
- of the list is data in `Page.Description`.
515
+ - The DataTable `toolbar` holds the table's own controls — its search and filter, the chooser, a
516
+ table / cards view — as they are, in reading order; its chips go to `toolbarChips`. A search
517
+ heads the row; a button never does: a lone «Экспорт» or «Создать» is the page's
518
+ (`Page.Actions`), a lone chooser goes over the row actions, a count or a total of the list is data
519
+ in `Page.Description`.
520
+ - **Actions on selected rows are one menu, added to the row.** `renderSelectionActions` returns the
521
+ `Dropdown.Item`s; while rows are selected the table puts a «Действия · N» menu button last in its
522
+ tools row — the search, filter and chooser stay where they were, and the button leaves when the
523
+ selection empties. Never a row of «Выбрано: N» and buttons that replaces the tools, never one
524
+ button per bulk action in the row. A destructive item asks with a `ConfirmDialog` that names the
525
+ count ([friction](ux.md#5-friction-matches-the-cost-of-a-mistake)).
526
+
527
+ ```tsx partial
528
+ // bad — the selection swaps the tools row for a bar of buttons; search and filter vanish
529
+ <DataTable
530
+ selectable
531
+ selected={selected}
532
+ onSelectedChange={setSelected}
533
+ toolbar={selected.length > 0 ? <SelectionBar count={selected.length} /> : <SmartFilter.Toolbar />}
534
+ />
535
+ ```
536
+
537
+ ```tsx good
538
+ // good — the tools stay, the actions on the selection are one menu that enters last
539
+ // declare: const FIELDS: Parameters<typeof SmartFilter.Root>[0]["fields"]
540
+ // declare: const members: { id: string; name: string }[]
541
+ // declare: const selected: string[]
542
+ // declare: const setSelected: (keys: string[]) => void
543
+ <SmartFilter.Root fields={FIELDS}>
544
+ <DataTable
545
+ columns={[{ id: "name", header: "Сотрудник", accessor: "name" }]}
546
+ rows={members}
547
+ getRowKey={(row) => row.id}
548
+ selectable
549
+ selected={selected}
550
+ onSelectedChange={(keys) => setSelected(keys.map(String))}
551
+ toolbar={<SmartFilter.Toolbar />}
552
+ toolbarChips={<SmartFilter.Chips />}
553
+ renderSelectionActions={() => (
554
+ <>
555
+ <Dropdown.Item>Сменить роль</Dropdown.Item>
556
+ <Dropdown.Item tone="danger">Отключить доступ</Dropdown.Item>
557
+ </>
558
+ )}
559
+ />
560
+ </SmartFilter.Root>
561
+ ```
472
562
 
473
563
  ### Tables
474
564
 
@@ -500,11 +590,11 @@ A filter stands next to what it filters.
500
590
 
501
591
  | Situation | Component |
502
592
  |---|---|
503
- | the result of an action (sent, created, failed to save; saved when the form goes away) | `Notification` via `notify()` |
504
- | a form saved and still on screen | the submit button's text: «Сохранено» |
593
+ | the result of an action (sent, created, failed to save; saved when the form or its save button goes away) | `Notification` via `notify()` |
594
+ | a form saved whose save button stays on screen | the submit button's text: «Сохранено» |
505
595
  | a state of the page or a section that persists (sync failed, trial ends, read-only) | `Banner` as the first block of `Page.Body` (or inside the Card it concerns) |
506
596
  | a problem with one field | the field's `error` |
507
- | a refused sign-in | `invalid` on the fields it concerns and the reason in an always-mounted `VisuallyHidden live="assertive"` — no Banner on a sign-in or sign-up form ([LoginForm](reference/components/login-form/COMPONENT.md)) |
597
+ | a refused sign-in | `invalid` on the e-mail and the reason in words as the password's `error` — no Banner ([field outcomes](api-contract.md#field-outcomes-a-mark-for-success-words-for-a-problem)) |
508
598
  | a region failed to load | the region shows it in place: DataTable `loadError`, or `EmptyPage tone="danger"` with `EmptyPage.RetryButton` |
509
599
  | a region is loading | DataTable `loading`; otherwise a `Skeleton` in the geometry of the content + `aria-busy` on the region; `Spinner` only when there is no shape to hold |
510
600
  | a region changes state (loading → data → empty → error, another record) | its content in `Crossfade state={status}` — always, so the change flows instead of flipping |
@@ -541,6 +631,76 @@ are `Drawer.Section` / `Modal.Section` with their own `Drawer.Actions` / `Modal.
541
631
  hand-made heading row; a footer's `Drawer.Meta` / `Modal.Meta` goes above the buttons by itself when
542
632
  the footer runs short.
543
633
 
634
+ ### A record in a drawer
635
+
636
+ A record opened beside the page — a person, a role, a deal — is one drawer per entity, the same
637
+ wherever it opens:
638
+
639
+ - **One entity, one drawer.** One component opens the record from every place — the person's own
640
+ account in the Sidebar, a row of the team table, a mention — by its id. Who looks decides what it
641
+ shows: a field the viewer may not change is `readOnly` (`disabled` when the reason is said beside
642
+ it), a tab the viewer may not see is not rendered, an action the viewer may not take is hidden. A
643
+ second drawer for the same entity («Мой аккаунт» beside «Сотрудник») drifts apart from the first
644
+ with every change.
645
+ - **One record header** — the same in a Drawer, a Modal and a Sheet. `Drawer.Icon` leads: a
646
+ glyph gets a 40px tone tile, the record's `Avatar.Root` or `Thumbnail.Root` (even inside a wrapper
647
+ of your own) stands at 40 with no tile. Line 1 is `Drawer.Title`, the name (`loading` while it
648
+ comes). Line 2 is `Drawer.Description`: the status `Badge.Root`s first — the header sizes them, no
649
+ `size` — then the line of data. `Drawer.Actions` hold only buttons that act on this record: ghost
650
+ icon Buttons, each in a `Tooltip.Trigger`, counted by [the menu threshold](#a-menu-from-two-actions);
651
+ they are centred on line 1, before the ×. A Badge in `Drawer.Actions` is a status in the wrong place; a way to
652
+ another place (a section, a list) is no header action — a link in the body beside the data it leads
653
+ to, or nothing.
654
+ - **One form, one save.** Every editable field of every tab is one form, and its submit is the one
655
+ «Сохранить» of `Drawer.Footer`, shown while the form is dirty, with `Drawer.Guard` (`dirty`,
656
+ `busy`). No save per section or tab, no «Есть несохранённые изменения» in `Drawer.Meta`: the save
657
+ button and the guard already say it. Actions that work at once — turn sign-in on, send a link,
658
+ end the other sessions — are buttons beside what they change and never wait for the save. After
659
+ the save the button leaves, so the success is a `Notification`.
660
+ - **Tabs are topics.** Each tab holds one topic, and a record's history (its log) is always a tab of
661
+ its own. Tabs that outgrow the width never merge: the strip scrolls ([switchers](responsive.md#switchers)).
662
+ - **Data at the drawer's level.** The drawer loads the record and the data of its tabs; a tab never
663
+ fetches on mount, so coming back to it shows what it showed instead of a skeleton that collapses
664
+ it. A tab that keeps a draft or its scroll keeps its panel with `Tabs.Panel keepMounted`. The
665
+ opening itself — the header and the tabs at once, the body as a Skeleton in the form's geometry —
666
+ is in [Drawer](reference/components/drawer/COMPONENT.md).
667
+
668
+ ```tsx partial
669
+ // bad — a way out in the header, a save per section, a status line in the footer
670
+ <Drawer.Actions>
671
+ <Button.Root variant="ghost" tone="neutral">Команда и роли</Button.Root>
672
+ </Drawer.Actions>
673
+ <Drawer.Section label="Профиль">… <Button.Root>Сохранить</Button.Root></Drawer.Section>
674
+ <Drawer.Section label="Доступ">… <Button.Root>Сохранить</Button.Root></Drawer.Section>
675
+ <Drawer.Footer>
676
+ <Drawer.Meta>Есть несохранённые изменения</Drawer.Meta>
677
+ </Drawer.Footer>
678
+ ```
679
+
680
+ ```tsx good
681
+ // good — one form over the tabs, one save while it is dirty, the guard holds the rest
682
+ // declare: const dirty: boolean
683
+ // declare: const saving: boolean
684
+ <Drawer.Content>
685
+ <Drawer.Guard dirty={dirty} busy={saving} />
686
+ <Drawer.Header>
687
+ <Drawer.Title>Анна Соколова</Drawer.Title>
688
+ </Drawer.Header>
689
+ <Drawer.Body>
690
+ <form id="member-form" noValidate>
691
+ …
692
+ </form>
693
+ </Drawer.Body>
694
+ <Drawer.Footer>
695
+ {dirty ? (
696
+ <Button.Root type="submit" form="member-form" loading={saving}>
697
+ Сохранить
698
+ </Button.Root>
699
+ ) : null}
700
+ </Drawer.Footer>
701
+ </Drawer.Content>
702
+ ```
703
+
544
704
  ## 11. Narrow screens
545
705
 
546
706
  - Layouts are [intrinsic](responsive.md#intrinsic-grids) — no breakpoints needed, and they work in
@@ -630,7 +790,7 @@ What each screen in [the index](#screens) demonstrates — keep these when you s
630
790
  [data-table/examples/screen.tsx](reference/components/data-table/examples/screen.tsx)
631
791
 
632
792
  - A work screen titles itself in its `Page.TopBar`: `Page.Title` is the only heading, its `Page.Description` is one line of figures («10 счетов · просрочено 2 на …»), not a sentence about the page; there is no second header in the flow.
633
- - The table is the whole page, so its filters are the page's: `SmartFilter.Root` wraps the page, `SmartFilter.Toolbar` in `Page.Tools`, `SmartFilter.Chips` in `Page.Chips`; export (`soft` + `neutral`, an icon beside its label) and the one primary action (`solid`, last) in `Page.Actions`. The table itself has no `toolbar`; add a KPI row above it and the search, filter and chips move into the table's `toolbar` / `toolbarChips`.
793
+ - The search and filters narrow the table, so they are the table's: `SmartFilter.Toolbar` in its `toolbar`, `SmartFilter.Chips` in its `toolbarChips`, the column chooser over the row menu (`columnsMenu`); the bar keeps the heading, export (`soft` + `neutral`, an icon beside its label) and the one primary action (`solid`, last) in `Page.Actions`. A KPI row added above changes nothing in the table's tools.
634
794
  - A status is a `Badge` with one `tone` per meaning across the product (`STATUS` is one map); the text carries the meaning, the tone repeats it.
635
795
  - Numbers and dates are `numeric` columns with `headerAlign="end"` (tabular figures, values and heading on the end edge). On a phone the table scrolls inside itself and `stickyFirstColumn` keeps the number in place — the page never scrolls sideways.
636
796
  - Row actions are a `Dropdown` behind an `action.more` icon of size `s` in the last column (`hideHeader`), pinned to the end edge with `stickyLastColumn` so they stay in reach while the columns scroll: controls in cells are one tier below the table. Delete is `tone="danger"` at the end of the menu.
@@ -664,7 +824,7 @@ What each screen in [the index](#screens) demonstrates — keep these when you s
664
824
 
665
825
  [drawer/examples/screen.tsx](reference/components/drawer/examples/screen.tsx)
666
826
 
667
- - A work screen titles itself in its `Page.TopBar`: `Page.Title` with the counts in `Page.Description`, the search (`SmartFilter` with `fields={[]}`) in `Page.Tools`; there is no second header in the flow.
827
+ - A work screen titles itself in its `Page.TopBar`: `Page.Title` with the counts in `Page.Description`; the search (`SmartFilter` with `fields={[]}`) narrows the table, so it is the table's `toolbar`; there is no second header in the flow.
668
828
  - The opening button is the page's primary action in `Page.Actions`; `Drawer.Trigger` wraps it as a slot.
669
829
  - The form is tied to the footer button through `form={formId}`: `Drawer.Footer` is outside the `<form>`, and submitting still works, by Enter too.
670
830
  - Groups are a `Fieldset` without a frame with a `title-s` heading 16 px above the fields; 32 px of air between groups, no dividers. `Fieldset disabled` turns all the fields off while the request runs.
@@ -187,9 +187,20 @@ of your own elements — when to move at all, the tokens, the rules and recipes
187
187
 
188
188
  ## 11. State changes are continuous
189
189
 
190
- Nothing on screen flips from one state to another. Kit controls already move into their new state.
191
- Every region you build that changes what it shows — loading → data → empty → error, one record →
192
- another — renders its content inside `<Crossfade state={status}>`: the old state fades out, the new
193
- one fades in, the height glides. The loading state is a `<Skeleton>` in the data's geometry (same
194
- rows, gaps and line heights), so the swap moves nothing; a `Spinner` only where there is no shape to
195
- hold. Data already on screen stays during a refresh (`aria-busy`), it does not go back to a skeleton.
190
+ Nothing on screen flips from one state to another, and nothing that loads unfolds. Kit controls
191
+ already move into their new state. Every region you build that changes what it shows — loading →
192
+ data → empty → error, one record → another — renders its content inside `<Crossfade state={status}>`.
193
+
194
+ - **Loading is a skeleton from the first paint.** While data loads the region shows the state
195
+ `"loading"` (that exact key): a `<Skeleton>` in the data's geometry (same rows, gaps and line
196
+ heights) rendered on the first render — never `null`, never an empty box that grows when the data
197
+ comes. A `Spinner` only where there is no shape to hold.
198
+ - **The data replaces the skeleton in place.** Leaving `"loading"` (or entering it), Crossfade swaps
199
+ with a short fade and the height settles in one frame — no glide, no reveal, no slide-in. A
200
+ skeleton in the wrong geometry therefore shows as a jump: match the data.
201
+ - **Only settled states glide.** data ↔ empty ↔ error, another record, a period or a section: the old
202
+ state fades out, the new one fades in, the height glides.
203
+ - **A region that mounts in its state is still.** Content already loaded (a cached tab, a page
204
+ opened again) renders as it is, with no fade.
205
+
206
+ Data already on screen stays during a refresh (`aria-busy`), it does not go back to a skeleton.
@@ -187,39 +187,89 @@ form rules: `Modal.Body` holds the `<form id>`, `Modal.Footer` holds Cancel + su
187
187
 
188
188
  ## Auth (sign in, code)
189
189
 
190
- No AppShell. `LoginForm` is the whole card: logo, title, provider buttons, the form and the footer link;
191
- center it on the canvas. Code step: `DigitInput` inside `LoginForm.Form`. Scenarios for sign-up, password
192
- reset and code confirmation are in `src/components/login-form/examples/`.
190
+ **One auth screen, in modes.** Sign-in, sign-up, an invite (set a password by a link), a password
191
+ reset and a one-time code are one public route — `/login`, the mode in its query (`?invite=…`,
192
+ `?reset=…`, `?step=code`) — and one `LoginForm` card whose header and form change with the mode.
193
+ A new link-driven step is a new mode of that screen, never a page of its own: it would get its own
194
+ canvas, its own copy of the card and drift from the others. No AppShell; `DigitInput` inside
195
+ `LoginForm.Form` for a code; the scenarios are in `src/components/login-form/examples/`.
196
+
197
+ **The head names the action.** `LoginForm.Root` keeps its default `align="start"`: `LoginForm.Logo`
198
+ holds the icon of the action (`action.login` for «Войти в аккаунт»; a key for «Задайте пароль»),
199
+ `LoginForm.Title` names the action, `LoginForm.Description` is one line of what or whom it concerns
200
+ («Приглашение в «Северный ветер» для a.sokolova@…»). The product mark centred over the title
201
+ (`align="center"`) belongs to a public brand page, not to the product's own door. Another state of
202
+ the screen (a link that expired, the check of a token) swaps inside the canvas around the card
203
+ through a `Crossfade` without `fill`, its loading branch a `Skeleton` of the card's geometry.
204
+
205
+ ```tsx partial
206
+ // bad — a page of its own for the invite, a centred brand mark over a generic title
207
+ // /invite/:token
208
+ <LoginForm.Root align="center">
209
+ <LoginForm.Header>
210
+ <LoginForm.Logo aria-label="CRM">…</LoginForm.Logo>
211
+ <LoginForm.Title>Добро пожаловать</LoginForm.Title>
212
+ </LoginForm.Header>
213
+ …
214
+ </LoginForm.Root>
215
+ ```
193
216
 
194
217
  ```tsx
195
218
  import { Button, Icon, Input, LinkButton, LoginForm } from "prime-ui-kit";
196
219
  import styles from "./SignIn.module.css";
197
220
 
198
- export function SignIn() {
221
+ /** The mode comes from the query of `/login`: `?invite=…` sets a password, nothing signs in. */
222
+ type AuthMode = "login" | "invite";
223
+
224
+ const HEAD = {
225
+ login: { icon: "action.login", title: "Войти в аккаунт", description: "Рабочая почта и пароль" },
226
+ invite: { icon: "object.key", title: "Задайте пароль", description: "Приглашение в «Северный ветер»" },
227
+ } as const;
228
+
229
+ export function SignIn({ mode }: { mode: AuthMode }) {
230
+ const head = HEAD[mode];
199
231
  return (
200
232
  <main className={styles.page}>
201
- <LoginForm.Root align="center">
233
+ <LoginForm.Root>
202
234
  <LoginForm.Header>
203
235
  <LoginForm.Logo>
204
- <Icon name="object.package" />
236
+ <Icon name={head.icon} />
205
237
  </LoginForm.Logo>
206
- <LoginForm.Title>Вход</LoginForm.Title>
207
- <LoginForm.Description>Отправим код на рабочую почту</LoginForm.Description>
238
+ <LoginForm.Title>{head.title}</LoginForm.Title>
239
+ <LoginForm.Description>{head.description}</LoginForm.Description>
208
240
  </LoginForm.Header>
209
241
  <LoginForm.Body>
210
242
  <LoginForm.Form onSubmit={(event) => event.preventDefault()}>
211
- <Input.Root label="Рабочая почта" required>
243
+ {mode === "login" ? (
244
+ <Input.Root label="Рабочая почта" required>
245
+ <Input.Wrapper>
246
+ <Input.Field type="email" autoComplete="email" placeholder="name@company.ru" />
247
+ </Input.Wrapper>
248
+ </Input.Root>
249
+ ) : null}
250
+ <Input.Root
251
+ label={mode === "login" ? "Пароль" : "Новый пароль"}
252
+ required
253
+ strength={mode === "invite"}
254
+ minStrength={mode === "invite" ? 3 : undefined}
255
+ >
212
256
  <Input.Wrapper>
213
- <Input.Field type="email" autoComplete="email" placeholder="name@company.ru" />
257
+ <Input.Field
258
+ type="password"
259
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
260
+ />
261
+ <Input.RevealButton />
214
262
  </Input.Wrapper>
215
263
  </Input.Root>
216
264
  <Button.Root type="submit" fullWidth>
217
- Получить код
265
+ {mode === "login" ? "Войти" : "Сохранить пароль"}
218
266
  </Button.Root>
219
267
  </LoginForm.Form>
220
- <LoginForm.Footer>
221
- Нет аккаунта? <LinkButton href="/signup">Зарегистрироваться</LinkButton>
222
- </LoginForm.Footer>
268
+ {mode === "login" ? (
269
+ <LoginForm.Footer>
270
+ Нет аккаунта? <LinkButton href="/login?mode=signup">Зарегистрироваться</LinkButton>
271
+ </LoginForm.Footer>
272
+ ) : null}
223
273
  </LoginForm.Body>
224
274
  </LoginForm.Root>
225
275
  </main>
@@ -35,7 +35,8 @@ Use the component and the motion comes with it. Never re-animate these with your
35
35
  | Overlays | grow from the trigger (menus, popovers, tooltip from its arrow), modals stay centred, drawers, sheets and toasts come from their edge; a dialog, drawer or sheet leaves faster than it came, a small floating panel opens and closes on the same quick `fast` clock; the last content stays on screen while the exit plays | `Dropdown`, `Popover`, `Tooltip`, `Modal`, `ConfirmDialog`, `Drawer`, `Sheet`, `MediaViewer`, notifications — the `Root` kept mounted |
36
36
  | Tooltips along a toolbar | the first waits, the next ones show at once | `Tooltip` (shared warm window) |
37
37
  | Toast stack | depth stack, expands on hover | `NotificationProvider` + `useNotifications` |
38
- | Loading → data → empty → error | the region cross-fades and glides its height; loading is a skeleton of the data | `Crossfade` around the region, `Skeleton` in the data's geometry |
38
+ | Loading → data | the skeleton is there from the first paint and the data takes its place with a short fade; the height never glides | `Crossfade` with the state `"loading"`, `Skeleton` in the data's geometry |
39
+ | Data → empty → error, another record | the region cross-fades and glides its height | `Crossfade` around the region |
39
40
  | Drag | the carried piece lifts, its neighbours glide apart, the drop glides into place; while its save runs the piece stays still and its grip turns busy, and a refused save glides it back — across columns too | `Dnd`, `Kanban` cards and columns, DataTable and `List` `onReorder`, `FileUpload` tiles |
40
41
  | A setting saved on the server | the thumb moves at once and a spinner turns inside it (a slider thumb dims); a refused save glides it back | `Switch` (`onCheckedChange` returns a Promise, or `loading`), `Slider` (`onCommit`) |
41
42
  | A chip or a file row removed | it waits dimmed with a spinner on its ×; once gone it fades and closes up, the next ones glide into its place; refused, it stays | `Badge` `onRemove`, `FileUpload` `onValueChange`, TagSelect chips |
@@ -142,8 +143,10 @@ export function DeleteDraft({ onDelete }: { onDelete: () => void }) {
142
143
  }
143
144
  ```
144
145
 
145
- **A region that loads.** Wrap it in `Crossfade` keyed by the state and show a `Skeleton` in the shape
146
- of the data — never a spinner in the middle of an empty card, never a flip.
146
+ **A region that loads.** Wrap it in `Crossfade` keyed by the state, the loading one keyed
147
+ `"loading"`, and show a `Skeleton` in the shape of the data from the first render — never a spinner
148
+ in the middle of an empty card, never a flip, never a region that unfolds as the data comes
149
+ ([the rule](foundations.md#11-state-changes-are-continuous)).
147
150
 
148
151
  **A metric that updates.** Put a number in a `Badge` or a `Sparkline` and change it: the digits roll.
149
152
  A number that changes by itself every frame (a running percentage) stays plain tabular text — rolling
@@ -188,6 +191,9 @@ export function CloseQuarter({ onClose }: { onClose: () => void }) {
188
191
  - `transition: all 0.3s ease` → list the properties, use tokens.
189
192
  - A spinner where the content's shape is known → `Skeleton` in that shape inside `Crossfade`.
190
193
  - Content that appears or disappears with no transition (`{loading ? <A/> : <B/>}`) → `Crossfade`.
194
+ - A loading state that renders nothing, or a list that slides or unfolds in when its data arrives →
195
+ a `Skeleton` in the data's geometry from the first render, under the state `"loading"`, so the data
196
+ replaces it in place.
191
197
  - Bounce, elastic or a spring that overshoots → `emphasized` lands without passing the target.
192
198
  - Animating the highlight of a list moved by arrow keys, or a command palette → keep it still.
193
199
  - Hover lift on table rows or list items → fill change only.
@@ -121,6 +121,7 @@ The soft fill is opaque: the soft hue is composited over the ground the avatar s
121
121
  - Avatar → name: space step 3 (12px) in lists and cards, step 2 (8px) in table cells.
122
122
  - Group: members overlap by an eighth of the diameter (initials are never cut) with a 2px ring in the ground they stand on: the current layer of the surface around them (no surface sets anything), or `--avatar-ring` from a host whose ground is not a layer (a reaction chip sets its own fill); the fills of initials and «+N» are composited over the same ground.
123
123
  - An icon fallback is half the diameter: a kit `Icon` reads `--prime-icon-size`, a raw svg too.
124
+ - In a leading media slot (`Drawer.Icon`, `Modal.Icon`, `Sheet.Icon`, `Card.Icon`) the avatar takes the slot's size (`l`, 40px) whatever `size` it was given, also through a wrapper component of yours; the slot then draws no tile.
124
125
 
125
126
  ## Accessibility
126
127