prime-ui-kit 0.20.1 → 0.21.1

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 (154) 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-4374EBWV.js → chunk-S4H2ZKWJ.js} +1049 -644
  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/page/Page.d.ts +1 -1
  34. package/dist/layout/sidebar/Sidebar.d.ts +3 -2
  35. package/dist/skill/anti-slop.md +35 -37
  36. package/dist/skill/api-contract.md +50 -2
  37. package/dist/skill/cheatsheet.md +15 -10
  38. package/dist/skill/checklist.md +20 -6
  39. package/dist/skill/choosing.md +82 -8
  40. package/dist/skill/components.md +1 -1
  41. package/dist/skill/composition.md +268 -51
  42. package/dist/skill/foundations.md +17 -6
  43. package/dist/skill/layouts.md +65 -14
  44. package/dist/skill/motion.md +9 -3
  45. package/dist/skill/reference/components/avatar/COMPONENT.md +1 -0
  46. package/dist/skill/reference/components/badge/COMPONENT.md +29 -5
  47. package/dist/skill/reference/components/badge/examples/examples.module.css +14 -0
  48. package/dist/skill/reference/components/badge/examples/overflow-count.tsx +32 -0
  49. package/dist/skill/reference/components/banner/COMPONENT.md +1 -1
  50. package/dist/skill/reference/components/card/COMPONENT.md +8 -4
  51. package/dist/skill/reference/components/card/examples/person-header.tsx +38 -0
  52. package/dist/skill/reference/components/composer/COMPONENT.md +35 -5
  53. package/dist/skill/reference/components/composer/examples/bot-keyboard.tsx +43 -0
  54. package/dist/skill/reference/components/confirm-dialog/COMPONENT.md +1 -1
  55. package/dist/skill/reference/components/crossfade/COMPONENT.md +18 -10
  56. package/dist/skill/reference/components/data-table/COMPONENT.md +17 -11
  57. package/dist/skill/reference/components/data-table/examples/screen.tsx +6 -9
  58. package/dist/skill/reference/components/data-table/examples/selection.tsx +60 -18
  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 +4 -4
  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 +10 -6
  93. package/dist/skill/reference/layout/page/examples/router.tsx +124 -0
  94. package/dist/skill/reference/layout/page/examples/tools.tsx +12 -22
  95. package/dist/skill/reference/layout/sidebar/COMPONENT.md +8 -3
  96. package/dist/skill/reference/layout/sidebar/examples/account-profile.tsx +66 -0
  97. package/dist/skill/responsive.md +28 -6
  98. package/dist/skill/ux.md +97 -2
  99. package/dist/tokens/semantic.d.ts +2 -1
  100. package/dist/tokens/themes/dark.d.ts +1 -0
  101. package/package.json +1 -1
  102. package/src/components/avatar/COMPONENT.md +1 -0
  103. package/src/components/badge/COMPONENT.md +29 -5
  104. package/src/components/badge/examples/examples.module.css +14 -0
  105. package/src/components/badge/examples/overflow-count.tsx +32 -0
  106. package/src/components/banner/COMPONENT.md +1 -1
  107. package/src/components/card/COMPONENT.md +8 -4
  108. package/src/components/card/examples/person-header.tsx +38 -0
  109. package/src/components/composer/COMPONENT.md +35 -5
  110. package/src/components/composer/examples/bot-keyboard.tsx +43 -0
  111. package/src/components/confirm-dialog/COMPONENT.md +1 -1
  112. package/src/components/crossfade/COMPONENT.md +18 -10
  113. package/src/components/data-table/COMPONENT.md +17 -11
  114. package/src/components/data-table/examples/screen.tsx +6 -9
  115. package/src/components/data-table/examples/selection.tsx +60 -18
  116. package/src/components/drawer/COMPONENT.md +41 -19
  117. package/src/components/drawer/examples/footer-actions.tsx +54 -0
  118. package/src/components/drawer/examples/header-actions.tsx +46 -25
  119. package/src/components/drawer/examples/link-loading.tsx +57 -0
  120. package/src/components/drawer/examples/screen.tsx +3 -5
  121. package/src/components/empty-page/COMPONENT.md +2 -2
  122. package/src/components/field-list/COMPONENT.md +4 -4
  123. package/src/components/field-list/examples/row-menu.tsx +1 -1
  124. package/src/components/hint/COMPONENT.md +1 -1
  125. package/src/components/input/COMPONENT.md +16 -3
  126. package/src/components/input/examples/password-policy.tsx +65 -0
  127. package/src/components/list/COMPONENT.md +1 -1
  128. package/src/components/login-form/COMPONENT.md +13 -11
  129. package/src/components/login-form/examples/overview.tsx +5 -5
  130. package/src/components/login-form/examples/states.tsx +7 -6
  131. package/src/components/message/COMPONENT.md +5 -5
  132. package/src/components/message/examples/bot-actions.tsx +47 -28
  133. package/src/components/modal/COMPONENT.md +31 -12
  134. package/src/components/modal/examples/structure.tsx +2 -4
  135. package/src/components/segmented-control/COMPONENT.md +11 -6
  136. package/src/components/segmented-control/examples/router.tsx +22 -0
  137. package/src/components/select/COMPONENT.md +4 -1
  138. package/src/components/sheet/COMPONENT.md +31 -12
  139. package/src/components/sheet/examples/header-actions.tsx +5 -3
  140. package/src/components/skeleton/COMPONENT.md +3 -2
  141. package/src/components/smart-filter/COMPONENT.md +2 -1
  142. package/src/components/smart-filter/examples/ranges.tsx +8 -2
  143. package/src/components/spinner/COMPONENT.md +1 -1
  144. package/src/components/tabs/COMPONENT.md +7 -4
  145. package/src/components/tabs/examples/in-drawer.tsx +18 -2
  146. package/src/components/thumbnail/COMPONENT.md +1 -0
  147. package/src/components/tooltip/COMPONENT.md +3 -3
  148. package/src/layout/page/COMPONENT.md +10 -6
  149. package/src/layout/page/examples/router.tsx +124 -0
  150. package/src/layout/page/examples/tools.tsx +12 -22
  151. package/src/layout/sidebar/COMPONENT.md +8 -3
  152. package/src/layout/sidebar/examples/account-profile.tsx +66 -0
  153. package/src/styles/theme-dark.css +1 -0
  154. 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,11 @@ 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; of route links for
