division-ds 0.5.0 → 0.7.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.
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ installs these rules into that project's `AGENTS.md`, `GEMINI.md` and Cursor rul
51
51
  `.claude/skills/` (Claude Code).
52
52
 
53
53
  An agent that supports MCP can have the docs and the same checks as tools instead:
54
- `claude mcp add division-ds -- npx -y division-ds@0.4.1 mcp` (`list_components`, `get_component`,
54
+ `claude mcp add division-ds -- npx -y division-ds@0.6.0 mcp` (`list_components`, `get_component`,
55
55
  `search`, `get_tokens`, `validate_code`, `check_contrast`, `get_add_command`,
56
56
  `get_review_checklist`).
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "division-ds",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Checks a project that uses Division DS components (raw colours, invented tokens, touching buttons, unknown props), and serves the docs and the same checks to agents over MCP.",
5
5
  "license": "MIT",
6
6
  "author": "Victor Ilioiu",
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- - **Best for:** You need to label a status with a word and a tone, like an order state or a sync result.
5
+ - **Best for:** You need to label a status with a word and a tone, like an order state or a sync result. Or a label someone made, like a project tag: then pass color instead of tone.
6
6
  - **Not for:** A filter or selection someone can remove. Use Tag for that.
7
7
  - Atom. Source: `src/components/atoms/badge/Badge.tsx`
8
8
  - Rendered docs page: `/components/badge`
@@ -10,7 +10,7 @@
10
10
  ## Import
11
11
 
12
12
  ```tsx
13
- import { Badge, badgeVariants } from 'src/components/atoms/badge/Badge'
13
+ import { Badge, badgeVariants, CATEGORY_COLORS } from 'src/components/atoms/badge/Badge'
14
14
  ```
15
15
 
16
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. An install has no index barrels: import each component from its own file, never from `components/molecules`.
@@ -19,7 +19,8 @@ Paths are from the Division DS repository root. Installed with shadcn they start
19
19
 
20
20
  | Prop | Type | Default | Description |
21
21
  |---|---|---|---|
22
- | `tone` | `'neutral' \| 'signal' \| 'ok' \| 'warning' \| 'hostile' \| 'info'` | `'neutral'` | The status the badge shows. |
22
+ | `tone` | `'neutral' \| 'signal' \| 'ok' \| 'warning' \| 'hostile' \| 'info'` | `'neutral'` | The status the badge shows. Leave it out when you pass color. |
23
+ | `color` | `'gray' \| 'brown' \| 'orange' \| 'yellow' \| 'green' \| 'teal' \| 'blue' \| 'purple' \| 'pink' \| 'red'` | | A category colour for a label people make themselves, like a project tag or a topic. It replaces tone: the types reject passing both. A category colour means 'these belong together', never good or bad. The list is exported as CATEGORY_COLORS, so an app can give each new label one at random. Works with both emphases and both sizes. |
23
24
  | `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. |
24
25
  | `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. |
25
26
  | `dot` | `boolean` | `false` | Adds a marker next to the text. |
@@ -36,6 +37,18 @@ Exported beside the component, from the same file. Import a type with `import ty
36
37
  export const badgeVariants = cva( …
37
38
  ```
38
39
 
40
+ ```ts
41
+ /** Categorical colours for labels people make (a project tag, a topic), in the order a picker
42
+ * shows them. Not statuses: a category colour says "same group", never good or bad. Exported so
43
+ * an app can give each new label one, for example
44
+ * `CATEGORY_COLORS[Math.floor(Math.random() * CATEGORY_COLORS.length)]`. */
45
+ export const CATEGORY_COLORS = ['gray', 'brown', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'red'] as const
46
+ ```
47
+
48
+ ```ts
49
+ export type CategoryColor = (typeof CATEGORY_COLORS)[number]
50
+ ```
51
+
39
52
  ## Examples
40
53
 
41
54
  Each is compiled and rendered on the docs page. Imports are omitted; see above.
@@ -64,6 +77,26 @@ const TONES = ['neutral', 'signal', 'ok', 'warning', 'hostile', 'info'] as const
64
77
  ))}
65
78
  ```
66
79
 
80
+ ### Category colours, subtle: labels people make, not statuses
81
+
82
+ ```tsx
83
+ {CATEGORY_COLORS.map(color => (
84
+ <Badge key={color} color={color}>
85
+ {color}
86
+ </Badge>
87
+ ))}
88
+ ```
89
+
90
+ ### Category colours, solid
91
+
92
+ ```tsx
93
+ {CATEGORY_COLORS.map(color => (
94
+ <Badge key={color} color={color} emphasis="solid">
95
+ {color}
96
+ </Badge>
97
+ ))}
98
+ ```
99
+
67
100
  ### With a dot
68
101
 
69
102
  ```tsx
@@ -158,12 +191,15 @@ Not focusable. No keyboard behaviour of its own.
158
191
  - Go easy on solid. One solid badge in a row reads as the exception. Six read as noise.
159
192
  - Keep the tone mapping the same across the product. ok is never ‘in progress’ on one screen and ‘done’ on another.
160
193
  - Use size md when the badge sits next to a headline figure, so the two read as one statement.
194
+ - Use tone for a status the system knows (paid, failed, syncing) and color for a label a person made (Design, Q3, Home). A colour on its own is not a status.
195
+ - Store the colour with the label, so it stays the same on every screen. Pick one from CATEGORY_COLORS when the label is created.
161
196
 
162
197
  ## Don't
163
198
 
164
199
  - Don't build a badge that's colour only, even with a tooltip.
165
200
  - Don't put a badge inside a button. It muddies what the clickable target is.
166
201
  - Don't use hostile for something that's only pending.
202
+ - Don't use a category colour to show a status. A red project label is not an error, and an error is tone="hostile", so it means the same thing on every screen.
167
203
  - 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.
168
204
  - 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.
169
205
  - 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.
@@ -20,6 +20,7 @@ Paths are from the Division DS repository root. Installed with shadcn they start
20
20
  | Prop | Type | Default | Description |
21
21
  |---|---|---|---|
22
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
+ | `tone` | `'signal' \| 'neutral'` | `'signal'` | Only the link variant reads it. signal is the signal-coloured, underlined link. neutral is primary text with no underline until hover or keyboard focus: use it for a clickable title on a kanban card or a list row, where a column of signal links would be too loud. A link-variant label wraps onto more lines rather than overflowing its column. |
23
24
  | `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
25
  | `fullWidth` | `boolean` | `false` | Stretches the wrapper as well as the button. |
25
26
  | `loading` | `boolean` | `false` | Sets aria-busy and aria-disabled, blocks the click and the form submit, and shows the spinner. The button stays focusable. |
@@ -100,6 +101,21 @@ Each is compiled and rendered on the docs page. Imports are omitted; see above.
100
101
  </p>
101
102
  ```
102
103
 
104
+ ### Neutral link: a kanban card title
105
+
106
+ ```tsx
107
+ <div className="w-64 flex items-start gap-2 p-3 border border-[var(--color-border-hairline)] bg-[var(--color-surface-raised)]">
108
+ <div className="min-w-0 flex-1 text-sm">
109
+ <Button variant="link" tone="neutral">
110
+ Replace the hop-up bucking on the second-line support rifles before Saturday
111
+ </Button>
112
+ </div>
113
+ <Button variant="ghost" size="xs">
114
+ Move
115
+ </Button>
116
+ </div>
117
+ ```
118
+
103
119
  ### Full width
104
120
 
