division-ds 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/AGENTS.md +16 -2
  2. package/README.md +11 -0
  3. package/division-ds-0.2.0.tgz +0 -0
  4. package/package.json +2 -2
  5. package/public/docs/components/accordion.md +205 -0
  6. package/public/docs/components/ai-label.md +88 -0
  7. package/public/docs/components/alert-dialog.md +290 -0
  8. package/public/docs/components/alert.md +173 -0
  9. package/public/docs/components/approval-request.md +146 -0
  10. package/public/docs/components/auth-form.md +130 -0
  11. package/public/docs/components/avatar-group.md +150 -0
  12. package/public/docs/components/avatar.md +177 -0
  13. package/public/docs/components/badge.md +176 -0
  14. package/public/docs/components/breadcrumbs.md +110 -0
  15. package/public/docs/components/button.md +202 -0
  16. package/public/docs/components/card.md +255 -0
  17. package/public/docs/components/carousel.md +128 -0
  18. package/public/docs/components/cart.md +170 -0
  19. package/public/docs/components/chart-panel.md +356 -0
  20. package/public/docs/components/chat-message.md +154 -0
  21. package/public/docs/components/chat-thread.md +242 -0
  22. package/public/docs/components/checkbox.md +147 -0
  23. package/public/docs/components/checkout-form.md +126 -0
  24. package/public/docs/components/command-palette.md +145 -0
  25. package/public/docs/components/data-table.md +399 -0
  26. package/public/docs/components/description-list.md +101 -0
  27. package/public/docs/components/dialog.md +195 -0
  28. package/public/docs/components/divider.md +95 -0
  29. package/public/docs/components/dropdown-menu.md +220 -0
  30. package/public/docs/components/empty-state.md +143 -0
  31. package/public/docs/components/footer.md +395 -0
  32. package/public/docs/components/form-field.md +213 -0
  33. package/public/docs/components/icon-button.md +125 -0
  34. package/public/docs/components/input.md +198 -0
  35. package/public/docs/components/kanban-board.md +298 -0
  36. package/public/docs/components/kbd.md +70 -0
  37. package/public/docs/components/link.md +96 -0
  38. package/public/docs/components/list-item.md +238 -0
  39. package/public/docs/components/loading-animation.md +70 -0
  40. package/public/docs/components/multi-select.md +200 -0
  41. package/public/docs/components/navbar.md +142 -0
  42. package/public/docs/components/newsletter-form.md +110 -0
  43. package/public/docs/components/page-header.md +149 -0
  44. package/public/docs/components/pagination.md +210 -0
  45. package/public/docs/components/product-card.md +169 -0
  46. package/public/docs/components/progress.md +99 -0
  47. package/public/docs/components/prompt-input.md +149 -0
  48. package/public/docs/components/quantity-stepper.md +138 -0
  49. package/public/docs/components/radio.md +140 -0
  50. package/public/docs/components/rating.md +104 -0
  51. package/public/docs/components/score-ring.md +93 -0
  52. package/public/docs/components/search.md +114 -0
  53. package/public/docs/components/section-heading.md +111 -0
  54. package/public/docs/components/segmented-control.md +124 -0
  55. package/public/docs/components/select.md +222 -0
  56. package/public/docs/components/sheet.md +143 -0
  57. package/public/docs/components/sidebar.md +191 -0
  58. package/public/docs/components/skeleton.md +133 -0
  59. package/public/docs/components/slider.md +112 -0
  60. package/public/docs/components/sparkline.md +126 -0
  61. package/public/docs/components/spinner.md +96 -0
  62. package/public/docs/components/stat.md +104 -0
  63. package/public/docs/components/status-tracker.md +143 -0
  64. package/public/docs/components/stepper.md +145 -0
  65. package/public/docs/components/switch.md +151 -0
  66. package/public/docs/components/tabs.md +178 -0
  67. package/public/docs/components/tag.md +103 -0
  68. package/public/docs/components/textarea.md +146 -0
  69. package/public/docs/components/timeline.md +150 -0
  70. package/public/docs/components/toast.md +192 -0
  71. package/public/docs/components/tool-call.md +105 -0
  72. package/public/docs/components/tooltip.md +106 -0
  73. package/public/manifest/components.json +189 -7
  74. package/public/manifest/tokens.json +39 -0
  75. package/scripts/division-ds.mjs +76 -37
  76. package/scripts/lib/agent-docs.mjs +59 -2
  77. package/scripts/lib/ai-copy.mjs +106 -0
  78. package/scripts/lib/button-spacing.mjs +5 -5
  79. package/scripts/lib/check-text.mjs +44 -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 +115 -0
  83. package/scripts/lib/mcp-tools.mjs +241 -0
  84. package/scripts/lib/props-validator.mjs +1 -1
  85. package/scripts/lib/rules-installer.mjs +42 -4
  86. package/skills/division-ds/SKILL.md +90 -0
  87. package/skills/division-ds/commands/add.md +25 -0
  88. package/skills/division-ds/commands/build.md +46 -0
  89. package/skills/division-ds/commands/fix.md +28 -0
  90. package/skills/division-ds/commands/review.md +25 -0
  91. package/skills/division-ds/topics/ai.md +7 -0
  92. package/skills/division-ds/topics/colour.md +11 -0
  93. package/skills/division-ds/topics/feedback.md +9 -0
  94. package/skills/division-ds/topics/forms.md +19 -0
  95. package/skills/division-ds/topics/layout.md +34 -0
  96. package/skills/division-ds/topics/motion.md +10 -0
  97. package/skills/division-ds/topics/overlays.md +9 -0