103
+ [a section with sub-pages](#a-section-with-sub-pages)), the view, a period that drives several blocks; a
104
+ search and filter (`SmartFilter.Toolbar`) only when it governs more than one table — a board and
105
+ its list, a media grid ([where the filters go](#where-the-filters-go)). Named with `aria-label`;
106
+ no buttons and no wrappers inside.
106
107
  - **`Page.Actions`** — the one place for the page's buttons: secondary ones (`soft` + `neutral`)
107
108
  first, the primary (`solid`) last. Every Button carries a `Button.Icon` beside its label: the bar
108
109
  shows it as the icon alone when the line runs short.
@@ -180,6 +181,62 @@ export function DealsPage({ fields, filter, onFilterChange, view, onViewChange,
180
181
  }
181
182
  ```
182
183
 
184
+ ### A section with sub-pages
185
+
186
+ One place of the app, several facets of it: «Мессенджеры» with «Каналы» and «Статистика», a client
187
+ with «Сделки» and «Документы». Each facet is a route of its own (`/messengers/channels`,
188
+ `/messengers/stats`), and the section stays one page:
189
+
190
+ - **The title is the section.** `Page.Title` reads «Мессенджеры» on every sub-route; the facet is
191
+ the current link of the switch, never a second title or a title that names the facet.
192
+ - **The switch is links in `Page.Tools`**: a `SegmentedControl` whose items are router links
193
+ (`SegmentedControl.Item asChild`), `value` = the current path. It is a navigation (Tab moves
194
+ through the links, the current one is `aria-current="page"`), on a phone it takes a line of its
195
+ own under the title by itself.
196
+ - **The body is the sub-page**, swapped in a `Crossfade` keyed by the path (`Routes` given the
197
+ `location`, so the leaving sub-page stays on its route while it fades).
198
+ - **The Sidebar stays on the section.** Its item is current on every sub-route: a `NavLink` to
199
+ `/messengers` without `end`, or `current={pathname.startsWith("/messengers")}`.
200
+ - Never a second Sidebar level for facets, never a `Tabs` strip inside `Page.Body` for sub-pages.
201
+ `Sidebar.Sub` is for views of one set of objects people open straight from the rail («Сделки» →
202
+ «Новые», «В работе»); `Tabs` switch panels that are not routes.
203
+
204
+ Live: [router.tsx](reference/layout/page/examples/router.tsx).
205
+
206
+ ```tsx
207
+ import { Page, SegmentedControl } from "prime-ui-kit";
208
+ import type { ReactNode } from "react";
209
+ import { Link, useLocation } from "react-router-dom";
210
+
211
+ const MESSENGER_PAGES = [
212
+ { to: "/messengers/channels", label: "Каналы" },
213
+ { to: "/messengers/stats", label: "Статистика" },
214
+ ];
215
+
216
+ export function MessengersPage({ children }: { children: ReactNode }) {
217
+ const { pathname } = useLocation();
218
+ return (
219
+ <Page.Root>
220
+ <Page.TopBar>
221
+ <Page.Heading>
222
+ <Page.Title>Мессенджеры</Page.Title>
223
+ </Page.Heading>
224
+ <Page.Tools aria-label="Мессенджеры">
225
+ <SegmentedControl.Root value={pathname} aria-label="Подразделы">
226
+ {MESSENGER_PAGES.map((page) => (
227
+ <SegmentedControl.Item key={page.to} value={page.to} asChild>
228
+ <Link to={page.to}>{page.label}</Link>
229
+ </SegmentedControl.Item>
230
+ ))}
231
+ </SegmentedControl.Root>
232
+ </Page.Tools>
233
+ </Page.TopBar>
234
+ <Page.Body>{children}</Page.Body>
235
+ </Page.Root>
236
+ );
237
+ }
238
+ ```
239
+
183
240
  ## 2. Rhythm — the 4px grid
184
241
 
185
242
  Every gap is a `--prime-space-N` token (N × 4px) set as `gap` on the parent. Closer means related.
@@ -287,20 +344,63 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
287
344
 
288
345
  - **One primary per area** (the page, card, dialog, form): `solid`, last in its row. Secondary:
289
346
  `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.
347
+ `neutral` in Modal / Drawer footers.
293
348
  - 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.
349
+ `Modal.Footer` / `Drawer.Footer`; row actions in the last column; actions on the selected rows of a
350
+ table in its selection menu ([controls of a table](#controls-of-a-table)).
295
351
  - 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`.
352
+ edits is not a third footer button: it stands in `Modal.Actions` / `Drawer.Actions` of the header
353
+ by the menu threshold below, confirmed by a `ConfirmDialog`.
298
354
  - An empty state's action replaces the header's primary, it does not duplicate it.
299
355
  - Destructive: `tone="danger"`; a standalone trigger is `outline`, in a menu it is the last item, the
300
356
  confirm is a `ConfirmDialog` (its `onConfirm` returns the request's Promise).
301
357
  - Going somewhere is a link (`LinkButton`, `Button.Root asChild` + `<a>`), doing something is a Button.
302
358
  One action — one label on the whole screen.
303
359
 
360
+ ### A menu from two actions
361
+
362
+ An action that is alone stays in sight; a «⋯» only gathers. Count the secondary actions of one
363
+ place — a page or dialog header, a table row, a card, a list row:
364
+
365
+ | Actions | Shape |
366
+ |---|---|
367
+ | 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 |
368
+ | 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"` |
369
+
370
+ A ⋯ with one item hides the only action behind a second press and a guess. Nothing else decides:
371
+ not the density of the row, not the number of its columns, not the room left for later actions. A
372
+ row of a `FieldList` follows the same count ([forms](#7-forms)).
373
+
374
+ ```tsx partial
375
+ // bad — a menu for the one thing this header can do
376
+ <Drawer.Actions>
377
+ <Dropdown.Root>
378
+ <Dropdown.Trigger>
379
+ <Button.Root variant="ghost" tone="neutral" aria-label="Другие действия">…</Button.Root>
380
+ </Dropdown.Trigger>
381
+ <Dropdown.Content>
382
+ <Dropdown.Item tone="danger">Удалить приглашение</Dropdown.Item>
383
+ </Dropdown.Content>
384
+ </Dropdown.Root>
385
+ </Drawer.Actions>
386
+ ```
387
+
388
+ ```tsx good
389
+ // good — one action in sight, named by its tooltip
390
+ <Drawer.Actions>
391
+ <Tooltip.Root>
392
+ <Tooltip.Trigger>
393
+ <Button.Root variant="ghost" tone="danger" aria-label="Удалить приглашение">
394
+ <Button.Icon>
395
+ <Icon name="action.delete" />
396
+ </Button.Icon>
397
+ </Button.Root>
398
+ </Tooltip.Trigger>
399
+ <Tooltip.Content>Удалить приглашение</Tooltip.Content>
400
+ </Tooltip.Root>
401
+ </Drawer.Actions>
402
+ ```
403
+
304
404
  ## 7. Forms
305
405
 
306
406
  - Labels above every field (`label`), placeholder is an example value, help in `hint`, problems in
@@ -310,10 +410,10 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
310
410
  columns. Never a label in every row, never numbered labels, never one record split over lines.
311
411
  FieldList draws every row's actions itself — the remove «×» and the handle after it, a pinned
312
412
  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.
413
+ - The row's actions (`rowActions`) follow [the menu threshold](#a-menu-from-two-actions): a row
414
+ that only removes shows its «×» (the default `"buttons"`, and the grip after it with
415
+ `reorderable`), however many columns it has; `"menu"` — one «⋯» with «Выше», «Ниже», «Удалить» —
416
+ only for a `reorderable` list on a touch-first screen, where moving by the menu replaces a drag.
317
417
  - The look (`variant`): `"plain"` (default) when the list is the content of its card or section;
318
418
  `"surface"` when the list stands among other fields of a form or card, so its rows read as one
319
419
  block apart from the fields around it.
@@ -334,40 +434,46 @@ Hierarchy comes from space and a handful of type roles, not from size jumps, col
334
434
  section, the whole `Card.Root` inside `<form>`, buttons in `Card.Footer`.
335
435
  - Submit: `noValidate`, validate all, set `error`s, focus the first invalid field, `loading` on the
336
436
  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`.
437
+ result: the save button stays on screen → it says «Сохранено»; the button goes away (the dialog
438
+ closes, the page navigates, the button shows only while dirty) → a `Notification`; a failure → a `Notification` or the field's `error`.
339
439
  - 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)).
440
+ - Choice controls are picked by what the value means, not by when it saves:
441
+ [choosing.md](choosing.md#a-flag-a-level-a-set).
343
442
 
344
443
  ## 8. Tables and lists
345
444
 
346
445
  ### Where the filters go
347
446
 
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.
447
+ A control stands with what it changes. Ask of each one: what does it narrow or switch?
448
+
449
+ - **One table** — its search and filter fields, their chips, its column chooser and the actions on
450
+ its selected rows belong to the `DataTable`, on every page: the page that is only this table, a
451
+ page with KPI cards above it, a page whose sections switch between tables. `SmartFilter.Root`
452
+ wraps the `DataTable`; `SmartFilter.Toolbar` goes in its `toolbar` (with a `DataTableColumnsMenu`
453
+ when the table has no row-actions column to carry `columnsMenu`), `SmartFilter.Chips` in its
454
+ `toolbarChips`, the selection menu comes from `renderSelectionActions`
455
+ ([server.tsx](reference/components/data-table/examples/server.tsx), [the list page](#list-page)).
456
+ The tools then stay put when the page grows a block or another section, and every table of the
457
+ app reads the same.
458
+ - **The whole page** — `Page.TopBar`: the heading with its line of data, the page's sections (a
459
+ `SegmentedControl`; of links, `SegmentedControl.Item asChild`, when each section is a route of its
460
+ own — «Сотрудники» / «Роли»), a view switch, a period that drives several blocks, the page's
461
+ buttons.
462
+ - **Several views of one set** — a search and filter that narrows a board and its list alike, a
463
+ media grid, a schedule without a tools row of its own: `SmartFilter.Root` wraps `Page.Root`,
464
+ `SmartFilter.Toolbar` in `Page.Tools`, `SmartFilter.Chips` in `Page.Chips`
465
+ ([the board](#sales-pipeline-board)).
466
+ - **A table section filters by its categories.** Every column with a closed set of values (a role, a
467
+ status, a type, an owner, a city) is an `options` field of the table's `SmartFilter`, a date
468
+ column a `date-range` field (its `calendar` with the presets of the domain), free text the
469
+ search. A search alone (`fields={[]}`) only for records with no such column. The value lives in
470
+ the URL ([nothing to guess](ux.md#6-nothing-to-guess)).
365
471
  - Both rows are one tool row of the kit: written in reading order, a search takes the free room,
366
472
  switchers and buttons keep their width at the end, and a short row wraps by itself — pass the
367
473
  controls as they are, never inside a flex wrapper of your own.
368
474
 
369
475
  ```tsx partial
370
- // bad — the table's search, filter, chips and chooser in the bar, above KPI cards they do not filter
476
+ // bad — the table's search, filter, chips and chooser in the bar, away from the rows they filter
371
477
  <SmartFilter.Root fields={FIELDS}>
372
478
  <Page.Root>
373
479
  <Page.TopBar>
@@ -463,12 +569,53 @@ A filter stands next to what it filters.
463
569
  chooser is `<DataTableColumnsMenu>` beside the table's search ([where the filters go](#where-the-filters-go)),
464
570
  bound to the same `hiddenColumns` / `columnOrder` state the table reads. Give the key column and
465
571
  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`.
572
+ - The DataTable `toolbar` holds the table's own controls — its search and filter, the chooser, a
573
+ table / cards view — as they are, in reading order; its chips go to `toolbarChips`. A search
574
+ heads the row; a button never does: a lone «Экспорт» or «Создать» is the page's
575
+ (`Page.Actions`), a lone chooser goes over the row actions, a count or a total of the list is data
576
+ in `Page.Description`.
577
+ - **Actions on selected rows are one menu, added to the row.** `renderSelectionActions` returns the
578
+ `Dropdown.Item`s; while rows are selected the table puts a «Действия · N» menu button last in its
579
+ tools row — the search, filter and chooser stay where they were, and the button leaves when the
580
+ selection empties. Never a row of «Выбрано: N» and buttons that replaces the tools, never one
581
+ button per bulk action in the row. A destructive item asks with a `ConfirmDialog` that names the
582
+ count ([friction](ux.md#5-friction-matches-the-cost-of-a-mistake)).
583
+
584
+ ```tsx partial
585
+ // bad — the selection swaps the tools row for a bar of buttons; search and filter vanish
586
+ <DataTable
587
+ selectable
588
+ selected={selected}
589
+ onSelectedChange={setSelected}
590
+ toolbar={selected.length > 0 ? <SelectionBar count={selected.length} /> : <SmartFilter.Toolbar />}
591
+ />
592
+ ```
593
+
594
+ ```tsx good
595
+ // good — the tools stay, the actions on the selection are one menu that enters last
596
+ // declare: const FIELDS: Parameters<typeof SmartFilter.Root>[0]["fields"]
597
+ // declare: const members: { id: string; name: string }[]
598
+ // declare: const selected: string[]
599
+ // declare: const setSelected: (keys: string[]) => void
600
+ <SmartFilter.Root fields={FIELDS}>
601
+ <DataTable
602
+ columns={[{ id: "name", header: "Сотрудник", accessor: "name" }]}
603
+ rows={members}
604
+ getRowKey={(row) => row.id}
605
+ selectable
606
+ selected={selected}
607
+ onSelectedChange={(keys) => setSelected(keys.map(String))}
608
+ toolbar={<SmartFilter.Toolbar />}
609
+ toolbarChips={<SmartFilter.Chips />}
610
+ renderSelectionActions={() => (
611
+ <>
612
+ <Dropdown.Item>Сменить роль</Dropdown.Item>
613
+ <Dropdown.Item tone="danger">Отключить доступ</Dropdown.Item>
614
+ </>
615
+ )}
616
+ />
617
+ </SmartFilter.Root>
618
+ ```
472
619
 
473
620
  ### Tables
474
621
 
@@ -500,11 +647,11 @@ A filter stands next to what it filters.
500
647
 
501
648
  | Situation | Component |
502
649
  |---|---|
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: «Сохранено» |
650
+ | the result of an action (sent, created, failed to save; saved when the form or its save button goes away) | `Notification` via `notify()` |
651
+ | a form saved whose save button stays on screen | the submit button's text: «Сохранено» |
505
652
  | 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
653
  | 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)) |
654
+ | 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
655
  | a region failed to load | the region shows it in place: DataTable `loadError`, or `EmptyPage tone="danger"` with `EmptyPage.RetryButton` |
509
656
  | 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
657
  | 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 +688,76 @@ are `Drawer.Section` / `Modal.Section` with their own `Drawer.Actions` / `Modal.
541
688
  hand-made heading row; a footer's `Drawer.Meta` / `Modal.Meta` goes above the buttons by itself when
542
689
  the footer runs short.
543
690
 
691
+ ### A record in a drawer
692
+
693
+ A record opened beside the page — a person, a role, a deal — is one drawer per entity, the same
694
+ wherever it opens:
695
+
696
+ - **One entity, one drawer.** One component opens the record from every place — the person's own
697
+ account in the Sidebar, a row of the team table, a mention — by its id. Who looks decides what it
698
+ shows: a field the viewer may not change is `readOnly` (`disabled` when the reason is said beside
699
+ it), a tab the viewer may not see is not rendered, an action the viewer may not take is hidden. A
700
+ second drawer for the same entity («Мой аккаунт» beside «Сотрудник») drifts apart from the first
701
+ with every change.
702
+ - **One record header** — the same in a Drawer, a Modal and a Sheet. `Drawer.Icon` leads: a
703
+ glyph gets a 40px tone tile, the record's `Avatar.Root` or `Thumbnail.Root` (even inside a wrapper
704
+ of your own) stands at 40 with no tile. Line 1 is `Drawer.Title`, the name (`loading` while it
705
+ comes). Line 2 is `Drawer.Description`: the status `Badge.Root`s first — the header sizes them, no
706
+ `size` — then the line of data. `Drawer.Actions` hold only buttons that act on this record: ghost
707
+ icon Buttons, each in a `Tooltip.Trigger`, counted by [the menu threshold](#a-menu-from-two-actions);
708
+ they are centred on line 1, before the ×. A Badge in `Drawer.Actions` is a status in the wrong place; a way to
709
+ another place (a section, a list) is no header action — a link in the body beside the data it leads
710
+ to, or nothing.
711
+ - **One form, one save.** Every editable field of every tab is one form, and its submit is the one
712
+ «Сохранить» of `Drawer.Footer`, shown while the form is dirty, with `Drawer.Guard` (`dirty`,
713
+ `busy`). No save per section or tab, no «Есть несохранённые изменения» in `Drawer.Meta`: the save
714
+ button and the guard already say it. Actions that work at once — turn sign-in on, send a link,
715
+ end the other sessions — are buttons beside what they change and never wait for the save. After
716
+ the save the button leaves, so the success is a `Notification`.
717
+ - **Tabs are topics.** Each tab holds one topic, and a record's history (its log) is always a tab of
718
+ its own. Tabs that outgrow the width never merge: the strip scrolls ([switchers](responsive.md#switchers)).
719
+ - **Data at the drawer's level.** The drawer loads the record and the data of its tabs; a tab never
720
+ fetches on mount, so coming back to it shows what it showed instead of a skeleton that collapses
721
+ it. A tab that keeps a draft or its scroll keeps its panel with `Tabs.Panel keepMounted`. The
722
+ opening itself — the header and the tabs at once, the body as a Skeleton in the form's geometry —
723
+ is in [Drawer](reference/components/drawer/COMPONENT.md).
724
+
725
+ ```tsx partial
726
+ // bad — a way out in the header, a save per section, a status line in the footer
727
+ <Drawer.Actions>
728
+ <Button.Root variant="ghost" tone="neutral">Команда и роли</Button.Root>
729
+ </Drawer.Actions>
730
+ <Drawer.Section label="Профиль">… <Button.Root>Сохранить</Button.Root></Drawer.Section>
731
+ <Drawer.Section label="Доступ">… <Button.Root>Сохранить</Button.Root></Drawer.Section>
732
+ <Drawer.Footer>
733
+ <Drawer.Meta>Есть несохранённые изменения</Drawer.Meta>
734
+ </Drawer.Footer>
735
+ ```
736
+
737
+ ```tsx good
738
+ // good — one form over the tabs, one save while it is dirty, the guard holds the rest
739
+ // declare: const dirty: boolean
740
+ // declare: const saving: boolean
741
+ <Drawer.Content>
742
+ <Drawer.Guard dirty={dirty} busy={saving} />
743
+ <Drawer.Header>
744
+ <Drawer.Title>Анна Соколова</Drawer.Title>
745
+ </Drawer.Header>
746
+ <Drawer.Body>
747
+ <form id="member-form" noValidate>
748
+ …
749
+ </form>
750
+ </Drawer.Body>
751
+ <Drawer.Footer>
752
+ {dirty ? (
753
+ <Button.Root type="submit" form="member-form" loading={saving}>
754
+ Сохранить
755
+ </Button.Root>
756
+ ) : null}
757
+ </Drawer.Footer>
758
+ </Drawer.Content>
759
+ ```
760
+
544
761
  ## 11. Narrow screens
545
762
 
546
763
  - Layouts are [intrinsic](responsive.md#intrinsic-grids) — no breakpoints needed, and they work in
@@ -630,7 +847,7 @@ What each screen in [the index](#screens) demonstrates — keep these when you s
630
847
  [data-table/examples/screen.tsx](reference/components/data-table/examples/screen.tsx)
631
848
 
632
849
  - 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`.
850
+ - 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
851
  - 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
852
  - 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
853
  - 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 +881,7 @@ What each screen in [the index](#screens) demonstrates — keep these when you s
664
881
 
665
882
  [drawer/examples/screen.tsx](reference/components/drawer/examples/screen.tsx)
666
883
 
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.
884
+ - 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
885
  - The opening button is the page's primary action in `Page.Actions`; `Drawer.Trigger` wraps it as a slot.
669
886
  - 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
887
  - 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.
@@ -142,6 +142,7 @@ when a count gets a colour — are in
142
142
  | Everyday places | `Sidebar.Group` without `label` at the top, never folded |
143
143
  | Category of work | `Sidebar.Group label collapsible` |
144
144
  | Views of one section | `Sidebar.Sub`, one level only |
145
+ | Facets of one section (its list, its statistics) | none: route links in the page's `Page.Tools` ([a section with sub-pages](composition.md#a-section-with-sub-pages)) |
145
146
  | Meta | `Sidebar.Footer`: help, settings, then `Sidebar.Account` with its menu last |
146
147
 
147
148
  ### Sidebar icons
@@ -187,39 +188,89 @@ form rules: `Modal.Body` holds the `<form id>`, `Modal.Footer` holds Cancel + su
187
188
 
188
189
  ## Auth (sign in, code)
189
190
 
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/`.
191
+ **One auth screen, in modes.** Sign-in, sign-up, an invite (set a password by a link), a password
192
+ reset and a one-time code are one public route — `/login`, the mode in its query (`?invite=…`,
193
+ `?reset=…`, `?step=code`) — and one `LoginForm` card whose header and form change with the mode.
194
+ A new link-driven step is a new mode of that screen, never a page of its own: it would get its own
195
+ canvas, its own copy of the card and drift from the others. No AppShell; `DigitInput` inside
196
+ `LoginForm.Form` for a code; the scenarios are in `src/components/login-form/examples/`.
197
+
198
+ **The head names the action.** `LoginForm.Root` keeps its default `align="start"`: `LoginForm.Logo`
199
+ holds the icon of the action (`action.login` for «Войти в аккаунт»; a key for «Задайте пароль»),
200
+ `LoginForm.Title` names the action, `LoginForm.Description` is one line of what or whom it concerns
201
+ («Приглашение в «Северный ветер» для a.sokolova@…»). The product mark centred over the title
202
+ (`align="center"`) belongs to a public brand page, not to the product's own door. Another state of
203
+ the screen (a link that expired, the check of a token) swaps inside the canvas around the card
204
+ through a `Crossfade` without `fill`, its loading branch a `Skeleton` of the card's geometry.
205
+
206
+ ```tsx partial
207
+ // bad — a page of its own for the invite, a centred brand mark over a generic title
208
+ // /invite/:token
209
+ <LoginForm.Root align="center">
210
+ <LoginForm.Header>
211
+ <LoginForm.Logo aria-label="CRM">…</LoginForm.Logo>
212
+ <LoginForm.Title>Добро пожаловать</LoginForm.Title>
213
+ </LoginForm.Header>
214
+ …
215
+ </LoginForm.Root>
216
+ ```
193
217
 
194
218
  ```tsx
195
219
  import { Button, Icon, Input, LinkButton, LoginForm } from "prime-ui-kit";
196
220
  import styles from "./SignIn.module.css";
197
221
 
198
- export function SignIn() {
222
+ /** The mode comes from the query of `/login`: `?invite=…` sets a password, nothing signs in. */
223
+ type AuthMode = "login" | "invite";
224
+
225
+ const HEAD = {
226
+ login: { icon: "action.login", title: "Войти в аккаунт", description: "Рабочая почта и пароль" },
227
+ invite: { icon: "object.key", title: "Задайте пароль", description: "Приглашение в «Северный ветер»" },
228
+ } as const;
229
+
230
+ export function SignIn({ mode }: { mode: AuthMode }) {
231
+ const head = HEAD[mode];
199
232
  return (
200
233
  <main className={styles.page}>
201
- <LoginForm.Root align="center">
234
+ <LoginForm.Root>
202
235
  <LoginForm.Header>
203
236
  <LoginForm.Logo>
204
- <Icon name="object.package" />
237
+ <Icon name={head.icon} />
205
238
  </LoginForm.Logo>
206
- <LoginForm.Title>Вход</LoginForm.Title>
207
- <LoginForm.Description>Отправим код на рабочую почту</LoginForm.Description>
239
+ <LoginForm.Title>{head.title}</LoginForm.Title>
240
+ <LoginForm.Description>{head.description}</LoginForm.Description>
208
241
  </LoginForm.Header>
209
242
  <LoginForm.Body>
210
243
  <LoginForm.Form onSubmit={(event) => event.preventDefault()}>
211
- <Input.Root label="Рабочая почта" required>
244
+ {mode === "login" ? (
245
+ <Input.Root label="Рабочая почта" required>
246
+ <Input.Wrapper>
247
+ <Input.Field type="email" autoComplete="email" placeholder="name@company.ru" />
248
+ </Input.Wrapper>
249
+ </Input.Root>
250
+ ) : null}
251
+ <Input.Root
252
+ label={mode === "login" ? "Пароль" : "Новый пароль"}
253
+ required
254
+ strength={mode === "invite"}
255
+ minStrength={mode === "invite" ? 3 : undefined}
256
+ >
212
257
  <Input.Wrapper>
213
- <Input.Field type="email" autoComplete="email" placeholder="name@company.ru" />
258
+ <Input.Field
259
+ type="password"
260
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
261
+ />
262
+ <Input.RevealButton />
214
263
  </Input.Wrapper>
215
264
  </Input.Root>
216
265
  <Button.Root type="submit" fullWidth>
217
- Получить код
266
+ {mode === "login" ? "Войти" : "Сохранить пароль"}
218
267
  </Button.Root>
219
268
  </LoginForm.Form>
220
- <LoginForm.Footer>
221
- Нет аккаунта? <LinkButton href="/signup">Зарегистрироваться</LinkButton>
222
- </LoginForm.Footer>
269
+ {mode === "login" ? (
270
+ <LoginForm.Footer>
271
+ Нет аккаунта? <LinkButton href="/login?mode=signup">Зарегистрироваться</LinkButton>
272
+ </LoginForm.Footer>
273
+ ) : null}
223
274
  </LoginForm.Body>
224
275
  </LoginForm.Root>
225
276
  </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