105
121
  ```tsx
@@ -147,6 +163,7 @@ Each is compiled and rendered on the docs page. Imports are omitted; see above.
147
163
  - 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
164
  - Pass icons through iconStart and iconEnd, not as children. That way they stay hidden from assistive tech and get sized with the button.
149
165
  - 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.
166
+ - Use variant="link" tone="neutral" for a card or row title that opens something on this page, such as the detail panel of a kanban card.
150
167
 
151
168
  ## Don't
152
169
 
@@ -200,5 +217,5 @@ A busy button stays focusable and tells people it's busy through loading. Native
200
217
  - **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
218
  - **Button**: The clipped, bevelled control. It never carries its own outline.
202
219
  - **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.
220
+ - **Label**: Oxanium semibold, uppercase: the display type role. The link variant switches to the body role, in the signal text colour, underlined (or primary text, underlined on hover and focus, with tone="neutral"), and wraps instead of staying on one line.
204
221
  - **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.
@@ -31,6 +31,7 @@ Paths are from the Division DS repository root. Installed with shadcn they start
31
31
  | `getItemLabel` | `(item: T) => string` | | Required. The card in plain words. The Move control, its menu and every screen reader announcement use it. |
32
32
  | `renderCard` | `(item: T) => ReactNode` | | Required. The card's body. It can hold one link or button. The board draws the frame. |
33
33
  | `renderCardCorner` | `(item: T) => ReactNode` | | A small mark in the card's bottom-right corner, under the Move control, about 2rem square, such as the owner's avatar. Wrap it in aria-hidden (so screen readers skip it) when the card already prints the same name. |
34
+ | `renderCardMenu` | `(item: T, moveTo: (columnId: string) => void, columnId: string) => ReactNode` | | Replaces the Move menu's default items (the card's name and a Move to item for each other column) with your own DropdownMenu parts, such as View, Edit or Delete. They go inside the board's menu. Call moveTo(columnId) to move the card the way the default menu does: to the end of that column, with onMove, the announcement and focus back on the Move control. columnId is the card's current column. |
34
35
  | `onMove` | `(itemId: string, toColumnId: string, toIndex: number) => void` | | Required. Called once per move. toIndex is the card's position in the destination column after the move. A move from the menu puts it last. |
35
36
  | `emptyColumnMessage` | `string` | `'Nothing here'` | Shown in a column with no cards. |
36
37
  | `columnHeadingLevel` | `2 \| 3 \| 4` | `3` | The heading level of the column titles. Pick one that sits under the page's own heading outline. |
@@ -236,6 +237,115 @@ function WorkOrders() {
236
237
  <WorkOrders />
237
238
  ```
238
239
 
240
+ ### Your own card menu, with renderCardMenu
241
+
242
+ ```tsx
243
+ type Order = { id: string; title: string; bay: string; status: 'open' | 'parts' | 'done' }
244
+
245
+ const ORDER_COLUMNS: KanbanColumn[] = [
246
+ { id: 'open', title: 'Open' },
247
+ { id: 'parts', title: 'Waiting for parts' },
248
+ { id: 'done', title: 'Done' },
249
+ ]
250
+
251
+ const ORDERS: Order[] = [
252
+ { id: 'wo-31', title: 'Replace dock door seal', bay: 'Bay 2', status: 'open' },
253
+ { id: 'wo-32', title: 'Recalibrate floor scale', bay: 'Bay 5', status: 'open' },
254
+ { id: 'wo-29', title: 'Service pallet jack', bay: 'Bay 1', status: 'done' },
255
+ ]
256
+
257
+ const orderAccessors = {
258
+ getItemId: (order: Order) => order.id,
259
+ getColumnId: (order: Order) => order.status,
260
+ setColumnId: (order: Order, status: string) => ({ ...order, status: status as Order['status'] }),
261
+ }
262
+
263
+ const ORDER_LABELS = ['Urgent', 'Safety', 'Contractor'] as const
264
+
265
+ function WorkOrdersWithMenu() {
266
+ const [orders, setOrders] = useState(ORDERS)
267
+ const [labels, setLabels] = useState<Record<string, string[]>>({ 'wo-31': ['Safety'] })
268
+ const [note, setNote] = useState('')
269
+ const toggleLabel = (id: string, label: string, on: boolean) =>
270
+ setLabels(current => ({
271
+ ...current,
272
+ [id]: on ? [...(current[id] ?? []), label] : (current[id] ?? []).filter(other => other !== label),
273
+ }))
274
+ return (
275
+ <div className="w-full min-w-0 flex flex-col gap-3">
276
+ <KanbanBoard
277
+ label="Depot work orders, with a card menu"
278
+ columns={ORDER_COLUMNS}
279
+ items={orders}
280
+ getItemId={orderAccessors.getItemId}
281
+ getColumnId={orderAccessors.getColumnId}
282
+ getItemLabel={order => order.title}
283
+ renderCard={order => (
284
+ <div className="flex flex-col gap-1">
285
+ <p className="font-semibold">{order.title}</p>
286
+ <p className="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-text-secondary)]">
287
+ {[order.bay, ...(labels[order.id] ?? [])].join(' · ')}
288
+ </p>
289
+ </div>
290
+ )}
291
+ renderCardMenu={(order, moveTo, columnId) => (
292
+ <>
293
+ <DropdownMenuItem icon={<Eye />} onSelect={() => setNote(`Opened ${order.title}.`)}>
294
+ View
295
+ </DropdownMenuItem>
296
+ <DropdownMenuItem icon={<Pencil />} onSelect={() => setNote(`Editing ${order.title}.`)}>
297
+ Edit
298
+ </DropdownMenuItem>
299
+ <DropdownMenuSub>
300
+ <DropdownMenuSubTrigger icon={<Tag />}>Labels</DropdownMenuSubTrigger>
301
+ <DropdownMenuSubContent>
302
+ {ORDER_LABELS.map(label => (
303
+ <DropdownMenuCheckboxItem
304
+ key={label}
305
+ checked={(labels[order.id] ?? []).includes(label)}
306
+ onCheckedChange={on => toggleLabel(order.id, label, on === true)}
307
+ onSelect={event => event.preventDefault()}
308
+ >
309
+ {label}
310
+ </DropdownMenuCheckboxItem>
311
+ ))}
312
+ </DropdownMenuSubContent>
313
+ </DropdownMenuSub>
314
+ <DropdownMenuSub>
315
+ <DropdownMenuSubTrigger icon={<ArrowRightLeft />}>Move to</DropdownMenuSubTrigger>
316
+ <DropdownMenuSubContent>
317
+ {ORDER_COLUMNS.filter(column => column.id !== columnId).map(column => (
318
+ <DropdownMenuItem key={column.id} onSelect={() => moveTo(column.id)}>
319
+ {column.title}
320
+ </DropdownMenuItem>
321
+ ))}
322
+ </DropdownMenuSubContent>
323
+ </DropdownMenuSub>
324
+ <DropdownMenuSeparator />
325
+ <DropdownMenuItem
326
+ tone="hostile"
327
+ icon={<Trash2 />}
328
+ onSelect={() => {
329
+ setOrders(current => current.filter(other => other.id !== order.id))
330
+ setNote(`Deleted ${order.title}.`)
331
+ }}
332
+ >
333
+ Delete
334
+ </DropdownMenuItem>
335
+ </>
336
+ )}
337
+ onMove={(id, status, index) => setOrders(current => applyKanbanMove(current, id, status, index, orderAccessors))}
338
+ emptyColumnMessage="No orders waiting"
339
+ columnHeadingLevel={4}
340
+ />
341
+ <p className={docsProse}>{note}</p>
342
+ </div>
343
+ )
344
+ }
345
+
346
+ <WorkOrdersWithMenu />
347
+ ```
348
+
239
349
  ## Keyboard
240
350
 
241
351
  | Keys | Result |
@@ -19,7 +19,7 @@ Paths are from the Division DS repository root. Installed with shadcn they start
19
19
 
20
20
  | Prop | Type | Default | Description |
21
21
  |---|---|---|---|
22
- | `tone` | `'signal' \| 'quiet'` | `'signal'` | Which colour treatment to use. |
22
+ | `tone` | `'signal' \| 'quiet' \| 'neutral'` | `'signal'` | Which colour treatment to use. neutral is primary text with no underline until hover or keyboard focus, for a clickable title in a list or on a card, where it is clear from position that the title is a link. Keep signal for links inside running text. |
23
23
  | `external` | `boolean` | `false` | Opens the link in a new tab. It adds target="_blank", the rel hardening (attributes that stop the new page reaching back into yours), a trailing icon and the hidden "(opens in a new tab)" warning. Using it together with target or rel is a type error, so it can't fail silently. external already sets those values, so those props are removed from the type instead of being quietly overwritten while the page runs. |
24
24
  | `href` | `string` | | Where the link goes. |
25
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). |
@@ -59,6 +59,9 @@ Each is compiled and rendered on the docs page. Imports are omitted; see above.
59
59
  <Link href="/kits" tone="quiet">
