@wallarm-org/mcp 3.2.0 → 3.3.0-rc-fix-claude-code.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.
@@ -0,0 +1,1705 @@
1
+ {
2
+ "version": "4.7.0",
3
+ "rules": "Every numeric utility is in px, not 4px steps: p-16 is 16px, p-4 is 4px. Translate Tailwind habits (p-4 → p-16, gap-6 → gap-24, w-64 → w-256).\n\nBefore composing a screen, call get_guideline for the pattern (app shell, list page, table, drawer, form, dialog, filters, empty state, settings, auth). Wrong → right:\n\n- p-5, gap-3, p-10 → stay on the scale: 1, 2, 4, 6, 8, 12, 16, 20, 24, 28, 32, 40, 48, 64…\n- space-y-4, margins per child → <VStack gap={16}>, <HStack gap={8}>\n- Padded page wrapper → Page > PageHeader / PageToolbar / PageContent own the 24px gutter\n- p-* on Card, DrawerBody, DialogBody, PageToolbar, Table → none; they own their padding\n- className on Text, Heading, Field, Drawer/Dialog Header/Body/Footer → wrap in your own element\n- Raw <h1>, <p>, <label> → <Heading as=\"h2\" size=\"lg\">, <Text size=\"sm\">, Field\n- <Text> default (16px) for body → size=\"sm\" (md is only an alias of sm)\n- leading-none (0px), leading-5 (5px) → leading-sm or leading-20\n- weight=\"semibold\" → weight is light|regular|medium|bold (medium = 450)\n- bg-primary, text-muted, bg-card, bg-white, text-gray-500 → bg-bg-primary, text-text-secondary, bg-bg-surface-1\n- text-error, bg-red-500 → danger tokens; categorical color → Badge color\n- dark: variant, setting data-theme → semantic tokens, useTheme().setTheme()\n- rounded-lg, rounded, rounded-[15px] → rounded-8 (controls), rounded-12 (panels)\n- shadow-* on DS components, z-* on overlays → never; they own both\n- focus:ring → focus-visible:ring-3 ring-focus-primary outline-none\n- size, DIALOG_SIZES, DRAWER_SIZES → width: Dialog 400 confirm, 560 default, 960 picker; Drawer 640 default, 700 Timeline form\n- Dialog, useToast from the root → @wallarm-org/design-system/Dialog, /Toast\n- Field invalid → error on the control + FieldError\n- Hand-built side menu → RemoteShell config={NavConfig}\n- ConfirmDialog, FilterBar, Combobox → compose Dialog parts, PageToolbar + FilterDropdown, Select + useSelectSearch\n- data-slot in tests → data-testid\n",
4
+ "guidelines": [
5
+ {
6
+ "slug": "foundations/spacing",
7
+ "kind": "foundation",
8
+ "title": "Spacing",
9
+ "description": "Use when choosing a gap, padding or margin value, a Stack or Flex gap prop, or the inner spacing of a page, card, drawer or dialog.",
10
+ "components": [
11
+ "Stack",
12
+ "HStack",
13
+ "VStack",
14
+ "Flex",
15
+ "Page",
16
+ "PageHeader",
17
+ "PageToolbar",
18
+ "PageContent",
19
+ "PageTitle",
20
+ "PageActions",
21
+ "Card",
22
+ "CardHeader",
23
+ "CardTitle",
24
+ "CardContent",
25
+ "CardFooter",
26
+ "Drawer",
27
+ "DrawerHeader",
28
+ "DrawerBody",
29
+ "DrawerFooter",
30
+ "Dialog",
31
+ "DialogBody",
32
+ "DialogFooter"
33
+ ],
34
+ "keywords": [
35
+ "spacing",
36
+ "gap",
37
+ "padding",
38
+ "margin",
39
+ "space",
40
+ "px",
41
+ "scale",
42
+ "inset",
43
+ "gutter",
44
+ "Stack",
45
+ "HStack",
46
+ "VStack",
47
+ "Flex",
48
+ "layout",
49
+ "whitespace"
50
+ ],
51
+ "order": 10,
52
+ "markdown": "# Spacing\n\nEvery numeric spacing and sizing utility counts in pixels. `p-16` is 16px; `p-4` is 4px.\n\n## What you use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Children in a column | `<VStack gap={16}>` | `space-y-4`, `flex flex-col gap-4` |\n| Children in a row | `<HStack gap={8}>` | `space-x-2`, per-child margins |\n| A row or column with `basis`, `grow` or `shrink` | `<Flex gap={12} basis='1/2'>` | A raw `div` with `flex basis-1/2` |\n| Padding on your own element | `p-16`, `px-24`, `py-8` | `p-4` meaning 16px |\n| Page gutters | `PageHeader`, `PageToolbar`, `PageContent` | Your own `px-*` on the page wrapper |\n| A bordered tile | `Card` with `CardHeader`, `CardContent`, `CardFooter` | A `div` with `p-16 rounded-8 border` |\n| Side panel or modal body | `DrawerBody`, `DialogBody` | Padding classes inside header, body or footer |\n\n## Rules\n\n- Read every number in a spacing or sizing class (`p-*`, `m-*`, `gap-*`, `w-*`, `h-*`, `size-*`, `inset-*`, `top-*`) as pixels.\n- **Never** copy a Tailwind docs value as-is. Multiply it by 4: Tailwind `p-4` is `p-16` here, `mt-8` is `mt-32`, `h-8` is `h-32`.\n- Stay on the scale. If a value is off it, round to the nearest step. Off-scale classes such as `p-10` still compile; the rule stops you, not the build.\n- If children sit in a line, use `Stack`, `HStack` or `VStack` with `gap`. **Never** use per-child margins or `space-*`.\n- Pass `gap` as a scale-number prop, not a `gap-*` class. Off-scale numbers fail type-checking.\n- If you omit `gap` on `Stack`, `HStack` or `VStack`, you get 4px. If you omit it on `Flex`, you get none.\n- If you want no gap on `Stack`, pass `gap={0}`. `Flex` has no `gap={0}`; omit the prop.\n- If you need `className` on the flex container, use `Stack`. `Flex` does not accept `className`.\n- `PageHeader`, `PageToolbar` and `PageContent` own the 24px side gutter. **Never** wrap `Page` in a padded container.\n- `Card` owns its 16px padding. **Never** add `p-*` to `Card` or its parts.\n- Put drawer and dialog content straight into `DrawerBody` or `DialogBody`. Their 24px gutter is fixed.\n- **Never** pass `className` to `DrawerHeader`, `DrawerBody` or `DrawerFooter`. They accept only `children` and `ref`.\n- Import `Dialog` parts from `@wallarm-org/design-system/Dialog`. `Dialog` is not exported from the package root.\n- **Never** reach for a `--spacing-10` token, a `space-*` component prop, or a `padding` prop on `Stack`, `Flex`, `Card` or `Page`. None exist.\n\n## Examples\n\nA column of sections uses `VStack`, not per-child margins.\n\n```tsx\nimport { Text, VStack } from '@wallarm-org/design-system';\n\n<VStack gap={24}>\n <Text>Profile</Text>\n <Text>Security</Text>\n</VStack>;\n```\n\nPadding on your own element uses the pixel number, not the Tailwind docs number.\n\n```tsx\n<div className='px-24 py-12'>Content</div>; // not 'px-6 py-3'\n```\n\nA page puts cards straight into `PageContent`, with no padding of its own.\n\n```tsx\nimport {\n Card, CardContent, CardHeader, CardTitle, HStack,\n Page, PageContent, PageHeader, PageTitle,\n} from '@wallarm-org/design-system';\n\n<Page name='overview' fullSize fixedHeight>\n <PageHeader>\n <PageTitle>Overview</PageTitle>\n </PageHeader>\n <PageContent>\n <HStack gap={16} align='stretch'>\n <Card>\n <CardHeader><CardTitle>Requests</CardTitle></CardHeader>\n <CardContent>1,204</CardContent>\n </Card>\n </HStack>\n </PageContent>\n</Page>;\n```\n\n## Reference\n\n### Scale\n\nEach step is a `--spacing-N` token and a utility suffix (`p-N`, `gap-N`, `w-N`), N in pixels. Zero is `--spacing-none` (`p-0`).\n\n| Range | Steps (px) |\n| --- | --- |\n| Tight | 1, 2, 4, 6, 8, 12 |\n| Component | 16, 20, 24, 28, 32, 36, 40, 44, 48 |\n| Layout | 56, 64, 80, 96, 112, 128, 144 |\n\n### Layout components\n\n| Component | `gap` values | Default `gap` | Direction | `className` |\n| --- | --- | --- | --- | --- |\n| `Stack` | `0` and every step `1`–`144` | `4` | `column` | Yes |\n| `VStack` | Same as `Stack` | `4` | `column` (fixed) | Yes |\n| `HStack` | Same as `Stack` | `4` | `row` (fixed), `align='center'` | Yes |\n| `Flex` | Every step `1`–`144`, no `0` | none | `row` | No |\n\n### Built into components\n\nDo not repeat these.\n\n| Component | Side gutter | Gap between items |\n| --- | --- | --- |\n| `PageHeader`, `PageToolbar`, `PageContent` | 24px | 12px (header, toolbar) |\n| `PageActions` | — | 8px |\n| `Card` | 16px all round | 8px between parts; 12px in `CardFooter` |\n| `DrawerHeader`, `DialogHeader` | 24px left | 12px |\n| `DrawerBody`, `DialogBody` | 24px | — |\n| `DrawerFooter`, `DialogFooter` | 24px, 16px vertical | 8px |\n| Drawer / Dialog inset from viewport | 8px / 32px | — |",
53
+ "sections": [
54
+ {
55
+ "id": "what-you-use",
56
+ "heading": "What you use",
57
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Children in a column | `<VStack gap={16}>` | `space-y-4`, `flex flex-col gap-4` |\n| Children in a row | `<HStack gap={8}>` | `space-x-2`, per-child margins |\n| A row or column with `basis`, `grow` or `shrink` | `<Flex gap={12} basis='1/2'>` | A raw `div` with `flex basis-1/2` |\n| Padding on your own element | `p-16`, `px-24`, `py-8` | `p-4` meaning 16px |\n| Page gutters | `PageHeader`, `PageToolbar`, `PageContent` | Your own `px-*` on the page wrapper |\n| A bordered tile | `Card` with `CardHeader`, `CardContent`, `CardFooter` | A `div` with `p-16 rounded-8 border` |\n| Side panel or modal body | `DrawerBody`, `DialogBody` | Padding classes inside header, body or footer |"
58
+ },
59
+ {
60
+ "id": "rules",
61
+ "heading": "Rules",
62
+ "markdown": "- Read every number in a spacing or sizing class (`p-*`, `m-*`, `gap-*`, `w-*`, `h-*`, `size-*`, `inset-*`, `top-*`) as pixels.\n- **Never** copy a Tailwind docs value as-is. Multiply it by 4: Tailwind `p-4` is `p-16` here, `mt-8` is `mt-32`, `h-8` is `h-32`.\n- Stay on the scale. If a value is off it, round to the nearest step. Off-scale classes such as `p-10` still compile; the rule stops you, not the build.\n- If children sit in a line, use `Stack`, `HStack` or `VStack` with `gap`. **Never** use per-child margins or `space-*`.\n- Pass `gap` as a scale-number prop, not a `gap-*` class. Off-scale numbers fail type-checking.\n- If you omit `gap` on `Stack`, `HStack` or `VStack`, you get 4px. If you omit it on `Flex`, you get none.\n- If you want no gap on `Stack`, pass `gap={0}`. `Flex` has no `gap={0}`; omit the prop.\n- If you need `className` on the flex container, use `Stack`. `Flex` does not accept `className`.\n- `PageHeader`, `PageToolbar` and `PageContent` own the 24px side gutter. **Never** wrap `Page` in a padded container.\n- `Card` owns its 16px padding. **Never** add `p-*` to `Card` or its parts.\n- Put drawer and dialog content straight into `DrawerBody` or `DialogBody`. Their 24px gutter is fixed.\n- **Never** pass `className` to `DrawerHeader`, `DrawerBody` or `DrawerFooter`. They accept only `children` and `ref`.\n- Import `Dialog` parts from `@wallarm-org/design-system/Dialog`. `Dialog` is not exported from the package root.\n- **Never** reach for a `--spacing-10` token, a `space-*` component prop, or a `padding` prop on `Stack`, `Flex`, `Card` or `Page`. None exist."
63
+ },
64
+ {
65
+ "id": "examples",
66
+ "heading": "Examples",
67
+ "markdown": "A column of sections uses `VStack`, not per-child margins.\n\n```tsx\nimport { Text, VStack } from '@wallarm-org/design-system';\n\n<VStack gap={24}>\n <Text>Profile</Text>\n <Text>Security</Text>\n</VStack>;\n```\n\nPadding on your own element uses the pixel number, not the Tailwind docs number.\n\n```tsx\n<div className='px-24 py-12'>Content</div>; // not 'px-6 py-3'\n```\n\nA page puts cards straight into `PageContent`, with no padding of its own.\n\n```tsx\nimport {\n Card, CardContent, CardHeader, CardTitle, HStack,\n Page, PageContent, PageHeader, PageTitle,\n} from '@wallarm-org/design-system';\n\n<Page name='overview' fullSize fixedHeight>\n <PageHeader>\n <PageTitle>Overview</PageTitle>\n </PageHeader>\n <PageContent>\n <HStack gap={16} align='stretch'>\n <Card>\n <CardHeader><CardTitle>Requests</CardTitle></CardHeader>\n <CardContent>1,204</CardContent>\n </Card>\n </HStack>\n </PageContent>\n</Page>;\n```"
68
+ },
69
+ {
70
+ "id": "reference",
71
+ "heading": "Reference",
72
+ "markdown": "### Scale\n\nEach step is a `--spacing-N` token and a utility suffix (`p-N`, `gap-N`, `w-N`), N in pixels. Zero is `--spacing-none` (`p-0`).\n\n| Range | Steps (px) |\n| --- | --- |\n| Tight | 1, 2, 4, 6, 8, 12 |\n| Component | 16, 20, 24, 28, 32, 36, 40, 44, 48 |\n| Layout | 56, 64, 80, 96, 112, 128, 144 |\n\n### Layout components\n\n| Component | `gap` values | Default `gap` | Direction | `className` |\n| --- | --- | --- | --- | --- |\n| `Stack` | `0` and every step `1`–`144` | `4` | `column` | Yes |\n| `VStack` | Same as `Stack` | `4` | `column` (fixed) | Yes |\n| `HStack` | Same as `Stack` | `4` | `row` (fixed), `align='center'` | Yes |\n| `Flex` | Every step `1`–`144`, no `0` | none | `row` | No |\n\n### Built into components\n\nDo not repeat these.\n\n| Component | Side gutter | Gap between items |\n| --- | --- | --- |\n| `PageHeader`, `PageToolbar`, `PageContent` | 24px | 12px (header, toolbar) |\n| `PageActions` | — | 8px |\n| `Card` | 16px all round | 8px between parts; 12px in `CardFooter` |\n| `DrawerHeader`, `DialogHeader` | 24px left | 12px |\n| `DrawerBody`, `DialogBody` | 24px | — |\n| `DrawerFooter`, `DialogFooter` | 24px, 16px vertical | 8px |\n| Drawer / Dialog inset from viewport | 8px / 32px | — |"
73
+ }
74
+ ]
75
+ },
76
+ {
77
+ "slug": "foundations/color",
78
+ "kind": "foundation",
79
+ "title": "Color",
80
+ "description": "Use when choosing a background, text, icon, border or hover color, so the value switches correctly between light and dark themes.",
81
+ "components": [
82
+ "Alert",
83
+ "AlertTitle",
84
+ "Badge",
85
+ "Banner",
86
+ "Button",
87
+ "Card",
88
+ "CardContent",
89
+ "Drawer",
90
+ "DropdownMenu",
91
+ "Link",
92
+ "Popover",
93
+ "Radio",
94
+ "SegmentedControl",
95
+ "Slider",
96
+ "Tag",
97
+ "Text",
98
+ "ThemeProvider"
99
+ ],
100
+ "keywords": [
101
+ "color",
102
+ "colour",
103
+ "token",
104
+ "background",
105
+ "bg",
106
+ "text color",
107
+ "border",
108
+ "surface",
109
+ "elevation",
110
+ "status",
111
+ "danger",
112
+ "warning",
113
+ "success",
114
+ "info",
115
+ "brand",
116
+ "ai",
117
+ "hover",
118
+ "pressed",
119
+ "states",
120
+ "dark mode",
121
+ "theme",
122
+ "palette",
123
+ "opacity"
124
+ ],
125
+ "order": 20,
126
+ "markdown": "# Color\n\nUse semantic tokens; all but `-fixed` switch with the theme. The prefix repeats: `bg-bg-surface-1`, `text-text-primary`.\n\n## What you use\n\n| Need | Use | Not |\n|---|---|---|\n| Body text | `text-text-primary` | `text-slate-900` |\n| Supporting text | `text-text-secondary` | `text-text-primary/60`, `text-muted` |\n| Placeholder or hint | `text-text-hint` | `text-text-tertiary` |\n| Error text | `text-text-danger` | `text-red-600` |\n| Text on a solid fill | `text-text-primary-alt` | `text-white` |\n| Icon ink | `text-icon-secondary` | `text-text-secondary` |\n| Page | `bg-bg-page-bg` | `bg-white` |\n| Card or panel | `bg-bg-surface-1` | `bg-white` |\n| Popover, menu, drawer | `bg-bg-surface-2` | `bg-bg-surface-1` |\n| Neutral tinted area | `bg-bg-light-primary`, `bg-bg-primary` | `bg-slate-50` |\n| Soft status box | `bg-bg-light-danger` + `border-border-danger` | `bg-red-50 border-red-300` |\n| Solid status block | `bg-bg-fill-danger` | `bg-red-600` |\n| Divider | `border-border-primary-light` | `border-gray-200` |\n| Control outline | `border-border-primary` | `border-border-primary-light` |\n| Hover, transparent item | `hover:bg-states-primary-hover` | `hover:bg-slate-100` |\n| Hover, solid fill | `overlay hover:overlay-states-on-fill-hover` | `hover:bg-bg-fill-brand/90`, `hover:brightness-110` |\n| Categorical color | `Badge` `color` | `bg-violet-100` on a `div` |\n| Status notice | `Alert` `color` | A `div` with status classes |\n\n## Rules\n\n**Surfaces**\n\n- If it sits in page flow (`Card`), use `bg-bg-surface-1`.\n- If it floats over content (`Popover`, `DropdownMenu`, `Drawer`), use `bg-bg-surface-2`.\n- If it is a raised thumb or indicator (`Slider`, `SegmentedControl`, `Radio`), use `bg-bg-surface-3`.\n- If it is a control track, use `bg-bg-primary`, never `bg-bg-surface-3`.\n- Use `bg-bg-surface-5` only for a raised strip like `Banner`.\n- Pick the level by elevation: levels 1–4 are all white in light. Separate them with borders and shadows.\n\n**Status**\n\n- If the area holds dark text, use `bg-bg-light-<status>` or `bg-bg-<status>` with `border-border-<status>`.\n- If it holds `text-text-primary-alt`, use `bg-bg-fill-<status>`.\n- Use `brand` only for the primary action, `ai` only for assistant content.\n- Pair status color with an icon or word.\n- Use `danger` for errors. `destructive` is only an `Alert`/`Button` prop and `ring-focus-destructive`.\n\n**States**\n\n- Transparent item: `hover:bg-states-primary-hover`, `active:bg-states-primary-pressed`. Selected: `bg-states-primary-active`.\n- Solid fill: add `overlay`, then `hover:overlay-states-on-fill-hover`, `active:overlay-states-on-fill-pressed`.\n- Brand/danger item: `bg-states-brand-*`, `bg-states-danger-*`. Inverted surface: `bg-states-primary-alt-*`.\n- `states-*` tokens are translucent layers over a resting fill.\n- Prefer components with built-in states, like `Button`.\n\n**Theme**\n\n- Switch themes with `useTheme().setTheme()`, never by setting `data-theme`.\n- Use `-alt` tokens on inverted surfaces; `-fixed` tokens only on fixed surfaces.\n- **Never** use the `dark:` variant. It follows the OS, not `ThemeProvider`.\n- **Never** use raw palette classes (`bg-red-500`), which ignore the theme, or opacity modifiers (`/90`).\n- **Never** reuse `bg-component-*` outside its component. Exception: an auth page paints `bg-component-app-shell-bg`; see [Auth page](../patterns/auth-page.md).\n- **Never** edit `--color-syntax-*` mappings.\n\n**These do not exist**\n\n- `bg-primary`, `text-primary`, `border-primary` → `bg-bg-primary`, `text-text-primary`, `border-border-primary`.\n- `text-muted`, `text-subtle`, `text-disabled` → `text-text-secondary`, `text-text-hint`, `text-text-disable-primary`.\n- `bg-surface`, `bg-card`, `bg-background`, `bg-muted` → `bg-bg-surface-1`, `bg-bg-light-primary`.\n- `bg-bg-surface-0`, `bg-bg-surface-6` → levels run 1–5.\n- `error` tokens → `danger`. `bg-bg-fill-primary` → no neutral solid fill. `border-border-secondary` → `border-border-primary-light`.\n- `Badge` `color='purple'` → `violet`. `Tag` has no `color` prop.\n\n## Examples\n\nA soft danger box in a card.\n\n```tsx\nimport { Card, CardContent, Text } from '@wallarm-org/design-system';\n\nexport const BlockedRequests = () => (\n <Card>\n <CardContent className='flex flex-col gap-8'>\n <Text size='sm' color='secondary'>Blocked requests</Text>\n <div className='rounded-8 border-1 border-border-danger bg-bg-light-danger px-12 py-8 text-text-danger'>\n 1 204 attacks blocked\n </div>\n </CardContent>\n </Card>\n);\n```\n\nNotices and labels are components.\n\n```tsx\nimport { Alert, AlertTitle, Badge } from '@wallarm-org/design-system';\n\nexport const QuotaNotice = () => (\n <>\n <Alert color='warning'><AlertTitle>Quota at 90%</AlertTitle></Alert>\n <Badge color='violet' type='secondary'>API abuse</Badge>\n </>\n);\n```\n\nA custom solid fill keeps its color on hover.\n\n```tsx\nexport const DeployTile = () => (\n <button\n type='button'\n className='overlay bg-bg-fill-brand text-text-primary-alt hover:overlay-states-on-fill-hover active:overlay-states-on-fill-pressed'\n >\n Deploy\n </button>\n);\n```\n\nSwitch themes with `useTheme`.\n\n```tsx\nimport { Button, useTheme } from '@wallarm-org/design-system';\n\nexport const DarkModeButton = () => {\n const { setTheme } = useTheme();\n const handleDark = () => setTheme('dark');\n\n return <Button onClick={handleDark}>Dark</Button>;\n};\n```\n\n## Reference\n\n`<status>`: `brand`, `danger`, `warning`, `success`, `info`, `ai`.\n\n| Family | Classes |\n|---|---|\n| Text | `text-text-` `primary`, `secondary`, `tertiary`, `hint`, `<status>`, `primary-alt`, `secondary-alt`, `tertiary-alt`, `primary-fixed`, `primary-alt-fixed` |\n| Disabled text | `text-text-disable-primary`, `-primary-alt`, `-<status>` (success is spelled `succes`) |\n| Link | `text-text-link-default`, `-hover`, `-default-alt`, `-hover-alt`; prefer `Link` |\n| Icon | `text-icon-` `primary`, `secondary`, `<status>`, `primary-alt`, `secondary-alt`, `primary-fixed`, `primary-alt-fixed`, `primary-disable`, `primary-alt-disable` |\n| Surface | `bg-bg-page-bg`, `bg-bg-surface-1`…`5` |\n| Neutral fill | `bg-bg-light-primary` < `bg-bg-primary` < `bg-bg-strong-primary` |\n| Status fill | `bg-bg-light-<status>` < `bg-bg-<status>` < `bg-bg-strong-<status>`; solid `bg-bg-fill-<status>` |\n| Border | `border-border-primary-light`, `-primary`, `-strong-primary`, `-<status>`, `-strong-<status>` |\n| States | `bg-states-<family>-default-alt`, `-hover`, `-pressed`, `-active`; `bg-states-default-all` |\n| Fill states | `overlay-states-on-fill-hover`, `-pressed`, `-disabled` (need `overlay`) |\n| Focus | `ring-focus-primary`, `-brand`, `-destructive`; see [Focus](./focus.md) |\n\n`<family>`: `primary`, `primary-alt`, or a `<status>`. `Text` `color`: `primary`, `primary-alt`, `secondary`, `secondary-alt`, `tertiary-alt`, `danger`, `inherit`. `Badge` `color`: `slate`, `gray`, `zinc`, `neutral`, `stone`, `red`, `w-orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `fuchsia`, `pink`, `rose`.",
127
+ "sections": [
128
+ {
129
+ "id": "what-you-use",
130
+ "heading": "What you use",
131
+ "markdown": "| Need | Use | Not |\n|---|---|---|\n| Body text | `text-text-primary` | `text-slate-900` |\n| Supporting text | `text-text-secondary` | `text-text-primary/60`, `text-muted` |\n| Placeholder or hint | `text-text-hint` | `text-text-tertiary` |\n| Error text | `text-text-danger` | `text-red-600` |\n| Text on a solid fill | `text-text-primary-alt` | `text-white` |\n| Icon ink | `text-icon-secondary` | `text-text-secondary` |\n| Page | `bg-bg-page-bg` | `bg-white` |\n| Card or panel | `bg-bg-surface-1` | `bg-white` |\n| Popover, menu, drawer | `bg-bg-surface-2` | `bg-bg-surface-1` |\n| Neutral tinted area | `bg-bg-light-primary`, `bg-bg-primary` | `bg-slate-50` |\n| Soft status box | `bg-bg-light-danger` + `border-border-danger` | `bg-red-50 border-red-300` |\n| Solid status block | `bg-bg-fill-danger` | `bg-red-600` |\n| Divider | `border-border-primary-light` | `border-gray-200` |\n| Control outline | `border-border-primary` | `border-border-primary-light` |\n| Hover, transparent item | `hover:bg-states-primary-hover` | `hover:bg-slate-100` |\n| Hover, solid fill | `overlay hover:overlay-states-on-fill-hover` | `hover:bg-bg-fill-brand/90`, `hover:brightness-110` |\n| Categorical color | `Badge` `color` | `bg-violet-100` on a `div` |\n| Status notice | `Alert` `color` | A `div` with status classes |"
132
+ },
133
+ {
134
+ "id": "rules",
135
+ "heading": "Rules",
136
+ "markdown": "**Surfaces**\n\n- If it sits in page flow (`Card`), use `bg-bg-surface-1`.\n- If it floats over content (`Popover`, `DropdownMenu`, `Drawer`), use `bg-bg-surface-2`.\n- If it is a raised thumb or indicator (`Slider`, `SegmentedControl`, `Radio`), use `bg-bg-surface-3`.\n- If it is a control track, use `bg-bg-primary`, never `bg-bg-surface-3`.\n- Use `bg-bg-surface-5` only for a raised strip like `Banner`.\n- Pick the level by elevation: levels 1–4 are all white in light. Separate them with borders and shadows.\n\n**Status**\n\n- If the area holds dark text, use `bg-bg-light-<status>` or `bg-bg-<status>` with `border-border-<status>`.\n- If it holds `text-text-primary-alt`, use `bg-bg-fill-<status>`.\n- Use `brand` only for the primary action, `ai` only for assistant content.\n- Pair status color with an icon or word.\n- Use `danger` for errors. `destructive` is only an `Alert`/`Button` prop and `ring-focus-destructive`.\n\n**States**\n\n- Transparent item: `hover:bg-states-primary-hover`, `active:bg-states-primary-pressed`. Selected: `bg-states-primary-active`.\n- Solid fill: add `overlay`, then `hover:overlay-states-on-fill-hover`, `active:overlay-states-on-fill-pressed`.\n- Brand/danger item: `bg-states-brand-*`, `bg-states-danger-*`. Inverted surface: `bg-states-primary-alt-*`.\n- `states-*` tokens are translucent layers over a resting fill.\n- Prefer components with built-in states, like `Button`.\n\n**Theme**\n\n- Switch themes with `useTheme().setTheme()`, never by setting `data-theme`.\n- Use `-alt` tokens on inverted surfaces; `-fixed` tokens only on fixed surfaces.\n- **Never** use the `dark:` variant. It follows the OS, not `ThemeProvider`.\n- **Never** use raw palette classes (`bg-red-500`), which ignore the theme, or opacity modifiers (`/90`).\n- **Never** reuse `bg-component-*` outside its component. Exception: an auth page paints `bg-component-app-shell-bg`; see [Auth page](../patterns/auth-page.md).\n- **Never** edit `--color-syntax-*` mappings.\n\n**These do not exist**\n\n- `bg-primary`, `text-primary`, `border-primary` → `bg-bg-primary`, `text-text-primary`, `border-border-primary`.\n- `text-muted`, `text-subtle`, `text-disabled` → `text-text-secondary`, `text-text-hint`, `text-text-disable-primary`.\n- `bg-surface`, `bg-card`, `bg-background`, `bg-muted` → `bg-bg-surface-1`, `bg-bg-light-primary`.\n- `bg-bg-surface-0`, `bg-bg-surface-6` → levels run 1–5.\n- `error` tokens → `danger`. `bg-bg-fill-primary` → no neutral solid fill. `border-border-secondary` → `border-border-primary-light`.\n- `Badge` `color='purple'` → `violet`. `Tag` has no `color` prop."
137
+ },
138
+ {
139
+ "id": "examples",
140
+ "heading": "Examples",
141
+ "markdown": "A soft danger box in a card.\n\n```tsx\nimport { Card, CardContent, Text } from '@wallarm-org/design-system';\n\nexport const BlockedRequests = () => (\n <Card>\n <CardContent className='flex flex-col gap-8'>\n <Text size='sm' color='secondary'>Blocked requests</Text>\n <div className='rounded-8 border-1 border-border-danger bg-bg-light-danger px-12 py-8 text-text-danger'>\n 1 204 attacks blocked\n </div>\n </CardContent>\n </Card>\n);\n```\n\nNotices and labels are components.\n\n```tsx\nimport { Alert, AlertTitle, Badge } from '@wallarm-org/design-system';\n\nexport const QuotaNotice = () => (\n <>\n <Alert color='warning'><AlertTitle>Quota at 90%</AlertTitle></Alert>\n <Badge color='violet' type='secondary'>API abuse</Badge>\n </>\n);\n```\n\nA custom solid fill keeps its color on hover.\n\n```tsx\nexport const DeployTile = () => (\n <button\n type='button'\n className='overlay bg-bg-fill-brand text-text-primary-alt hover:overlay-states-on-fill-hover active:overlay-states-on-fill-pressed'\n >\n Deploy\n </button>\n);\n```\n\nSwitch themes with `useTheme`.\n\n```tsx\nimport { Button, useTheme } from '@wallarm-org/design-system';\n\nexport const DarkModeButton = () => {\n const { setTheme } = useTheme();\n const handleDark = () => setTheme('dark');\n\n return <Button onClick={handleDark}>Dark</Button>;\n};\n```"
142
+ },
143
+ {
144
+ "id": "reference",
145
+ "heading": "Reference",
146
+ "markdown": "`<status>`: `brand`, `danger`, `warning`, `success`, `info`, `ai`.\n\n| Family | Classes |\n|---|---|\n| Text | `text-text-` `primary`, `secondary`, `tertiary`, `hint`, `<status>`, `primary-alt`, `secondary-alt`, `tertiary-alt`, `primary-fixed`, `primary-alt-fixed` |\n| Disabled text | `text-text-disable-primary`, `-primary-alt`, `-<status>` (success is spelled `succes`) |\n| Link | `text-text-link-default`, `-hover`, `-default-alt`, `-hover-alt`; prefer `Link` |\n| Icon | `text-icon-` `primary`, `secondary`, `<status>`, `primary-alt`, `secondary-alt`, `primary-fixed`, `primary-alt-fixed`, `primary-disable`, `primary-alt-disable` |\n| Surface | `bg-bg-page-bg`, `bg-bg-surface-1`…`5` |\n| Neutral fill | `bg-bg-light-primary` < `bg-bg-primary` < `bg-bg-strong-primary` |\n| Status fill | `bg-bg-light-<status>` < `bg-bg-<status>` < `bg-bg-strong-<status>`; solid `bg-bg-fill-<status>` |\n| Border | `border-border-primary-light`, `-primary`, `-strong-primary`, `-<status>`, `-strong-<status>` |\n| States | `bg-states-<family>-default-alt`, `-hover`, `-pressed`, `-active`; `bg-states-default-all` |\n| Fill states | `overlay-states-on-fill-hover`, `-pressed`, `-disabled` (need `overlay`) |\n| Focus | `ring-focus-primary`, `-brand`, `-destructive`; see [Focus](./focus.md) |\n\n`<family>`: `primary`, `primary-alt`, or a `<status>`. `Text` `color`: `primary`, `primary-alt`, `secondary`, `secondary-alt`, `tertiary-alt`, `danger`, `inherit`. `Badge` `color`: `slate`, `gray`, `zinc`, `neutral`, `stone`, `red`, `w-orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `fuchsia`, `pink`, `rose`."
147
+ }
148
+ ]
149
+ },
150
+ {
151
+ "slug": "foundations/typography",
152
+ "kind": "foundation",
153
+ "title": "Typography",
154
+ "description": "Use when you set text size, weight, line height or font family, or pick between Text, Heading and Code.",
155
+ "components": [
156
+ "Text",
157
+ "Heading",
158
+ "Code"
159
+ ],
160
+ "keywords": [
161
+ "typography",
162
+ "font",
163
+ "font size",
164
+ "text size",
165
+ "line height",
166
+ "leading",
167
+ "weight",
168
+ "heading",
169
+ "body",
170
+ "title",
171
+ "label",
172
+ "caption",
173
+ "mono",
174
+ "tracking",
175
+ "letter spacing"
176
+ ],
177
+ "order": 30,
178
+ "markdown": "# Typography\n\nSet text with `Text`, `Heading` and `Code`; raw classes go only on your own elements.\n\n## What you use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Body copy, labels, descriptions | `<Text size=\"sm\">` | `<p className=\"text-sm\">` |\n| Secondary or meta text | `<Text size=\"xs\" color=\"secondary\">` | `text-gray-500` |\n| A title or section heading | `<Heading as=\"h2\" size=\"lg\">` | `<Text size=\"xl\" weight=\"bold\">` |\n| Code, IDs, hashes, paths | `<Code size=\"m\">` | `font-mono` on a `<span>` |\n| Text in your own element | `text-sm`, `font-medium`, `leading-20` | `font-regular` |\n\nThere are no `heading-*` or `body-*` classes, no `font-regular`, and no `semibold` value on the `weight` prop.\n\n## Rules\n\n- If you need body text in an app screen, pass `size=\"sm\"` (14/20). `Text` defaults to `lg` (16/24).\n- If you see `size=\"md\"` on `Text` or `text-md`, write `sm` / `text-sm`. They render the same 14/20.\n- If you need a heading, set `as` to the real level. `Heading` defaults to `h1`, `size=\"xl\"`, `weight=\"bold\"`.\n- **Never** pass `className` to `Text` (the build fails) or `Heading` (it is ignored). Style a wrapper element instead.\n- If a `Text` or `Code` sits inside a `<p>`, pass `asChild` with a `<span>` child. Both render a `<p>`. Still pass `size`: `asChild` keeps the default (16px).\n- `leading-N` is N pixels (`leading-5` is 5px). For a size's paired line height, use the named step (`leading-sm` = 20px).\n- **Never** use `leading-none` or `lineHeight=\"none\"`: 0px, lines overlap.\n- Keep `lineClamp` 1–6; higher values get no CSS.\n- If you mark an error, use `color=\"danger\"` on `Text` and `color=\"destructive\"` on `Code`. Neither accepts the other's value.\n- **Never** add letter spacing below 20px. `Heading` sets tracking per size.\n- **Never** use `font-pixel` (decorative, `EmptyState` only) or `font-serif` (points at Inter, not bundled) in app screens.\n- If you need weight 800, write `font-extrabold`, **never** the misspelled `font-extrablod`.\n- `font-medium` is 450, not 500. You load no font: the theme ships Geist and Geist Mono.\n\n## Examples\n\nA section header with a secondary description and a code value.\n\n```tsx\nimport { Code, Heading, Text } from '@wallarm-org/design-system';\n\n<section className='flex flex-col gap-8'>\n <Heading as='h2' size='lg' weight='medium'>Blocking rules</Heading>\n <Text size='sm' color='secondary'>Requests that match are blocked.</Text>\n <Code size='m'>/api/v1/users/{'{id}'}</Code>\n</section>\n```\n\nInline text inside a paragraph you own, with its size set to match.\n\n```tsx\n<p className='text-sm leading-20'>\n Last seen <Text asChild size='sm' weight='medium'><span>2 min ago</span></Text>\n</p>\n```\n\nA style the props lack goes on a wrapper.\n\n```tsx\n<div className='tabular-nums'>\n <Text size='sm'>1,204 requests</Text>\n</div>\n```\n\n## Reference\n\n### Size scale (`text-*` sets size and paired line height, px)\n\n| Class | Size / LH | Class | Size / LH |\n| --- | --- | --- | --- |\n| `text-2xs` | 10 / 12 | `text-3xl` | 30 / 36 |\n| `text-xs` | 12 / 16 | `text-4xl` | 36 / 40 |\n| `text-sm` | 14 / 20 | `text-5xl` | 48 / 48 |\n| `text-base` | 16 / 24 | `text-6xl` | 60 / 60 |\n| `text-lg` | 18 / 24 | `text-7xl` | 72 / 72 |\n| `text-xl` | 20 / 28 | `text-8xl` | 96 / 96 |\n| `text-2xl` | 24 / 32 | `text-9xl` | 128 / 128 |\n\n### Weights\n\n| Class | Value | `weight` prop |\n| --- | --- | --- |\n| `font-light` | 300 | `light` |\n| `font-normal` | 400 | `regular` |\n| `font-medium` | 450 | `medium` |\n| `font-semibold` | 600 | none |\n| `font-bold` | 700 | `bold` |\n| `font-extrabold` | 800 | none |\n\n### Component sizes (size / line height)\n\n| Component | Sizes | Default |\n| --- | --- | --- |\n| `Text` | `xs` 12/16, `sm` 14/20, `md` 14/20, `lg` 16/24, `xl` 18/28 | `lg` |\n| `Heading` | `sm`, `md`, `lg`, `xl`, `2xl`…`7xl` = `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`…`text-7xl` | `xl` |\n| `Code` | `xs` 10/12, `s` 12/16, `m` 14/20, `l` 16/20 | `l` |\n\n### Component colors\n\n| Component | `color` values | Default |\n| --- | --- | --- |\n| `Text` | `primary`, `primary-alt`, `secondary`, `secondary-alt`, `tertiary-alt`, `danger`, `inherit` | `inherit` |\n| `Heading` | `primary`, `primary-alt`, `secondary`, `secondary-alt`, `inherit` | `inherit` |\n| `Code` | `primary`, `primary-alt`, `secondary`, `destructive` | `primary` |\n\n`Text` has no `success`, `warning` or `brand`. Without `truncate`, `Text` keeps line breaks. `Code` accepts `className`.\n\nRelated: [Spacing](../foundations/spacing.md), [Color](../foundations/color.md).",
179
+ "sections": [
180
+ {
181
+ "id": "what-you-use",
182
+ "heading": "What you use",
183
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Body copy, labels, descriptions | `<Text size=\"sm\">` | `<p className=\"text-sm\">` |\n| Secondary or meta text | `<Text size=\"xs\" color=\"secondary\">` | `text-gray-500` |\n| A title or section heading | `<Heading as=\"h2\" size=\"lg\">` | `<Text size=\"xl\" weight=\"bold\">` |\n| Code, IDs, hashes, paths | `<Code size=\"m\">` | `font-mono` on a `<span>` |\n| Text in your own element | `text-sm`, `font-medium`, `leading-20` | `font-regular` |\n\nThere are no `heading-*` or `body-*` classes, no `font-regular`, and no `semibold` value on the `weight` prop."
184
+ },
185
+ {
186
+ "id": "rules",
187
+ "heading": "Rules",
188
+ "markdown": "- If you need body text in an app screen, pass `size=\"sm\"` (14/20). `Text` defaults to `lg` (16/24).\n- If you see `size=\"md\"` on `Text` or `text-md`, write `sm` / `text-sm`. They render the same 14/20.\n- If you need a heading, set `as` to the real level. `Heading` defaults to `h1`, `size=\"xl\"`, `weight=\"bold\"`.\n- **Never** pass `className` to `Text` (the build fails) or `Heading` (it is ignored). Style a wrapper element instead.\n- If a `Text` or `Code` sits inside a `<p>`, pass `asChild` with a `<span>` child. Both render a `<p>`. Still pass `size`: `asChild` keeps the default (16px).\n- `leading-N` is N pixels (`leading-5` is 5px). For a size's paired line height, use the named step (`leading-sm` = 20px).\n- **Never** use `leading-none` or `lineHeight=\"none\"`: 0px, lines overlap.\n- Keep `lineClamp` 1–6; higher values get no CSS.\n- If you mark an error, use `color=\"danger\"` on `Text` and `color=\"destructive\"` on `Code`. Neither accepts the other's value.\n- **Never** add letter spacing below 20px. `Heading` sets tracking per size.\n- **Never** use `font-pixel` (decorative, `EmptyState` only) or `font-serif` (points at Inter, not bundled) in app screens.\n- If you need weight 800, write `font-extrabold`, **never** the misspelled `font-extrablod`.\n- `font-medium` is 450, not 500. You load no font: the theme ships Geist and Geist Mono."
189
+ },
190
+ {
191
+ "id": "examples",
192
+ "heading": "Examples",
193
+ "markdown": "A section header with a secondary description and a code value.\n\n```tsx\nimport { Code, Heading, Text } from '@wallarm-org/design-system';\n\n<section className='flex flex-col gap-8'>\n <Heading as='h2' size='lg' weight='medium'>Blocking rules</Heading>\n <Text size='sm' color='secondary'>Requests that match are blocked.</Text>\n <Code size='m'>/api/v1/users/{'{id}'}</Code>\n</section>\n```\n\nInline text inside a paragraph you own, with its size set to match.\n\n```tsx\n<p className='text-sm leading-20'>\n Last seen <Text asChild size='sm' weight='medium'><span>2 min ago</span></Text>\n</p>\n```\n\nA style the props lack goes on a wrapper.\n\n```tsx\n<div className='tabular-nums'>\n <Text size='sm'>1,204 requests</Text>\n</div>\n```"
194
+ },
195
+ {
196
+ "id": "reference",
197
+ "heading": "Reference",
198
+ "markdown": "### Size scale (`text-*` sets size and paired line height, px)\n\n| Class | Size / LH | Class | Size / LH |\n| --- | --- | --- | --- |\n| `text-2xs` | 10 / 12 | `text-3xl` | 30 / 36 |\n| `text-xs` | 12 / 16 | `text-4xl` | 36 / 40 |\n| `text-sm` | 14 / 20 | `text-5xl` | 48 / 48 |\n| `text-base` | 16 / 24 | `text-6xl` | 60 / 60 |\n| `text-lg` | 18 / 24 | `text-7xl` | 72 / 72 |\n| `text-xl` | 20 / 28 | `text-8xl` | 96 / 96 |\n| `text-2xl` | 24 / 32 | `text-9xl` | 128 / 128 |\n\n### Weights\n\n| Class | Value | `weight` prop |\n| --- | --- | --- |\n| `font-light` | 300 | `light` |\n| `font-normal` | 400 | `regular` |\n| `font-medium` | 450 | `medium` |\n| `font-semibold` | 600 | none |\n| `font-bold` | 700 | `bold` |\n| `font-extrabold` | 800 | none |\n\n### Component sizes (size / line height)\n\n| Component | Sizes | Default |\n| --- | --- | --- |\n| `Text` | `xs` 12/16, `sm` 14/20, `md` 14/20, `lg` 16/24, `xl` 18/28 | `lg` |\n| `Heading` | `sm`, `md`, `lg`, `xl`, `2xl`…`7xl` = `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`…`text-7xl` | `xl` |\n| `Code` | `xs` 10/12, `s` 12/16, `m` 14/20, `l` 16/20 | `l` |\n\n### Component colors\n\n| Component | `color` values | Default |\n| --- | --- | --- |\n| `Text` | `primary`, `primary-alt`, `secondary`, `secondary-alt`, `tertiary-alt`, `danger`, `inherit` | `inherit` |\n| `Heading` | `primary`, `primary-alt`, `secondary`, `secondary-alt`, `inherit` | `inherit` |\n| `Code` | `primary`, `primary-alt`, `secondary`, `destructive` | `primary` |\n\n`Text` has no `success`, `warning` or `brand`. Without `truncate`, `Text` keeps line breaks. `Code` accepts `className`.\n\nRelated: [Spacing](../foundations/spacing.md), [Color](../foundations/color.md)."
199
+ }
200
+ ]
201
+ },
202
+ {
203
+ "slug": "foundations/radius-elevation",
204
+ "kind": "foundation",
205
+ "title": "Radius and elevation",
206
+ "description": "Use when choosing a border radius or a shadow for your own element, or when matching the corners and depth of a design-system surface.",
207
+ "components": [
208
+ "Button",
209
+ "Input",
210
+ "Select",
211
+ "Card",
212
+ "CardContent",
213
+ "Tag",
214
+ "Tooltip",
215
+ "Avatar",
216
+ "DropdownMenu",
217
+ "Popover",
218
+ "PopoverTrigger",
219
+ "PopoverContent",
220
+ "Drawer",
221
+ "Dialog",
222
+ "SearchModal",
223
+ "Alert",
224
+ "Table",
225
+ "Toast",
226
+ "ChatInput",
227
+ "Checkbox",
228
+ "Radio",
229
+ "Switch",
230
+ "Kbd",
231
+ "NavRail",
232
+ "SegmentedControl",
233
+ "Skeleton",
234
+ "InputGroup",
235
+ "SplitButton"
236
+ ],
237
+ "keywords": [
238
+ "radius",
239
+ "border-radius",
240
+ "rounded",
241
+ "corners",
242
+ "shadow",
243
+ "box-shadow",
244
+ "elevation",
245
+ "depth",
246
+ "drop shadow",
247
+ "raised",
248
+ "floating",
249
+ "dark mode",
250
+ "rounded-8",
251
+ "rounded-12",
252
+ "shadow-md",
253
+ "shadow-xl"
254
+ ],
255
+ "order": 40,
256
+ "markdown": "# Radius and elevation\n\nRadius utilities are named in pixels. Shadows are named by size and do not change in dark mode.\n\n## What you use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Corners on a control-sized element | `rounded-8` | `rounded-lg`, `rounded-md`, `rounded` |\n| Corners on a menu item or a small inline chip | `rounded-6` | `rounded-md` |\n| Corners on a panel or floating surface | `rounded-12` | `rounded-xl` |\n| A circle or a pill | `rounded-full` | `rounded-[50%]`, `rounded-[999px]` |\n| A bordered tile with a shadow | `Card` | A `div` with `rounded-8 border shadow-sm` |\n| A floating panel anchored to a trigger | `Popover`, `PopoverTrigger`, `PopoverContent` | A positioned `div` with `rounded-12 shadow-md` |\n| A side panel or a modal | `Drawer` or `Dialog` | A `div` with `rounded-12 shadow-xl` |\n| A loading placeholder with matching corners | `<Skeleton rounded={8} />` | `<Skeleton className='rounded-8' />` |\n| Depth on your own floating element | `shadow-md` + `border border-border-primary-light bg-bg-surface-2` | `shadow-md` alone, `drop-shadow-*` |\n\n## Rules\n\n- Read `rounded-*` numbers as pixels. If a value is off the scale, round to the nearest step.\n- **Never** use Tailwind's named radii (`rounded-xs` … `rounded-4xl`) or bare `rounded`. They compile to rem defaults off the scale.\n- **Never** write an arbitrary radius or shadow such as `rounded-[15px]` or `shadow-[0_2px_4px_rgba(0,0,0,0.1)]`.\n- If your element sits next to a design-system component, copy that component's radius. A custom button-sized element next to `Button` gets `rounded-8`.\n- If you nest a rounded element inside a padded rounded container, use a smaller inner radius: `rounded-12` outside, 4px padding, `rounded-8` inside.\n- If you round only some corners, use side or corner variants of the same step: `rounded-t-12`, `rounded-tl-12`.\n- If an element stays in the page flow, give it no shadow. `Card` is the one in-flow surface with a shadow (`shadow-sm`).\n- If an element floats, use the shadow for its layer below.\n- If you add a shadow to your own floating element, pair it with `border border-border-primary-light` and a surface fill such as `bg-bg-surface-2`. They carry the depth in dark mode.\n- **Never** add `shadow-*` to `Card`, `Input`, `Button` or another design-system component. They own their shadow.\n- **Never** add a dark-mode shadow (`dark:shadow-lg`) or use `shadow-2xl`.\n- **Never** use a shadow as a focus indicator. See [Focus](./focus.md).\n\nThere is no `--elevation-*` token, no `shadow-card`/`shadow-popover`/`shadow-modal` utility, and no dark-mode shadow value.\n\n## Examples\n\nA placeholder takes its corners from the `rounded` prop.\n\n```tsx\nimport { Card, CardContent, Skeleton } from '@wallarm-org/design-system';\n\nexport const UsageTile = ({ loading }: { loading: boolean }) => (\n <Card>\n <CardContent>\n {loading ? (\n <Skeleton width='100%' height='120px' rounded={8} />\n ) : (\n <div className='h-120 rounded-8 border border-border-primary-light' />\n )}\n </CardContent>\n </Card>\n);\n```\n\nA custom floating element pairs its shadow with a border and fill.\n\n```tsx\n<div className='rounded-12 border border-border-primary-light bg-bg-surface-2 p-12 shadow-md'>\n …\n</div>\n```\n\nA nested item steps the radius down.\n\n```tsx\n<div className='flex rounded-12 bg-bg-surface-2 p-4'>\n <div className='rounded-8 bg-bg-surface-1 px-12 py-6'>Day</div>\n</div>\n```\n\n## Reference\n\n### Radius scale\n\n| Utility | Value | Used by |\n| --- | --- | --- |\n| `rounded-none` | 0 | Joined edges in `InputGroup`, `SplitButton` |\n| `rounded-2` | 2px | Indicators, status dots |\n| `rounded-4` | 4px | `Checkbox` box, `Kbd`, `Switch` thumb |\n| `rounded-6` | 6px | `DropdownMenu` items, small badges, `Skeleton` default |\n| `rounded-8` | 8px | `Button`, `Input`, `Select`, `Tag`, `Tooltip`, `Card`, `Avatar` sm/md |\n| `rounded-10` | 10px | `NavRail` items |\n| `rounded-12` | 12px | `Popover`, `DropdownMenu`, `Dialog`, `Drawer`, `Alert`, `Table` container |\n| `rounded-16` | 16px | `Toast`, `ChatInput`, chat messages |\n| `rounded-24`, `rounded-32` | 24px, 32px | `Skeleton` only |\n| `rounded-full` | 9999px | `Radio`, numeric badges, pills |\n\n`Skeleton` `rounded` accepts `'none'`, `'full'`, `2`, `4`, `6`, `8`, `10`, `12`, `16`, `24`, `32`.\n\n### Elevation scale\n\n| Utility | Layer | Used by |\n| --- | --- | --- |\n| `shadow-none` | Reset | Fields inside `InputGroup` |\n| `shadow-2xs` | Hairline lift | Bordered filter triggers |\n| `shadow-xs` | Raised control | `Button` primary/outline, `Input`, `Select`, `Checkbox` |\n| `shadow-sm` | In-flow tile, moving thumb | `Card`, `Switch` thumb, `SegmentedControl` indicator |\n| `shadow-md` | Anchored floating surface | `Popover`, `DropdownMenu`, tooltip tour steps |\n| `shadow-lg` | Notification, dragged item | `Toast`, `Table` bulk action bar, dragged rows |\n| `shadow-xl` | Modal layer | `Drawer`, `Dialog`, `SearchModal` |\n\nSee [Spacing](./spacing.md) for padding inside rounded surfaces.",
257
+ "sections": [
258
+ {
259
+ "id": "what-you-use",
260
+ "heading": "What you use",
261
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Corners on a control-sized element | `rounded-8` | `rounded-lg`, `rounded-md`, `rounded` |\n| Corners on a menu item or a small inline chip | `rounded-6` | `rounded-md` |\n| Corners on a panel or floating surface | `rounded-12` | `rounded-xl` |\n| A circle or a pill | `rounded-full` | `rounded-[50%]`, `rounded-[999px]` |\n| A bordered tile with a shadow | `Card` | A `div` with `rounded-8 border shadow-sm` |\n| A floating panel anchored to a trigger | `Popover`, `PopoverTrigger`, `PopoverContent` | A positioned `div` with `rounded-12 shadow-md` |\n| A side panel or a modal | `Drawer` or `Dialog` | A `div` with `rounded-12 shadow-xl` |\n| A loading placeholder with matching corners | `<Skeleton rounded={8} />` | `<Skeleton className='rounded-8' />` |\n| Depth on your own floating element | `shadow-md` + `border border-border-primary-light bg-bg-surface-2` | `shadow-md` alone, `drop-shadow-*` |"
262
+ },
263
+ {
264
+ "id": "rules",
265
+ "heading": "Rules",
266
+ "markdown": "- Read `rounded-*` numbers as pixels. If a value is off the scale, round to the nearest step.\n- **Never** use Tailwind's named radii (`rounded-xs` … `rounded-4xl`) or bare `rounded`. They compile to rem defaults off the scale.\n- **Never** write an arbitrary radius or shadow such as `rounded-[15px]` or `shadow-[0_2px_4px_rgba(0,0,0,0.1)]`.\n- If your element sits next to a design-system component, copy that component's radius. A custom button-sized element next to `Button` gets `rounded-8`.\n- If you nest a rounded element inside a padded rounded container, use a smaller inner radius: `rounded-12` outside, 4px padding, `rounded-8` inside.\n- If you round only some corners, use side or corner variants of the same step: `rounded-t-12`, `rounded-tl-12`.\n- If an element stays in the page flow, give it no shadow. `Card` is the one in-flow surface with a shadow (`shadow-sm`).\n- If an element floats, use the shadow for its layer below.\n- If you add a shadow to your own floating element, pair it with `border border-border-primary-light` and a surface fill such as `bg-bg-surface-2`. They carry the depth in dark mode.\n- **Never** add `shadow-*` to `Card`, `Input`, `Button` or another design-system component. They own their shadow.\n- **Never** add a dark-mode shadow (`dark:shadow-lg`) or use `shadow-2xl`.\n- **Never** use a shadow as a focus indicator. See [Focus](./focus.md).\n\nThere is no `--elevation-*` token, no `shadow-card`/`shadow-popover`/`shadow-modal` utility, and no dark-mode shadow value."
267
+ },
268
+ {
269
+ "id": "examples",
270
+ "heading": "Examples",
271
+ "markdown": "A placeholder takes its corners from the `rounded` prop.\n\n```tsx\nimport { Card, CardContent, Skeleton } from '@wallarm-org/design-system';\n\nexport const UsageTile = ({ loading }: { loading: boolean }) => (\n <Card>\n <CardContent>\n {loading ? (\n <Skeleton width='100%' height='120px' rounded={8} />\n ) : (\n <div className='h-120 rounded-8 border border-border-primary-light' />\n )}\n </CardContent>\n </Card>\n);\n```\n\nA custom floating element pairs its shadow with a border and fill.\n\n```tsx\n<div className='rounded-12 border border-border-primary-light bg-bg-surface-2 p-12 shadow-md'>\n …\n</div>\n```\n\nA nested item steps the radius down.\n\n```tsx\n<div className='flex rounded-12 bg-bg-surface-2 p-4'>\n <div className='rounded-8 bg-bg-surface-1 px-12 py-6'>Day</div>\n</div>\n```"
272
+ },
273
+ {
274
+ "id": "reference",
275
+ "heading": "Reference",
276
+ "markdown": "### Radius scale\n\n| Utility | Value | Used by |\n| --- | --- | --- |\n| `rounded-none` | 0 | Joined edges in `InputGroup`, `SplitButton` |\n| `rounded-2` | 2px | Indicators, status dots |\n| `rounded-4` | 4px | `Checkbox` box, `Kbd`, `Switch` thumb |\n| `rounded-6` | 6px | `DropdownMenu` items, small badges, `Skeleton` default |\n| `rounded-8` | 8px | `Button`, `Input`, `Select`, `Tag`, `Tooltip`, `Card`, `Avatar` sm/md |\n| `rounded-10` | 10px | `NavRail` items |\n| `rounded-12` | 12px | `Popover`, `DropdownMenu`, `Dialog`, `Drawer`, `Alert`, `Table` container |\n| `rounded-16` | 16px | `Toast`, `ChatInput`, chat messages |\n| `rounded-24`, `rounded-32` | 24px, 32px | `Skeleton` only |\n| `rounded-full` | 9999px | `Radio`, numeric badges, pills |\n\n`Skeleton` `rounded` accepts `'none'`, `'full'`, `2`, `4`, `6`, `8`, `10`, `12`, `16`, `24`, `32`.\n\n### Elevation scale\n\n| Utility | Layer | Used by |\n| --- | --- | --- |\n| `shadow-none` | Reset | Fields inside `InputGroup` |\n| `shadow-2xs` | Hairline lift | Bordered filter triggers |\n| `shadow-xs` | Raised control | `Button` primary/outline, `Input`, `Select`, `Checkbox` |\n| `shadow-sm` | In-flow tile, moving thumb | `Card`, `Switch` thumb, `SegmentedControl` indicator |\n| `shadow-md` | Anchored floating surface | `Popover`, `DropdownMenu`, tooltip tour steps |\n| `shadow-lg` | Notification, dragged item | `Toast`, `Table` bulk action bar, dragged rows |\n| `shadow-xl` | Modal layer | `Drawer`, `Dialog`, `SearchModal` |\n\nSee [Spacing](./spacing.md) for padding inside rounded surfaces."
277
+ }
278
+ ]
279
+ },
280
+ {
281
+ "slug": "foundations/layering",
282
+ "kind": "foundation",
283
+ "title": "Layering",
284
+ "description": "Use when an overlay must paint above another one, or when you consider adding a z-index class to app code.",
285
+ "components": [
286
+ "Drawer",
287
+ "Dialog",
288
+ "DropdownMenu",
289
+ "Select",
290
+ "Popover",
291
+ "Calendar",
292
+ "FilterCascade",
293
+ "Tooltip",
294
+ "Toaster",
295
+ "SearchModal",
296
+ "Tour",
297
+ "SelectionBulkBar",
298
+ "TableActionBar",
299
+ "FeedbackPulse",
300
+ "PageHeader"
301
+ ],
302
+ "keywords": [
303
+ "z-index",
304
+ "z",
305
+ "stacking",
306
+ "layer",
307
+ "portal",
308
+ "overlay",
309
+ "modal",
310
+ "nested drawer",
311
+ "dialog over drawer",
312
+ "dropdown in dialog",
313
+ "tooltip above",
314
+ "toast above",
315
+ "sticky header",
316
+ "popper"
317
+ ],
318
+ "order": 50,
319
+ "markdown": "# Layering\n\nOverlays stack themselves by nesting depth. App code never sets z-index on a design-system overlay.\n\n## What you use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| A panel over the page | `Drawer` | A `fixed` div with `z-50` |\n| A modal over the page or over a drawer | `Dialog` from `@wallarm-org/design-system/Dialog` | A second `Drawer` styled as a modal |\n| A menu, select, popover or date picker inside a drawer or dialog | `DropdownMenu`, `Select`, `Popover`, `Calendar`, `FilterCascade` as-is | A `z-[100]` class on the content |\n| A hint above everything | `Tooltip` | A `Popover` with a raised z-index |\n| A notification above every drawer and dialog | `useToast()` from `@wallarm-org/design-system/Toast`, plus one `Toaster` | A `Drawer` without overlay |\n| A sticky page header | `PageHeader` with `sticky` | `sticky top-0 z-50` in app code |\n| Bulk actions for a table in a drawer or dialog | Buttons in `DrawerFooter` or `DialogFooter` | `TableActionBar` |\n\nWhat does not exist:\n\n- No z-index token scale and no `z-overlay`, `z-modal`, `z-popover` or `z-toast` utility.\n- No `zIndex` prop on any overlay.\n- No `portal={false}` or container prop on `Drawer`, `Dialog`, `DropdownMenu`, `Select`, `Popover` or `Tooltip`.\n- No `Overlay` export. The backdrop comes from `Drawer` and `Dialog` when `overlay` is `true`.\n- No `Dialog` in the package root. Import it from `@wallarm-org/design-system/Dialog`.\n\n## Rules\n\n- If an overlay opens from inside another overlay, render it as a React child of the parent's content. Depth comes from the React tree.\n- If you open a `Dialog` from a `Drawer`, put the `Dialog` inside `DrawerContent`. Only a React-ancestor drawer or dialog dismisses the nested one.\n- If you nest a `Drawer` in a `Drawer`, or a `Dialog` in a `Dialog`, expect the parent to scale back. A `Dialog` over a `Drawer` does not scale the drawer.\n- If a dropdown, select, popover, calendar or tooltip sits in any overlay, use it as-is. It already paints above its host.\n- If your app has its own fixed element (chat widget), keep its z-index below 40. At 40 or higher it paints over the drawer backdrop.\n- If your app has its own sticky element inside a scroll area, use `z-10`.\n- **Never** add a `z-*` class or `style={{ zIndex }}` to `DrawerContent`, `DropdownMenuContent`, `SelectPositioner`, `PopoverContent`, `CalendarContent` or `TooltipContent`.\n- If chrome you can't change (a framework or docs-site header) sits at z-index 40 or higher, raise `--drawer-overlay-z-index` and `--drawer-positioner-z-index` together on `:root` above it, positioner 10 higher. Keep both under 900.\n- **Never** override `--tooltip-z-index` or `--toast-z-index`, and never change only one of the two drawer variables.\n- **Never** wrap an overlay trigger in `transform`, `filter` or `isolation: isolate` to fix stacking. Overlay content is portaled to `document.body`.\n- **Never** open `SearchModal` from inside a `Drawer` or `Dialog`. The backdrops cover the search box.\n- **Never** start a `Tour` while a `Drawer` or `Dialog` is open. The drawer ties with or covers the tour.\n- **Never** show `TableActionBar` for a table inside a `Drawer` or `Dialog`. The drawer ties with or covers the bar.\n\n## Examples\n\nA menu inside a drawer paints above the drawer with no z-index class.\n\n```tsx\n<DrawerContent>\n <DrawerBody>\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral'>Actions</Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem>Duplicate</DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerBody>\n</DrawerContent>\n```\n\nA confirm dialog opened from a drawer is a React child of `DrawerContent`.\n\n```tsx\nimport { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\n\n<DrawerFooter>\n <Dialog>\n <DialogTrigger asChild>\n <Button variant='primary' color='brand'>Delete</Button>\n </DialogTrigger>\n <DialogContent>\n <DialogTitle>Delete rule?</DialogTitle>\n </DialogContent>\n </Dialog>\n</DrawerFooter>\n```\n\nAn app-owned floating widget stays below the drawer backdrop.\n\n```tsx\n<div className='fixed right-16 bottom-16 z-30'>{chat}</div>\n```\n\n## Reference\n\n### Depth-based overlays\n\n`Drawer`, `Dialog`, `DropdownMenu`, `Select`, `Popover`, `Calendar` and `FilterCascade`. Each level adds 20.\n\n| Depth | Backdrop | Content |\n| --- | --- | --- |\n| 0 | 40 | 50 |\n| 1 | 60 | 70 |\n| 2 | 80 | 90 |\n| 3 | 100 | 110 |\n\n### Fixed overlays\n\n| Component | z-index |\n| --- | --- |\n| `Tooltip` | 1000 |\n| `Toaster` | 900 |\n| `SelectionBulkBar` (`placement='floating'`) | 200 |\n| `TableActionBar`, `Tour`, `FeedbackPulse` | 50 |\n| `SearchModal` | 41 |\n| `Tour` backdrop | 40 |\n\n### Variables\n\n| Variable | Value |\n| --- | --- |\n| `--drawer-overlay-z-index` | 40 |\n| `--drawer-positioner-z-index` | 50 |\n| `--toast-z-index` | 900 |\n| `--tooltip-z-index` | 1000 |\n\n### In-flow values\n\nIn-component only; never competes with overlays.\n\n| Value | Where |\n| --- | --- |\n| `z-10` | `PageHeader` `sticky`, sticky menu group labels, pinned table body cells |\n| `z-30` to `z-40` | Table headers |",
320
+ "sections": [
321
+ {
322
+ "id": "what-you-use",
323
+ "heading": "What you use",
324
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| A panel over the page | `Drawer` | A `fixed` div with `z-50` |\n| A modal over the page or over a drawer | `Dialog` from `@wallarm-org/design-system/Dialog` | A second `Drawer` styled as a modal |\n| A menu, select, popover or date picker inside a drawer or dialog | `DropdownMenu`, `Select`, `Popover`, `Calendar`, `FilterCascade` as-is | A `z-[100]` class on the content |\n| A hint above everything | `Tooltip` | A `Popover` with a raised z-index |\n| A notification above every drawer and dialog | `useToast()` from `@wallarm-org/design-system/Toast`, plus one `Toaster` | A `Drawer` without overlay |\n| A sticky page header | `PageHeader` with `sticky` | `sticky top-0 z-50` in app code |\n| Bulk actions for a table in a drawer or dialog | Buttons in `DrawerFooter` or `DialogFooter` | `TableActionBar` |\n\nWhat does not exist:\n\n- No z-index token scale and no `z-overlay`, `z-modal`, `z-popover` or `z-toast` utility.\n- No `zIndex` prop on any overlay.\n- No `portal={false}` or container prop on `Drawer`, `Dialog`, `DropdownMenu`, `Select`, `Popover` or `Tooltip`.\n- No `Overlay` export. The backdrop comes from `Drawer` and `Dialog` when `overlay` is `true`.\n- No `Dialog` in the package root. Import it from `@wallarm-org/design-system/Dialog`."
325
+ },
326
+ {
327
+ "id": "rules",
328
+ "heading": "Rules",
329
+ "markdown": "- If an overlay opens from inside another overlay, render it as a React child of the parent's content. Depth comes from the React tree.\n- If you open a `Dialog` from a `Drawer`, put the `Dialog` inside `DrawerContent`. Only a React-ancestor drawer or dialog dismisses the nested one.\n- If you nest a `Drawer` in a `Drawer`, or a `Dialog` in a `Dialog`, expect the parent to scale back. A `Dialog` over a `Drawer` does not scale the drawer.\n- If a dropdown, select, popover, calendar or tooltip sits in any overlay, use it as-is. It already paints above its host.\n- If your app has its own fixed element (chat widget), keep its z-index below 40. At 40 or higher it paints over the drawer backdrop.\n- If your app has its own sticky element inside a scroll area, use `z-10`.\n- **Never** add a `z-*` class or `style={{ zIndex }}` to `DrawerContent`, `DropdownMenuContent`, `SelectPositioner`, `PopoverContent`, `CalendarContent` or `TooltipContent`.\n- If chrome you can't change (a framework or docs-site header) sits at z-index 40 or higher, raise `--drawer-overlay-z-index` and `--drawer-positioner-z-index` together on `:root` above it, positioner 10 higher. Keep both under 900.\n- **Never** override `--tooltip-z-index` or `--toast-z-index`, and never change only one of the two drawer variables.\n- **Never** wrap an overlay trigger in `transform`, `filter` or `isolation: isolate` to fix stacking. Overlay content is portaled to `document.body`.\n- **Never** open `SearchModal` from inside a `Drawer` or `Dialog`. The backdrops cover the search box.\n- **Never** start a `Tour` while a `Drawer` or `Dialog` is open. The drawer ties with or covers the tour.\n- **Never** show `TableActionBar` for a table inside a `Drawer` or `Dialog`. The drawer ties with or covers the bar."
330
+ },
331
+ {
332
+ "id": "examples",
333
+ "heading": "Examples",
334
+ "markdown": "A menu inside a drawer paints above the drawer with no z-index class.\n\n```tsx\n<DrawerContent>\n <DrawerBody>\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral'>Actions</Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem>Duplicate</DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerBody>\n</DrawerContent>\n```\n\nA confirm dialog opened from a drawer is a React child of `DrawerContent`.\n\n```tsx\nimport { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\n\n<DrawerFooter>\n <Dialog>\n <DialogTrigger asChild>\n <Button variant='primary' color='brand'>Delete</Button>\n </DialogTrigger>\n <DialogContent>\n <DialogTitle>Delete rule?</DialogTitle>\n </DialogContent>\n </Dialog>\n</DrawerFooter>\n```\n\nAn app-owned floating widget stays below the drawer backdrop.\n\n```tsx\n<div className='fixed right-16 bottom-16 z-30'>{chat}</div>\n```"
335
+ },
336
+ {
337
+ "id": "reference",
338
+ "heading": "Reference",
339
+ "markdown": "### Depth-based overlays\n\n`Drawer`, `Dialog`, `DropdownMenu`, `Select`, `Popover`, `Calendar` and `FilterCascade`. Each level adds 20.\n\n| Depth | Backdrop | Content |\n| --- | --- | --- |\n| 0 | 40 | 50 |\n| 1 | 60 | 70 |\n| 2 | 80 | 90 |\n| 3 | 100 | 110 |\n\n### Fixed overlays\n\n| Component | z-index |\n| --- | --- |\n| `Tooltip` | 1000 |\n| `Toaster` | 900 |\n| `SelectionBulkBar` (`placement='floating'`) | 200 |\n| `TableActionBar`, `Tour`, `FeedbackPulse` | 50 |\n| `SearchModal` | 41 |\n| `Tour` backdrop | 40 |\n\n### Variables\n\n| Variable | Value |\n| --- | --- |\n| `--drawer-overlay-z-index` | 40 |\n| `--drawer-positioner-z-index` | 50 |\n| `--toast-z-index` | 900 |\n| `--tooltip-z-index` | 1000 |\n\n### In-flow values\n\nIn-component only; never competes with overlays.\n\n| Value | Where |\n| --- | --- |\n| `z-10` | `PageHeader` `sticky`, sticky menu group labels, pinned table body cells |\n| `z-30` to `z-40` | Table headers |"
340
+ }
341
+ ]
342
+ },
343
+ {
344
+ "slug": "foundations/focus",
345
+ "kind": "foundation",
346
+ "title": "Focus",
347
+ "description": "Use when an element must show keyboard focus, to pick the ring recipe, the focus color token, and where no ring goes.",
348
+ "components": [
349
+ "Button",
350
+ "Card",
351
+ "CardContent",
352
+ "Input",
353
+ "Textarea",
354
+ "InputGroup",
355
+ "Checkbox",
356
+ "Radio",
357
+ "Switch",
358
+ "DropdownMenu",
359
+ "DropdownMenuItem",
360
+ "Select",
361
+ "SelectOption",
362
+ "NavRail",
363
+ "Slider",
364
+ "Link"
365
+ ],
366
+ "keywords": [
367
+ "focus",
368
+ "focus-visible",
369
+ "ring",
370
+ "focus ring",
371
+ "outline",
372
+ "keyboard",
373
+ "a11y",
374
+ "accessibility",
375
+ "tab",
376
+ "ring-inset",
377
+ "focus-within"
378
+ ],
379
+ "order": 60,
380
+ "markdown": "# Focus\n\nKeyboard focus is a 3px ring in a translucent focus color, shown on `:focus-visible`.\nDS components draw it already. Write a ring only on elements your app builds.\n\n## What you use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Ring on an app-built control | `outline-none focus-visible:ring-3 focus-visible:ring-focus-primary` | `focus:ring-*` |\n| Ring on a brand-colored control | `focus-visible:ring-focus-brand` | `ring-focus-primary` |\n| Ring on a destructive control or invalid field | `focus-visible:ring-focus-destructive` | `ring-focus-primary` |\n| Ring inside `overflow-hidden` or flush to a container edge | add `focus-visible:ring-inset` | an outer ring, which gets clipped |\n| Ring on a wrapper around a native input | `has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-focus-primary` (or `focus-within:`) | a ring on the inner input |\n| Input with addons | `InputGroup` | a hand-built wrapper ring |\n| Focusable card | `Card` with `onClick` | a `<div tabIndex>` with your own ring |\n| Focus color on a DS button | the `Button` `color` prop (default `brand`) | a `className` override |\n\n## Rules\n\n- Use `focus-visible:`, not `focus:`. The ring is for keyboard focus only.\n- Pair the ring with `outline-none`, so the browser outline and the ring never show together.\n- Use `ring-3`. **Never** copy `ring-2` from Card, Resizable, CodeEditor or SimpleCharts: it is known drift.\n- Always set a `ring-focus-*` color. `ring-3` alone draws the default color.\n- Keep the element's radius; the ring follows it.\n- If a parent has `overflow-hidden`, add `ring-inset`.\n- If the control is brand-colored (`Button` with `color='brand'` in any variant, a checked or indeterminate Checkbox, a checked Radio or Switch), use `ring-focus-brand`. Neutral controls (`Button` with `color='neutral'` or `'neutral-alt'`, unchecked Checkbox, Radio or Switch, tabs, tags, inputs) use `ring-focus-primary`.\n- If the control is destructive (`Button` with `color='destructive'`, any variant), use `ring-focus-destructive`.\n- If you build a text field, match `Input`: on focus, `focus-visible:not-disabled:border-border-strong-primary` plus `ring-focus-primary`. Invalid: keep `border-border-strong-danger`, ring `ring-focus-destructive`, hover `ring-focus-destructive-hover`.\n- If an input sits inside a styled wrapper, put the ring on the wrapper.\n- If focus moves by arrow keys (menu, listbox), the highlighted row is the indicator. `DropdownMenuItem` and `SelectOption` use `bg-states-primary-hover`; brand and danger items use `bg-states-brand-hover` and `bg-states-danger-hover`.\n- `NavRail` items show an overlay (`overlay-states-primary-hover`); the `Slider` thumb scales up with a shadow. Neither gets a ring.\n- **Never** add a ring to a menu item or listbox option.\n- **Never** add a ring to a popover, dialog or drawer surface (`DropdownMenu`, `Select` content, `Drawer`).\n- **Never** use `outline-none` on an interactive element without a replacement indicator.\n- **Never** use a border color, `ring-border-*` or a raw palette color as a focus ring.\n- **Never** use `ring-focus-ring-primary`. The token exists but nothing uses it.\n- **Never** add `ring-offset-*`. DS rings sit flush.\n- **Never** override a DS component's ring through `className`. Pick the `color` prop.\n\n### What does not exist\n\n- No bare `ring-focus` token; always add a suffix.\n- No `--ring` or ring-width token. Width is the `ring-3` utility.\n- No `focusRing` prop on any component.\n- No exported focus-ring class helper. Write the class string.\n- `Link` has no focus-visible style, only `outline-none`. This is a known gap.\n\n## Examples\n\nA DS button picks its ring color from `color`; no class needed.\n\n```tsx\nimport { Button } from '@wallarm-org/design-system';\n\nexport const DeleteButton = ({ onDelete }: { onDelete: () => void }) => (\n <Button variant='primary' color='destructive' onClick={onDelete}>\n Delete\n </Button>\n);\n```\n\nA card becomes focusable and draws its ring once it has a click handler.\n\n```tsx\nimport { Card, CardContent } from '@wallarm-org/design-system';\n\nexport const DetailsCard = ({ onOpen }: { onOpen: () => void }) => (\n <Card onClick={onOpen}>\n <CardContent>Open details</CardContent>\n </Card>\n);\n```\n\nAn app-built control uses the standard recipe.\n\n```tsx\nexport const CustomTrigger = ({ onOpen }: { onOpen: () => void }) => (\n <button\n type='button'\n className='rounded-8 outline-none focus-visible:ring-3 focus-visible:ring-focus-primary'\n onClick={onOpen}\n >\n Custom trigger\n </button>\n);\n```\n\nA row inside a scroller with `overflow-hidden` uses an inset ring.\n\n```tsx\nexport const ClippedRow = ({ onSelect }: { onSelect: () => void }) => (\n <div className='overflow-hidden'>\n <button\n type='button'\n className='w-full outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-focus-primary'\n onClick={onSelect}\n >\n Row\n </button>\n </div>\n);\n```\n\n## Reference\n\n`ring-*` widths are px in Tailwind v4 (`ring-3` is 3px) and do not scale with `--spacing`.\n\n| Token | Utility | Value |\n| --- | --- | --- |\n| `--color-focus-primary` | `ring-focus-primary` | `slate-400` at 30% |\n| `--color-focus-brand` | `ring-focus-brand` | `w-orange-600` at 30% |\n| `--color-focus-destructive` | `ring-focus-destructive` | `red-600` at 20% |\n| `--color-focus-destructive-hover` | `ring-focus-destructive-hover` | `red-600` at 30% |",
381
+ "sections": [
382
+ {
383
+ "id": "what-you-use",
384
+ "heading": "What you use",
385
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Ring on an app-built control | `outline-none focus-visible:ring-3 focus-visible:ring-focus-primary` | `focus:ring-*` |\n| Ring on a brand-colored control | `focus-visible:ring-focus-brand` | `ring-focus-primary` |\n| Ring on a destructive control or invalid field | `focus-visible:ring-focus-destructive` | `ring-focus-primary` |\n| Ring inside `overflow-hidden` or flush to a container edge | add `focus-visible:ring-inset` | an outer ring, which gets clipped |\n| Ring on a wrapper around a native input | `has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-focus-primary` (or `focus-within:`) | a ring on the inner input |\n| Input with addons | `InputGroup` | a hand-built wrapper ring |\n| Focusable card | `Card` with `onClick` | a `<div tabIndex>` with your own ring |\n| Focus color on a DS button | the `Button` `color` prop (default `brand`) | a `className` override |"
386
+ },
387
+ {
388
+ "id": "rules",
389
+ "heading": "Rules",
390
+ "markdown": "- Use `focus-visible:`, not `focus:`. The ring is for keyboard focus only.\n- Pair the ring with `outline-none`, so the browser outline and the ring never show together.\n- Use `ring-3`. **Never** copy `ring-2` from Card, Resizable, CodeEditor or SimpleCharts: it is known drift.\n- Always set a `ring-focus-*` color. `ring-3` alone draws the default color.\n- Keep the element's radius; the ring follows it.\n- If a parent has `overflow-hidden`, add `ring-inset`.\n- If the control is brand-colored (`Button` with `color='brand'` in any variant, a checked or indeterminate Checkbox, a checked Radio or Switch), use `ring-focus-brand`. Neutral controls (`Button` with `color='neutral'` or `'neutral-alt'`, unchecked Checkbox, Radio or Switch, tabs, tags, inputs) use `ring-focus-primary`.\n- If the control is destructive (`Button` with `color='destructive'`, any variant), use `ring-focus-destructive`.\n- If you build a text field, match `Input`: on focus, `focus-visible:not-disabled:border-border-strong-primary` plus `ring-focus-primary`. Invalid: keep `border-border-strong-danger`, ring `ring-focus-destructive`, hover `ring-focus-destructive-hover`.\n- If an input sits inside a styled wrapper, put the ring on the wrapper.\n- If focus moves by arrow keys (menu, listbox), the highlighted row is the indicator. `DropdownMenuItem` and `SelectOption` use `bg-states-primary-hover`; brand and danger items use `bg-states-brand-hover` and `bg-states-danger-hover`.\n- `NavRail` items show an overlay (`overlay-states-primary-hover`); the `Slider` thumb scales up with a shadow. Neither gets a ring.\n- **Never** add a ring to a menu item or listbox option.\n- **Never** add a ring to a popover, dialog or drawer surface (`DropdownMenu`, `Select` content, `Drawer`).\n- **Never** use `outline-none` on an interactive element without a replacement indicator.\n- **Never** use a border color, `ring-border-*` or a raw palette color as a focus ring.\n- **Never** use `ring-focus-ring-primary`. The token exists but nothing uses it.\n- **Never** add `ring-offset-*`. DS rings sit flush.\n- **Never** override a DS component's ring through `className`. Pick the `color` prop.\n\n### What does not exist\n\n- No bare `ring-focus` token; always add a suffix.\n- No `--ring` or ring-width token. Width is the `ring-3` utility.\n- No `focusRing` prop on any component.\n- No exported focus-ring class helper. Write the class string.\n- `Link` has no focus-visible style, only `outline-none`. This is a known gap."
391
+ },
392
+ {
393
+ "id": "examples",
394
+ "heading": "Examples",
395
+ "markdown": "A DS button picks its ring color from `color`; no class needed.\n\n```tsx\nimport { Button } from '@wallarm-org/design-system';\n\nexport const DeleteButton = ({ onDelete }: { onDelete: () => void }) => (\n <Button variant='primary' color='destructive' onClick={onDelete}>\n Delete\n </Button>\n);\n```\n\nA card becomes focusable and draws its ring once it has a click handler.\n\n```tsx\nimport { Card, CardContent } from '@wallarm-org/design-system';\n\nexport const DetailsCard = ({ onOpen }: { onOpen: () => void }) => (\n <Card onClick={onOpen}>\n <CardContent>Open details</CardContent>\n </Card>\n);\n```\n\nAn app-built control uses the standard recipe.\n\n```tsx\nexport const CustomTrigger = ({ onOpen }: { onOpen: () => void }) => (\n <button\n type='button'\n className='rounded-8 outline-none focus-visible:ring-3 focus-visible:ring-focus-primary'\n onClick={onOpen}\n >\n Custom trigger\n </button>\n);\n```\n\nA row inside a scroller with `overflow-hidden` uses an inset ring.\n\n```tsx\nexport const ClippedRow = ({ onSelect }: { onSelect: () => void }) => (\n <div className='overflow-hidden'>\n <button\n type='button'\n className='w-full outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-focus-primary'\n onClick={onSelect}\n >\n Row\n </button>\n </div>\n);\n```"
396
+ },
397
+ {
398
+ "id": "reference",
399
+ "heading": "Reference",
400
+ "markdown": "`ring-*` widths are px in Tailwind v4 (`ring-3` is 3px) and do not scale with `--spacing`.\n\n| Token | Utility | Value |\n| --- | --- | --- |\n| `--color-focus-primary` | `ring-focus-primary` | `slate-400` at 30% |\n| `--color-focus-brand` | `ring-focus-brand` | `w-orange-600` at 30% |\n| `--color-focus-destructive` | `ring-focus-destructive` | `red-600` at 20% |\n| `--color-focus-destructive-hover` | `ring-focus-destructive-hover` | `red-600` at 30% |"
401
+ }
402
+ ]
403
+ },
404
+ {
405
+ "slug": "patterns/app-layout",
406
+ "kind": "pattern",
407
+ "title": "Application layout",
408
+ "description": "Use when placing a screen in the console frame: the host AppShell, a product RemoteShell driven by a NavConfig, breadcrumbs and where Page goes.",
409
+ "components": [
410
+ "AppShell",
411
+ "AppShellHeader",
412
+ "AppShellRail",
413
+ "AppShellRemote",
414
+ "AppShellAside",
415
+ "TopHeader",
416
+ "TopHeaderLogo",
417
+ "TopHeaderActions",
418
+ "TopHeaderSeparator",
419
+ "NotificationCenter",
420
+ "NotificationCenterTrigger",
421
+ "ChatTrigger",
422
+ "DropdownMenu",
423
+ "NavRail",
424
+ "NavRailBody",
425
+ "NavRailItem",
426
+ "NavRailSeparator",
427
+ "NavRailFooter",
428
+ "NavRailSkeleton",
429
+ "RemoteShell",
430
+ "RemoteShellPanel",
431
+ "RemoteShellBreadcrumb",
432
+ "RemoteShellContent",
433
+ "Page",
434
+ "PageHeader",
435
+ "PageTitle",
436
+ "PageContent",
437
+ "Breadcrumbs",
438
+ "UtilityPage",
439
+ "SplashScreen",
440
+ "Skeleton"
441
+ ],
442
+ "keywords": [
443
+ "app shell",
444
+ "layout",
445
+ "application layout",
446
+ "frame",
447
+ "chrome",
448
+ "top bar",
449
+ "top header",
450
+ "header",
451
+ "sidebar",
452
+ "side nav",
453
+ "navigation",
454
+ "navigation rail",
455
+ "rail",
456
+ "1st rail",
457
+ "2nd rail",
458
+ "product menu",
459
+ "nav panel",
460
+ "nav config",
461
+ "left menu",
462
+ "collapsed sidebar",
463
+ "expanded sidebar",
464
+ "micro-frontend",
465
+ "MFE",
466
+ "host",
467
+ "remote",
468
+ "product group",
469
+ "breadcrumbs",
470
+ "drill down",
471
+ "scope switcher",
472
+ "content area",
473
+ "scroll",
474
+ "splash",
475
+ "loading screen",
476
+ "404",
477
+ "error page",
478
+ "settings section",
479
+ "settings",
480
+ "notifications",
481
+ "notification center",
482
+ "bell",
483
+ "inbox",
484
+ "unread count",
485
+ "user menu",
486
+ "account menu",
487
+ "avatar",
488
+ "profile",
489
+ "sign out",
490
+ "theme",
491
+ "header actions"
492
+ ],
493
+ "order": 5,
494
+ "markdown": "# Application layout\n\nTwo shells: `AppShell` (the host's chrome) and `RemoteShell` (a product's chrome, built from a `NavConfig`).\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| The console frame: top bar, product rail, content surface | `AppShell` › `AppShellHeader` + `AppShellRail` + `AppShellRemote`, rendered once by the host | An `AppShell` per page, or a hand-made grid |\n| A product with its own menu | `RemoteShell config={navConfig}` › `RemoteShellPanel` + `RemoteShellBreadcrumb` + `RemoteShellContent` › `Page` | Hand-built panel markup, or `AppShell` in the product |\n| A product inside a host group | Content only; the host wraps the group in one `RemoteShell` | A second `RemoteShell` in the product |\n| A host-local section (Settings) | `RemoteShell` in the host route, with its own `NavConfig` | A remote for it |\n| A product with one page | `RemoteShell` without `RemoteShellPanel`, or content only | An empty `NavConfig` |\n| Home or a cross-product page | `Page` directly in `AppShellRemote` | `RemoteShell` with an empty config |\n| The app cannot render (unknown URL, 500, offline, no tenant access) | `UtilityPage` instead of the shell | `UtilityPage` inside `AppShellRemote` |\n| Cold start | `SplashScreen`, then `<AppShell reveal>` | A `Loader` on a blank page |\n\nPick the `NavConfig` node by what the entry does:\n\n| Entry | Node | Not |\n| --- | --- | --- |\n| A page | `type: 'link'` with `path` (a bare segment, no slash) | A `group` with one child |\n| Nested pages with no page of their own | `type: 'group'`; `defaultExpanded` on the one holding the usual page | `drill` |\n| Pick an entity, then its sub-pages | `type: 'drill'` with `param`, `entities` and `children` | A `Select` in the page header |\n| A label over a run of entries | `type: 'section-header'` | A disabled link |\n| A line between entries | `dividerAfter: true` on the entry above | An empty link |\n\n## Rules\n\n**Ownership**\n\n- The host renders `AppShell` once at the root. It owns the product list in `NavRail` and knows nothing about a product's routes.\n- The product owns its `NavConfig` (next to its routes) and renders its own `RemoteShell`. **Never** render `AppShell`, `TopHeader` or `NavRail` in a product.\n- A product in a host group renders content only. The host draws the group `RemoteShell` for two or more children.\n\n**Header and rail footer**\n\n- Settings is a `NavRailItem` in `NavRailFooter`, never in the product panel or top header.\n- The user menu is the last `NavRailFooter` item: an `avatar` `NavRailItem` in `DropdownMenuTrigger`.\n- Notifications are a `NotificationCenter` in `TopHeaderActions`, never a hand-made bell and popover.\n- `TopHeaderActions` order: search, `TopHeaderSeparator`, tenant switcher, `ChatTrigger`, `NotificationCenter`, help.\n\n**URL is the only state**\n\n- Pass the router's pathname to `pathname` and `onNavigate` to the router, always together.\n- If the router has its own `basepath`, omit `basePath` on `RemoteShell`.\n- The active item, drill level and trail come from `pathname`. **Never** keep them in state.\n- Exactly one `NavRailItem` is `active`, from the first URL segment: Home on `/`, the product on `/edge/…`, Settings (in `NavRailFooter`) on `/settings/…`.\n\n**Sizes (built in; do not override)**\n\n- Top header 36px. Rail 48px collapsed, 184px expanded, 78px compact. Product panel 216px, 164–256px with `resizable`. Rail and panel items 32px.\n- `AppShellRemote` owns the rounded surface and border. **Never** restyle it or add a `w-*` to the shell regions.\n- Page gutters are 24px, owned by `Page` parts. **Never** pad `RemoteShellContent` or `AppShellRemote`.\n\n**Scroll**\n\n- The shell is `h-screen`. **Never** set `overflow` or `height` on `html` or `body`.\n- The rail, the panel and `RemoteShellContent` scroll on their own. A table page uses `<Page fullSize fixedHeight>` so only `PageContent` scrolls.\n\n**Breadcrumbs**\n\n- In a product, the trail is `RemoteShellBreadcrumb`: product, groups as text, drills as a scope switcher, current page. **Never** also put `Breadcrumbs` in `PageHeader`.\n- `RemoteShellBreadcrumb` ends at the last `NavConfig` entry. Past it (a record name), render your own `Breadcrumbs` from `useRemoteShellContext()`; a grouped product reports them through `@wallarm/sdk` `onBreadcrumbChange`.\n- Trail-row controls are `RemoteShellBreadcrumb` children.\n- Home and cross-product pages have no trail.\n\n**Layering**\n\n- In-shell sticky layers are `z-10`; overlays portal above the shell. **Never** add `z-*` to a shell region. See [Layering](../foundations/layering.md).\n\n## Examples\n\nA standalone product: header actions, Settings and the user menu in the rail footer, a `NavConfig` panel, trail and page.\n\nA standalone product at `/edge/api-sessions`. The host renders `AppShell` with the product\nlist in `NavRail`; the product renders `RemoteShell` with its own `NavConfig`, and its page\ngoes in `RemoteShellContent`. The panel, the active item and the trail all come from\n`pathname`.\n\n```tsx story=Default\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Default = () => {\n const [pathname, setPathname] = useState('/edge/api-sessions');\n const [railMode, setRailMode] = useState<NavRailMode>('collapsed');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n const columns: TableColumnDef<ApiSession>[] = [\n { accessorKey: 'id', header: 'Session', size: 160 },\n { accessorKey: 'application', header: 'Application', size: 240 },\n { accessorKey: 'requests', header: 'Requests', size: 120 },\n { accessorKey: 'started', header: 'Started', size: 200 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-api-sessions' fullSize fixedHeight data-testid='sessions-page'>\n <PageHeader>\n <PageTitle>API Sessions</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='API sessions'\n data={API_SESSIONS}\n columns={columns}\n data-testid='sessions-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nHome: a page straight in `AppShellRemote`, expanded rail, no panel.\n\nHome is a host page, not a product: `Page` goes straight into `AppShellRemote`, with no\n`RemoteShell`, no panel and no breadcrumbs. The rail is expanded at the top level.\n\n```tsx story=Home\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Home = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nNotifications, open from the header bell.\n\nThe notifications inbox open from the bell in `TopHeaderActions`: unread count on the bell,\nNew and Read tabs. The app owns the list and the unread count.\n\n```tsx story=NotificationsOpen\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const NotificationsOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [notificationsOpen, setNotificationsOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter\n data-testid='notifications'\n open={notificationsOpen}\n onOpenChange={setNotificationsOpen}\n >\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nThe user menu, open from the rail's avatar item.\n\nThe user menu open from the avatar item at the foot of `NavRailFooter`: profile settings,\nappearance, sidebar mode and sign out.\n\n```tsx story=AccountMenuOpen\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const AccountMenuOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [accountMenuOpen, setAccountMenuOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n open={accountMenuOpen}\n onOpenChange={setAccountMenuOpen}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nA product inside a host group: the host's group shell around the product's page.\n\nA product inside a host group. The host renders one group `RemoteShell` with a link per child\nproduct; the child renders its page only, with no shell of its own.\n\n```tsx story=GroupedProduct\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const GroupedProduct = () => {\n const [pathname, setPathname] = useState('/security-testing/threat-replay');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<ReplayRun>[] = [\n { accessorKey: 'target', header: 'Target', size: 240 },\n { accessorKey: 'attacks', header: 'Replayed attacks', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'security-testing'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SECURITY_TESTING_GROUP}\n basePath='/security-testing'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='group-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='threat-replay' fullSize fixedHeight data-testid='replay-page'>\n <PageHeader>\n <PageTitle>Threat Replay</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Replay runs'\n data={REPLAY_RUNS}\n columns={columns}\n data-testid='replay-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nSettings: a host route rendering `RemoteShell`, with no breadcrumbs in `PageHeader`.\n\nSettings is a host route that renders `RemoteShell` itself. The trail comes from\n`RemoteShellBreadcrumb`, so `PageHeader` holds no `Breadcrumbs`.\n\n```tsx story=SettingsSection\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings, Users } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const SettingsSection = () => {\n const [pathname, setPathname] = useState('/settings/users');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'Name', size: 200 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SETTINGS_NAV}\n basePath='/settings'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='settings-shell'\n >\n <RemoteShellPanel />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser}>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Users'\n data={MEMBERS}\n columns={columns}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nA `drill` node: the entity level, the back button and the scope switcher.\n\nA `drill` node at `/edge/data-planes/production/services`: the panel shows the entity level\nwith a back button, and the trail gets a scope switcher for the entity.\n\n```tsx story=DrillDown\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const DrillDown = () => {\n const [pathname, setPathname] = useState('/edge/data-planes/production/services');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Service>[] = [\n { accessorKey: 'name', header: 'Service', size: 200 },\n { accessorKey: 'nodes', header: 'Nodes', size: 120 },\n { accessorKey: 'upstream', header: 'Upstream', size: 280 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-services' fullSize fixedHeight data-testid='services-page'>\n <PageHeader>\n <PageTitle>Services</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Services'\n data={SERVICES}\n columns={columns}\n data-testid='services-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nLoading: rail and header skeletons, product panel rows and a content loader.\n\nWhile the host loads the user and the product list, skeletons sit where the header actions\nand rail items land. While a product loads, its panel shows skeleton rows under the product\nname and its content shows a loader.\n\n```tsx story=Loading\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Home as HomeIcon } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailItem, NavRailSeparator, NavRailSkeleton } from '@wallarm-org/design-system/NavRail';\nimport { RemoteShell, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { Skeleton } from '@wallarm-org/design-system/Skeleton';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Loading = () => (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/'>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Skeleton width='120px' height='20px' rounded={6} />\n <TopHeaderSeparator />\n <Skeleton width='60px' height='20px' rounded={6} />\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem href='/' icon={HomeIcon} label='Home' shortcut={['G', 'H']} />\n <NavRailSeparator />\n <NavRailSkeleton />\n </NavRailBody>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname='/edge'\n onNavigate={onNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel isLoading />\n <RemoteShellContent isLoading />\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n);\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Cold start | Splash, then the shell | `SplashScreen`, `AppShell reveal` |\n| Host data loading | Skeletons where header actions and rail items land | `Skeleton`, `NavRailSkeleton` |\n| Product bundle loading | Splash in `AppShellRemote` | `SplashScreen` |\n| Product data loading | Product name and skeleton rows in the panel; loader in content | `RemoteShellPanel isLoading`, `RemoteShellContent isLoading` |\n| Page data loading | Real `PageHeader`, skeletons in content | `Table isLoading` |\n| Page or request failed | Shell stays; your message in `PageContent` | App-rendered |\n| Product bundle failed | Shell stays; your message in `AppShellRemote` | App-rendered |\n| Unknown URL, server down, offline, no tenant access | Replace the whole shell | `UtilityPage` |\n| No access to one product or page | Leave it out of the rail or `NavConfig` | `NavConfig` |\n\n## What your app owns\n\n| Concern | Rule |\n| --- | --- |\n| Router wiring | `pathname` from the router, `onNavigate={to => navigate({ to })}` |\n| Rail mode | Store the user's preference; \"adaptive\" (expanded on Home, collapsed in a product) is your logic |\n| Product list | From the microfrontends API; rail items with `href`, `label`, `icon` and a `shortcut` chord |\n| Recent and account menus | Your `DropdownMenu` compositions |\n| Notifications | Data, unread count, mark-read logic, the preferences page |\n| Permissions | Leave forbidden products and entries out of the rail and `NavConfig` |\n| Errors | An error boundary in `AppShellRemote` and in the product's content |\n| Prefetch on hover | `onPointerOver` and `onFocus` on `RemoteShellPanel` |\n| Panel width | `resizable` keeps it in memory only; it resets on remount |\n| Drill labels | `entities` are static; pass fresh ones in `NavConfig` when data changes |\n| Test IDs | Panel items and crumbs have none; use roles inside `{shell}--panel` and `{shell}--breadcrumb` |\n\n## Related\n\n- [Spacing](../foundations/spacing.md)\n- [Layering](../foundations/layering.md)\n- [Resource list page](./resource-list-page.md)\n- [Settings page](./settings-page.md)\n- [Empty states](./empty-states.md)\n- [Auth page](./auth-page.md)",
495
+ "sections": [
496
+ {
497
+ "id": "when-to-use",
498
+ "heading": "When to use",
499
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| The console frame: top bar, product rail, content surface | `AppShell` › `AppShellHeader` + `AppShellRail` + `AppShellRemote`, rendered once by the host | An `AppShell` per page, or a hand-made grid |\n| A product with its own menu | `RemoteShell config={navConfig}` › `RemoteShellPanel` + `RemoteShellBreadcrumb` + `RemoteShellContent` › `Page` | Hand-built panel markup, or `AppShell` in the product |\n| A product inside a host group | Content only; the host wraps the group in one `RemoteShell` | A second `RemoteShell` in the product |\n| A host-local section (Settings) | `RemoteShell` in the host route, with its own `NavConfig` | A remote for it |\n| A product with one page | `RemoteShell` without `RemoteShellPanel`, or content only | An empty `NavConfig` |\n| Home or a cross-product page | `Page` directly in `AppShellRemote` | `RemoteShell` with an empty config |\n| The app cannot render (unknown URL, 500, offline, no tenant access) | `UtilityPage` instead of the shell | `UtilityPage` inside `AppShellRemote` |\n| Cold start | `SplashScreen`, then `<AppShell reveal>` | A `Loader` on a blank page |\n\nPick the `NavConfig` node by what the entry does:\n\n| Entry | Node | Not |\n| --- | --- | --- |\n| A page | `type: 'link'` with `path` (a bare segment, no slash) | A `group` with one child |\n| Nested pages with no page of their own | `type: 'group'`; `defaultExpanded` on the one holding the usual page | `drill` |\n| Pick an entity, then its sub-pages | `type: 'drill'` with `param`, `entities` and `children` | A `Select` in the page header |\n| A label over a run of entries | `type: 'section-header'` | A disabled link |\n| A line between entries | `dividerAfter: true` on the entry above | An empty link |"
500
+ },
501
+ {
502
+ "id": "rules",
503
+ "heading": "Rules",
504
+ "markdown": "**Ownership**\n\n- The host renders `AppShell` once at the root. It owns the product list in `NavRail` and knows nothing about a product's routes.\n- The product owns its `NavConfig` (next to its routes) and renders its own `RemoteShell`. **Never** render `AppShell`, `TopHeader` or `NavRail` in a product.\n- A product in a host group renders content only. The host draws the group `RemoteShell` for two or more children.\n\n**Header and rail footer**\n\n- Settings is a `NavRailItem` in `NavRailFooter`, never in the product panel or top header.\n- The user menu is the last `NavRailFooter` item: an `avatar` `NavRailItem` in `DropdownMenuTrigger`.\n- Notifications are a `NotificationCenter` in `TopHeaderActions`, never a hand-made bell and popover.\n- `TopHeaderActions` order: search, `TopHeaderSeparator`, tenant switcher, `ChatTrigger`, `NotificationCenter`, help.\n\n**URL is the only state**\n\n- Pass the router's pathname to `pathname` and `onNavigate` to the router, always together.\n- If the router has its own `basepath`, omit `basePath` on `RemoteShell`.\n- The active item, drill level and trail come from `pathname`. **Never** keep them in state.\n- Exactly one `NavRailItem` is `active`, from the first URL segment: Home on `/`, the product on `/edge/…`, Settings (in `NavRailFooter`) on `/settings/…`.\n\n**Sizes (built in; do not override)**\n\n- Top header 36px. Rail 48px collapsed, 184px expanded, 78px compact. Product panel 216px, 164–256px with `resizable`. Rail and panel items 32px.\n- `AppShellRemote` owns the rounded surface and border. **Never** restyle it or add a `w-*` to the shell regions.\n- Page gutters are 24px, owned by `Page` parts. **Never** pad `RemoteShellContent` or `AppShellRemote`.\n\n**Scroll**\n\n- The shell is `h-screen`. **Never** set `overflow` or `height` on `html` or `body`.\n- The rail, the panel and `RemoteShellContent` scroll on their own. A table page uses `<Page fullSize fixedHeight>` so only `PageContent` scrolls.\n\n**Breadcrumbs**\n\n- In a product, the trail is `RemoteShellBreadcrumb`: product, groups as text, drills as a scope switcher, current page. **Never** also put `Breadcrumbs` in `PageHeader`.\n- `RemoteShellBreadcrumb` ends at the last `NavConfig` entry. Past it (a record name), render your own `Breadcrumbs` from `useRemoteShellContext()`; a grouped product reports them through `@wallarm/sdk` `onBreadcrumbChange`.\n- Trail-row controls are `RemoteShellBreadcrumb` children.\n- Home and cross-product pages have no trail.\n\n**Layering**\n\n- In-shell sticky layers are `z-10`; overlays portal above the shell. **Never** add `z-*` to a shell region. See [Layering](../foundations/layering.md)."
505
+ },
506
+ {
507
+ "id": "examples",
508
+ "heading": "Examples",
509
+ "markdown": "A standalone product: header actions, Settings and the user menu in the rail footer, a `NavConfig` panel, trail and page.\n\nA standalone product at `/edge/api-sessions`. The host renders `AppShell` with the product\nlist in `NavRail`; the product renders `RemoteShell` with its own `NavConfig`, and its page\ngoes in `RemoteShellContent`. The panel, the active item and the trail all come from\n`pathname`.\n\n```tsx story=Default\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Default = () => {\n const [pathname, setPathname] = useState('/edge/api-sessions');\n const [railMode, setRailMode] = useState<NavRailMode>('collapsed');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n const columns: TableColumnDef<ApiSession>[] = [\n { accessorKey: 'id', header: 'Session', size: 160 },\n { accessorKey: 'application', header: 'Application', size: 240 },\n { accessorKey: 'requests', header: 'Requests', size: 120 },\n { accessorKey: 'started', header: 'Started', size: 200 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-api-sessions' fullSize fixedHeight data-testid='sessions-page'>\n <PageHeader>\n <PageTitle>API Sessions</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='API sessions'\n data={API_SESSIONS}\n columns={columns}\n data-testid='sessions-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nHome: a page straight in `AppShellRemote`, expanded rail, no panel.\n\nHome is a host page, not a product: `Page` goes straight into `AppShellRemote`, with no\n`RemoteShell`, no panel and no breadcrumbs. The rail is expanded at the top level.\n\n```tsx story=Home\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Home = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nNotifications, open from the header bell.\n\nThe notifications inbox open from the bell in `TopHeaderActions`: unread count on the bell,\nNew and Read tabs. The app owns the list and the unread count.\n\n```tsx story=NotificationsOpen\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const NotificationsOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [notificationsOpen, setNotificationsOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter\n data-testid='notifications'\n open={notificationsOpen}\n onOpenChange={setNotificationsOpen}\n >\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nThe user menu, open from the rail's avatar item.\n\nThe user menu open from the avatar item at the foot of `NavRailFooter`: profile settings,\nappearance, sidebar mode and sign out.\n\n```tsx story=AccountMenuOpen\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const AccountMenuOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [accountMenuOpen, setAccountMenuOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n open={accountMenuOpen}\n onOpenChange={setAccountMenuOpen}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nA product inside a host group: the host's group shell around the product's page.\n\nA product inside a host group. The host renders one group `RemoteShell` with a link per child\nproduct; the child renders its page only, with no shell of its own.\n\n```tsx story=GroupedProduct\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const GroupedProduct = () => {\n const [pathname, setPathname] = useState('/security-testing/threat-replay');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<ReplayRun>[] = [\n { accessorKey: 'target', header: 'Target', size: 240 },\n { accessorKey: 'attacks', header: 'Replayed attacks', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'security-testing'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SECURITY_TESTING_GROUP}\n basePath='/security-testing'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='group-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='threat-replay' fullSize fixedHeight data-testid='replay-page'>\n <PageHeader>\n <PageTitle>Threat Replay</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Replay runs'\n data={REPLAY_RUNS}\n columns={columns}\n data-testid='replay-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nSettings: a host route rendering `RemoteShell`, with no breadcrumbs in `PageHeader`.\n\nSettings is a host route that renders `RemoteShell` itself. The trail comes from\n`RemoteShellBreadcrumb`, so `PageHeader` holds no `Breadcrumbs`.\n\n```tsx story=SettingsSection\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings, Users } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const SettingsSection = () => {\n const [pathname, setPathname] = useState('/settings/users');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'Name', size: 200 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SETTINGS_NAV}\n basePath='/settings'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='settings-shell'\n >\n <RemoteShellPanel />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser}>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Users'\n data={MEMBERS}\n columns={columns}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nA `drill` node: the entity level, the back button and the scope switcher.\n\nA `drill` node at `/edge/data-planes/production/services`: the panel shows the entity level\nwith a back button, and the trail gets a scope switcher for the entity.\n\n```tsx story=DrillDown\nimport { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const DrillDown = () => {\n const [pathname, setPathname] = useState('/edge/data-planes/production/services');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Service>[] = [\n { accessorKey: 'name', header: 'Service', size: 200 },\n { accessorKey: 'nodes', header: 'Nodes', size: 120 },\n { accessorKey: 'upstream', header: 'Upstream', size: 280 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-services' fullSize fixedHeight data-testid='services-page'>\n <PageHeader>\n <PageTitle>Services</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Services'\n data={SERVICES}\n columns={columns}\n data-testid='services-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};\n```\n\nLoading: rail and header skeletons, product panel rows and a content loader.\n\nWhile the host loads the user and the product list, skeletons sit where the header actions\nand rail items land. While a product loads, its panel shows skeleton rows under the product\nname and its content shows a loader.\n\n```tsx story=Loading\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Home as HomeIcon } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailItem, NavRailSeparator, NavRailSkeleton } from '@wallarm-org/design-system/NavRail';\nimport { RemoteShell, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { Skeleton } from '@wallarm-org/design-system/Skeleton';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Loading = () => (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/'>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Skeleton width='120px' height='20px' rounded={6} />\n <TopHeaderSeparator />\n <Skeleton width='60px' height='20px' rounded={6} />\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem href='/' icon={HomeIcon} label='Home' shortcut={['G', 'H']} />\n <NavRailSeparator />\n <NavRailSkeleton />\n </NavRailBody>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname='/edge'\n onNavigate={onNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel isLoading />\n <RemoteShellContent isLoading />\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n);\n```"
510
+ },
511
+ {
512
+ "id": "states",
513
+ "heading": "States",
514
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Cold start | Splash, then the shell | `SplashScreen`, `AppShell reveal` |\n| Host data loading | Skeletons where header actions and rail items land | `Skeleton`, `NavRailSkeleton` |\n| Product bundle loading | Splash in `AppShellRemote` | `SplashScreen` |\n| Product data loading | Product name and skeleton rows in the panel; loader in content | `RemoteShellPanel isLoading`, `RemoteShellContent isLoading` |\n| Page data loading | Real `PageHeader`, skeletons in content | `Table isLoading` |\n| Page or request failed | Shell stays; your message in `PageContent` | App-rendered |\n| Product bundle failed | Shell stays; your message in `AppShellRemote` | App-rendered |\n| Unknown URL, server down, offline, no tenant access | Replace the whole shell | `UtilityPage` |\n| No access to one product or page | Leave it out of the rail or `NavConfig` | `NavConfig` |"
515
+ },
516
+ {
517
+ "id": "what-your-app-owns",
518
+ "heading": "What your app owns",
519
+ "markdown": "| Concern | Rule |\n| --- | --- |\n| Router wiring | `pathname` from the router, `onNavigate={to => navigate({ to })}` |\n| Rail mode | Store the user's preference; \"adaptive\" (expanded on Home, collapsed in a product) is your logic |\n| Product list | From the microfrontends API; rail items with `href`, `label`, `icon` and a `shortcut` chord |\n| Recent and account menus | Your `DropdownMenu` compositions |\n| Notifications | Data, unread count, mark-read logic, the preferences page |\n| Permissions | Leave forbidden products and entries out of the rail and `NavConfig` |\n| Errors | An error boundary in `AppShellRemote` and in the product's content |\n| Prefetch on hover | `onPointerOver` and `onFocus` on `RemoteShellPanel` |\n| Panel width | `resizable` keeps it in memory only; it resets on remount |\n| Drill labels | `entities` are static; pass fresh ones in `NavConfig` when data changes |\n| Test IDs | Panel items and crumbs have none; use roles inside `{shell}--panel` and `{shell}--breadcrumb` |"
520
+ },
521
+ {
522
+ "id": "related",
523
+ "heading": "Related",
524
+ "markdown": "- [Spacing](../foundations/spacing.md)\n- [Layering](../foundations/layering.md)\n- [Resource list page](./resource-list-page.md)\n- [Settings page](./settings-page.md)\n- [Empty states](./empty-states.md)\n- [Auth page](./auth-page.md)"
525
+ }
526
+ ],
527
+ "stories": [
528
+ {
529
+ "name": "Default",
530
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Default = () => {\n const [pathname, setPathname] = useState('/edge/api-sessions');\n const [railMode, setRailMode] = useState<NavRailMode>('collapsed');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n const columns: TableColumnDef<ApiSession>[] = [\n { accessorKey: 'id', header: 'Session', size: 160 },\n { accessorKey: 'application', header: 'Application', size: 240 },\n { accessorKey: 'requests', header: 'Requests', size: 120 },\n { accessorKey: 'started', header: 'Started', size: 200 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-api-sessions' fullSize fixedHeight data-testid='sessions-page'>\n <PageHeader>\n <PageTitle>API Sessions</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='API sessions'\n data={API_SESSIONS}\n columns={columns}\n data-testid='sessions-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};",
531
+ "description": "A standalone product at `/edge/api-sessions`. The host renders `AppShell` with the product\nlist in `NavRail`; the product renders `RemoteShell` with its own `NavConfig`, and its page\ngoes in `RemoteShellContent`. The panel, the active item and the trail all come from\n`pathname`."
532
+ },
533
+ {
534
+ "name": "Home",
535
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Home = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};",
536
+ "description": "Home is a host page, not a product: `Page` goes straight into `AppShellRemote`, with no\n`RemoteShell`, no panel and no breadcrumbs. The rail is expanded at the top level."
537
+ },
538
+ {
539
+ "name": "NotificationsOpen",
540
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const NotificationsOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [notificationsOpen, setNotificationsOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter\n data-testid='notifications'\n open={notificationsOpen}\n onOpenChange={setNotificationsOpen}\n >\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};",
541
+ "description": "The notifications inbox open from the bell in `TopHeaderActions`: unread count on the bell,\nNew and Read tabs. The app owns the list and the unread count."
542
+ },
543
+ {
544
+ "name": "AccountMenuOpen",
545
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChatTrigger } from '@wallarm-org/design-system/Chat';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIcon, DropdownMenuItemText, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuTriggerItem } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, LogOut, Paintbrush, PanelLeftDashed, Search, Settings, UserRound } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, type NavRailMode, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { NotificationCenter, NotificationCenterBody, NotificationCenterContent, NotificationCenterHeader, NotificationCenterHeaderAction, NotificationCenterHeaderActions, NotificationCenterItem, NotificationCenterItemDescription, NotificationCenterItemTime, NotificationCenterItemTitle, NotificationCenterList, NotificationCenterTabs, NotificationCenterTabsList, NotificationCenterTitle, NotificationCenterTrigger } from '@wallarm-org/design-system/NotificationCenter';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { TabsContent, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useTheme } from '@wallarm-org/design-system/ThemeProvider';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const AccountMenuOpen = () => {\n const [railMode, setRailMode] = useState<NavRailMode>('expanded');\n const [accountMenuOpen, setAccountMenuOpen] = useState(true);\n const { theme, setTheme } = useTheme();\n const unread = NOTIFICATIONS.filter(item => item.unread);\n const read = NOTIFICATIONS.filter(item => !item.unread);\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleRailModeChange = ({ value }: { value: string }) => {\n const next = RAIL_MODES.find(mode => mode === value);\n if (next) setRailMode(next);\n };\n const handleThemeChange = ({ value }: { value: string }) => {\n const next = THEMES.find(option => option === value);\n if (next) setTheme(next);\n };\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small' aria-label='Search'>\n <Search />\n </Button>\n <TopHeaderSeparator />\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <ChatTrigger onClick={onOpenAssistant} />\n <NotificationCenter data-testid='notifications'>\n <NotificationCenterTrigger count={unread.length} />\n <NotificationCenterContent>\n <NotificationCenterHeader>\n <NotificationCenterTitle />\n <NotificationCenterHeaderActions>\n <NotificationCenterHeaderAction onClick={onMarkAllRead}>\n Mark all as read\n </NotificationCenterHeaderAction>\n <NotificationCenterHeaderAction asChild aria-label='Notification settings'>\n <a href='/settings/notifications' onClick={handleRailClick}>\n <Settings />\n </a>\n </NotificationCenterHeaderAction>\n </NotificationCenterHeaderActions>\n </NotificationCenterHeader>\n <NotificationCenterTabs defaultValue='new'>\n <NotificationCenterTabsList>\n <TabsTrigger value='new'>\n New <NumericBadge size='small'>{unread.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </NotificationCenterTabsList>\n <TabsContent value='new'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {unread.map(item => (\n <NotificationCenterItem key={item.id} unread>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n <TabsContent value='read'>\n <NotificationCenterBody>\n <NotificationCenterList>\n {read.map(item => (\n <NotificationCenterItem key={item.id}>\n <NotificationCenterItemTitle onClick={onOpenNotification}>\n {item.title}\n </NotificationCenterItemTitle>\n <NotificationCenterItemTime value={item.createdAt} />\n <NotificationCenterItemDescription>\n {item.description}\n </NotificationCenterItemDescription>\n </NotificationCenterItem>\n ))}\n </NotificationCenterList>\n </NotificationCenterBody>\n </TabsContent>\n </NotificationCenterTabs>\n </NotificationCenterContent>\n </NotificationCenter>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode={railMode} data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n <DropdownMenu\n data-testid='account-menu'\n positioning={{ placement: 'right-end', gutter: 6, offset: { crossAxis: 12 } }}\n open={accountMenuOpen}\n onOpenChange={setAccountMenuOpen}\n >\n <DropdownMenuTrigger>\n <NavRailItem\n icon={UserRound}\n label='Maria Lopez'\n avatar\n data-testid='nav-rail-account'\n />\n </DropdownMenuTrigger>\n <DropdownMenuContent className='w-200'>\n <DropdownMenuItem onSelect={onOpenProfile}>\n <DropdownMenuItemIcon>\n <Settings />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Profile settings</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <Paintbrush />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Appearance</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>\n {THEMES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{THEME_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenu>\n <DropdownMenuTriggerItem>\n <DropdownMenuItemIcon>\n <PanelLeftDashed />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sidebar mode</DropdownMenuItemText>\n </DropdownMenuTriggerItem>\n <DropdownMenuContent>\n <DropdownMenuRadioGroup value={railMode} onValueChange={handleRailModeChange}>\n {RAIL_MODES.map(option => (\n <DropdownMenuRadioItem key={option} value={option}>\n <DropdownMenuItemText>{RAIL_MODE_LABELS[option]}</DropdownMenuItemText>\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n <DropdownMenuSeparator />\n <DropdownMenuItem onSelect={onSignOut}>\n <DropdownMenuItemIcon>\n <LogOut />\n </DropdownMenuItemIcon>\n <DropdownMenuItemText>Sign out</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <Page name='home' data-testid='home-page'>\n <PageHeader>\n <PageTitle>Good morning, Maria</PageTitle>\n </PageHeader>\n <PageContent>\n <Text size='sm' color='secondary'>\n 3 new incidents and 1 node update since your last visit.\n </Text>\n </PageContent>\n </Page>\n </AppShellRemote>\n </AppShell>\n );\n};",
546
+ "description": "The user menu open from the avatar item at the foot of `NavRailFooter`: profile settings,\nappearance, sidebar mode and sign out."
547
+ },
548
+ {
549
+ "name": "GroupedProduct",
550
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const GroupedProduct = () => {\n const [pathname, setPathname] = useState('/security-testing/threat-replay');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<ReplayRun>[] = [\n { accessorKey: 'target', header: 'Target', size: 240 },\n { accessorKey: 'attacks', header: 'Replayed attacks', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'security-testing'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SECURITY_TESTING_GROUP}\n basePath='/security-testing'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='group-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='threat-replay' fullSize fixedHeight data-testid='replay-page'>\n <PageHeader>\n <PageTitle>Threat Replay</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Replay runs'\n data={REPLAY_RUNS}\n columns={columns}\n data-testid='replay-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};",
551
+ "description": "A product inside a host group. The host renders one group `RemoteShell` with a link per child\nproduct; the child renders its page only, with no shell of its own."
552
+ },
553
+ {
554
+ "name": "SettingsSection",
555
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings, Users } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const SettingsSection = () => {\n const [pathname, setPathname] = useState('/settings/users');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'Name', size: 200 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 160 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n active\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={SETTINGS_NAV}\n basePath='/settings'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='settings-shell'\n >\n <RemoteShellPanel />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser}>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Users'\n data={MEMBERS}\n columns={columns}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};",
556
+ "description": "Settings is a host route that renders `RemoteShell` itself. The trail comes from\n`RemoteShellBreadcrumb`, so `PageHeader` holds no `Breadcrumbs`."
557
+ },
558
+ {
559
+ "name": "DrillDown",
560
+ "code": "import { type MouseEvent, useState } from 'react';\nimport { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { ChevronUpDown, CircleHelp, History, Home as HomeIcon, Settings } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailFooter, NavRailItem, NavRailSeparator } from '@wallarm-org/design-system/NavRail';\nimport { Page, PageContent, PageHeader, PageTitle } from '@wallarm-org/design-system/Page';\nimport { RemoteShell, RemoteShellBreadcrumb, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo } from '@wallarm-org/design-system/TopHeader';\n\nexport const DrillDown = () => {\n const [pathname, setPathname] = useState('/edge/data-planes/production/services');\n\n const handleRailClick = (event: MouseEvent<HTMLAnchorElement>) => {\n event.preventDefault();\n onRailNavigate(event.currentTarget.getAttribute('href'));\n };\n const handleNavigate = (to: string) => {\n onNavigate(to);\n setPathname(to);\n };\n\n const columns: TableColumnDef<Service>[] = [\n { accessorKey: 'name', header: 'Service', size: 200 },\n { accessorKey: 'nodes', header: 'Nodes', size: 120 },\n { accessorKey: 'upstream', header: 'Upstream', size: 280 },\n ];\n\n return (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/' onClick={handleRailClick}>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Button variant='ghost' color='neutral' size='small'>\n Acme Bank\n <ChevronUpDown />\n </Button>\n <Button variant='ghost' color='neutral' size='small' aria-label='Help'>\n <CircleHelp />\n </Button>\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem\n href='/'\n icon={HomeIcon}\n label='Home'\n shortcut={['G', 'H']}\n onClick={handleRailClick}\n data-testid='nav-rail-home'\n />\n <NavRailItem href='/recent' icon={History} label='Recent' onClick={handleRailClick} />\n <NavRailSeparator />\n {PRODUCTS.map(product => (\n <NavRailItem\n key={product.id}\n href={`/${product.id}`}\n icon={product.icon}\n label={product.label}\n shortcut={product.shortcut}\n active={product.id === 'edge'}\n onClick={handleRailClick}\n data-testid={`nav-rail-${product.id}`}\n />\n ))}\n </NavRailBody>\n <NavRailFooter>\n <NavRailItem\n href='/settings'\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n onClick={handleRailClick}\n data-testid='nav-rail-settings'\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname={pathname}\n onNavigate={handleNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel resizable />\n <RemoteShellBreadcrumb />\n <RemoteShellContent>\n <Page name='edge-services' fullSize fixedHeight data-testid='services-page'>\n <PageHeader>\n <PageTitle>Services</PageTitle>\n </PageHeader>\n <PageContent>\n <Table\n aria-label='Services'\n data={SERVICES}\n columns={columns}\n data-testid='services-table'\n />\n </PageContent>\n </Page>\n </RemoteShellContent>\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n );\n};",
561
+ "description": "A `drill` node at `/edge/data-planes/production/services`: the panel shows the entity level\nwith a back button, and the trail gets a scope switcher for the entity."
562
+ },
563
+ {
564
+ "name": "Loading",
565
+ "code": "import { AppShell, AppShellHeader, AppShellRail, AppShellRemote } from '@wallarm-org/design-system/AppShell';\nimport { Home as HomeIcon } from '@wallarm-org/design-system/icons';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { NavRail, NavRailBody, NavRailItem, NavRailSeparator, NavRailSkeleton } from '@wallarm-org/design-system/NavRail';\nimport { RemoteShell, RemoteShellContent, RemoteShellPanel } from '@wallarm-org/design-system/RemoteShell';\nimport { Skeleton } from '@wallarm-org/design-system/Skeleton';\nimport { TopHeader, TopHeaderActions, TopHeaderLogo, TopHeaderSeparator } from '@wallarm-org/design-system/TopHeader';\n\nexport const Loading = () => (\n <AppShell data-testid='app-shell'>\n <AppShellHeader>\n <TopHeader data-testid='top-header'>\n <TopHeaderLogo href='/'>\n <Logo size='md' />\n </TopHeaderLogo>\n <TopHeaderActions>\n <Skeleton width='120px' height='20px' rounded={6} />\n <TopHeaderSeparator />\n <Skeleton width='60px' height='20px' rounded={6} />\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail mode='collapsed' data-testid='nav-rail'>\n <NavRailBody>\n <NavRailItem href='/' icon={HomeIcon} label='Home' shortcut={['G', 'H']} />\n <NavRailSeparator />\n <NavRailSkeleton />\n </NavRailBody>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote role='main'>\n <RemoteShell\n config={EDGE_NAV}\n basePath='/edge'\n pathname='/edge'\n onNavigate={onNavigate}\n data-testid='product-shell'\n >\n <RemoteShellPanel isLoading />\n <RemoteShellContent isLoading />\n </RemoteShell>\n </AppShellRemote>\n </AppShell>\n);",
566
+ "description": "While the host loads the user and the product list, skeletons sit where the header actions\nand rail items land. While a product loads, its panel shows skeleton rows under the product\nname and its content shows a loader."
567
+ }
568
+ ]
569
+ },
570
+ {
571
+ "slug": "patterns/resource-list-page",
572
+ "kind": "pattern",
573
+ "title": "Resource list page",
574
+ "description": "Use when building a page that lists one kind of entity (users, roles, SSO providers, channels) with a create action, search, status tabs and a table.",
575
+ "components": [
576
+ "Page",
577
+ "PageHeader",
578
+ "PageHeaderGroup",
579
+ "PageTitle",
580
+ "PageDescription",
581
+ "PageActions",
582
+ "PageToolbar",
583
+ "PageContent",
584
+ "RemoteShellBreadcrumb",
585
+ "Button",
586
+ "SearchInput",
587
+ "SegmentedTabs",
588
+ "SegmentedTabsList",
589
+ "SegmentedTabsTrigger",
590
+ "NumericBadge",
591
+ "Table",
592
+ "TableEmptyState",
593
+ "EmptyState",
594
+ "EmptyStateIllustration",
595
+ "EmptyStateMessage",
596
+ "EmptyStateTitle",
597
+ "EmptyStateDescription",
598
+ "EmptyStateActions"
599
+ ],
600
+ "keywords": [
601
+ "list page",
602
+ "index page",
603
+ "resource list",
604
+ "entity list",
605
+ "collection page",
606
+ "settings list",
607
+ "page header",
608
+ "breadcrumbs",
609
+ "toolbar",
610
+ "search",
611
+ "status tabs",
612
+ "segmented tabs",
613
+ "counts",
614
+ "create button",
615
+ "table page",
616
+ "CRUD"
617
+ ],
618
+ "order": 10,
619
+ "markdown": "# Resource list page\n\nA resource list page is `Page` with a header, a filter toolbar and one `Table`. All parts import from `@wallarm-org/design-system`.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| A list of one entity with create, search and a status filter | This pattern | A `Card` grid, a hand-built `<table>` |\n| A list with no create action and no filter | `Page`, `PageHeader`, `PageContent`; drop the rest | A bare `Table` with a custom heading |\n| One record with its fields | [Detail drawer](./detail-drawer.md) | A one-row table |\n| Many filter dimensions, chips, scope | [Filtering](./filtering.md) in `PageToolbar` | Several `SegmentedTabs` side by side |\n| Column, cell, row menu and bulk bar rules | [Entity table](./entity-table.md) | Rules from this page |\n| Shell, rails, product menu, breadcrumbs | [Application layout](./app-layout.md) | Rendering them inside the page |\n\n## Rules\n\nSpacing counts in pixels: `px-24` is 24px. See [Spacing](../foundations/spacing.md).\n\n**Frame and spacing**\n\n- If the page lists rows, use `<Page fullSize fixedHeight>`. The body scrolls; header and toolbar stay put.\n- `PageHeader`, `PageToolbar` and `PageContent` own the 24px side gutter. **Never** wrap `Page` or them in a padded container, and **never** add `px-*`, `py-*` or `gap-*` to `PageToolbar` or `PageContent`.\n- Toolbar to table is 12px, and search to tabs is 12px. Both are automatic; add nothing.\n- Put `Table` straight into `PageContent`. It aligns to the gutter.\n- **Never** put the search or the status tabs inside `PageHeader` or `PageContent`. They go in `PageToolbar`.\n\n**Header**\n\n- The trail (\"Settings › Users\") is `RemoteShellBreadcrumb`, above the page; see [Application layout](./app-layout.md). **Never** put `Breadcrumbs` in `PageHeader`.\n- The title repeats the last crumb.\n- Give `PageHeaderGroup` `className='flex-1'` so a long description never pushes the buttons down.\n- Write `PageDescription` as one plain sentence: what the entity is and how it relates to sibling pages.\n- Show one primary button in `PageActions`, labelled \"Create\" plus the entity: \"Create user\".\n- Use `Button size='medium'` (32px) in `PageActions`. The default `large` is 36px.\n- If there is a code-first alternative (\"Edit as code\"), make it `variant='secondary'` in the same `PageActions`.\n\n**Toolbar**\n\n- Give `SearchInput` `className='w-[320px]'` and a placeholder naming the searched fields: \"Search name or email\". Keep the default size.\n- `SearchInput` has a built-in clear button that calls `onChange('')`. **Never** add a separate Clear button.\n- Use `SegmentedTabs` with `value` and `onChange(value: string)` as the status filter. It has no `onValueChange`.\n- Put the count in a `NumericBadge` inside each `SegmentedTabsTrigger`, after the label. Recompute counts when data changes.\n- **Never** add `SegmentedTabsContent` for a filter. The tabs change `data`; the one `Table` stays.\n- If you need filters beyond the one status filter, use `FilterInput` or [Filtering](./filtering.md).\n\n**Table**\n\n- Pass `getRowId` so selection and row state survive refetches.\n- **Never** use `meta.renderMenuAction` for entity actions you expect to be always visible: its `…` shows only on row hover, an open menu or preview. Add a trailing actions column; see [Entity table](./entity-table.md).\n\n**What does not exist**\n\nThere is no breadcrumbs prop or slot on `Page` or `PageHeader`, no `PageBreadcrumbs`, no `size` prop on `PageTitle`, no `className` on `SegmentedTabs`, and no `DRAWER_SIZES` or `DIALOG_SIZES` export.\n\n## Examples\n\nThe page frame: a fixed-height `Page` with the table directly in `PageContent`.\n\nThe whole page: title, description and one create action in the header; search\nand status tabs with counts in the toolbar; a user table with a status badge and an\nalways-visible actions column. Search and tabs filter the rows.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='date' />\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n deactivated: matching.filter(user => user.status === 'deactivated').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='deactivated'>\n Deactivated <NumericBadge>{counts.deactivated}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA header with a title block and one create action.\n\nThe title repeats the last crumb of the product's trail; one create action sits on the right.\n\n```tsx story=Header\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\n\nexport const Header = () => (\n <Page name='settings-users' data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n);\n```\n\nA toolbar with search and status tabs that carry counts.\n\nSearch clears with its built-in button; the tabs filter the one table, with no tab panels.\n\n```tsx story=Toolbar\nimport { useState } from 'react';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\n\nexport const Toolbar = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const counts = {\n all: USERS.length,\n active: USERS.filter(user => user.status === 'active').length,\n invited: USERS.filter(user => user.status === 'invited').length,\n };\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n </Page>\n );\n};\n```\n\nNo results: `type='collection-empty'` with a button that resets filters.\n\nSearch or a tab matched nothing: \"No results found\" with a \"Reset filters\" button that clears\nboth. The header keeps its one create action; the empty state never adds another, and never\nuses `type='no-results'` (that is for cards and menus).\n\n```tsx story=NoResults\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('contractor');\n const [status, setStatus] = useState('all');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nNothing exists yet: the same `type='collection-empty'`, with no button.\n\nNothing exists yet: the real header with its create button, the toolbar, and an empty state\nwith no button of its own — one create action per screen.\n\n```tsx story=Empty\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const [query, setQuery] = useState('');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <Users />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>Invite people so they can sign in.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| First load | Skeleton rows under a real header. **Never** a `Loader` in place of the table | `<Table data={[]} isLoading />` |\n| Loading more | Keep `data`, set `isLoading`; skeletons append below. **Never** clear `data` while fetching | `Table isLoading` |\n| Nothing exists yet | Header with its create button, toolbar, empty state with no button (one create action per screen) | `TableEmptyState` › `EmptyState type='collection-empty'` |\n| Search or tab matched nothing | \"No results found\" and a \"Reset filters\" button. **Never** a create CTA, **never** `type='no-results'` (that is for cards and menus) | `TableEmptyState` › `EmptyState type='collection-empty'` |\n| Search active | The typed value stays in the field | `SearchInput` |\n| Request failed | Your own message; the table has no error state | App-rendered, in `PageContent` |\n\n## What your app owns\n\n| Concern | Recipe |\n| --- | --- |\n| Shell and rails | The page renders `Page` only, inside `RemoteShellContent`. See [Application layout](./app-layout.md) |\n| Fetching, search, status filter | Pass the filtered array as `data`; set `isLoading` while fetching |\n| Status counts | Compute from the server or the full array |\n| Create flow | Short form: `Dialog` from `@wallarm-org/design-system/Dialog`. Long form: `Drawer`. See [Create and edit forms](./create-edit-forms.md) |\n| Detail drawer width | Presets are 640, 960 and 1130. Figma uses 700; pass `width={640}` unless design confirms 700. See [Detail drawer](./detail-drawer.md) |\n| URL state | Sync `query` and `status` to the URL yourself; there is no router integration |\n| Error state | Render your own message in `PageContent` |\n\n## Related\n\n- [Application layout](./app-layout.md)\n- [Spacing](../foundations/spacing.md)\n- [Entity table](./entity-table.md)\n- [Filtering](./filtering.md)\n- [Empty states](./empty-states.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Detail drawer](./detail-drawer.md)\n- [Confirmation dialogs](./confirmation-dialogs.md)",
620
+ "sections": [
621
+ {
622
+ "id": "when-to-use",
623
+ "heading": "When to use",
624
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| A list of one entity with create, search and a status filter | This pattern | A `Card` grid, a hand-built `<table>` |\n| A list with no create action and no filter | `Page`, `PageHeader`, `PageContent`; drop the rest | A bare `Table` with a custom heading |\n| One record with its fields | [Detail drawer](./detail-drawer.md) | A one-row table |\n| Many filter dimensions, chips, scope | [Filtering](./filtering.md) in `PageToolbar` | Several `SegmentedTabs` side by side |\n| Column, cell, row menu and bulk bar rules | [Entity table](./entity-table.md) | Rules from this page |\n| Shell, rails, product menu, breadcrumbs | [Application layout](./app-layout.md) | Rendering them inside the page |"
625
+ },
626
+ {
627
+ "id": "rules",
628
+ "heading": "Rules",
629
+ "markdown": "Spacing counts in pixels: `px-24` is 24px. See [Spacing](../foundations/spacing.md).\n\n**Frame and spacing**\n\n- If the page lists rows, use `<Page fullSize fixedHeight>`. The body scrolls; header and toolbar stay put.\n- `PageHeader`, `PageToolbar` and `PageContent` own the 24px side gutter. **Never** wrap `Page` or them in a padded container, and **never** add `px-*`, `py-*` or `gap-*` to `PageToolbar` or `PageContent`.\n- Toolbar to table is 12px, and search to tabs is 12px. Both are automatic; add nothing.\n- Put `Table` straight into `PageContent`. It aligns to the gutter.\n- **Never** put the search or the status tabs inside `PageHeader` or `PageContent`. They go in `PageToolbar`.\n\n**Header**\n\n- The trail (\"Settings › Users\") is `RemoteShellBreadcrumb`, above the page; see [Application layout](./app-layout.md). **Never** put `Breadcrumbs` in `PageHeader`.\n- The title repeats the last crumb.\n- Give `PageHeaderGroup` `className='flex-1'` so a long description never pushes the buttons down.\n- Write `PageDescription` as one plain sentence: what the entity is and how it relates to sibling pages.\n- Show one primary button in `PageActions`, labelled \"Create\" plus the entity: \"Create user\".\n- Use `Button size='medium'` (32px) in `PageActions`. The default `large` is 36px.\n- If there is a code-first alternative (\"Edit as code\"), make it `variant='secondary'` in the same `PageActions`.\n\n**Toolbar**\n\n- Give `SearchInput` `className='w-[320px]'` and a placeholder naming the searched fields: \"Search name or email\". Keep the default size.\n- `SearchInput` has a built-in clear button that calls `onChange('')`. **Never** add a separate Clear button.\n- Use `SegmentedTabs` with `value` and `onChange(value: string)` as the status filter. It has no `onValueChange`.\n- Put the count in a `NumericBadge` inside each `SegmentedTabsTrigger`, after the label. Recompute counts when data changes.\n- **Never** add `SegmentedTabsContent` for a filter. The tabs change `data`; the one `Table` stays.\n- If you need filters beyond the one status filter, use `FilterInput` or [Filtering](./filtering.md).\n\n**Table**\n\n- Pass `getRowId` so selection and row state survive refetches.\n- **Never** use `meta.renderMenuAction` for entity actions you expect to be always visible: its `…` shows only on row hover, an open menu or preview. Add a trailing actions column; see [Entity table](./entity-table.md).\n\n**What does not exist**\n\nThere is no breadcrumbs prop or slot on `Page` or `PageHeader`, no `PageBreadcrumbs`, no `size` prop on `PageTitle`, no `className` on `SegmentedTabs`, and no `DRAWER_SIZES` or `DIALOG_SIZES` export."
630
+ },
631
+ {
632
+ "id": "examples",
633
+ "heading": "Examples",
634
+ "markdown": "The page frame: a fixed-height `Page` with the table directly in `PageContent`.\n\nThe whole page: title, description and one create action in the header; search\nand status tabs with counts in the toolbar; a user table with a status badge and an\nalways-visible actions column. Search and tabs filter the rows.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='date' />\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n deactivated: matching.filter(user => user.status === 'deactivated').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='deactivated'>\n Deactivated <NumericBadge>{counts.deactivated}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA header with a title block and one create action.\n\nThe title repeats the last crumb of the product's trail; one create action sits on the right.\n\n```tsx story=Header\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\n\nexport const Header = () => (\n <Page name='settings-users' data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n);\n```\n\nA toolbar with search and status tabs that carry counts.\n\nSearch clears with its built-in button; the tabs filter the one table, with no tab panels.\n\n```tsx story=Toolbar\nimport { useState } from 'react';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\n\nexport const Toolbar = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const counts = {\n all: USERS.length,\n active: USERS.filter(user => user.status === 'active').length,\n invited: USERS.filter(user => user.status === 'invited').length,\n };\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n </Page>\n );\n};\n```\n\nNo results: `type='collection-empty'` with a button that resets filters.\n\nSearch or a tab matched nothing: \"No results found\" with a \"Reset filters\" button that clears\nboth. The header keeps its one create action; the empty state never adds another, and never\nuses `type='no-results'` (that is for cards and menus).\n\n```tsx story=NoResults\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('contractor');\n const [status, setStatus] = useState('all');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nNothing exists yet: the same `type='collection-empty'`, with no button.\n\nNothing exists yet: the real header with its create button, the toolbar, and an empty state\nwith no button of its own — one create action per screen.\n\n```tsx story=Empty\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const [query, setQuery] = useState('');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <Users />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>Invite people so they can sign in.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```"
635
+ },
636
+ {
637
+ "id": "states",
638
+ "heading": "States",
639
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| First load | Skeleton rows under a real header. **Never** a `Loader` in place of the table | `<Table data={[]} isLoading />` |\n| Loading more | Keep `data`, set `isLoading`; skeletons append below. **Never** clear `data` while fetching | `Table isLoading` |\n| Nothing exists yet | Header with its create button, toolbar, empty state with no button (one create action per screen) | `TableEmptyState` › `EmptyState type='collection-empty'` |\n| Search or tab matched nothing | \"No results found\" and a \"Reset filters\" button. **Never** a create CTA, **never** `type='no-results'` (that is for cards and menus) | `TableEmptyState` › `EmptyState type='collection-empty'` |\n| Search active | The typed value stays in the field | `SearchInput` |\n| Request failed | Your own message; the table has no error state | App-rendered, in `PageContent` |"
640
+ },
641
+ {
642
+ "id": "what-your-app-owns",
643
+ "heading": "What your app owns",
644
+ "markdown": "| Concern | Recipe |\n| --- | --- |\n| Shell and rails | The page renders `Page` only, inside `RemoteShellContent`. See [Application layout](./app-layout.md) |\n| Fetching, search, status filter | Pass the filtered array as `data`; set `isLoading` while fetching |\n| Status counts | Compute from the server or the full array |\n| Create flow | Short form: `Dialog` from `@wallarm-org/design-system/Dialog`. Long form: `Drawer`. See [Create and edit forms](./create-edit-forms.md) |\n| Detail drawer width | Presets are 640, 960 and 1130. Figma uses 700; pass `width={640}` unless design confirms 700. See [Detail drawer](./detail-drawer.md) |\n| URL state | Sync `query` and `status` to the URL yourself; there is no router integration |\n| Error state | Render your own message in `PageContent` |"
645
+ },
646
+ {
647
+ "id": "related",
648
+ "heading": "Related",
649
+ "markdown": "- [Application layout](./app-layout.md)\n- [Spacing](../foundations/spacing.md)\n- [Entity table](./entity-table.md)\n- [Filtering](./filtering.md)\n- [Empty states](./empty-states.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Detail drawer](./detail-drawer.md)\n- [Confirmation dialogs](./confirmation-dialogs.md)"
650
+ }
651
+ ],
652
+ "stories": [
653
+ {
654
+ "name": "Default",
655
+ "code": "import { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='date' />\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n deactivated: matching.filter(user => user.status === 'deactivated').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='deactivated'>\n Deactivated <NumericBadge>{counts.deactivated}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
656
+ "description": "The whole page: title, description and one create action in the header; search\nand status tabs with counts in the toolbar; a user table with a status badge and an\nalways-visible actions column. Search and tabs filter the rows."
657
+ },
658
+ {
659
+ "name": "Header",
660
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\n\nexport const Header = () => (\n <Page name='settings-users' data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n);",
661
+ "description": "The title repeats the last crumb of the product's trail; one create action sits on the right."
662
+ },
663
+ {
664
+ "name": "Toolbar",
665
+ "code": "import { useState } from 'react';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\n\nexport const Toolbar = () => {\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n\n const counts = {\n all: USERS.length,\n active: USERS.filter(user => user.status === 'active').length,\n invited: USERS.filter(user => user.status === 'invited').length,\n };\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n </Page>\n );\n};",
666
+ "description": "Search clears with its built-in button; the tabs filter the one table, with no tab panels."
667
+ },
668
+ {
669
+ "name": "NoResults",
670
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX, Users } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('contractor');\n const [status, setStatus] = useState('all');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = USERS.filter(\n user => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search),\n );\n const counts = {\n all: matching.length,\n active: matching.filter(user => user.status === 'active').length,\n invited: matching.filter(user => user.status === 'invited').length,\n };\n const rows = status === 'all' ? matching : matching.filter(user => user.status === status);\n\n const handleReset = () => {\n setQuery('');\n setStatus('all');\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n <SegmentedTabs value={status} onChange={setStatus} data-testid='users-status-tabs'>\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{counts.all}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{counts.active}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{counts.invited}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={rows}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search or status.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
671
+ "description": "Search or a tab matched nothing: \"No results found\" with a \"Reset filters\" button that clears\nboth. The header keeps its one create action; the empty state never adds another, and never\nuses `type='no-results'` (that is for cards and menus)."
672
+ },
673
+ {
674
+ "name": "Empty",
675
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Users } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const [query, setQuery] = useState('');\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateUser} data-testid='users-create'>\n Create user\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='w-[320px]'\n data-testid='users-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <Users />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>Invite people so they can sign in.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
676
+ "description": "Nothing exists yet: the real header with its create button, the toolbar, and an empty state\nwith no button of its own — one create action per screen."
677
+ }
678
+ ]
679
+ },
680
+ {
681
+ "slug": "patterns/entity-table",
682
+ "kind": "pattern",
683
+ "title": "Entity table",
684
+ "description": "Use when you render a list of users, roles, providers, channels or policies as a Table, with status badges, tags, a row menu and bulk actions.",
685
+ "components": [
686
+ "Table",
687
+ "TableActionBar",
688
+ "TableActionBarSelection",
689
+ "TableEmptyState",
690
+ "Badge",
691
+ "Tag",
692
+ "OverflowList",
693
+ "Avatar",
694
+ "AvatarContent",
695
+ "AvatarName",
696
+ "AvatarDescription",
697
+ "Code",
698
+ "FormatDateTime",
699
+ "DropdownMenu",
700
+ "DropdownMenuItem",
701
+ "Button",
702
+ "Tooltip",
703
+ "Switch",
704
+ "EmptyState"
705
+ ],
706
+ "keywords": [
707
+ "table",
708
+ "data table",
709
+ "grid",
710
+ "list",
711
+ "rows",
712
+ "columns",
713
+ "entity list",
714
+ "users table",
715
+ "cell",
716
+ "status badge",
717
+ "tags",
718
+ "+N",
719
+ "overflow",
720
+ "row menu",
721
+ "row actions",
722
+ "kebab",
723
+ "ellipsis",
724
+ "three dots",
725
+ "bulk actions",
726
+ "bulk bar",
727
+ "selection",
728
+ "checkbox",
729
+ "sort",
730
+ "empty value",
731
+ "em dash",
732
+ "column description",
733
+ "reorder"
734
+ ],
735
+ "order": 20,
736
+ "markdown": "# Entity table\n\nOne `Table` with a fixed cell vocabulary, a trailing row menu and a bulk bar.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| A list of entities on a page | `Table` inside `PageContent` | A `div` grid, a `Card` per row |\n| A list in a drawer or dialog | The same `Table` inside `DrawerBody` or `DialogBody` (from `@wallarm-org/design-system/Dialog`) | A custom bordered list |\n| Actions on one row | A trailing `actions` column with a `DropdownMenu` | Buttons repeated in every row |\n| Actions on many rows | `onRowSelectionChange` plus `TableActionBar` | Your own floating bar |\n| Details of one entity | `Attribute` in a drawer | A two-column `Table` |\n| Kanban, preference matrix | Product code | This pattern |\n\nUse only these cells.\n\n| Data | Cell | Not |\n| --- | --- | --- |\n| Person | `Avatar size='md'` with `AvatarName` | `img` plus `span` |\n| Second line | `AvatarDescription` | Two `Text` lines |\n| Status | `Badge variant='dotted' type='secondary' color=…` | Colored text, a raw dot |\n| Locked row | `Badge` with `Lock` in a `Tooltip` | A bare lock icon |\n| Groups, roles | `OverflowList` of `Tag` | Joined text, custom \"+N\" |\n| ID, key, hash | `Code size='m'` | A monospace `span` |\n| Recent time | `FormatDateTime format='relative'` | `toLocaleString()` |\n| Exact time | `FormatDateTime format='datetime'` | Two cells |\n| Count, metric | The raw number | `meta.align` |\n| On/off setting | `Switch` with `SwitchControl` | `Checkbox` |\n| Missing value | `'—'` (em dash) | `0`, `N/A`, an empty cell |\n\n## Rules\n\n**Layout and spacing**\n\n- The first column is pinned and never hideable: put the entity name there.\n- `Table` owns its frame, borders, padding and header. **Never** wrap it in a bordered `div` or restyle cells via `meta.cellClassName`.\n- `PageContent`, `DrawerBody` and `DialogBody` own the gutter. **Never** add `p-24` or any padding around `Table`.\n- Set widths with `size`: name `320`, email `240`, status `140`, timestamp `200`, actions `60` plus `minSize: 60` (the default minimum is 96px).\n- If columns must keep their `size`, pass `stretch={false}`.\n- Two-line cell: add a `Text size='xs' color='secondary'` line; there is no `rowHeight`.\n\n**Row menu**\n\n- If rows have actions, add the trailing display column shown below (an `id`, no `accessorKey`, `enableHiding: false`).\n- **Never** use `meta.renderMenuAction` for entity actions: its menu shows only on hover. There is no `actions` column type, `alwaysShowMenu` or `TableRow`.\n- Trigger: `Button variant='ghost' color='neutral' size='small'`, `Ellipsis` icon, `aria-label` naming the row.\n- Order items: open, edit, reorder, state changes, `DropdownMenuSeparator`, destructive.\n- Use `variant='destructive'` and name the entity: \"Delete user\", not \"Delete\". Confirm before deleting.\n- Inherited or read-only row: only view and \"Open at {scope}\".\n\n**Data and selection**\n\n- Always pass `getRowId={row => row.id}`; selection, `activeRowId` and reordering key on it.\n- For selection, pass `rowSelection` and `onRowSelectionChange`; without the handler there are no checkboxes.\n- Sorting: `sorting` and `onSortingChange`, plus `manualSorting` if the server sorts.\n- Bulk actions: `Button variant='ghost' color='neutral-alt'` in `TableActionBar`; destructive last with `color='destructive'`; options go in a `DropdownMenu`.\n- Custom selection text: `TableActionBarSelection` (from `@wallarm-org/design-system/Table`) with `BulkBarSummaryCount`, `BulkBarSummarySelectAll`, `BulkBarSummaryClear` as direct children.\n- Drag to reorder: `onRowReorder`; pin rows with `getRowCanDrag`.\n\n**Values**\n\n- Status `color`: Active, Enabled, Connected `green`; Invited, Pending, Needs attention `yellow`; Expired, Failed, Error `red`; Deactivated, Disabled, Draft `gray`. `Badge` has no `status` prop.\n- **Never** show `0` for missing data; show `'—'`. `FormatDateTime` renders `—` for `null` itself.\n- **Never** set `meta.align` on numbers; they align right on their own. If the first row can be `null` or `'—'`, set `meta: { sortType: 'number' }`.\n- Header explanation: `meta.description: { type: 'tooltip', content }`; `type: 'text'` keeps it visible.\n- Import `OverflowList` from `@wallarm-org/design-system/OverflowList`; pass it a stable `items` array (`row.original.groups`). There is no `maxItems`.\n- Tests: pass `data-testid` to `Table`; **never** select by `data-slot`.\n\n## Examples\n\nThe whole table: the cell vocabulary, an always-visible row menu, sorting and a bulk bar for selected rows.\n\nAdds an ID in `Code` and an exact time to the `Cells` vocabulary. Sort by any header; select\nrows to bring up the bulk bar.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Code } from '@wallarm-org/design-system/Code';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, Trash2 } from '@wallarm-org/design-system/icons';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, type TableSortingState, Table, TableActionBar } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [sorting, setSorting] = useState<TableSortingState>([]);\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderId = ({ row }: TableCellContext<User, unknown>) => (\n <Code size='m'>{row.original.id}</Code>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='datetime' />\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleResend = () => onResendInvite(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n {user.status === 'invited' && (\n <DropdownMenuItem onSelect={handleResend}>\n <DropdownMenuItemText>Resend invite</DropdownMenuItemText>\n </DropdownMenuItem>\n )}\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'id', header: 'User ID', size: 160, cell: renderId },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleAssignGroup = () => onAssignGroup(Object.keys(selection));\n const handleBulkDelete = () => onBulkDelete(Object.keys(selection));\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n sorting={sorting}\n onSortingChange={setSorting}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='users-table'\n >\n <TableActionBar>\n <Button variant='ghost' color='neutral-alt' onClick={handleAssignGroup}>\n Assign group\n </Button>\n <Button color='destructive' onClick={handleBulkDelete} data-testid='users-bulk-delete'>\n <Trash2 /> Delete\n </Button>\n </TableActionBar>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA person with a second line, a status badge, groups as tags, and a number whose header explains it.\n\nTags collapse into \"+N\" when the column runs out of room; a user with no groups or no requests\nshows `—`.\n\n```tsx story=Cells\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Cells = () => {\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nThe row menu: the trigger names the row, and the destructive item comes last and names the entity.\n\nThe column has an `id` and no `accessorKey`, and can't be hidden, so the menu never disappears\nbehind the column settings.\n\n```tsx story=RowMenu\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const RowMenu = () => {\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nFirst load keeps the header and fills the body with placeholder rows.\n\n`isLoading` draws the skeleton rows, not a `Loader`; `skeletonCount` sets how many (12 by\ndefault).\n\n```tsx story=Loading\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n isLoading\n skeletonCount={6}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nNothing created yet: an empty state under the header.\n\n`EmptyState type='collection-empty'` inside `TableEmptyState`. It has no button when the page\nheader already has Create.\n\n```tsx story=Empty\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <UserPlus />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>\n Invite people to sign in to your organization.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| First load | `isLoading` (12 skeleton rows; `skeletonCount`), not a `Loader` | `Table` |\n| Nothing created | `EmptyState type='collection-empty'`; no button if `PageActions` has Create; keep header and toolbar | `TableEmptyState` |\n| No matches | `type='collection-empty'`, \"No results found\", `Button variant='outline' color='neutral'` \"Reset filters\"; not `type='no-results'` | `TableEmptyState` |\n| Rows selected | The bar appears on its own | `TableActionBar` |\n| Row open in a drawer | `activeRowId={openId}`; not a row `className` | `Table` |\n| Switch saving | Keep the `Switch`, `Loader` in a reserved slot beside it | `Switch`, `Loader` |\n| Locked by scope | Disabled control, `Lock` with a `Tooltip` naming the scope; never hide the row | `Tooltip` |\n\n## What your app owns\n\n| You own | The DS owns |\n| --- | --- |\n| `columns`, order, widths | Cell padding, borders, header, hover and selected backgrounds |\n| Status-to-`color` mapping and labels | `Badge` dot, size and colors |\n| Row menu items and handlers | Menu focus and keyboard |\n| Fetching, filtering, server sorting, pagination | Client sorting |\n| Which bulk actions exist | Bar placement, \"Select all\", \"Clear\" |\n| Delete confirmation | Nothing; compose `Dialog` |\n| Scope marker | Nothing; compose `Badge variant='dotted'` with `Lock` |\n| Metric cell (% over \"x of y\") | Nothing; compose `FormatNumber` and `Text size='xs'` |\n| Grouped or orderable tables | `getSubRows`, `renderGroupRow`, `onRowReorder` |\n\n## Related\n\n- [Resource list page](./resource-list-page.md): the page shell.\n- [Detail drawer](./detail-drawer.md): opens from a row.\n- [Confirmation dialogs](./confirmation-dialogs.md): confirm deletes.\n- [Feedback toasts](./feedback-toasts.md): report results.\n- [Empty states](./empty-states.md), [Filtering](./filtering.md), [Spacing](../foundations/spacing.md).",
737
+ "sections": [
738
+ {
739
+ "id": "when-to-use",
740
+ "heading": "When to use",
741
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| A list of entities on a page | `Table` inside `PageContent` | A `div` grid, a `Card` per row |\n| A list in a drawer or dialog | The same `Table` inside `DrawerBody` or `DialogBody` (from `@wallarm-org/design-system/Dialog`) | A custom bordered list |\n| Actions on one row | A trailing `actions` column with a `DropdownMenu` | Buttons repeated in every row |\n| Actions on many rows | `onRowSelectionChange` plus `TableActionBar` | Your own floating bar |\n| Details of one entity | `Attribute` in a drawer | A two-column `Table` |\n| Kanban, preference matrix | Product code | This pattern |\n\nUse only these cells.\n\n| Data | Cell | Not |\n| --- | --- | --- |\n| Person | `Avatar size='md'` with `AvatarName` | `img` plus `span` |\n| Second line | `AvatarDescription` | Two `Text` lines |\n| Status | `Badge variant='dotted' type='secondary' color=…` | Colored text, a raw dot |\n| Locked row | `Badge` with `Lock` in a `Tooltip` | A bare lock icon |\n| Groups, roles | `OverflowList` of `Tag` | Joined text, custom \"+N\" |\n| ID, key, hash | `Code size='m'` | A monospace `span` |\n| Recent time | `FormatDateTime format='relative'` | `toLocaleString()` |\n| Exact time | `FormatDateTime format='datetime'` | Two cells |\n| Count, metric | The raw number | `meta.align` |\n| On/off setting | `Switch` with `SwitchControl` | `Checkbox` |\n| Missing value | `'—'` (em dash) | `0`, `N/A`, an empty cell |"
742
+ },
743
+ {
744
+ "id": "rules",
745
+ "heading": "Rules",
746
+ "markdown": "**Layout and spacing**\n\n- The first column is pinned and never hideable: put the entity name there.\n- `Table` owns its frame, borders, padding and header. **Never** wrap it in a bordered `div` or restyle cells via `meta.cellClassName`.\n- `PageContent`, `DrawerBody` and `DialogBody` own the gutter. **Never** add `p-24` or any padding around `Table`.\n- Set widths with `size`: name `320`, email `240`, status `140`, timestamp `200`, actions `60` plus `minSize: 60` (the default minimum is 96px).\n- If columns must keep their `size`, pass `stretch={false}`.\n- Two-line cell: add a `Text size='xs' color='secondary'` line; there is no `rowHeight`.\n\n**Row menu**\n\n- If rows have actions, add the trailing display column shown below (an `id`, no `accessorKey`, `enableHiding: false`).\n- **Never** use `meta.renderMenuAction` for entity actions: its menu shows only on hover. There is no `actions` column type, `alwaysShowMenu` or `TableRow`.\n- Trigger: `Button variant='ghost' color='neutral' size='small'`, `Ellipsis` icon, `aria-label` naming the row.\n- Order items: open, edit, reorder, state changes, `DropdownMenuSeparator`, destructive.\n- Use `variant='destructive'` and name the entity: \"Delete user\", not \"Delete\". Confirm before deleting.\n- Inherited or read-only row: only view and \"Open at {scope}\".\n\n**Data and selection**\n\n- Always pass `getRowId={row => row.id}`; selection, `activeRowId` and reordering key on it.\n- For selection, pass `rowSelection` and `onRowSelectionChange`; without the handler there are no checkboxes.\n- Sorting: `sorting` and `onSortingChange`, plus `manualSorting` if the server sorts.\n- Bulk actions: `Button variant='ghost' color='neutral-alt'` in `TableActionBar`; destructive last with `color='destructive'`; options go in a `DropdownMenu`.\n- Custom selection text: `TableActionBarSelection` (from `@wallarm-org/design-system/Table`) with `BulkBarSummaryCount`, `BulkBarSummarySelectAll`, `BulkBarSummaryClear` as direct children.\n- Drag to reorder: `onRowReorder`; pin rows with `getRowCanDrag`.\n\n**Values**\n\n- Status `color`: Active, Enabled, Connected `green`; Invited, Pending, Needs attention `yellow`; Expired, Failed, Error `red`; Deactivated, Disabled, Draft `gray`. `Badge` has no `status` prop.\n- **Never** show `0` for missing data; show `'—'`. `FormatDateTime` renders `—` for `null` itself.\n- **Never** set `meta.align` on numbers; they align right on their own. If the first row can be `null` or `'—'`, set `meta: { sortType: 'number' }`.\n- Header explanation: `meta.description: { type: 'tooltip', content }`; `type: 'text'` keeps it visible.\n- Import `OverflowList` from `@wallarm-org/design-system/OverflowList`; pass it a stable `items` array (`row.original.groups`). There is no `maxItems`.\n- Tests: pass `data-testid` to `Table`; **never** select by `data-slot`."
747
+ },
748
+ {
749
+ "id": "examples",
750
+ "heading": "Examples",
751
+ "markdown": "The whole table: the cell vocabulary, an always-visible row menu, sorting and a bulk bar for selected rows.\n\nAdds an ID in `Code` and an exact time to the `Cells` vocabulary. Sort by any header; select\nrows to bring up the bulk bar.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Code } from '@wallarm-org/design-system/Code';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, Trash2 } from '@wallarm-org/design-system/icons';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, type TableSortingState, Table, TableActionBar } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [sorting, setSorting] = useState<TableSortingState>([]);\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderId = ({ row }: TableCellContext<User, unknown>) => (\n <Code size='m'>{row.original.id}</Code>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='datetime' />\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleResend = () => onResendInvite(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n {user.status === 'invited' && (\n <DropdownMenuItem onSelect={handleResend}>\n <DropdownMenuItemText>Resend invite</DropdownMenuItemText>\n </DropdownMenuItem>\n )}\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'id', header: 'User ID', size: 160, cell: renderId },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleAssignGroup = () => onAssignGroup(Object.keys(selection));\n const handleBulkDelete = () => onBulkDelete(Object.keys(selection));\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n sorting={sorting}\n onSortingChange={setSorting}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='users-table'\n >\n <TableActionBar>\n <Button variant='ghost' color='neutral-alt' onClick={handleAssignGroup}>\n Assign group\n </Button>\n <Button color='destructive' onClick={handleBulkDelete} data-testid='users-bulk-delete'>\n <Trash2 /> Delete\n </Button>\n </TableActionBar>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA person with a second line, a status badge, groups as tags, and a number whose header explains it.\n\nTags collapse into \"+N\" when the column runs out of room; a user with no groups or no requests\nshows `—`.\n\n```tsx story=Cells\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Cells = () => {\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nThe row menu: the trigger names the row, and the destructive item comes last and names the entity.\n\nThe column has an `id` and no `accessorKey`, and can't be hidden, so the menu never disappears\nbehind the column settings.\n\n```tsx story=RowMenu\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const RowMenu = () => {\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nFirst load keeps the header and fills the body with placeholder rows.\n\n`isLoading` draws the skeleton rows, not a `Loader`; `skeletonCount` sets how many (12 by\ndefault).\n\n```tsx story=Loading\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n isLoading\n skeletonCount={6}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};\n```\n\nNothing created yet: an empty state under the header.\n\n`EmptyState type='collection-empty'` inside `TableEmptyState`. It has no button when the page\nheader already has Create.\n\n```tsx story=Empty\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <UserPlus />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>\n Invite people to sign in to your organization.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```"
752
+ },
753
+ {
754
+ "id": "states",
755
+ "heading": "States",
756
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| First load | `isLoading` (12 skeleton rows; `skeletonCount`), not a `Loader` | `Table` |\n| Nothing created | `EmptyState type='collection-empty'`; no button if `PageActions` has Create; keep header and toolbar | `TableEmptyState` |\n| No matches | `type='collection-empty'`, \"No results found\", `Button variant='outline' color='neutral'` \"Reset filters\"; not `type='no-results'` | `TableEmptyState` |\n| Rows selected | The bar appears on its own | `TableActionBar` |\n| Row open in a drawer | `activeRowId={openId}`; not a row `className` | `Table` |\n| Switch saving | Keep the `Switch`, `Loader` in a reserved slot beside it | `Switch`, `Loader` |\n| Locked by scope | Disabled control, `Lock` with a `Tooltip` naming the scope; never hide the row | `Tooltip` |"
757
+ },
758
+ {
759
+ "id": "what-your-app-owns",
760
+ "heading": "What your app owns",
761
+ "markdown": "| You own | The DS owns |\n| --- | --- |\n| `columns`, order, widths | Cell padding, borders, header, hover and selected backgrounds |\n| Status-to-`color` mapping and labels | `Badge` dot, size and colors |\n| Row menu items and handlers | Menu focus and keyboard |\n| Fetching, filtering, server sorting, pagination | Client sorting |\n| Which bulk actions exist | Bar placement, \"Select all\", \"Clear\" |\n| Delete confirmation | Nothing; compose `Dialog` |\n| Scope marker | Nothing; compose `Badge variant='dotted'` with `Lock` |\n| Metric cell (% over \"x of y\") | Nothing; compose `FormatNumber` and `Text size='xs'` |\n| Grouped or orderable tables | `getSubRows`, `renderGroupRow`, `onRowReorder` |"
762
+ },
763
+ {
764
+ "id": "related",
765
+ "heading": "Related",
766
+ "markdown": "- [Resource list page](./resource-list-page.md): the page shell.\n- [Detail drawer](./detail-drawer.md): opens from a row.\n- [Confirmation dialogs](./confirmation-dialogs.md): confirm deletes.\n- [Feedback toasts](./feedback-toasts.md): report results.\n- [Empty states](./empty-states.md), [Filtering](./filtering.md), [Spacing](../foundations/spacing.md)."
767
+ }
768
+ ],
769
+ "stories": [
770
+ {
771
+ "name": "Default",
772
+ "code": "import { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Code } from '@wallarm-org/design-system/Code';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Ellipsis, Trash2 } from '@wallarm-org/design-system/icons';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, type TableSortingState, Table, TableActionBar } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [sorting, setSorting] = useState<TableSortingState>([]);\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderId = ({ row }: TableCellContext<User, unknown>) => (\n <Code size='m'>{row.original.id}</Code>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderLastSignIn = ({ row }: TableCellContext<User, unknown>) => (\n <FormatDateTime value={row.original.lastSignIn} format='datetime' />\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleResend = () => onResendInvite(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n {user.status === 'invited' && (\n <DropdownMenuItem onSelect={handleResend}>\n <DropdownMenuItemText>Resend invite</DropdownMenuItemText>\n </DropdownMenuItem>\n )}\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'id', header: 'User ID', size: 160, cell: renderId },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200, cell: renderLastSignIn },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleAssignGroup = () => onAssignGroup(Object.keys(selection));\n const handleBulkDelete = () => onBulkDelete(Object.keys(selection));\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n sorting={sorting}\n onSortingChange={setSorting}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='users-table'\n >\n <TableActionBar>\n <Button variant='ghost' color='neutral-alt' onClick={handleAssignGroup}>\n Assign group\n </Button>\n <Button color='destructive' onClick={handleBulkDelete} data-testid='users-bulk-delete'>\n <Trash2 /> Delete\n </Button>\n </TableActionBar>\n </Table>\n </PageContent>\n </Page>\n );\n};",
773
+ "description": "Adds an ID in `Code` and an exact time to the `Cells` vocabulary. Sort by any header; select\nrows to bring up the bulk bar."
774
+ },
775
+ {
776
+ "name": "Cells",
777
+ "code": "import { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { OverflowList } from '@wallarm-org/design-system/OverflowList';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Cells = () => {\n const STATUS = {\n active: { label: 'Active', color: 'green' },\n invited: { label: 'Invited', color: 'yellow' },\n expired: { label: 'Expired', color: 'red' },\n deactivated: { label: 'Deactivated', color: 'gray' },\n } as const;\n\n const renderGroup = (group: string) => <Tag key={group}>{group}</Tag>;\n\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) => (\n <Badge variant='dotted' type='secondary' color={STATUS[row.original.status].color}>\n {STATUS[row.original.status].label}\n </Badge>\n );\n\n const renderGroups = ({ row }: TableCellContext<User, unknown>) =>\n row.original.groups.length > 0 ? (\n <OverflowList className='gap-4' items={row.original.groups} itemRenderer={renderGroup} />\n ) : (\n '—'\n );\n\n const renderRequests = ({ row }: TableCellContext<User, unknown>) => row.original.requests ?? '—';\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n accessorKey: 'groups',\n header: 'Groups',\n size: 240,\n enableSorting: false,\n cell: renderGroups,\n },\n {\n accessorKey: 'requests',\n header: 'API requests',\n size: 140,\n meta: { sortType: 'number', description: { type: 'tooltip', content: 'Last 24 hours' } },\n cell: renderRequests,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};",
778
+ "description": "Tags collapse into \"+N\" when the column runs out of room; a user with no groups or no requests\nshows `—`."
779
+ },
780
+ {
781
+ "name": "RowMenu",
782
+ "code": "import { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const RowMenu = () => {\n const renderUser = ({ row }: TableCellContext<User, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleEdit = () => onEditUser(user);\n const handleDeactivate = () => onDeactivateUser(user);\n const handleDelete = () => onDeleteUser(user);\n\n return (\n <DropdownMenu data-testid='users-row-menu'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDeactivate}>\n <DropdownMenuItemText>Deactivate</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320, cell: renderUser },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='settings-users' data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={USERS}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};",
783
+ "description": "The column has an `id` and no `accessorKey`, and can't be hidden, so the menu never disappears\nbehind the column settings."
784
+ },
785
+ {
786
+ "name": "Loading",
787
+ "code": "import { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n isLoading\n skeletonCount={6}\n data-testid='users-table'\n />\n </PageContent>\n </Page>\n );\n};",
788
+ "description": "`isLoading` draws the skeleton rows, not a `Loader`; `skeletonCount` sets how many (12 by\ndefault)."
789
+ },
790
+ {
791
+ "name": "Empty",
792
+ "code": "import { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Empty = () => {\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 320 },\n { accessorKey: 'id', header: 'User ID', size: 160 },\n { accessorKey: 'groups', header: 'Groups', size: 240 },\n { accessorKey: 'status', header: 'Status', size: 140 },\n { accessorKey: 'lastSignIn', header: 'Last sign-in', size: 200 },\n ];\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageContent>\n <Table\n aria-label='Users'\n data={[]}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='users-empty-state'>\n <EmptyStateIllustration>\n <UserPlus />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No users yet</EmptyStateTitle>\n <EmptyStateDescription>\n Invite people to sign in to your organization.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
793
+ "description": "`EmptyState type='collection-empty'` inside `TableEmptyState`. It has no button when the page\nheader already has Create."
794
+ }
795
+ ]
796
+ },
797
+ {
798
+ "slug": "patterns/confirmation-dialogs",
799
+ "kind": "pattern",
800
+ "title": "Confirmation dialogs",
801
+ "description": "Use when an action deletes, disables or overwrites something, or when closing a form would lose unsaved changes, and the user must confirm first.",
802
+ "components": [
803
+ "Dialog",
804
+ "Drawer",
805
+ "Button",
806
+ "Input",
807
+ "Toast",
808
+ "Toaster"
809
+ ],
810
+ "keywords": [
811
+ "confirm",
812
+ "confirmation",
813
+ "are you sure",
814
+ "delete",
815
+ "remove",
816
+ "revoke",
817
+ "deactivate",
818
+ "disable",
819
+ "destructive",
820
+ "type to confirm",
821
+ "unsaved changes",
822
+ "leave without saving",
823
+ "discard",
824
+ "modal",
825
+ "alert dialog",
826
+ "ConfirmDialog"
827
+ ],
828
+ "order": 30,
829
+ "markdown": "# Confirmation dialogs\n\nA confirmation dialog asks one question about one action, then runs that action or cancels it. Compose it from `Dialog` parts. There is no `ConfirmDialog`, `AlertDialog`, `useConfirm` or `UnsavedChangesDialog`.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Delete, revoke, deactivate, disable | `Dialog width={400}`, confirm `color='destructive'` | A toast with Undo, or a `Switch` that silently removes access |\n| Activate or enable | `Dialog width={400}`, default brand confirm | `color='destructive'` |\n| Delete that also breaks linked objects | Type-to-confirm: no `width` (560) + `Input` | A longer description |\n| Closing a drawer with unsaved edits | Unsaved-changes guard, no `width` (560) | `window.confirm` or `closeOnEscape={false}` |\n| Continue despite a warning | `Dialog width={400}`, brand \"Create anyway\" | An `Alert` to dismiss first |\n| Reversible action that loses no data | No dialog; run it and show a toast | A confirmation |\n| Several fields before acting | [Create and edit forms](create-edit-forms.md) | A form inside `DialogBody` |\n| Report the result | A toast | A second dialog that says \"Done\" |\n\n## Rules\n\n### Structure\n\n- Import every `Dialog*` part from `@wallarm-org/design-system/Dialog`. `Dialog` is not exported from the package root.\n- Use `width={400}` for delete, state change and proceed-anyway. Omit `width` (560) for type-to-confirm and unsaved changes. **Never** use `960` for a confirmation.\n- Write the width as a number; `DIALOG_SIZES` and a `size` prop do not exist. If the description wraps, keep 400.\n- Footer order, left to right: Cancel, then confirm. The confirm button is always rightmost.\n- Confirm buttons use the default `variant='primary'`. Every other footer button is `variant='ghost' color='neutral'`.\n- **Never** use `color='destructive'` for an action that grants access or creates something.\n- Wrap Cancel in `DialogClose asChild`. **Never** wrap the confirm button in `DialogClose`: it closes the dialog before the request runs.\n- **Never** add a close button to `DialogHeader`. It renders the close X itself.\n- There is no `onConfirm`, `onCancel`, `confirmLabel`, `destructive` or `loading` prop on `Dialog`, no `DialogAction`/`DialogCancel`, and no initial-focus prop.\n\n### Spacing\n\n- The parts own all padding (header `pt-20 pb-16 px-24`, body `px-24 py-8`, footer `px-24 py-16 gap-8`). Keep the body's 8px though Figma draws the input flush.\n- **Never** add `p-*`, `gap-*` or `space-y-*` around dialog content, wrap footer buttons in a `div`/`HStack`, or pass `size` to them (default 36px).\n- **Never** pass `className` to `DialogHeader`, `DialogBody` or `DialogFooter`. They take only `children` and `ref`.\n\n### Behavior\n\n- While the request runs, pass `loading={pending}` to confirm (it also disables it; do not add `disabled={pending}`) and ignore close requests. Close only on success; on failure keep it open with an error toast.\n- If the dialog closes, reset the type-to-confirm input to `''`. Match the entity name exactly, case-sensitive.\n- If the dialog opens from a `Drawer`, render it inside `DrawerContent`. See [Layering](../foundations/layering.md).\n- To guard unsaved changes, intercept `onOpenChange(false)` on the `Drawer`. One handler covers the close X, `Escape` and outside click.\n- **Never** open a confirmation from a toast or from another confirmation.\n- Use one controlled `open` state per dialog, set by the control that starts the action.\n\n### Copy\n\n| Part | Write | Not |\n| --- | --- | --- |\n| Title | \"Delete user?\" / bulk \"Delete 3 users?\" | \"Are you sure?\", \"Warning\", \"Delete\", \"Delete selected?\" |\n| Title, unsaved changes | \"Unsaved changes\" | \"Discard changes?\" |\n| Description | \"This permanently deletes {name} and all their access. You can't undo this.\" | \"This action cannot be undone.\" with no object named |\n| Description, bulk | \"This permanently deletes 3 users and all their access. You can't undo this.\" | Naming only the first user |\n| Description, reversible | \"{name} loses access until you reactivate them. Their roles and data are kept.\" | Hiding what is kept |\n| Confirm | \"Delete user\" / bulk \"Delete 3 users\" | \"OK\", \"Yes\", \"Confirm\", \"Delete\", \"Delete all\" |\n| Cancel | \"Cancel\" | \"No\", \"Close\", \"Back\" |\n| Footer, unsaved changes | \"Keep editing\", \"Leave without saving\" (both ghost neutral), \"Save changes\" (brand) | \"Cancel\" |\n| Type-to-confirm placeholder | `Enter \"{name}\" to confirm` | \"Type here\" |\n| Toast | \"User deleted\" / \"3 users deleted\" | \"Success!\", \"User was deleted successfully.\" |\n\n- Title is `<Verb> <object>?` in sentence case. Confirm repeats that verb and noun without `?`.\n- Name the specific object in the description, and end with what can be undone or what is kept. If nothing can be undone, write \"You can't undo this.\"\n- If Figma's confirm label differs from the title (\"Delete rule?\" / \"Delete permission\"), use the title's noun.\n\n## Examples\n\nA delete confirmation with pending, success and failure handling.\n\nOpened from a row's actions menu, which sets the selected user and the dialog's own `open`\nstate. While the request runs, `Escape` and outside clicks are ignored; on success the row is\nremoved.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [users, setUsers] = useState(USERS);\n const [selected, setSelected] = useState<User | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) =>\n row.original.status === 'active' ? (\n <Badge variant='dotted' type='secondary' color='green'>\n Active\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='gray'>\n Deactivated\n </Badge>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleDeleteSelect = () => {\n setSelected(user);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n data-testid='users-actions'\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem\n variant='destructive'\n onSelect={handleDeleteSelect}\n data-testid='users-delete'\n >\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 220 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleDeleteOpenChange = (next: boolean) => {\n if (!pending) setDeleteOpen(next);\n };\n\n const handleDelete = async () => {\n if (!selected) return;\n setPending(true);\n try {\n await deleteUser(selected.id);\n setUsers(prev => prev.filter(user => user.id !== selected.id));\n setDeleteOpen(false);\n toast.create({ title: 'User deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={users}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={handleDeleteOpenChange}\n data-testid='delete-user-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete user?</DialogTitle>\n <DialogDescription>\n This permanently deletes {selected?.name} and all their access. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='delete-user-cancel'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-user-confirm'\n >\n Delete user\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nA state change that grants access uses the default brand confirm.\n\nThe description says what the user gets back. Once active, the same button deactivates: that\ndialog uses a destructive confirm and says the roles and data are kept.\n\n```tsx story=Activate\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Activate = () => {\n const toast = useToast();\n const [status, setStatus] = useState(INACTIVE_USER.status);\n const [open, setOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const isActive = status === 'active';\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (!pending) setOpen(next);\n };\n\n const handleStatusChange = async () => {\n const nextStatus: UserStatus = isActive ? 'deactivated' : 'active';\n setPending(true);\n try {\n await setUserStatus(INACTIVE_USER.id, nextStatus);\n setStatus(nextStatus);\n setOpen(false);\n toast.create({ title: isActive ? 'User deactivated' : 'User activated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-user' fullSize fixedHeight data-testid='user-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{INACTIVE_USER.name}</PageTitle>\n <PageDescription>\n {INACTIVE_USER.email} · {INACTIVE_USER.role}\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleOpen}\n data-testid='user-change-status'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog\n width={400}\n open={open}\n onOpenChange={handleOpenChange}\n data-testid='user-status-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{isActive ? 'Deactivate user?' : 'Activate user?'}</DialogTitle>\n <DialogDescription>\n {isActive\n ? `${INACTIVE_USER.name} loses access until you reactivate them. Their roles and data are kept.`\n : `${INACTIVE_USER.name} can sign in again with the roles they had before.`}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color={isActive ? 'destructive' : 'brand'}\n loading={pending}\n onClick={handleStatusChange}\n data-testid='user-status-confirm'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nType-to-confirm omits `width` and keeps confirm disabled until the name matches.\n\nFor a delete that also breaks linked objects. The match is exact and case-sensitive, and\nclosing the dialog resets the input to `''`.\n\n```tsx story=TypeToConfirm\nimport { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TypeToConfirm = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [value, setValue] = useState('');\n const [pending, setPending] = useState(false);\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (pending) return;\n setOpen(next);\n if (!next) setValue('');\n };\n\n const handleValueChange = (event: ChangeEvent<HTMLInputElement>) => {\n setValue(event.target.value);\n };\n\n const handleDelete = async () => {\n setPending(true);\n try {\n await deleteProvider(PROVIDER_NAME);\n setOpen(false);\n setValue('');\n toast.create({ title: 'Identity provider deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete identity provider\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{PROVIDER_NAME}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='destructive'\n onClick={handleOpen}\n data-testid='provider-delete'\n >\n Delete identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='delete-provider-dialog'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete identity provider?</DialogTitle>\n <DialogDescription>\n \"{PROVIDER_NAME}\" is unlinked and deleted, along with its rules. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogBody>\n <Input\n aria-label='Provider name'\n placeholder={`Enter \"${PROVIDER_NAME}\" to confirm`}\n value={value}\n onChange={handleValueChange}\n data-testid='delete-provider-input'\n />\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n disabled={value !== PROVIDER_NAME}\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-provider-confirm'\n >\n Delete identity provider\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nAn unsaved-changes guard intercepts the drawer's close and renders the dialog inside `DrawerContent`.\n\nWhile the form is dirty, the close X, Cancel, `Escape` and an outside click all open the same\ndialog. \"Keep editing\" returns to the form; \"Leave without saving\" drops the edits.\n\n```tsx story=UnsavedChanges\nimport { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const UnsavedChanges = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [guardOpen, setGuardOpen] = useState(false);\n const [savedName, setSavedName] = useState(PROVIDER_NAME);\n const [name, setName] = useState(PROVIDER_NAME);\n const [saving, setSaving] = useState(false);\n\n const isDirty = name !== savedName;\n\n const handleOpen = () => setOpen(true);\n\n const handleDrawerOpenChange = (next: boolean) => {\n if (saving) return;\n if (!next && isDirty) {\n setGuardOpen(true);\n return;\n }\n setOpen(next);\n };\n\n const handleGuardOpenChange = (next: boolean) => {\n if (!saving) setGuardOpen(next);\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n };\n\n const handleLeave = () => {\n setName(savedName);\n setGuardOpen(false);\n setOpen(false);\n };\n\n const handleSave = async () => {\n setSaving(true);\n try {\n await saveProvider({ name });\n setSavedName(name);\n setGuardOpen(false);\n setOpen(false);\n toast.create({ title: 'Identity provider updated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update identity provider\", type: 'error' });\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{savedName}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleOpen} data-testid='provider-edit'>\n Edit identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Drawer open={open} onOpenChange={handleDrawerOpenChange} data-testid='edit-provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Edit identity provider</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Field>\n <FieldLabel>Display name</FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='provider-name' />\n </Field>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button disabled={!isDirty} loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DrawerFooter>\n\n <Dialog\n open={guardOpen}\n onOpenChange={handleGuardOpenChange}\n data-testid='unsaved-changes-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Unsaved changes</DialogTitle>\n <DialogDescription>\n You have unsaved changes. Leaving will lose them unless you save.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='unsaved-changes-keep'>\n Keep editing\n </Button>\n </DialogClose>\n <Button\n variant='ghost'\n color='neutral'\n onClick={handleLeave}\n data-testid='unsaved-changes-leave'\n >\n Leave without saving\n </Button>\n <Button loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Idle | Question, consequence, Cancel and confirm enabled | `Dialog` |\n| Type-to-confirm, empty or wrong | Confirm disabled | `Button disabled={value !== name}` |\n| Type-to-confirm, matches | Confirm enabled | `Button` |\n| Pending | Spinner on confirm, dialog stays open, close ignored | `Button loading={pending}` |\n| Success | Dialog closes, then a toast | `toast.create({ type: 'default' })` |\n| Failure | Dialog stays open, error toast, confirm enabled again | `toast.create({ type: 'error' })` |\n\n## What your app owns\n\n- The request, `pending` state, error handling and blocking close while pending.\n- Type-to-confirm matching and resetting the input on close.\n- Dirty tracking (your form library's flag) and intercepting the drawer close.\n- Route changes and tab close from a dirty form: use your router's blocker and `beforeunload`. The design system does not handle them.\n- Result toasts: `useToast` from `@wallarm-org/design-system/Toast`, with one `Toaster` at the app root.\n- All copy, including entity names.\n\n## Related\n\n- [Action-result toasts](feedback-toasts.md): the toast after a confirmed action.\n- [Entity detail drawer](detail-drawer.md): opens delete and state-change dialogs from its Actions menu.\n- [Create and edit forms](create-edit-forms.md): forms that need the unsaved-changes guard.\n- [Layering](../foundations/layering.md): nesting a `Dialog` inside a `Drawer`.\n- [Spacing](../foundations/spacing.md) and [Typography](../foundations/typography.md): the dialog's built-in padding and text styles.",
830
+ "sections": [
831
+ {
832
+ "id": "when-to-use",
833
+ "heading": "When to use",
834
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Delete, revoke, deactivate, disable | `Dialog width={400}`, confirm `color='destructive'` | A toast with Undo, or a `Switch` that silently removes access |\n| Activate or enable | `Dialog width={400}`, default brand confirm | `color='destructive'` |\n| Delete that also breaks linked objects | Type-to-confirm: no `width` (560) + `Input` | A longer description |\n| Closing a drawer with unsaved edits | Unsaved-changes guard, no `width` (560) | `window.confirm` or `closeOnEscape={false}` |\n| Continue despite a warning | `Dialog width={400}`, brand \"Create anyway\" | An `Alert` to dismiss first |\n| Reversible action that loses no data | No dialog; run it and show a toast | A confirmation |\n| Several fields before acting | [Create and edit forms](create-edit-forms.md) | A form inside `DialogBody` |\n| Report the result | A toast | A second dialog that says \"Done\" |"
835
+ },
836
+ {
837
+ "id": "rules",
838
+ "heading": "Rules",
839
+ "markdown": "### Structure\n\n- Import every `Dialog*` part from `@wallarm-org/design-system/Dialog`. `Dialog` is not exported from the package root.\n- Use `width={400}` for delete, state change and proceed-anyway. Omit `width` (560) for type-to-confirm and unsaved changes. **Never** use `960` for a confirmation.\n- Write the width as a number; `DIALOG_SIZES` and a `size` prop do not exist. If the description wraps, keep 400.\n- Footer order, left to right: Cancel, then confirm. The confirm button is always rightmost.\n- Confirm buttons use the default `variant='primary'`. Every other footer button is `variant='ghost' color='neutral'`.\n- **Never** use `color='destructive'` for an action that grants access or creates something.\n- Wrap Cancel in `DialogClose asChild`. **Never** wrap the confirm button in `DialogClose`: it closes the dialog before the request runs.\n- **Never** add a close button to `DialogHeader`. It renders the close X itself.\n- There is no `onConfirm`, `onCancel`, `confirmLabel`, `destructive` or `loading` prop on `Dialog`, no `DialogAction`/`DialogCancel`, and no initial-focus prop.\n\n### Spacing\n\n- The parts own all padding (header `pt-20 pb-16 px-24`, body `px-24 py-8`, footer `px-24 py-16 gap-8`). Keep the body's 8px though Figma draws the input flush.\n- **Never** add `p-*`, `gap-*` or `space-y-*` around dialog content, wrap footer buttons in a `div`/`HStack`, or pass `size` to them (default 36px).\n- **Never** pass `className` to `DialogHeader`, `DialogBody` or `DialogFooter`. They take only `children` and `ref`.\n\n### Behavior\n\n- While the request runs, pass `loading={pending}` to confirm (it also disables it; do not add `disabled={pending}`) and ignore close requests. Close only on success; on failure keep it open with an error toast.\n- If the dialog closes, reset the type-to-confirm input to `''`. Match the entity name exactly, case-sensitive.\n- If the dialog opens from a `Drawer`, render it inside `DrawerContent`. See [Layering](../foundations/layering.md).\n- To guard unsaved changes, intercept `onOpenChange(false)` on the `Drawer`. One handler covers the close X, `Escape` and outside click.\n- **Never** open a confirmation from a toast or from another confirmation.\n- Use one controlled `open` state per dialog, set by the control that starts the action.\n\n### Copy\n\n| Part | Write | Not |\n| --- | --- | --- |\n| Title | \"Delete user?\" / bulk \"Delete 3 users?\" | \"Are you sure?\", \"Warning\", \"Delete\", \"Delete selected?\" |\n| Title, unsaved changes | \"Unsaved changes\" | \"Discard changes?\" |\n| Description | \"This permanently deletes {name} and all their access. You can't undo this.\" | \"This action cannot be undone.\" with no object named |\n| Description, bulk | \"This permanently deletes 3 users and all their access. You can't undo this.\" | Naming only the first user |\n| Description, reversible | \"{name} loses access until you reactivate them. Their roles and data are kept.\" | Hiding what is kept |\n| Confirm | \"Delete user\" / bulk \"Delete 3 users\" | \"OK\", \"Yes\", \"Confirm\", \"Delete\", \"Delete all\" |\n| Cancel | \"Cancel\" | \"No\", \"Close\", \"Back\" |\n| Footer, unsaved changes | \"Keep editing\", \"Leave without saving\" (both ghost neutral), \"Save changes\" (brand) | \"Cancel\" |\n| Type-to-confirm placeholder | `Enter \"{name}\" to confirm` | \"Type here\" |\n| Toast | \"User deleted\" / \"3 users deleted\" | \"Success!\", \"User was deleted successfully.\" |\n\n- Title is `<Verb> <object>?` in sentence case. Confirm repeats that verb and noun without `?`.\n- Name the specific object in the description, and end with what can be undone or what is kept. If nothing can be undone, write \"You can't undo this.\"\n- If Figma's confirm label differs from the title (\"Delete rule?\" / \"Delete permission\"), use the title's noun."
840
+ },
841
+ {
842
+ "id": "examples",
843
+ "heading": "Examples",
844
+ "markdown": "A delete confirmation with pending, success and failure handling.\n\nOpened from a row's actions menu, which sets the selected user and the dialog's own `open`\nstate. While the request runs, `Escape` and outside clicks are ignored; on success the row is\nremoved.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [users, setUsers] = useState(USERS);\n const [selected, setSelected] = useState<User | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) =>\n row.original.status === 'active' ? (\n <Badge variant='dotted' type='secondary' color='green'>\n Active\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='gray'>\n Deactivated\n </Badge>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleDeleteSelect = () => {\n setSelected(user);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n data-testid='users-actions'\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem\n variant='destructive'\n onSelect={handleDeleteSelect}\n data-testid='users-delete'\n >\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 220 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleDeleteOpenChange = (next: boolean) => {\n if (!pending) setDeleteOpen(next);\n };\n\n const handleDelete = async () => {\n if (!selected) return;\n setPending(true);\n try {\n await deleteUser(selected.id);\n setUsers(prev => prev.filter(user => user.id !== selected.id));\n setDeleteOpen(false);\n toast.create({ title: 'User deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={users}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={handleDeleteOpenChange}\n data-testid='delete-user-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete user?</DialogTitle>\n <DialogDescription>\n This permanently deletes {selected?.name} and all their access. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='delete-user-cancel'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-user-confirm'\n >\n Delete user\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nA state change that grants access uses the default brand confirm.\n\nThe description says what the user gets back. Once active, the same button deactivates: that\ndialog uses a destructive confirm and says the roles and data are kept.\n\n```tsx story=Activate\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Activate = () => {\n const toast = useToast();\n const [status, setStatus] = useState(INACTIVE_USER.status);\n const [open, setOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const isActive = status === 'active';\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (!pending) setOpen(next);\n };\n\n const handleStatusChange = async () => {\n const nextStatus: UserStatus = isActive ? 'deactivated' : 'active';\n setPending(true);\n try {\n await setUserStatus(INACTIVE_USER.id, nextStatus);\n setStatus(nextStatus);\n setOpen(false);\n toast.create({ title: isActive ? 'User deactivated' : 'User activated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-user' fullSize fixedHeight data-testid='user-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{INACTIVE_USER.name}</PageTitle>\n <PageDescription>\n {INACTIVE_USER.email} · {INACTIVE_USER.role}\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleOpen}\n data-testid='user-change-status'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog\n width={400}\n open={open}\n onOpenChange={handleOpenChange}\n data-testid='user-status-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{isActive ? 'Deactivate user?' : 'Activate user?'}</DialogTitle>\n <DialogDescription>\n {isActive\n ? `${INACTIVE_USER.name} loses access until you reactivate them. Their roles and data are kept.`\n : `${INACTIVE_USER.name} can sign in again with the roles they had before.`}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color={isActive ? 'destructive' : 'brand'}\n loading={pending}\n onClick={handleStatusChange}\n data-testid='user-status-confirm'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nType-to-confirm omits `width` and keeps confirm disabled until the name matches.\n\nFor a delete that also breaks linked objects. The match is exact and case-sensitive, and\nclosing the dialog resets the input to `''`.\n\n```tsx story=TypeToConfirm\nimport { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TypeToConfirm = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [value, setValue] = useState('');\n const [pending, setPending] = useState(false);\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (pending) return;\n setOpen(next);\n if (!next) setValue('');\n };\n\n const handleValueChange = (event: ChangeEvent<HTMLInputElement>) => {\n setValue(event.target.value);\n };\n\n const handleDelete = async () => {\n setPending(true);\n try {\n await deleteProvider(PROVIDER_NAME);\n setOpen(false);\n setValue('');\n toast.create({ title: 'Identity provider deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete identity provider\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{PROVIDER_NAME}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='destructive'\n onClick={handleOpen}\n data-testid='provider-delete'\n >\n Delete identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='delete-provider-dialog'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete identity provider?</DialogTitle>\n <DialogDescription>\n \"{PROVIDER_NAME}\" is unlinked and deleted, along with its rules. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogBody>\n <Input\n aria-label='Provider name'\n placeholder={`Enter \"${PROVIDER_NAME}\" to confirm`}\n value={value}\n onChange={handleValueChange}\n data-testid='delete-provider-input'\n />\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n disabled={value !== PROVIDER_NAME}\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-provider-confirm'\n >\n Delete identity provider\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nAn unsaved-changes guard intercepts the drawer's close and renders the dialog inside `DrawerContent`.\n\nWhile the form is dirty, the close X, Cancel, `Escape` and an outside click all open the same\ndialog. \"Keep editing\" returns to the form; \"Leave without saving\" drops the edits.\n\n```tsx story=UnsavedChanges\nimport { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const UnsavedChanges = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [guardOpen, setGuardOpen] = useState(false);\n const [savedName, setSavedName] = useState(PROVIDER_NAME);\n const [name, setName] = useState(PROVIDER_NAME);\n const [saving, setSaving] = useState(false);\n\n const isDirty = name !== savedName;\n\n const handleOpen = () => setOpen(true);\n\n const handleDrawerOpenChange = (next: boolean) => {\n if (saving) return;\n if (!next && isDirty) {\n setGuardOpen(true);\n return;\n }\n setOpen(next);\n };\n\n const handleGuardOpenChange = (next: boolean) => {\n if (!saving) setGuardOpen(next);\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n };\n\n const handleLeave = () => {\n setName(savedName);\n setGuardOpen(false);\n setOpen(false);\n };\n\n const handleSave = async () => {\n setSaving(true);\n try {\n await saveProvider({ name });\n setSavedName(name);\n setGuardOpen(false);\n setOpen(false);\n toast.create({ title: 'Identity provider updated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update identity provider\", type: 'error' });\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{savedName}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleOpen} data-testid='provider-edit'>\n Edit identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Drawer open={open} onOpenChange={handleDrawerOpenChange} data-testid='edit-provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Edit identity provider</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Field>\n <FieldLabel>Display name</FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='provider-name' />\n </Field>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button disabled={!isDirty} loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DrawerFooter>\n\n <Dialog\n open={guardOpen}\n onOpenChange={handleGuardOpenChange}\n data-testid='unsaved-changes-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Unsaved changes</DialogTitle>\n <DialogDescription>\n You have unsaved changes. Leaving will lose them unless you save.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='unsaved-changes-keep'>\n Keep editing\n </Button>\n </DialogClose>\n <Button\n variant='ghost'\n color='neutral'\n onClick={handleLeave}\n data-testid='unsaved-changes-leave'\n >\n Leave without saving\n </Button>\n <Button loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```"
845
+ },
846
+ {
847
+ "id": "states",
848
+ "heading": "States",
849
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Idle | Question, consequence, Cancel and confirm enabled | `Dialog` |\n| Type-to-confirm, empty or wrong | Confirm disabled | `Button disabled={value !== name}` |\n| Type-to-confirm, matches | Confirm enabled | `Button` |\n| Pending | Spinner on confirm, dialog stays open, close ignored | `Button loading={pending}` |\n| Success | Dialog closes, then a toast | `toast.create({ type: 'default' })` |\n| Failure | Dialog stays open, error toast, confirm enabled again | `toast.create({ type: 'error' })` |"
850
+ },
851
+ {
852
+ "id": "what-your-app-owns",
853
+ "heading": "What your app owns",
854
+ "markdown": "- The request, `pending` state, error handling and blocking close while pending.\n- Type-to-confirm matching and resetting the input on close.\n- Dirty tracking (your form library's flag) and intercepting the drawer close.\n- Route changes and tab close from a dirty form: use your router's blocker and `beforeunload`. The design system does not handle them.\n- Result toasts: `useToast` from `@wallarm-org/design-system/Toast`, with one `Toaster` at the app root.\n- All copy, including entity names."
855
+ },
856
+ {
857
+ "id": "related",
858
+ "heading": "Related",
859
+ "markdown": "- [Action-result toasts](feedback-toasts.md): the toast after a confirmed action.\n- [Entity detail drawer](detail-drawer.md): opens delete and state-change dialogs from its Actions menu.\n- [Create and edit forms](create-edit-forms.md): forms that need the unsaved-changes guard.\n- [Layering](../foundations/layering.md): nesting a `Dialog` inside a `Drawer`.\n- [Spacing](../foundations/spacing.md) and [Typography](../foundations/typography.md): the dialog's built-in padding and text styles."
860
+ }
861
+ ],
862
+ "stories": [
863
+ {
864
+ "name": "Default",
865
+ "code": "import { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [users, setUsers] = useState(USERS);\n const [selected, setSelected] = useState<User | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const renderStatus = ({ row }: TableCellContext<User, unknown>) =>\n row.original.status === 'active' ? (\n <Badge variant='dotted' type='secondary' color='green'>\n Active\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='gray'>\n Deactivated\n </Badge>\n );\n\n const renderActions = ({ row }: TableCellContext<User, unknown>) => {\n const user = row.original;\n const handleDeleteSelect = () => {\n setSelected(user);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${user.name}`}\n data-testid='users-actions'\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem\n variant='destructive'\n onSelect={handleDeleteSelect}\n data-testid='users-delete'\n >\n <DropdownMenuItemText>Delete user</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<User>[] = [\n { accessorKey: 'name', header: 'Name', size: 220 },\n { accessorKey: 'email', header: 'Email', size: 280 },\n { accessorKey: 'role', header: 'Role', size: 180 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n const handleDeleteOpenChange = (next: boolean) => {\n if (!pending) setDeleteOpen(next);\n };\n\n const handleDelete = async () => {\n if (!selected) return;\n setPending(true);\n try {\n await deleteUser(selected.id);\n setUsers(prev => prev.filter(user => user.id !== selected.id));\n setDeleteOpen(false);\n toast.create({ title: 'User deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-users' fullSize fixedHeight data-testid='users-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Users</PageTitle>\n <PageDescription>\n People who can sign in to your organization, with access set by roles and groups.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Users'\n data={users}\n columns={columns}\n getRowId={getUserId}\n data-testid='users-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={handleDeleteOpenChange}\n data-testid='delete-user-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete user?</DialogTitle>\n <DialogDescription>\n This permanently deletes {selected?.name} and all their access. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='delete-user-cancel'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-user-confirm'\n >\n Delete user\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};",
866
+ "description": "Opened from a row's actions menu, which sets the selected user and the dialog's own `open`\nstate. While the request runs, `Escape` and outside clicks are ignored; on success the row is\nremoved."
867
+ },
868
+ {
869
+ "name": "Activate",
870
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Activate = () => {\n const toast = useToast();\n const [status, setStatus] = useState(INACTIVE_USER.status);\n const [open, setOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const isActive = status === 'active';\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (!pending) setOpen(next);\n };\n\n const handleStatusChange = async () => {\n const nextStatus: UserStatus = isActive ? 'deactivated' : 'active';\n setPending(true);\n try {\n await setUserStatus(INACTIVE_USER.id, nextStatus);\n setStatus(nextStatus);\n setOpen(false);\n toast.create({ title: isActive ? 'User deactivated' : 'User activated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update user\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-user' fullSize fixedHeight data-testid='user-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{INACTIVE_USER.name}</PageTitle>\n <PageDescription>\n {INACTIVE_USER.email} · {INACTIVE_USER.role}\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleOpen}\n data-testid='user-change-status'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog\n width={400}\n open={open}\n onOpenChange={handleOpenChange}\n data-testid='user-status-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{isActive ? 'Deactivate user?' : 'Activate user?'}</DialogTitle>\n <DialogDescription>\n {isActive\n ? `${INACTIVE_USER.name} loses access until you reactivate them. Their roles and data are kept.`\n : `${INACTIVE_USER.name} can sign in again with the roles they had before.`}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color={isActive ? 'destructive' : 'brand'}\n loading={pending}\n onClick={handleStatusChange}\n data-testid='user-status-confirm'\n >\n {isActive ? 'Deactivate user' : 'Activate user'}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};",
871
+ "description": "The description says what the user gets back. Once active, the same button deactivates: that\ndialog uses a destructive confirm and says the roles and data are kept."
872
+ },
873
+ {
874
+ "name": "TypeToConfirm",
875
+ "code": "import { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TypeToConfirm = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [value, setValue] = useState('');\n const [pending, setPending] = useState(false);\n\n const handleOpen = () => setOpen(true);\n\n const handleOpenChange = (next: boolean) => {\n if (pending) return;\n setOpen(next);\n if (!next) setValue('');\n };\n\n const handleValueChange = (event: ChangeEvent<HTMLInputElement>) => {\n setValue(event.target.value);\n };\n\n const handleDelete = async () => {\n setPending(true);\n try {\n await deleteProvider(PROVIDER_NAME);\n setOpen(false);\n setValue('');\n toast.create({ title: 'Identity provider deleted', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't delete identity provider\", type: 'error' });\n } finally {\n setPending(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{PROVIDER_NAME}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='destructive'\n onClick={handleOpen}\n data-testid='provider-delete'\n >\n Delete identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='delete-provider-dialog'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete identity provider?</DialogTitle>\n <DialogDescription>\n \"{PROVIDER_NAME}\" is unlinked and deleted, along with its rules. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogBody>\n <Input\n aria-label='Provider name'\n placeholder={`Enter \"${PROVIDER_NAME}\" to confirm`}\n value={value}\n onChange={handleValueChange}\n data-testid='delete-provider-input'\n />\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n color='destructive'\n disabled={value !== PROVIDER_NAME}\n loading={pending}\n onClick={handleDelete}\n data-testid='delete-provider-confirm'\n >\n Delete identity provider\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};",
876
+ "description": "For a delete that also breaks linked objects. The match is exact and case-sensitive, and\nclosing the dialog resets the input to `''`."
877
+ },
878
+ {
879
+ "name": "UnsavedChanges",
880
+ "code": "import { type ChangeEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const UnsavedChanges = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [guardOpen, setGuardOpen] = useState(false);\n const [savedName, setSavedName] = useState(PROVIDER_NAME);\n const [name, setName] = useState(PROVIDER_NAME);\n const [saving, setSaving] = useState(false);\n\n const isDirty = name !== savedName;\n\n const handleOpen = () => setOpen(true);\n\n const handleDrawerOpenChange = (next: boolean) => {\n if (saving) return;\n if (!next && isDirty) {\n setGuardOpen(true);\n return;\n }\n setOpen(next);\n };\n\n const handleGuardOpenChange = (next: boolean) => {\n if (!saving) setGuardOpen(next);\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n };\n\n const handleLeave = () => {\n setName(savedName);\n setGuardOpen(false);\n setOpen(false);\n };\n\n const handleSave = async () => {\n setSaving(true);\n try {\n await saveProvider({ name });\n setSavedName(name);\n setGuardOpen(false);\n setOpen(false);\n toast.create({ title: 'Identity provider updated', type: 'default' });\n } catch {\n toast.create({ title: \"Couldn't update identity provider\", type: 'error' });\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <Page name='settings-sso-provider' fullSize fixedHeight data-testid='provider-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>{savedName}</PageTitle>\n <PageDescription>SAML identity provider for single sign-on.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleOpen} data-testid='provider-edit'>\n Edit identity provider\n </Button>\n </PageActions>\n </PageHeader>\n\n <Drawer open={open} onOpenChange={handleDrawerOpenChange} data-testid='edit-provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Edit identity provider</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Field>\n <FieldLabel>Display name</FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='provider-name' />\n </Field>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button disabled={!isDirty} loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DrawerFooter>\n\n <Dialog\n open={guardOpen}\n onOpenChange={handleGuardOpenChange}\n data-testid='unsaved-changes-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Unsaved changes</DialogTitle>\n <DialogDescription>\n You have unsaved changes. Leaving will lose them unless you save.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' data-testid='unsaved-changes-keep'>\n Keep editing\n </Button>\n </DialogClose>\n <Button\n variant='ghost'\n color='neutral'\n onClick={handleLeave}\n data-testid='unsaved-changes-leave'\n >\n Leave without saving\n </Button>\n <Button loading={saving} onClick={handleSave}>\n Save changes\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};",
881
+ "description": "While the form is dirty, the close X, Cancel, `Escape` and an outside click all open the same\ndialog. \"Keep editing\" returns to the form; \"Leave without saving\" drops the edits."
882
+ }
883
+ ]
884
+ },
885
+ {
886
+ "slug": "patterns/feedback-toasts",
887
+ "kind": "pattern",
888
+ "title": "Feedback toasts",
889
+ "description": "Use when a mutation finishes (row menu, bulk bar, dialog confirm, autosave, test run) and the app must report the result with a transient toast.",
890
+ "components": [
891
+ "Toaster",
892
+ "ToastActions",
893
+ "Button",
894
+ "Alert",
895
+ "Banner",
896
+ "Dialog",
897
+ "FieldError"
898
+ ],
899
+ "keywords": [
900
+ "toast",
901
+ "toaster",
902
+ "useToast",
903
+ "notification",
904
+ "snackbar",
905
+ "feedback",
906
+ "success message",
907
+ "error message",
908
+ "loading toast",
909
+ "action result",
910
+ "confirmation message",
911
+ "flash message",
912
+ "saved",
913
+ "deleted"
914
+ ],
915
+ "order": 40,
916
+ "markdown": "# Feedback toasts\n\nA toast reports the result of the user's last action, then leaves on its own.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Confirm a finished mutation (\"User deleted\") | Toast, `type: 'default'` | `Alert` in the page |\n| Show progress of a request that takes seconds | Toast, `type: 'loading'`, then `update` | A spinner in a blocking `Dialog` |\n| Report an async result (test sent, sync done) | Toast, `type: 'success'` or `'error'` | `type: 'default'` |\n| Offer one next step after create | Toast with `actions` | A second dialog |\n| Ask the user to decide | `Dialog` | Toast with two action buttons |\n| Show a result inside a form (connection test) | `Alert` in the form | Toast |\n| Show standing account or system state | `Banner` | Toast with a long `duration` |\n| Show a field validation error | `FieldError` under the field | Toast |\n\nIf the message is the only place the user learns something important, do not use a toast. It disappears.\n\n## Rules\n\n### Type and layout\n\n- If the user's own mutation finished, use `type: 'default'` (no icon). A delete confirmed in a `Dialog` is a finished mutation.\n- Otherwise use `'success'`, `'error'`, `'warning'` (finished with a caveat) or `'info'` (neutral notice).\n- Exception: save toasts on a settings page use `type: 'success'`. See [Settings page](./settings-page.md).\n- **Always** set `type`. Without it the toast renders as `'info'` with a blue icon.\n- A `description` always switches to the `extended` layout (title over description, 10000 ms), even with `variant: 'simple'`. Without it the toast is one 48px row, 5000 ms.\n- `icon` replaces the type icon and takes the type color unless it sets its own `text-*` class.\n\n### Placement and spacing\n\n- `Toaster` owns position (bottom center), 24px padding, 12px gaps and layering above every drawer and dialog. **Never** wrap it in a positioned element.\n- `Toast` owns its padding and width (256–560px). **Never** add spacing or width to toast content.\n- Three toasts show at once; hover expands the stack and pauses every timer.\n- Mount one `Toaster` at the app root. **Never** mount a second.\n\n### Loading and updates\n\n- If you create a `loading` toast, **always** resolve it with `update` or `dismiss`. It has no timer and never leaves on its own.\n- If an `update` adds a `description`, also pass `duration: 10000`. The timer is fixed at `create`.\n- Pass `closable: false` only to a `loading` toast the user must not lose.\n- If the user can repeat an action (copy, save), pass a stable `id` so the toast updates instead of stacking.\n\n### Copy\n\n| Case | Pattern | Example |\n| --- | --- | --- |\n| Mutation done | `<Object> <past-tense verb>` | \"User deleted\", \"Changes saved\" |\n| Toggle | `<Object> enabled` / `disabled` | \"Policy enabled\" |\n| In flight | `<Verb>ing <object>…` | \"Testing channel…\" |\n| Async success | `<Result> via <provider>` | \"Test delivered via Slack\" |\n| Failure | `<Action> failed — <cause>` | \"Test failed — Slack rejected the request: missing API key\" |\n| Create with next step | Title, one-sentence description, one action | \"Channel created\" + \"Go to destination\" |\n\n- Use sentence case and name the object, not \"Success\" or \"Done\". **Never** end a title with a period or \"!\".\n- If the error text is long, put the action in `title` and the cause in `description`.\n- **Never** pre-truncate copy; it clamps with a tooltip.\n\n### Behavior\n\n- Fire one toast per user action. A bulk action fires one toast for the batch (\"3 users deleted\").\n- If a `Dialog` confirmed the action, close the dialog first, then fire the toast.\n- Use `actions` only for a next step after create, one action (two max), each a `<Button variant='secondary' size='small' color='neutral-alt'>` inside `ToastActions`.\n- **Never** make Undo, Confirm or Retry available only in a toast.\n\n### Imports\n\n- Import `useToast` from `@wallarm-org/design-system/Toast`. It is not exported from the package root.\n- Outside React, use `toaster.create(options)` and `toaster.update(id, options)`.\n\n## Examples\n\nConfirm a finished mutation with a plain toast. A delete confirmed in a dialog closes the dialog first.\n\nThe whole page, with every toast from the one app-root `Toaster`: create, then send test, copy\nlink and delete from each row's menu. The parts below show each toast on its own.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [channels, setChannels] = useState(CHANNELS);\n const [deleteTarget, setDeleteTarget] = useState<Channel | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n const handleConfirmDelete = () => {\n if (!deleteTarget) return;\n onDeleteChannel(deleteTarget);\n setChannels(channels.filter(channel => channel.id !== deleteTarget.id));\n setDeleteOpen(false);\n toast.create({ id: `delete-${deleteTarget.id}`, title: 'Channel deleted', type: 'default' });\n };\n\n const renderActions = ({ row }: TableCellContext<Channel, unknown>) => {\n const channel = row.original;\n\n const handleTest = async () => {\n const id = toast.create({\n id: `test-${channel.id}`,\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: `Test delivered via ${channel.provider}`, type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(channel.link);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n const handleDelete = () => {\n setDeleteTarget(channel);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu data-testid='channel-actions'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${channel.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleTest}>\n <DropdownMenuItemText>Send test</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleCopyLink}>\n <DropdownMenuItemText>Copy link</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete channel</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<Channel>[] = [\n { accessorKey: 'name', header: 'Name', size: 240 },\n { accessorKey: 'provider', header: 'Provider', size: 160 },\n { accessorKey: 'events', header: 'Events', size: 320 },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='integrations-channels' fullSize fixedHeight data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Notification channels'\n data={channels}\n columns={columns}\n getRowId={getChannelId}\n data-testid='channels-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={setDeleteOpen}\n data-testid='channels-delete-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete channel?</DialogTitle>\n <DialogDescription>\n Wallarm stops sending notifications to {deleteTarget?.name}. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button color='destructive' onClick={handleConfirmDelete}>\n Delete channel\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nOffer one next step after create.\n\nThe description switches the toast to the extended layout, so it stays 10000 ms.\n\n```tsx story=NextStep\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const NextStep = () => {\n const toast = useToast();\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n return (\n <Page name='integrations-channels' data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nTurn a loading toast into the result in place.\n\nThe loading toast has no timer; the 5000 ms timer starts when `update` resolves it.\n\n```tsx story=TestDelivered\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestDelivered = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({\n id: 'test-ch-soc-alerts',\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: 'Test delivered via Slack', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>#soc-alerts</PageTitle>\n <PageDescription>Slack channel. Receives attacks and vulnerabilities.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nIf the failure adds a description, extend the timer in the same update.\n\nThe action goes in `title` and the provider's cause in `description`.\n\n```tsx story=TestFailed\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestFailed = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({ id: 'test-ch-siem', title: 'Testing channel…', type: 'loading' });\n try {\n await sendRejectedTestNotification();\n toast.update({ id, title: 'Test delivered via Splunk', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>SIEM ingest</PageTitle>\n <PageDescription>\n Splunk HTTP Event Collector. Receives attacks, incidents and system events.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nReuse an `id` so repeated clicks update one toast instead of stacking.\n\nIf the clipboard is blocked, the same id turns the toast into an error.\n\n```tsx story=CopyLink\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CopyLink = () => {\n const toast = useToast();\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(ONCALL_LINK);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Security on-call</PageTitle>\n <PageDescription>PagerDuty service. Receives incidents.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleCopyLink}\n data-testid='channel-copy-link'\n >\n Copy link\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Done | Plain toast, leaves after 5000 ms | `toast.create({ title, type: 'default' })` |\n| In flight | Spinner, no timer, no progress bar | `toast.create({ title, type: 'loading' })` |\n| Resolved | Same toast changes in place, timer starts | `toast.update({ id, title, type: 'success' })` |\n| Failed | Same toast turns into an error | `toast.update({ id, title, type: 'error' })` |\n| Repeated action | Visible toast updates, no copy | `toast.create({ id: 'copy-link', … })` |\n| Dismissed by code | One toast removed, or all | `toast.dismiss({ id })`, `toast.dismiss()` |\n\n## What your app owns\n\n- Toast copy, firing after the request resolves, resolving every `loading` toast, and stable ids.\n\nThese do not exist:\n\n- Placement or visible-count props. `Toaster` takes no props.\n- An Undo helper or a promise helper on `useToast`.\n- Rich text. `title` and `description` are strings.\n- `className` or `testId` options. The toast root gets `data-testid` equal to its `id`, so pass `id` to locate it in tests.\n- An exported duration constant. Use the literals 5000 and 10000.\n\n## Related\n\n- [Confirmation dialogs](./confirmation-dialogs.md): the dialog before most toasts.\n- [Settings page](./settings-page.md): autosave fires a \"<Field> updated\" toast.\n- [Create and edit forms](./create-edit-forms.md): the create toast with a next-step action.\n- [Layering](../foundations/layering.md): why toasts paint above drawers and dialogs.",
917
+ "sections": [
918
+ {
919
+ "id": "when-to-use",
920
+ "heading": "When to use",
921
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Confirm a finished mutation (\"User deleted\") | Toast, `type: 'default'` | `Alert` in the page |\n| Show progress of a request that takes seconds | Toast, `type: 'loading'`, then `update` | A spinner in a blocking `Dialog` |\n| Report an async result (test sent, sync done) | Toast, `type: 'success'` or `'error'` | `type: 'default'` |\n| Offer one next step after create | Toast with `actions` | A second dialog |\n| Ask the user to decide | `Dialog` | Toast with two action buttons |\n| Show a result inside a form (connection test) | `Alert` in the form | Toast |\n| Show standing account or system state | `Banner` | Toast with a long `duration` |\n| Show a field validation error | `FieldError` under the field | Toast |\n\nIf the message is the only place the user learns something important, do not use a toast. It disappears."
922
+ },
923
+ {
924
+ "id": "rules",
925
+ "heading": "Rules",
926
+ "markdown": "### Type and layout\n\n- If the user's own mutation finished, use `type: 'default'` (no icon). A delete confirmed in a `Dialog` is a finished mutation.\n- Otherwise use `'success'`, `'error'`, `'warning'` (finished with a caveat) or `'info'` (neutral notice).\n- Exception: save toasts on a settings page use `type: 'success'`. See [Settings page](./settings-page.md).\n- **Always** set `type`. Without it the toast renders as `'info'` with a blue icon.\n- A `description` always switches to the `extended` layout (title over description, 10000 ms), even with `variant: 'simple'`. Without it the toast is one 48px row, 5000 ms.\n- `icon` replaces the type icon and takes the type color unless it sets its own `text-*` class.\n\n### Placement and spacing\n\n- `Toaster` owns position (bottom center), 24px padding, 12px gaps and layering above every drawer and dialog. **Never** wrap it in a positioned element.\n- `Toast` owns its padding and width (256–560px). **Never** add spacing or width to toast content.\n- Three toasts show at once; hover expands the stack and pauses every timer.\n- Mount one `Toaster` at the app root. **Never** mount a second.\n\n### Loading and updates\n\n- If you create a `loading` toast, **always** resolve it with `update` or `dismiss`. It has no timer and never leaves on its own.\n- If an `update` adds a `description`, also pass `duration: 10000`. The timer is fixed at `create`.\n- Pass `closable: false` only to a `loading` toast the user must not lose.\n- If the user can repeat an action (copy, save), pass a stable `id` so the toast updates instead of stacking.\n\n### Copy\n\n| Case | Pattern | Example |\n| --- | --- | --- |\n| Mutation done | `<Object> <past-tense verb>` | \"User deleted\", \"Changes saved\" |\n| Toggle | `<Object> enabled` / `disabled` | \"Policy enabled\" |\n| In flight | `<Verb>ing <object>…` | \"Testing channel…\" |\n| Async success | `<Result> via <provider>` | \"Test delivered via Slack\" |\n| Failure | `<Action> failed — <cause>` | \"Test failed — Slack rejected the request: missing API key\" |\n| Create with next step | Title, one-sentence description, one action | \"Channel created\" + \"Go to destination\" |\n\n- Use sentence case and name the object, not \"Success\" or \"Done\". **Never** end a title with a period or \"!\".\n- If the error text is long, put the action in `title` and the cause in `description`.\n- **Never** pre-truncate copy; it clamps with a tooltip.\n\n### Behavior\n\n- Fire one toast per user action. A bulk action fires one toast for the batch (\"3 users deleted\").\n- If a `Dialog` confirmed the action, close the dialog first, then fire the toast.\n- Use `actions` only for a next step after create, one action (two max), each a `<Button variant='secondary' size='small' color='neutral-alt'>` inside `ToastActions`.\n- **Never** make Undo, Confirm or Retry available only in a toast.\n\n### Imports\n\n- Import `useToast` from `@wallarm-org/design-system/Toast`. It is not exported from the package root.\n- Outside React, use `toaster.create(options)` and `toaster.update(id, options)`."
927
+ },
928
+ {
929
+ "id": "examples",
930
+ "heading": "Examples",
931
+ "markdown": "Confirm a finished mutation with a plain toast. A delete confirmed in a dialog closes the dialog first.\n\nThe whole page, with every toast from the one app-root `Toaster`: create, then send test, copy\nlink and delete from each row's menu. The parts below show each toast on its own.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [channels, setChannels] = useState(CHANNELS);\n const [deleteTarget, setDeleteTarget] = useState<Channel | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n const handleConfirmDelete = () => {\n if (!deleteTarget) return;\n onDeleteChannel(deleteTarget);\n setChannels(channels.filter(channel => channel.id !== deleteTarget.id));\n setDeleteOpen(false);\n toast.create({ id: `delete-${deleteTarget.id}`, title: 'Channel deleted', type: 'default' });\n };\n\n const renderActions = ({ row }: TableCellContext<Channel, unknown>) => {\n const channel = row.original;\n\n const handleTest = async () => {\n const id = toast.create({\n id: `test-${channel.id}`,\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: `Test delivered via ${channel.provider}`, type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(channel.link);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n const handleDelete = () => {\n setDeleteTarget(channel);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu data-testid='channel-actions'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${channel.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleTest}>\n <DropdownMenuItemText>Send test</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleCopyLink}>\n <DropdownMenuItemText>Copy link</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete channel</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<Channel>[] = [\n { accessorKey: 'name', header: 'Name', size: 240 },\n { accessorKey: 'provider', header: 'Provider', size: 160 },\n { accessorKey: 'events', header: 'Events', size: 320 },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='integrations-channels' fullSize fixedHeight data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Notification channels'\n data={channels}\n columns={columns}\n getRowId={getChannelId}\n data-testid='channels-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={setDeleteOpen}\n data-testid='channels-delete-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete channel?</DialogTitle>\n <DialogDescription>\n Wallarm stops sending notifications to {deleteTarget?.name}. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button color='destructive' onClick={handleConfirmDelete}>\n Delete channel\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};\n```\n\nOffer one next step after create.\n\nThe description switches the toast to the extended layout, so it stays 10000 ms.\n\n```tsx story=NextStep\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const NextStep = () => {\n const toast = useToast();\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n return (\n <Page name='integrations-channels' data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nTurn a loading toast into the result in place.\n\nThe loading toast has no timer; the 5000 ms timer starts when `update` resolves it.\n\n```tsx story=TestDelivered\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestDelivered = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({\n id: 'test-ch-soc-alerts',\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: 'Test delivered via Slack', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>#soc-alerts</PageTitle>\n <PageDescription>Slack channel. Receives attacks and vulnerabilities.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nIf the failure adds a description, extend the timer in the same update.\n\nThe action goes in `title` and the provider's cause in `description`.\n\n```tsx story=TestFailed\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestFailed = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({ id: 'test-ch-siem', title: 'Testing channel…', type: 'loading' });\n try {\n await sendRejectedTestNotification();\n toast.update({ id, title: 'Test delivered via Splunk', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>SIEM ingest</PageTitle>\n <PageDescription>\n Splunk HTTP Event Collector. Receives attacks, incidents and system events.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```\n\nReuse an `id` so repeated clicks update one toast instead of stacking.\n\nIf the clipboard is blocked, the same id turns the toast into an error.\n\n```tsx story=CopyLink\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CopyLink = () => {\n const toast = useToast();\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(ONCALL_LINK);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Security on-call</PageTitle>\n <PageDescription>PagerDuty service. Receives incidents.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleCopyLink}\n data-testid='channel-copy-link'\n >\n Copy link\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};\n```"
932
+ },
933
+ {
934
+ "id": "states",
935
+ "heading": "States",
936
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Done | Plain toast, leaves after 5000 ms | `toast.create({ title, type: 'default' })` |\n| In flight | Spinner, no timer, no progress bar | `toast.create({ title, type: 'loading' })` |\n| Resolved | Same toast changes in place, timer starts | `toast.update({ id, title, type: 'success' })` |\n| Failed | Same toast turns into an error | `toast.update({ id, title, type: 'error' })` |\n| Repeated action | Visible toast updates, no copy | `toast.create({ id: 'copy-link', … })` |\n| Dismissed by code | One toast removed, or all | `toast.dismiss({ id })`, `toast.dismiss()` |"
937
+ },
938
+ {
939
+ "id": "what-your-app-owns",
940
+ "heading": "What your app owns",
941
+ "markdown": "- Toast copy, firing after the request resolves, resolving every `loading` toast, and stable ids.\n\nThese do not exist:\n\n- Placement or visible-count props. `Toaster` takes no props.\n- An Undo helper or a promise helper on `useToast`.\n- Rich text. `title` and `description` are strings.\n- `className` or `testId` options. The toast root gets `data-testid` equal to its `id`, so pass `id` to locate it in tests.\n- An exported duration constant. Use the literals 5000 and 10000."
942
+ },
943
+ {
944
+ "id": "related",
945
+ "heading": "Related",
946
+ "markdown": "- [Confirmation dialogs](./confirmation-dialogs.md): the dialog before most toasts.\n- [Settings page](./settings-page.md): autosave fires a \"<Field> updated\" toast.\n- [Create and edit forms](./create-edit-forms.md): the create toast with a next-step action.\n- [Layering](../foundations/layering.md): why toasts paint above drawers and dialogs."
947
+ }
948
+ ],
949
+ "stories": [
950
+ {
951
+ "name": "Default",
952
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { Ellipsis } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const Default = () => {\n const toast = useToast();\n const [channels, setChannels] = useState(CHANNELS);\n const [deleteTarget, setDeleteTarget] = useState<Channel | null>(null);\n const [deleteOpen, setDeleteOpen] = useState(false);\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n const handleConfirmDelete = () => {\n if (!deleteTarget) return;\n onDeleteChannel(deleteTarget);\n setChannels(channels.filter(channel => channel.id !== deleteTarget.id));\n setDeleteOpen(false);\n toast.create({ id: `delete-${deleteTarget.id}`, title: 'Channel deleted', type: 'default' });\n };\n\n const renderActions = ({ row }: TableCellContext<Channel, unknown>) => {\n const channel = row.original;\n\n const handleTest = async () => {\n const id = toast.create({\n id: `test-${channel.id}`,\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: `Test delivered via ${channel.provider}`, type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(channel.link);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n const handleDelete = () => {\n setDeleteTarget(channel);\n setDeleteOpen(true);\n };\n\n return (\n <DropdownMenu data-testid='channel-actions'>\n <DropdownMenuTrigger asChild>\n <Button\n variant='ghost'\n color='neutral'\n size='small'\n aria-label={`Actions for ${channel.name}`}\n >\n <Ellipsis />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleTest}>\n <DropdownMenuItemText>Send test</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleCopyLink}>\n <DropdownMenuItemText>Copy link</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete channel</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n };\n\n const columns: TableColumnDef<Channel>[] = [\n { accessorKey: 'name', header: 'Name', size: 240 },\n { accessorKey: 'provider', header: 'Provider', size: 160 },\n { accessorKey: 'events', header: 'Events', size: 320 },\n {\n id: 'actions',\n header: '',\n size: 60,\n minSize: 60,\n enableHiding: false,\n cell: renderActions,\n },\n ];\n\n return (\n <Page name='integrations-channels' fullSize fixedHeight data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='Notification channels'\n data={channels}\n columns={columns}\n getRowId={getChannelId}\n data-testid='channels-table'\n />\n </PageContent>\n\n <Dialog\n width={400}\n open={deleteOpen}\n onOpenChange={setDeleteOpen}\n data-testid='channels-delete-dialog'\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Delete channel?</DialogTitle>\n <DialogDescription>\n Wallarm stops sending notifications to {deleteTarget?.name}. You can't undo this.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button color='destructive' onClick={handleConfirmDelete}>\n Delete channel\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Page>\n );\n};",
953
+ "description": "The whole page, with every toast from the one app-root `Toaster`: create, then send test, copy\nlink and delete from each row's menu. The parts below show each toast on its own."
954
+ },
955
+ {
956
+ "name": "NextStep",
957
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { ToastActions, useToast } from '@wallarm-org/design-system/Toast';\n\nexport const NextStep = () => {\n const toast = useToast();\n\n const handleCreate = () => {\n toast.create({\n id: 'channel-created',\n title: 'Channel created',\n description: 'Route notifications through this channel in a destination.',\n type: 'default',\n actions: (\n <ToastActions>\n <Button variant='secondary' size='small' color='neutral-alt' onClick={onOpenDestination}>\n Go to destination\n </Button>\n </ToastActions>\n ),\n });\n };\n\n return (\n <Page name='integrations-channels' data-testid='channels-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Notification channels</PageTitle>\n <PageDescription>\n Where Wallarm sends attack, incident and system notifications.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={handleCreate} data-testid='channels-create'>\n Create channel\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};",
958
+ "description": "The description switches the toast to the extended layout, so it stays 10000 ms."
959
+ },
960
+ {
961
+ "name": "TestDelivered",
962
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestDelivered = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({\n id: 'test-ch-soc-alerts',\n title: 'Testing channel…',\n type: 'loading',\n });\n try {\n await sendTestNotification();\n toast.update({ id, title: 'Test delivered via Slack', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>#soc-alerts</PageTitle>\n <PageDescription>Slack channel. Receives attacks and vulnerabilities.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};",
963
+ "description": "The loading toast has no timer; the 5000 ms timer starts when `update` resolves it."
964
+ },
965
+ {
966
+ "name": "TestFailed",
967
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const TestFailed = () => {\n const toast = useToast();\n\n const handleTest = async () => {\n const id = toast.create({ id: 'test-ch-siem', title: 'Testing channel…', type: 'loading' });\n try {\n await sendRejectedTestNotification();\n toast.update({ id, title: 'Test delivered via Splunk', type: 'success' });\n } catch (error) {\n toast.update({\n id,\n title: 'Test failed',\n description: error instanceof Error ? error.message : undefined,\n type: 'error',\n duration: 10000,\n });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>SIEM ingest</PageTitle>\n <PageDescription>\n Splunk HTTP Event Collector. Receives attacks, incidents and system events.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleTest}\n data-testid='channel-send-test'\n >\n Send test\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};",
968
+ "description": "The action goes in `title` and the provider's cause in `description`."
969
+ },
970
+ {
971
+ "name": "CopyLink",
972
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Page, PageActions, PageDescription, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CopyLink = () => {\n const toast = useToast();\n\n const handleCopyLink = async () => {\n try {\n await navigator.clipboard.writeText(ONCALL_LINK);\n toast.create({ id: 'copy-link', title: 'Link copied', type: 'default' });\n } catch {\n toast.create({ id: 'copy-link', title: 'Copy failed', type: 'error' });\n }\n };\n\n return (\n <Page name='integrations-channel' data-testid='channel-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Security on-call</PageTitle>\n <PageDescription>PagerDuty service. Receives incidents.</PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button\n size='medium'\n variant='outline'\n color='neutral'\n onClick={handleCopyLink}\n data-testid='channel-copy-link'\n >\n Copy link\n </Button>\n </PageActions>\n </PageHeader>\n </Page>\n );\n};",
973
+ "description": "If the clipboard is blocked, the same id turns the toast into an error."
974
+ }
975
+ ]
976
+ },
977
+ {
978
+ "slug": "patterns/detail-drawer",
979
+ "kind": "pattern",
980
+ "title": "Detail drawer",
981
+ "description": "Use when a row click opens a read-only view of one entity in a side panel, with key-value attributes, sections, tabs and a nested table.",
982
+ "components": [
983
+ "Drawer",
984
+ "DrawerContent",
985
+ "DrawerHeader",
986
+ "DrawerTitle",
987
+ "DrawerBody",
988
+ "DrawerResizeHandle",
989
+ "Attribute",
990
+ "AttributeLabel",
991
+ "AttributeValue",
992
+ "Heading",
993
+ "Separator",
994
+ "HStack",
995
+ "VStack",
996
+ "Tabs",
997
+ "TabsList",
998
+ "TabsTrigger",
999
+ "TabsContent",
1000
+ "TabsLineActions",
1001
+ "NumericBadge",
1002
+ "DropdownMenu",
1003
+ "DropdownMenuTrigger",
1004
+ "DropdownMenuContent",
1005
+ "DropdownMenuItem",
1006
+ "DropdownMenuSeparator",
1007
+ "Button",
1008
+ "InlineCodeSnippet",
1009
+ "Tag",
1010
+ "FormatDateTime",
1011
+ "Table",
1012
+ "EmptyState",
1013
+ "EmptyStateMessage",
1014
+ "EmptyStateTitle",
1015
+ "Alert",
1016
+ "AlertContent",
1017
+ "AlertTitle",
1018
+ "AlertControls",
1019
+ "Tooltip",
1020
+ "Dialog"
1021
+ ],
1022
+ "keywords": [
1023
+ "drawer",
1024
+ "detail view",
1025
+ "details panel",
1026
+ "side panel",
1027
+ "side sheet",
1028
+ "preview",
1029
+ "inspector",
1030
+ "entity details",
1031
+ "read-only view",
1032
+ "attributes",
1033
+ "key value",
1034
+ "properties",
1035
+ "label value",
1036
+ "actions menu",
1037
+ "header actions",
1038
+ "tabs in drawer",
1039
+ "nested table",
1040
+ "drawer width",
1041
+ "700",
1042
+ "resize",
1043
+ "row click",
1044
+ "master cell"
1045
+ ],
1046
+ "order": 50,
1047
+ "markdown": "# Detail drawer\n\nA detail drawer is a `Drawer` with no footer. It shows one entity as `Attribute` rows, titled sections and an optional nested `Table`.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Read-only details of one row | This pattern, opened from `Table` `onMasterCellClick` | A new page, a `Dialog` |\n| Edit several fields | A separate edit drawer or dialog. See [Create and edit forms](./create-edit-forms.md) | Inputs inside the detail drawer |\n| Edit one field in place | `InlineEdit` inside `AttributeValue` | A form in the drawer |\n| Actions on the entity | One \"Actions\" `DropdownMenu` in `DrawerHeader` | Several header buttons, a footer |\n| Related collections (roles, rules, members) | `Tabs` with `NumericBadge` counts, one `Table` per tab | Stacked tables with no tabs |\n| A few titled groups of attributes | `Heading` sections split by `Separator` | `Accordion`, a `Card` per group |\n| Confirm a destructive action | A `Dialog` inside `DrawerContent`. See [Confirmation dialogs](./confirmation-dialogs.md) | `window.confirm` |\n| Keep the list usable while open | `modal={false} overlay={false} closeOnOutsideClick={false}` | Default props |\n\n## Rules\n\n**Drawer**\n\n- Mount one controlled `Drawer` (`open`, `onOpenChange`) for the list. **Never** one per row.\n- Open it from the list `Table` `onMasterCellClick`; mark the row with `activeRowId`. Row-menu clicks do not open it.\n- Keep the last entity in state after close so content does not blank.\n- Pass `width={640}`. Use 700 (the Figma frame) only if design confirms it. `width` is read once on mount.\n- If resizable, put `DrawerResizeHandle` first in `DrawerContent` (400–1130px).\n- **Never** add a `DrawerFooter` or your own close button.\n- In tests, pass `data-testid` to `Drawer`. **Never** select by `data-slot`.\n\n**Header actions**\n\n- One `DropdownMenu` in `DrawerHeader`, trigger `Button variant='outline' color='neutral' size='small'` \"Actions\" + `ChevronDown`.\n- Order: edit, state changes, \"Go to …\" links, `DropdownMenuSeparator`, destructive items.\n- Destructive items use `variant='destructive'` and name the entity: \"Delete provider\".\n- Keep the built-in 12px before the close X, though Figma shows 4px.\n\n**Spacing**\n\n- `DrawerBody` owns the 24px gutter. **Never** pad inside it or pass `className` to `DrawerHeader`/`DrawerBody`.\n- Attribute rows: `VStack gap={6} className='pb-8'`. Sections: `Separator spacing={4}`.\n- Section header: `HStack justify='between' align='center' className='py-8'`, `Heading as='h3' size='md' weight='medium'`, optional `Button size='small'`.\n- Full-width tabs: `<div className='-mx-24'>` around `TabsList`, not its `className`. Table 16px from the edge: `<div className='-mx-8'>` around `Table`, never on it. No other negative margins.\n\n**Attributes**\n\n- Always pass `orientation='horizontal'`; the default is vertical.\n- One `AttributeLabel` `width` per drawer: 140 matches Figma; up to 256 for long labels. **Never** wrap a label.\n- Two columns: `grid grid-cols-2 gap-x-8 gap-y-6`, default label width.\n- Section help: an `Info` icon in a `Tooltip` after the heading.\n\n| Data | Value | Not |\n| --- | --- | --- |\n| Plain text | The string as the `AttributeValue` child | A `Text` with your own size |\n| ID, URL, claim, scope, path | `InlineCodeSnippet code={value} size='md'` | `Code`, a monospace `span` |\n| Several codes | Several `InlineCodeSnippet` in `HStack gap={4}` | One joined string |\n| Role, tag, label | `Tag` | `Badge`, plain text |\n| Timestamp | `FormatDateTime format='datetime' layout='inline'` | `toLocaleString()` |\n| Secret | \"Set — never shown\" | Dots, a masked input |\n| Missing value | `Attribute isEmpty` | `N/A`, `0`, your own dash |\n| Icon with text | `HStack gap={6} align='center'` | An `img` with your own margin |\n\n`InlineCodeSnippet` copies on click; `copyable={false}` turns that off.\n\n**Tabs and tables**\n\n- Keep `Tabs` in `DrawerBody` to scroll with the attributes. Pinned tabs: `Tabs asChild` around `DrawerHeader`, `TabsList`, `DrawerBody` (`Drawer` `WithTabs` story); attributes then go in each `TabsContent`.\n- Tab action: `Button size='small'` in `TabsLineActions`.\n- Nested tables follow [Entity table](./entity-table.md). **Never** use `TableActionBar` in a drawer: it renders underneath.\n\n**Does not exist:** `DRAWER_SIZES`, a `size` prop on `Drawer`, `DrawerActions`, `DrawerHeaderActions`, `AttributeGroup`, `AttributeList`, `DescriptionList`, a section component, `fullBleed` on `TabsList` or `Table`.\n\n## Examples\n\nThe list opens one shared drawer from the master cell.\n\nClick a provider name: one shared, non-modal drawer opens and the row stays marked. The header\nhas a single \"Actions\" menu; the body has horizontal attributes (a code value, a secret, a\ntimestamp), then full-width tabs with counts, each with an inset nested table. Google Workspace\nhas no rules, so its table keeps the headers and shows a short empty state.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { ChevronDown, Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [tab, setTab] = useState('rules');\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n const renderVerifiedAt = ({ row }: TableCellContext<Domain, unknown>) => (\n <FormatDateTime value={row.original.verifiedAt} format='date' />\n );\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n const domainColumns: TableColumnDef<Domain>[] = [\n { accessorKey: 'name', header: 'Domain', size: 300 },\n { accessorKey: 'verifiedAt', header: 'Verified', size: 200, cell: renderVerifiedAt },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n const handleEdit = () => onEditProvider(provider);\n const handleDisable = () => onDisableProvider(provider);\n const handleGoToAuditLog = () => onGoToAuditLog(provider);\n const handleDelete = () => onDeleteProvider(provider);\n const handleCreateRule = () => onCreateRule(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerResizeHandle />\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {provider && (\n <>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!provider.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>\n {provider.defaultRole && <Tag>{provider.defaultRole}</Tag>}\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime\n value={provider.createdAt}\n format='datetime'\n layout='inline'\n />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{provider.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='domains'>\n Domains <NumericBadge>{provider.domains.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={provider.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {provider.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n <TabsContent value='domains'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Domains'\n data={provider.domains}\n columns={domainColumns}\n getRowId={getItemId}\n data-testid='provider-domains-table'\n >\n {provider.domains.length === 0 && (\n <EmptyState type='collection-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No domains yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\nThe drawer shell with a resize handle and the header actions menu.\n\nDrag the left edge to resize between 400 and 1130px; `onResizeEnd` gets the final width to\npersist. The close X comes built in, so there is no footer and no close button of your own.\n\n```tsx story=Header\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const Header = () => {\n const [open, setOpen] = useState(true);\n\n const handleEdit = () => onEditProvider(OKTA);\n const handleDisable = () => onDisableProvider(OKTA);\n const handleGoToAuditLog = () => onGoToAuditLog(OKTA);\n const handleDelete = () => onDeleteProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerResizeHandle onResizeEnd={onSaveDrawerWidth} />\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nAttribute rows with a code value, a secret, a missing value and a timestamp.\n\nEvery row is horizontal with the same 140px label. Entra ID has no default role, so that row\nstays and shows the empty marker; the codes copy on click.\n\n```tsx story=Attributes\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Attributes = () => {\n const [open, setOpen] = useState(true);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{ENTRA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={ENTRA.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!ENTRA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{ENTRA.defaultRole && <Tag>{ENTRA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={ENTRA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nA section with a title and an action.\n\nThe first group needs no title. Each later group starts with a `Separator` and a header row; the\naction applies to that section only.\n\n```tsx story=Section\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Section = () => {\n const [open, setOpen] = useState(true);\n\n const handleEditDefaults = () => onEditProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={OKTA.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <HStack\n justify='between'\n align='center'\n className='py-8'\n data-testid='provider-defaults-section'\n >\n <Heading as='h3' size='md' weight='medium'>\n Defaults\n </Heading>\n <Button size='small' variant='outline' color='neutral' onClick={handleEditDefaults}>\n Edit defaults\n </Button>\n </HStack>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal' isEmpty={!OKTA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{OKTA.defaultRole && <Tag>{OKTA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={OKTA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nFull-width tabs with a count and an inset nested table that stays visible when empty.\n\nGoogle Workspace has no rules yet: the table keeps its headers and shows a one-line empty state\nwith no action, since \"Create rule\" already sits on the tab line.\n\n```tsx story=NestedTable\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const NestedTable = () => {\n const [open, setOpen] = useState(true);\n const [tab, setTab] = useState('rules');\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n\n const handleCreateRule = () => onCreateRule(GOOGLE);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{GOOGLE.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{GOOGLE.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={GOOGLE.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {GOOGLE.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Entity loading | `loading` on every `Attribute` (skeleton label is 100 wide whatever `width`). Not a `Loader` over the body | `Attribute` |\n| Nested table loading | `isLoading` with `skeletonCount={3}` | `Table` |\n| Nested collection empty | Keep headers; `type='collection-empty'` as a direct `Table` child when `rows.length === 0 && !isLoading`. Title and description only, no `EmptyStateActions`. Not `TableEmptyState` (too tall) | `EmptyState` |\n| Field has no value | `isEmpty`; never hide the row | `Attribute` |\n| Viewing an old version | `color='info'` first in `DrawerBody`, \"Back to current\" and \"Restore\" in `AlertControls`; disable header actions | `Alert` |\n| Row open in the drawer | `activeRowId={selectedId}`; not a row `className` | `Table` |\n| Action running | `loading` on its button, then a toast; keep the drawer open | `Button` |\n\nThe entity is loading.\n\nThe entity is still loading: every `Attribute` shows its skeleton and the nested table shows\nthree skeleton rows. No `Loader` over the body, and the header keeps its title.\n\n```tsx story=Loading\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { accessorKey: 'value', header: 'Claim', size: 300 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider?.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>{provider?.issuer}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>{provider?.clientId}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>{provider?.createdAt}</AttributeValue>\n </Attribute>\n </VStack>\n <Separator spacing={4} />\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={[]}\n columns={ruleColumns}\n getRowId={getItemId}\n isLoading\n skeletonCount={3}\n data-testid='provider-rules-table'\n />\n </div>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\nViewing an old version.\n\nViewing an old version: an info `Alert` comes first in the body with \"Back to current\" and\n\"Restore\", and the header \"Actions\" menu is disabled until the reader goes back.\n\n```tsx story=OldVersion\nimport { useState } from 'react';\nimport { Alert, AlertContent, AlertControls, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const OldVersion = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [isOldVersion, setIsOldVersion] = useState(true);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n // The old version had a default role that was removed later.\n const defaultRole = isOldVersion ? 'Security analyst' : provider?.defaultRole;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setIsOldVersion(true);\n setOpen(true);\n };\n const handleBackToCurrent = () => setIsOldVersion(false);\n const handleRestore = () => onRestoreVersion(provider);\n const handleEdit = () => onEditProvider(provider);\n const handleDelete = () => onDeleteProvider(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small' disabled={isOldVersion}>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {isOldVersion && (\n <Alert color='info' data-testid='provider-version-alert'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>You are viewing the version from Oct 28, 2026</AlertTitle>\n <AlertDescription>Changes made after it are not shown.</AlertDescription>\n </AlertContent>\n <AlertControls>\n <Button\n variant='secondary'\n color='neutral'\n size='small'\n onClick={handleBackToCurrent}\n >\n Back to current\n </Button>\n <Button variant='secondary' color='neutral' size='small' onClick={handleRestore}>\n Restore\n </Button>\n </AlertControls>\n </Alert>\n )}\n {provider && (\n <VStack gap={6} className='pt-12 pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{defaultRole && <Tag>{defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n </VStack>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\n## What your app owns\n\n| You own | The DS owns |\n| --- | --- |\n| `selectedId`, `open`, fetching | Animation, focus trap, close X |\n| Width; persisting it via `onResizeEnd` | Resize drag and limits |\n| Attributes, sections, tabs, label width | Row height, truncation, skeletons |\n| Menu items and handlers | Menu positioning and keyboard |\n| Section headers, grid, bleed offsets | Body gutter, header spacing |\n| Confirmation, version restore | Nothing; compose `Dialog`, `Alert` |\n\n## Related\n\n- [Entity table](./entity-table.md), [Resource list page](./resource-list-page.md)\n- [Create and edit forms](./create-edit-forms.md), [Confirmation dialogs](./confirmation-dialogs.md)\n- [Action-result toasts](./feedback-toasts.md), [Empty states](./empty-states.md)\n- [Spacing](../foundations/spacing.md), [Layering](../foundations/layering.md)",
1048
+ "sections": [
1049
+ {
1050
+ "id": "when-to-use",
1051
+ "heading": "When to use",
1052
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Read-only details of one row | This pattern, opened from `Table` `onMasterCellClick` | A new page, a `Dialog` |\n| Edit several fields | A separate edit drawer or dialog. See [Create and edit forms](./create-edit-forms.md) | Inputs inside the detail drawer |\n| Edit one field in place | `InlineEdit` inside `AttributeValue` | A form in the drawer |\n| Actions on the entity | One \"Actions\" `DropdownMenu` in `DrawerHeader` | Several header buttons, a footer |\n| Related collections (roles, rules, members) | `Tabs` with `NumericBadge` counts, one `Table` per tab | Stacked tables with no tabs |\n| A few titled groups of attributes | `Heading` sections split by `Separator` | `Accordion`, a `Card` per group |\n| Confirm a destructive action | A `Dialog` inside `DrawerContent`. See [Confirmation dialogs](./confirmation-dialogs.md) | `window.confirm` |\n| Keep the list usable while open | `modal={false} overlay={false} closeOnOutsideClick={false}` | Default props |"
1053
+ },
1054
+ {
1055
+ "id": "rules",
1056
+ "heading": "Rules",
1057
+ "markdown": "**Drawer**\n\n- Mount one controlled `Drawer` (`open`, `onOpenChange`) for the list. **Never** one per row.\n- Open it from the list `Table` `onMasterCellClick`; mark the row with `activeRowId`. Row-menu clicks do not open it.\n- Keep the last entity in state after close so content does not blank.\n- Pass `width={640}`. Use 700 (the Figma frame) only if design confirms it. `width` is read once on mount.\n- If resizable, put `DrawerResizeHandle` first in `DrawerContent` (400–1130px).\n- **Never** add a `DrawerFooter` or your own close button.\n- In tests, pass `data-testid` to `Drawer`. **Never** select by `data-slot`.\n\n**Header actions**\n\n- One `DropdownMenu` in `DrawerHeader`, trigger `Button variant='outline' color='neutral' size='small'` \"Actions\" + `ChevronDown`.\n- Order: edit, state changes, \"Go to …\" links, `DropdownMenuSeparator`, destructive items.\n- Destructive items use `variant='destructive'` and name the entity: \"Delete provider\".\n- Keep the built-in 12px before the close X, though Figma shows 4px.\n\n**Spacing**\n\n- `DrawerBody` owns the 24px gutter. **Never** pad inside it or pass `className` to `DrawerHeader`/`DrawerBody`.\n- Attribute rows: `VStack gap={6} className='pb-8'`. Sections: `Separator spacing={4}`.\n- Section header: `HStack justify='between' align='center' className='py-8'`, `Heading as='h3' size='md' weight='medium'`, optional `Button size='small'`.\n- Full-width tabs: `<div className='-mx-24'>` around `TabsList`, not its `className`. Table 16px from the edge: `<div className='-mx-8'>` around `Table`, never on it. No other negative margins.\n\n**Attributes**\n\n- Always pass `orientation='horizontal'`; the default is vertical.\n- One `AttributeLabel` `width` per drawer: 140 matches Figma; up to 256 for long labels. **Never** wrap a label.\n- Two columns: `grid grid-cols-2 gap-x-8 gap-y-6`, default label width.\n- Section help: an `Info` icon in a `Tooltip` after the heading.\n\n| Data | Value | Not |\n| --- | --- | --- |\n| Plain text | The string as the `AttributeValue` child | A `Text` with your own size |\n| ID, URL, claim, scope, path | `InlineCodeSnippet code={value} size='md'` | `Code`, a monospace `span` |\n| Several codes | Several `InlineCodeSnippet` in `HStack gap={4}` | One joined string |\n| Role, tag, label | `Tag` | `Badge`, plain text |\n| Timestamp | `FormatDateTime format='datetime' layout='inline'` | `toLocaleString()` |\n| Secret | \"Set — never shown\" | Dots, a masked input |\n| Missing value | `Attribute isEmpty` | `N/A`, `0`, your own dash |\n| Icon with text | `HStack gap={6} align='center'` | An `img` with your own margin |\n\n`InlineCodeSnippet` copies on click; `copyable={false}` turns that off.\n\n**Tabs and tables**\n\n- Keep `Tabs` in `DrawerBody` to scroll with the attributes. Pinned tabs: `Tabs asChild` around `DrawerHeader`, `TabsList`, `DrawerBody` (`Drawer` `WithTabs` story); attributes then go in each `TabsContent`.\n- Tab action: `Button size='small'` in `TabsLineActions`.\n- Nested tables follow [Entity table](./entity-table.md). **Never** use `TableActionBar` in a drawer: it renders underneath.\n\n**Does not exist:** `DRAWER_SIZES`, a `size` prop on `Drawer`, `DrawerActions`, `DrawerHeaderActions`, `AttributeGroup`, `AttributeList`, `DescriptionList`, a section component, `fullBleed` on `TabsList` or `Table`."
1058
+ },
1059
+ {
1060
+ "id": "examples",
1061
+ "heading": "Examples",
1062
+ "markdown": "The list opens one shared drawer from the master cell.\n\nClick a provider name: one shared, non-modal drawer opens and the row stays marked. The header\nhas a single \"Actions\" menu; the body has horizontal attributes (a code value, a secret, a\ntimestamp), then full-width tabs with counts, each with an inset nested table. Google Workspace\nhas no rules, so its table keeps the headers and shows a short empty state.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { ChevronDown, Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [tab, setTab] = useState('rules');\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n const renderVerifiedAt = ({ row }: TableCellContext<Domain, unknown>) => (\n <FormatDateTime value={row.original.verifiedAt} format='date' />\n );\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n const domainColumns: TableColumnDef<Domain>[] = [\n { accessorKey: 'name', header: 'Domain', size: 300 },\n { accessorKey: 'verifiedAt', header: 'Verified', size: 200, cell: renderVerifiedAt },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n const handleEdit = () => onEditProvider(provider);\n const handleDisable = () => onDisableProvider(provider);\n const handleGoToAuditLog = () => onGoToAuditLog(provider);\n const handleDelete = () => onDeleteProvider(provider);\n const handleCreateRule = () => onCreateRule(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerResizeHandle />\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {provider && (\n <>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!provider.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>\n {provider.defaultRole && <Tag>{provider.defaultRole}</Tag>}\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime\n value={provider.createdAt}\n format='datetime'\n layout='inline'\n />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{provider.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='domains'>\n Domains <NumericBadge>{provider.domains.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={provider.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {provider.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n <TabsContent value='domains'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Domains'\n data={provider.domains}\n columns={domainColumns}\n getRowId={getItemId}\n data-testid='provider-domains-table'\n >\n {provider.domains.length === 0 && (\n <EmptyState type='collection-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No domains yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\nThe drawer shell with a resize handle and the header actions menu.\n\nDrag the left edge to resize between 400 and 1130px; `onResizeEnd` gets the final width to\npersist. The close X comes built in, so there is no footer and no close button of your own.\n\n```tsx story=Header\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const Header = () => {\n const [open, setOpen] = useState(true);\n\n const handleEdit = () => onEditProvider(OKTA);\n const handleDisable = () => onDisableProvider(OKTA);\n const handleGoToAuditLog = () => onGoToAuditLog(OKTA);\n const handleDelete = () => onDeleteProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerResizeHandle onResizeEnd={onSaveDrawerWidth} />\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nAttribute rows with a code value, a secret, a missing value and a timestamp.\n\nEvery row is horizontal with the same 140px label. Entra ID has no default role, so that row\nstays and shows the empty marker; the codes copy on click.\n\n```tsx story=Attributes\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Attributes = () => {\n const [open, setOpen] = useState(true);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{ENTRA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={ENTRA.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!ENTRA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{ENTRA.defaultRole && <Tag>{ENTRA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={ENTRA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nA section with a title and an action.\n\nThe first group needs no title. Each later group starts with a `Separator` and a header row; the\naction applies to that section only.\n\n```tsx story=Section\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Section = () => {\n const [open, setOpen] = useState(true);\n\n const handleEditDefaults = () => onEditProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={OKTA.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <HStack\n justify='between'\n align='center'\n className='py-8'\n data-testid='provider-defaults-section'\n >\n <Heading as='h3' size='md' weight='medium'>\n Defaults\n </Heading>\n <Button size='small' variant='outline' color='neutral' onClick={handleEditDefaults}>\n Edit defaults\n </Button>\n </HStack>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal' isEmpty={!OKTA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{OKTA.defaultRole && <Tag>{OKTA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={OKTA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```\n\nFull-width tabs with a count and an inset nested table that stays visible when empty.\n\nGoogle Workspace has no rules yet: the table keeps its headers and shows a one-line empty state\nwith no action, since \"Create rule\" already sits on the tab line.\n\n```tsx story=NestedTable\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const NestedTable = () => {\n const [open, setOpen] = useState(true);\n const [tab, setTab] = useState('rules');\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n\n const handleCreateRule = () => onCreateRule(GOOGLE);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{GOOGLE.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{GOOGLE.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={GOOGLE.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {GOOGLE.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};\n```"
1063
+ },
1064
+ {
1065
+ "id": "states",
1066
+ "heading": "States",
1067
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Entity loading | `loading` on every `Attribute` (skeleton label is 100 wide whatever `width`). Not a `Loader` over the body | `Attribute` |\n| Nested table loading | `isLoading` with `skeletonCount={3}` | `Table` |\n| Nested collection empty | Keep headers; `type='collection-empty'` as a direct `Table` child when `rows.length === 0 && !isLoading`. Title and description only, no `EmptyStateActions`. Not `TableEmptyState` (too tall) | `EmptyState` |\n| Field has no value | `isEmpty`; never hide the row | `Attribute` |\n| Viewing an old version | `color='info'` first in `DrawerBody`, \"Back to current\" and \"Restore\" in `AlertControls`; disable header actions | `Alert` |\n| Row open in the drawer | `activeRowId={selectedId}`; not a row `className` | `Table` |\n| Action running | `loading` on its button, then a toast; keep the drawer open | `Button` |\n\nThe entity is loading.\n\nThe entity is still loading: every `Attribute` shows its skeleton and the nested table shows\nthree skeleton rows. No `Loader` over the body, and the header keeps its title.\n\n```tsx story=Loading\nimport { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { accessorKey: 'value', header: 'Claim', size: 300 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider?.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>{provider?.issuer}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>{provider?.clientId}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>{provider?.createdAt}</AttributeValue>\n </Attribute>\n </VStack>\n <Separator spacing={4} />\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={[]}\n columns={ruleColumns}\n getRowId={getItemId}\n isLoading\n skeletonCount={3}\n data-testid='provider-rules-table'\n />\n </div>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```\n\nViewing an old version.\n\nViewing an old version: an info `Alert` comes first in the body with \"Back to current\" and\n\"Restore\", and the header \"Actions\" menu is disabled until the reader goes back.\n\n```tsx story=OldVersion\nimport { useState } from 'react';\nimport { Alert, AlertContent, AlertControls, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const OldVersion = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [isOldVersion, setIsOldVersion] = useState(true);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n // The old version had a default role that was removed later.\n const defaultRole = isOldVersion ? 'Security analyst' : provider?.defaultRole;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setIsOldVersion(true);\n setOpen(true);\n };\n const handleBackToCurrent = () => setIsOldVersion(false);\n const handleRestore = () => onRestoreVersion(provider);\n const handleEdit = () => onEditProvider(provider);\n const handleDelete = () => onDeleteProvider(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small' disabled={isOldVersion}>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {isOldVersion && (\n <Alert color='info' data-testid='provider-version-alert'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>You are viewing the version from Oct 28, 2026</AlertTitle>\n <AlertDescription>Changes made after it are not shown.</AlertDescription>\n </AlertContent>\n <AlertControls>\n <Button\n variant='secondary'\n color='neutral'\n size='small'\n onClick={handleBackToCurrent}\n >\n Back to current\n </Button>\n <Button variant='secondary' color='neutral' size='small' onClick={handleRestore}>\n Restore\n </Button>\n </AlertControls>\n </Alert>\n )}\n {provider && (\n <VStack gap={6} className='pt-12 pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{defaultRole && <Tag>{defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n </VStack>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};\n```"
1068
+ },
1069
+ {
1070
+ "id": "what-your-app-owns",
1071
+ "heading": "What your app owns",
1072
+ "markdown": "| You own | The DS owns |\n| --- | --- |\n| `selectedId`, `open`, fetching | Animation, focus trap, close X |\n| Width; persisting it via `onResizeEnd` | Resize drag and limits |\n| Attributes, sections, tabs, label width | Row height, truncation, skeletons |\n| Menu items and handlers | Menu positioning and keyboard |\n| Section headers, grid, bleed offsets | Body gutter, header spacing |\n| Confirmation, version restore | Nothing; compose `Dialog`, `Alert` |"
1073
+ },
1074
+ {
1075
+ "id": "related",
1076
+ "heading": "Related",
1077
+ "markdown": "- [Entity table](./entity-table.md), [Resource list page](./resource-list-page.md)\n- [Create and edit forms](./create-edit-forms.md), [Confirmation dialogs](./confirmation-dialogs.md)\n- [Action-result toasts](./feedback-toasts.md), [Empty states](./empty-states.md)\n- [Spacing](../foundations/spacing.md), [Layering](../foundations/layering.md)"
1078
+ }
1079
+ ],
1080
+ "stories": [
1081
+ {
1082
+ "name": "Default",
1083
+ "code": "import { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { ChevronDown, Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Default = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [tab, setTab] = useState('rules');\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n const renderVerifiedAt = ({ row }: TableCellContext<Domain, unknown>) => (\n <FormatDateTime value={row.original.verifiedAt} format='date' />\n );\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n const domainColumns: TableColumnDef<Domain>[] = [\n { accessorKey: 'name', header: 'Domain', size: 300 },\n { accessorKey: 'verifiedAt', header: 'Verified', size: 200, cell: renderVerifiedAt },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n const handleEdit = () => onEditProvider(provider);\n const handleDisable = () => onDisableProvider(provider);\n const handleGoToAuditLog = () => onGoToAuditLog(provider);\n const handleDelete = () => onDeleteProvider(provider);\n const handleCreateRule = () => onCreateRule(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerResizeHandle />\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {provider && (\n <>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!provider.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>\n {provider.defaultRole && <Tag>{provider.defaultRole}</Tag>}\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime\n value={provider.createdAt}\n format='datetime'\n layout='inline'\n />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{provider.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsTrigger value='domains'>\n Domains <NumericBadge>{provider.domains.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={provider.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {provider.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n <TabsContent value='domains'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Domains'\n data={provider.domains}\n columns={domainColumns}\n getRowId={getItemId}\n data-testid='provider-domains-table'\n >\n {provider.domains.length === 0 && (\n <EmptyState type='collection-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No domains yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};",
1084
+ "description": "Click a provider name: one shared, non-modal drawer opens and the row stays marked. The header\nhas a single \"Actions\" menu; the body has horizontal attributes (a code value, a secret, a\ntimestamp), then full-width tabs with counts, each with an inset nested table. Google Workspace\nhas no rules, so its table keeps the headers and shows a short empty state."
1085
+ },
1086
+ {
1087
+ "name": "Header",
1088
+ "code": "import { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerResizeHandle, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const Header = () => {\n const [open, setOpen] = useState(true);\n\n const handleEdit = () => onEditProvider(OKTA);\n const handleDisable = () => onDisableProvider(OKTA);\n const handleGoToAuditLog = () => onGoToAuditLog(OKTA);\n const handleDelete = () => onDeleteProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerResizeHandle onResizeEnd={onSaveDrawerWidth} />\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small'>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleDisable}>\n <DropdownMenuItemText>Disable provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuItem onSelect={handleGoToAuditLog}>\n <DropdownMenuItemText>Go to audit log</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};",
1089
+ "description": "Drag the left edge to resize between 400 and 1130px; `onResizeEnd` gets the final width to\npersist. The close X comes built in, so there is no footer and no close button of your own."
1090
+ },
1091
+ {
1092
+ "name": "Attributes",
1093
+ "code": "import { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Attributes = () => {\n const [open, setOpen] = useState(true);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{ENTRA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={ENTRA.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client secret</AttributeLabel>\n <AttributeValue>Set — never shown</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!ENTRA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{ENTRA.defaultRole && <Tag>{ENTRA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={ENTRA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};",
1094
+ "description": "Every row is horizontal with the same 140px label. Entra ID has no default role, so that row\nstays and shows the empty marker; the codes copy on click."
1095
+ },
1096
+ {
1097
+ "name": "Section",
1098
+ "code": "import { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { FormatDateTime } from '@wallarm-org/design-system/FormatDateTime';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const Section = () => {\n const [open, setOpen] = useState(true);\n\n const handleEditDefaults = () => onEditProvider(OKTA);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{OKTA.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{OKTA.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={OKTA.issuer} size='md' />\n </AttributeValue>\n </Attribute>\n </VStack>\n\n <Separator spacing={4} />\n\n <HStack\n justify='between'\n align='center'\n className='py-8'\n data-testid='provider-defaults-section'\n >\n <Heading as='h3' size='md' weight='medium'>\n Defaults\n </Heading>\n <Button size='small' variant='outline' color='neutral' onClick={handleEditDefaults}>\n Edit defaults\n </Button>\n </HStack>\n <VStack gap={6} className='pb-8'>\n <Attribute orientation='horizontal' isEmpty={!OKTA.defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{OKTA.defaultRole && <Tag>{OKTA.defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>\n <FormatDateTime value={OKTA.createdAt} format='datetime' layout='inline' />\n </AttributeValue>\n </Attribute>\n </VStack>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};",
1099
+ "description": "The first group needs no title. Each later group starts with a `Separator` and a header row; the\naction applies to that section only."
1100
+ },
1101
+ {
1102
+ "name": "NestedTable",
1103
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { EmptyState, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Plus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { type TableCellContext, type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tabs, TabsContent, TabsLineActions, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const NestedTable = () => {\n const [open, setOpen] = useState(true);\n const [tab, setTab] = useState('rules');\n\n const renderClaim = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <InlineCodeSnippet code={`${row.original.claim} = ${row.original.value}`} size='md' />\n );\n const renderRole = ({ row }: TableCellContext<RoleRule, unknown>) => (\n <Tag>{row.original.role}</Tag>\n );\n\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { id: 'claim', header: 'Claim', size: 300, cell: renderClaim },\n { accessorKey: 'role', header: 'Role', size: 200, cell: renderRole },\n ];\n\n const handleCreateRule = () => onCreateRule(GOOGLE);\n\n return (\n <Drawer open={open} onOpenChange={setOpen} width={640} data-testid='provider-drawer'>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{GOOGLE.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <Tabs value={tab} onChange={setTab} data-testid='provider-tabs'>\n <div className='-mx-24'>\n <TabsList>\n <TabsTrigger value='rules'>\n Role rules <NumericBadge>{GOOGLE.rules.length}</NumericBadge>\n </TabsTrigger>\n <TabsLineActions>\n <Button size='small' onClick={handleCreateRule}>\n <Plus /> Create rule\n </Button>\n </TabsLineActions>\n </TabsList>\n </div>\n <TabsContent value='rules'>\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={GOOGLE.rules}\n columns={ruleColumns}\n getRowId={getItemId}\n data-testid='provider-rules-table'\n >\n {GOOGLE.rules.length === 0 && (\n <EmptyState type='collection-empty' data-testid='provider-rules-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>No role rules yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </TabsContent>\n </Tabs>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n );\n};",
1104
+ "description": "Google Workspace has no rules yet: the table keeps its headers and shows a one-line empty state\nwith no action, since \"Create rule\" already sits on the tab line."
1105
+ },
1106
+ {
1107
+ "name": "Loading",
1108
+ "code": "import { useState } from 'react';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\n\nexport const Loading = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n const ruleColumns: TableColumnDef<RoleRule>[] = [\n { accessorKey: 'value', header: 'Claim', size: 300 },\n { accessorKey: 'role', header: 'Role', size: 200 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setOpen(true);\n };\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n </DrawerHeader>\n <DrawerBody>\n <VStack gap={6} className='pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider?.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Issuer URL</AttributeLabel>\n <AttributeValue>{provider?.issuer}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>{provider?.clientId}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' loading>\n <AttributeLabel width={140}>Created at</AttributeLabel>\n <AttributeValue>{provider?.createdAt}</AttributeValue>\n </Attribute>\n </VStack>\n <Separator spacing={4} />\n <div className='-mx-8 pt-8'>\n <Table\n aria-label='Role rules'\n data={[]}\n columns={ruleColumns}\n getRowId={getItemId}\n isLoading\n skeletonCount={3}\n data-testid='provider-rules-table'\n />\n </div>\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};",
1109
+ "description": "The entity is still loading: every `Attribute` shows its skeleton and the nested table shows\nthree skeleton rows. No `Loader` over the body, and the header keeps its title."
1110
+ },
1111
+ {
1112
+ "name": "OldVersion",
1113
+ "code": "import { useState } from 'react';\nimport { Alert, AlertContent, AlertControls, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Attribute, AttributeLabel, AttributeValue } from '@wallarm-org/design-system/Attribute';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { InlineCodeSnippet } from '@wallarm-org/design-system/CodeSnippet';\nimport { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from '@wallarm-org/design-system/Drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemText, DropdownMenuSeparator, DropdownMenuTrigger } from '@wallarm-org/design-system/DropdownMenu';\nimport { ChevronDown } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { type TableColumnDef, Table } from '@wallarm-org/design-system/Table';\nimport { Tag } from '@wallarm-org/design-system/Tag';\n\nexport const OldVersion = () => {\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [open, setOpen] = useState(false);\n const [isOldVersion, setIsOldVersion] = useState(true);\n\n const provider = PROVIDERS.find(item => item.id === selectedId) ?? null;\n // The old version had a default role that was removed later.\n const defaultRole = isOldVersion ? 'Security analyst' : provider?.defaultRole;\n\n const columns: TableColumnDef<Provider>[] = [\n { accessorKey: 'name', header: 'Name', size: 260 },\n { accessorKey: 'protocol', header: 'Protocol', size: 180 },\n { accessorKey: 'issuer', header: 'Issuer', size: 360 },\n ];\n\n const handleMasterCellClick = (id: string) => {\n setSelectedId(id);\n setIsOldVersion(true);\n setOpen(true);\n };\n const handleBackToCurrent = () => setIsOldVersion(false);\n const handleRestore = () => onRestoreVersion(provider);\n const handleEdit = () => onEditProvider(provider);\n const handleDelete = () => onDeleteProvider(provider);\n\n return (\n <Page name='settings-sso' fullSize fixedHeight data-testid='providers-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>SSO providers</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <Table\n aria-label='SSO providers'\n data={PROVIDERS}\n columns={columns}\n getRowId={getProviderId}\n activeRowId={open ? selectedId : null}\n onMasterCellClick={handleMasterCellClick}\n data-testid='providers-table'\n />\n </PageContent>\n\n <Drawer\n open={open}\n onOpenChange={setOpen}\n modal={false}\n overlay={false}\n closeOnOutsideClick={false}\n width={640}\n data-testid='provider-drawer'\n >\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>{provider?.name}</DrawerTitle>\n <DropdownMenu data-testid='provider-actions'>\n <DropdownMenuTrigger asChild>\n <Button variant='outline' color='neutral' size='small' disabled={isOldVersion}>\n Actions <ChevronDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n <DropdownMenuItem onSelect={handleEdit}>\n <DropdownMenuItemText>Edit provider</DropdownMenuItemText>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant='destructive' onSelect={handleDelete}>\n <DropdownMenuItemText>Delete provider</DropdownMenuItemText>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </DrawerHeader>\n\n <DrawerBody>\n {isOldVersion && (\n <Alert color='info' data-testid='provider-version-alert'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>You are viewing the version from Oct 28, 2026</AlertTitle>\n <AlertDescription>Changes made after it are not shown.</AlertDescription>\n </AlertContent>\n <AlertControls>\n <Button\n variant='secondary'\n color='neutral'\n size='small'\n onClick={handleBackToCurrent}\n >\n Back to current\n </Button>\n <Button variant='secondary' color='neutral' size='small' onClick={handleRestore}>\n Restore\n </Button>\n </AlertControls>\n </Alert>\n )}\n {provider && (\n <VStack gap={6} className='pt-12 pb-8' data-testid='provider-attributes'>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Protocol</AttributeLabel>\n <AttributeValue>{provider.protocol}</AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal'>\n <AttributeLabel width={140}>Client ID</AttributeLabel>\n <AttributeValue>\n <InlineCodeSnippet code={provider.clientId} size='md' />\n </AttributeValue>\n </Attribute>\n <Attribute orientation='horizontal' isEmpty={!defaultRole}>\n <AttributeLabel width={140}>Default role</AttributeLabel>\n <AttributeValue>{defaultRole && <Tag>{defaultRole}</Tag>}</AttributeValue>\n </Attribute>\n </VStack>\n )}\n </DrawerBody>\n </DrawerContent>\n </Drawer>\n </Page>\n );\n};",
1114
+ "description": "Viewing an old version: an info `Alert` comes first in the body with \"Back to current\" and\n\"Restore\", and the header \"Actions\" menu is disabled until the reader goes back."
1115
+ }
1116
+ ]
1117
+ },
1118
+ {
1119
+ "slug": "patterns/create-edit-forms",
1120
+ "kind": "pattern",
1121
+ "title": "Create and edit forms",
1122
+ "description": "Use when building a form that creates or edits an entity, and you must choose between a Dialog, a Drawer with Timeline sections, or a Drawer with a Stepper.",
1123
+ "components": [
1124
+ "Dialog",
1125
+ "Drawer",
1126
+ "Field",
1127
+ "Input",
1128
+ "Textarea",
1129
+ "Timeline",
1130
+ "Stepper",
1131
+ "Button",
1132
+ "VStack",
1133
+ "Tooltip",
1134
+ "Alert",
1135
+ "Table",
1136
+ "InlineEdit",
1137
+ "Attribute",
1138
+ "Toaster"
1139
+ ],
1140
+ "keywords": [
1141
+ "form",
1142
+ "create",
1143
+ "edit",
1144
+ "new",
1145
+ "add",
1146
+ "wizard",
1147
+ "steps",
1148
+ "stepper",
1149
+ "multi-step",
1150
+ "sections",
1151
+ "timeline",
1152
+ "drawer form",
1153
+ "modal form",
1154
+ "dialog form",
1155
+ "field",
1156
+ "label",
1157
+ "required",
1158
+ "optional",
1159
+ "validation",
1160
+ "error",
1161
+ "submit",
1162
+ "cancel",
1163
+ "unsaved changes"
1164
+ ],
1165
+ "order": 60,
1166
+ "markdown": "# Create and edit forms\n\nForm size picks the container.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Up to 6 fields, one concern (create user) | `Dialog` with no `width` (560) | A `Drawer` for three inputs |\n| Dependent sections seen together (create provider) | `Drawer width={700}` + `Timeline` with numbered sections | A `Stepper` hiding sections |\n| Gated parts, each needs the previous (create policy) | `Drawer` with no `width` (640) + `Stepper` | One long `Timeline` with 20 fields |\n| Pick many rows (\"Add users to group\") | `Dialog width={960}` with a selectable `Table` | A 200-item multi-select |\n| Edit one value on a detail view | `InlineEdit` on the `Attribute` | A drawer for one field |\n| Independent values saved one by one | [Settings page](settings-page.md) | A drawer opened from the page |\n| A yes/no before an action | [Confirmation dialogs](confirmation-dialogs.md) | A form dialog with no fields |\n\n- Edit uses the same container as create.\n- There is no `Form`, `FormDialog`, `FormDrawer`, `Wizard`, vertical `Stepper` or form-level validation component.\n\n## Rules\n\n### Container and layout\n\n- Wrap body and footer (or the whole `Stepper`) in `<form className='flex min-h-0 flex-1 flex-col'>` so Enter submits.\n- Size with `width` (400 to 1130): `Dialog` 560 default, 400/960 presets; `Drawer` 640 default, `700` for Timeline forms. There is no `size` prop or exported size constant.\n- Bodies own their padding. **Never** add `p-*`, `px-*` or `py-*` around their content.\n- **Never** pass `className` to `DrawerHeader`, `DrawerBody`, `DrawerFooter`, their `Dialog*` aliases, or `Field`. Size the control or grid cell instead.\n- **Never** add a close X; the header has one.\n- Space fields with `<VStack gap={24}>` in a dialog and `<VStack gap={12}>` in a drawer section. Not `FieldGroup` (fixed 16px). There is no `gap` prop on `Field` or `TimelineContent`.\n- **Never** style `FieldGroup`, `FieldLabel` or `FieldDescription` with `className`; it is ignored.\n- Add `className='py-16'` to `StepperContent`.\n- If two related fields are short (name and ID, port and protocol), put them in `grid grid-cols-2 gap-12`. URLs, secrets and free text get full width.\n- Number `Timeline` sections in `TimelineIndicator`; title them with a noun phrase.\n- `Stepper` wraps `DrawerBody` and `DrawerFooter`; its Prev/Next triggers work only inside it. Give it `className='flex min-h-0 flex-1 flex-col'`.\n- Inside `Stepper`: `StepperList aria-label='…'` (one `StepperItem index status` per step), then `DrawerBody` holding one `StepperContent index` per step, then `DrawerFooter`.\n- Max 6 `Stepper` steps. Vertical numbered sections are a `Timeline`, even if Figma says \"stepper\".\n\n### Fields\n\n- Wrap every control in `Field`; **never** a bare `<label>`.\n- Order the parts: `FieldLabel` (with `FieldIndicator`), `FieldDescription`, control, `FieldError`.\n- Put `required` on `Field` and `FieldIndicator` in every label (`*` or \"(Optional)\"). **Never** type \"(optional)\" yourself.\n- Pass `error` and `disabled` to the control itself. `Field invalid` and `Field disabled` do not reach `Input` or `Textarea`.\n- One helper line at most, stating the consequence.\n- Show the provider icon in the select option and trigger.\n\n### Buttons and copy\n\n| Element | Write | Not |\n| --- | --- | --- |\n| Title | \"Create identity provider\" / \"Edit identity provider\" | \"New\", \"Provider settings\" |\n| Submit | Title's verb + entity; edit: \"Save changes\" | \"Submit\", \"OK\", \"Update\" |\n| Stepper | \"Next\"; last step: \"Create policy\" | \"Continue →\", \"Finish\" |\n| Description | What happens after submit: \"We send an invitation link to this email.\" | A restatement of the title |\n| Cancel | \"Cancel\" | \"Close\", \"Discard\" |\n| Error | \"Enter a client ID.\" | \"Invalid\", \"Field is required!\" |\n\n- Submit is the default primary `Button type='submit'`, last on the right. Cancel is `variant='ghost' color='neutral'` in `DialogClose`/`DrawerClose asChild`.\n- Omit `size` on footer buttons.\n- **Never** put submit in the header or add a second primary.\n- Stepper footer: Cancel left; Back (`variant='outline' color='neutral'`) and Next right. Back disables itself on step 1; don't wire `disabled`.\n- On the last step, render submit instead of `StepperNextTrigger` (disabled there).\n\n### Behavior\n\n- Call `event.preventDefault()`. Close only after success.\n- Edit: prefill fields; disable submit until a change.\n- Show `FieldError` only after blur or submit.\n- `Stepper` never blocks steps. Set `status='danger'` on steps with errors, `'completed'` on valid ones. There is no `linear`, `isStepValid` or `orientation` prop.\n\n## Examples\n\nA dialog form with a 24px field stack.\n\nSubmit stays disabled until the required fields are filled, the email error shows only after\nblur or submit, and a successful request closes the dialog, then shows a toast.\n\n```tsx story=CreateInDialog\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [email, setEmail] = useState('');\n const [message, setMessage] = useState('');\n const [emailTouched, setEmailTouched] = useState(false);\n const [pending, setPending] = useState(false);\n\n const emailInvalid = emailTouched && !EMAIL_PATTERN.test(email.trim());\n const canSubmit = name.trim() !== '' && email.trim() !== '';\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName('');\n setEmail('');\n setMessage('');\n setEmailTouched(false);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handleEmailBlur = () => setEmailTouched(true);\n const handleMessageChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setMessage(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setEmailTouched(true);\n if (!EMAIL_PATTERN.test(email.trim())) return;\n setPending(true);\n await saveRequest();\n setPending(false);\n handleOpenChange(false);\n toast.create({ id: 'user-created', title: 'User created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='create-user-dialog'>\n <DialogTrigger asChild>\n <Button>Create user</Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Create user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Maria Lopez'\n data-testid='create-user-name'\n />\n </Field>\n <Field required data-testid='create-user-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>We send an invitation link to this email.</FieldDescription>\n <Input\n type='email'\n value={email}\n error={emailInvalid}\n onChange={handleEmailChange}\n onBlur={handleEmailBlur}\n placeholder='maria.lopez@acme-bank.com'\n data-testid='create-user-email'\n />\n {emailInvalid && <FieldError>Enter an email address.</FieldError>}\n </Field>\n <Field>\n <FieldLabel>\n Message\n <FieldIndicator />\n </FieldLabel>\n <Textarea\n value={message}\n onChange={handleMessageChange}\n placeholder='Added to the SOC on-call rotation.'\n />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-user-submit'\n >\n Create user\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```\n\nAn invalid field: `error` goes on the control, `FieldError` renders only while invalid.\n\nShown after blur: the description stays above the control, the error line below it.\n\n```tsx story=InvalidField\nimport { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\n\nexport const InvalidField = () => (\n <div className='w-400 p-24'>\n <Field required data-testid='invalid-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>The invitation link expires in 7 days.</FieldDescription>\n <Input type='email' defaultValue='maria.lopez' error />\n <FieldError>Enter an email address.</FieldError>\n </Field>\n </div>\n);\n```\n\nEdit reuses the create container. A field fixed after create is disabled, with a tooltip on a wrapping `span`.\n\nFields are prefilled, and \"Save changes\" stays disabled until a value changes.\n\n```tsx story=EditInDialog\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const EditInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState(USER.name);\n const [email, setEmail] = useState(USER.email);\n const [pending, setPending] = useState(false);\n\n const dirty = name !== USER.name || email !== USER.email;\n const canSubmit = dirty && name.trim() !== '' && EMAIL_PATTERN.test(email.trim());\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName(USER.name);\n setEmail(USER.email);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'user-updated', title: 'User updated', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='edit-user-dialog'>\n <DialogTrigger asChild>\n <Button variant='outline' color='neutral'>\n Edit user\n </Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Edit user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n User ID\n <FieldIndicator />\n </FieldLabel>\n <Tooltip>\n <TooltipTrigger asChild>\n <span>\n <Input value={USER.id} disabled data-testid='edit-user-id' />\n </span>\n </TooltipTrigger>\n <TooltipContent>The ID can't change after the user is created.</TooltipContent>\n </Tooltip>\n </Field>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='edit-user-name' />\n </Field>\n <Field required>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <Input type='email' value={email} onChange={handleEmailChange} />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='edit-user-submit'\n >\n Save changes\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```\n\nNumbered `Timeline` sections, each a 12px field stack, inside a `Drawer width={700}` body.\n\nShort related fields share a two-column row; URLs and secrets get full width. \"Test\nconnection\" reports in its section with an `Alert`, never a toast.\n\n```tsx story=CreateInDrawerWithSections\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Timeline, TimelineConnector, TimelineContent, TimelineIndicator, TimelineItem, TimelineSeparator, TimelineTitle } from '@wallarm-org/design-system/Timeline';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSections = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [slug, setSlug] = useState('');\n const [domain, setDomain] = useState('');\n const [clientId, setClientId] = useState('');\n const [clientSecret, setClientSecret] = useState('');\n const [testing, setTesting] = useState(false);\n const [connected, setConnected] = useState(false);\n const [pending, setPending] = useState(false);\n\n const credentialsFilled = domain.trim() !== '' && clientId.trim() !== '' && clientSecret !== '';\n const canSubmit = name.trim() !== '' && slug.trim() !== '' && credentialsFilled;\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleSlugChange = (event: ChangeEvent<HTMLInputElement>) => setSlug(event.target.value);\n const handleDomainChange = (event: ChangeEvent<HTMLInputElement>) => {\n setDomain(event.target.value);\n setConnected(false);\n };\n const handleClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientId(event.target.value);\n setConnected(false);\n };\n const handleClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientSecret(event.target.value);\n setConnected(false);\n };\n\n const handleTestConnection = async () => {\n setTesting(true);\n await saveRequest();\n setTesting(false);\n setConnected(true);\n };\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'provider-created', title: 'Identity provider created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} width={700} data-testid='create-provider-drawer'>\n <DrawerTrigger asChild>\n <Button>Create identity provider</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create identity provider</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DrawerBody>\n <Timeline data-testid='create-provider-sections'>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>1</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Provider details</TimelineTitle>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} placeholder='Okta SSO' />\n </Field>\n <Field required>\n <FieldLabel>\n Provider ID\n <FieldIndicator />\n </FieldLabel>\n <Input value={slug} onChange={handleSlugChange} placeholder='okta-sso' />\n </Field>\n </div>\n </VStack>\n </TimelineContent>\n </TimelineItem>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>2</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Okta connection</TimelineTitle>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Okta domain\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={domain}\n onChange={handleDomainChange}\n placeholder='https://acme-bank.okta.com'\n data-testid='create-provider-domain'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client ID\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={clientId}\n onChange={handleClientIdChange}\n data-testid='create-provider-client-id'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client secret\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Stored encrypted; you can't view it again.\n </FieldDescription>\n <Input\n type='password'\n value={clientSecret}\n onChange={handleClientSecretChange}\n data-testid='create-provider-client-secret'\n />\n </Field>\n <div>\n <Button\n variant='outline'\n color='neutral'\n disabled={!credentialsFilled}\n loading={testing}\n onClick={handleTestConnection}\n data-testid='create-provider-test'\n >\n Test connection\n </Button>\n </div>\n {connected && (\n <Alert color='success' data-testid='create-provider-test-result'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>Connected to Okta</AlertTitle>\n <AlertDescription>\n Wallarm reached acme-bank.okta.com with these credentials.\n </AlertDescription>\n </AlertContent>\n </Alert>\n )}\n </VStack>\n </TimelineContent>\n </TimelineItem>\n </Timeline>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-provider-submit'\n >\n Create identity provider\n </Button>\n </DrawerFooter>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};\n```\n\nA `Stepper` wrapping body and footer; submit replaces Next on the last step.\n\nCancel sits left, Back and Next right. Steps never block: a step left with empty required\nfields turns `danger`, a filled one `completed`.\n\n```tsx story=CreateInDrawerWithSteps\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerFooterControls, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Stepper, StepperContent, StepperIndicator, StepperItem, type StepperItemStatus, StepperList, StepperNextTrigger, StepperPrevTrigger, StepperSeparator, StepperTitle, StepperTrigger } from '@wallarm-org/design-system/Stepper';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSteps = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [step, setStep] = useState(0);\n const [name, setName] = useState('');\n const [description, setDescription] = useState('');\n const [host, setHost] = useState('');\n const [path, setPath] = useState('');\n const [rps, setRps] = useState('');\n const [pending, setPending] = useState(false);\n const [visited, setVisited] = useState<number[]>([]);\n\n const valid = [name.trim() !== '', host.trim() !== '' && path.trim() !== '', rps.trim() !== ''];\n const statuses = valid.map<StepperItemStatus>((ok, i) =>\n !visited.includes(i) ? 'upcoming' : ok ? 'completed' : 'danger',\n );\n const last = step === POLICY_STEPS.length - 1;\n\n const handleStepChange = ({ step: next }: { step: number }) => {\n setVisited(prev => (prev.includes(step) ? prev : [...prev, step]));\n setStep(next);\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleDescriptionChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setDescription(event.target.value);\n const handleHostChange = (event: ChangeEvent<HTMLInputElement>) => setHost(event.target.value);\n const handlePathChange = (event: ChangeEvent<HTMLInputElement>) => setPath(event.target.value);\n const handleRpsChange = (event: ChangeEvent<HTMLInputElement>) => setRps(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'policy-created', title: 'Policy created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} data-testid='create-policy-drawer'>\n <DrawerTrigger asChild>\n <Button>Create policy</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create policy</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <Stepper\n count={POLICY_STEPS.length}\n step={step}\n onStepChange={handleStepChange}\n className='flex min-h-0 flex-1 flex-col'\n data-testid='create-policy-stepper'\n >\n <StepperList aria-label='Create policy steps'>\n {POLICY_STEPS.map((title, i) => (\n <StepperItem key={title} index={i} status={statuses[i]}>\n <StepperTrigger>\n <StepperIndicator />\n <StepperTitle>{title}</StepperTitle>\n </StepperTrigger>\n <StepperSeparator />\n </StepperItem>\n ))}\n </StepperList>\n <DrawerBody>\n <StepperContent index={0} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Login brute-force limit'\n />\n </Field>\n <Field>\n <FieldLabel>\n Description\n <FieldIndicator />\n </FieldLabel>\n <Textarea value={description} onChange={handleDescriptionChange} />\n </Field>\n </VStack>\n </StepperContent>\n <StepperContent index={1} className='py-16'>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Host\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={host}\n onChange={handleHostChange}\n placeholder='api.acme-bank.com'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Path\n <FieldIndicator />\n </FieldLabel>\n <Input value={path} onChange={handlePathChange} placeholder='/v1/login' />\n </Field>\n </div>\n </VStack>\n </StepperContent>\n <StepperContent index={2} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Requests per second\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Requests over the limit from one IP get a 429.\n </FieldDescription>\n <Input\n type='number'\n value={rps}\n onChange={handleRpsChange}\n placeholder='10'\n />\n </Field>\n </VStack>\n </StepperContent>\n </DrawerBody>\n <DrawerFooter>\n <DrawerFooterControls placement='left'>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n </DrawerFooterControls>\n <DrawerFooterControls placement='right'>\n <StepperPrevTrigger asChild>\n <Button variant='outline' color='neutral'>\n Back\n </Button>\n </StepperPrevTrigger>\n {last ? (\n <Button\n type='submit'\n disabled={!valid.every(Boolean)}\n loading={pending}\n data-testid='create-policy-submit'\n >\n Create policy\n </Button>\n ) : (\n <StepperNextTrigger asChild>\n <Button>Next</Button>\n </StepperNextTrigger>\n )}\n </DrawerFooterControls>\n </DrawerFooter>\n </Stepper>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Pristine | Submit disabled until required filled | `Button disabled={!canSubmit}` |\n| Invalid field | Red border and an error line | `error` on the control + `FieldError` |\n| Submitting | Spinner on submit | `Button loading={pending}` (also disables) |\n| Server rejects one field | Message on that field | `error` + `FieldError` |\n| Server rejects the request | Error atop the body; values kept | `Alert color='destructive'` |\n| \"Test connection\" result | In the section, never a toast | `Alert color='success'` / `'destructive'` |\n| Later section not ready | Disabled, with why: \"Select a service first\" | `FieldDescription` |\n| Field fixed after create | Disabled, with a tooltip | `TooltipTrigger asChild` on a `span` |\n| Dirty close | Unsaved-changes dialog | Variant D in [Confirmation dialogs](confirmation-dialogs.md) |\n| Success | Close, then a toast | `useToast().create({ title: 'User created', type: 'success' })` |\n\n## What your app owns\n\n- Values, validation, touched/dirty state, the request and server errors.\n- Values across close: containers unmount content, so lift state out.\n- The unsaved-changes guard: intercept `onOpenChange(false)`.\n- Current step and statuses (`step`, `onStepChange`, `status`).\n- Focusing the first invalid field after submit.\n- The toast: `useToast` from `@wallarm-org/design-system/Toast`, one `Toaster` at the root.\n- A live preview (SSO \"Continue with …\"): build it from `Button` in a `grid grid-cols-2 gap-12` row.\n\n`FieldIndicator`, `Dialog*` and `useToast` import from subpaths. Where the `Stepper` story differs, follow this page.\n\n## Related\n\n- [Confirmation dialogs](confirmation-dialogs.md): unsaved-changes guard.\n- [Feedback toasts](feedback-toasts.md): success toast.\n- [Detail drawer](detail-drawer.md): opens the edit form.\n- [Settings page](settings-page.md)\n- [Spacing](../foundations/spacing.md)\n- [Layering](../foundations/layering.md): a `Dialog` inside a `Drawer`.",
1167
+ "sections": [
1168
+ {
1169
+ "id": "when-to-use",
1170
+ "heading": "When to use",
1171
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Up to 6 fields, one concern (create user) | `Dialog` with no `width` (560) | A `Drawer` for three inputs |\n| Dependent sections seen together (create provider) | `Drawer width={700}` + `Timeline` with numbered sections | A `Stepper` hiding sections |\n| Gated parts, each needs the previous (create policy) | `Drawer` with no `width` (640) + `Stepper` | One long `Timeline` with 20 fields |\n| Pick many rows (\"Add users to group\") | `Dialog width={960}` with a selectable `Table` | A 200-item multi-select |\n| Edit one value on a detail view | `InlineEdit` on the `Attribute` | A drawer for one field |\n| Independent values saved one by one | [Settings page](settings-page.md) | A drawer opened from the page |\n| A yes/no before an action | [Confirmation dialogs](confirmation-dialogs.md) | A form dialog with no fields |\n\n- Edit uses the same container as create.\n- There is no `Form`, `FormDialog`, `FormDrawer`, `Wizard`, vertical `Stepper` or form-level validation component."
1172
+ },
1173
+ {
1174
+ "id": "rules",
1175
+ "heading": "Rules",
1176
+ "markdown": "### Container and layout\n\n- Wrap body and footer (or the whole `Stepper`) in `<form className='flex min-h-0 flex-1 flex-col'>` so Enter submits.\n- Size with `width` (400 to 1130): `Dialog` 560 default, 400/960 presets; `Drawer` 640 default, `700` for Timeline forms. There is no `size` prop or exported size constant.\n- Bodies own their padding. **Never** add `p-*`, `px-*` or `py-*` around their content.\n- **Never** pass `className` to `DrawerHeader`, `DrawerBody`, `DrawerFooter`, their `Dialog*` aliases, or `Field`. Size the control or grid cell instead.\n- **Never** add a close X; the header has one.\n- Space fields with `<VStack gap={24}>` in a dialog and `<VStack gap={12}>` in a drawer section. Not `FieldGroup` (fixed 16px). There is no `gap` prop on `Field` or `TimelineContent`.\n- **Never** style `FieldGroup`, `FieldLabel` or `FieldDescription` with `className`; it is ignored.\n- Add `className='py-16'` to `StepperContent`.\n- If two related fields are short (name and ID, port and protocol), put them in `grid grid-cols-2 gap-12`. URLs, secrets and free text get full width.\n- Number `Timeline` sections in `TimelineIndicator`; title them with a noun phrase.\n- `Stepper` wraps `DrawerBody` and `DrawerFooter`; its Prev/Next triggers work only inside it. Give it `className='flex min-h-0 flex-1 flex-col'`.\n- Inside `Stepper`: `StepperList aria-label='…'` (one `StepperItem index status` per step), then `DrawerBody` holding one `StepperContent index` per step, then `DrawerFooter`.\n- Max 6 `Stepper` steps. Vertical numbered sections are a `Timeline`, even if Figma says \"stepper\".\n\n### Fields\n\n- Wrap every control in `Field`; **never** a bare `<label>`.\n- Order the parts: `FieldLabel` (with `FieldIndicator`), `FieldDescription`, control, `FieldError`.\n- Put `required` on `Field` and `FieldIndicator` in every label (`*` or \"(Optional)\"). **Never** type \"(optional)\" yourself.\n- Pass `error` and `disabled` to the control itself. `Field invalid` and `Field disabled` do not reach `Input` or `Textarea`.\n- One helper line at most, stating the consequence.\n- Show the provider icon in the select option and trigger.\n\n### Buttons and copy\n\n| Element | Write | Not |\n| --- | --- | --- |\n| Title | \"Create identity provider\" / \"Edit identity provider\" | \"New\", \"Provider settings\" |\n| Submit | Title's verb + entity; edit: \"Save changes\" | \"Submit\", \"OK\", \"Update\" |\n| Stepper | \"Next\"; last step: \"Create policy\" | \"Continue →\", \"Finish\" |\n| Description | What happens after submit: \"We send an invitation link to this email.\" | A restatement of the title |\n| Cancel | \"Cancel\" | \"Close\", \"Discard\" |\n| Error | \"Enter a client ID.\" | \"Invalid\", \"Field is required!\" |\n\n- Submit is the default primary `Button type='submit'`, last on the right. Cancel is `variant='ghost' color='neutral'` in `DialogClose`/`DrawerClose asChild`.\n- Omit `size` on footer buttons.\n- **Never** put submit in the header or add a second primary.\n- Stepper footer: Cancel left; Back (`variant='outline' color='neutral'`) and Next right. Back disables itself on step 1; don't wire `disabled`.\n- On the last step, render submit instead of `StepperNextTrigger` (disabled there).\n\n### Behavior\n\n- Call `event.preventDefault()`. Close only after success.\n- Edit: prefill fields; disable submit until a change.\n- Show `FieldError` only after blur or submit.\n- `Stepper` never blocks steps. Set `status='danger'` on steps with errors, `'completed'` on valid ones. There is no `linear`, `isStepValid` or `orientation` prop."
1177
+ },
1178
+ {
1179
+ "id": "examples",
1180
+ "heading": "Examples",
1181
+ "markdown": "A dialog form with a 24px field stack.\n\nSubmit stays disabled until the required fields are filled, the email error shows only after\nblur or submit, and a successful request closes the dialog, then shows a toast.\n\n```tsx story=CreateInDialog\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [email, setEmail] = useState('');\n const [message, setMessage] = useState('');\n const [emailTouched, setEmailTouched] = useState(false);\n const [pending, setPending] = useState(false);\n\n const emailInvalid = emailTouched && !EMAIL_PATTERN.test(email.trim());\n const canSubmit = name.trim() !== '' && email.trim() !== '';\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName('');\n setEmail('');\n setMessage('');\n setEmailTouched(false);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handleEmailBlur = () => setEmailTouched(true);\n const handleMessageChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setMessage(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setEmailTouched(true);\n if (!EMAIL_PATTERN.test(email.trim())) return;\n setPending(true);\n await saveRequest();\n setPending(false);\n handleOpenChange(false);\n toast.create({ id: 'user-created', title: 'User created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='create-user-dialog'>\n <DialogTrigger asChild>\n <Button>Create user</Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Create user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Maria Lopez'\n data-testid='create-user-name'\n />\n </Field>\n <Field required data-testid='create-user-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>We send an invitation link to this email.</FieldDescription>\n <Input\n type='email'\n value={email}\n error={emailInvalid}\n onChange={handleEmailChange}\n onBlur={handleEmailBlur}\n placeholder='maria.lopez@acme-bank.com'\n data-testid='create-user-email'\n />\n {emailInvalid && <FieldError>Enter an email address.</FieldError>}\n </Field>\n <Field>\n <FieldLabel>\n Message\n <FieldIndicator />\n </FieldLabel>\n <Textarea\n value={message}\n onChange={handleMessageChange}\n placeholder='Added to the SOC on-call rotation.'\n />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-user-submit'\n >\n Create user\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```\n\nAn invalid field: `error` goes on the control, `FieldError` renders only while invalid.\n\nShown after blur: the description stays above the control, the error line below it.\n\n```tsx story=InvalidField\nimport { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\n\nexport const InvalidField = () => (\n <div className='w-400 p-24'>\n <Field required data-testid='invalid-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>The invitation link expires in 7 days.</FieldDescription>\n <Input type='email' defaultValue='maria.lopez' error />\n <FieldError>Enter an email address.</FieldError>\n </Field>\n </div>\n);\n```\n\nEdit reuses the create container. A field fixed after create is disabled, with a tooltip on a wrapping `span`.\n\nFields are prefilled, and \"Save changes\" stays disabled until a value changes.\n\n```tsx story=EditInDialog\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const EditInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState(USER.name);\n const [email, setEmail] = useState(USER.email);\n const [pending, setPending] = useState(false);\n\n const dirty = name !== USER.name || email !== USER.email;\n const canSubmit = dirty && name.trim() !== '' && EMAIL_PATTERN.test(email.trim());\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName(USER.name);\n setEmail(USER.email);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'user-updated', title: 'User updated', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='edit-user-dialog'>\n <DialogTrigger asChild>\n <Button variant='outline' color='neutral'>\n Edit user\n </Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Edit user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n User ID\n <FieldIndicator />\n </FieldLabel>\n <Tooltip>\n <TooltipTrigger asChild>\n <span>\n <Input value={USER.id} disabled data-testid='edit-user-id' />\n </span>\n </TooltipTrigger>\n <TooltipContent>The ID can't change after the user is created.</TooltipContent>\n </Tooltip>\n </Field>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='edit-user-name' />\n </Field>\n <Field required>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <Input type='email' value={email} onChange={handleEmailChange} />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='edit-user-submit'\n >\n Save changes\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```\n\nNumbered `Timeline` sections, each a 12px field stack, inside a `Drawer width={700}` body.\n\nShort related fields share a two-column row; URLs and secrets get full width. \"Test\nconnection\" reports in its section with an `Alert`, never a toast.\n\n```tsx story=CreateInDrawerWithSections\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Timeline, TimelineConnector, TimelineContent, TimelineIndicator, TimelineItem, TimelineSeparator, TimelineTitle } from '@wallarm-org/design-system/Timeline';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSections = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [slug, setSlug] = useState('');\n const [domain, setDomain] = useState('');\n const [clientId, setClientId] = useState('');\n const [clientSecret, setClientSecret] = useState('');\n const [testing, setTesting] = useState(false);\n const [connected, setConnected] = useState(false);\n const [pending, setPending] = useState(false);\n\n const credentialsFilled = domain.trim() !== '' && clientId.trim() !== '' && clientSecret !== '';\n const canSubmit = name.trim() !== '' && slug.trim() !== '' && credentialsFilled;\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleSlugChange = (event: ChangeEvent<HTMLInputElement>) => setSlug(event.target.value);\n const handleDomainChange = (event: ChangeEvent<HTMLInputElement>) => {\n setDomain(event.target.value);\n setConnected(false);\n };\n const handleClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientId(event.target.value);\n setConnected(false);\n };\n const handleClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientSecret(event.target.value);\n setConnected(false);\n };\n\n const handleTestConnection = async () => {\n setTesting(true);\n await saveRequest();\n setTesting(false);\n setConnected(true);\n };\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'provider-created', title: 'Identity provider created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} width={700} data-testid='create-provider-drawer'>\n <DrawerTrigger asChild>\n <Button>Create identity provider</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create identity provider</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DrawerBody>\n <Timeline data-testid='create-provider-sections'>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>1</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Provider details</TimelineTitle>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} placeholder='Okta SSO' />\n </Field>\n <Field required>\n <FieldLabel>\n Provider ID\n <FieldIndicator />\n </FieldLabel>\n <Input value={slug} onChange={handleSlugChange} placeholder='okta-sso' />\n </Field>\n </div>\n </VStack>\n </TimelineContent>\n </TimelineItem>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>2</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Okta connection</TimelineTitle>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Okta domain\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={domain}\n onChange={handleDomainChange}\n placeholder='https://acme-bank.okta.com'\n data-testid='create-provider-domain'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client ID\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={clientId}\n onChange={handleClientIdChange}\n data-testid='create-provider-client-id'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client secret\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Stored encrypted; you can't view it again.\n </FieldDescription>\n <Input\n type='password'\n value={clientSecret}\n onChange={handleClientSecretChange}\n data-testid='create-provider-client-secret'\n />\n </Field>\n <div>\n <Button\n variant='outline'\n color='neutral'\n disabled={!credentialsFilled}\n loading={testing}\n onClick={handleTestConnection}\n data-testid='create-provider-test'\n >\n Test connection\n </Button>\n </div>\n {connected && (\n <Alert color='success' data-testid='create-provider-test-result'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>Connected to Okta</AlertTitle>\n <AlertDescription>\n Wallarm reached acme-bank.okta.com with these credentials.\n </AlertDescription>\n </AlertContent>\n </Alert>\n )}\n </VStack>\n </TimelineContent>\n </TimelineItem>\n </Timeline>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-provider-submit'\n >\n Create identity provider\n </Button>\n </DrawerFooter>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};\n```\n\nA `Stepper` wrapping body and footer; submit replaces Next on the last step.\n\nCancel sits left, Back and Next right. Steps never block: a step left with empty required\nfields turns `danger`, a filled one `completed`.\n\n```tsx story=CreateInDrawerWithSteps\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerFooterControls, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Stepper, StepperContent, StepperIndicator, StepperItem, type StepperItemStatus, StepperList, StepperNextTrigger, StepperPrevTrigger, StepperSeparator, StepperTitle, StepperTrigger } from '@wallarm-org/design-system/Stepper';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSteps = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [step, setStep] = useState(0);\n const [name, setName] = useState('');\n const [description, setDescription] = useState('');\n const [host, setHost] = useState('');\n const [path, setPath] = useState('');\n const [rps, setRps] = useState('');\n const [pending, setPending] = useState(false);\n const [visited, setVisited] = useState<number[]>([]);\n\n const valid = [name.trim() !== '', host.trim() !== '' && path.trim() !== '', rps.trim() !== ''];\n const statuses = valid.map<StepperItemStatus>((ok, i) =>\n !visited.includes(i) ? 'upcoming' : ok ? 'completed' : 'danger',\n );\n const last = step === POLICY_STEPS.length - 1;\n\n const handleStepChange = ({ step: next }: { step: number }) => {\n setVisited(prev => (prev.includes(step) ? prev : [...prev, step]));\n setStep(next);\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleDescriptionChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setDescription(event.target.value);\n const handleHostChange = (event: ChangeEvent<HTMLInputElement>) => setHost(event.target.value);\n const handlePathChange = (event: ChangeEvent<HTMLInputElement>) => setPath(event.target.value);\n const handleRpsChange = (event: ChangeEvent<HTMLInputElement>) => setRps(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'policy-created', title: 'Policy created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} data-testid='create-policy-drawer'>\n <DrawerTrigger asChild>\n <Button>Create policy</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create policy</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <Stepper\n count={POLICY_STEPS.length}\n step={step}\n onStepChange={handleStepChange}\n className='flex min-h-0 flex-1 flex-col'\n data-testid='create-policy-stepper'\n >\n <StepperList aria-label='Create policy steps'>\n {POLICY_STEPS.map((title, i) => (\n <StepperItem key={title} index={i} status={statuses[i]}>\n <StepperTrigger>\n <StepperIndicator />\n <StepperTitle>{title}</StepperTitle>\n </StepperTrigger>\n <StepperSeparator />\n </StepperItem>\n ))}\n </StepperList>\n <DrawerBody>\n <StepperContent index={0} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Login brute-force limit'\n />\n </Field>\n <Field>\n <FieldLabel>\n Description\n <FieldIndicator />\n </FieldLabel>\n <Textarea value={description} onChange={handleDescriptionChange} />\n </Field>\n </VStack>\n </StepperContent>\n <StepperContent index={1} className='py-16'>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Host\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={host}\n onChange={handleHostChange}\n placeholder='api.acme-bank.com'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Path\n <FieldIndicator />\n </FieldLabel>\n <Input value={path} onChange={handlePathChange} placeholder='/v1/login' />\n </Field>\n </div>\n </VStack>\n </StepperContent>\n <StepperContent index={2} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Requests per second\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Requests over the limit from one IP get a 429.\n </FieldDescription>\n <Input\n type='number'\n value={rps}\n onChange={handleRpsChange}\n placeholder='10'\n />\n </Field>\n </VStack>\n </StepperContent>\n </DrawerBody>\n <DrawerFooter>\n <DrawerFooterControls placement='left'>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n </DrawerFooterControls>\n <DrawerFooterControls placement='right'>\n <StepperPrevTrigger asChild>\n <Button variant='outline' color='neutral'>\n Back\n </Button>\n </StepperPrevTrigger>\n {last ? (\n <Button\n type='submit'\n disabled={!valid.every(Boolean)}\n loading={pending}\n data-testid='create-policy-submit'\n >\n Create policy\n </Button>\n ) : (\n <StepperNextTrigger asChild>\n <Button>Next</Button>\n </StepperNextTrigger>\n )}\n </DrawerFooterControls>\n </DrawerFooter>\n </Stepper>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};\n```"
1182
+ },
1183
+ {
1184
+ "id": "states",
1185
+ "heading": "States",
1186
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Pristine | Submit disabled until required filled | `Button disabled={!canSubmit}` |\n| Invalid field | Red border and an error line | `error` on the control + `FieldError` |\n| Submitting | Spinner on submit | `Button loading={pending}` (also disables) |\n| Server rejects one field | Message on that field | `error` + `FieldError` |\n| Server rejects the request | Error atop the body; values kept | `Alert color='destructive'` |\n| \"Test connection\" result | In the section, never a toast | `Alert color='success'` / `'destructive'` |\n| Later section not ready | Disabled, with why: \"Select a service first\" | `FieldDescription` |\n| Field fixed after create | Disabled, with a tooltip | `TooltipTrigger asChild` on a `span` |\n| Dirty close | Unsaved-changes dialog | Variant D in [Confirmation dialogs](confirmation-dialogs.md) |\n| Success | Close, then a toast | `useToast().create({ title: 'User created', type: 'success' })` |"
1187
+ },
1188
+ {
1189
+ "id": "what-your-app-owns",
1190
+ "heading": "What your app owns",
1191
+ "markdown": "- Values, validation, touched/dirty state, the request and server errors.\n- Values across close: containers unmount content, so lift state out.\n- The unsaved-changes guard: intercept `onOpenChange(false)`.\n- Current step and statuses (`step`, `onStepChange`, `status`).\n- Focusing the first invalid field after submit.\n- The toast: `useToast` from `@wallarm-org/design-system/Toast`, one `Toaster` at the root.\n- A live preview (SSO \"Continue with …\"): build it from `Button` in a `grid grid-cols-2 gap-12` row.\n\n`FieldIndicator`, `Dialog*` and `useToast` import from subpaths. Where the `Stepper` story differs, follow this page."
1192
+ },
1193
+ {
1194
+ "id": "related",
1195
+ "heading": "Related",
1196
+ "markdown": "- [Confirmation dialogs](confirmation-dialogs.md): unsaved-changes guard.\n- [Feedback toasts](feedback-toasts.md): success toast.\n- [Detail drawer](detail-drawer.md): opens the edit form.\n- [Settings page](settings-page.md)\n- [Spacing](../foundations/spacing.md)\n- [Layering](../foundations/layering.md): a `Dialog` inside a `Drawer`."
1197
+ }
1198
+ ],
1199
+ "stories": [
1200
+ {
1201
+ "name": "CreateInDialog",
1202
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [email, setEmail] = useState('');\n const [message, setMessage] = useState('');\n const [emailTouched, setEmailTouched] = useState(false);\n const [pending, setPending] = useState(false);\n\n const emailInvalid = emailTouched && !EMAIL_PATTERN.test(email.trim());\n const canSubmit = name.trim() !== '' && email.trim() !== '';\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName('');\n setEmail('');\n setMessage('');\n setEmailTouched(false);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handleEmailBlur = () => setEmailTouched(true);\n const handleMessageChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setMessage(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setEmailTouched(true);\n if (!EMAIL_PATTERN.test(email.trim())) return;\n setPending(true);\n await saveRequest();\n setPending(false);\n handleOpenChange(false);\n toast.create({ id: 'user-created', title: 'User created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='create-user-dialog'>\n <DialogTrigger asChild>\n <Button>Create user</Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Create user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Maria Lopez'\n data-testid='create-user-name'\n />\n </Field>\n <Field required data-testid='create-user-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>We send an invitation link to this email.</FieldDescription>\n <Input\n type='email'\n value={email}\n error={emailInvalid}\n onChange={handleEmailChange}\n onBlur={handleEmailBlur}\n placeholder='maria.lopez@acme-bank.com'\n data-testid='create-user-email'\n />\n {emailInvalid && <FieldError>Enter an email address.</FieldError>}\n </Field>\n <Field>\n <FieldLabel>\n Message\n <FieldIndicator />\n </FieldLabel>\n <Textarea\n value={message}\n onChange={handleMessageChange}\n placeholder='Added to the SOC on-call rotation.'\n />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-user-submit'\n >\n Create user\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};",
1203
+ "description": "Submit stays disabled until the required fields are filled, the email error shows only after\nblur or submit, and a successful request closes the dialog, then shows a toast."
1204
+ },
1205
+ {
1206
+ "name": "InvalidField",
1207
+ "code": "import { Field, FieldDescription, FieldError, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\n\nexport const InvalidField = () => (\n <div className='w-400 p-24'>\n <Field required data-testid='invalid-email-field'>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>The invitation link expires in 7 days.</FieldDescription>\n <Input type='email' defaultValue='maria.lopez' error />\n <FieldError>Enter an email address.</FieldError>\n </Field>\n </div>\n);",
1208
+ "description": "Shown after blur: the description stays above the control, the error line below it."
1209
+ },
1210
+ {
1211
+ "name": "EditInDialog",
1212
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@wallarm-org/design-system/Dialog';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const EditInDialog = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState(USER.name);\n const [email, setEmail] = useState(USER.email);\n const [pending, setPending] = useState(false);\n\n const dirty = name !== USER.name || email !== USER.email;\n const canSubmit = dirty && name.trim() !== '' && EMAIL_PATTERN.test(email.trim());\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setName(USER.name);\n setEmail(USER.email);\n }\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'user-updated', title: 'User updated', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Dialog open={open} onOpenChange={handleOpenChange} data-testid='edit-user-dialog'>\n <DialogTrigger asChild>\n <Button variant='outline' color='neutral'>\n Edit user\n </Button>\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Edit user</DialogTitle>\n </DialogHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DialogBody>\n <VStack gap={24}>\n <Field required>\n <FieldLabel>\n User ID\n <FieldIndicator />\n </FieldLabel>\n <Tooltip>\n <TooltipTrigger asChild>\n <span>\n <Input value={USER.id} disabled data-testid='edit-user-id' />\n </span>\n </TooltipTrigger>\n <TooltipContent>The ID can't change after the user is created.</TooltipContent>\n </Tooltip>\n </Field>\n <Field required>\n <FieldLabel>\n Full name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} data-testid='edit-user-name' />\n </Field>\n <Field required>\n <FieldLabel>\n Email\n <FieldIndicator />\n </FieldLabel>\n <Input type='email' value={email} onChange={handleEmailChange} />\n </Field>\n </VStack>\n </DialogBody>\n <DialogFooter>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='edit-user-submit'\n >\n Save changes\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n </div>\n );\n};",
1213
+ "description": "Fields are prefilled, and \"Save changes\" stays disabled until a value changes."
1214
+ },
1215
+ {
1216
+ "name": "CreateInDrawerWithSections",
1217
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '@wallarm-org/design-system/Alert';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Timeline, TimelineConnector, TimelineContent, TimelineIndicator, TimelineItem, TimelineSeparator, TimelineTitle } from '@wallarm-org/design-system/Timeline';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSections = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [name, setName] = useState('');\n const [slug, setSlug] = useState('');\n const [domain, setDomain] = useState('');\n const [clientId, setClientId] = useState('');\n const [clientSecret, setClientSecret] = useState('');\n const [testing, setTesting] = useState(false);\n const [connected, setConnected] = useState(false);\n const [pending, setPending] = useState(false);\n\n const credentialsFilled = domain.trim() !== '' && clientId.trim() !== '' && clientSecret !== '';\n const canSubmit = name.trim() !== '' && slug.trim() !== '' && credentialsFilled;\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleSlugChange = (event: ChangeEvent<HTMLInputElement>) => setSlug(event.target.value);\n const handleDomainChange = (event: ChangeEvent<HTMLInputElement>) => {\n setDomain(event.target.value);\n setConnected(false);\n };\n const handleClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientId(event.target.value);\n setConnected(false);\n };\n const handleClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {\n setClientSecret(event.target.value);\n setConnected(false);\n };\n\n const handleTestConnection = async () => {\n setTesting(true);\n await saveRequest();\n setTesting(false);\n setConnected(true);\n };\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'provider-created', title: 'Identity provider created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} width={700} data-testid='create-provider-drawer'>\n <DrawerTrigger asChild>\n <Button>Create identity provider</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create identity provider</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <DrawerBody>\n <Timeline data-testid='create-provider-sections'>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>1</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Provider details</TimelineTitle>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input value={name} onChange={handleNameChange} placeholder='Okta SSO' />\n </Field>\n <Field required>\n <FieldLabel>\n Provider ID\n <FieldIndicator />\n </FieldLabel>\n <Input value={slug} onChange={handleSlugChange} placeholder='okta-sso' />\n </Field>\n </div>\n </VStack>\n </TimelineContent>\n </TimelineItem>\n <TimelineItem>\n <TimelineConnector>\n <TimelineIndicator>2</TimelineIndicator>\n <TimelineSeparator />\n </TimelineConnector>\n <TimelineContent>\n <TimelineTitle>Okta connection</TimelineTitle>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Okta domain\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={domain}\n onChange={handleDomainChange}\n placeholder='https://acme-bank.okta.com'\n data-testid='create-provider-domain'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client ID\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={clientId}\n onChange={handleClientIdChange}\n data-testid='create-provider-client-id'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Client secret\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Stored encrypted; you can't view it again.\n </FieldDescription>\n <Input\n type='password'\n value={clientSecret}\n onChange={handleClientSecretChange}\n data-testid='create-provider-client-secret'\n />\n </Field>\n <div>\n <Button\n variant='outline'\n color='neutral'\n disabled={!credentialsFilled}\n loading={testing}\n onClick={handleTestConnection}\n data-testid='create-provider-test'\n >\n Test connection\n </Button>\n </div>\n {connected && (\n <Alert color='success' data-testid='create-provider-test-result'>\n <AlertIcon />\n <AlertContent>\n <AlertTitle>Connected to Okta</AlertTitle>\n <AlertDescription>\n Wallarm reached acme-bank.okta.com with these credentials.\n </AlertDescription>\n </AlertContent>\n </Alert>\n )}\n </VStack>\n </TimelineContent>\n </TimelineItem>\n </Timeline>\n </DrawerBody>\n <DrawerFooter>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n <Button\n type='submit'\n disabled={!canSubmit}\n loading={pending}\n data-testid='create-provider-submit'\n >\n Create identity provider\n </Button>\n </DrawerFooter>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};",
1218
+ "description": "Short related fields share a two-column row; URLs and secrets get full width. \"Test\nconnection\" reports in its section with an `Alert`, never a toast."
1219
+ },
1220
+ {
1221
+ "name": "CreateInDrawerWithSteps",
1222
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerFooterControls, DrawerHeader, DrawerTitle, DrawerTrigger } from '@wallarm-org/design-system/Drawer';\nimport { Field, FieldDescription, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Stepper, StepperContent, StepperIndicator, StepperItem, type StepperItemStatus, StepperList, StepperNextTrigger, StepperPrevTrigger, StepperSeparator, StepperTitle, StepperTrigger } from '@wallarm-org/design-system/Stepper';\nimport { Textarea } from '@wallarm-org/design-system/Textarea';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const CreateInDrawerWithSteps = () => {\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const [step, setStep] = useState(0);\n const [name, setName] = useState('');\n const [description, setDescription] = useState('');\n const [host, setHost] = useState('');\n const [path, setPath] = useState('');\n const [rps, setRps] = useState('');\n const [pending, setPending] = useState(false);\n const [visited, setVisited] = useState<number[]>([]);\n\n const valid = [name.trim() !== '', host.trim() !== '' && path.trim() !== '', rps.trim() !== ''];\n const statuses = valid.map<StepperItemStatus>((ok, i) =>\n !visited.includes(i) ? 'upcoming' : ok ? 'completed' : 'danger',\n );\n const last = step === POLICY_STEPS.length - 1;\n\n const handleStepChange = ({ step: next }: { step: number }) => {\n setVisited(prev => (prev.includes(step) ? prev : [...prev, step]));\n setStep(next);\n };\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => setName(event.target.value);\n const handleDescriptionChange = (event: ChangeEvent<HTMLTextAreaElement>) =>\n setDescription(event.target.value);\n const handleHostChange = (event: ChangeEvent<HTMLInputElement>) => setHost(event.target.value);\n const handlePathChange = (event: ChangeEvent<HTMLInputElement>) => setPath(event.target.value);\n const handleRpsChange = (event: ChangeEvent<HTMLInputElement>) => setRps(event.target.value);\n\n const handleSubmit = async (event: FormEvent) => {\n event.preventDefault();\n setPending(true);\n await saveRequest();\n setPending(false);\n setOpen(false);\n toast.create({ id: 'policy-created', title: 'Policy created', type: 'success' });\n };\n\n return (\n <div className='p-24'>\n <Drawer open={open} onOpenChange={setOpen} data-testid='create-policy-drawer'>\n <DrawerTrigger asChild>\n <Button>Create policy</Button>\n </DrawerTrigger>\n <DrawerContent>\n <DrawerHeader>\n <DrawerTitle>Create policy</DrawerTitle>\n </DrawerHeader>\n <form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>\n <Stepper\n count={POLICY_STEPS.length}\n step={step}\n onStepChange={handleStepChange}\n className='flex min-h-0 flex-1 flex-col'\n data-testid='create-policy-stepper'\n >\n <StepperList aria-label='Create policy steps'>\n {POLICY_STEPS.map((title, i) => (\n <StepperItem key={title} index={i} status={statuses[i]}>\n <StepperTrigger>\n <StepperIndicator />\n <StepperTitle>{title}</StepperTitle>\n </StepperTrigger>\n <StepperSeparator />\n </StepperItem>\n ))}\n </StepperList>\n <DrawerBody>\n <StepperContent index={0} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Name\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={name}\n onChange={handleNameChange}\n placeholder='Login brute-force limit'\n />\n </Field>\n <Field>\n <FieldLabel>\n Description\n <FieldIndicator />\n </FieldLabel>\n <Textarea value={description} onChange={handleDescriptionChange} />\n </Field>\n </VStack>\n </StepperContent>\n <StepperContent index={1} className='py-16'>\n <VStack gap={12}>\n <div className='grid grid-cols-2 gap-12'>\n <Field required>\n <FieldLabel>\n Host\n <FieldIndicator />\n </FieldLabel>\n <Input\n value={host}\n onChange={handleHostChange}\n placeholder='api.acme-bank.com'\n />\n </Field>\n <Field required>\n <FieldLabel>\n Path\n <FieldIndicator />\n </FieldLabel>\n <Input value={path} onChange={handlePathChange} placeholder='/v1/login' />\n </Field>\n </div>\n </VStack>\n </StepperContent>\n <StepperContent index={2} className='py-16'>\n <VStack gap={12}>\n <Field required>\n <FieldLabel>\n Requests per second\n <FieldIndicator />\n </FieldLabel>\n <FieldDescription>\n Requests over the limit from one IP get a 429.\n </FieldDescription>\n <Input\n type='number'\n value={rps}\n onChange={handleRpsChange}\n placeholder='10'\n />\n </Field>\n </VStack>\n </StepperContent>\n </DrawerBody>\n <DrawerFooter>\n <DrawerFooterControls placement='left'>\n <DrawerClose asChild>\n <Button variant='ghost' color='neutral'>\n Cancel\n </Button>\n </DrawerClose>\n </DrawerFooterControls>\n <DrawerFooterControls placement='right'>\n <StepperPrevTrigger asChild>\n <Button variant='outline' color='neutral'>\n Back\n </Button>\n </StepperPrevTrigger>\n {last ? (\n <Button\n type='submit'\n disabled={!valid.every(Boolean)}\n loading={pending}\n data-testid='create-policy-submit'\n >\n Create policy\n </Button>\n ) : (\n <StepperNextTrigger asChild>\n <Button>Next</Button>\n </StepperNextTrigger>\n )}\n </DrawerFooterControls>\n </DrawerFooter>\n </Stepper>\n </form>\n </DrawerContent>\n </Drawer>\n </div>\n );\n};",
1223
+ "description": "Cancel sits left, Back and Next right. Steps never block: a step left with empty required\nfields turns `danger`, a filled one `completed`."
1224
+ }
1225
+ ]
1226
+ },
1227
+ {
1228
+ "slug": "patterns/settings-page",
1229
+ "kind": "pattern",
1230
+ "title": "Settings page",
1231
+ "description": "Use when building a page of independent settings (profile, preferences, appearance) where each value saves on its own and a toast confirms it.",
1232
+ "components": [
1233
+ "Page",
1234
+ "PageHeader",
1235
+ "PageHeaderGroup",
1236
+ "PageContent",
1237
+ "PageTitle",
1238
+ "RemoteShellBreadcrumb",
1239
+ "Heading",
1240
+ "Text",
1241
+ "Field",
1242
+ "FieldLabel",
1243
+ "FieldError",
1244
+ "Input",
1245
+ "Select",
1246
+ "SelectInput",
1247
+ "SegmentedControl",
1248
+ "RadioGroup",
1249
+ "Radio",
1250
+ "Switch",
1251
+ "Button",
1252
+ "Avatar",
1253
+ "FileUpload",
1254
+ "Separator",
1255
+ "Tooltip",
1256
+ "Toast",
1257
+ "Dialog",
1258
+ "PasswordInput",
1259
+ "PasswordComplexity",
1260
+ "ThemeProvider",
1261
+ "ImageCropper",
1262
+ "Slider",
1263
+ "NavRail"
1264
+ ],
1265
+ "keywords": [
1266
+ "settings",
1267
+ "settings page",
1268
+ "profile",
1269
+ "preferences",
1270
+ "account settings",
1271
+ "user settings",
1272
+ "appearance",
1273
+ "theme picker",
1274
+ "label value row",
1275
+ "settings row",
1276
+ "settings section",
1277
+ "autosave",
1278
+ "save on blur",
1279
+ "danger zone",
1280
+ "delete account",
1281
+ "change password",
1282
+ "change email",
1283
+ "timezone",
1284
+ "date format",
1285
+ "time format",
1286
+ "sidebar style",
1287
+ "form page"
1288
+ ],
1289
+ "order": 70,
1290
+ "markdown": "# Settings page\n\nA 640px column of sections. Each row saves itself and a toast confirms it. No Save button.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Independent values of one account or org | This pattern | One form with a Save button |\n| Values saved together (a provider config) | [Create and edit forms](./create-edit-forms.md) in a `Drawer` | Rows that autosave |\n| A list of entities | [Resource list page](./resource-list-page.md) | A row per entity |\n| An events × channels matrix | `Table` with `Switch` cells ([Entity table](./entity-table.md)) | A row per cell |\n| Email or password change | A `Dialog` opened from the row | Editing the value in place |\n| Deleting the account | Type-to-confirm `Dialog` ([Confirmation dialogs](./confirmation-dialogs.md)) | Deleting at once |\n\nPick the row kind by the control:\n\n| Control | Row |\n| --- | --- |\n| `Input`, `Select`, `Switch` | Field row: `Field orientation='horizontal'` › `FieldLabel` + a `w-[320px] shrink-0` wrapper |\n| `SegmentedControl` | Action row with `aria-label` on the control |\n| `Button`, `Avatar` | Action row: `Text` + control |\n| `RadioGroup variant='card'` | No row; the section `Heading` labels it through `aria-labelledby` |\n\n## Rules\n\n**Layout and spacing** (spacing counts in pixels, `gap-16` = 16px):\n\n- Use `Page fullSize fixedHeight` › `PageHeader` › `PageContent`. **Never** add `PageActions`, `PageToolbar` or `PageDescription`.\n- `PageHeader` › `PageHeaderGroup` › `PageTitle`. The trail is the Settings section's `RemoteShellBreadcrumb` ([Application layout](./app-layout.md)); **never** put `Breadcrumbs` in `PageHeader`.\n- `PageContent` owns the 24px gutter. **Never** add padding around it.\n- Put the sections in one `div className='flex max-w-[640px] flex-col gap-16'`. **Never** wrap it in a `Card` or padded box. **Never** give it `mx-auto`; it stays left-aligned.\n- Section: `flex flex-col gap-16` › `Heading as='h2' size='xl' weight='medium' color='primary'` + a `flex flex-col` row list.\n- Put `Separator spacing={16}` after every row, including a section's last row, except in the last section.\n- **Never** add `gap-*` to the row list. The separators carry the rhythm.\n- Wrap `Input` and `Select` in `w-[320px] shrink-0`. Leave compact controls unwrapped.\n- Give action rows `flex min-h-36 items-center justify-between gap-16`. Use the default `Button` size (36px).\n- Put card radios in `div className='grid grid-cols-3 gap-8'` inside the `RadioGroup`.\n- **Never** pass `className` to `Field`, `FieldLabel`, `Heading`, `Text`, `RadioGroup` or `Radio`. Size them through a wrapper `div`.\n\n**Controls:**\n\n- Save text on blur and choices on change. **Never** save per keystroke or an unchanged value.\n- Use `SelectInput` as the trigger. Over ~10 options, add `SelectHeader` › `SelectSearchInput` with `useSelectSearch`.\n- Use `SegmentedControl` for 2–4 short options. Its `onChange` gets `(value: string)`.\n- Use `RadioGroup variant='card'` only when options carry a picture or description (theme).\n- Use `Button variant='outline' color='neutral'` for a row action that opens a dialog (\"Change password\").\n- Make the avatar the upload trigger: `FileUploadTrigger asChild` › `Avatar as='button'` with `AvatarOverlay`.\n- If a value needs confirming (email, password), open a 560 `Dialog` that asks for the current password. **Never** edit it in the row.\n- If two values only make sense together, put them in one dialog. **Never** autosave half a pair.\n- Put the danger zone last: text (`max-w-[320px]`) left, `Button color='destructive'` right.\n\n**Copy:**\n\n- Page title = rail item (\"Profile\"); breadcrumbs \"Settings › Profile\". Section titles are one noun (\"Preferences\"). Labels are sentence case, no colon.\n- Write the toast as \"<Label> updated\" with `type: 'success'`, one per saved value. This overrides `'default'` from [Feedback toasts](./feedback-toasts.md).\n\nThere is no `SettingsPage`, `SettingsSection` or `SettingsRow`, no `className` on `Field`, no `label` on `Separator`, no `loading` on `Switch` or `Input`, no `'system'` theme value, and no theme preview tile.\n\n## Examples\n\nThe page frame: a title in the header, sections straight in `PageContent`.\n\nSettings › Profile: About, Preferences, Appearance, Security and the danger zone in one\nleft-aligned 640px column. Text saves on blur, choices on change, and every save shows a\n\"<Label> updated\" toast. Email and password open dialogs; two-factor is locked by the org.\n\n```tsx story=Default\nimport { type ChangeEvent, useState } from 'react';\nimport { Avatar, AvatarFallback, AvatarImage, AvatarOverlay } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { FileUpload, FileUploadTrigger, useFilePreviewUrl } from '@wallarm-org/design-system/FileUpload';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Radio, RadioDescription, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const Default = () => {\n const toast = useToast();\n const [profile, setProfile] = useState(PROFILE);\n const [name, setName] = useState(PROFILE.name);\n const [nameError, setNameError] = useState('');\n const [photos, setPhotos] = useState<File[]>([]);\n const photo = useFilePreviewUrl(photos[0]);\n\n const timezones = createListCollection({ items: TIMEZONES });\n const dateFormats = createListCollection({ items: DATE_FORMATS });\n\n const save = async (patch: Partial<Profile>, label: string) => {\n setProfile({ ...profile, ...patch });\n try {\n await onSaveProfile(patch);\n toast.create({ title: `${label} updated`, type: 'success' });\n } catch {\n toast.create({ title: `${label} update failed`, type: 'error' });\n }\n };\n\n const handlePhotoChange = (files: File[]) => {\n setPhotos(files);\n toast.create({ title: 'Photo updated', type: 'success' });\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n const handleNameBlur = () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value !== profile.name) save({ name: value }, 'Name');\n };\n\n const handleTimezoneChange = ({ value }: { value: string[] }) =>\n save({ timezone: value[0] }, 'Timezone');\n const handleDateFormatChange = ({ value }: { value: string[] }) =>\n save({ dateFormat: value[0] }, 'Date format');\n const handleTimeFormatChange = (value: string) => save({ timeFormat: value }, 'Time format');\n const handleWeeklyReportChange = ({ checked }: { checked: boolean }) =>\n save({ weeklyReport: checked }, 'Weekly report');\n const handleSidebarStyleChange = (value: string) =>\n save({ sidebarStyle: value }, 'Sidebar style');\n const handleThemeChange = ({ value }: { value: string | null }) => {\n if (value) save({ theme: value }, 'Theme');\n };\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Photo\n </Text>\n <FileUpload\n accept='image/png,image/jpeg,image/webp'\n maxFileSize={512 * 1024}\n value={photos}\n onValueChange={handlePhotoChange}\n className='w-auto'\n data-testid='profile-photo'\n >\n <FileUploadTrigger asChild>\n <Avatar as='button' aria-label='Change photo'>\n <AvatarImage src={photo} />\n <AvatarFallback name={profile.name} />\n <AvatarOverlay />\n </Avatar>\n </FileUploadTrigger>\n </FileUpload>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n value={[profile.timezone]}\n onValueChange={handleTimezoneChange}\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Date format</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={dateFormats}\n value={[profile.dateFormat]}\n onValueChange={handleDateFormatChange}\n data-testid='profile-date-format'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {dateFormats.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Time format\n </Text>\n <SegmentedControl\n aria-label='Time format'\n value={profile.timeFormat}\n onChange={handleTimeFormatChange}\n data-testid='profile-time-format'\n >\n <SegmentedControlItem value='12h'>12-hour</SegmentedControlItem>\n <SegmentedControlItem value='24h'>24-hour</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch\n checked={profile.weeklyReport}\n onCheckedChange={handleWeeklyReportChange}\n data-testid='profile-weekly-report'\n >\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading id='appearance-heading' as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <RadioGroup\n variant='card'\n name='theme'\n aria-labelledby='appearance-heading'\n value={profile.theme}\n onValueChange={handleThemeChange}\n >\n <div className='grid grid-cols-3 gap-8' data-testid='profile-theme'>\n <Radio value='light'>\n <RadioIndicator />\n <RadioLabel>Light</RadioLabel>\n <RadioDescription>Bright surfaces for daytime work</RadioDescription>\n </Radio>\n <Radio value='dark'>\n <RadioIndicator />\n <RadioLabel>Dark</RadioLabel>\n <RadioDescription>Dim surfaces for a SOC at night</RadioDescription>\n </Radio>\n <Radio value='system'>\n <RadioIndicator />\n <RadioLabel>System</RadioLabel>\n <RadioDescription>Follows your operating system</RadioDescription>\n </Radio>\n </div>\n </RadioGroup>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={profile.sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Password\n </Text>\n <Button\n variant='outline'\n color='neutral'\n onClick={onChangePassword}\n data-testid='profile-change-password'\n >\n Change password\n </Button>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Danger zone\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <div className='max-w-[320px]'>\n <Text size='sm' color='secondary'>\n Deletes your account and signs you out. Your organization's data stays.\n </Text>\n </div>\n <Button\n color='destructive'\n onClick={onDeleteAccount}\n data-testid='profile-delete-account'\n >\n Delete account\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nA field row saves on blur and shows an inline error; a failed save keeps the typed value.\n\nStarts invalid: the message sits under the input inside the 320px wrapper, and nothing saves\nuntil the name is valid. A failed save shows an error toast instead of \"Name updated\".\n\n```tsx story=InvalidValue\nimport { type ChangeEvent, useState } from 'react';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const InvalidValue = () => {\n const toast = useToast();\n const [savedName, setSavedName] = useState(PROFILE.name);\n const [name, setName] = useState('');\n const [nameError, setNameError] = useState('Enter your name');\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n // On failure the input keeps the typed value, and the next blur retries.\n const handleNameBlur = async () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value === savedName) return;\n try {\n await onSaveProfile({ name: value });\n setSavedName(value);\n toast.create({ title: 'Name updated', type: 'success' });\n } catch {\n toast.create({ title: 'Name update failed', type: 'error' });\n }\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nAn action row labels with `Text` and `aria-label`, not `FieldLabel`.\n\nThe control saves on change; the row keeps its 36px height whatever the control is.\n\n```tsx story=ActionRow\nimport { useState } from 'react';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ActionRow = () => {\n const toast = useToast();\n const [sidebarStyle, setSidebarStyle] = useState(PROFILE.sidebarStyle);\n\n const handleSidebarStyleChange = (value: string) => {\n setSidebarStyle(value);\n onSaveProfile({ sidebarStyle: value });\n toast.create({ title: 'Sidebar style updated', type: 'success' });\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nAn org-locked value puts the tooltip on `Lock`; a disabled `Switch` never opens one.\n\nThe row stays visible with its current value, so users see what the org enforces.\n\n```tsx story=LockedValue\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const LockedValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);\n```\n\nA read-only email opens its dialog on click or Enter.\n\nThe dialog asks for the current password before it changes the email.\n\n```tsx story=ReadOnlyValue\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\n\nexport const ReadOnlyValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Saved | Success toast \"<Label> updated\" | `useToast().create({ type: 'success' })` |\n| Save failed | Error toast; keep the typed value | `useToast().create({ type: 'error' })` |\n| Invalid value | Message inside the 320 wrapper; no save, no toast | `Input error` + `FieldError` |\n| Saving a select | Disabled until the request ends | `Select disabled` |\n| Saving a button action | Loading button; never disable the page | `Button loading` |\n| Saving a switch | Disabled until the request ends | `Switch disabled` |\n| Locked by the org | Disabled control + `Lock` tooltip; keep the row | `Tooltip` › `TooltipTrigger` |\n| Opens a dialog | Read-only value | `Input readOnly` |\n\nPass `error` to `Input`; `Field invalid` alone does not mark it.\n\nWhile a value saves, only its own control changes.\n\nSaving: a select and a switch stay disabled until the request ends, and a button action shows\nits loading state. The rest of the page stays usable.\n\n```tsx story=Saving\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Saving = () => {\n const timezones = createListCollection({ items: TIMEZONES });\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n defaultValue={['Asia/Singapore']}\n disabled\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch checked disabled data-testid='profile-weekly-report'>\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Active sessions\n </Text>\n <Button\n variant='outline'\n color='neutral'\n loading\n data-testid='profile-sign-out-sessions'\n >\n Sign out of all sessions\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\n## What your app owns\n\n| Concern | Recipe |\n| --- | --- |\n| Saving, validation, toasts | Call your API, then `useToast().create(...)` |\n| Email and password dialogs | `Dialog` from `@wallarm-org/design-system/Dialog`; `PasswordInput` with `PasswordComplexity` and `passwordValidators` for the new password. See [Create and edit forms](./create-edit-forms.md) |\n| Avatar crop | `Dialog` with `ImageCropper` and `Slider`; `FileUpload` only holds the file |\n| \"System\" theme | `useTheme().setTheme` takes `'light'` or `'dark'`. Store `'system'` yourself and resolve it with `matchMedia('(prefers-color-scheme: dark)')` |\n| Theme previews | Pass your own image in `RadioDescription` |\n| Sidebar style | `useTheme().frameStyle` / `setFrameStyle('neutral' \\| 'branded')` |\n| Sidebar mode | Store it yourself; pass `mode` (`'expanded'`, `'collapsed'`, `'compact'`) to `NavRail`. \"Adaptive\" is your logic |\n| Date and time formats | Option labels built from the current date |\n| Org locks | Which values are locked |\n| Delete account | Type-to-confirm `Dialog`, then sign the user out |\n\n## Related\n\n- [Application layout](./app-layout.md)\n- [Spacing](../foundations/spacing.md)\n- [Typography](../foundations/typography.md)\n- [Resource list page](./resource-list-page.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Confirmation dialogs](./confirmation-dialogs.md)\n- [Feedback toasts](./feedback-toasts.md)\n- [Entity table](./entity-table.md)",
1291
+ "sections": [
1292
+ {
1293
+ "id": "when-to-use",
1294
+ "heading": "When to use",
1295
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Independent values of one account or org | This pattern | One form with a Save button |\n| Values saved together (a provider config) | [Create and edit forms](./create-edit-forms.md) in a `Drawer` | Rows that autosave |\n| A list of entities | [Resource list page](./resource-list-page.md) | A row per entity |\n| An events × channels matrix | `Table` with `Switch` cells ([Entity table](./entity-table.md)) | A row per cell |\n| Email or password change | A `Dialog` opened from the row | Editing the value in place |\n| Deleting the account | Type-to-confirm `Dialog` ([Confirmation dialogs](./confirmation-dialogs.md)) | Deleting at once |\n\nPick the row kind by the control:\n\n| Control | Row |\n| --- | --- |\n| `Input`, `Select`, `Switch` | Field row: `Field orientation='horizontal'` › `FieldLabel` + a `w-[320px] shrink-0` wrapper |\n| `SegmentedControl` | Action row with `aria-label` on the control |\n| `Button`, `Avatar` | Action row: `Text` + control |\n| `RadioGroup variant='card'` | No row; the section `Heading` labels it through `aria-labelledby` |"
1296
+ },
1297
+ {
1298
+ "id": "rules",
1299
+ "heading": "Rules",
1300
+ "markdown": "**Layout and spacing** (spacing counts in pixels, `gap-16` = 16px):\n\n- Use `Page fullSize fixedHeight` › `PageHeader` › `PageContent`. **Never** add `PageActions`, `PageToolbar` or `PageDescription`.\n- `PageHeader` › `PageHeaderGroup` › `PageTitle`. The trail is the Settings section's `RemoteShellBreadcrumb` ([Application layout](./app-layout.md)); **never** put `Breadcrumbs` in `PageHeader`.\n- `PageContent` owns the 24px gutter. **Never** add padding around it.\n- Put the sections in one `div className='flex max-w-[640px] flex-col gap-16'`. **Never** wrap it in a `Card` or padded box. **Never** give it `mx-auto`; it stays left-aligned.\n- Section: `flex flex-col gap-16` › `Heading as='h2' size='xl' weight='medium' color='primary'` + a `flex flex-col` row list.\n- Put `Separator spacing={16}` after every row, including a section's last row, except in the last section.\n- **Never** add `gap-*` to the row list. The separators carry the rhythm.\n- Wrap `Input` and `Select` in `w-[320px] shrink-0`. Leave compact controls unwrapped.\n- Give action rows `flex min-h-36 items-center justify-between gap-16`. Use the default `Button` size (36px).\n- Put card radios in `div className='grid grid-cols-3 gap-8'` inside the `RadioGroup`.\n- **Never** pass `className` to `Field`, `FieldLabel`, `Heading`, `Text`, `RadioGroup` or `Radio`. Size them through a wrapper `div`.\n\n**Controls:**\n\n- Save text on blur and choices on change. **Never** save per keystroke or an unchanged value.\n- Use `SelectInput` as the trigger. Over ~10 options, add `SelectHeader` › `SelectSearchInput` with `useSelectSearch`.\n- Use `SegmentedControl` for 2–4 short options. Its `onChange` gets `(value: string)`.\n- Use `RadioGroup variant='card'` only when options carry a picture or description (theme).\n- Use `Button variant='outline' color='neutral'` for a row action that opens a dialog (\"Change password\").\n- Make the avatar the upload trigger: `FileUploadTrigger asChild` › `Avatar as='button'` with `AvatarOverlay`.\n- If a value needs confirming (email, password), open a 560 `Dialog` that asks for the current password. **Never** edit it in the row.\n- If two values only make sense together, put them in one dialog. **Never** autosave half a pair.\n- Put the danger zone last: text (`max-w-[320px]`) left, `Button color='destructive'` right.\n\n**Copy:**\n\n- Page title = rail item (\"Profile\"); breadcrumbs \"Settings › Profile\". Section titles are one noun (\"Preferences\"). Labels are sentence case, no colon.\n- Write the toast as \"<Label> updated\" with `type: 'success'`, one per saved value. This overrides `'default'` from [Feedback toasts](./feedback-toasts.md).\n\nThere is no `SettingsPage`, `SettingsSection` or `SettingsRow`, no `className` on `Field`, no `label` on `Separator`, no `loading` on `Switch` or `Input`, no `'system'` theme value, and no theme preview tile."
1301
+ },
1302
+ {
1303
+ "id": "examples",
1304
+ "heading": "Examples",
1305
+ "markdown": "The page frame: a title in the header, sections straight in `PageContent`.\n\nSettings › Profile: About, Preferences, Appearance, Security and the danger zone in one\nleft-aligned 640px column. Text saves on blur, choices on change, and every save shows a\n\"<Label> updated\" toast. Email and password open dialogs; two-factor is locked by the org.\n\n```tsx story=Default\nimport { type ChangeEvent, useState } from 'react';\nimport { Avatar, AvatarFallback, AvatarImage, AvatarOverlay } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { FileUpload, FileUploadTrigger, useFilePreviewUrl } from '@wallarm-org/design-system/FileUpload';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Radio, RadioDescription, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const Default = () => {\n const toast = useToast();\n const [profile, setProfile] = useState(PROFILE);\n const [name, setName] = useState(PROFILE.name);\n const [nameError, setNameError] = useState('');\n const [photos, setPhotos] = useState<File[]>([]);\n const photo = useFilePreviewUrl(photos[0]);\n\n const timezones = createListCollection({ items: TIMEZONES });\n const dateFormats = createListCollection({ items: DATE_FORMATS });\n\n const save = async (patch: Partial<Profile>, label: string) => {\n setProfile({ ...profile, ...patch });\n try {\n await onSaveProfile(patch);\n toast.create({ title: `${label} updated`, type: 'success' });\n } catch {\n toast.create({ title: `${label} update failed`, type: 'error' });\n }\n };\n\n const handlePhotoChange = (files: File[]) => {\n setPhotos(files);\n toast.create({ title: 'Photo updated', type: 'success' });\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n const handleNameBlur = () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value !== profile.name) save({ name: value }, 'Name');\n };\n\n const handleTimezoneChange = ({ value }: { value: string[] }) =>\n save({ timezone: value[0] }, 'Timezone');\n const handleDateFormatChange = ({ value }: { value: string[] }) =>\n save({ dateFormat: value[0] }, 'Date format');\n const handleTimeFormatChange = (value: string) => save({ timeFormat: value }, 'Time format');\n const handleWeeklyReportChange = ({ checked }: { checked: boolean }) =>\n save({ weeklyReport: checked }, 'Weekly report');\n const handleSidebarStyleChange = (value: string) =>\n save({ sidebarStyle: value }, 'Sidebar style');\n const handleThemeChange = ({ value }: { value: string | null }) => {\n if (value) save({ theme: value }, 'Theme');\n };\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Photo\n </Text>\n <FileUpload\n accept='image/png,image/jpeg,image/webp'\n maxFileSize={512 * 1024}\n value={photos}\n onValueChange={handlePhotoChange}\n className='w-auto'\n data-testid='profile-photo'\n >\n <FileUploadTrigger asChild>\n <Avatar as='button' aria-label='Change photo'>\n <AvatarImage src={photo} />\n <AvatarFallback name={profile.name} />\n <AvatarOverlay />\n </Avatar>\n </FileUploadTrigger>\n </FileUpload>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n value={[profile.timezone]}\n onValueChange={handleTimezoneChange}\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Date format</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={dateFormats}\n value={[profile.dateFormat]}\n onValueChange={handleDateFormatChange}\n data-testid='profile-date-format'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {dateFormats.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Time format\n </Text>\n <SegmentedControl\n aria-label='Time format'\n value={profile.timeFormat}\n onChange={handleTimeFormatChange}\n data-testid='profile-time-format'\n >\n <SegmentedControlItem value='12h'>12-hour</SegmentedControlItem>\n <SegmentedControlItem value='24h'>24-hour</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch\n checked={profile.weeklyReport}\n onCheckedChange={handleWeeklyReportChange}\n data-testid='profile-weekly-report'\n >\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading id='appearance-heading' as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <RadioGroup\n variant='card'\n name='theme'\n aria-labelledby='appearance-heading'\n value={profile.theme}\n onValueChange={handleThemeChange}\n >\n <div className='grid grid-cols-3 gap-8' data-testid='profile-theme'>\n <Radio value='light'>\n <RadioIndicator />\n <RadioLabel>Light</RadioLabel>\n <RadioDescription>Bright surfaces for daytime work</RadioDescription>\n </Radio>\n <Radio value='dark'>\n <RadioIndicator />\n <RadioLabel>Dark</RadioLabel>\n <RadioDescription>Dim surfaces for a SOC at night</RadioDescription>\n </Radio>\n <Radio value='system'>\n <RadioIndicator />\n <RadioLabel>System</RadioLabel>\n <RadioDescription>Follows your operating system</RadioDescription>\n </Radio>\n </div>\n </RadioGroup>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={profile.sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Password\n </Text>\n <Button\n variant='outline'\n color='neutral'\n onClick={onChangePassword}\n data-testid='profile-change-password'\n >\n Change password\n </Button>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Danger zone\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <div className='max-w-[320px]'>\n <Text size='sm' color='secondary'>\n Deletes your account and signs you out. Your organization's data stays.\n </Text>\n </div>\n <Button\n color='destructive'\n onClick={onDeleteAccount}\n data-testid='profile-delete-account'\n >\n Delete account\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nA field row saves on blur and shows an inline error; a failed save keeps the typed value.\n\nStarts invalid: the message sits under the input inside the 320px wrapper, and nothing saves\nuntil the name is valid. A failed save shows an error toast instead of \"Name updated\".\n\n```tsx story=InvalidValue\nimport { type ChangeEvent, useState } from 'react';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const InvalidValue = () => {\n const toast = useToast();\n const [savedName, setSavedName] = useState(PROFILE.name);\n const [name, setName] = useState('');\n const [nameError, setNameError] = useState('Enter your name');\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n // On failure the input keeps the typed value, and the next blur retries.\n const handleNameBlur = async () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value === savedName) return;\n try {\n await onSaveProfile({ name: value });\n setSavedName(value);\n toast.create({ title: 'Name updated', type: 'success' });\n } catch {\n toast.create({ title: 'Name update failed', type: 'error' });\n }\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nAn action row labels with `Text` and `aria-label`, not `FieldLabel`.\n\nThe control saves on change; the row keeps its 36px height whatever the control is.\n\n```tsx story=ActionRow\nimport { useState } from 'react';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ActionRow = () => {\n const toast = useToast();\n const [sidebarStyle, setSidebarStyle] = useState(PROFILE.sidebarStyle);\n\n const handleSidebarStyleChange = (value: string) => {\n setSidebarStyle(value);\n onSaveProfile({ sidebarStyle: value });\n toast.create({ title: 'Sidebar style updated', type: 'success' });\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```\n\nAn org-locked value puts the tooltip on `Lock`; a disabled `Switch` never opens one.\n\nThe row stays visible with its current value, so users see what the org enforces.\n\n```tsx story=LockedValue\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const LockedValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);\n```\n\nA read-only email opens its dialog on click or Enter.\n\nThe dialog asks for the current password before it changes the email.\n\n```tsx story=ReadOnlyValue\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\n\nexport const ReadOnlyValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);\n```"
1306
+ },
1307
+ {
1308
+ "id": "states",
1309
+ "heading": "States",
1310
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Saved | Success toast \"<Label> updated\" | `useToast().create({ type: 'success' })` |\n| Save failed | Error toast; keep the typed value | `useToast().create({ type: 'error' })` |\n| Invalid value | Message inside the 320 wrapper; no save, no toast | `Input error` + `FieldError` |\n| Saving a select | Disabled until the request ends | `Select disabled` |\n| Saving a button action | Loading button; never disable the page | `Button loading` |\n| Saving a switch | Disabled until the request ends | `Switch disabled` |\n| Locked by the org | Disabled control + `Lock` tooltip; keep the row | `Tooltip` › `TooltipTrigger` |\n| Opens a dialog | Read-only value | `Input readOnly` |\n\nPass `error` to `Input`; `Field invalid` alone does not mark it.\n\nWhile a value saves, only its own control changes.\n\nSaving: a select and a switch stay disabled until the request ends, and a button action shows\nits loading state. The rest of the page stays usable.\n\n```tsx story=Saving\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Saving = () => {\n const timezones = createListCollection({ items: TIMEZONES });\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n defaultValue={['Asia/Singapore']}\n disabled\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch checked disabled data-testid='profile-weekly-report'>\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Active sessions\n </Text>\n <Button\n variant='outline'\n color='neutral'\n loading\n data-testid='profile-sign-out-sessions'\n >\n Sign out of all sessions\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};\n```"
1311
+ },
1312
+ {
1313
+ "id": "what-your-app-owns",
1314
+ "heading": "What your app owns",
1315
+ "markdown": "| Concern | Recipe |\n| --- | --- |\n| Saving, validation, toasts | Call your API, then `useToast().create(...)` |\n| Email and password dialogs | `Dialog` from `@wallarm-org/design-system/Dialog`; `PasswordInput` with `PasswordComplexity` and `passwordValidators` for the new password. See [Create and edit forms](./create-edit-forms.md) |\n| Avatar crop | `Dialog` with `ImageCropper` and `Slider`; `FileUpload` only holds the file |\n| \"System\" theme | `useTheme().setTheme` takes `'light'` or `'dark'`. Store `'system'` yourself and resolve it with `matchMedia('(prefers-color-scheme: dark)')` |\n| Theme previews | Pass your own image in `RadioDescription` |\n| Sidebar style | `useTheme().frameStyle` / `setFrameStyle('neutral' \\| 'branded')` |\n| Sidebar mode | Store it yourself; pass `mode` (`'expanded'`, `'collapsed'`, `'compact'`) to `NavRail`. \"Adaptive\" is your logic |\n| Date and time formats | Option labels built from the current date |\n| Org locks | Which values are locked |\n| Delete account | Type-to-confirm `Dialog`, then sign the user out |"
1316
+ },
1317
+ {
1318
+ "id": "related",
1319
+ "heading": "Related",
1320
+ "markdown": "- [Application layout](./app-layout.md)\n- [Spacing](../foundations/spacing.md)\n- [Typography](../foundations/typography.md)\n- [Resource list page](./resource-list-page.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Confirmation dialogs](./confirmation-dialogs.md)\n- [Feedback toasts](./feedback-toasts.md)\n- [Entity table](./entity-table.md)"
1321
+ }
1322
+ ],
1323
+ "stories": [
1324
+ {
1325
+ "name": "Default",
1326
+ "code": "import { type ChangeEvent, useState } from 'react';\nimport { Avatar, AvatarFallback, AvatarImage, AvatarOverlay } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { FileUpload, FileUploadTrigger, useFilePreviewUrl } from '@wallarm-org/design-system/FileUpload';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { Radio, RadioDescription, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const Default = () => {\n const toast = useToast();\n const [profile, setProfile] = useState(PROFILE);\n const [name, setName] = useState(PROFILE.name);\n const [nameError, setNameError] = useState('');\n const [photos, setPhotos] = useState<File[]>([]);\n const photo = useFilePreviewUrl(photos[0]);\n\n const timezones = createListCollection({ items: TIMEZONES });\n const dateFormats = createListCollection({ items: DATE_FORMATS });\n\n const save = async (patch: Partial<Profile>, label: string) => {\n setProfile({ ...profile, ...patch });\n try {\n await onSaveProfile(patch);\n toast.create({ title: `${label} updated`, type: 'success' });\n } catch {\n toast.create({ title: `${label} update failed`, type: 'error' });\n }\n };\n\n const handlePhotoChange = (files: File[]) => {\n setPhotos(files);\n toast.create({ title: 'Photo updated', type: 'success' });\n };\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n const handleNameBlur = () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value !== profile.name) save({ name: value }, 'Name');\n };\n\n const handleTimezoneChange = ({ value }: { value: string[] }) =>\n save({ timezone: value[0] }, 'Timezone');\n const handleDateFormatChange = ({ value }: { value: string[] }) =>\n save({ dateFormat: value[0] }, 'Date format');\n const handleTimeFormatChange = (value: string) => save({ timeFormat: value }, 'Time format');\n const handleWeeklyReportChange = ({ checked }: { checked: boolean }) =>\n save({ weeklyReport: checked }, 'Weekly report');\n const handleSidebarStyleChange = (value: string) =>\n save({ sidebarStyle: value }, 'Sidebar style');\n const handleThemeChange = ({ value }: { value: string | null }) => {\n if (value) save({ theme: value }, 'Theme');\n };\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Photo\n </Text>\n <FileUpload\n accept='image/png,image/jpeg,image/webp'\n maxFileSize={512 * 1024}\n value={photos}\n onValueChange={handlePhotoChange}\n className='w-auto'\n data-testid='profile-photo'\n >\n <FileUploadTrigger asChild>\n <Avatar as='button' aria-label='Change photo'>\n <AvatarImage src={photo} />\n <AvatarFallback name={profile.name} />\n <AvatarOverlay />\n </Avatar>\n </FileUploadTrigger>\n </FileUpload>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n value={[profile.timezone]}\n onValueChange={handleTimezoneChange}\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Date format</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={dateFormats}\n value={[profile.dateFormat]}\n onValueChange={handleDateFormatChange}\n data-testid='profile-date-format'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {dateFormats.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Time format\n </Text>\n <SegmentedControl\n aria-label='Time format'\n value={profile.timeFormat}\n onChange={handleTimeFormatChange}\n data-testid='profile-time-format'\n >\n <SegmentedControlItem value='12h'>12-hour</SegmentedControlItem>\n <SegmentedControlItem value='24h'>24-hour</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch\n checked={profile.weeklyReport}\n onCheckedChange={handleWeeklyReportChange}\n data-testid='profile-weekly-report'\n >\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading id='appearance-heading' as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <RadioGroup\n variant='card'\n name='theme'\n aria-labelledby='appearance-heading'\n value={profile.theme}\n onValueChange={handleThemeChange}\n >\n <div className='grid grid-cols-3 gap-8' data-testid='profile-theme'>\n <Radio value='light'>\n <RadioIndicator />\n <RadioLabel>Light</RadioLabel>\n <RadioDescription>Bright surfaces for daytime work</RadioDescription>\n </Radio>\n <Radio value='dark'>\n <RadioIndicator />\n <RadioLabel>Dark</RadioLabel>\n <RadioDescription>Dim surfaces for a SOC at night</RadioDescription>\n </Radio>\n <Radio value='system'>\n <RadioIndicator />\n <RadioLabel>System</RadioLabel>\n <RadioDescription>Follows your operating system</RadioDescription>\n </Radio>\n </div>\n </RadioGroup>\n <Separator spacing={16} />\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={profile.sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Password\n </Text>\n <Button\n variant='outline'\n color='neutral'\n onClick={onChangePassword}\n data-testid='profile-change-password'\n >\n Change password\n </Button>\n </div>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Danger zone\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <div className='max-w-[320px]'>\n <Text size='sm' color='secondary'>\n Deletes your account and signs you out. Your organization's data stays.\n </Text>\n </div>\n <Button\n color='destructive'\n onClick={onDeleteAccount}\n data-testid='profile-delete-account'\n >\n Delete account\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};",
1327
+ "description": "Settings › Profile: About, Preferences, Appearance, Security and the danger zone in one\nleft-aligned 640px column. Text saves on blur, choices on change, and every save shows a\n\"<Label> updated\" toast. Email and password open dialogs; two-factor is locked by the org."
1328
+ },
1329
+ {
1330
+ "name": "InvalidValue",
1331
+ "code": "import { type ChangeEvent, useState } from 'react';\nimport { Field, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const InvalidValue = () => {\n const toast = useToast();\n const [savedName, setSavedName] = useState(PROFILE.name);\n const [name, setName] = useState('');\n const [nameError, setNameError] = useState('Enter your name');\n\n const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {\n setName(event.target.value);\n setNameError('');\n };\n\n // On failure the input keeps the typed value, and the next blur retries.\n const handleNameBlur = async () => {\n const value = name.trim();\n if (!value) {\n setNameError('Enter your name');\n return;\n }\n if (value === savedName) return;\n try {\n await onSaveProfile({ name: value });\n setSavedName(value);\n toast.create({ title: 'Name updated', type: 'success' });\n } catch {\n toast.create({ title: 'Name update failed', type: 'error' });\n }\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Name</FieldLabel>\n <div className='flex w-[320px] shrink-0 flex-col gap-4'>\n <Input\n value={name}\n error={!!nameError}\n onChange={handleNameChange}\n onBlur={handleNameBlur}\n data-testid='profile-name'\n />\n {nameError && <FieldError>{nameError}</FieldError>}\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};",
1332
+ "description": "Starts invalid: the message sits under the input inside the 320px wrapper, and nothing saves\nuntil the name is valid. A failed save shows an error toast instead of \"Name updated\"."
1333
+ },
1334
+ {
1335
+ "name": "ActionRow",
1336
+ "code": "import { useState } from 'react';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ActionRow = () => {\n const toast = useToast();\n const [sidebarStyle, setSidebarStyle] = useState(PROFILE.sidebarStyle);\n\n const handleSidebarStyleChange = (value: string) => {\n setSidebarStyle(value);\n onSaveProfile({ sidebarStyle: value });\n toast.create({ title: 'Sidebar style updated', type: 'success' });\n };\n\n return (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Appearance\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Sidebar style\n </Text>\n <SegmentedControl\n aria-label='Sidebar style'\n value={sidebarStyle}\n onChange={handleSidebarStyleChange}\n data-testid='profile-sidebar-style'\n >\n <SegmentedControlItem value='neutral'>Neutral</SegmentedControlItem>\n <SegmentedControlItem value='branded'>Branded</SegmentedControlItem>\n </SegmentedControl>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};",
1337
+ "description": "The control saves on change; the row keeps its 36px height whatever the control is."
1338
+ },
1339
+ {
1340
+ "name": "LockedValue",
1341
+ "code": "import { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Lock } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@wallarm-org/design-system/Tooltip';\n\nexport const LockedValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Two-factor authentication</FieldLabel>\n <div className='flex items-center gap-8'>\n <Tooltip>\n <TooltipTrigger asChild>\n <Lock aria-label='Locked' />\n </TooltipTrigger>\n <TooltipContent>Enabled by your organization</TooltipContent>\n </Tooltip>\n <Switch checked disabled data-testid='profile-two-factor'>\n <SwitchControl />\n </Switch>\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);",
1342
+ "description": "The row stays visible with its current value, so users see what the org enforces."
1343
+ },
1344
+ {
1345
+ "name": "ReadOnlyValue",
1346
+ "code": "import { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Page, PageContent } from '@wallarm-org/design-system/Page';\n\nexport const ReadOnlyValue = () => (\n <Page name='settings-profile' data-testid='profile-page'>\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n About\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Email</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Input\n readOnly\n value={EMAIL}\n onClick={onEditEmail}\n onKeyDown={handleEmailKeyDown}\n data-testid='profile-email'\n />\n </div>\n </Field>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n);",
1347
+ "description": "The dialog asks for the current password before it changes the email."
1348
+ },
1349
+ {
1350
+ "name": "Saving",
1351
+ "code": "import { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Page, PageContent, PageHeader, PageHeaderGroup, PageTitle } from '@wallarm-org/design-system/Page';\nimport { createListCollection, Select, SelectContent, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner } from '@wallarm-org/design-system/Select';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { Switch, SwitchControl } from '@wallarm-org/design-system/Switch';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Saving = () => {\n const timezones = createListCollection({ items: TIMEZONES });\n\n return (\n <Page name='settings-profile' fullSize fixedHeight data-testid='profile-page'>\n <PageHeader>\n <PageHeaderGroup>\n <PageTitle>Profile</PageTitle>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageContent>\n <div className='flex max-w-[640px] flex-col gap-16'>\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Preferences\n </Heading>\n <div className='flex flex-col'>\n <Field orientation='horizontal'>\n <FieldLabel>Timezone</FieldLabel>\n <div className='w-[320px] shrink-0'>\n <Select\n collection={timezones}\n defaultValue={['Asia/Singapore']}\n disabled\n data-testid='profile-timezone'\n >\n <SelectInput className='w-full' />\n <SelectPositioner>\n <SelectContent>\n {timezones.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))}\n </SelectContent>\n </SelectPositioner>\n </Select>\n </div>\n </Field>\n <Separator spacing={16} />\n <Field orientation='horizontal'>\n <FieldLabel>Weekly attack summary email</FieldLabel>\n <Switch checked disabled data-testid='profile-weekly-report'>\n <SwitchControl />\n </Switch>\n </Field>\n <Separator spacing={16} />\n </div>\n </section>\n\n <section className='flex flex-col gap-16'>\n <Heading as='h2' size='xl' weight='medium' color='primary'>\n Security\n </Heading>\n <div className='flex flex-col'>\n <div className='flex min-h-36 items-center justify-between gap-16'>\n <Text size='sm' weight='medium' color='primary'>\n Active sessions\n </Text>\n <Button\n variant='outline'\n color='neutral'\n loading\n data-testid='profile-sign-out-sessions'\n >\n Sign out of all sessions\n </Button>\n </div>\n </div>\n </section>\n </div>\n </PageContent>\n </Page>\n );\n};",
1352
+ "description": "Saving: a select and a switch stay disabled until the request ends, and a button action shows\nits loading state. The rest of the page stays usable."
1353
+ }
1354
+ ]
1355
+ },
1356
+ {
1357
+ "slug": "patterns/empty-states",
1358
+ "kind": "pattern",
1359
+ "title": "Empty states",
1360
+ "description": "Use when a table, nested table, tab, section, column or menu has no content and you must pick the EmptyState scale, copy and action.",
1361
+ "components": [
1362
+ "EmptyState",
1363
+ "EmptyStateIllustration",
1364
+ "EmptyStateMessage",
1365
+ "EmptyStateTitle",
1366
+ "EmptyStateDescription",
1367
+ "EmptyStateActions",
1368
+ "EmptyStateLink",
1369
+ "TableEmptyState",
1370
+ "Table",
1371
+ "SelectEmptyState",
1372
+ "FilterDropdownEmpty",
1373
+ "FilterCascadeEmpty",
1374
+ "Tabs",
1375
+ "TabsContent",
1376
+ "Button"
1377
+ ],
1378
+ "keywords": [
1379
+ "empty state",
1380
+ "empty",
1381
+ "no data",
1382
+ "no results",
1383
+ "nothing found",
1384
+ "zero state",
1385
+ "blank slate",
1386
+ "no items yet",
1387
+ "nothing yet",
1388
+ "first use",
1389
+ "onboarding",
1390
+ "empty table",
1391
+ "empty tab",
1392
+ "empty column",
1393
+ "empty list",
1394
+ "dependency",
1395
+ "select first",
1396
+ "reset filters",
1397
+ "caught up",
1398
+ "placeholder"
1399
+ ],
1400
+ "order": 90,
1401
+ "markdown": "# Empty states\n\nAn empty state is `EmptyState` placed where the content would be. The header around it stays.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| A page table with no rows | `TableEmptyState` › `EmptyState type='collection-empty'` | Hiding `PageHeader` or `PageToolbar` |\n| A table in a drawer or section with no rows | `EmptyState type='collection-empty'` as a direct `Table` child | `TableEmptyState` (too tall) |\n| Search or filters matched nothing | `EmptyState` with \"Reset filters\" | A create action |\n| An earlier choice is missing (\"Select a service first\") | `EmptyState` with no actions | Disabling the section silently |\n| A tab or popover panel with nothing in it | `EmptyState` inside `TabsContent` | Hiding the tab |\n| A card, kanban column or chart with no data | `EmptyState type='no-results'` | The icon tile and a pixel title |\n| A `Select` with no options | Nothing: `SelectContent` shows `SelectEmptyState` itself | A hand-built \"No results\" row |\n| A `FilterDropdown` search with no matches | `FilterDropdownEmpty` | `EmptyState` |\n| A `FilterCascade` search with no matches | Nothing: `FilterCascadeEmpty` shows itself | `EmptyState` |\n| One missing value in a cell or attribute | The em dash `'—'`. See [Entity table](./entity-table.md) | `EmptyState` |\n| Data is loading | `Table isLoading` (skeleton rows) | Any empty state |\n| A request failed | Your own error message | `EmptyState` |\n\n`type` sets the scale, not the cause. If the empty area fills a page, table or panel, use `collection-empty` (the default). If it sits in a small container, use `no-results`.\n\n## Rules\n\n**Composition**\n\n- Compose the parts: `EmptyState` › `EmptyStateIllustration` (`collection-empty` only) › `EmptyStateMessage` › `EmptyStateTitle` + `EmptyStateDescription` › `EmptyStateActions` › `EmptyStateLink` (optional).\n- `collection-empty` needs a title and a description. `no-results` needs one of them: a title in a column or card, a description in a chart.\n- **Never** pass `type` to a part. Parts take the scale from `EmptyState`.\n- There is no `size`, `variant`, `icon`, `title` or `action` prop, and no `className` on `TableEmptyState`.\n- **Never** place a bare icon. Put one icon from `@wallarm-org/design-system/icons` in `EmptyStateIllustration` and leave its size alone.\n- `EmptyState` is already a live region (`role='status'`). **Never** wrap it in another.\n\n**Layout and spacing**\n\n- `EmptyState` owns its spacing: `collection-empty` is 256–560px wide with 24px vertical padding and 16px gaps; `no-results` is 240px wide with 8px padding. **Never** add `py-*`, `gap-*` or `w-*` to it or its parts.\n- In a page table, wrap it in `TableEmptyState`: it adds 128px vertical padding and renders only when there are no rows and `isLoading` is false, so it needs no guard.\n- In a drawer or section table, render `EmptyState` directly in `Table`, guarded by `data.length === 0 && !isLoading`.\n- **Never** wrap `EmptyState` in a `Card` or a bordered box.\n- Keep `PageHeader`, `PageToolbar`, section headers and table column headers visible when empty.\n\n**Copy and actions**\n\n- Write the title as a short statement: \"No rules yet\". **Never** an accusation: \"You have no rules\".\n- Keep the description to one or two sentences: what the user gets, or what unlocks the area.\n- Show one create action per screen. If `PageActions` or a section header has the create button, the empty state has no button.\n- If no header has a create button, put `Button size='medium'` in `EmptyStateActions`.\n- If search or filters matched nothing, offer \"Reset filters\" as `Button variant='outline' color='neutral'`. **Never** a create action.\n- If a dependency is missing, name the choice that unlocks the area. **Never** say \"No {items} yet\".\n- If one area can be dependency-empty or nothing-yet, switch the copy.\n- If each tab can be empty, write copy per tab. **Never** reuse one sentence across tabs.\n- Pick an icon that names the entity: `Users` for users, `Shapes` for rules, `CheckCheck` for caught up, `History` for read.\n- **Never** use an `Alert` or a toast to say a list is empty.\n\n## Examples\n\nA page table with nothing yet: `PageActions` has the create button, so the empty state has none.\n\nThe header, toolbar and column headers stay visible. Typing a search switches the copy to\n\"No results found\" with \"Reset filters\": the app passes its own flag for the cause.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Bell, FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n\n const isFiltered = query.trim() !== '';\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={[]}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n {isFiltered ? (\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n Try resetting your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n ) : (\n <EmptyState data-testid='triggers-empty-state'>\n <EmptyStateIllustration>\n <Bell />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No triggers yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to block an IP or notify your team when an attack matches its\n condition.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nSearch or filters matched nothing: reset, never create.\n\nThe table has rows: \"Reset filters\" clears the search and they come back.\n\n```tsx story=NoResults\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('graphql');\n\n const search = query.trim().toLowerCase();\n const rows = TRIGGERS.filter(trigger => trigger.name.toLowerCase().includes(search));\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={rows}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>Try resetting your search or filters.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA drawer or section table: `EmptyState` is a direct `Table` child, hidden while loading.\n\nNo `TableEmptyState` here: its 128px vertical padding is too tall for a section.\n\n```tsx story=SectionTable\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Shapes } from '@wallarm-org/design-system/icons';\nimport { Table } from '@wallarm-org/design-system/Table';\n\nexport const SectionTable = () => {\n const isLoading = false;\n\n return (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='mapping-rules-section'>\n <Heading size='lg' weight='medium'>\n Mapping rules\n </Heading>\n <Table\n aria-label='Mapping rules'\n data={MAPPING_RULES}\n columns={MAPPING_RULE_COLUMNS}\n getRowId={getMappingRuleId}\n isLoading={isLoading}\n data-testid='mapping-rules-table'\n >\n {MAPPING_RULES.length === 0 && !isLoading && (\n <EmptyState data-testid='mapping-rules-empty-state'>\n <EmptyStateIllustration>\n <Shapes />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No rules yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to grant a role or a group from a claim this provider sends.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n );\n};\n```\n\nA missing dependency: name the choice that unlocks the area, with no action.\n\nThe section header stays visible. **Never** \"No operations yet\".\n\n```tsx story=Dependency\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Link2 } from '@wallarm-org/design-system/icons';\n\nexport const Dependency = () => (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='operations-section'>\n <Heading size='lg' weight='medium'>\n Operations\n </Heading>\n <EmptyState data-testid='operations-empty-state'>\n <EmptyStateIllustration>\n <Link2 />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Select a service first</EmptyStateTitle>\n <EmptyStateDescription>Choose a service to pick its operations.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n);\n```\n\nEmpty tabs: each tab gets its own copy and an icon that names the case.\n\nA notification panel: no action, and every tab stays visible.\n\n```tsx story=EmptyTabs\nimport { useState } from 'react';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { CheckCheck, History } from '@wallarm-org/design-system/icons';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\n\nexport const EmptyTabs = () => {\n const [tab, setTab] = useState('unread');\n\n return (\n <div className='w-400 p-24'>\n <Tabs value={tab} onChange={setTab} data-testid='notifications-tabs'>\n <TabsList>\n <TabsTrigger value='unread'>Unread</TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </TabsList>\n <TabsContent value='unread'>\n <EmptyState data-testid='notifications-unread-empty'>\n <EmptyStateIllustration>\n <CheckCheck />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>You're all caught up</EmptyStateTitle>\n <EmptyStateDescription>\n New attacks and incidents will show up here.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n <TabsContent value='read'>\n <EmptyState data-testid='notifications-read-empty'>\n <EmptyStateIllustration>\n <History />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing read yet</EmptyStateTitle>\n <EmptyStateDescription>\n Notifications you open move to this tab.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n </Tabs>\n </div>\n );\n};\n```\n\nSmall containers: a kanban column gets a title only, a chart card a description only.\n\n`type='no-results'` has no illustration. Centre it in the column or the card body.\n\n```tsx story=SmallContainers\nimport { Card, CardContent, CardHeader, CardTitle } from '@wallarm-org/design-system/Card';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SmallContainers = () => (\n <div className='flex gap-16 p-24'>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-open'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n Open\n </Text>\n <NumericBadge>1</NumericBadge>\n </div>\n <div className='flex flex-col gap-4 rounded-8 border border-border-primary p-12'>\n <Text size='sm' weight='medium'>\n SQL injection on /api/v2/payments\n </Text>\n <Text size='xs' color='secondary'>\n payments.acme-bank.com\n </Text>\n </div>\n </div>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-in-progress'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n In progress\n </Text>\n <NumericBadge>0</NumericBadge>\n </div>\n <div className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='incidents-column-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing runs here yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n </div>\n <Card className='h-320 w-400' data-testid='attacks-chart'>\n <CardHeader>\n <CardTitle>Attacks by type</CardTitle>\n </CardHeader>\n <CardContent className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='attacks-chart-empty'>\n <EmptyStateMessage>\n <EmptyStateDescription>No data found for selected period</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </CardContent>\n </Card>\n </div>\n);\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Loading | Skeleton rows, no empty state | `Table isLoading` |\n| Nothing yet, create button in a header | \"No {items} yet\" + what creating one does; no button | `TableEmptyState` › `EmptyState` |\n| Nothing yet, no create button anywhere | Title, description, `Button size='medium'` | `EmptyState` › `EmptyStateActions` |\n| Filters matched nothing | \"No results found\" + \"Reset filters\" | `EmptyState` › `EmptyStateActions` |\n| Earlier choice missing | \"Select a service first\", no action | `EmptyState` |\n| Tab empty | Per-tab copy, no action: \"You're all caught up\" (`CheckCheck`), \"Nothing read yet\" (`History`) | `EmptyState` in `TabsContent` |\n| Column, card or chart empty | Title only, or description only in a chart | `EmptyState type='no-results'` |\n| Request failed | Your own message | App-rendered |\n\n## What your app owns\n\n- **The cause.** The DS cannot tell nothing-yet, filtered and dependency-missing apart. Pass your own flag.\n- **Copy.** `EmptyState` has no default strings.\n- **Dependency gating.** Show the dependency state until the earlier field has a value. See [Create and edit forms](./create-edit-forms.md).\n- **Errors.** There is no error variant. Render your own message.\n- **Notification center.** No popover block exists. Compose `Popover`, `Tabs` and `EmptyState`.\n- **Kanban.** No kanban component exists. Centre `EmptyState type='no-results'` in your column.\n- **Figma drift.** Figma flags the empty state component for an update. Follow the code scale until it changes.\n\n## Related\n\n- [Resource list page](./resource-list-page.md)\n- [Entity table](./entity-table.md)\n- [Detail drawer](./detail-drawer.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Filtering](./filtering.md)\n- [Spacing](../foundations/spacing.md)\n- [Typography](../foundations/typography.md)",
1402
+ "sections": [
1403
+ {
1404
+ "id": "when-to-use",
1405
+ "heading": "When to use",
1406
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| A page table with no rows | `TableEmptyState` › `EmptyState type='collection-empty'` | Hiding `PageHeader` or `PageToolbar` |\n| A table in a drawer or section with no rows | `EmptyState type='collection-empty'` as a direct `Table` child | `TableEmptyState` (too tall) |\n| Search or filters matched nothing | `EmptyState` with \"Reset filters\" | A create action |\n| An earlier choice is missing (\"Select a service first\") | `EmptyState` with no actions | Disabling the section silently |\n| A tab or popover panel with nothing in it | `EmptyState` inside `TabsContent` | Hiding the tab |\n| A card, kanban column or chart with no data | `EmptyState type='no-results'` | The icon tile and a pixel title |\n| A `Select` with no options | Nothing: `SelectContent` shows `SelectEmptyState` itself | A hand-built \"No results\" row |\n| A `FilterDropdown` search with no matches | `FilterDropdownEmpty` | `EmptyState` |\n| A `FilterCascade` search with no matches | Nothing: `FilterCascadeEmpty` shows itself | `EmptyState` |\n| One missing value in a cell or attribute | The em dash `'—'`. See [Entity table](./entity-table.md) | `EmptyState` |\n| Data is loading | `Table isLoading` (skeleton rows) | Any empty state |\n| A request failed | Your own error message | `EmptyState` |\n\n`type` sets the scale, not the cause. If the empty area fills a page, table or panel, use `collection-empty` (the default). If it sits in a small container, use `no-results`."
1407
+ },
1408
+ {
1409
+ "id": "rules",
1410
+ "heading": "Rules",
1411
+ "markdown": "**Composition**\n\n- Compose the parts: `EmptyState` › `EmptyStateIllustration` (`collection-empty` only) › `EmptyStateMessage` › `EmptyStateTitle` + `EmptyStateDescription` › `EmptyStateActions` › `EmptyStateLink` (optional).\n- `collection-empty` needs a title and a description. `no-results` needs one of them: a title in a column or card, a description in a chart.\n- **Never** pass `type` to a part. Parts take the scale from `EmptyState`.\n- There is no `size`, `variant`, `icon`, `title` or `action` prop, and no `className` on `TableEmptyState`.\n- **Never** place a bare icon. Put one icon from `@wallarm-org/design-system/icons` in `EmptyStateIllustration` and leave its size alone.\n- `EmptyState` is already a live region (`role='status'`). **Never** wrap it in another.\n\n**Layout and spacing**\n\n- `EmptyState` owns its spacing: `collection-empty` is 256–560px wide with 24px vertical padding and 16px gaps; `no-results` is 240px wide with 8px padding. **Never** add `py-*`, `gap-*` or `w-*` to it or its parts.\n- In a page table, wrap it in `TableEmptyState`: it adds 128px vertical padding and renders only when there are no rows and `isLoading` is false, so it needs no guard.\n- In a drawer or section table, render `EmptyState` directly in `Table`, guarded by `data.length === 0 && !isLoading`.\n- **Never** wrap `EmptyState` in a `Card` or a bordered box.\n- Keep `PageHeader`, `PageToolbar`, section headers and table column headers visible when empty.\n\n**Copy and actions**\n\n- Write the title as a short statement: \"No rules yet\". **Never** an accusation: \"You have no rules\".\n- Keep the description to one or two sentences: what the user gets, or what unlocks the area.\n- Show one create action per screen. If `PageActions` or a section header has the create button, the empty state has no button.\n- If no header has a create button, put `Button size='medium'` in `EmptyStateActions`.\n- If search or filters matched nothing, offer \"Reset filters\" as `Button variant='outline' color='neutral'`. **Never** a create action.\n- If a dependency is missing, name the choice that unlocks the area. **Never** say \"No {items} yet\".\n- If one area can be dependency-empty or nothing-yet, switch the copy.\n- If each tab can be empty, write copy per tab. **Never** reuse one sentence across tabs.\n- Pick an icon that names the entity: `Users` for users, `Shapes` for rules, `CheckCheck` for caught up, `History` for read.\n- **Never** use an `Alert` or a toast to say a list is empty."
1412
+ },
1413
+ {
1414
+ "id": "examples",
1415
+ "heading": "Examples",
1416
+ "markdown": "A page table with nothing yet: `PageActions` has the create button, so the empty state has none.\n\nThe header, toolbar and column headers stay visible. Typing a search switches the copy to\n\"No results found\" with \"Reset filters\": the app passes its own flag for the cause.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Bell, FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n\n const isFiltered = query.trim() !== '';\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={[]}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n {isFiltered ? (\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n Try resetting your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n ) : (\n <EmptyState data-testid='triggers-empty-state'>\n <EmptyStateIllustration>\n <Bell />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No triggers yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to block an IP or notify your team when an attack matches its\n condition.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nSearch or filters matched nothing: reset, never create.\n\nThe table has rows: \"Reset filters\" clears the search and they come back.\n\n```tsx story=NoResults\nimport { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('graphql');\n\n const search = query.trim().toLowerCase();\n const rows = TRIGGERS.filter(trigger => trigger.name.toLowerCase().includes(search));\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={rows}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>Try resetting your search or filters.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nA drawer or section table: `EmptyState` is a direct `Table` child, hidden while loading.\n\nNo `TableEmptyState` here: its 128px vertical padding is too tall for a section.\n\n```tsx story=SectionTable\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Shapes } from '@wallarm-org/design-system/icons';\nimport { Table } from '@wallarm-org/design-system/Table';\n\nexport const SectionTable = () => {\n const isLoading = false;\n\n return (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='mapping-rules-section'>\n <Heading size='lg' weight='medium'>\n Mapping rules\n </Heading>\n <Table\n aria-label='Mapping rules'\n data={MAPPING_RULES}\n columns={MAPPING_RULE_COLUMNS}\n getRowId={getMappingRuleId}\n isLoading={isLoading}\n data-testid='mapping-rules-table'\n >\n {MAPPING_RULES.length === 0 && !isLoading && (\n <EmptyState data-testid='mapping-rules-empty-state'>\n <EmptyStateIllustration>\n <Shapes />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No rules yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to grant a role or a group from a claim this provider sends.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n );\n};\n```\n\nA missing dependency: name the choice that unlocks the area, with no action.\n\nThe section header stays visible. **Never** \"No operations yet\".\n\n```tsx story=Dependency\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Link2 } from '@wallarm-org/design-system/icons';\n\nexport const Dependency = () => (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='operations-section'>\n <Heading size='lg' weight='medium'>\n Operations\n </Heading>\n <EmptyState data-testid='operations-empty-state'>\n <EmptyStateIllustration>\n <Link2 />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Select a service first</EmptyStateTitle>\n <EmptyStateDescription>Choose a service to pick its operations.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n);\n```\n\nEmpty tabs: each tab gets its own copy and an icon that names the case.\n\nA notification panel: no action, and every tab stays visible.\n\n```tsx story=EmptyTabs\nimport { useState } from 'react';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { CheckCheck, History } from '@wallarm-org/design-system/icons';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\n\nexport const EmptyTabs = () => {\n const [tab, setTab] = useState('unread');\n\n return (\n <div className='w-400 p-24'>\n <Tabs value={tab} onChange={setTab} data-testid='notifications-tabs'>\n <TabsList>\n <TabsTrigger value='unread'>Unread</TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </TabsList>\n <TabsContent value='unread'>\n <EmptyState data-testid='notifications-unread-empty'>\n <EmptyStateIllustration>\n <CheckCheck />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>You're all caught up</EmptyStateTitle>\n <EmptyStateDescription>\n New attacks and incidents will show up here.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n <TabsContent value='read'>\n <EmptyState data-testid='notifications-read-empty'>\n <EmptyStateIllustration>\n <History />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing read yet</EmptyStateTitle>\n <EmptyStateDescription>\n Notifications you open move to this tab.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n </Tabs>\n </div>\n );\n};\n```\n\nSmall containers: a kanban column gets a title only, a chart card a description only.\n\n`type='no-results'` has no illustration. Centre it in the column or the card body.\n\n```tsx story=SmallContainers\nimport { Card, CardContent, CardHeader, CardTitle } from '@wallarm-org/design-system/Card';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SmallContainers = () => (\n <div className='flex gap-16 p-24'>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-open'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n Open\n </Text>\n <NumericBadge>1</NumericBadge>\n </div>\n <div className='flex flex-col gap-4 rounded-8 border border-border-primary p-12'>\n <Text size='sm' weight='medium'>\n SQL injection on /api/v2/payments\n </Text>\n <Text size='xs' color='secondary'>\n payments.acme-bank.com\n </Text>\n </div>\n </div>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-in-progress'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n In progress\n </Text>\n <NumericBadge>0</NumericBadge>\n </div>\n <div className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='incidents-column-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing runs here yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n </div>\n <Card className='h-320 w-400' data-testid='attacks-chart'>\n <CardHeader>\n <CardTitle>Attacks by type</CardTitle>\n </CardHeader>\n <CardContent className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='attacks-chart-empty'>\n <EmptyStateMessage>\n <EmptyStateDescription>No data found for selected period</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </CardContent>\n </Card>\n </div>\n);\n```"
1417
+ },
1418
+ {
1419
+ "id": "states",
1420
+ "heading": "States",
1421
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Loading | Skeleton rows, no empty state | `Table isLoading` |\n| Nothing yet, create button in a header | \"No {items} yet\" + what creating one does; no button | `TableEmptyState` › `EmptyState` |\n| Nothing yet, no create button anywhere | Title, description, `Button size='medium'` | `EmptyState` › `EmptyStateActions` |\n| Filters matched nothing | \"No results found\" + \"Reset filters\" | `EmptyState` › `EmptyStateActions` |\n| Earlier choice missing | \"Select a service first\", no action | `EmptyState` |\n| Tab empty | Per-tab copy, no action: \"You're all caught up\" (`CheckCheck`), \"Nothing read yet\" (`History`) | `EmptyState` in `TabsContent` |\n| Column, card or chart empty | Title only, or description only in a chart | `EmptyState type='no-results'` |\n| Request failed | Your own message | App-rendered |"
1422
+ },
1423
+ {
1424
+ "id": "what-your-app-owns",
1425
+ "heading": "What your app owns",
1426
+ "markdown": "- **The cause.** The DS cannot tell nothing-yet, filtered and dependency-missing apart. Pass your own flag.\n- **Copy.** `EmptyState` has no default strings.\n- **Dependency gating.** Show the dependency state until the earlier field has a value. See [Create and edit forms](./create-edit-forms.md).\n- **Errors.** There is no error variant. Render your own message.\n- **Notification center.** No popover block exists. Compose `Popover`, `Tabs` and `EmptyState`.\n- **Kanban.** No kanban component exists. Centre `EmptyState type='no-results'` in your column.\n- **Figma drift.** Figma flags the empty state component for an update. Follow the code scale until it changes."
1427
+ },
1428
+ {
1429
+ "id": "related",
1430
+ "heading": "Related",
1431
+ "markdown": "- [Resource list page](./resource-list-page.md)\n- [Entity table](./entity-table.md)\n- [Detail drawer](./detail-drawer.md)\n- [Create and edit forms](./create-edit-forms.md)\n- [Filtering](./filtering.md)\n- [Spacing](../foundations/spacing.md)\n- [Typography](../foundations/typography.md)"
1432
+ }
1433
+ ],
1434
+ "stories": [
1435
+ {
1436
+ "name": "Default",
1437
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Bell, FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n\n const isFiltered = query.trim() !== '';\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={[]}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n {isFiltered ? (\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n Try resetting your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n ) : (\n <EmptyState data-testid='triggers-empty-state'>\n <EmptyStateIllustration>\n <Bell />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No triggers yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to block an IP or notify your team when an attack matches its\n condition.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
1438
+ "description": "The header, toolbar and column headers stay visible. Typing a search switches the copy to\n\"No results found\" with \"Reset filters\": the app passes its own flag for the cause."
1439
+ },
1440
+ {
1441
+ "name": "NoResults",
1442
+ "code": "import { useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { FilterX, SearchX } from '@wallarm-org/design-system/icons';\nimport { Page, PageActions, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const NoResults = () => {\n const [query, setQuery] = useState('graphql');\n\n const search = query.trim().toLowerCase();\n const rows = TRIGGERS.filter(trigger => trigger.name.toLowerCase().includes(search));\n\n const handleReset = () => setQuery('');\n\n return (\n <Page name='triggers' fullSize fixedHeight data-testid='triggers-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Triggers</PageTitle>\n <PageDescription>\n Automatic reactions to attacks, incidents and new API endpoints.\n </PageDescription>\n </PageHeaderGroup>\n <PageActions>\n <Button size='medium' onClick={onCreateTrigger} data-testid='triggers-create'>\n Create trigger\n </Button>\n </PageActions>\n </PageHeader>\n\n <PageToolbar>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search triggers'\n className='w-320'\n data-testid='triggers-search'\n />\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Triggers'\n data={rows}\n columns={TRIGGER_COLUMNS}\n getRowId={getTriggerId}\n data-testid='triggers-table'\n >\n <TableEmptyState>\n <EmptyState data-testid='triggers-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>Try resetting your search or filters.</EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
1443
+ "description": "The table has rows: \"Reset filters\" clears the search and they come back."
1444
+ },
1445
+ {
1446
+ "name": "SectionTable",
1447
+ "code": "import { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Shapes } from '@wallarm-org/design-system/icons';\nimport { Table } from '@wallarm-org/design-system/Table';\n\nexport const SectionTable = () => {\n const isLoading = false;\n\n return (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='mapping-rules-section'>\n <Heading size='lg' weight='medium'>\n Mapping rules\n </Heading>\n <Table\n aria-label='Mapping rules'\n data={MAPPING_RULES}\n columns={MAPPING_RULE_COLUMNS}\n getRowId={getMappingRuleId}\n isLoading={isLoading}\n data-testid='mapping-rules-table'\n >\n {MAPPING_RULES.length === 0 && !isLoading && (\n <EmptyState data-testid='mapping-rules-empty-state'>\n <EmptyStateIllustration>\n <Shapes />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No rules yet</EmptyStateTitle>\n <EmptyStateDescription>\n Create one to grant a role or a group from a claim this provider sends.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n );\n};",
1448
+ "description": "No `TableEmptyState` here: its 128px vertical padding is too tall for a section."
1449
+ },
1450
+ {
1451
+ "name": "Dependency",
1452
+ "code": "import { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Link2 } from '@wallarm-org/design-system/icons';\n\nexport const Dependency = () => (\n <div className='flex w-640 flex-col gap-12 p-24' data-testid='operations-section'>\n <Heading size='lg' weight='medium'>\n Operations\n </Heading>\n <EmptyState data-testid='operations-empty-state'>\n <EmptyStateIllustration>\n <Link2 />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Select a service first</EmptyStateTitle>\n <EmptyStateDescription>Choose a service to pick its operations.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n);",
1453
+ "description": "The section header stays visible. **Never** \"No operations yet\"."
1454
+ },
1455
+ {
1456
+ "name": "EmptyTabs",
1457
+ "code": "import { useState } from 'react';\nimport { EmptyState, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { CheckCheck, History } from '@wallarm-org/design-system/icons';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@wallarm-org/design-system/Tabs';\n\nexport const EmptyTabs = () => {\n const [tab, setTab] = useState('unread');\n\n return (\n <div className='w-400 p-24'>\n <Tabs value={tab} onChange={setTab} data-testid='notifications-tabs'>\n <TabsList>\n <TabsTrigger value='unread'>Unread</TabsTrigger>\n <TabsTrigger value='read'>Read</TabsTrigger>\n </TabsList>\n <TabsContent value='unread'>\n <EmptyState data-testid='notifications-unread-empty'>\n <EmptyStateIllustration>\n <CheckCheck />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>You're all caught up</EmptyStateTitle>\n <EmptyStateDescription>\n New attacks and incidents will show up here.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n <TabsContent value='read'>\n <EmptyState data-testid='notifications-read-empty'>\n <EmptyStateIllustration>\n <History />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing read yet</EmptyStateTitle>\n <EmptyStateDescription>\n Notifications you open move to this tab.\n </EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </TabsContent>\n </Tabs>\n </div>\n );\n};",
1458
+ "description": "A notification panel: no action, and every tab stays visible."
1459
+ },
1460
+ {
1461
+ "name": "SmallContainers",
1462
+ "code": "import { Card, CardContent, CardHeader, CardTitle } from '@wallarm-org/design-system/Card';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SmallContainers = () => (\n <div className='flex gap-16 p-24'>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-open'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n Open\n </Text>\n <NumericBadge>1</NumericBadge>\n </div>\n <div className='flex flex-col gap-4 rounded-8 border border-border-primary p-12'>\n <Text size='sm' weight='medium'>\n SQL injection on /api/v2/payments\n </Text>\n <Text size='xs' color='secondary'>\n payments.acme-bank.com\n </Text>\n </div>\n </div>\n <div\n className='flex h-320 w-280 flex-col gap-8 rounded-12 border border-border-primary-light p-12'\n data-testid='incidents-column-in-progress'\n >\n <div className='flex items-center gap-8'>\n <Text size='sm' weight='medium'>\n In progress\n </Text>\n <NumericBadge>0</NumericBadge>\n </div>\n <div className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='incidents-column-empty'>\n <EmptyStateMessage>\n <EmptyStateTitle>Nothing runs here yet</EmptyStateTitle>\n </EmptyStateMessage>\n </EmptyState>\n </div>\n </div>\n <Card className='h-320 w-400' data-testid='attacks-chart'>\n <CardHeader>\n <CardTitle>Attacks by type</CardTitle>\n </CardHeader>\n <CardContent className='flex flex-1 items-center justify-center'>\n <EmptyState type='no-results' data-testid='attacks-chart-empty'>\n <EmptyStateMessage>\n <EmptyStateDescription>No data found for selected period</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n </CardContent>\n </Card>\n </div>\n);",
1463
+ "description": "`type='no-results'` has no illustration. Centre it in the column or the card body."
1464
+ }
1465
+ ]
1466
+ },
1467
+ {
1468
+ "slug": "patterns/filtering",
1469
+ "kind": "pattern",
1470
+ "title": "Filtering",
1471
+ "description": "Use when narrowing a list or table by attributes, or picking values from a long list — filter chips, hierarchical scope, query builder, searchable select, picker dialog.",
1472
+ "components": [
1473
+ "FilterDropdown",
1474
+ "FilterDropdownTrigger",
1475
+ "FilterDropdownContent",
1476
+ "FilterDropdownOption",
1477
+ "FilterCascade",
1478
+ "FilterCascadeTrigger",
1479
+ "FilterCascadeContent",
1480
+ "FilterCascadeCheckboxItem",
1481
+ "FilterInput",
1482
+ "SearchInput",
1483
+ "PageToolbar",
1484
+ "Select",
1485
+ "SelectInput",
1486
+ "SelectSearchInput",
1487
+ "SegmentedTabs",
1488
+ "SegmentedControl",
1489
+ "NumericBadge",
1490
+ "Table",
1491
+ "TableEmptyState",
1492
+ "EmptyState",
1493
+ "Dialog",
1494
+ "Field",
1495
+ "Button"
1496
+ ],
1497
+ "keywords": [
1498
+ "filter",
1499
+ "filters",
1500
+ "filter bar",
1501
+ "filter chip",
1502
+ "filter pill",
1503
+ "facet",
1504
+ "facets",
1505
+ "toolbar",
1506
+ "scope filter",
1507
+ "hierarchy",
1508
+ "cascade",
1509
+ "query builder",
1510
+ "search",
1511
+ "searchable select",
1512
+ "combobox",
1513
+ "autocomplete",
1514
+ "select search",
1515
+ "picker",
1516
+ "picker dialog",
1517
+ "add users",
1518
+ "multi select",
1519
+ "view toggle"
1520
+ ],
1521
+ "order": 90,
1522
+ "markdown": "# Filtering\n\nPick the control by the shape of the values; every filter applies on pick.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Text match on name or ID | `SearchInput` in `PageToolbar` | `Input` with a search icon |\n| One status with counts | `SegmentedTabs` + `NumericBadge` | `FilterDropdown` |\n| One flat attribute, one value | `FilterDropdown` with `allLabel` | `Select` in a toolbar |\n| One flat attribute, several values | `FilterDropdown multiple` | `Checkbox` rows |\n| A hierarchy (organization › deployment › application) | `FilterCascade` | Nested `DropdownMenu`s |\n| Over four attributes, operators, AND/OR | `FilterInput` | Five `FilterDropdown`s |\n| A value in a form | `Select` + `SelectInput` in `Field` | `FilterDropdown` |\n| Pick rows from a large set | `Dialog width={960}` + selectable `Table` | A huge multi `Select` |\n| Table or schema view | `SegmentedControl` with icons | `Tabs` |\n\nThere is no `FilterBar`, `PageFilters` or `Combobox` (use `Select` + `useSelectSearch`), and no `onApply` prop.\n\n## Rules\n\n**Filter bar layout**\n\n- `PageToolbar` owns the 24px gutter and 12px gap. **Never** add `gap-*`, `px-*` or `py-*` to it.\n- Wrap `SearchInput` and the filters in one `<div className='flex items-center gap-8'>`.\n- `SearchInput` is `w-[320px]` in a toolbar, `flex-1` in a dialog.\n- A view toggle goes last with `className='ml-auto'`.\n- **Never** set a width on filter triggers; they hug their text up to 180px.\n- **Never** stack two `SegmentedTabs` in one toolbar.\n\n**FilterDropdown**\n\n- Always give it a `label`; it names the trigger and aria-label.\n- If the attribute is exclusive, use `allLabel` («All …» resets). If values combine, use `multiple` (OR-ed).\n- Build `collection` with `createListCollection({ items, groupBy })`.\n- Render options from `useFilterDropdown().groups` in a child component so they follow the search.\n- `details.value` of `[]` means unset. Pass counts as `hint`.\n- **Never** put `FilterDropdown` in a form or add an Apply button to a filter menu.\n\n**FilterCascade**\n\n- Import all cascade parts from `@wallarm-org/design-system/FilterCascade`, never the root.\n- Build `collection` with `createFilterCascadeCollection(items, accessors)`; plain `{ value, label, children }` items need no accessors.\n- If a level loads on demand, pass `loadChildren` and mark parents `hasChildren`. If a parent is pickable, pass `parentLabel`.\n- If the menu needs a toggle (\"Own only\"), use `FilterCascadeCheckboxItem` in `FilterCascadeSection`; you keep its state.\n- **Never** leave a description empty; show \"No policies\" for zero.\n\n**FilterInput**\n\n- Pass `fields` (`name`, `label`, `type`, `values`) and `value` / `onChange(ExprNode | null)`. Its menus and chips are built in.\n\n**Searchable select**\n\n- Put `SelectSearchInput` in `SelectHeader`, wired to `useSelectSearch`. Pass the original collection to `Select`; render `filteredCollection`.\n- If the menu must match the trigger, pass `positioning={{ sameWidth: true }}` and `SelectPositioner className='min-w-full max-w-full'`; otherwise it is 240–320px.\n- Option icons: set `icon` on each item.\n- For several values use `SelectInput multiple`. `SelectButtonTag` is a trigger, not a chip list.\n\n**Picker dialog**\n\n- Use `Dialog width={960}`; there is no `DIALOG_SIZES`.\n- Dialog parts own the 24px gutter. **Never** pad them. `DialogBody` has no `className`: put `flex flex-col gap-8` on a child div, search and tabs in `flex items-center gap-12`.\n- Pass `getRowId` so selection survives search and paging.\n- Primary is `disabled` \"Add users\" until rows are picked, then \"Add 1 user\", \"Add 3 users\".\n\n## Examples\n\nA filter bar over a table: search, a multiple and a single filter, a scope chip, and the view toggle last.\n\nEvery pick applies at once. When the filters match nothing, the table offers \"Reset filters\",\nwhich clears the search and every filter.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\nimport { FilterDropdown, FilterDropdownAllOption, FilterDropdownContent, FilterDropdownFooter, FilterDropdownOption, FilterDropdownTrigger, type FilterDropdownValueChangeDetails, useFilterDropdown } from '@wallarm-org/design-system/FilterDropdown';\nimport { FilterX, List, SearchX, Workflow } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [types, setTypes] = useState<string[]>([]);\n const [status, setStatus] = useState<string[]>([]);\n const [scope, setScope] = useState<string[]>([]);\n const [view, setView] = useState('table');\n\n const TYPE_LABELS: Record<AttackType, string> = {\n sqli: 'SQL injection',\n xss: 'XSS',\n rce: 'RCE',\n ptrav: 'Path traversal',\n ssrf: 'SSRF',\n };\n const typeItems = Object.entries(TYPE_LABELS).map(([value, label]) => ({\n value,\n label,\n count: ATTACKS.filter(attack => attack.type === value).length,\n }));\n const typeOptions = createListCollection({ items: typeItems });\n const statusOptions = createListCollection({\n items: [\n { value: 'blocked', label: 'Blocked' },\n { value: 'monitored', label: 'Monitored' },\n ],\n });\n const scopeOptions = createFilterCascadeCollection(SCOPES);\n\n const renderType = ({ row }: TableCellContext<Attack, unknown>) => TYPE_LABELS[row.original.type];\n\n const renderStatus = ({ row }: TableCellContext<Attack, unknown>) =>\n row.original.status === 'blocked' ? (\n <Badge variant='dotted' type='secondary' color='red'>\n Blocked\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='yellow'>\n Monitored\n </Badge>\n );\n\n const columns: TableColumnDef<Attack>[] = [\n { accessorKey: 'type', header: 'Type', size: 160, cell: renderType },\n { accessorKey: 'path', header: 'Request', size: 280 },\n { accessorKey: 'source', header: 'Source IP', size: 160 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'hits', header: 'Hits', size: 100 },\n ];\n\n const search = query.trim().toLowerCase();\n const rows = ATTACKS.filter(\n attack =>\n (attack.path.toLowerCase().includes(search) || attack.source.includes(search)) &&\n (types.length === 0 || types.includes(attack.type)) &&\n (status.length === 0 || status.includes(attack.status)) &&\n (scope.length === 0 || attack.deployment === scope[0]) &&\n (scope.length < 2 || attack.application === scope[1]),\n );\n\n const handleTypesChange = (details: FilterDropdownValueChangeDetails) => setTypes(details.value);\n const handleStatusChange = (details: FilterDropdownValueChangeDetails) =>\n setStatus(details.value);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n\n const handleReset = () => {\n setQuery('');\n setTypes([]);\n setStatus([]);\n setScope([]);\n };\n\n return (\n <Page name='attacks' fullSize fixedHeight data-testid='attacks-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Attacks</PageTitle>\n <PageDescription>\n Malicious requests the filtering nodes detected in the last 7 days.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageToolbar>\n <div className='flex items-center gap-8'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search request or IP'\n className='w-[320px]'\n data-testid='attacks-search'\n />\n <FilterDropdown\n label='Type'\n multiple\n collection={typeOptions}\n value={types}\n onValueChange={handleTypesChange}\n data-testid='attacks-type-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n {/* Under 8 options the menu has no search, so the options are listed directly.\n From 8, render them from `useFilterDropdown().groups` in a child component. */}\n {typeItems.map(item => (\n <FilterDropdownOption key={item.value} item={item} hint={item.count} />\n ))}\n <FilterDropdownFooter />\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterDropdown\n label='Status'\n allLabel='All statuses'\n collection={statusOptions}\n value={status}\n onValueChange={handleStatusChange}\n data-testid='attacks-status-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n <FilterDropdownAllOption />\n {statusOptions.items.map(item => (\n <FilterDropdownOption key={item.value} item={item} />\n ))}\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterCascade\n label='Scope'\n collection={scopeOptions}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='attacks-scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent />\n </FilterCascade>\n </div>\n <SegmentedControl\n value={view}\n onChange={setView}\n className='ml-auto'\n data-testid='attacks-view-toggle'\n >\n <SegmentedControlItem value='table'>\n <List />\n <span className='sr-only'>Table view</span>\n </SegmentedControlItem>\n <SegmentedControlItem value='schema'>\n <Workflow />\n <span className='sr-only'>Schema view</span>\n </SegmentedControlItem>\n </SegmentedControl>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Attacks'\n data={rows}\n columns={columns}\n getRowId={getAttackId}\n data-testid='attacks-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='attacks-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n No attacks match your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nFrom eight options a filter menu gets a search, and its options follow the query.\n\nOptions are grouped by deployment and carry attack counts. On reopen, picked options are\npinned under «Selected»; a query with no match shows an empty message and keeps the menu open.\n\n```tsx story=SearchableFilter\nimport { useState } from 'react';\nimport { FilterDropdown, FilterDropdownTrigger, type FilterDropdownValueChangeDetails } from '@wallarm-org/design-system/FilterDropdown';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\n\nexport const SearchableFilter = () => {\n const [applications, setApplications] = useState<string[]>([]);\n const applicationOptions = createListCollection({\n items: APPLICATIONS,\n groupBy: item => item.deployment,\n });\n\n const handleApplicationsChange = (details: FilterDropdownValueChangeDetails) =>\n setApplications(details.value);\n\n return (\n <div className='p-24'>\n <FilterDropdown\n label='Application'\n multiple\n collection={applicationOptions}\n value={applications}\n onValueChange={handleApplicationsChange}\n data-testid='applications-filter'\n >\n <FilterDropdownTrigger />\n <ApplicationMenu />\n </FilterDropdown>\n </div>\n );\n};\n```\n\nA scope chip whose applications load on demand and whose deployments can be picked.\n\nEach option shows its policy count, \"No policies\" for zero. ✕ on the trigger returns to every\nscope, not one level up.\n\n```tsx story=ScopeFilter\nimport { useState } from 'react';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeItems, type FilterCascadeLevelData, FilterCascadeLevels, FilterCascadeParentItem, FilterCascadeSearch, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\n\nexport const ScopeFilter = () => {\n const [scope, setScope] = useState<string[]>([]);\n const scopes = createFilterCascadeCollection(DEPLOYMENTS, {\n getValue: node => node.id,\n getLabel: node => node.name,\n hasChildren: node => node.kind === 'deployment',\n getIcon: node => (\n <span\n aria-hidden\n className={\n node.kind === 'deployment'\n ? 'size-6 rounded-full bg-badge-sky-strong'\n : 'size-6 rounded-full bg-badge-teal-strong'\n }\n />\n ),\n getDescription: node =>\n node.policies === 0\n ? 'No policies'\n : `${node.policies} ${node.policies === 1 ? 'policy' : 'policies'}`,\n });\n\n const loadApplications = (deployment: ScopeNode) => fetchApplications(deployment.id);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n const renderLevel = (level: FilterCascadeLevelData) =>\n level.depth === 0 ? (\n <FilterCascadeItems />\n ) : (\n <>\n <FilterCascadeParentItem />\n <FilterCascadeItems />\n </>\n );\n\n return (\n <div className='p-24'>\n <FilterCascade\n label='Scope'\n collection={scopes}\n loadChildren={loadApplications}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent>\n <FilterCascadeSearch placeholder='Search' />\n <FilterCascadeLevels>{renderLevel}</FilterCascadeLevels>\n </FilterCascadeContent>\n </FilterCascade>\n </div>\n );\n};\n```\n\nA searchable select in a form, with the menu matching the trigger.\n\nA settings field saves on pick, with no Save button. A query with no match shows\n`SelectEmptyState` and keeps the menu open.\n\n```tsx story=SearchableSelect\nimport { useState } from 'react';\nimport { Field, FieldDescription, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { createListCollection, Select, SelectContent, SelectEmptyState, SelectHeader, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner, SelectSearchInput, useSelectSearch } from '@wallarm-org/design-system/Select';\n\nexport const SearchableSelect = () => {\n const [timeZone, setTimeZone] = useState('UTC');\n const timeZones = createListCollection({ items: TIME_ZONES });\n const { searchValue, onSearchChange, filteredCollection } = useSelectSearch(timeZones);\n\n const handleTimeZoneChange = (details: { value: string[] }) => {\n const next = details.value[0] ?? timeZone;\n setTimeZone(next);\n onTimeZoneSaved(next);\n };\n\n return (\n <div className='w-[400px] p-24'>\n <Field>\n <FieldLabel>Time zone</FieldLabel>\n <Select\n collection={timeZones}\n value={[timeZone]}\n positioning={{ sameWidth: true }}\n onValueChange={handleTimeZoneChange}\n data-testid='timezone-select'\n >\n <SelectInput placeholder='Choose a time zone' data-testid='timezone-input' />\n <SelectPositioner className='min-w-full max-w-full'>\n <SelectHeader>\n <SelectSearchInput value={searchValue} onChange={onSearchChange} />\n </SelectHeader>\n <SelectContent>\n {filteredCollection.size === 0 ? (\n <SelectEmptyState description={`No results for \"${searchValue}\"`} />\n ) : (\n filteredCollection.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))\n )}\n </SelectContent>\n </SelectPositioner>\n </Select>\n <FieldDescription>\n Dates in reports and on the Attacks page use this time zone.\n </FieldDescription>\n </Field>\n </div>\n );\n};\n```\n\nA picker dialog whose primary counts the selection: disabled \"Add users\", then \"Add 2 users\".\n\nSearch and status tabs sit over a selectable `Table`. Picked rows stay picked while the search\nchanges, because rows are keyed by `getRowId`.\n\n```tsx story=PickerDialog\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogFooterControls, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, Table } from '@wallarm-org/design-system/Table';\n\nexport const PickerDialog = () => {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const renderMember = ({ row }: TableCellContext<Member, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<Member, unknown>) =>\n row.original.status === 'active' ? 'Active' : 'Invited';\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'User', size: 520, cell: renderMember },\n { accessorKey: 'status', header: 'Status', size: 200, cell: renderStatus },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = MEMBERS.filter(\n member =>\n member.name.toLowerCase().includes(search) || member.email.toLowerCase().includes(search),\n );\n const activeCount = matching.filter(member => member.status === 'active').length;\n const invitedCount = matching.length - activeCount;\n const rows = status === 'all' ? matching : matching.filter(member => member.status === status);\n const count = Object.keys(selection).length;\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setQuery('');\n setStatus('all');\n setSelection({});\n }\n };\n const handleOpen = () => setOpen(true);\n const handleAdd = () => {\n onAddUsers(Object.keys(selection));\n handleOpenChange(false);\n };\n\n return (\n <div className='p-24'>\n <Button size='medium' onClick={handleOpen} data-testid='members-add'>\n <UserPlus />\n Add users\n </Button>\n <Dialog open={open} onOpenChange={handleOpenChange} width={960} data-testid='members-picker'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Add users to Security team</DialogTitle>\n </DialogHeader>\n <DialogBody>\n <div className='flex flex-col gap-8'>\n <div className='flex items-center gap-12'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='flex-1'\n data-testid='members-search'\n />\n <SegmentedTabs\n value={status}\n onChange={setStatus}\n data-testid='members-status-tabs'\n >\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{matching.length}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{activeCount}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{invitedCount}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </div>\n <Table\n aria-label='Users to add'\n data={rows}\n columns={columns}\n getRowId={getMemberId}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='members-table'\n >\n {rows.length === 0 && (\n <EmptyState type='collection-empty' data-testid='members-no-results'>\n <EmptyStateMessage>\n <EmptyStateTitle>No users found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </DialogBody>\n <DialogFooter>\n <DialogFooterControls>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' size='large'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n size='large'\n disabled={count === 0}\n onClick={handleAdd}\n data-testid='members-confirm'\n >\n {count === 0 ? 'Add users' : `Add ${count} ${count === 1 ? 'user' : 'users'}`}\n </Button>\n </DialogFooterControls>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Unset | Single: `allLabel`; multi: `label` | `FilterDropdownTrigger` |\n| One value / several | \"Type • Lua\" / \"Type • 2\", built in; never custom text or a tag per value | `FilterDropdownTrigger` |\n| Reset one filter | Multi: ✕ or Backspace; single: «All». No per-filter Reset button | `FilterDropdown` |\n| Reset scope | ✕ returns to the page default, not one level up | `FilterCascadeTrigger` |\n| 8+ options | Search appears (`searchThreshold`) | `FilterDropdownSearch`, `SelectSearchInput` |\n| Menu search, no match | Empty message; menu stays open | `FilterDropdownEmpty`, `SelectEmptyState` |\n| Cascade level loading or failed | Built-in loading rows, then Retry | `FilterCascade` |\n| Filters match no rows | `type='collection-empty'` + outline neutral \"Reset filters\"; never a create CTA | `TableEmptyState` |\n| Default value | Pinned first in a \"Suggested\" group | `SelectGroup` |\n| Select on settings page | Saves on pick, toast; no Save button | `Select`, `Toast` |\n| Picker running | Primary `loading`, dialog open; on success close and toast \"3 users added\" | `Button`, `Dialog` |\n| Picker, no match | `EmptyState type='collection-empty'` as a direct `Table` child; `TableEmptyState` is too tall | `EmptyState` |\n| Picker, already a member | Leave the row out of `data` | `Table` |\n\n## What your app owns\n\n| You own | The DS owns |\n| --- | --- |\n| Applying `details.value` to your data | Filter values and trigger text |\n| URL sync (no router integration) | Nothing |\n| \"Reset all\": every filter `[]`, search `''` | Per-filter clear |\n| Option counts (`hint`, `getDescription`); a pinned option goes first in its own `category` | Rendering them |\n| Scope dots: `size-6 rounded-full` span, `bg-badge-violet-strong` / `-sky-strong` / `-teal-strong`, via `getIcon` | The icon slot |\n| Server search: debounce `query`, refetch `data` | Client filtering only |\n| Picker selection across pages: keep `rowSelection` keys (row ids) when `data` changes | Checkbox column |\n| Picker user cell (name, muted email): see [Entity table](./entity-table.md) | Nothing |\n\n## Related\n\n- [Resource list page](./resource-list-page.md), [Entity table](./entity-table.md), [Empty states](./empty-states.md)\n- [Feedback toasts](./feedback-toasts.md), [Settings page](./settings-page.md), [Create and edit forms](./create-edit-forms.md)\n- [Spacing](../foundations/spacing.md)",
1523
+ "sections": [
1524
+ {
1525
+ "id": "when-to-use",
1526
+ "heading": "When to use",
1527
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Text match on name or ID | `SearchInput` in `PageToolbar` | `Input` with a search icon |\n| One status with counts | `SegmentedTabs` + `NumericBadge` | `FilterDropdown` |\n| One flat attribute, one value | `FilterDropdown` with `allLabel` | `Select` in a toolbar |\n| One flat attribute, several values | `FilterDropdown multiple` | `Checkbox` rows |\n| A hierarchy (organization › deployment › application) | `FilterCascade` | Nested `DropdownMenu`s |\n| Over four attributes, operators, AND/OR | `FilterInput` | Five `FilterDropdown`s |\n| A value in a form | `Select` + `SelectInput` in `Field` | `FilterDropdown` |\n| Pick rows from a large set | `Dialog width={960}` + selectable `Table` | A huge multi `Select` |\n| Table or schema view | `SegmentedControl` with icons | `Tabs` |\n\nThere is no `FilterBar`, `PageFilters` or `Combobox` (use `Select` + `useSelectSearch`), and no `onApply` prop."
1528
+ },
1529
+ {
1530
+ "id": "rules",
1531
+ "heading": "Rules",
1532
+ "markdown": "**Filter bar layout**\n\n- `PageToolbar` owns the 24px gutter and 12px gap. **Never** add `gap-*`, `px-*` or `py-*` to it.\n- Wrap `SearchInput` and the filters in one `<div className='flex items-center gap-8'>`.\n- `SearchInput` is `w-[320px]` in a toolbar, `flex-1` in a dialog.\n- A view toggle goes last with `className='ml-auto'`.\n- **Never** set a width on filter triggers; they hug their text up to 180px.\n- **Never** stack two `SegmentedTabs` in one toolbar.\n\n**FilterDropdown**\n\n- Always give it a `label`; it names the trigger and aria-label.\n- If the attribute is exclusive, use `allLabel` («All …» resets). If values combine, use `multiple` (OR-ed).\n- Build `collection` with `createListCollection({ items, groupBy })`.\n- Render options from `useFilterDropdown().groups` in a child component so they follow the search.\n- `details.value` of `[]` means unset. Pass counts as `hint`.\n- **Never** put `FilterDropdown` in a form or add an Apply button to a filter menu.\n\n**FilterCascade**\n\n- Import all cascade parts from `@wallarm-org/design-system/FilterCascade`, never the root.\n- Build `collection` with `createFilterCascadeCollection(items, accessors)`; plain `{ value, label, children }` items need no accessors.\n- If a level loads on demand, pass `loadChildren` and mark parents `hasChildren`. If a parent is pickable, pass `parentLabel`.\n- If the menu needs a toggle (\"Own only\"), use `FilterCascadeCheckboxItem` in `FilterCascadeSection`; you keep its state.\n- **Never** leave a description empty; show \"No policies\" for zero.\n\n**FilterInput**\n\n- Pass `fields` (`name`, `label`, `type`, `values`) and `value` / `onChange(ExprNode | null)`. Its menus and chips are built in.\n\n**Searchable select**\n\n- Put `SelectSearchInput` in `SelectHeader`, wired to `useSelectSearch`. Pass the original collection to `Select`; render `filteredCollection`.\n- If the menu must match the trigger, pass `positioning={{ sameWidth: true }}` and `SelectPositioner className='min-w-full max-w-full'`; otherwise it is 240–320px.\n- Option icons: set `icon` on each item.\n- For several values use `SelectInput multiple`. `SelectButtonTag` is a trigger, not a chip list.\n\n**Picker dialog**\n\n- Use `Dialog width={960}`; there is no `DIALOG_SIZES`.\n- Dialog parts own the 24px gutter. **Never** pad them. `DialogBody` has no `className`: put `flex flex-col gap-8` on a child div, search and tabs in `flex items-center gap-12`.\n- Pass `getRowId` so selection survives search and paging.\n- Primary is `disabled` \"Add users\" until rows are picked, then \"Add 1 user\", \"Add 3 users\"."
1533
+ },
1534
+ {
1535
+ "id": "examples",
1536
+ "heading": "Examples",
1537
+ "markdown": "A filter bar over a table: search, a multiple and a single filter, a scope chip, and the view toggle last.\n\nEvery pick applies at once. When the filters match nothing, the table offers \"Reset filters\",\nwhich clears the search and every filter.\n\n```tsx story=Default\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\nimport { FilterDropdown, FilterDropdownAllOption, FilterDropdownContent, FilterDropdownFooter, FilterDropdownOption, FilterDropdownTrigger, type FilterDropdownValueChangeDetails, useFilterDropdown } from '@wallarm-org/design-system/FilterDropdown';\nimport { FilterX, List, SearchX, Workflow } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [types, setTypes] = useState<string[]>([]);\n const [status, setStatus] = useState<string[]>([]);\n const [scope, setScope] = useState<string[]>([]);\n const [view, setView] = useState('table');\n\n const TYPE_LABELS: Record<AttackType, string> = {\n sqli: 'SQL injection',\n xss: 'XSS',\n rce: 'RCE',\n ptrav: 'Path traversal',\n ssrf: 'SSRF',\n };\n const typeItems = Object.entries(TYPE_LABELS).map(([value, label]) => ({\n value,\n label,\n count: ATTACKS.filter(attack => attack.type === value).length,\n }));\n const typeOptions = createListCollection({ items: typeItems });\n const statusOptions = createListCollection({\n items: [\n { value: 'blocked', label: 'Blocked' },\n { value: 'monitored', label: 'Monitored' },\n ],\n });\n const scopeOptions = createFilterCascadeCollection(SCOPES);\n\n const renderType = ({ row }: TableCellContext<Attack, unknown>) => TYPE_LABELS[row.original.type];\n\n const renderStatus = ({ row }: TableCellContext<Attack, unknown>) =>\n row.original.status === 'blocked' ? (\n <Badge variant='dotted' type='secondary' color='red'>\n Blocked\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='yellow'>\n Monitored\n </Badge>\n );\n\n const columns: TableColumnDef<Attack>[] = [\n { accessorKey: 'type', header: 'Type', size: 160, cell: renderType },\n { accessorKey: 'path', header: 'Request', size: 280 },\n { accessorKey: 'source', header: 'Source IP', size: 160 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'hits', header: 'Hits', size: 100 },\n ];\n\n const search = query.trim().toLowerCase();\n const rows = ATTACKS.filter(\n attack =>\n (attack.path.toLowerCase().includes(search) || attack.source.includes(search)) &&\n (types.length === 0 || types.includes(attack.type)) &&\n (status.length === 0 || status.includes(attack.status)) &&\n (scope.length === 0 || attack.deployment === scope[0]) &&\n (scope.length < 2 || attack.application === scope[1]),\n );\n\n const handleTypesChange = (details: FilterDropdownValueChangeDetails) => setTypes(details.value);\n const handleStatusChange = (details: FilterDropdownValueChangeDetails) =>\n setStatus(details.value);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n\n const handleReset = () => {\n setQuery('');\n setTypes([]);\n setStatus([]);\n setScope([]);\n };\n\n return (\n <Page name='attacks' fullSize fixedHeight data-testid='attacks-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Attacks</PageTitle>\n <PageDescription>\n Malicious requests the filtering nodes detected in the last 7 days.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageToolbar>\n <div className='flex items-center gap-8'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search request or IP'\n className='w-[320px]'\n data-testid='attacks-search'\n />\n <FilterDropdown\n label='Type'\n multiple\n collection={typeOptions}\n value={types}\n onValueChange={handleTypesChange}\n data-testid='attacks-type-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n {/* Under 8 options the menu has no search, so the options are listed directly.\n From 8, render them from `useFilterDropdown().groups` in a child component. */}\n {typeItems.map(item => (\n <FilterDropdownOption key={item.value} item={item} hint={item.count} />\n ))}\n <FilterDropdownFooter />\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterDropdown\n label='Status'\n allLabel='All statuses'\n collection={statusOptions}\n value={status}\n onValueChange={handleStatusChange}\n data-testid='attacks-status-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n <FilterDropdownAllOption />\n {statusOptions.items.map(item => (\n <FilterDropdownOption key={item.value} item={item} />\n ))}\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterCascade\n label='Scope'\n collection={scopeOptions}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='attacks-scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent />\n </FilterCascade>\n </div>\n <SegmentedControl\n value={view}\n onChange={setView}\n className='ml-auto'\n data-testid='attacks-view-toggle'\n >\n <SegmentedControlItem value='table'>\n <List />\n <span className='sr-only'>Table view</span>\n </SegmentedControlItem>\n <SegmentedControlItem value='schema'>\n <Workflow />\n <span className='sr-only'>Schema view</span>\n </SegmentedControlItem>\n </SegmentedControl>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Attacks'\n data={rows}\n columns={columns}\n getRowId={getAttackId}\n data-testid='attacks-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='attacks-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n No attacks match your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};\n```\n\nFrom eight options a filter menu gets a search, and its options follow the query.\n\nOptions are grouped by deployment and carry attack counts. On reopen, picked options are\npinned under «Selected»; a query with no match shows an empty message and keeps the menu open.\n\n```tsx story=SearchableFilter\nimport { useState } from 'react';\nimport { FilterDropdown, FilterDropdownTrigger, type FilterDropdownValueChangeDetails } from '@wallarm-org/design-system/FilterDropdown';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\n\nexport const SearchableFilter = () => {\n const [applications, setApplications] = useState<string[]>([]);\n const applicationOptions = createListCollection({\n items: APPLICATIONS,\n groupBy: item => item.deployment,\n });\n\n const handleApplicationsChange = (details: FilterDropdownValueChangeDetails) =>\n setApplications(details.value);\n\n return (\n <div className='p-24'>\n <FilterDropdown\n label='Application'\n multiple\n collection={applicationOptions}\n value={applications}\n onValueChange={handleApplicationsChange}\n data-testid='applications-filter'\n >\n <FilterDropdownTrigger />\n <ApplicationMenu />\n </FilterDropdown>\n </div>\n );\n};\n```\n\nA scope chip whose applications load on demand and whose deployments can be picked.\n\nEach option shows its policy count, \"No policies\" for zero. ✕ on the trigger returns to every\nscope, not one level up.\n\n```tsx story=ScopeFilter\nimport { useState } from 'react';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeItems, type FilterCascadeLevelData, FilterCascadeLevels, FilterCascadeParentItem, FilterCascadeSearch, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\n\nexport const ScopeFilter = () => {\n const [scope, setScope] = useState<string[]>([]);\n const scopes = createFilterCascadeCollection(DEPLOYMENTS, {\n getValue: node => node.id,\n getLabel: node => node.name,\n hasChildren: node => node.kind === 'deployment',\n getIcon: node => (\n <span\n aria-hidden\n className={\n node.kind === 'deployment'\n ? 'size-6 rounded-full bg-badge-sky-strong'\n : 'size-6 rounded-full bg-badge-teal-strong'\n }\n />\n ),\n getDescription: node =>\n node.policies === 0\n ? 'No policies'\n : `${node.policies} ${node.policies === 1 ? 'policy' : 'policies'}`,\n });\n\n const loadApplications = (deployment: ScopeNode) => fetchApplications(deployment.id);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n const renderLevel = (level: FilterCascadeLevelData) =>\n level.depth === 0 ? (\n <FilterCascadeItems />\n ) : (\n <>\n <FilterCascadeParentItem />\n <FilterCascadeItems />\n </>\n );\n\n return (\n <div className='p-24'>\n <FilterCascade\n label='Scope'\n collection={scopes}\n loadChildren={loadApplications}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent>\n <FilterCascadeSearch placeholder='Search' />\n <FilterCascadeLevels>{renderLevel}</FilterCascadeLevels>\n </FilterCascadeContent>\n </FilterCascade>\n </div>\n );\n};\n```\n\nA searchable select in a form, with the menu matching the trigger.\n\nA settings field saves on pick, with no Save button. A query with no match shows\n`SelectEmptyState` and keeps the menu open.\n\n```tsx story=SearchableSelect\nimport { useState } from 'react';\nimport { Field, FieldDescription, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { createListCollection, Select, SelectContent, SelectEmptyState, SelectHeader, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner, SelectSearchInput, useSelectSearch } from '@wallarm-org/design-system/Select';\n\nexport const SearchableSelect = () => {\n const [timeZone, setTimeZone] = useState('UTC');\n const timeZones = createListCollection({ items: TIME_ZONES });\n const { searchValue, onSearchChange, filteredCollection } = useSelectSearch(timeZones);\n\n const handleTimeZoneChange = (details: { value: string[] }) => {\n const next = details.value[0] ?? timeZone;\n setTimeZone(next);\n onTimeZoneSaved(next);\n };\n\n return (\n <div className='w-[400px] p-24'>\n <Field>\n <FieldLabel>Time zone</FieldLabel>\n <Select\n collection={timeZones}\n value={[timeZone]}\n positioning={{ sameWidth: true }}\n onValueChange={handleTimeZoneChange}\n data-testid='timezone-select'\n >\n <SelectInput placeholder='Choose a time zone' data-testid='timezone-input' />\n <SelectPositioner className='min-w-full max-w-full'>\n <SelectHeader>\n <SelectSearchInput value={searchValue} onChange={onSearchChange} />\n </SelectHeader>\n <SelectContent>\n {filteredCollection.size === 0 ? (\n <SelectEmptyState description={`No results for \"${searchValue}\"`} />\n ) : (\n filteredCollection.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))\n )}\n </SelectContent>\n </SelectPositioner>\n </Select>\n <FieldDescription>\n Dates in reports and on the Attacks page use this time zone.\n </FieldDescription>\n </Field>\n </div>\n );\n};\n```\n\nA picker dialog whose primary counts the selection: disabled \"Add users\", then \"Add 2 users\".\n\nSearch and status tabs sit over a selectable `Table`. Picked rows stay picked while the search\nchanges, because rows are keyed by `getRowId`.\n\n```tsx story=PickerDialog\nimport { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogFooterControls, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, Table } from '@wallarm-org/design-system/Table';\n\nexport const PickerDialog = () => {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const renderMember = ({ row }: TableCellContext<Member, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<Member, unknown>) =>\n row.original.status === 'active' ? 'Active' : 'Invited';\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'User', size: 520, cell: renderMember },\n { accessorKey: 'status', header: 'Status', size: 200, cell: renderStatus },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = MEMBERS.filter(\n member =>\n member.name.toLowerCase().includes(search) || member.email.toLowerCase().includes(search),\n );\n const activeCount = matching.filter(member => member.status === 'active').length;\n const invitedCount = matching.length - activeCount;\n const rows = status === 'all' ? matching : matching.filter(member => member.status === status);\n const count = Object.keys(selection).length;\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setQuery('');\n setStatus('all');\n setSelection({});\n }\n };\n const handleOpen = () => setOpen(true);\n const handleAdd = () => {\n onAddUsers(Object.keys(selection));\n handleOpenChange(false);\n };\n\n return (\n <div className='p-24'>\n <Button size='medium' onClick={handleOpen} data-testid='members-add'>\n <UserPlus />\n Add users\n </Button>\n <Dialog open={open} onOpenChange={handleOpenChange} width={960} data-testid='members-picker'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Add users to Security team</DialogTitle>\n </DialogHeader>\n <DialogBody>\n <div className='flex flex-col gap-8'>\n <div className='flex items-center gap-12'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='flex-1'\n data-testid='members-search'\n />\n <SegmentedTabs\n value={status}\n onChange={setStatus}\n data-testid='members-status-tabs'\n >\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{matching.length}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{activeCount}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{invitedCount}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </div>\n <Table\n aria-label='Users to add'\n data={rows}\n columns={columns}\n getRowId={getMemberId}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='members-table'\n >\n {rows.length === 0 && (\n <EmptyState type='collection-empty' data-testid='members-no-results'>\n <EmptyStateMessage>\n <EmptyStateTitle>No users found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </DialogBody>\n <DialogFooter>\n <DialogFooterControls>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' size='large'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n size='large'\n disabled={count === 0}\n onClick={handleAdd}\n data-testid='members-confirm'\n >\n {count === 0 ? 'Add users' : `Add ${count} ${count === 1 ? 'user' : 'users'}`}\n </Button>\n </DialogFooterControls>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </div>\n );\n};\n```"
1538
+ },
1539
+ {
1540
+ "id": "states",
1541
+ "heading": "States",
1542
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Unset | Single: `allLabel`; multi: `label` | `FilterDropdownTrigger` |\n| One value / several | \"Type • Lua\" / \"Type • 2\", built in; never custom text or a tag per value | `FilterDropdownTrigger` |\n| Reset one filter | Multi: ✕ or Backspace; single: «All». No per-filter Reset button | `FilterDropdown` |\n| Reset scope | ✕ returns to the page default, not one level up | `FilterCascadeTrigger` |\n| 8+ options | Search appears (`searchThreshold`) | `FilterDropdownSearch`, `SelectSearchInput` |\n| Menu search, no match | Empty message; menu stays open | `FilterDropdownEmpty`, `SelectEmptyState` |\n| Cascade level loading or failed | Built-in loading rows, then Retry | `FilterCascade` |\n| Filters match no rows | `type='collection-empty'` + outline neutral \"Reset filters\"; never a create CTA | `TableEmptyState` |\n| Default value | Pinned first in a \"Suggested\" group | `SelectGroup` |\n| Select on settings page | Saves on pick, toast; no Save button | `Select`, `Toast` |\n| Picker running | Primary `loading`, dialog open; on success close and toast \"3 users added\" | `Button`, `Dialog` |\n| Picker, no match | `EmptyState type='collection-empty'` as a direct `Table` child; `TableEmptyState` is too tall | `EmptyState` |\n| Picker, already a member | Leave the row out of `data` | `Table` |"
1543
+ },
1544
+ {
1545
+ "id": "what-your-app-owns",
1546
+ "heading": "What your app owns",
1547
+ "markdown": "| You own | The DS owns |\n| --- | --- |\n| Applying `details.value` to your data | Filter values and trigger text |\n| URL sync (no router integration) | Nothing |\n| \"Reset all\": every filter `[]`, search `''` | Per-filter clear |\n| Option counts (`hint`, `getDescription`); a pinned option goes first in its own `category` | Rendering them |\n| Scope dots: `size-6 rounded-full` span, `bg-badge-violet-strong` / `-sky-strong` / `-teal-strong`, via `getIcon` | The icon slot |\n| Server search: debounce `query`, refetch `data` | Client filtering only |\n| Picker selection across pages: keep `rowSelection` keys (row ids) when `data` changes | Checkbox column |\n| Picker user cell (name, muted email): see [Entity table](./entity-table.md) | Nothing |"
1548
+ },
1549
+ {
1550
+ "id": "related",
1551
+ "heading": "Related",
1552
+ "markdown": "- [Resource list page](./resource-list-page.md), [Entity table](./entity-table.md), [Empty states](./empty-states.md)\n- [Feedback toasts](./feedback-toasts.md), [Settings page](./settings-page.md), [Create and edit forms](./create-edit-forms.md)\n- [Spacing](../foundations/spacing.md)"
1553
+ }
1554
+ ],
1555
+ "stories": [
1556
+ {
1557
+ "name": "Default",
1558
+ "code": "import { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateIllustration, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\nimport { FilterDropdown, FilterDropdownAllOption, FilterDropdownContent, FilterDropdownFooter, FilterDropdownOption, FilterDropdownTrigger, type FilterDropdownValueChangeDetails, useFilterDropdown } from '@wallarm-org/design-system/FilterDropdown';\nimport { FilterX, List, SearchX, Workflow } from '@wallarm-org/design-system/icons';\nimport { Page, PageContent, PageDescription, PageHeader, PageHeaderGroup, PageTitle, PageToolbar } from '@wallarm-org/design-system/Page';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedControl, SegmentedControlItem } from '@wallarm-org/design-system/SegmentedControl';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\nimport { type TableCellContext, type TableColumnDef, Table, TableEmptyState } from '@wallarm-org/design-system/Table';\n\nexport const Default = () => {\n const [query, setQuery] = useState('');\n const [types, setTypes] = useState<string[]>([]);\n const [status, setStatus] = useState<string[]>([]);\n const [scope, setScope] = useState<string[]>([]);\n const [view, setView] = useState('table');\n\n const TYPE_LABELS: Record<AttackType, string> = {\n sqli: 'SQL injection',\n xss: 'XSS',\n rce: 'RCE',\n ptrav: 'Path traversal',\n ssrf: 'SSRF',\n };\n const typeItems = Object.entries(TYPE_LABELS).map(([value, label]) => ({\n value,\n label,\n count: ATTACKS.filter(attack => attack.type === value).length,\n }));\n const typeOptions = createListCollection({ items: typeItems });\n const statusOptions = createListCollection({\n items: [\n { value: 'blocked', label: 'Blocked' },\n { value: 'monitored', label: 'Monitored' },\n ],\n });\n const scopeOptions = createFilterCascadeCollection(SCOPES);\n\n const renderType = ({ row }: TableCellContext<Attack, unknown>) => TYPE_LABELS[row.original.type];\n\n const renderStatus = ({ row }: TableCellContext<Attack, unknown>) =>\n row.original.status === 'blocked' ? (\n <Badge variant='dotted' type='secondary' color='red'>\n Blocked\n </Badge>\n ) : (\n <Badge variant='dotted' type='secondary' color='yellow'>\n Monitored\n </Badge>\n );\n\n const columns: TableColumnDef<Attack>[] = [\n { accessorKey: 'type', header: 'Type', size: 160, cell: renderType },\n { accessorKey: 'path', header: 'Request', size: 280 },\n { accessorKey: 'source', header: 'Source IP', size: 160 },\n { accessorKey: 'status', header: 'Status', size: 140, cell: renderStatus },\n { accessorKey: 'hits', header: 'Hits', size: 100 },\n ];\n\n const search = query.trim().toLowerCase();\n const rows = ATTACKS.filter(\n attack =>\n (attack.path.toLowerCase().includes(search) || attack.source.includes(search)) &&\n (types.length === 0 || types.includes(attack.type)) &&\n (status.length === 0 || status.includes(attack.status)) &&\n (scope.length === 0 || attack.deployment === scope[0]) &&\n (scope.length < 2 || attack.application === scope[1]),\n );\n\n const handleTypesChange = (details: FilterDropdownValueChangeDetails) => setTypes(details.value);\n const handleStatusChange = (details: FilterDropdownValueChangeDetails) =>\n setStatus(details.value);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n\n const handleReset = () => {\n setQuery('');\n setTypes([]);\n setStatus([]);\n setScope([]);\n };\n\n return (\n <Page name='attacks' fullSize fixedHeight data-testid='attacks-page'>\n <PageHeader>\n <PageHeaderGroup className='flex-1'>\n <PageTitle>Attacks</PageTitle>\n <PageDescription>\n Malicious requests the filtering nodes detected in the last 7 days.\n </PageDescription>\n </PageHeaderGroup>\n </PageHeader>\n\n <PageToolbar>\n <div className='flex items-center gap-8'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search request or IP'\n className='w-[320px]'\n data-testid='attacks-search'\n />\n <FilterDropdown\n label='Type'\n multiple\n collection={typeOptions}\n value={types}\n onValueChange={handleTypesChange}\n data-testid='attacks-type-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n {/* Under 8 options the menu has no search, so the options are listed directly.\n From 8, render them from `useFilterDropdown().groups` in a child component. */}\n {typeItems.map(item => (\n <FilterDropdownOption key={item.value} item={item} hint={item.count} />\n ))}\n <FilterDropdownFooter />\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterDropdown\n label='Status'\n allLabel='All statuses'\n collection={statusOptions}\n value={status}\n onValueChange={handleStatusChange}\n data-testid='attacks-status-filter'\n >\n <FilterDropdownTrigger />\n <FilterDropdownContent>\n <FilterDropdownAllOption />\n {statusOptions.items.map(item => (\n <FilterDropdownOption key={item.value} item={item} />\n ))}\n </FilterDropdownContent>\n </FilterDropdown>\n <FilterCascade\n label='Scope'\n collection={scopeOptions}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='attacks-scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent />\n </FilterCascade>\n </div>\n <SegmentedControl\n value={view}\n onChange={setView}\n className='ml-auto'\n data-testid='attacks-view-toggle'\n >\n <SegmentedControlItem value='table'>\n <List />\n <span className='sr-only'>Table view</span>\n </SegmentedControlItem>\n <SegmentedControlItem value='schema'>\n <Workflow />\n <span className='sr-only'>Schema view</span>\n </SegmentedControlItem>\n </SegmentedControl>\n </PageToolbar>\n\n <PageContent>\n <Table\n aria-label='Attacks'\n data={rows}\n columns={columns}\n getRowId={getAttackId}\n data-testid='attacks-table'\n >\n <TableEmptyState>\n <EmptyState type='collection-empty' data-testid='attacks-no-results'>\n <EmptyStateIllustration>\n <SearchX />\n </EmptyStateIllustration>\n <EmptyStateMessage>\n <EmptyStateTitle>No results found</EmptyStateTitle>\n <EmptyStateDescription>\n No attacks match your search or filters.\n </EmptyStateDescription>\n </EmptyStateMessage>\n <EmptyStateActions>\n <Button size='medium' variant='outline' color='neutral' onClick={handleReset}>\n <FilterX />\n Reset filters\n </Button>\n </EmptyStateActions>\n </EmptyState>\n </TableEmptyState>\n </Table>\n </PageContent>\n </Page>\n );\n};",
1559
+ "description": "Every pick applies at once. When the filters match nothing, the table offers \"Reset filters\",\nwhich clears the search and every filter."
1560
+ },
1561
+ {
1562
+ "name": "SearchableFilter",
1563
+ "code": "import { useState } from 'react';\nimport { FilterDropdown, FilterDropdownTrigger, type FilterDropdownValueChangeDetails } from '@wallarm-org/design-system/FilterDropdown';\nimport { createListCollection } from '@wallarm-org/design-system/Select';\n\nexport const SearchableFilter = () => {\n const [applications, setApplications] = useState<string[]>([]);\n const applicationOptions = createListCollection({\n items: APPLICATIONS,\n groupBy: item => item.deployment,\n });\n\n const handleApplicationsChange = (details: FilterDropdownValueChangeDetails) =>\n setApplications(details.value);\n\n return (\n <div className='p-24'>\n <FilterDropdown\n label='Application'\n multiple\n collection={applicationOptions}\n value={applications}\n onValueChange={handleApplicationsChange}\n data-testid='applications-filter'\n >\n <FilterDropdownTrigger />\n <ApplicationMenu />\n </FilterDropdown>\n </div>\n );\n};",
1564
+ "description": "Options are grouped by deployment and carry attack counts. On reopen, picked options are\npinned under «Selected»; a query with no match shows an empty message and keeps the menu open."
1565
+ },
1566
+ {
1567
+ "name": "ScopeFilter",
1568
+ "code": "import { useState } from 'react';\nimport { createFilterCascadeCollection, FilterCascade, FilterCascadeContent, FilterCascadeItems, type FilterCascadeLevelData, FilterCascadeLevels, FilterCascadeParentItem, FilterCascadeSearch, FilterCascadeTrigger, type FilterCascadeValueChangeDetails } from '@wallarm-org/design-system/FilterCascade';\n\nexport const ScopeFilter = () => {\n const [scope, setScope] = useState<string[]>([]);\n const scopes = createFilterCascadeCollection(DEPLOYMENTS, {\n getValue: node => node.id,\n getLabel: node => node.name,\n hasChildren: node => node.kind === 'deployment',\n getIcon: node => (\n <span\n aria-hidden\n className={\n node.kind === 'deployment'\n ? 'size-6 rounded-full bg-badge-sky-strong'\n : 'size-6 rounded-full bg-badge-teal-strong'\n }\n />\n ),\n getDescription: node =>\n node.policies === 0\n ? 'No policies'\n : `${node.policies} ${node.policies === 1 ? 'policy' : 'policies'}`,\n });\n\n const loadApplications = (deployment: ScopeNode) => fetchApplications(deployment.id);\n const handleScopeChange = (details: FilterCascadeValueChangeDetails) => setScope(details.value);\n const renderLevel = (level: FilterCascadeLevelData) =>\n level.depth === 0 ? (\n <FilterCascadeItems />\n ) : (\n <>\n <FilterCascadeParentItem />\n <FilterCascadeItems />\n </>\n );\n\n return (\n <div className='p-24'>\n <FilterCascade\n label='Scope'\n collection={scopes}\n loadChildren={loadApplications}\n parentLabel='Deployment level'\n value={scope}\n onValueChange={handleScopeChange}\n data-testid='scope-filter'\n >\n <FilterCascadeTrigger />\n <FilterCascadeContent>\n <FilterCascadeSearch placeholder='Search' />\n <FilterCascadeLevels>{renderLevel}</FilterCascadeLevels>\n </FilterCascadeContent>\n </FilterCascade>\n </div>\n );\n};",
1569
+ "description": "Each option shows its policy count, \"No policies\" for zero. ✕ on the trigger returns to every\nscope, not one level up."
1570
+ },
1571
+ {
1572
+ "name": "SearchableSelect",
1573
+ "code": "import { useState } from 'react';\nimport { Field, FieldDescription, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { createListCollection, Select, SelectContent, SelectEmptyState, SelectHeader, SelectInput, SelectOption, SelectOptionIndicator, SelectOptionText, SelectPositioner, SelectSearchInput, useSelectSearch } from '@wallarm-org/design-system/Select';\n\nexport const SearchableSelect = () => {\n const [timeZone, setTimeZone] = useState('UTC');\n const timeZones = createListCollection({ items: TIME_ZONES });\n const { searchValue, onSearchChange, filteredCollection } = useSelectSearch(timeZones);\n\n const handleTimeZoneChange = (details: { value: string[] }) => {\n const next = details.value[0] ?? timeZone;\n setTimeZone(next);\n onTimeZoneSaved(next);\n };\n\n return (\n <div className='w-[400px] p-24'>\n <Field>\n <FieldLabel>Time zone</FieldLabel>\n <Select\n collection={timeZones}\n value={[timeZone]}\n positioning={{ sameWidth: true }}\n onValueChange={handleTimeZoneChange}\n data-testid='timezone-select'\n >\n <SelectInput placeholder='Choose a time zone' data-testid='timezone-input' />\n <SelectPositioner className='min-w-full max-w-full'>\n <SelectHeader>\n <SelectSearchInput value={searchValue} onChange={onSearchChange} />\n </SelectHeader>\n <SelectContent>\n {filteredCollection.size === 0 ? (\n <SelectEmptyState description={`No results for \"${searchValue}\"`} />\n ) : (\n filteredCollection.items.map(item => (\n <SelectOption key={item.value} item={item}>\n <SelectOptionText>{item.label}</SelectOptionText>\n <SelectOptionIndicator />\n </SelectOption>\n ))\n )}\n </SelectContent>\n </SelectPositioner>\n </Select>\n <FieldDescription>\n Dates in reports and on the Attacks page use this time zone.\n </FieldDescription>\n </Field>\n </div>\n );\n};",
1574
+ "description": "A settings field saves on pick, with no Save button. A query with no match shows\n`SelectEmptyState` and keeps the menu open."
1575
+ },
1576
+ {
1577
+ "name": "PickerDialog",
1578
+ "code": "import { useState } from 'react';\nimport { Avatar, AvatarContent, AvatarDescription, AvatarFallback, AvatarName } from '@wallarm-org/design-system/Avatar';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogFooterControls, DialogHeader, DialogTitle } from '@wallarm-org/design-system/Dialog';\nimport { EmptyState, EmptyStateDescription, EmptyStateMessage, EmptyStateTitle } from '@wallarm-org/design-system/EmptyState';\nimport { UserPlus } from '@wallarm-org/design-system/icons';\nimport { NumericBadge } from '@wallarm-org/design-system/NumericBadge';\nimport { SearchInput } from '@wallarm-org/design-system/SearchInput';\nimport { SegmentedTabs, SegmentedTabsList, SegmentedTabsTrigger } from '@wallarm-org/design-system/SegmentedTabs';\nimport { type TableCellContext, type TableColumnDef, type TableRowSelectionState, Table } from '@wallarm-org/design-system/Table';\n\nexport const PickerDialog = () => {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState('');\n const [status, setStatus] = useState('all');\n const [selection, setSelection] = useState<TableRowSelectionState>({});\n\n const renderMember = ({ row }: TableCellContext<Member, unknown>) => (\n <Avatar size='md'>\n <AvatarFallback name={row.original.name} />\n <AvatarContent>\n <AvatarName>{row.original.name}</AvatarName>\n <AvatarDescription>{row.original.email}</AvatarDescription>\n </AvatarContent>\n </Avatar>\n );\n\n const renderStatus = ({ row }: TableCellContext<Member, unknown>) =>\n row.original.status === 'active' ? 'Active' : 'Invited';\n\n const columns: TableColumnDef<Member>[] = [\n { accessorKey: 'name', header: 'User', size: 520, cell: renderMember },\n { accessorKey: 'status', header: 'Status', size: 200, cell: renderStatus },\n ];\n\n const search = query.trim().toLowerCase();\n const matching = MEMBERS.filter(\n member =>\n member.name.toLowerCase().includes(search) || member.email.toLowerCase().includes(search),\n );\n const activeCount = matching.filter(member => member.status === 'active').length;\n const invitedCount = matching.length - activeCount;\n const rows = status === 'all' ? matching : matching.filter(member => member.status === status);\n const count = Object.keys(selection).length;\n\n const handleOpenChange = (next: boolean) => {\n setOpen(next);\n if (!next) {\n setQuery('');\n setStatus('all');\n setSelection({});\n }\n };\n const handleOpen = () => setOpen(true);\n const handleAdd = () => {\n onAddUsers(Object.keys(selection));\n handleOpenChange(false);\n };\n\n return (\n <div className='p-24'>\n <Button size='medium' onClick={handleOpen} data-testid='members-add'>\n <UserPlus />\n Add users\n </Button>\n <Dialog open={open} onOpenChange={handleOpenChange} width={960} data-testid='members-picker'>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>Add users to Security team</DialogTitle>\n </DialogHeader>\n <DialogBody>\n <div className='flex flex-col gap-8'>\n <div className='flex items-center gap-12'>\n <SearchInput\n value={query}\n onChange={setQuery}\n placeholder='Search name or email'\n className='flex-1'\n data-testid='members-search'\n />\n <SegmentedTabs\n value={status}\n onChange={setStatus}\n data-testid='members-status-tabs'\n >\n <SegmentedTabsList>\n <SegmentedTabsTrigger value='all'>\n All <NumericBadge>{matching.length}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='active'>\n Active <NumericBadge>{activeCount}</NumericBadge>\n </SegmentedTabsTrigger>\n <SegmentedTabsTrigger value='invited'>\n Invited <NumericBadge>{invitedCount}</NumericBadge>\n </SegmentedTabsTrigger>\n </SegmentedTabsList>\n </SegmentedTabs>\n </div>\n <Table\n aria-label='Users to add'\n data={rows}\n columns={columns}\n getRowId={getMemberId}\n rowSelection={selection}\n onRowSelectionChange={setSelection}\n data-testid='members-table'\n >\n {rows.length === 0 && (\n <EmptyState type='collection-empty' data-testid='members-no-results'>\n <EmptyStateMessage>\n <EmptyStateTitle>No users found</EmptyStateTitle>\n <EmptyStateDescription>No users match your search.</EmptyStateDescription>\n </EmptyStateMessage>\n </EmptyState>\n )}\n </Table>\n </div>\n </DialogBody>\n <DialogFooter>\n <DialogFooterControls>\n <DialogClose asChild>\n <Button variant='ghost' color='neutral' size='large'>\n Cancel\n </Button>\n </DialogClose>\n <Button\n size='large'\n disabled={count === 0}\n onClick={handleAdd}\n data-testid='members-confirm'\n >\n {count === 0 ? 'Add users' : `Add ${count} ${count === 1 ? 'user' : 'users'}`}\n </Button>\n </DialogFooterControls>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </div>\n );\n};",
1579
+ "description": "Search and status tabs sit over a selectable `Table`. Picked rows stay picked while the search\nchanges, because rows are keyed by `getRowId`."
1580
+ }
1581
+ ]
1582
+ },
1583
+ {
1584
+ "slug": "patterns/auth-page",
1585
+ "kind": "pattern",
1586
+ "title": "Auth page",
1587
+ "description": "Use when building a sign-in, organization select, SSO choice or password reset screen that shows one centered card on the animated background.",
1588
+ "components": [
1589
+ "AnimatedBackground",
1590
+ "Logo",
1591
+ "Heading",
1592
+ "Text",
1593
+ "VStack",
1594
+ "HStack",
1595
+ "Field",
1596
+ "FieldLabel",
1597
+ "FieldError",
1598
+ "FieldGroup",
1599
+ "Input",
1600
+ "PasswordInput",
1601
+ "OTPInput",
1602
+ "PasswordComplexity",
1603
+ "Button",
1604
+ "Badge",
1605
+ "Separator",
1606
+ "RadioGroup",
1607
+ "Radio",
1608
+ "RadioIndicator",
1609
+ "RadioLabel",
1610
+ "Tag",
1611
+ "Link",
1612
+ "UtilityPage",
1613
+ "Card",
1614
+ "Toaster"
1615
+ ],
1616
+ "keywords": [
1617
+ "auth",
1618
+ "login",
1619
+ "log in",
1620
+ "sign in",
1621
+ "sign-in",
1622
+ "signin",
1623
+ "sign up",
1624
+ "password",
1625
+ "forgot password",
1626
+ "reset password",
1627
+ "OTP",
1628
+ "one-time code",
1629
+ "verification code",
1630
+ "SSO",
1631
+ "single sign-on",
1632
+ "continue with",
1633
+ "OR separator",
1634
+ "last used",
1635
+ "organization select",
1636
+ "tenant select",
1637
+ "login card",
1638
+ "AuthCard",
1639
+ "AuthPage",
1640
+ "LoginForm"
1641
+ ],
1642
+ "order": 100,
1643
+ "markdown": "# Auth page\n\nAn auth page is one 480px card, centered on `AnimatedBackground`, that holds one step of the sign-in flow.\n\n## When to use\n\n| Need | Use | Not |\n| --- | --- | --- |\n| Sign in with email and password | Auth card: email step, then password step | A `Dialog` over the app |\n| Pick one of several organizations | Auth card with `RadioGroup variant='card'` | `Select` inside the card |\n| Enter a reset code and a new password | Auth card with `OTPInput`, `PasswordInput`, `PasswordComplexity` | Six separate `Input`s |\n| Show a 404, 403, 500 or offline screen | `UtilityPage` | The auth card |\n| Show that the app loads after sign-in | `SplashScreen` | A spinner inside the auth card |\n\nThere is no `AuthCard`, `AuthPage`, `AuthLayout` or `LoginForm` component; copy the first example into your app.\n\n## Rules\n\n### Layout and spacing\n\n- **Never** render the card inside `Page` or `AppShell`.\n- `AnimatedBackground` centers the card. **Never** center it yourself with `fixed`, `absolute` or `translate`.\n- Card: `w-[480px] rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80 flex flex-col gap-32`.\n- **Never** add a shadow. **Never** use `Card`: it owns 16px padding.\n- **Never** use `UtilityPage` for an auth step: its title is styled as an error code. **Never** copy its `w-[482px]`.\n- **Never** copy the `AppShell` `LoginFlow` story; it has a shadow.\n- Logo: `Logo type='full' size='md' className='mb-32 self-start'` (64px to the title).\n- Title block: `VStack gap={0} align='start'`, `Heading size='2xl' weight='medium'` over `Text size='sm' color='secondary'`.\n- Form: `VStack gap={16}` or `FieldGroup`. **Never** wrap `Field` to add margins.\n- SSO and \"Back\" buttons: `variant='secondary' color='neutral' fullWidth`, stacked with `VStack gap={8}`. OR row: `HStack gap={12}` with two `Separator className='flex-1'` around a `span className='text-xs text-text-tertiary'`.\n- **Never** pass `size` to `Button`, `Input` or `PasswordInput` in the card. The 36px defaults are correct.\n- **Never** pass `className` to `Field`, `Heading` or `Text`. It is not applied.\n- If the card outgrows the viewport, add `max-h-[calc(100dvh-64px)] overflow-y-auto`.\n- Background: `bg-component-app-shell-bg`. There is no `bg-card`, `bg-primary` or `bg-surface`.\n\n### Flow\n\n- One card asks one thing. If a step needs two decisions, split it into two cards.\n- If the email domain uses SSO, redirect after the email step. **Never** show an empty password step.\n- If there are 6 or more organizations, show 5 and a ghost \"Show more\" `Button` under the group.\n\n### Form behavior\n\n- Use one `<form onSubmit>` per card; the primary `Button` is `type='submit' fullWidth`.\n- Give every other `Button` in the form `type='button'`; without it, the button submits.\n- **Never** put a bare `FieldAction` in a `<form>`: it submits the form. Use `FieldAction asChild` with an `<a href>`.\n- `autoComplete`: `email`, `current-password` (sign-in), `new-password` (reset).\n- Pass `otp` and `groupSize={3}` to `OTPInput`. The default groups 2 + 2 + 2.\n- **Never** add `disabled={pending}` next to `loading`; use `disabled` only for an incomplete form. Keep fields enabled while pending.\n- If sign-in fails, show `FieldError` under the password and keep both values. **Never** use an `Alert`, `Banner` or toast.\n- If the code is wrong, pass `error` to `OTPInput` and add a `FieldError` in the same `Field`.\n- Show the \"LAST\" badge on at most one button.\n- For the resend countdown, use `Link disabled`. `FieldAction` takes no `disabled`.\n- Import `FieldAction` and `FieldIndicator` from `@wallarm-org/design-system/Field`, `useToast` from `@wallarm-org/design-system/Toast`, icons from `@wallarm-org/design-system/icons`.\n\n### Copy\n\n| Part | Write | Not |\n| --- | --- | --- |\n| Sign-in title / subtitle | \"Welcome back!\" / \"Sign in to your account\" | \"Login\" |\n| Other titles | \"Select organization\", \"Reset password\" | \"Choose org\" |\n| Credentials error | \"Email or password is incorrect.\" | \"Wrong password\", \"User not found\" |\n| Submit | \"Sign in\" on the password step, \"Continue\" elsewhere | \"Log in\", \"Submit\", \"Next\" |\n| SSO button | \"Continue with {provider}\" | \"Sign in with SSO\" |\n| Separator | \"OR\" | \"or continue with\" |\n| Organization subtitle | \"Choose which organization to sign in to.\" | \"Select one\" |\n| Reset subtitle | \"We've sent a code to {email}\" | \"Check your inbox\" |\n| Resend | \"Resend in {n}s\", then \"Resend code\" | \"Didn't get it?\" |\n| Reset toast | \"Password reset\" | \"Your password was reset successfully!\" |\n| Placeholders | \"you@your-company.com\", \"Enter\" | \"Email\", \"Password\" |\n\n- **Never** say which credential is wrong. Use sentence case.\n\n## Examples\n\nThe sign-in flow in the card shell every step renders inside.\n\nThe sign-in flow: an email step, then a password step with \"Forgot password?\", the OR row and\nSSO buttons, one of them marked \"LAST\". There is no backend, so \"Sign in\" always shows the\nwrong-credentials state; an SSO button shows its spinner on that button only.\n\n```tsx story=Default\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Default = () => {\n const [step, setStep] = useState<'email' | 'password'>('email');\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [failed, setFailed] = useState(false);\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleEmailSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n setStep('password');\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email, password });\n setFailed(true);\n };\n const handleBack = () => {\n setStep('email');\n setPassword('');\n setFailed(false);\n };\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='sign-in-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Welcome back!\n </Heading>\n <Text size='sm' color='secondary'>\n Sign in to your account\n </Text>\n </VStack>\n\n {step === 'email' && (\n <form onSubmit={handleEmailSubmit}>\n <VStack gap={16}>\n <Field data-testid='sign-in-email' required>\n <FieldLabel>Email</FieldLabel>\n <Input\n type='email'\n autoComplete='email'\n placeholder='you@your-company.com'\n value={email}\n onChange={handleEmailChange}\n />\n </Field>\n <Button data-testid='sign-in-continue' type='submit' fullWidth disabled={!email}>\n Continue\n </Button>\n </VStack>\n </form>\n )}\n\n {step === 'password' && (\n <>\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='sign-in-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <VStack gap={8}>\n <Button data-testid='sign-in-submit' type='submit' fullWidth disabled={!password}>\n Sign in\n </Button>\n <Button\n data-testid='sign-in-back'\n type='button'\n variant='secondary'\n color='neutral'\n fullWidth\n onClick={handleBack}\n >\n Back\n </Button>\n </VStack>\n </VStack>\n </form>\n\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n\n <VStack gap={8} data-testid='sign-in-sso'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </>\n )}\n </div>\n </AnimatedBackground>\n );\n};\n```\n\nA password field with \"Forgot password?\" as a link and the failed state.\n\nThe failed password step: the error names neither credential, the password stays in the field,\nand editing it clears the error.\n\n```tsx story=WrongCredentials\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const WrongCredentials = () => {\n const [password, setPassword] = useState('Winter2026');\n const [failed, setFailed] = useState(true);\n\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email: 'maria.lopez@acme-bank.com', password });\n setFailed(true);\n };\n\n return (\n <div\n data-testid='wrong-credentials-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='wrong-credentials-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <Button\n data-testid='wrong-credentials-submit'\n type='submit'\n fullWidth\n disabled={!password}\n >\n Sign in\n </Button>\n </VStack>\n </form>\n </div>\n );\n};\n```\n\nThe OR row and SSO buttons with the \"LAST\" badge.\n\nA clicked SSO button shows its spinner; the other buttons stay as they are.\n\n```tsx story=SsoButtons\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\n\nexport const SsoButtons = () => {\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <div\n data-testid='sso-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n <VStack gap={8} data-testid='sso-buttons'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </div>\n );\n};\n```\n\nOrganization select, collapsed to 5 rows with \"Show more\".\n\nOrganization select for a user in 7 organizations: card radios collapsed to 5 rows, with a\nghost \"Show more\" button under the group.\n\n```tsx story=SelectOrganization\nimport { type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { Radio, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SelectOrganization = () => {\n const [value, setValue] = useState<string | null>(null);\n const [expanded, setExpanded] = useState(false);\n\n const visible = expanded ? ORGANIZATIONS : ORGANIZATIONS.slice(0, 5);\n\n const handleValueChange = (details: { value: string | null }) => setValue(details.value);\n const handleToggle = () => setExpanded(!expanded);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onOrganizationSelect(value);\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='organization-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Select organization\n </Heading>\n <Text size='sm' color='secondary'>\n Choose which organization to sign in to.\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <VStack gap={8} data-testid='organization-list'>\n <RadioGroup variant='card' value={value} onValueChange={handleValueChange}>\n {visible.map(org => (\n <Radio key={org.id} value={org.id}>\n <RadioIndicator />\n <RadioLabel>{org.name}</RadioLabel>\n </Radio>\n ))}\n </RadioGroup>\n {ORGANIZATIONS.length > 5 && (\n <Button\n data-testid='organization-show-more'\n type='button'\n variant='ghost'\n color='neutral'\n onClick={handleToggle}\n >\n {expanded ? 'Show less' : 'Show more'}\n </Button>\n )}\n </VStack>\n <Button data-testid='organization-continue' type='submit' fullWidth disabled={!value}>\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};\n```\n\nThe reset code with a resend countdown, and the password rules from `passwordValidators`.\n\nPassword reset: a 6-digit code with a resend countdown, a new password with its confirmation,\nand the full rule list from `passwordValidators`. \"Continue\" stays disabled until the code is\ncomplete and every rule is met; it then shows the \"Password reset\" toast.\n\n```tsx story=ResetPassword\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { OTPInput } from '@wallarm-org/design-system/InputOTP';\nimport { Link } from '@wallarm-org/design-system/Link';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordComplexity, type PasswordComplexityItem, passwordValidators } from '@wallarm-org/design-system/PasswordComplexity';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ResetPassword = () => {\n const toast = useToast();\n const [code, setCode] = useState('');\n const [password, setPassword] = useState('');\n const [confirm, setConfirm] = useState('');\n\n // The app owns the resend timer; the story freezes it at 55 seconds.\n const secondsLeft = 55;\n\n const rules: PasswordComplexityItem[] = [\n {\n id: 'length',\n label: 'At least 8 characters',\n met: passwordValidators.minLength(8)(password),\n },\n {\n id: 'uppercase',\n label: 'One uppercase letter (A–Z)',\n met: passwordValidators.hasUppercase(password),\n },\n {\n id: 'lowercase',\n label: 'One lowercase letter (a–z)',\n met: passwordValidators.hasLowercase(password),\n },\n { id: 'number', label: 'One number (0–9)', met: passwordValidators.hasNumber(password) },\n {\n id: 'symbol',\n label: 'One symbol (e.g. ! ? @ #)',\n met: passwordValidators.hasSymbol(password),\n },\n {\n id: 'match',\n label: 'Both passwords match',\n met: passwordValidators.passwordsMatch(password, confirm),\n },\n ];\n const complete = code.length === 6 && rules.every(rule => rule.met);\n\n const handleCodeChange = (details: { valueAsString: string }) => setCode(details.valueAsString);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) =>\n setPassword(event.target.value);\n const handleConfirmChange = (event: ChangeEvent<HTMLInputElement>) =>\n setConfirm(event.target.value);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onPasswordReset();\n toast.create({ id: 'password-reset', title: 'Password reset', type: 'default' });\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='reset-password-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Reset password\n </Heading>\n <Text size='sm' color='secondary'>\n We've sent a code to maria.lopez@acme-bank.com\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <Field data-testid='reset-password-code' required>\n <FieldLabel>\n Code\n <FieldIndicator />\n <Link\n asChild\n size='sm'\n weight='medium'\n disabled={secondsLeft > 0}\n onClick={onResend}\n className='ml-auto'\n >\n <button type='button'>\n {secondsLeft > 0 ? `Resend in ${secondsLeft}s` : 'Resend code'}\n </button>\n </Link>\n </FieldLabel>\n <OTPInput otp groupSize={3} onValueChange={handleCodeChange} />\n </Field>\n <Field data-testid='reset-password-new' required>\n <FieldLabel>\n New password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={password}\n onChange={handlePasswordChange}\n />\n </Field>\n <Field data-testid='reset-password-confirm' required>\n <FieldLabel>\n Confirm password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={confirm}\n onChange={handleConfirmChange}\n />\n </Field>\n <PasswordComplexity data-testid='reset-password-rules' items={rules} />\n <Button\n data-testid='reset-password-submit'\n type='submit'\n fullWidth\n disabled={!complete}\n >\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};\n```\n\n## States\n\n| State | Show | Example component |\n| --- | --- | --- |\n| Email step | Email field and \"Continue\" | `Field` + `Input type='email'` |\n| Password step | Password, \"Forgot password?\", OR, SSO | `PasswordInput`, `FieldAction asChild` |\n| Submitting | Spinner on \"Sign in\" | `Button loading` |\n| Wrong credentials | Red password border, error line | `Field invalid`, `PasswordInput error`, `FieldError` |\n| Last used method | \"LAST\" on one button | `Badge size='small' color='blue'` |\n| SSO redirect | Spinner on the clicked button only | `Button loading` |\n| Organization chosen | Building icon, name, \"Change\" above the fields | `Tag` + `Link type='muted' size='sm'` |\n| 6+ organizations | 5 rows, then \"Show more\" / \"Show less\" | Ghost `Button` |\n| Resend countdown | \"Resend in 55s\", disabled | `Link disabled` |\n| Password rules | Dot for unmet, check and strike-through for met | `PasswordComplexity` |\n| Reset done | Sign-in card, then a toast | `toast.create({ title: 'Password reset', type: 'default' })` |\n\n## What your app owns\n\n- Step routing, the password-or-SSO lookup, credential checks, `pending` and error state, SSO redirects, the resend timer, code verification and the reset email.\n- Storing the last-used method (cookie or `localStorage`).\n- One `Toaster` at the app root.\n\nThese do not exist:\n\n- A `variant` on `UtilityPage`.\n- A `label` prop on `Separator`, or a badge prop on `Button`.\n- A \"Show more\" row in `RadioGroup`. The card variant shows separate cards, not one joined list.\n- A clear (X) button on `Input`, or a tertiary `Text color`.\n- An outline for the last-used button. Render the badge only.\n- A non-focusable `Tag`. Accept the extra tab stop.\n\n## Related\n\n- [Feedback toasts](./feedback-toasts.md): the \"Password reset\" toast and the single `Toaster`.\n- [Create and edit forms](./create-edit-forms.md): `Field`, `FieldError` and required markers.\n- [Spacing](../foundations/spacing.md): px utilities and stack gaps.\n- [Typography](../foundations/typography.md): `Heading` and `Text` sizes.\n- [Color](../foundations/color.md): surface, border and text tokens.\n- [Radius and elevation](../foundations/radius-elevation.md): `rounded-12` and why the card has no shadow.",
1644
+ "sections": [
1645
+ {
1646
+ "id": "when-to-use",
1647
+ "heading": "When to use",
1648
+ "markdown": "| Need | Use | Not |\n| --- | --- | --- |\n| Sign in with email and password | Auth card: email step, then password step | A `Dialog` over the app |\n| Pick one of several organizations | Auth card with `RadioGroup variant='card'` | `Select` inside the card |\n| Enter a reset code and a new password | Auth card with `OTPInput`, `PasswordInput`, `PasswordComplexity` | Six separate `Input`s |\n| Show a 404, 403, 500 or offline screen | `UtilityPage` | The auth card |\n| Show that the app loads after sign-in | `SplashScreen` | A spinner inside the auth card |\n\nThere is no `AuthCard`, `AuthPage`, `AuthLayout` or `LoginForm` component; copy the first example into your app."
1649
+ },
1650
+ {
1651
+ "id": "rules",
1652
+ "heading": "Rules",
1653
+ "markdown": "### Layout and spacing\n\n- **Never** render the card inside `Page` or `AppShell`.\n- `AnimatedBackground` centers the card. **Never** center it yourself with `fixed`, `absolute` or `translate`.\n- Card: `w-[480px] rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80 flex flex-col gap-32`.\n- **Never** add a shadow. **Never** use `Card`: it owns 16px padding.\n- **Never** use `UtilityPage` for an auth step: its title is styled as an error code. **Never** copy its `w-[482px]`.\n- **Never** copy the `AppShell` `LoginFlow` story; it has a shadow.\n- Logo: `Logo type='full' size='md' className='mb-32 self-start'` (64px to the title).\n- Title block: `VStack gap={0} align='start'`, `Heading size='2xl' weight='medium'` over `Text size='sm' color='secondary'`.\n- Form: `VStack gap={16}` or `FieldGroup`. **Never** wrap `Field` to add margins.\n- SSO and \"Back\" buttons: `variant='secondary' color='neutral' fullWidth`, stacked with `VStack gap={8}`. OR row: `HStack gap={12}` with two `Separator className='flex-1'` around a `span className='text-xs text-text-tertiary'`.\n- **Never** pass `size` to `Button`, `Input` or `PasswordInput` in the card. The 36px defaults are correct.\n- **Never** pass `className` to `Field`, `Heading` or `Text`. It is not applied.\n- If the card outgrows the viewport, add `max-h-[calc(100dvh-64px)] overflow-y-auto`.\n- Background: `bg-component-app-shell-bg`. There is no `bg-card`, `bg-primary` or `bg-surface`.\n\n### Flow\n\n- One card asks one thing. If a step needs two decisions, split it into two cards.\n- If the email domain uses SSO, redirect after the email step. **Never** show an empty password step.\n- If there are 6 or more organizations, show 5 and a ghost \"Show more\" `Button` under the group.\n\n### Form behavior\n\n- Use one `<form onSubmit>` per card; the primary `Button` is `type='submit' fullWidth`.\n- Give every other `Button` in the form `type='button'`; without it, the button submits.\n- **Never** put a bare `FieldAction` in a `<form>`: it submits the form. Use `FieldAction asChild` with an `<a href>`.\n- `autoComplete`: `email`, `current-password` (sign-in), `new-password` (reset).\n- Pass `otp` and `groupSize={3}` to `OTPInput`. The default groups 2 + 2 + 2.\n- **Never** add `disabled={pending}` next to `loading`; use `disabled` only for an incomplete form. Keep fields enabled while pending.\n- If sign-in fails, show `FieldError` under the password and keep both values. **Never** use an `Alert`, `Banner` or toast.\n- If the code is wrong, pass `error` to `OTPInput` and add a `FieldError` in the same `Field`.\n- Show the \"LAST\" badge on at most one button.\n- For the resend countdown, use `Link disabled`. `FieldAction` takes no `disabled`.\n- Import `FieldAction` and `FieldIndicator` from `@wallarm-org/design-system/Field`, `useToast` from `@wallarm-org/design-system/Toast`, icons from `@wallarm-org/design-system/icons`.\n\n### Copy\n\n| Part | Write | Not |\n| --- | --- | --- |\n| Sign-in title / subtitle | \"Welcome back!\" / \"Sign in to your account\" | \"Login\" |\n| Other titles | \"Select organization\", \"Reset password\" | \"Choose org\" |\n| Credentials error | \"Email or password is incorrect.\" | \"Wrong password\", \"User not found\" |\n| Submit | \"Sign in\" on the password step, \"Continue\" elsewhere | \"Log in\", \"Submit\", \"Next\" |\n| SSO button | \"Continue with {provider}\" | \"Sign in with SSO\" |\n| Separator | \"OR\" | \"or continue with\" |\n| Organization subtitle | \"Choose which organization to sign in to.\" | \"Select one\" |\n| Reset subtitle | \"We've sent a code to {email}\" | \"Check your inbox\" |\n| Resend | \"Resend in {n}s\", then \"Resend code\" | \"Didn't get it?\" |\n| Reset toast | \"Password reset\" | \"Your password was reset successfully!\" |\n| Placeholders | \"you@your-company.com\", \"Enter\" | \"Email\", \"Password\" |\n\n- **Never** say which credential is wrong. Use sentence case."
1654
+ },
1655
+ {
1656
+ "id": "examples",
1657
+ "heading": "Examples",
1658
+ "markdown": "The sign-in flow in the card shell every step renders inside.\n\nThe sign-in flow: an email step, then a password step with \"Forgot password?\", the OR row and\nSSO buttons, one of them marked \"LAST\". There is no backend, so \"Sign in\" always shows the\nwrong-credentials state; an SSO button shows its spinner on that button only.\n\n```tsx story=Default\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Default = () => {\n const [step, setStep] = useState<'email' | 'password'>('email');\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [failed, setFailed] = useState(false);\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleEmailSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n setStep('password');\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email, password });\n setFailed(true);\n };\n const handleBack = () => {\n setStep('email');\n setPassword('');\n setFailed(false);\n };\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='sign-in-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Welcome back!\n </Heading>\n <Text size='sm' color='secondary'>\n Sign in to your account\n </Text>\n </VStack>\n\n {step === 'email' && (\n <form onSubmit={handleEmailSubmit}>\n <VStack gap={16}>\n <Field data-testid='sign-in-email' required>\n <FieldLabel>Email</FieldLabel>\n <Input\n type='email'\n autoComplete='email'\n placeholder='you@your-company.com'\n value={email}\n onChange={handleEmailChange}\n />\n </Field>\n <Button data-testid='sign-in-continue' type='submit' fullWidth disabled={!email}>\n Continue\n </Button>\n </VStack>\n </form>\n )}\n\n {step === 'password' && (\n <>\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='sign-in-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <VStack gap={8}>\n <Button data-testid='sign-in-submit' type='submit' fullWidth disabled={!password}>\n Sign in\n </Button>\n <Button\n data-testid='sign-in-back'\n type='button'\n variant='secondary'\n color='neutral'\n fullWidth\n onClick={handleBack}\n >\n Back\n </Button>\n </VStack>\n </VStack>\n </form>\n\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n\n <VStack gap={8} data-testid='sign-in-sso'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </>\n )}\n </div>\n </AnimatedBackground>\n );\n};\n```\n\nA password field with \"Forgot password?\" as a link and the failed state.\n\nThe failed password step: the error names neither credential, the password stays in the field,\nand editing it clears the error.\n\n```tsx story=WrongCredentials\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const WrongCredentials = () => {\n const [password, setPassword] = useState('Winter2026');\n const [failed, setFailed] = useState(true);\n\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email: 'maria.lopez@acme-bank.com', password });\n setFailed(true);\n };\n\n return (\n <div\n data-testid='wrong-credentials-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='wrong-credentials-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <Button\n data-testid='wrong-credentials-submit'\n type='submit'\n fullWidth\n disabled={!password}\n >\n Sign in\n </Button>\n </VStack>\n </form>\n </div>\n );\n};\n```\n\nThe OR row and SSO buttons with the \"LAST\" badge.\n\nA clicked SSO button shows its spinner; the other buttons stay as they are.\n\n```tsx story=SsoButtons\nimport { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\n\nexport const SsoButtons = () => {\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <div\n data-testid='sso-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n <VStack gap={8} data-testid='sso-buttons'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </div>\n );\n};\n```\n\nOrganization select, collapsed to 5 rows with \"Show more\".\n\nOrganization select for a user in 7 organizations: card radios collapsed to 5 rows, with a\nghost \"Show more\" button under the group.\n\n```tsx story=SelectOrganization\nimport { type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { Radio, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SelectOrganization = () => {\n const [value, setValue] = useState<string | null>(null);\n const [expanded, setExpanded] = useState(false);\n\n const visible = expanded ? ORGANIZATIONS : ORGANIZATIONS.slice(0, 5);\n\n const handleValueChange = (details: { value: string | null }) => setValue(details.value);\n const handleToggle = () => setExpanded(!expanded);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onOrganizationSelect(value);\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='organization-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Select organization\n </Heading>\n <Text size='sm' color='secondary'>\n Choose which organization to sign in to.\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <VStack gap={8} data-testid='organization-list'>\n <RadioGroup variant='card' value={value} onValueChange={handleValueChange}>\n {visible.map(org => (\n <Radio key={org.id} value={org.id}>\n <RadioIndicator />\n <RadioLabel>{org.name}</RadioLabel>\n </Radio>\n ))}\n </RadioGroup>\n {ORGANIZATIONS.length > 5 && (\n <Button\n data-testid='organization-show-more'\n type='button'\n variant='ghost'\n color='neutral'\n onClick={handleToggle}\n >\n {expanded ? 'Show less' : 'Show more'}\n </Button>\n )}\n </VStack>\n <Button data-testid='organization-continue' type='submit' fullWidth disabled={!value}>\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};\n```\n\nThe reset code with a resend countdown, and the password rules from `passwordValidators`.\n\nPassword reset: a 6-digit code with a resend countdown, a new password with its confirmation,\nand the full rule list from `passwordValidators`. \"Continue\" stays disabled until the code is\ncomplete and every rule is met; it then shows the \"Password reset\" toast.\n\n```tsx story=ResetPassword\nimport { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { OTPInput } from '@wallarm-org/design-system/InputOTP';\nimport { Link } from '@wallarm-org/design-system/Link';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordComplexity, type PasswordComplexityItem, passwordValidators } from '@wallarm-org/design-system/PasswordComplexity';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ResetPassword = () => {\n const toast = useToast();\n const [code, setCode] = useState('');\n const [password, setPassword] = useState('');\n const [confirm, setConfirm] = useState('');\n\n // The app owns the resend timer; the story freezes it at 55 seconds.\n const secondsLeft = 55;\n\n const rules: PasswordComplexityItem[] = [\n {\n id: 'length',\n label: 'At least 8 characters',\n met: passwordValidators.minLength(8)(password),\n },\n {\n id: 'uppercase',\n label: 'One uppercase letter (A–Z)',\n met: passwordValidators.hasUppercase(password),\n },\n {\n id: 'lowercase',\n label: 'One lowercase letter (a–z)',\n met: passwordValidators.hasLowercase(password),\n },\n { id: 'number', label: 'One number (0–9)', met: passwordValidators.hasNumber(password) },\n {\n id: 'symbol',\n label: 'One symbol (e.g. ! ? @ #)',\n met: passwordValidators.hasSymbol(password),\n },\n {\n id: 'match',\n label: 'Both passwords match',\n met: passwordValidators.passwordsMatch(password, confirm),\n },\n ];\n const complete = code.length === 6 && rules.every(rule => rule.met);\n\n const handleCodeChange = (details: { valueAsString: string }) => setCode(details.valueAsString);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) =>\n setPassword(event.target.value);\n const handleConfirmChange = (event: ChangeEvent<HTMLInputElement>) =>\n setConfirm(event.target.value);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onPasswordReset();\n toast.create({ id: 'password-reset', title: 'Password reset', type: 'default' });\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='reset-password-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Reset password\n </Heading>\n <Text size='sm' color='secondary'>\n We've sent a code to maria.lopez@acme-bank.com\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <Field data-testid='reset-password-code' required>\n <FieldLabel>\n Code\n <FieldIndicator />\n <Link\n asChild\n size='sm'\n weight='medium'\n disabled={secondsLeft > 0}\n onClick={onResend}\n className='ml-auto'\n >\n <button type='button'>\n {secondsLeft > 0 ? `Resend in ${secondsLeft}s` : 'Resend code'}\n </button>\n </Link>\n </FieldLabel>\n <OTPInput otp groupSize={3} onValueChange={handleCodeChange} />\n </Field>\n <Field data-testid='reset-password-new' required>\n <FieldLabel>\n New password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={password}\n onChange={handlePasswordChange}\n />\n </Field>\n <Field data-testid='reset-password-confirm' required>\n <FieldLabel>\n Confirm password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={confirm}\n onChange={handleConfirmChange}\n />\n </Field>\n <PasswordComplexity data-testid='reset-password-rules' items={rules} />\n <Button\n data-testid='reset-password-submit'\n type='submit'\n fullWidth\n disabled={!complete}\n >\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};\n```"
1659
+ },
1660
+ {
1661
+ "id": "states",
1662
+ "heading": "States",
1663
+ "markdown": "| State | Show | Example component |\n| --- | --- | --- |\n| Email step | Email field and \"Continue\" | `Field` + `Input type='email'` |\n| Password step | Password, \"Forgot password?\", OR, SSO | `PasswordInput`, `FieldAction asChild` |\n| Submitting | Spinner on \"Sign in\" | `Button loading` |\n| Wrong credentials | Red password border, error line | `Field invalid`, `PasswordInput error`, `FieldError` |\n| Last used method | \"LAST\" on one button | `Badge size='small' color='blue'` |\n| SSO redirect | Spinner on the clicked button only | `Button loading` |\n| Organization chosen | Building icon, name, \"Change\" above the fields | `Tag` + `Link type='muted' size='sm'` |\n| 6+ organizations | 5 rows, then \"Show more\" / \"Show less\" | Ghost `Button` |\n| Resend countdown | \"Resend in 55s\", disabled | `Link disabled` |\n| Password rules | Dot for unmet, check and strike-through for met | `PasswordComplexity` |\n| Reset done | Sign-in card, then a toast | `toast.create({ title: 'Password reset', type: 'default' })` |"
1664
+ },
1665
+ {
1666
+ "id": "what-your-app-owns",
1667
+ "heading": "What your app owns",
1668
+ "markdown": "- Step routing, the password-or-SSO lookup, credential checks, `pending` and error state, SSO redirects, the resend timer, code verification and the reset email.\n- Storing the last-used method (cookie or `localStorage`).\n- One `Toaster` at the app root.\n\nThese do not exist:\n\n- A `variant` on `UtilityPage`.\n- A `label` prop on `Separator`, or a badge prop on `Button`.\n- A \"Show more\" row in `RadioGroup`. The card variant shows separate cards, not one joined list.\n- A clear (X) button on `Input`, or a tertiary `Text color`.\n- An outline for the last-used button. Render the badge only.\n- A non-focusable `Tag`. Accept the extra tab stop."
1669
+ },
1670
+ {
1671
+ "id": "related",
1672
+ "heading": "Related",
1673
+ "markdown": "- [Feedback toasts](./feedback-toasts.md): the \"Password reset\" toast and the single `Toaster`.\n- [Create and edit forms](./create-edit-forms.md): `Field`, `FieldError` and required markers.\n- [Spacing](../foundations/spacing.md): px utilities and stack gaps.\n- [Typography](../foundations/typography.md): `Heading` and `Text` sizes.\n- [Color](../foundations/color.md): surface, border and text tokens.\n- [Radius and elevation](../foundations/radius-elevation.md): `rounded-12` and why the card has no shadow."
1674
+ }
1675
+ ],
1676
+ "stories": [
1677
+ {
1678
+ "name": "Default",
1679
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Input } from '@wallarm-org/design-system/Input';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const Default = () => {\n const [step, setStep] = useState<'email' | 'password'>('email');\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [failed, setFailed] = useState(false);\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => setEmail(event.target.value);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleEmailSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n setStep('password');\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email, password });\n setFailed(true);\n };\n const handleBack = () => {\n setStep('email');\n setPassword('');\n setFailed(false);\n };\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='sign-in-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Welcome back!\n </Heading>\n <Text size='sm' color='secondary'>\n Sign in to your account\n </Text>\n </VStack>\n\n {step === 'email' && (\n <form onSubmit={handleEmailSubmit}>\n <VStack gap={16}>\n <Field data-testid='sign-in-email' required>\n <FieldLabel>Email</FieldLabel>\n <Input\n type='email'\n autoComplete='email'\n placeholder='you@your-company.com'\n value={email}\n onChange={handleEmailChange}\n />\n </Field>\n <Button data-testid='sign-in-continue' type='submit' fullWidth disabled={!email}>\n Continue\n </Button>\n </VStack>\n </form>\n )}\n\n {step === 'password' && (\n <>\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='sign-in-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <VStack gap={8}>\n <Button data-testid='sign-in-submit' type='submit' fullWidth disabled={!password}>\n Sign in\n </Button>\n <Button\n data-testid='sign-in-back'\n type='button'\n variant='secondary'\n color='neutral'\n fullWidth\n onClick={handleBack}\n >\n Back\n </Button>\n </VStack>\n </VStack>\n </form>\n\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n\n <VStack gap={8} data-testid='sign-in-sso'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </>\n )}\n </div>\n </AnimatedBackground>\n );\n};",
1680
+ "description": "The sign-in flow: an email step, then a password step with \"Forgot password?\", the OR row and\nSSO buttons, one of them marked \"LAST\". There is no backend, so \"Sign in\" always shows the\nwrong-credentials state; an SSO button shows its spinner on that button only."
1681
+ },
1682
+ {
1683
+ "name": "WrongCredentials",
1684
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldAction, FieldError, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\n\nexport const WrongCredentials = () => {\n const [password, setPassword] = useState('Winter2026');\n const [failed, setFailed] = useState(true);\n\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {\n setPassword(event.target.value);\n setFailed(false);\n };\n const handleSignIn = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onSignIn({ email: 'maria.lopez@acme-bank.com', password });\n setFailed(true);\n };\n\n return (\n <div\n data-testid='wrong-credentials-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <form onSubmit={handleSignIn}>\n <VStack gap={16}>\n <Field data-testid='wrong-credentials-password' invalid={failed}>\n <FieldLabel>\n Password\n <FieldAction asChild>\n <a href='#forgot-password'>Forgot password?</a>\n </FieldAction>\n </FieldLabel>\n <PasswordInput\n autoComplete='current-password'\n placeholder='Enter'\n error={failed}\n value={password}\n onChange={handlePasswordChange}\n />\n {failed && <FieldError>Email or password is incorrect.</FieldError>}\n </Field>\n <Button\n data-testid='wrong-credentials-submit'\n type='submit'\n fullWidth\n disabled={!password}\n >\n Sign in\n </Button>\n </VStack>\n </form>\n </div>\n );\n};",
1685
+ "description": "The failed password step: the error names neither credential, the password stays in the field,\nand editing it clears the error."
1686
+ },
1687
+ {
1688
+ "name": "SsoButtons",
1689
+ "code": "import { useState } from 'react';\nimport { Badge } from '@wallarm-org/design-system/Badge';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Aws, Google } from '@wallarm-org/design-system/icons';\nimport { Separator } from '@wallarm-org/design-system/Separator';\nimport { HStack, VStack } from '@wallarm-org/design-system/Stack';\n\nexport const SsoButtons = () => {\n const [ssoPending, setSsoPending] = useState<SignInMethod | null>(null);\n\n const handleAws = () => {\n setSsoPending('aws');\n onSsoContinue('aws');\n };\n const handleGoogle = () => {\n setSsoPending('google');\n onSsoContinue('google');\n };\n\n return (\n <div\n data-testid='sso-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <HStack gap={12}>\n <Separator className='flex-1' />\n <span className='text-xs text-text-tertiary'>OR</span>\n <Separator className='flex-1' />\n </HStack>\n <VStack gap={8} data-testid='sso-buttons'>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'aws'}\n onClick={handleAws}\n >\n <Aws />\n Continue with AWS\n </Button>\n {LAST_METHOD === 'aws' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n <div className='relative w-full'>\n <Button\n variant='secondary'\n color='neutral'\n fullWidth\n loading={ssoPending === 'google'}\n onClick={handleGoogle}\n >\n <Google />\n Continue with Google\n </Button>\n {LAST_METHOD === 'google' && (\n <Badge size='small' color='blue' className='absolute -top-6 -right-8 uppercase'>\n Last\n </Badge>\n )}\n </div>\n </VStack>\n </div>\n );\n};",
1690
+ "description": "A clicked SSO button shows its spinner; the other buttons stay as they are."
1691
+ },
1692
+ {
1693
+ "name": "SelectOrganization",
1694
+ "code": "import { type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { Radio, RadioGroup, RadioIndicator, RadioLabel } from '@wallarm-org/design-system/Radio';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\n\nexport const SelectOrganization = () => {\n const [value, setValue] = useState<string | null>(null);\n const [expanded, setExpanded] = useState(false);\n\n const visible = expanded ? ORGANIZATIONS : ORGANIZATIONS.slice(0, 5);\n\n const handleValueChange = (details: { value: string | null }) => setValue(details.value);\n const handleToggle = () => setExpanded(!expanded);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onOrganizationSelect(value);\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='organization-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Select organization\n </Heading>\n <Text size='sm' color='secondary'>\n Choose which organization to sign in to.\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <VStack gap={8} data-testid='organization-list'>\n <RadioGroup variant='card' value={value} onValueChange={handleValueChange}>\n {visible.map(org => (\n <Radio key={org.id} value={org.id}>\n <RadioIndicator />\n <RadioLabel>{org.name}</RadioLabel>\n </Radio>\n ))}\n </RadioGroup>\n {ORGANIZATIONS.length > 5 && (\n <Button\n data-testid='organization-show-more'\n type='button'\n variant='ghost'\n color='neutral'\n onClick={handleToggle}\n >\n {expanded ? 'Show less' : 'Show more'}\n </Button>\n )}\n </VStack>\n <Button data-testid='organization-continue' type='submit' fullWidth disabled={!value}>\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};",
1695
+ "description": "Organization select for a user in 7 organizations: card radios collapsed to 5 rows, with a\nghost \"Show more\" button under the group."
1696
+ },
1697
+ {
1698
+ "name": "ResetPassword",
1699
+ "code": "import { type ChangeEvent, type FormEvent, useState } from 'react';\nimport { AnimatedBackground } from '@wallarm-org/design-system/AnimatedBackground';\nimport { Button } from '@wallarm-org/design-system/Button';\nimport { Field, FieldIndicator, FieldLabel } from '@wallarm-org/design-system/Field';\nimport { Heading } from '@wallarm-org/design-system/Heading';\nimport { OTPInput } from '@wallarm-org/design-system/InputOTP';\nimport { Link } from '@wallarm-org/design-system/Link';\nimport { Logo } from '@wallarm-org/design-system/Logo';\nimport { PasswordComplexity, type PasswordComplexityItem, passwordValidators } from '@wallarm-org/design-system/PasswordComplexity';\nimport { PasswordInput } from '@wallarm-org/design-system/PasswordInput';\nimport { VStack } from '@wallarm-org/design-system/Stack';\nimport { Text } from '@wallarm-org/design-system/Text';\nimport { useToast } from '@wallarm-org/design-system/Toast';\n\nexport const ResetPassword = () => {\n const toast = useToast();\n const [code, setCode] = useState('');\n const [password, setPassword] = useState('');\n const [confirm, setConfirm] = useState('');\n\n // The app owns the resend timer; the story freezes it at 55 seconds.\n const secondsLeft = 55;\n\n const rules: PasswordComplexityItem[] = [\n {\n id: 'length',\n label: 'At least 8 characters',\n met: passwordValidators.minLength(8)(password),\n },\n {\n id: 'uppercase',\n label: 'One uppercase letter (A–Z)',\n met: passwordValidators.hasUppercase(password),\n },\n {\n id: 'lowercase',\n label: 'One lowercase letter (a–z)',\n met: passwordValidators.hasLowercase(password),\n },\n { id: 'number', label: 'One number (0–9)', met: passwordValidators.hasNumber(password) },\n {\n id: 'symbol',\n label: 'One symbol (e.g. ! ? @ #)',\n met: passwordValidators.hasSymbol(password),\n },\n {\n id: 'match',\n label: 'Both passwords match',\n met: passwordValidators.passwordsMatch(password, confirm),\n },\n ];\n const complete = code.length === 6 && rules.every(rule => rule.met);\n\n const handleCodeChange = (details: { valueAsString: string }) => setCode(details.valueAsString);\n const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) =>\n setPassword(event.target.value);\n const handleConfirmChange = (event: ChangeEvent<HTMLInputElement>) =>\n setConfirm(event.target.value);\n const handleSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n onPasswordReset();\n toast.create({ id: 'password-reset', title: 'Password reset', type: 'default' });\n };\n\n return (\n <AnimatedBackground className='h-full w-full bg-component-app-shell-bg'>\n <div\n data-testid='reset-password-card'\n className='flex max-h-[calc(100dvh-64px)] w-[480px] flex-col gap-32 overflow-y-auto rounded-12 border-1 border-border-primary-light bg-bg-surface-1 px-48 pt-32 pb-80'\n >\n <Logo type='full' size='md' className='mb-32 self-start' />\n <VStack gap={0} align='start'>\n <Heading size='2xl' weight='medium'>\n Reset password\n </Heading>\n <Text size='sm' color='secondary'>\n We've sent a code to maria.lopez@acme-bank.com\n </Text>\n </VStack>\n\n <form onSubmit={handleSubmit}>\n <VStack gap={16}>\n <Field data-testid='reset-password-code' required>\n <FieldLabel>\n Code\n <FieldIndicator />\n <Link\n asChild\n size='sm'\n weight='medium'\n disabled={secondsLeft > 0}\n onClick={onResend}\n className='ml-auto'\n >\n <button type='button'>\n {secondsLeft > 0 ? `Resend in ${secondsLeft}s` : 'Resend code'}\n </button>\n </Link>\n </FieldLabel>\n <OTPInput otp groupSize={3} onValueChange={handleCodeChange} />\n </Field>\n <Field data-testid='reset-password-new' required>\n <FieldLabel>\n New password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={password}\n onChange={handlePasswordChange}\n />\n </Field>\n <Field data-testid='reset-password-confirm' required>\n <FieldLabel>\n Confirm password\n <FieldIndicator />\n </FieldLabel>\n <PasswordInput\n autoComplete='new-password'\n placeholder='Enter'\n value={confirm}\n onChange={handleConfirmChange}\n />\n </Field>\n <PasswordComplexity data-testid='reset-password-rules' items={rules} />\n <Button\n data-testid='reset-password-submit'\n type='submit'\n fullWidth\n disabled={!complete}\n >\n Continue\n </Button>\n </VStack>\n </form>\n </div>\n </AnimatedBackground>\n );\n};",
1700
+ "description": "Password reset: a 6-digit code with a resend countdown, a new password with its confirmation,\nand the full rule list from `passwordValidators`. \"Continue\" stays disabled until the code is\ncomplete and every rule is met; it then shows the \"Password reset\" toast."
1701
+ }
1702
+ ]
1703
+ }
1704
+ ]
1705
+ }