division-ds 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/AGENTS.md +23 -3
  2. package/README.md +11 -0
  3. package/division-ds-0.2.1.tgz +0 -0
  4. package/package.json +2 -2
  5. package/public/docs/components/accordion.md +207 -0
  6. package/public/docs/components/ai-label.md +90 -0
  7. package/public/docs/components/alert-dialog.md +292 -0
  8. package/public/docs/components/alert.md +175 -0
  9. package/public/docs/components/approval-request.md +148 -0
  10. package/public/docs/components/auth-form.md +132 -0
  11. package/public/docs/components/avatar-group.md +152 -0
  12. package/public/docs/components/avatar.md +179 -0
  13. package/public/docs/components/badge.md +178 -0
  14. package/public/docs/components/breadcrumbs.md +112 -0
  15. package/public/docs/components/button.md +204 -0
  16. package/public/docs/components/card.md +257 -0
  17. package/public/docs/components/carousel.md +130 -0
  18. package/public/docs/components/cart.md +172 -0
  19. package/public/docs/components/chart-panel.md +358 -0
  20. package/public/docs/components/chat-message.md +157 -0
  21. package/public/docs/components/chat-thread.md +244 -0
  22. package/public/docs/components/checkbox.md +149 -0
  23. package/public/docs/components/checkout-form.md +128 -0
  24. package/public/docs/components/command-palette.md +147 -0
  25. package/public/docs/components/data-table.md +401 -0
  26. package/public/docs/components/description-list.md +103 -0
  27. package/public/docs/components/dialog.md +197 -0
  28. package/public/docs/components/divider.md +97 -0
  29. package/public/docs/components/dropdown-menu.md +222 -0
  30. package/public/docs/components/empty-state.md +145 -0
  31. package/public/docs/components/footer.md +397 -0
  32. package/public/docs/components/form-field.md +216 -0
  33. package/public/docs/components/icon-button.md +127 -0
  34. package/public/docs/components/input.md +200 -0
  35. package/public/docs/components/kanban-board.md +300 -0
  36. package/public/docs/components/kbd.md +72 -0
  37. package/public/docs/components/link.md +98 -0
  38. package/public/docs/components/list-item.md +241 -0
  39. package/public/docs/components/loading-animation.md +72 -0
  40. package/public/docs/components/multi-select.md +202 -0
  41. package/public/docs/components/navbar.md +144 -0
  42. package/public/docs/components/newsletter-form.md +112 -0
  43. package/public/docs/components/page-header.md +151 -0
  44. package/public/docs/components/pagination.md +212 -0
  45. package/public/docs/components/product-card.md +172 -0
  46. package/public/docs/components/progress.md +101 -0
  47. package/public/docs/components/prompt-input.md +151 -0
  48. package/public/docs/components/quantity-stepper.md +140 -0
  49. package/public/docs/components/radio.md +177 -0
  50. package/public/docs/components/rating.md +106 -0
  51. package/public/docs/components/score-ring.md +95 -0
  52. package/public/docs/components/search.md +116 -0
  53. package/public/docs/components/section-heading.md +113 -0
  54. package/public/docs/components/segmented-control.md +126 -0
  55. package/public/docs/components/select.md +224 -0
  56. package/public/docs/components/sheet.md +145 -0
  57. package/public/docs/components/sidebar.md +193 -0
  58. package/public/docs/components/skeleton.md +135 -0
  59. package/public/docs/components/slider.md +114 -0
  60. package/public/docs/components/sparkline.md +128 -0
  61. package/public/docs/components/spinner.md +98 -0
  62. package/public/docs/components/stat.md +106 -0
  63. package/public/docs/components/status-tracker.md +145 -0
  64. package/public/docs/components/stepper.md +147 -0
  65. package/public/docs/components/switch.md +153 -0
  66. package/public/docs/components/tabs.md +180 -0
  67. package/public/docs/components/tag.md +105 -0
  68. package/public/docs/components/textarea.md +148 -0
  69. package/public/docs/components/timeline.md +152 -0
  70. package/public/docs/components/toast.md +194 -0
  71. package/public/docs/components/tool-call.md +107 -0
  72. package/public/docs/components/tooltip.md +108 -0
  73. package/public/manifest/components.json +198 -15
  74. package/public/manifest/tokens.json +75 -5
  75. package/scripts/division-ds.mjs +78 -42
  76. package/scripts/lib/agent-docs.mjs +80 -6
  77. package/scripts/lib/ai-copy.mjs +120 -0
  78. package/scripts/lib/button-spacing.mjs +5 -5
  79. package/scripts/lib/check-text.mjs +57 -0
  80. package/scripts/lib/contrast.mjs +111 -0
  81. package/scripts/lib/host-inline.mjs +59 -0
  82. package/scripts/lib/mcp-server.mjs +116 -0
  83. package/scripts/lib/mcp-tools.mjs +266 -0
  84. package/scripts/lib/props-validator.mjs +3 -2
  85. package/scripts/lib/rules-installer.mjs +42 -4
  86. package/scripts/lib/token-discipline.mjs +10 -3
  87. package/skills/division-ds/SKILL.md +90 -0
  88. package/skills/division-ds/commands/add.md +25 -0
  89. package/skills/division-ds/commands/build.md +46 -0
  90. package/skills/division-ds/commands/fix.md +28 -0
  91. package/skills/division-ds/commands/review.md +25 -0
  92. package/skills/division-ds/topics/ai.md +8 -0
  93. package/skills/division-ds/topics/colour.md +11 -0
  94. package/skills/division-ds/topics/feedback.md +9 -0
  95. package/skills/division-ds/topics/forms.md +22 -0
  96. package/skills/division-ds/topics/layout.md +36 -0
  97. package/skills/division-ds/topics/motion.md +10 -0
  98. package/skills/division-ds/topics/overlays.md +9 -0