60
60
  Field kits
61
61
  </Link>
62
+ <Link href="/kits" tone="neutral">
63
+ Field kits
64
+ </Link>
62
65
  ```
63
66
 
64
67
  ### External
@@ -30,6 +30,9 @@ Paths are from the Division DS repository root. Installed with shadcn they start
30
30
  | `onValueChange` | `(value: string[]) => void` | | Runs with the whole new array every time an option is added or removed. |
31
31
  | `placeholder` | `string` | `'Select…'` | Shown while nothing is chosen. |
32
32
  | `emptyMessage` | `string` | `'No matches'` | Shown in the panel when the typed text matches no option. |
33
+ | `creatable` | `boolean` | `false` | Adds one last row, `Create "<text>"`, when the typed text matches no option (ignoring case). It is never shown for empty text or for text that is already an option. It is an ordinary option in the list: the arrows reach it, Enter or a click chooses it, and screen readers read its label. |
34
+ | `onCreate` | `(text: string) => void` | | Runs with the typed text, trimmed, when the create row is chosen. The text field is then cleared. The component does not add anything itself: add the new option to options and its value to value here. |
35
+ | `createLabel` | `(text: string) => string` | `(text) => `Create "${text}"`` | The create row's label, for other wording or another language. |
33
36
  | `inputSize` | `'sm' \| 'md' \| 'lg'` | `'md'` | Matches Input and Select: a minimum height of 32, 40 or 48px (pixels), with text at size xs, sm or base. It also sets the size of the typed text and of the option rows in the list (28, 32 or 40px). The tags come in one size, so sm fits a single row of them snugly, and md and lg add room around it. The field grows past the minimum as tags wrap. |
34
37
  | `invalid` | `boolean` | `false` | Sets aria-invalid (which tells screen readers the value has a problem) and the invalid border. FormField sets it from error. |
35
38
  | `disabled` | `boolean` | `false` | Turns the field off. The chosen tags stay visible, without their remove buttons. |
@@ -94,6 +97,39 @@ function ControlledExample() {
94
97
  <ControlledExample />
95
98
  ```
96
99
 
100
+ ### Creatable: pick or create labels
101
+
102
+ ```tsx
103
+ const LABELS = [
104
+ { value: 'bug', label: 'Bug' },
105
+ { value: 'design', label: 'Design' },
106
+ { value: 'docs', label: 'Docs' },
107
+ ]
108
+
109
+ function CreatableExample() {
110
+ const [options, setOptions] = useState(LABELS)
111
+ const [labels, setLabels] = useState<string[]>(['design'])
112
+ return (
113
+ <div className="w-full max-w-md">
114
+ <FormField label="Labels" help="Type a label that is not in the list, then press Enter to create it.">
115
+ <MultiSelect
116
+ options={options}
117
+ value={labels}
118
+ onValueChange={setLabels}
119
+ creatable
120
+ onCreate={text => {
121
+ setOptions(current => [...current, { value: text, label: text }])
122
+ setLabels(current => [...current, text])
123
+ }}
124
+ />
125
+ </FormField>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ <CreatableExample />
131
+ ```
132
+
97
133
  ### Invalid
98
134
 
99
135
  ```tsx
@@ -161,6 +197,7 @@ const LOADOUT = [
161
197
  |---|---|
162
198
  | `↓` `↑` | Opens the list, then moves the highlight, skipping rows that are turned off. |
163
199
  | `Enter` | Adds or removes the highlighted option. The list stays open. |
200
+ | `Enter` | On the create row (creatable): runs onCreate with the typed text and clears the text field. |
164
201
  | `Backspace` | When the text field is empty, removes the last tag. |
165
202
  | `Esc` | Closes the list. Press it again to clear the typed text. |
166
203
  | `Tab` | Moves through the tags’ remove buttons and the text field, and closes the list on the way out. |
@@ -20,7 +20,7 @@ Paths are from the Division DS repository root. Installed with shadcn they start
20
20
  | Prop | Type | Default | Description |
21
21
  |---|---|---|---|
22
22
  | `label` | `string` | | Required. The name screen readers give the navigation region. |
23
- | `groups` | `SidebarGroup[]` | | Required. Each group has title, icon, items, collapsible and defaultOpen. |
23
+ | `groups` | `SidebarGroup[]` | | Required. Each group has title, icon, items, collapsible and defaultOpen, and optionally action (a small icon button at the right end of the title row, beside the fold toggle, never inside it) and footer (under the items, lined up with them, hidden while the group is folded). |
24
24
  | `header` | `ReactNode` | | Sits above the groups: a product name, a workspace switcher. |
25
25
  | `footer` | `ReactNode` | | Pinned below the groups when the Sidebar has a height (h-full in a column layout): the signed-in account, a help link. |
26
26
  | `renderLink` | `(props: LinkRenderProps) => ReactNode` | `a plain anchor` | Pass your router's link component here. Map href to its own prop, and pass className, aria-current and onClick through. |
@@ -54,6 +54,13 @@ export type SidebarGroup = {
54
54
  collapsible?: boolean
55
55
  /** Ignored when the group holds the current item, which always starts open. @default true */
56
56
  defaultOpen?: boolean
57
+ /** Drawn at the far right of the title row: a small icon button, e.g. a ghost size="sm" Button
58
+ * with a Plus icon and aria-label="New project". It sits beside the fold toggle, never inside it,
59
+ * so pressing it does not open or close the group. The title truncates before it is pushed out. */
60
+ action?: ReactNode
61
+ /** Drawn under the items, inside the group, lined up with the items' left edge and padding: an
62
+ * "Add project" button. Hidden (and unreachable by Tab) while the group is folded. */
63
+ footer?: ReactNode
57
64
  }
58
65
  ```
59
66
 
@@ -104,6 +111,36 @@ const FOOTER = <p className="text-xs text-[var(--color-text-secondary)]">Signed
104
111
  </div>
105
112
  ```
106
113
 
114
+ ### A Projects group with an action in its title row and a footer under its items
115
+
116
+ ```tsx
117
+ const PROJECT_GROUPS: SidebarGroup[] = [
118
+ {
119
+ title: 'Projects',
120
+ icon: <FolderKanban />,
121
+ action: (
122
+ <IconButton label="New project" variant="ghost" size="sm">
123
+ <Plus aria-hidden="true" />
124
+ </IconButton>
125
+ ),
126
+ items: [
127
+ { label: 'Spring restock', href: '#spring-restock', icon: <Folder /> },
128
+ { label: 'Warehouse move', href: '#warehouse-move', icon: <Folder /> },
129
+ ],
130
+ footer: (
131
+ <Button variant="ghost" size="sm">
132
+ <Plus aria-hidden="true" />
133
+ New project
134
+ </Button>
135
+ ),
136
+ },
137
+ ]
138
+
139
+ <div className="max-w-64">
140
+ <Sidebar label="Projects navigation" groups={PROJECT_GROUPS} />
141
+ </div>
142
+ ```
143
+
107
144
  ### In a sheet on a narrow screen: picking a link closes the sheet, through renderLink
108
145
 
109
146
  ```tsx