@@ -0,0 +1,177 @@
1
+ # Avatar
2
+
3
+ > A small square that shows who someone is. The name is required, because it's both the alt text (the description screen readers read for an image) and the source of the initials. Without one, an avatar is just an image nobody can identify.
4
+
5
+ - **Best for:** Showing who a person is, next to their name, as in a roster row, a comment or an account menu.
6
+ - **Not for:** Several people in one spot: use AvatarGroup.
7
+ - Atom. Source: `src/components/atoms/avatar/Avatar.tsx`
8
+ - Rendered docs page: `/components/avatar`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Avatar, avatarVariants, initialsFrom } from 'src/components/atoms/avatar/Avatar'
14
+ ```
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `name` | `string` | | Required. The source of the fallback initials and the announced name. |
21
+ | `src` | `string` | | Optional image. If it can't load, the fallback takes its place without the layout jumping. |
22
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 24 / 36 / 48px. |
23
+ | `status` | `'online' \| 'away' \| 'busy' \| 'offline'` | | Whether the person is around. It adds the dot and adds the word to the announced name. Leave it out when you don't know. |
24
+ | `className` | `string` | | Merged with the component's classes. When Tailwind utility classes clash, the later one wins. |
25
+ | `ref` | `Ref<HTMLSpanElement>` | | Passed through to the root element. |
26
+ | `...rest` | `Omit<HTMLAttributes<HTMLSpanElement>, 'aria-label' \| 'children'>` | | Every native span prop except aria-label and children. aria-label is removed from the types rather than quietly overwritten when the page runs. The announced name comes from the sr-only span, and an aria-label on the root would override it. children is removed because the component renders its own image/fallback/sr-only children, so anything passed as children would silently disappear. |
27
+
28
+ ## Types and helpers
29
+
30
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
31
+
32
+ ```ts
33
+ /** First letter of the first two words, uppercased.
34
+ *
35
+ * `[...word][0]`, not `word[0]`: a string index yields a UTF-16 code unit, so a name whose first
36
+ * character is outside the Basic Multilingual Plane (an emoji, an extended CJK ideograph, a
37
+ * mathematical or historic-script letter) returns a lone high surrogate — an unpaired half of a
38
+ * character, which renders as a replacement glyph. Spreading iterates by code point instead. */
39
+ export function initialsFrom(name: string)
40
+ ```
41
+
42
+ ```ts
43
+ export const avatarVariants = cva( …
44
+ ```
45
+
46
+ ```ts
47
+ /** Every native span prop except `aria-label` is available — a molecule composing an Avatar can
48
+ * attach a `data-testid` or extra `aria-*`/`data-*` attributes. `aria-label` is typed `never`
49
+ * rather than merely omitted: Avatar's whole guarantee is that the announced name comes from the
50
+ * `sr-only` span's content and is identical whether or not the image loads. An `aria-label` on
51
+ * the root would silently outrank that content-derived name in the accessibility tree (an
52
+ * element's `aria-label` always wins over its text content when the accessible name is
53
+ * computed), defeating the one thing this component promises. `Omit<>` alone is a no-op here —
54
+ * TypeScript's JSX checker never treats an `aria-*`/`data-*`-prefixed attribute as an
55
+ * excess/unknown property — so the key must be redeclared with type `never` to actually be
56
+ * enforced. Same remedy as `IconButton`'s `aria-label` exclusion (see `IconButton.tsx`).
57
+ *
58
+ * `children` is excluded for a plainer reason: `HTMLAttributes` offers it, but this component
59
+ * renders its own image/fallback/`sr-only` children, so JSX children on the element always win
60
+ * and a `children` prop is silently discarded — reachable, typed, and dead. It is an ordinary
61
+ * key, so the `Omit` does the rejecting and the declared `never` only makes the error legible;
62
+ * both are shipped, matching `IconButton`. */
63
+ export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline'
64
+ ```
65
+
66
+ ## Examples
67
+
68
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
69
+
70
+ ### Sizes
71
+
72
+ ```tsx
73
+ <Avatar name="Dana Iverson" size="sm" />
74
+ <Avatar name="Dana Iverson" size="md" />
75
+ <Avatar name="Dana Iverson" size="lg" />
76
+ ```
77
+
78
+ ### With an image
79
+
80
+ ```tsx
81
+ const DEMO_IMAGE =
82
+ 'data:image/svg+xml;utf8,' +
83
+ encodeURIComponent(
84
+ '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">' +
85
+ '<rect width="64" height="32" fill="#7c9c6b"/>' + // token-discipline: allow, pixels of a sample photo, not interface colour
86
+ '<rect y="32" width="64" height="32" fill="#3f5a49"/>' +
87
+ '</svg>'
88
+ )
89
+
90
+ <Avatar name="Dana Iverson" src={DEMO_IMAGE} size="lg" />
91
+ ```
92
+
93
+ ### Fallback initials
94
+
95
+ ```tsx
96
+ <Avatar name="Dana Iverson" size="lg" />
97
+ <Avatar name="Ravi Okonkwo" size="lg" />
98
+ <Avatar name="Lin" size="lg" />
99
+ ```
100
+
101
+ ### Status: the dot is decoration, and the status word is part of the avatar's name
102
+
103
+ ```tsx
104
+ const STATUS_TEAM = [
105
+ { name: 'Ilse Varga', status: 'online' },
106
+ { name: 'Ravi Okonkwo', status: 'away' },
107
+ { name: 'Mei Tanaka', status: 'busy' },
108
+ { name: 'Sana Idrissi', status: 'offline' },
109
+ ] as const
110
+
111
+ <div className="flex flex-col gap-3">
112
+ {STATUS_TEAM.map(member => (
113
+ <div key={member.name} className="flex items-center gap-3">
114
+ <Avatar name={member.name} status={member.status} size="md" />
115
+ <div>
116
+ <p className="text-sm">{member.name}</p>
117
+ {/* Shown as text too, so a sighted user never has to decode the colour. Hidden
118
+ from assistive tech, which already heard it in the avatar's name. */}
119
+ <p aria-hidden="true" className="text-sm text-[var(--color-text-secondary)] capitalize">
120
+ {member.status}
121
+ </p>
122
+ </div>
123
+ </div>
124
+ ))}
125
+ </div>
126
+ ```
127
+
128
+ ### A row
129
+
130
+ ```tsx
131
+ const FIELD_TEAM = [
132
+ { name: 'Dana Iverson', role: 'Route Lead' },
133
+ { name: 'Ravi Okonkwo', role: 'Comms' },
134
+ { name: 'Mei Tanaka', role: 'Medic' },
135
+ { name: 'Sana Idrissi', role: 'Logistics' },
136
+ ]
137
+
138
+ <div className="flex flex-col gap-3">
139
+ {FIELD_TEAM.map(member => (
140
+ <div key={member.name} className="flex items-center gap-3">
141
+ <Avatar name={member.name} size="md" />
142
+ <div>
143
+ <p className="text-sm">{member.name}</p>
144
+ <p className="text-xs text-[var(--color-text-muted)]">{member.role}</p>
145
+ </div>
146
+ </div>
147
+ ))}
148
+ </div>
149
+ ```
150
+
151
+ ## Keyboard
152
+
153
+ Not focusable. No keyboard behaviour of its own.
154
+
155
+ ## Do
156
+
157
+ - Pass the full name, not the initials. The component works those out itself.
158
+ - Let it fall back. The fallback is part of the design, not an error state.
159
+ - Use sm inside dense rows and tables.
160
+ - Set status only where presence is live and matters (a roster, a chat). A dot that's out of date is worse than no dot.
161
+
162
+ ## Don't
163
+
164
+ - Don't round it. The system has no round corners, and an avatar isn't the place to start.
165
+ - Don't add the cut corner to the sm avatar. It's 24px, the same size Kbd leaves out by name. An 8px cut takes a third of that edge, and it looks like a broken square instead of a shaped one.
166
+ - Don't build an overlapping stack by hand. AvatarGroup does it, with a '+N' that's real text.
167
+ - Don't explain the dot colours only in a legend. The status word is already in the name, so show it as text where sighted users need it too.
168
+ - Don't use an avatar as the only label for a person. Put the name next to it in dense UI (user interface).
169
+ - Don't pass aria-label. It's removed from the types so nothing can override it, and name already decides the one label that gets announced.
170
+
171
+ ## Anatomy
172
+
173
+ - **Root**: A border.hairline-strong edge on the sunken surface, with overflow clipped and a cut bottom-right corner at md and lg.
174
+ - **Image**: alt is empty on purpose, because the hidden name already does the announcing.
175
+ - **Fallback**: Initials worked out from the name, aria-hidden, shown when there's no image or it fails to load. aria-hidden is an ARIA attribute (Accessible Rich Internet Applications, the labels that tell screen readers what things are) that hides something from screen readers.
176
+ - **Hidden name**: sr-only (visible to screen readers only) and always there, so the announcement is the same either way. With a status it reads "Ilse Varga, online".
177
+ - **Status dot**: Optional. A square in the top-right corner (the bottom-right is the cut), in accent.ok.text, accent.warning.text, accent.hostile.text or text.muted, with a 2px (pixel) surface.base keyline around it. It's aria-hidden because the status is announced in the name.
@@ -0,0 +1,176 @@
1
+ # Badge
2
+
3
+ > A small chip that shows a status. It comes in six tones and two emphases, and it always carries a word. A colour on its own isn't a status anyone can read.
4
+
5
+ - **Best for:** You need to label a status with a word and a tone, like an order state or a sync result.
6
+ - **Not for:** A filter or selection someone can remove. Use Tag for that.
7
+ - Atom. Source: `src/components/atoms/badge/Badge.tsx`
8
+ - Rendered docs page: `/components/badge`
9
+
10
+ ## Import
11
+
12
+ ```tsx
13
+ import { Badge, badgeVariants } from 'src/components/atoms/badge/Badge'
14
+ ```
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `tone` | `'neutral' \| 'signal' \| 'ok' \| 'warning' \| 'hostile' \| 'info'` | `'neutral'` | The status the badge shows. |
21
+ | `emphasis` | `'solid' \| 'subtle'` | `'subtle'` | Solid uses the invariant fill (the same in light and dark themes) with its matching ink. Subtle is the outline badge: a 1px border and text in the tone’s theme-adaptive text token, with no fill. There's no separate outline emphasis, because it would be this same look under a second name. |
22
+ | `size` | `'sm' \| 'md'` | `'sm'` | sm (small) is the chip for everyday interface: rows, cards, tables. md (medium) takes the small control height (32px) and the 14px text step. Use it for a badge next to display-size type, like a price. |
23
+ | `dot` | `boolean` | `false` | Adds a marker next to the text. |
24
+ | `icon` | `ReactNode` | | An icon before the text, hidden from screen readers with aria-hidden and sized to the badge. It takes the badge’s colour, so the same contrast pair that covers the text covers it too. It adds to the word. It never replaces it. |
25
+ | `className` | `string` | | Merged with the component classes. If two clash, the later Tailwind utility wins (Tailwind is the CSS framework this system styles with). |
26
+ | `ref` | `Ref<HTMLSpanElement>` | | Passed through to the span element. |
27
+ | `...rest` | `HTMLAttributes<HTMLSpanElement>` | | Any other span attribute. For example, aria-hidden on a count that a label next to it already reads out. |
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 const badgeVariants = cva( …
35
+ ```
36
+
37
+ ## Examples
38
+
39
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
40
+
41
+ ### Tones, subtle (the outline badge)
42
+
43
+ ```tsx
44
+ const TONES = ['neutral', 'signal', 'ok', 'warning', 'hostile', 'info'] as const
45
+
46
+ {TONES.map(tone => (
47
+ <Badge key={tone} tone={tone} emphasis="subtle">
48
+ {tone}
49
+ </Badge>
50
+ ))}
51
+ ```
52
+
53
+ ### Tones, solid
54
+
55
+ ```tsx
56
+ const TONES = ['neutral', 'signal', 'ok', 'warning', 'hostile', 'info'] as const
57
+
58
+ {TONES.map(tone => (
59
+ <Badge key={tone} tone={tone} emphasis="solid">
60
+ {tone}
61
+ </Badge>
62
+ ))}
63
+ ```
64
+
65
+ ### With a dot
66
+
67
+ ```tsx
68
+ <Badge tone="ok" dot>
69
+ Nominal
70
+ </Badge>
71
+ <Badge tone="warning" dot>
72
+ Degraded
73
+ </Badge>
74
+ <Badge tone="hostile" dot>
75
+ Offline
76
+ </Badge>
77
+ ```
78
+
79
+ ### With an icon
80
+
81
+ ```tsx
82
+ <Badge tone="ok" icon={<Check />}>
83
+ In stock
84
+ </Badge>
85
+ <Badge tone="warning" icon={<Clock />}>
86
+ Backorder
87
+ </Badge>
88
+ <Badge tone="signal" emphasis="solid" icon={<Zap />}>
89
+ New
90
+ </Badge>
91
+ <Badge tone="info" size="md" icon={<Truck />}>
92
+ Ships today
93
+ </Badge>
94
+ ```
95
+
96
+ ### Sizes
97
+
98
+ ```tsx
99
+ <Badge tone="signal" emphasis="solid">
100
+ Sale
101
+ </Badge>
102
+ <Badge tone="signal" emphasis="solid" size="md">
103
+ Sale
104
+ </Badge>
105
+ <Badge tone="ok" dot>
106
+ In stock
107
+ </Badge>
108
+ <Badge tone="ok" dot size="md">
109
+ In stock
110
+ </Badge>
111
+ ```
112
+
113
+ ### Size md, beside a price
114
+
115
+ ```tsx
116
+ <p className="flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-4xl tabular-nums text-[var(--color-text-primary)]">
117
+ <span>€189.00</span>
118
+ <Badge tone="signal" emphasis="solid" size="md">
119
+ −39%
120
+ </Badge>
121
+ </p>
122
+ ```
123
+
124
+ ### In a row
125
+
126
+ ```tsx
127
+ <div className="flex flex-col gap-2 w-full max-w-xs">
128
+ <div className="flex items-center justify-between">
129
+ <span className="text-sm">Depot Relay</span>
130
+ <Badge tone="ok" emphasis="subtle">
131
+ Nominal
132
+ </Badge>
133
+ </div>
134
+ <div className="flex items-center justify-between">
135
+ <span className="text-sm">North Sensor Array</span>
136
+ <Badge tone="warning" emphasis="subtle">
137
+ Degraded
138
+ </Badge>
139
+ </div>
140
+ <div className="flex items-center justify-between">
141
+ <span className="text-sm">Northgate Beacon</span>
142
+ <Badge tone="hostile" emphasis="solid">
143
+ Offline
144
+ </Badge>
145
+ </div>
146
+ </div>
147
+ ```
148
+
149
+ ## Keyboard
150
+
151
+ Not focusable. No keyboard behaviour of its own.
152
+
153
+ ## Do
154
+
155
+ - Give every badge a word.
156
+ - Go easy on solid. One solid badge in a row reads as the exception. Six read as noise.
157
+ - Keep the tone mapping the same across the product. ok is never ‘in progress’ on one screen and ‘done’ on another.
158
+ - Use size md when the badge sits next to a headline figure, so the two read as one statement.
159
+
160
+ ## Don't
161
+
162
+ - Don't build a badge that's colour only, even with a tooltip.
163
+ - Don't put a badge inside a button. It muddies what the clickable target is.
164
+ - Don't use hostile for something that's only pending.
165
+ - Don't bevel it (give it a cut corner). At 22px a badge is chrome, the frame around content, not an object with its own footprint. Kbd follows the same rule for the same reason.
166
+ - Don't resize a badge with height or text classes. The two sizes are the prop. A third size is a change to the system.
167
+ - Don't use size md in a table row or on a card. It's as tall as a small button, so it starts to look like one.
168
+ - Don't give one badge both an icon and a dot. Pick one marker. Two say the same thing twice.
169
+ - Don't colour the icon yourself. It inherits the badge’s tone, and that's the pair the contrast gate (the automated colour-contrast check) measures.
170
+
171
+ ## Anatomy
172
+
173
+ - **Chip**: Set in mono (a fixed-width typeface), uppercase. That's the tertiary type role. By default it's 22px tall with 11px text (px means pixels); at size md it's 32px tall with 14px text.
174
+ - **Icon**: Optional, and placed before the text. It's aria-hidden (an ARIA attribute, from Accessible Rich Internet Applications, that hides it from screen readers) and uses the current text colour. It's 12px, or 14px at size md.
175
+ - **Dot**: An optional square, also aria-hidden, in the current text colour. It's 6px, or 8px at size md.
176
+ - **Label**: Required. The word is what makes the badge accessible.
@@ -0,0 +1,110 @@
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
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `items` | `Crumb[]` | | { label, href? }. A level with no href shows as plain text. |
21
+ | `maxItems` | `number` | | Collapse the trail when it has more levels than this. It never goes below 3. |
22
+ | `label` | `string` | `'Breadcrumb'` | The name of the nav landmark. |
23
+ | `className` | `string` | | Merged onto the nav. |
24
+ | `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. |
25
+ | `ref` | `Ref<HTMLElement>` | | Passed through to the nav DOM element. |
26
+
27
+ ## Types and helpers
28
+
29
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
30
+
31
+ ```ts
32
+ export type Crumb = { label: string; href?: string }
33
+ ```
34
+
35
+ ```ts
36
+ /** First item, a gap, then the last (maxItems - 1). Three is the floor: below it there is no
37
+ * middle left to collapse and the gap would replace the only context the trail gives. */
38
+ export function collapseCrumbs(items: Crumb[], maxItems?: number): (Crumb | 'gap')[]
39
+ ```
40
+
41
+ ## Examples
42
+
43
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
44
+
45
+ ### Default
46
+
47
+ ```tsx
48
+ const TRAIL = [
49
+ { label: 'Catalogue', href: '/catalogue' },
50
+ { label: 'Optics', href: '/catalogue/optics' },
51
+ { label: 'Holo sights', href: '/catalogue/optics/holo-sights' },
52
+ { label: 'Prism holo sight' },
53
+ ]
54
+
55
+ <Breadcrumbs items={TRAIL} label="Breadcrumb, default example" />
56
+ ```
57
+
58
+ ### Collapsed
59
+
60
+ ```tsx
61
+ const DEPOT_TRAIL = [
62
+ { label: 'Depot', href: '/depot' },
63
+ { label: 'Region: North', href: '/depot/north' },
64
+ { label: 'Site: Fell Beck', href: '/depot/north/fell-beck' },
65
+ { label: 'Building: Stores', href: '/depot/north/fell-beck/stores' },
66
+ { label: 'Bay 4', href: '/depot/north/fell-beck/stores/bay-4' },
67
+ { label: 'Crate 118' },
68
+ ]
69
+
70
+ <Breadcrumbs items={DEPOT_TRAIL} maxItems={3} label="Breadcrumb, collapsed" />
71
+ ```
72
+
73
+ ### A level that is not a page
74
+
75
+ ```tsx
76
+ const MID_LEVEL_TRAIL = [
77
+ { label: 'Catalogue', href: '/catalogue' },
78
+ { label: 'Optics' },
79
+ { label: 'Holo sights', href: '/catalogue/optics/holo-sights' },
80
+ ]
81
+
82
+ <Breadcrumbs items={MID_LEVEL_TRAIL} label="Breadcrumb, mid-level example" />
83
+ ```
84
+
85
+ ## Keyboard
86
+
87
+ | Keys | Result |
88
+ |---|---|
89
+ | `Tab` | Moves through the linked levels in order. The current page isn't a stop. |
90
+
91
+ ## Do
92
+
93
+ - Start from the top of the hierarchy, not from the home page, when the site has one obvious root.
94
+ - Use the page's own heading text as its crumb.
95
+ - Collapse long trails on narrow layouts with maxItems.
96
+
97
+ ## Don't
98
+
99
+ - Don't use breadcrumbs for a sequence of steps. That's Stepper.
100
+ - Don't make the current page a link.
101
+ - Don't put a breadcrumb trail on a page that's only one level deep.
102
+
103
+ ## Anatomy
104
+
105
+ - **nav**: A navigation landmark (a region screen readers can jump to) named 'Breadcrumb'.
106
+ - **ol**: An ordered list, because the order is the information.
107
+ - **Level**: A quiet Link.
108
+ - **Current page**: Text with aria-current=page, which tells screen readers it's the page you're on. Never a link to itself.
109
+ - **Separator**: A slash in monospaced type, marked aria-hidden so screen readers skip it.
110
+ - **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,202 @@
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
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `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. |
21
+ | `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. |
22
+ | `fullWidth` | `boolean` | `false` | Stretches the wrapper as well as the button. |
23
+ | `loading` | `boolean` | `false` | Sets aria-busy and aria-disabled, blocks the click and the form submit, and shows the spinner. The button stays focusable. |
24
+ | `loadingLabel` | `string` | `'Working'` | Text that's hidden on screen but read aloud while loading. |
25
+ | `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. |
26
+ | `iconEnd` | `ReactNode` | | An icon after the label, rendered aria-hidden. It stays while loading. Not available with asChild. |
27
+ | `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. |
28
+ | `wrapperClassName` | `string` | | Classes for the focus wrapper, not the button. |
29
+ | `className` | `string` | | Merged with the component's classes. When Tailwind utility classes clash, the later one wins. |
30
+ | `ref` | `Ref<HTMLButtonElement>` | | Passed through to the button element. |
31
+ | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | A Button never submits a form by accident. Ask for submit on the one that should. |
32
+ | `...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. |
33
+
34
+ ## Types and helpers
35
+
36
+ Exported beside the component, from the same file. Import a type with `import type { ... }`.
37
+
38
+ ```ts
39
+ export const buttonVariants = cva( …
40
+ ```
41
+
42
+ ## Examples
43
+
44
+ Each is compiled and rendered on the docs page. Imports are omitted; see above.
45
+
46
+ ### Variants
47
+
48
+ ```tsx
49
+ <Button variant="primary">Dispatch team</Button>
50
+ <Button variant="secondary">Save draft</Button>
51
+ <Button variant="danger">Abort</Button>
52
+ <Button variant="ghost">Cancel</Button>
53
+ ```
54
+
55
+ ### Sizes
56
+
57
+ ```tsx
58
+ <Button size="xs">Dispatch team</Button>
59
+ <Button size="sm">Dispatch team</Button>
60
+ <Button size="md">Dispatch team</Button>
61
+ <Button size="lg">Dispatch team</Button>
62
+ ```
63
+
64
+ ### States
65
+
66
+ ```tsx
67
+ <Button>Dispatch team</Button>
68
+ <Button loading loadingLabel="Dispatching">
69
+ Dispatch team
70
+ </Button>
71
+ <Button disabled>Dispatch team</Button>
72
+ ```
73
+
74
+ ### With icons
75
+
76
+ ```tsx
77
+ <Button iconStart={<Plus />}>Add to loadout</Button>
78
+ <Button variant="secondary" iconEnd={<ArrowRight />}>
79
+ Continue
80
+ </Button>
81
+ <Button variant="ghost" size="sm" iconStart={<Download />}>
82
+ Export
83
+ </Button>
84
+ <Button loading loadingLabel="Adding" iconStart={<Plus />}>
85
+ Add to loadout
86
+ </Button>
87
+ ```
88
+
89
+ ### Link variant
90
+
91
+ ```tsx
92
+ <p className="text-sm text-[var(--color-text-secondary)] max-w-md">
93
+ No replicas match these filters.{' '}
94
+ <Button variant="link">
95
+ Reset filters
96
+ </Button>{' '}
97
+ to see the full catalogue.
98
+ </p>
99
+ ```
100
+
101
+ ### Full width
102
+
103
+ ```tsx
104
+ <div className="w-80">
105
+ <Button fullWidth>Dispatch team</Button>
106
+ </div>
107
+ ```
108
+
109
+ ### As a link
110
+
111
+ ```tsx
112
+ <Button asChild>
113
+ <a href="/kits">Browse kits</a>
114
+ </Button>
115
+ ```
116
+
117
+ ### Focus ring is never clipped
118
+
119
+ ```tsx
120
+ <div className="flex flex-col gap-4">
121
+ <p className="text-sm text-[var(--color-text-secondary)] max-w-md">
122
+ Press Tab to focus the button below, in either theme. The focus ring (the outline
123
+ that shows which element the keyboard is on) draws a clean rectangle around the whole
124
+ bevelled shape. You'll never see it cut off, because the component draws it on the
125
+ outer wrapper, which isn't clipped, and not on the bevelled button itself.
126
+ </p>
127
+ <Button variant="primary">Dispatch team</Button>
128
+ </div>
129
+ ```
130
+
131
+ ## Keyboard
132
+
133
+ | Keys | Result |
134
+ |---|---|
135
+ | `Tab` | Moves focus to the button. The ring appears on the wrapper. |
136
+ | `Enter` | Activates it. While loading it does nothing, and the caller's onClick doesn't run. |
137
+ | `Space` | Activates it. While loading it does nothing, and the caller's onClick doesn't run. |
138
+
139
+ ## Do
140
+
141
+ - One primary button per view. The signal colour only means something if it's rare.
142
+ - Use asChild for a link that should look like a button, so it stays a real link.
143
+ - Say what the button does: ‘Dispatch team’, not ‘Submit’.
144
+ - Use loading instead of disabling the button and swapping the label yourself.
145
+ - 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.
146
+ - Pass icons through iconStart and iconEnd, not as children. That way they stay hidden from assistive tech and get sized with the button.
147
+ - 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.
148
+
149
+ ## Don't
150
+
151
+ - Don't add bevel-* or an outline class yourself. You'll clip the ring off.
152
+ - Don't use danger for anything that can be undone.
153
+ - Don't put a ghost button next to a secondary one. People can't tell which matters more any more.
154
+ - Don't disable a button without saying somewhere why it's disabled.
155
+ - 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.
156
+ - Don't make an icon-only button by passing iconStart with no label. That's IconButton, which requires a name.
157
+ - Don't use xs for a page's main actions. It's the minimum target size, for dense rows only.
158
+ - 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.
159
+
160
+ ## Common mistakes
161
+
162
+ Wrong:
163
+
164
+ ```tsx
165
+ <div>
166
+ <Button variant="secondary">Cancel</Button>
167
+ <Button>Save</Button>
168
+ </div>
169
+ ```
170
+
171
+ Right:
172
+
173
+ ```tsx
174
+ <div className="flex flex-wrap gap-3">
175
+ <Button variant="secondary">Cancel</Button>
176
+ <Button>Save</Button>
177
+ </div>
178
+ ```
179
+
180
+ 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.
181
+
182
+ Wrong:
183
+
184
+ ```tsx
185
+ <Button disabled={saving}>Save</Button>
186
+ ```
187
+
188
+ Right:
189
+
190
+ ```tsx
191
+ <Button loading={saving}>Save</Button>
192
+ ```
193
+
194
+ 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.
195
+
196
+ ## Anatomy
197
+
198
+ - **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.
199
+ - **Button**: The clipped, bevelled control. It never carries its own outline.
200
+ - **Spinner**: Decoration only, shown while loading. The hidden label is what screen readers announce.
201
+ - **Label**: Oxanium semibold, uppercase: the display type role. The link variant switches to the body role, in the signal text colour, underlined.
202
+ - **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.