@@ -0,0 +1,112 @@
1
+ # Breadcrumbs
2
+
3
+ > Shows where this page sits among the pages above it. The last level is the current page, shown as text. Every level above it is a link.
4
+
5
+ - **Best for:** Showing where a page sits in a hierarchy more than one level deep.
6
+ - **Not for:** Steps in a sequence: use Stepper.
7
+ - Molecule. Source: `src/components/molecules/breadcrumbs/Breadcrumbs.tsx`
8
+ - Rendered docs page: `/components/breadcrumbs`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Breadcrumbs, collapseCrumbs } from 'src/components/molecules/breadcrumbs/Breadcrumbs'
14
+ ```
15
+
16
+ Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.
17
+
18
+ ## Props
19
+
20
+ | Prop | Type | Default | Description |
21
+ |---|---|---|---|
22
+ | `items` | `Crumb[]` | | { label, href? }. A level with no href shows as plain text. |
23
+ | `maxItems` | `number` | | Collapse the trail when it has more levels than this. It never goes below 3. |
24
+ | `label` | `string` | `'Breadcrumb'` | The name of the nav landmark. |
25
+ | `className` | `string` | | Merged onto the nav. |
26
+ | `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | How each linked level is drawn. Pass your router's link component to move between pages inside the app. |
27
+ | `ref` | `Ref<HTMLElement>` | | Passed through to the nav DOM element. |
28
+
29
+ ## Types and helpers
30
+
31
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
32
+
33
+ ```ts
34
+ export type Crumb = { label: string; href?: string }
35
+ ```
36
+
37
+ ```ts
38
+ /** First item, a gap, then the last (maxItems - 1). Three is the floor: below it there is no
39
+ * middle left to collapse and the gap would replace the only context the trail gives. */
40
+ export function collapseCrumbs(items: Crumb[], maxItems?: number): (Crumb | 'gap')[]
41
+ ```
42
+
43
+ ## Examples
44
+
45
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
46
+
47
+ ### Default
48
+
49
+ ```tsx
50
+ const TRAIL = [
51
+ { label: 'Catalogue', href: '/catalogue' },
52
+ { label: 'Optics', href: '/catalogue/optics' },
53
+ { label: 'Holo sights', href: '/catalogue/optics/holo-sights' },
54
+ { label: 'Prism holo sight' },
55
+ ]
56
+
57
+ <Breadcrumbs items={TRAIL} label="Breadcrumb, default example" />
58
+ ```
59
+
60
+ ### Collapsed
61
+
62
+ ```tsx
63
+ const DEPOT_TRAIL = [
64
+ { label: 'Depot', href: '/depot' },
65
+ { label: 'Region: North', href: '/depot/north' },
66
+ { label: 'Site: Fell Beck', href: '/depot/north/fell-beck' },
67
+ { label: 'Building: Stores', href: '/depot/north/fell-beck/stores' },
68
+ { label: 'Bay 4', href: '/depot/north/fell-beck/stores/bay-4' },
69
+ { label: 'Crate 118' },
70
+ ]
71
+
72
+ <Breadcrumbs items={DEPOT_TRAIL} maxItems={3} label="Breadcrumb, collapsed" />
73
+ ```
74
+
75
+ ### A level that is not a page
76
+
77
+ ```tsx
78
+ const MID_LEVEL_TRAIL = [
79
+ { label: 'Catalogue', href: '/catalogue' },
80
+ { label: 'Optics' },
81
+ { label: 'Holo sights', href: '/catalogue/optics/holo-sights' },
82
+ ]
83
+
84
+ <Breadcrumbs items={MID_LEVEL_TRAIL} label="Breadcrumb, mid-level example" />
85
+ ```
86
+
87
+ ## Keyboard
88
+
89
+ | Keys | Result |
90
+ |---|---|
91
+ | `Tab` | Moves through the linked levels in order. The current page isn't a stop. |
92
+
93
+ ## Do
94
+
95
+ - Start from the top of the hierarchy, not from the home page, when the site has one obvious root.
96
+ - Use the page's own heading text as its crumb.
97
+ - Collapse long trails on narrow layouts with maxItems.
98
+
99
+ ## Don't
100
+
101
+ - Don't use breadcrumbs for a sequence of steps. That's Stepper.
102
+ - Don't make the current page a link.
103
+ - Don't put a breadcrumb trail on a page that's only one level deep.
104
+
105
+ ## Anatomy
106
+
107
+ - **nav**: A navigation landmark (a region screen readers can jump to) named 'Breadcrumb'.
108
+ - **ol**: An ordered list, because the order is the information.
109
+ - **Level**: A quiet Link.
110
+ - **Current page**: Text with aria-current=page, which tells screen readers it's the page you're on. Never a link to itself.
111
+ - **Separator**: A slash in monospaced type, marked aria-hidden so screen readers skip it.
112
+ - **Gap**: An ellipsis (…) when the middle levels are collapsed, read out as 'more levels' by screen readers. It isn't a button.
@@ -0,0 +1,204 @@
1
+ # Button
2
+
3
+ > The main control for taking an action. It comes in five variants (one of them a button that looks like a text link) and four sizes, with optional icons on either side of the label. It also draws its own focus ring, the outline that shows which control the keyboard is on. The component places that ring for you, because the bevel (the cut corners) would clip one placed anywhere else.
4
+
5
+ - **Best for:** An action on this page, like submit, save or open a dialog.
6
+ - **Not for:** Going somewhere (use Link, or Button asChild around an anchor), or an action with no visible word (use IconButton).
7
+ - Atom. Source: `src/components/atoms/button/Button.tsx`
8
+ - Rendered docs page: `/components/button`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Button, buttonVariants } from 'src/components/atoms/button/Button'
14
+ ```
15
+
16
+ Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.
17
+
18
+ ## Props
19
+
20
+ | Prop | Type | Default | Description |
21
+ |---|---|---|---|
22
+ | `variant` | `'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'` | `'primary'` | Which fill/border treatment to use. link looks like a Link (signal text, underline, no fill, no bevel, no fixed height) but it's still a button. Use it for an action inside a sentence, not for going to another page. |
23
+ | `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | 24 / 32 / 40 / 48px (pixels). xs is exactly the 24px minimum target, the smallest area people can reliably click or tap. It isn't a token step (there is no xs control-height token) and it's meant for dense toolbars and table rows. Size has no effect on the link variant, which takes the size of the text around it. |
24
+ | `fullWidth` | `boolean` | `false` | Stretches the wrapper as well as the button. |
25
+ | `loading` | `boolean` | `false` | Sets aria-busy and aria-disabled, blocks the click and the form submit, and shows the spinner. The button stays focusable. |
26
+ | `loadingLabel` | `string` | `'Working'` | Text that's hidden on screen but read aloud while loading. |
27
+ | `iconStart` | `ReactNode` | | An icon before the label, rendered aria-hidden and sized to the button. The spinner replaces it while loading. It isn't available with asChild: put the icon inside the child element instead. |
28
+ | `iconEnd` | `ReactNode` | | An icon after the label, rendered aria-hidden. It stays while loading. Not available with asChild. |
29
+ | `asChild` | `boolean` | `false` | Renders the child element with the button's classes. Use it for links that look like buttons. Combining it with disabled, loading or loadingLabel is a type error, so the code won't compile, instead of quietly doing nothing. A link can't really be disabled or made busy, so those props are removed from the types rather than ignored when the page runs. |
30
+ | `wrapperClassName` | `string` | | Classes for the focus wrapper, not the button. |
31
+ | `className` | `string` | | Merged with the component's classes. When Tailwind utility classes clash, the later one wins. |
32
+ | `ref` | `Ref<HTMLButtonElement>` | | Passed through to the button element. |
33
+ | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | A Button never submits a form by accident. Ask for submit on the one that should. |
34
+ | `...rest` | `Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-busy' \| 'children'>` | | Every native button prop except aria-busy. aria-busy is removed from the types rather than quietly overwritten when the page runs. loading is the only thing that sets it, and a loading button saying aria-busy="false" would be a spinning, aria-disabled control telling assistive tech (screen readers and other tools people with disabilities use) that it's idle. children is its own declared prop above, not part of rest. |
35
+
36
+ ## Types and helpers
37
+
38
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
39
+
40
+ ```ts
41
+ export const buttonVariants = cva( …
42
+ ```
43
+
44
+ ## Examples
45
+
46
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
47
+
48
+ ### Variants
49
+
50
+ ```tsx
51
+ <Button variant="primary">Dispatch team</Button>
52
+ <Button variant="secondary">Save draft</Button>
53
+ <Button variant="danger">Abort</Button>
54
+ <Button variant="ghost">Cancel</Button>
55
+ ```
56
+
57
+ ### Sizes
58
+
59
+ ```tsx
60
+ <Button size="xs">Dispatch team</Button>
61
+ <Button size="sm">Dispatch team</Button>
62
+ <Button size="md">Dispatch team</Button>
63
+ <Button size="lg">Dispatch team</Button>
64
+ ```
65
+
66
+ ### States
67
+
68
+ ```tsx
69
+ <Button>Dispatch team</Button>
70
+ <Button loading loadingLabel="Dispatching">
71
+ Dispatch team
72
+ </Button>
73
+ <Button disabled>Dispatch team</Button>
74
+ ```
75
+
76
+ ### With icons
77
+
78
+ ```tsx
79
+ <Button iconStart={<Plus />}>Add to loadout</Button>
80
+ <Button variant="secondary" iconEnd={<ArrowRight />}>
81
+ Continue
82
+ </Button>
83
+ <Button variant="ghost" size="sm" iconStart={<Download />}>
84
+ Export
85
+ </Button>
86
+ <Button loading loadingLabel="Adding" iconStart={<Plus />}>
87
+ Add to loadout
88
+ </Button>
89
+ ```
90
+
91
+ ### Link variant
92
+
93
+ ```tsx
94
+ <p className="text-sm text-[var(--color-text-secondary)] max-w-md">
95
+ No replicas match these filters.{' '}
96
+ <Button variant="link">
97
+ Reset filters
98
+ </Button>{' '}
99
+ to see the full catalogue.
100
+ </p>
101
+ ```
102
+
103
+ ### Full width
104
+
105
+ ```tsx
106
+ <div className="w-80">
107
+ <Button fullWidth>Dispatch team</Button>
108
+ </div>
109
+ ```
110
+
111
+ ### As a link
112
+
113
+ ```tsx
114
+ <Button asChild>
115
+ <a href="/kits">Browse kits</a>
116
+ </Button>
117
+ ```
118
+
119
+ ### Focus ring is never clipped
120
+
121
+ ```tsx
122
+ <div className="flex flex-col gap-4">
123
+ <p className="text-sm text-[var(--color-text-secondary)] max-w-md">
124
+ Press Tab to focus the button below, in either theme. The focus ring (the outline
125
+ that shows which element the keyboard is on) draws a clean rectangle around the whole
126
+ bevelled shape. You'll never see it cut off, because the component draws it on the
127
+ outer wrapper, which isn't clipped, and not on the bevelled button itself.
128
+ </p>
129
+ <Button variant="primary">Dispatch team</Button>
130
+ </div>
131
+ ```
132
+
133
+ ## Keyboard
134
+
135
+ | Keys | Result |
136
+ |---|---|
137
+ | `Tab` | Moves focus to the button. The ring appears on the wrapper. |
138
+ | `Enter` | Activates it. While loading it does nothing, and the caller's onClick doesn't run. |
139
+ | `Space` | Activates it. While loading it does nothing, and the caller's onClick doesn't run. |
140
+
141
+ ## Do
142
+
143
+ - One primary button per view. The signal colour only means something if it's rare.
144
+ - Use asChild for a link that should look like a button, so it stays a real link.
145
+ - Say what the button does: ‘Dispatch team’, not ‘Submit’.
146
+ - Use loading instead of disabling the button and swapping the label yourself.
147
+ - Use loading on a submit button. It keeps focus while the form works, so a keyboard user is never thrown back to the top of the page.
148
+ - Pass icons through iconStart and iconEnd, not as children. That way they stay hidden from assistive tech and get sized with the button.
149
+ - Use the link variant for an action that sits in running text or next to a heading (‘Reset filters’). Use Link for anything that goes somewhere.
150
+
151
+ ## Don't
152
+
153
+ - Don't add bevel-* or an outline class yourself. You'll clip the ring off.
154
+ - Don't use danger for anything that can be undone.
155
+ - Don't put a ghost button next to a secondary one. People can't tell which matters more any more.
156
+ - Don't disable a button without saying somewhere why it's disabled.
157
+ - Don't use the link variant to go to another page. A control that goes somewhere is a Link, or a Button with asChild around an anchor.
158
+ - Don't make an icon-only button by passing iconStart with no label. That's IconButton, which requires a name.
159
+ - Don't use xs for a page's main actions. It's the minimum target size, for dense rows only.
160
+ - Don't try to disable a link (asChild) or give it a loading state. Render the state you want directly instead (a plain span, or a real disabled button) rather than fighting the type error.
161
+
162
+ ## Common mistakes
163
+
164
+ Wrong:
165
+
166
+ ```tsx
167
+ <div>
168
+ <Button variant="secondary">Cancel</Button>
169
+ <Button>Save</Button>
170
+ </div>
171
+ ```
172
+
173
+ Right:
174
+
175
+ ```tsx
176
+ <div className="flex flex-wrap gap-3">
177
+ <Button variant="secondary">Cancel</Button>
178
+ <Button>Save</Button>
179
+ </div>
180
+ ```
181
+
182
+ Buttons render inline-flex, and JSX (the HTML-like syntax React components are written in) drops the whitespace between them, so buttons placed side by side touch. The parent sets the gap. npm run check:button-spacing fails if there isn't one.
183
+
184
+ Wrong:
185
+
186
+ ```tsx
187
+ <Button disabled={saving}>Save</Button>
188
+ ```
189
+
190
+ Right:
191
+
192
+ ```tsx
193
+ <Button loading={saving}>Save</Button>
194
+ ```
195
+
196
+ A busy button stays focusable and tells people it's busy through loading. Native disabled is for an action that isn't available, and it takes the button out of the tab order, so the Tab key skips it.
197
+
198
+ ## Anatomy
199
+
200
+ - **Focus wrapper**: A span that isn't clipped, carrying .bevel-focus-wrap. It draws the ring on :has(:focus-visible), so a mouse click doesn't show it, plus the reticle brackets (corner marks like a targeting sight) that close in on hover. It's always there and never optional.
201
+ - **Button**: The clipped, bevelled control. It never carries its own outline.
202
+ - **Spinner**: Decoration only, shown while loading. The hidden label is what screen readers announce.
203
+ - **Label**: Oxanium semibold, uppercase: the display type role. The link variant switches to the body role, in the signal text colour, underlined.
204
+ - **Icons**: Optional, before and after the label. Each sits in an aria-hidden slot sized to the button (14px at xs and sm, 16px at md, 20px at lg). aria-hidden is an ARIA attribute (Accessible Rich Internet Applications, the labels that tell screen readers what things are) that hides the icon from screen readers. While loading, the spinner takes the leading slot.
@@ -0,0 +1,257 @@
1
+ # Card
2
+
3
+ > A surface that groups related content. It gets the crosshair corners, the visual mark this system uses for a frame around several things. It never gets the cut corner, because cards hold buttons.
4
+
5
+ - **Best for:** Grouping related content on one surface, or one item in a grid whose whole surface is one link.
6
+ - **Not for:** A card inside another card: cards don't nest.
7
+ - Molecule. Source: `src/components/molecules/card/Card.tsx`
8
+ - Rendered docs page: `/components/card`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Card, CardActions, CardBody, CardDescription, CardFooter, CardHeader, CardMedia, CardTitle, cardLinkClass } from 'src/components/molecules/card/Card'
14
+ ```
15
+
16
+ Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.
17
+
18
+ ## Props
19
+
20
+ | Prop | Type | Default | Description |
21
+ |---|---|---|---|
22
+ | `as` | `'article' \| 'section' \| 'div'` | `'article'` | article for an item that stands on its own, section for a group inside a page, div for neither. |
23
+ | `interactive` | `boolean` | `false` | Says the whole card is one click target, through a link or button stretched over it. Adds the hover and focus border and a 4px lift. It adds no role and no Tab stop: the link inside is still the control. |
24
+ | `level (CardTitle)` | `2 \| 3 \| 4` | `3` | The heading level, set on CardTitle only. Card itself takes no level. The page decides its own outline. |
25
+ | `src (CardMedia)` | `string` | | The picture, drawn with object-cover (it fills the box and crops the overflow) and loaded lazily (only when it's about to scroll into view). Leave it out to show CardMedia's children instead, such as a placeholder. |
26
+ | `alt (CardMedia)` | `string` | `''` | The alt text screen readers read for the picture. Empty by default: the picture is decorative because the card title already names it. Write real text only when the picture says something the title doesn't. |
27
+ | `aspect (CardMedia)` | `'16/9' \| '4/3' \| '1/1'` | `'16/9'` | The shape of the box the picture is cropped to. |
28
+ | `className` | `string` | | Merged onto any part. |
29
+ | `...rest` | `HTMLAttributes<...>` | | Standard HTML attributes on every part, except aria-labelledby on Card: the Card points that at its CardTitle itself, and the types won't accept one passed in. Give CardTitle an id only if something else needs it. |
30
+ | `ref` | `Ref<HTMLElement>` | | Passed through to the Card root DOM element. |
31
+
32
+ ## Types and helpers
33
+
34
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
35
+
36
+ ```ts
37
+ /** The stretched link an interactive card needs, for the `a` (or button) in its title: the link
38
+ * is the name's own, its `::after` covers the card, and the focus ring is drawn there, around the
39
+ * whole card. The shape ProductCard uses. Exported so a consumer never copies the string. */
40
+ export const cardLinkClass =
41
+ "outline-none hover:underline underline-offset-2 after:absolute after:inset-0 after:content-[''] focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-[var(--color-focus-ring)]"
42
+ ```
43
+
44
+ ## Examples
45
+
46
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
47
+
48
+ ### Depot
49
+
50
+ ```tsx
51
+ function Depot({ title = 'North field depot' }: { title?: string }) {
52
+ return (
53
+ <Card>
54
+ <CardHeader>
55
+ <CardTitle>{title}</CardTitle>
56
+ <CardDescription>Open until 18:00.</CardDescription>
57
+ </CardHeader>
58
+ <CardBody>Twelve kits ready for collection.</CardBody>
59
+ <CardFooter>
60
+ <Button size="sm" variant="ghost">
61
+ Details
62
+ </Button>
63
+ <Button size="sm">Collect</Button>
64
+ </CardFooter>
65
+ </Card>
66
+ )
67
+ }
68
+
69
+ <div className="max-w-sm w-full">
70
+ <Depot />
71
+ </div>
72
+ ```
73
+
74
+ ### With an image: edge to edge, no padding around it. The title already names the picture, so its alt text (the description a screen reader reads out) is empty
75
+
76
+ ```tsx
77
+ <div className="max-w-sm w-full">
78
+ <Card>
79
+ <CardMedia src="/media/hero-poster-960.webp" aspect="16/9" />
80
+ <CardHeader className="pt-5">
81
+ <CardTitle>Range hall depot</CardTitle>
82
+ <CardDescription>Open until 18:00.</CardDescription>
83
+ </CardHeader>
84
+ <CardBody>Twelve kits ready for collection.</CardBody>
85
+ </Card>
86
+ </div>
87
+ ```
88
+
89
+ ### A row of three
90
+
91
+ ```tsx
92
+ function Depot({ title = 'North field depot' }: { title?: string }) {
93
+ return (
94
+ <Card>
95
+ <CardHeader>
96
+ <CardTitle>{title}</CardTitle>
97
+ <CardDescription>Open until 18:00.</CardDescription>
98
+ </CardHeader>
99
+ <CardBody>Twelve kits ready for collection.</CardBody>
100
+ <CardFooter>
101
+ <Button size="sm" variant="ghost">
102
+ Details
103
+ </Button>
104
+ <Button size="sm">Collect</Button>
105
+ </CardFooter>
106
+ </Card>
107
+ )
108
+ }
109
+
110
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full">
111
+ <Depot title="West site depot" />
112
+ <Card>
113
+ <CardHeader>
114
+ <CardTitle>South gate depot</CardTitle>
115
+ <CardDescription>Open until 20:00.</CardDescription>
116
+ </CardHeader>
117
+ <CardBody>Four kits ready for collection, three on order.</CardBody>
118
+ <CardFooter>
119
+ <Button size="sm" variant="ghost">
120
+ Details
121
+ </Button>
122
+ <Button size="sm">Collect</Button>
123
+ </CardFooter>
124
+ </Card>
125
+ <Card>
126
+ <CardHeader>
127
+ <CardTitle>Harbour depot</CardTitle>
128
+ <CardDescription>Closed today.</CardDescription>
129
+ </CardHeader>
130
+ <CardBody>Nothing ready. Next opening tomorrow, 08:00.</CardBody>
131
+ <CardFooter>
132
+ <Button size="sm" variant="secondary">
133
+ Notify me
134
+ </Button>
135
+ </CardFooter>
136
+ </Card>
137
+ </div>
138
+ ```
139
+
140
+ ### Actions inside the content, no action bar: left-aligned, primary first, secondary after
141
+
142
+ ```tsx
143
+ <div className="max-w-sm w-full">
144
+ <Card>
145
+ <CardHeader>
146
+ <CardTitle>Marsh depot</CardTitle>
147
+ <CardDescription>Open until 17:00.</CardDescription>
148
+ </CardHeader>
149
+ <CardBody>
150
+ Twelve kits ready for collection. Bring your booking number.
151
+ <CardActions>
152
+ <Button size="sm">Collect</Button>
153
+ <Button size="sm" variant="secondary">
154
+ Details
155
+ </Button>
156
+ </CardActions>
157
+ </CardBody>
158
+ </Card>
159
+ </div>
160
+ ```
161
+
162
+ ### Interactive: the whole card is one link. Hover over it, or press Tab to reach it
163
+
164
+ ```tsx
165
+ function DepotLink({ title, note, href }: { title: string; note: string; href: string }) {
166
+ return (
167
+ <Card interactive className="h-full">
168
+ <CardBody className="flex flex-col gap-1">
169
+ <CardTitle>
170
+ <a href={href} className={cardLinkClass}>
171
+ {title}
172
+ </a>
173
+ </CardTitle>
174
+ <CardDescription>{note}</CardDescription>
175
+ </CardBody>
176
+ </Card>
177
+ )
178
+ }
179
+
180
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full">
181
+ <DepotLink title="Ridge depot" note="Open until 18:00. Twelve kits ready." href="#ridge-depot" />
182
+ <DepotLink title="Quarry depot" note="Open until 20:00. Four kits ready." href="#quarry-depot" />
183
+ <DepotLink title="Mill depot" note="Closed today. Opens tomorrow, 08:00." href="#mill-depot" />
184
+ </div>
185
+ ```
186
+
187
+ ### As a section
188
+
189
+ ```tsx
190
+ <div className="max-w-sm w-full">
191
+ <Card as="section">
192
+ <CardHeader>
193
+ <CardTitle level={2}>Totals</CardTitle>
194
+ <CardDescription>This week, all depots.</CardDescription>
195
+ </CardHeader>
196
+ <CardBody>128 kits collected, 6 outstanding.</CardBody>
197
+ </Card>
198
+ </div>
199
+ ```
200
+
201
+ ## Keyboard
202
+
203
+ | Keys | Result |
204
+ |---|---|
205
+ | `Tab` | Moves through the card's own buttons and links. The card itself isn't a stop, interactive or not. Focus on anything inside an interactive card lights its border and lifts it 4px. |
206
+
207
+ ## Do
208
+
209
+ - Put actions in CardFooter when the card ends in an action bar. It's a right-aligned row with the gap between buttons already set and the primary last. A fullWidth button fills it.
210
+ - Put actions in CardActions, inside CardBody, when the button belongs to the text above it and there should be no bar. Primary first, then the secondary.
211
+ - Wherever a card has a primary button, put a secondary or ghost button beside it (Details, Cancel), unless the card really has only one action.
212
+ - Give every card a title, even a short one. It names the card for screen readers.
213
+ - Put CardMedia first in the card, and give a row of cards the same aspect, so the titles line up.
214
+ - Give every card in a row the same parts, so they line up.
215
+ - Use section for a group inside a page and article for an item that stands on its own.
216
+ - Set interactive on a card whose whole surface is one link, and only on those: a product, a category tile.
217
+ - Make the click target a real link stretched over the card from its title, so the name, the focus ring and middle-click all belong to the link.
218
+
219
+ ## Don't
220
+
221
+ - Don't make the whole card a link by wrapping it in an a element. That puts clickable things inside a clickable thing. Stretch the title's link over the card and set interactive.
222
+ - Don't set interactive on a card that only holds a button or a link somewhere inside it. The lit border promises that a click anywhere does something.
223
+ - Don't add your own hover border, lift or glow to a card. There's one prop and one treatment, so every clickable card responds the same way.
224
+ - Don't clip an interactive card: it lifts 4px and its crosshair arms reach 5px outside it. Carousel leaves that room by itself.
225
+ - Don't put CardActions and CardFooter in the same card. Two places to act, with opposite alignment and order, read as two different decisions.
226
+ - Don't right-align CardActions or put the primary last in it. The two parts differ on purpose: the bar is on the right with the primary last, the inline row is on the left with the primary first.
227
+ - Don't pad around a picture to fake CardMedia, or wrap it in CardBody. The picture runs edge to edge, so it goes directly in the Card.
228
+ - Don't bevel a card.
229
+ - Don't nest cards.
230
+
231
+ ## Common mistakes
232
+
233
+ Wrong:
234
+
235
+ ```tsx
236
+ <Card className="border-2 border-[var(--color-accent-signal-fill)]">
237
+ ```
238
+
239
+ Right:
240
+
241
+ ```tsx
242
+ <Card style={{ border: '2px solid var(--color-accent-signal-fill)' }}>
243
+ ```
244
+
245
+ The panel sets its own border outside any CSS cascade layer, so a border utility class never wins. To highlight or select a card, set the border inline, using a token (one of the system's named design values).
246
+
247
+ ## Anatomy
248
+
249
+ - **Card**: The root. An article element by default, named by its title. Uses the .panel and .crosshair classes.
250
+ - **Interactive state**: Only with interactive. On hover, and while anything inside has keyboard focus, the thin border turns accent.signal.fill and the whole card slides up 4px (four pixels). It slides back down when the pointer leaves or focus moves on. It's a CSS transform, so nothing around it moves. When someone has turned on reduced motion (prefers-reduced-motion), the border still changes but the card doesn't move.
251
+ - **CardMedia**: A picture across the full width of the card, flush with the top and side edges inside the border, cropped to an aspect ratio. It's the first child, before CardHeader or CardBody. Without a src, its children fill the box instead (a placeholder).
252
+ - **CardHeader**: Groups the title and description.
253
+ - **CardTitle**: A heading at whatever level the page needs, 3 by default.
254
+ - **CardDescription**: A short secondary line under the title.
255
+ - **CardBody**: Grows to fill the space, so the footers in a row of cards line up.
256
+ - **CardFooter**: The action bar: right-aligned, with the primary button last (rightmost), above a thin line that runs edge to edge. It's where a task ends, the same position as DialogFooter.
257
+ - **CardActions**: Actions inside the content, placed in CardBody after the text. A row that wraps, left-aligned, primary first and secondary after it, with no line and no bar. It's on the left because people read a body down its left edge, so the button stays next to the text it acts on at any card width.