@@ -189,5 +226,7 @@ const closeOnChoose = ({ onClick, ...props }: LinkRenderProps) => (
189
226
  - **nav**: The navigation region. Screen readers call it by the label prop.
190
227
  - **Group title**: A disclosure button (one that shows and hides the list) when the group can fold, plain text when it can't. It's text.secondary, so it still reads on a raised panel. Its list opens and closes by animating its height. Opening also fades and slides each item in, each one a little later than the one before (capped, so a long group never feels slow). Closing fades everything out together, all at once. If someone has asked their system for less motion (prefers-reduced-motion: reduce), both happen instantly.
191
228
  - **Item**: An icon, a label and an optional count. The current page gets the fill, plus aria-current, an ARIA attribute (Accessible Rich Internet Applications, the labels screen readers rely on) that tells screen readers this is the page you are on.
229
+ - **Group action**: Optional. A small icon button at the right end of the title row, such as New project. It sits next to the fold toggle, not inside it, so pressing it never folds the group. A long title is cut short before it can push the action out.
230
+ - **Group footer**: Optional. Under the group's items and lined up with them: an Add project button. It folds away with the items.
192
231
  - **Header**: Sits above the groups: a product name, a workspace switcher.
193
232
  - **Footer**: Pinned below the groups: the signed-in account, a help link.
@@ -29,6 +29,7 @@ Paths are from the Division DS repository root. Installed with shadcn they start
29
29
  | `exitCode` | `number` | | With status exited: shown beside the word, and a non-zero code turns it hostile. |
30
30
  | `statusMessage` | `string` | | Detail beside the status, and announced with it: the reason for an error, the attempt while reconnecting. |
31
31
  | `readOnly` | `boolean` | `false` | Blocks input and never fires onData. Selecting and copying keep working, and Tab and Escape leave the terminal as they do anywhere else. |
32
+ | `shiftEnter` | `'newline' \| 'default'` | `'default'` | What Shift+Enter sends. xterm sends the same carriage return as Enter, so a chat program inside submits. newline sends ESC then carriage return instead, which Claude Code and similar programs read as a new line. Not sent while readOnly or composing with an input method. |
32
33
  | `announceOutput` | `boolean` | `false` | Reads output aloud through xterm’s live region, up to 20 lines at a time. For a single session a screen reader user is working in. Leave it off when several terminals share a page. |
33
34
  | `onData` | `(data: string) => void` | | What the person typed or pasted, plus the replies a program asks the terminal for. Send it to the process. |
34
35
  | `onResize` | `(size: { cols: number; rows: number }) => void` | | The new grid size, debounced by 100ms and only when cols or rows changed. It fires once after mounting. |
@@ -329,6 +330,7 @@ function SampleTerminal(props: Omit<TerminalProps, 'ref'>) {
329
330
  | `Ctrl` `Shift` `L` | Moves focus out of the terminal to the next tab stop. Tab and Escape belong to the program, so this is the way out. |
330
331
  | `Ctrl` `C` | With text selected, copies it and clears the selection. With nothing selected, goes to the program as an interrupt. |
331
332
  | `Ctrl` `Shift` `C` | Copies the selection and keeps it. |
333
+ | `Shift` `Enter` | Sends Enter to the program, unless shiftEnter is newline, when it sends ESC then Enter: a new line in chat programs such as Claude Code. |
332
334
  | `Ctrl` `V` | Pastes. Ctrl+Shift+V does the same. A program that wants a literal Ctrl+V does not get it. |
333
335
 
334
336
  ## Do
@@ -336,6 +338,7 @@ function SampleTerminal(props: Omit<TerminalProps, 'ref'>) {
336
338
  - Give each terminal a label that says which session it is, so a page of them can be told apart.
337
339
  - Set readOnly when nobody can usefully type: a finished process, a session someone else owns.
338
340
  - Say why in statusMessage when status is error.
341
+ - Set shiftEnter="newline" when the session runs a chat program, such as Claude Code, where Shift+Enter should start a new line.
339
342
  - Use write’s callback to pause the source when output arrives faster than the page can draw it.
340
343
 
341
344
  ## Don't
@@ -266,7 +266,7 @@
266
266
  "tier": "atoms",
267
267
  "slug": "badge",
268
268
  "summary": "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.",
269
- "useWhen": "You need to label a status with a word and a tone, like an order state or a sync result.",
269
+ "useWhen": "You need to label a status with a word and a tone, like an order state or a sync result. Or a label someone made, like a project tag: then pass color instead of tone.",
270
270
  "notFor": "A filter or selection someone can remove. Use Tag for that.",
271
271
  "source": "src/components/atoms/badge/Badge.tsx",
272
272
  "docs": "/docs/components/badge.md",
@@ -274,7 +274,8 @@
274
274
  {
275
275
  "names": [
276
276
  "Badge",
277
- "badgeVariants"
277
+ "badgeVariants",
278
+ "CATEGORY_COLORS"
278
279
  ],
279
280
  "from": "src/components/atoms/badge/Badge"
280
281
  }
@@ -284,7 +285,12 @@
284
285
  "name": "tone",
285
286
  "type": "'neutral' | 'signal' | 'ok' | 'warning' | 'hostile' | 'info'",
286
287
  "default": "'neutral'",
287
- "description": "The status the badge shows."
288
+ "description": "The status the badge shows. Leave it out when you pass color."
289
+ },
290
+ {
291
+ "name": "color",
292
+ "type": "'gray' | 'brown' | 'orange' | 'yellow' | 'green' | 'teal' | 'blue' | 'purple' | 'pink' | 'red'",
293
+ "description": "A category colour for a label people make themselves, like a project tag or a topic. It replaces tone: the types reject passing both. A category colour means 'these belong together', never good or bad. The list is exported as CATEGORY_COLORS, so an app can give each new label one at random. Works with both emphases and both sizes."
288
294
  },
289
295
  {
290
296
  "name": "emphasis",
@@ -331,6 +337,18 @@
331
337
  "kind": "const",
332
338
  "code": "export const badgeVariants = cva( …",
333
339
  "from": "src/components/atoms/badge/Badge"
340
+ },
341
+ {
342
+ "name": "CATEGORY_COLORS",
343
+ "kind": "const",
344
+ "code": "/** Categorical colours for labels people make (a project tag, a topic), in the order a picker\n * shows them. Not statuses: a category colour says \"same group\", never good or bad. Exported so\n * an app can give each new label one, for example\n * `CATEGORY_COLORS[Math.floor(Math.random() * CATEGORY_COLORS.length)]`. */\nexport const CATEGORY_COLORS = ['gray', 'brown', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'red'] as const",
345
+ "from": "src/components/atoms/badge/Badge"
346
+ },
347
+ {
348
+ "name": "CategoryColor",
349
+ "kind": "type",
350
+ "code": "export type CategoryColor = (typeof CATEGORY_COLORS)[number]",
351
+ "from": "src/components/atoms/badge/Badge"
334
352
  }
335
353
  ],
336
354
  "keyboard": [],
@@ -338,12 +356,15 @@
338
356
  "Give every badge a word.",
339
357
  "Go easy on solid. One solid badge in a row reads as the exception. Six read as noise.",
340
358
  "Keep the tone mapping the same across the product. ok is never ‘in progress’ on one screen and ‘done’ on another.",
341
- "Use size md when the badge sits next to a headline figure, so the two read as one statement."
359
+ "Use size md when the badge sits next to a headline figure, so the two read as one statement.",
360
+ "Use tone for a status the system knows (paid, failed, syncing) and color for a label a person made (Design, Q3, Home). A colour on its own is not a status.",
361
+ "Store the colour with the label, so it stays the same on every screen. Pick one from CATEGORY_COLORS when the label is created."
342
362
  ],
343
363
  "donts": [
344
364
  "Don't build a badge that's colour only, even with a tooltip.",
345
365
  "Don't put a badge inside a button. It muddies what the clickable target is.",
346
366
  "Don't use hostile for something that's only pending.",
367
+ "Don't use a category colour to show a status. A red project label is not an error, and an error is tone=\"hostile\", so it means the same thing on every screen.",
347
368
  "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.",
348
369
  "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.",
349
370
  "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.",
@@ -377,6 +398,14 @@
377
398
  "caption": "Tones, solid",
378
399
  "source": "const TONES = ['neutral', 'signal', 'ok', 'warning', 'hostile', 'info'] as const\n\n{TONES.map(tone => (\n <Badge key={tone} tone={tone} emphasis=\"solid\">\n {tone}\n </Badge>\n))}"
379
400
  },
401
+ {
402
+ "caption": "Category colours, subtle: labels people make, not statuses",
403
+ "source": "{CATEGORY_COLORS.map(color => (\n <Badge key={color} color={color}>\n {color}\n </Badge>\n))}"
404
+ },
405
+ {
406
+ "caption": "Category colours, solid",
407
+ "source": "{CATEGORY_COLORS.map(color => (\n <Badge key={color} color={color} emphasis=\"solid\">\n {color}\n </Badge>\n))}"
408
+ },
380
409
  {
381
410
  "caption": "With a dot",
382
411
  "source": "<Badge tone=\"ok\" dot>\n Nominal\n</Badge>\n<Badge tone=\"warning\" dot>\n Degraded\n</Badge>\n<Badge tone=\"hostile\" dot>\n Offline\n</Badge>"
@@ -426,6 +455,12 @@
426
455
  "default": "'primary'",
427
456
  "description": "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."
428
457
  },
458
+ {
459
+ "name": "tone",
460
+ "type": "'signal' | 'neutral'",
461
+ "default": "'signal'",
462
+ "description": "Only the link variant reads it. signal is the signal-coloured, underlined link. neutral is primary text with no underline until hover or keyboard focus: use it for a clickable title on a kanban card or a list row, where a column of signal links would be too loud. A link-variant label wraps onto more lines rather than overflowing its column."
463
+ },
429
464
  {
430
465
  "name": "size",
431
466
  "type": "'xs' | 'sm' | 'md' | 'lg'",
@@ -528,7 +563,8 @@
528
563
  "Use loading instead of disabling the button and swapping the label yourself.",
529
564
  "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.",
530
565
  "Pass icons through iconStart and iconEnd, not as children. That way they stay hidden from assistive tech and get sized with the button.",
531
- "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."
566
+ "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.",
567
+ "Use variant=\"link\" tone=\"neutral\" for a card or row title that opens something on this page, such as the detail panel of a kanban card."
532
568
  ],
533
569
  "donts": [
534
570
  "Don't add bevel-* or an outline class yourself. You'll clip the ring off.",
@@ -567,7 +603,7 @@
567
603
  },
568
604
  {
569
605
  "part": "Label",
570
- "description": "Oxanium semibold, uppercase: the display type role. The link variant switches to the body role, in the signal text colour, underlined."
606
+ "description": "Oxanium semibold, uppercase: the display type role. The link variant switches to the body role, in the signal text colour, underlined (or primary text, underlined on hover and focus, with tone=\"neutral\"), and wraps instead of staying on one line."
571
607
  },
572
608
  {
573
609
  "part": "Icons",
@@ -595,6 +631,10 @@
595
631
  "caption": "Link variant",
596
632
  "source": "<p className=\"text-sm text-[var(--color-text-secondary)] max-w-md\">\n No replicas match these filters.{' '}\n <Button variant=\"link\">\n Reset filters\n </Button>{' '}\n to see the full catalogue.\n</p>"
597
633
  },
634
+ {
635
+ "caption": "Neutral link: a kanban card title",
636
+ "source": "<div className=\"w-64 flex items-start gap-2 p-3 border border-[var(--color-border-hairline)] bg-[var(--color-surface-raised)]\">\n <div className=\"min-w-0 flex-1 text-sm\">\n <Button variant=\"link\" tone=\"neutral\">\n Replace the hop-up bucking on the second-line support rifles before Saturday\n </Button>\n </div>\n <Button variant=\"ghost\" size=\"xs\">\n Move\n </Button>\n</div>"
637
+ },
598
638
  {
599
639
  "caption": "Full width",
600
640
  "source": "<div className=\"w-80\">\n <Button fullWidth>Dispatch team</Button>\n</div>"
@@ -1327,9 +1367,9 @@
1327
1367
  "props": [
1328
1368
  {
1329
1369
  "name": "tone",
1330
- "type": "'signal' | 'quiet'",
1370
+ "type": "'signal' | 'quiet' | 'neutral'",
1331
1371
  "default": "'signal'",
1332
- "description": "Which colour treatment to use."
1372
+ "description": "Which colour treatment to use. neutral is primary text with no underline until hover or keyboard focus, for a clickable title in a list or on a card, where it is clear from position that the title is a link. Keep signal for links inside running text."
1333
1373
  },
1334
1374
  {
1335
1375
  "name": "external",
@@ -1417,7 +1457,7 @@
1417
1457
  },
1418
1458
  {
1419
1459
  "caption": "Tones",
1420
- "source": "<Link href=\"/kits\" tone=\"signal\">\n Field kits\n</Link>\n<Link href=\"/kits\" tone=\"quiet\">\n Field kits\n</Link>"
1460
+ "source": "<Link href=\"/kits\" tone=\"signal\">\n Field kits\n</Link>\n<Link href=\"/kits\" tone=\"quiet\">\n Field kits\n</Link>\n<Link href=\"/kits\" tone=\"neutral\">\n Field kits\n</Link>"
1421
1461
  },
1422
1462
  {
1423
1463
  "caption": "External",
@@ -6283,6 +6323,23 @@
6283
6323
  "default": "'No matches'",
6284
6324
  "description": "Shown in the panel when the typed text matches no option."
6285
6325
  },
6326
+ {
6327
+ "name": "creatable",
6328
+ "type": "boolean",
6329
+ "default": "false",
6330
+ "description": "Adds one last row, `Create \"<text>\"`, when the typed text matches no option (ignoring case). It is never shown for empty text or for text that is already an option. It is an ordinary option in the list: the arrows reach it, Enter or a click chooses it, and screen readers read its label."
6331
+ },
6332
+ {
6333
+ "name": "onCreate",
6334
+ "type": "(text: string) => void",
6335
+ "description": "Runs with the typed text, trimmed, when the create row is chosen. The text field is then cleared. The component does not add anything itself: add the new option to options and its value to value here."
6336
+ },
6337
+ {
6338
+ "name": "createLabel",
6339
+ "type": "(text: string) => string",
6340
+ "default": "(text) => `Create \"${text}\"`",
6341
+ "description": "The create row's label, for other wording or another language."
6342
+ },
6286
6343
  {
6287
6344
  "name": "inputSize",
6288
6345
  "type": "'sm' | 'md' | 'lg'",
@@ -6347,6 +6404,12 @@
6347
6404
  ],
6348
6405
  "does": "Adds or removes the highlighted option. The list stays open."
6349
6406
  },
6407
+ {
6408
+ "keys": [
6409
+ "Enter"
6410
+ ],
6411
+ "does": "On the create row (creatable): runs onCreate with the typed text and clears the text field."
6412
+ },
6350
6413
  {
6351
6414
  "keys": [
6352
6415
  "Backspace"
@@ -6411,6 +6474,10 @@
6411
6474
  "caption": "Controlled: the page holds the chosen options",
6412
6475
  "source": "const LOADOUT = [\n { value: 'mags', label: 'Spare magazines' },\n { value: 'goggles', label: 'Full-seal goggles' },\n { value: 'mesh', label: 'Mesh face guard' },\n { value: 'radio', label: 'Team radio', disabled: true },\n { value: 'gloves', label: 'Gloves' },\n { value: 'loader', label: 'Speed loader' },\n { value: 'tracer', label: 'Tracer unit' },\n { value: 'pouch', label: 'Dump pouch' },\n]\n\nfunction ControlledExample() {\n const [kit, setKit] = useState<string[]>(['mags', 'goggles', 'gloves'])\n return (\n <div className=\"flex flex-col gap-2 w-full max-w-md\">\n <FormField label=\"Loadout\" help=\"Remove a tag and watch the others close the gap.\">\n <MultiSelect options={LOADOUT} value={kit} onValueChange={setKit} />\n </FormField>\n <span className=\"font-mono text-xs text-[var(--color-text-secondary)]\">value: [{kit.join(', ')}]</span>\n </div>\n )\n}\n\n<ControlledExample />"
6413
6476
  },
6477
+ {
6478
+ "caption": "Creatable: pick or create labels",
6479
+ "source": "const LABELS = [\n { value: 'bug', label: 'Bug' },\n { value: 'design', label: 'Design' },\n { value: 'docs', label: 'Docs' },\n]\n\nfunction CreatableExample() {\n const [options, setOptions] = useState(LABELS)\n const [labels, setLabels] = useState<string[]>(['design'])\n return (\n <div className=\"w-full max-w-md\">\n <FormField label=\"Labels\" help=\"Type a label that is not in the list, then press Enter to create it.\">\n <MultiSelect\n options={options}\n value={labels}\n onValueChange={setLabels}\n creatable\n onCreate={text => {\n setOptions(current => [...current, { value: text, label: text }])\n setLabels(current => [...current, text])\n }}\n />\n </FormField>\n </div>\n )\n}\n\n<CreatableExample />"
6480
+ },
6414
6481
  {
6415
6482
  "caption": "Invalid",
6416
6483
  "source": "const LOADOUT = [\n { value: 'mags', label: 'Spare magazines' },\n { value: 'goggles', label: 'Full-seal goggles' },\n { value: 'mesh', label: 'Mesh face guard' },\n { value: 'radio', label: 'Team radio', disabled: true },\n { value: 'gloves', label: 'Gloves' },\n { value: 'loader', label: 'Speed loader' },\n { value: 'tracer', label: 'Tracer unit' },\n { value: 'pouch', label: 'Dump pouch' },\n]\n\n<div className=\"w-full max-w-md\">\n <FormField label=\"Loadout\" error=\"Pick eye protection.\">\n <MultiSelect options={LOADOUT} defaultValue={['mags']} />\n </FormField>\n</div>"
@@ -11091,6 +11158,11 @@
11091
11158
  "type": "(item: T) => ReactNode",
11092
11159
  "description": "A small mark in the card's bottom-right corner, under the Move control, about 2rem square, such as the owner's avatar. Wrap it in aria-hidden (so screen readers skip it) when the card already prints the same name."
11093
11160
  },
11161
+ {
11162
+ "name": "renderCardMenu",
11163
+ "type": "(item: T, moveTo: (columnId: string) => void, columnId: string) => ReactNode",
11164
+ "description": "Replaces the Move menu's default items (the card's name and a Move to item for each other column) with your own DropdownMenu parts, such as View, Edit or Delete. They go inside the board's menu. Call moveTo(columnId) to move the card the way the default menu does: to the end of that column, with onMove, the announcement and focus back on the Move control. columnId is the card's current column."
11165
+ },
11094
11166
  {
11095
11167
  "name": "onMove",
11096
11168
  "type": "(itemId: string, toColumnId: string, toIndex: number) => void",
@@ -11271,6 +11343,10 @@
11271
11343
  {
11272
11344
  "caption": "Work orders, with an empty column",
11273
11345
  "source": "type Order = { id: string; title: string; bay: string; status: 'open' | 'parts' | 'done' }\n\nconst ORDER_COLUMNS: KanbanColumn[] = [\n { id: 'open', title: 'Open' },\n { id: 'parts', title: 'Waiting for parts' },\n { id: 'done', title: 'Done' },\n]\n\nconst ORDERS: Order[] = [\n { id: 'wo-31', title: 'Replace dock door seal', bay: 'Bay 2', status: 'open' },\n { id: 'wo-32', title: 'Recalibrate floor scale', bay: 'Bay 5', status: 'open' },\n { id: 'wo-29', title: 'Service pallet jack', bay: 'Bay 1', status: 'done' },\n]\n\nconst orderAccessors = {\n getItemId: (order: Order) => order.id,\n getColumnId: (order: Order) => order.status,\n setColumnId: (order: Order, status: string) => ({ ...order, status: status as Order['status'] }),\n}\n\nfunction WorkOrders() {\n const [orders, setOrders] = useState(ORDERS)\n return (\n <div className=\"w-full min-w-0\">\n <KanbanBoard\n label=\"Depot work orders\"\n columns={ORDER_COLUMNS}\n items={orders}\n getItemId={orderAccessors.getItemId}\n getColumnId={orderAccessors.getColumnId}\n getItemLabel={order => order.title}\n renderCard={order => (\n <div className=\"flex flex-col gap-1\">\n <p className=\"font-semibold\">{order.title}</p>\n <p className=\"font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-text-secondary)]\">{order.bay}</p>\n </div>\n )}\n onMove={(id, status, index) => setOrders(current => applyKanbanMove(current, id, status, index, orderAccessors))}\n emptyColumnMessage=\"No orders waiting\"\n columnHeadingLevel={4}\n />\n </div>\n )\n}\n\n<WorkOrders />"
11346
+ },
11347
+ {
11348
+ "caption": "Your own card menu, with renderCardMenu",
11349
+ "source": "type Order = { id: string; title: string; bay: string; status: 'open' | 'parts' | 'done' }\n\nconst ORDER_COLUMNS: KanbanColumn[] = [\n { id: 'open', title: 'Open' },\n { id: 'parts', title: 'Waiting for parts' },\n { id: 'done', title: 'Done' },\n]\n\nconst ORDERS: Order[] = [\n { id: 'wo-31', title: 'Replace dock door seal', bay: 'Bay 2', status: 'open' },\n { id: 'wo-32', title: 'Recalibrate floor scale', bay: 'Bay 5', status: 'open' },\n { id: 'wo-29', title: 'Service pallet jack', bay: 'Bay 1', status: 'done' },\n]\n\nconst orderAccessors = {\n getItemId: (order: Order) => order.id,\n getColumnId: (order: Order) => order.status,\n setColumnId: (order: Order, status: string) => ({ ...order, status: status as Order['status'] }),\n}\n\nconst ORDER_LABELS = ['Urgent', 'Safety', 'Contractor'] as const\n\nfunction WorkOrdersWithMenu() {\n const [orders, setOrders] = useState(ORDERS)\n const [labels, setLabels] = useState<Record<string, string[]>>({ 'wo-31': ['Safety'] })\n const [note, setNote] = useState('')\n const toggleLabel = (id: string, label: string, on: boolean) =>\n setLabels(current => ({\n ...current,\n [id]: on ? [...(current[id] ?? []), label] : (current[id] ?? []).filter(other => other !== label),\n }))\n return (\n <div className=\"w-full min-w-0 flex flex-col gap-3\">\n <KanbanBoard\n label=\"Depot work orders, with a card menu\"\n columns={ORDER_COLUMNS}\n items={orders}\n getItemId={orderAccessors.getItemId}\n getColumnId={orderAccessors.getColumnId}\n getItemLabel={order => order.title}\n renderCard={order => (\n <div className=\"flex flex-col gap-1\">\n <p className=\"font-semibold\">{order.title}</p>\n <p className=\"font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-text-secondary)]\">\n {[order.bay, ...(labels[order.id] ?? [])].join(' · ')}\n </p>\n </div>\n )}\n renderCardMenu={(order, moveTo, columnId) => (\n <>\n <DropdownMenuItem icon={<Eye />} onSelect={() => setNote(`Opened ${order.title}.`)}>\n View\n </DropdownMenuItem>\n <DropdownMenuItem icon={<Pencil />} onSelect={() => setNote(`Editing ${order.title}.`)}>\n Edit\n </DropdownMenuItem>\n <DropdownMenuSub>\n <DropdownMenuSubTrigger icon={<Tag />}>Labels</DropdownMenuSubTrigger>\n <DropdownMenuSubContent>\n {ORDER_LABELS.map(label => (\n <DropdownMenuCheckboxItem\n key={label}\n checked={(labels[order.id] ?? []).includes(label)}\n onCheckedChange={on => toggleLabel(order.id, label, on === true)}\n onSelect={event => event.preventDefault()}\n >\n {label}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n <DropdownMenuSub>\n <DropdownMenuSubTrigger icon={<ArrowRightLeft />}>Move to</DropdownMenuSubTrigger>\n <DropdownMenuSubContent>\n {ORDER_COLUMNS.filter(column => column.id !== columnId).map(column => (\n <DropdownMenuItem key={column.id} onSelect={() => moveTo(column.id)}>\n {column.title}\n </DropdownMenuItem>\n ))}\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n tone=\"hostile\"\n icon={<Trash2 />}\n onSelect={() => {\n setOrders(current => current.filter(other => other.id !== order.id))\n setNote(`Deleted ${order.title}.`)\n }}\n >\n Delete\n </DropdownMenuItem>\n </>\n )}\n onMove={(id, status, index) => setOrders(current => applyKanbanMove(current, id, status, index, orderAccessors))}\n emptyColumnMessage=\"No orders waiting\"\n columnHeadingLevel={4}\n />\n <p className={docsProse}>{note}</p>\n </div>\n )\n}\n\n<WorkOrdersWithMenu />"
11274
11350
  }
11275
11351
  ],
11276
11352
  "closed": true
@@ -11935,7 +12011,7 @@
11935
12011
  {
11936
12012
  "name": "groups",
11937
12013
  "type": "SidebarGroup[]",
11938
- "description": "Required. Each group has title, icon, items, collapsible and defaultOpen."
12014
+ "description": "Required. Each group has title, icon, items, collapsible and defaultOpen, and optionally action (a small icon button at the right end of the title row, beside the fold toggle, never inside it) and footer (under the items, lined up with them, hidden while the group is folded)."
11939
12015
  },
11940
12016
  {
11941
12017
  "name": "header",
@@ -11974,7 +12050,7 @@
11974
12050
  {
11975
12051
  "name": "SidebarGroup",
11976
12052
  "kind": "type",
11977
- "code": "export type SidebarGroup = {\n /** Also the React key for this group; unique within a Sidebar. */\n title: string\n /** Drawn before the title, aria-hidden; the title alone names the group. */\n icon?: ReactNode\n items: SidebarItem[]\n /** @default true */\n collapsible?: boolean\n /** Ignored when the group holds the current item, which always starts open. @default true */\n defaultOpen?: boolean\n}",
12053
+ "code": "export type SidebarGroup = {\n /** Also the React key for this group; unique within a Sidebar. */\n title: string\n /** Drawn before the title, aria-hidden; the title alone names the group. */\n icon?: ReactNode\n items: SidebarItem[]\n /** @default true */\n collapsible?: boolean\n /** Ignored when the group holds the current item, which always starts open. @default true */\n defaultOpen?: boolean\n /** Drawn at the far right of the title row: a small icon button, e.g. a ghost size=\"sm\" Button\n * with a Plus icon and aria-label=\"New project\". It sits beside the fold toggle, never inside it,\n * so pressing it does not open or close the group. The title truncates before it is pushed out. */\n action?: ReactNode\n /** Drawn under the items, inside the group, lined up with the items' left edge and padding: an\n * \"Add project\" button. Hidden (and unreachable by Tab) while the group is folded. */\n footer?: ReactNode\n}",
11978
12054
  "from": "src/components/organisms/sidebar/Sidebar"
11979
12055
  }
11980
12056
  ],
@@ -12021,6 +12097,14 @@
12021
12097
  "part": "Item",
12022
12098
  "description": "An icon, a label and an optional count. The current page gets the fill, plus aria-current, an ARIA attribute (Accessible Rich Internet Applications, the labels screen readers rely on) that tells screen readers this is the page you are on."
12023
12099
  },
12100
+ {
12101
+ "part": "Group action",
12102
+ "description": "Optional. A small icon button at the right end of the title row, such as New project. It sits next to the fold toggle, not inside it, so pressing it never folds the group. A long title is cut short before it can push the action out."
12103
+ },
12104
+ {
12105
+ "part": "Group footer",
12106
+ "description": "Optional. Under the group's items and lined up with them: an Add project button. It folds away with the items."
12107
+ },
12024
12108
  {
12025
12109
  "part": "Header",
12026
12110
  "description": "Sits above the groups: a product name, a workspace switcher."
@@ -12035,6 +12119,10 @@
12035
12119
  "caption": "Dashboard navigation",
12036
12120
  "source": "const GROUPS: SidebarGroup[] = [\n {\n title: 'Operations',\n icon: <Warehouse />,\n items: [\n { label: 'Overview', href: '#overview', icon: <LayoutDashboard /> },\n { label: 'Orders', href: '#orders', icon: <Package />, current: true, count: 12 },\n { label: 'Shipments', href: '#shipments', icon: <Truck /> },\n { label: 'Returns', href: '#returns', icon: <Inbox />, count: 3 },\n ],\n },\n {\n title: 'Catalogue',\n icon: <Boxes />,\n defaultOpen: false,\n items: [\n { label: 'Products', href: '#products', icon: <Box /> },\n { label: 'Inventory', href: '#inventory', icon: <Map /> },\n ],\n },\n {\n title: 'Account',\n icon: <UserCog />,\n collapsible: false,\n items: [\n { label: 'Settings', href: '#settings', icon: <Settings /> },\n { label: 'Team', href: '#team', icon: <Users /> },\n ],\n },\n]\n\nconst HEADER = <p className=\"font-display font-semibold\">Hexline ops</p>\n\nconst FOOTER = <p className=\"text-xs text-[var(--color-text-secondary)]\">Signed in as Ana Pop</p>\n\n<div className=\"max-w-64\">\n <Sidebar label=\"Dashboard navigation\" groups={GROUPS} header={HEADER} footer={FOOTER} />\n</div>"
12037
12121
  },
12122
+ {
12123
+ "caption": "A Projects group with an action in its title row and a footer under its items",
12124
+ "source": "const PROJECT_GROUPS: SidebarGroup[] = [\n {\n title: 'Projects',\n icon: <FolderKanban />,\n action: (\n <IconButton label=\"New project\" variant=\"ghost\" size=\"sm\">\n <Plus aria-hidden=\"true\" />\n </IconButton>\n ),\n items: [\n { label: 'Spring restock', href: '#spring-restock', icon: <Folder /> },\n { label: 'Warehouse move', href: '#warehouse-move', icon: <Folder /> },\n ],\n footer: (\n <Button variant=\"ghost\" size=\"sm\">\n <Plus aria-hidden=\"true\" />\n New project\n </Button>\n ),\n },\n]\n\n<div className=\"max-w-64\">\n <Sidebar label=\"Projects navigation\" groups={PROJECT_GROUPS} />\n</div>"
12125
+ },
12038
12126
  {
12039
12127
  "caption": "In a sheet on a narrow screen: picking a link closes the sheet, through renderLink",
12040
12128
  "source": "const GROUPS: SidebarGroup[] = [\n {\n title: 'Operations',\n icon: <Warehouse />,\n items: [\n { label: 'Overview', href: '#overview', icon: <LayoutDashboard /> },\n { label: 'Orders', href: '#orders', icon: <Package />, current: true, count: 12 },\n { label: 'Shipments', href: '#shipments', icon: <Truck /> },\n { label: 'Returns', href: '#returns', icon: <Inbox />, count: 3 },\n ],\n },\n {\n title: 'Catalogue',\n icon: <Boxes />,\n defaultOpen: false,\n items: [\n { label: 'Products', href: '#products', icon: <Box /> },\n { label: 'Inventory', href: '#inventory', icon: <Map /> },\n ],\n },\n {\n title: 'Account',\n icon: <UserCog />,\n collapsible: false,\n items: [\n { label: 'Settings', href: '#settings', icon: <Settings /> },\n { label: 'Team', href: '#team', icon: <Users /> },\n ],\n },\n]\n\nconst HEADER = <p className=\"font-display font-semibold\">Hexline ops</p>\n\nconst FOOTER = <p className=\"text-xs text-[var(--color-text-secondary)]\">Signed in as Ana Pop</p>\n\nconst closeOnChoose = ({ onClick, ...props }: LinkRenderProps) => (\n <SheetClose asChild type={undefined}>\n <a {...props} onClick={onClick} />\n </SheetClose>\n)\n\n<Sheet>\n <SheetTrigger asChild>\n <Button variant=\"secondary\">Open navigation</Button>\n </SheetTrigger>\n <SheetContent side=\"left\" title=\"Navigation\">\n <Sidebar\n label=\"Dashboard navigation, in a sheet\"\n groups={GROUPS}\n header={HEADER}\n footer={FOOTER}\n renderLink={closeOnChoose}\n />\n </SheetContent>\n</Sheet>"
@@ -12091,6 +12179,12 @@
12091
12179
  "default": "false",
12092
12180
  "description": "Blocks input and never fires onData. Selecting and copying keep working, and Tab and Escape leave the terminal as they do anywhere else."
12093
12181
  },
12182
+ {
12183
+ "name": "shiftEnter",
12184
+ "type": "'newline' | 'default'",
12185
+ "default": "'default'",
12186
+ "description": "What Shift+Enter sends. xterm sends the same carriage return as Enter, so a chat program inside submits. newline sends ESC then carriage return instead, which Claude Code and similar programs read as a new line. Not sent while readOnly or composing with an input method."
12187
+ },
12094
12188
  {
12095
12189
  "name": "announceOutput",
12096
12190
  "type": "boolean",
@@ -12188,6 +12282,13 @@
12188
12282
  ],
12189
12283
  "does": "Copies the selection and keeps it."
12190
12284
  },
12285
+ {
12286
+ "keys": [
12287
+ "Shift",
12288
+ "Enter"
12289
+ ],
12290
+ "does": "Sends Enter to the program, unless shiftEnter is newline, when it sends ESC then Enter: a new line in chat programs such as Claude Code."
12291
+ },
12191
12292
  {
12192
12293
  "keys": [
12193
12294
  "Ctrl",
@@ -12200,6 +12301,7 @@
12200
12301
  "Give each terminal a label that says which session it is, so a page of them can be told apart.",
12201
12302
  "Set readOnly when nobody can usefully type: a finished process, a session someone else owns.",
12202
12303
  "Say why in statusMessage when status is error.",
12304
+ "Set shiftEnter=\"newline\" when the session runs a chat program, such as Claude Code, where Shift+Enter should start a new line.",
12203
12305
  "Use write’s callback to pause the source when output arrives faster than the page can draw it."
12204
12306
  ],
12205
12307
  "donts": [
@@ -755,6 +755,216 @@
755
755
  "light": "10.76:1 on accent.warning.fill"
756
756
  }
757
757
  },
758
+ {
759
+ "name": "--color-category-gray-text",
760
+ "layer": "semantic",
761
+ "value": {
762
+ "dark": "#a3a9b3",
763
+ "light": "#5c636e"
764
+ },
765
+ "use": "Subtle Badge color=\"gray\": text and border. A label, not a status."
766
+ },
767
+ {
768
+ "name": "--color-category-gray-fill",
769
+ "layer": "semantic",
770
+ "value": "#9aa1ab",
771
+ "use": "Solid Badge color=\"gray\": the fill."
772
+ },
773
+ {
774
+ "name": "--color-category-gray-ink",
775
+ "layer": "semantic",
776
+ "value": "#0a0602",
777
+ "use": "Text on category.gray.fill."
778
+ },
779
+ {
780
+ "name": "--color-category-brown-text",
781
+ "layer": "semantic",
782
+ "value": {
783
+ "dark": "#c9a383",
784
+ "light": "#7a5232"
785
+ },
786
+ "use": "Subtle Badge color=\"brown\": text and border. A label, not a status."
787
+ },
788
+ {
789
+ "name": "--color-category-brown-fill",
790
+ "layer": "semantic",
791
+ "value": "#b98a64",
792
+ "use": "Solid Badge color=\"brown\": the fill."
793
+ },
794
+ {
795
+ "name": "--color-category-brown-ink",
796
+ "layer": "semantic",
797
+ "value": "#0a0602",
798
+ "use": "Text on category.brown.fill."
799
+ },
800
+ {
801
+ "name": "--color-category-orange-text",
802
+ "layer": "semantic",
803
+ "value": {
804
+ "dark": "#f0a060",
805
+ "light": "#a34a00"
806
+ },
807
+ "use": "Subtle Badge color=\"orange\": text and border. A label, not a status."
808
+ },
809
+ {
810
+ "name": "--color-category-orange-fill",
811
+ "layer": "semantic",
812
+ "value": "#f0913a",
813
+ "use": "Solid Badge color=\"orange\": the fill."
814
+ },
815
+ {
816
+ "name": "--color-category-orange-ink",
817
+ "layer": "semantic",
818
+ "value": "#0a0602",
819
+ "use": "Text on category.orange.fill."
820
+ },
821
+ {
822
+ "name": "--color-category-yellow-text",
823
+ "layer": "semantic",
824
+ "value": {
825
+ "dark": "#e6c65a",
826
+ "light": "#7a5c00"
827
+ },
828
+ "use": "Subtle Badge color=\"yellow\": text and border. A label, not a status."
829
+ },
830
+ {
831
+ "name": "--color-category-yellow-fill",
832
+ "layer": "semantic",
833
+ "value": "#e8c341",
834
+ "use": "Solid Badge color=\"yellow\": the fill."
835
+ },
836
+ {
837
+ "name": "--color-category-yellow-ink",
838
+ "layer": "semantic",
839
+ "value": "#0a0602",
840
+ "use": "Text on category.yellow.fill."
841
+ },
842
+ {
843
+ "name": "--color-category-green-text",
844
+ "layer": "semantic",
845
+ "value": {
846
+ "dark": "#6fcf8f",
847
+ "light": "#1e6b3a"
848
+ },
849
+ "use": "Subtle Badge color=\"green\": text and border. A label, not a status."
850
+ },
851
+ {
852
+ "name": "--color-category-green-fill",
853
+ "layer": "semantic",
854
+ "value": "#4fbf7a",
855
+ "use": "Solid Badge color=\"green\": the fill."
856
+ },
857
+ {
858
+ "name": "--color-category-green-ink",
859
+ "layer": "semantic",
860
+ "value": "#0a0602",
861
+ "use": "Text on category.green.fill."
862
+ },
863
+ {
864
+ "name": "--color-category-teal-text",
865
+ "layer": "semantic",
866
+ "value": {
867
+ "dark": "#5ccfc4",
868
+ "light": "#0d6662"
869
+ },
870
+ "use": "Subtle Badge color=\"teal\": text and border. A label, not a status."
871
+ },
872
+ {
873
+ "name": "--color-category-teal-fill",
874
+ "layer": "semantic",
875
+ "value": "#3fbfb2",
876
+ "use": "Solid Badge color=\"teal\": the fill."
877
+ },
878
+ {
879
+ "name": "--color-category-teal-ink",
880
+ "layer": "semantic",
881
+ "value": "#0a0602",
882
+ "use": "Text on category.teal.fill."
883
+ },
884
+ {
885
+ "name": "--color-category-blue-text",
886
+ "layer": "semantic",
887
+ "value": {
888
+ "dark": "#7fb2ff",
889
+ "light": "#1f5fbf"
890
+ },
891
+ "use": "Subtle Badge color=\"blue\": text and border. A label, not a status."
892
+ },
893
+ {
894
+ "name": "--color-category-blue-fill",
895
+ "layer": "semantic",
896
+ "value": "#6fa3f5",
897
+ "use": "Solid Badge color=\"blue\": the fill."
898
+ },
899
+ {
900
+ "name": "--color-category-blue-ink",
901
+ "layer": "semantic",
902
+ "value": "#0a0602",
903
+ "use": "Text on category.blue.fill."
904
+ },
905
+ {
906
+ "name": "--color-category-purple-text",
907
+ "layer": "semantic",
908
+ "value": {
909
+ "dark": "#b59cf5",
910
+ "light": "#6a3fbf"
911
+ },
912
+ "use": "Subtle Badge color=\"purple\": text and border. A label, not a status."
913
+ },
914
+ {
915
+ "name": "--color-category-purple-fill",
916
+ "layer": "semantic",
917
+ "value": "#a88af0",
918
+ "use": "Solid Badge color=\"purple\": the fill."
919
+ },
920
+ {
921
+ "name": "--color-category-purple-ink",
922
+ "layer": "semantic",
923
+ "value": "#0a0602",
924
+ "use": "Text on category.purple.fill."
925
+ },
926
+ {
927
+ "name": "--color-category-pink-text",
928
+ "layer": "semantic",
929
+ "value": {
930
+ "dark": "#f08fc4",
931
+ "light": "#a3306f"
932
+ },
933
+ "use": "Subtle Badge color=\"pink\": text and border. A label, not a status."
934
+ },
935
+ {
936
+ "name": "--color-category-pink-fill",
937
+ "layer": "semantic",
938
+ "value": "#ec84bb",
939
+ "use": "Solid Badge color=\"pink\": the fill."
940
+ },
941
+ {
942
+ "name": "--color-category-pink-ink",
943
+ "layer": "semantic",
944
+ "value": "#0a0602",
945
+ "use": "Text on category.pink.fill."
946
+ },
947
+ {
948
+ "name": "--color-category-red-text",
949
+ "layer": "semantic",
950
+ "value": {
951
+ "dark": "#f58a8a",
952
+ "light": "#b42828"
953
+ },
954
+ "use": "Subtle Badge color=\"red\": text and border. A label, not a status."
955
+ },
956
+ {
957
+ "name": "--color-category-red-fill",
958
+ "layer": "semantic",
959
+ "value": "#f07a7a",
960
+ "use": "Solid Badge color=\"red\": the fill."
961
+ },
962
+ {
963
+ "name": "--color-category-red-ink",
964
+ "layer": "semantic",
965
+ "value": "#0a0602",
966
+ "use": "Text on category.red.fill."
967
+ },
758
968
  {
759
969
  "name": "--color-numeral-pad",
760
970
  "layer": "semantic",