@proteos/ui 0.55.0 → 0.57.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 (50) hide show
  1. package/dist/{chunk-IKAX76MB.js → chunk-NSE4VW3Z.js} +45 -21
  2. package/dist/chunk-NSE4VW3Z.js.map +1 -0
  3. package/dist/{chunk-MWAXK66H.cjs → chunk-NWGG5WFL.cjs} +45 -21
  4. package/dist/chunk-NWGG5WFL.cjs.map +1 -0
  5. package/dist/index.cjs +377 -361
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +46 -14
  8. package/dist/index.d.ts +46 -14
  9. package/dist/index.js +124 -108
  10. package/dist/index.js.map +1 -1
  11. package/dist/{pdf-view-MH75RVQP.js → pdf-view-RQ2OZYHJ.js} +3 -3
  12. package/dist/{pdf-view-MH75RVQP.js.map → pdf-view-RQ2OZYHJ.js.map} +1 -1
  13. package/dist/{pdf-view-K4MMVHVY.cjs → pdf-view-RRMAUK3L.cjs} +7 -7
  14. package/dist/{pdf-view-K4MMVHVY.cjs.map → pdf-view-RRMAUK3L.cjs.map} +1 -1
  15. package/package.json +2 -2
  16. package/src/components/avatar/avatar.stories.tsx +13 -0
  17. package/src/components/avatar/avatar.tsx +16 -9
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +2 -2
  19. package/src/components/button/button.stories.tsx +202 -97
  20. package/src/components/button/button.test.tsx +40 -7
  21. package/src/components/button/button.tsx +71 -18
  22. package/src/components/combobox/combobox.tsx +2 -2
  23. package/src/components/currency-input/currency-input.tsx +2 -2
  24. package/src/components/date-picker/calendar-internals.tsx +1 -1
  25. package/src/components/date-picker/date-picker.tsx +3 -3
  26. package/src/components/date-time-picker/date-time-picker.tsx +3 -3
  27. package/src/components/icon-picker/icon-picker.tsx +2 -2
  28. package/src/components/image-lightbox/image-lightbox.tsx +2 -2
  29. package/src/components/menu/menu.tsx +2 -2
  30. package/src/components/modal/modal.tsx +4 -4
  31. package/src/components/multi-select/multi-select.tsx +2 -2
  32. package/src/components/popover/popover.tsx +2 -2
  33. package/src/components/record-filter/chip-value.tsx +6 -5
  34. package/src/components/record-filter/filter-value-input.tsx +11 -9
  35. package/src/components/record-filter/nested-group.tsx +2 -2
  36. package/src/components/record-filter/operators.test.ts +67 -12
  37. package/src/components/record-filter/operators.ts +43 -2
  38. package/src/components/record-filter/record-filter-builder-panel.tsx +11 -23
  39. package/src/components/record-filter/record-filter-chips.tsx +3 -10
  40. package/src/components/record-filter/types.ts +6 -1
  41. package/src/components/segmented-control/segmented-control.stories.tsx +134 -8
  42. package/src/components/segmented-control/segmented-control.test.tsx +81 -8
  43. package/src/components/segmented-control/segmented-control.tsx +38 -19
  44. package/src/components/select/select.tsx +2 -2
  45. package/src/components/time-field/time-field.tsx +1 -1
  46. package/src/components/toast/toast.tsx +2 -2
  47. package/src/components/tooltip/tooltip.tsx +2 -2
  48. package/src/styles.css +58 -0
  49. package/dist/chunk-IKAX76MB.js.map +0 -1
  50. package/dist/chunk-MWAXK66H.cjs.map +0 -1
@@ -1,13 +1,13 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
 
3
- import { Button } from './button'
3
+ import { Button, buttonStyles, type ButtonVariants } from './button'
4
4
 
5
5
  const meta: Meta<typeof Button> = {
6
6
  title: 'Components/Button',
7
7
  component: Button,
8
8
  tags: ['autodocs'],
9
9
  args: {
10
- children: 'Start building →',
10
+ children: 'Save changes',
11
11
  variant: 'primary',
12
12
  size: 'md',
13
13
  shape: 'rounded',
@@ -15,7 +15,7 @@ const meta: Meta<typeof Button> = {
15
15
  argTypes: {
16
16
  variant: {
17
17
  control: { type: 'inline-radio' },
18
- options: ['primary', 'accent', 'secondary', 'ghost', 'link'],
18
+ options: ['cta', 'primary', 'secondary', 'ghost', 'subtle', 'link', 'danger'],
19
19
  },
20
20
  size: {
21
21
  control: { type: 'inline-radio' },
@@ -34,35 +34,55 @@ type Story = StoryObj<typeof Button>
34
34
 
35
35
  export const Playground: Story = {}
36
36
 
37
- const Arrow = () => (
38
- <svg viewBox="0 0 12 12" fill="none" aria-hidden>
39
- <path d="M2 6h8m-3-3 3 3-3 3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
37
+ const Plus = () => (
38
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden>
39
+ <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
40
40
  </svg>
41
41
  )
42
42
 
43
- export const Primary: Story = {
43
+ const ArrowRight = () => (
44
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden>
45
+ <path
46
+ d="M3 8h10m-4-4 4 4-4 4"
47
+ stroke="currentColor"
48
+ strokeWidth="1.4"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ />
52
+ </svg>
53
+ )
54
+
55
+ /* =========================================================================
56
+ The ladder. One `cta` per view; `primary` is the everyday button; the rest
57
+ step down. `accent` (not shown) is a frozen alias of `cta`.
58
+ ========================================================================= */
59
+
60
+ export const Cta: Story = {
44
61
  render: () => (
45
62
  <div className="flex flex-wrap items-center gap-3">
46
- <Button>Start building →</Button>
47
- <Button size="sm">Save</Button>
48
- <Button size="lg">Compose system</Button>
49
- <Button isDisabled>Disabled</Button>
63
+ <Button variant="cta" size="sm">
64
+ New record
65
+ </Button>
66
+ <Button variant="cta">
67
+ Publish changes <ArrowRight />
68
+ </Button>
69
+ <Button variant="cta" size="lg">
70
+ Sign in
71
+ </Button>
72
+ <Button variant="cta" isDisabled>
73
+ Disabled
74
+ </Button>
50
75
  </div>
51
76
  ),
52
77
  }
53
78
 
54
- export const Accent: Story = {
79
+ export const Primary: Story = {
55
80
  render: () => (
56
81
  <div className="flex flex-wrap items-center gap-3">
57
- <Button variant="accent">
58
- Compose <Arrow />
59
- </Button>
60
- <Button variant="accent" size="sm">
61
- Run
62
- </Button>
63
- <Button variant="accent" size="lg">
64
- Publish changes
65
- </Button>
82
+ <Button size="sm">Save</Button>
83
+ <Button>Save changes</Button>
84
+ <Button size="lg">Duplicate</Button>
85
+ <Button isDisabled>Disabled</Button>
66
86
  </div>
67
87
  ),
68
88
  }
@@ -70,12 +90,12 @@ export const Accent: Story = {
70
90
  export const Secondary: Story = {
71
91
  render: () => (
72
92
  <div className="flex flex-wrap items-center gap-3">
73
- <Button variant="secondary">Read manifesto</Button>
74
93
  <Button variant="secondary" size="sm">
75
94
  Cancel
76
95
  </Button>
96
+ <Button variant="secondary">Cancel</Button>
77
97
  <Button variant="secondary" size="lg">
78
- Book a demo
98
+ Back
79
99
  </Button>
80
100
  </div>
81
101
  ),
@@ -89,7 +109,20 @@ export const Ghost: Story = {
89
109
  Group
90
110
  </Button>
91
111
  <Button variant="ghost">Export</Button>
92
- <Button variant="ghost">+ New shipment</Button>
112
+ </div>
113
+ ),
114
+ }
115
+
116
+ export const Subtle: Story = {
117
+ render: () => (
118
+ <div className="flex flex-wrap items-center gap-3">
119
+ <Button variant="subtle">More</Button>
120
+ <Button variant="subtle" size="sm">
121
+ Edit
122
+ </Button>
123
+ <Button variant="subtle" size="icon-md" aria-label="Add">
124
+ <Plus />
125
+ </Button>
93
126
  </div>
94
127
  ),
95
128
  }
@@ -99,7 +132,21 @@ export const Link: Story = {
99
132
  <div className="flex flex-wrap items-center gap-6">
100
133
  <Button variant="link">Read the docs</Button>
101
134
  <Button variant="link">View architecture</Button>
102
- <Button variant="link">Schedule an architect</Button>
135
+ </div>
136
+ ),
137
+ }
138
+
139
+ export const Danger: Story = {
140
+ render: () => (
141
+ <div className="flex flex-wrap items-center gap-3">
142
+ <Button variant="secondary">Cancel</Button>
143
+ <Button variant="danger">Delete record</Button>
144
+ <Button variant="danger" size="sm">
145
+ Remove
146
+ </Button>
147
+ <Button variant="danger" isDisabled>
148
+ Disabled
149
+ </Button>
103
150
  </div>
104
151
  ),
105
152
  }
@@ -111,107 +158,176 @@ export const AllVariants: Story = {
111
158
  parameters: { controls: { hideNoControlsWarning: true } },
112
159
  render: () => (
113
160
  <div className={columns}>
114
- <div className={label}>Primary</div>
161
+ <div className={label}>cta</div>
115
162
  <div className="flex flex-wrap items-center gap-3">
116
- <Button size="sm">Save</Button>
117
- <Button>Start building →</Button>
118
- <Button size="lg">Compose system</Button>
119
- <Button isDisabled>Disabled</Button>
120
- </div>
121
-
122
- <div className={label}>Accent</div>
123
- <div className="flex flex-wrap items-center gap-3">
124
- <Button variant="accent" size="sm">
125
- Run
163
+ <Button variant="cta" size="sm">
164
+ New record
126
165
  </Button>
127
- <Button variant="accent">
128
- Compose <Arrow />
166
+ <Button variant="cta">
167
+ Publish changes <ArrowRight />
129
168
  </Button>
130
- <Button variant="accent" size="lg">
131
- Publish changes
169
+ <Button variant="cta" size="lg">
170
+ Sign in
132
171
  </Button>
133
172
  </div>
134
173
 
135
- <div className={label}>Secondary</div>
174
+ <div className={label}>primary</div>
175
+ <div className="flex flex-wrap items-center gap-3">
176
+ <Button size="sm">Save</Button>
177
+ <Button>Save changes</Button>
178
+ <Button size="lg">Duplicate</Button>
179
+ <Button isDisabled>Disabled</Button>
180
+ </div>
181
+
182
+ <div className={label}>secondary</div>
136
183
  <div className="flex flex-wrap items-center gap-3">
137
184
  <Button variant="secondary" size="sm">
138
185
  Cancel
139
186
  </Button>
140
- <Button variant="secondary">Read manifesto</Button>
187
+ <Button variant="secondary">Cancel</Button>
141
188
  <Button variant="secondary" size="lg">
142
- Book a demo
189
+ Back
143
190
  </Button>
144
191
  </div>
145
192
 
146
- <div className={label}>Ghost</div>
193
+ <div className={label}>ghost</div>
147
194
  <div className="flex flex-wrap items-center gap-3">
148
195
  <Button variant="ghost" size="sm">
149
196
  Group
150
197
  </Button>
151
198
  <Button variant="ghost">Filter</Button>
152
- <Button variant="ghost">+ New shipment</Button>
199
+ <Button variant="ghost">Export</Button>
153
200
  </div>
154
201
 
155
- <div className={label}>Link</div>
202
+ <div className={label}>subtle</div>
203
+ <div className="flex flex-wrap items-center gap-3">
204
+ <Button variant="subtle" size="sm">
205
+ Edit
206
+ </Button>
207
+ <Button variant="subtle">More</Button>
208
+ </div>
209
+
210
+ <div className={label}>link</div>
156
211
  <div className="flex flex-wrap items-center gap-6">
157
212
  <Button variant="link">Read the docs</Button>
158
213
  <Button variant="link">View architecture</Button>
159
214
  </div>
215
+
216
+ <div className={label}>danger</div>
217
+ <div className="flex flex-wrap items-center gap-3">
218
+ <Button variant="danger" size="sm">
219
+ Remove
220
+ </Button>
221
+ <Button variant="danger">Delete record</Button>
222
+ </div>
160
223
  </div>
161
224
  ),
162
225
  }
163
226
 
164
227
  /* =========================================================================
165
- Shape — default `rounded` (soft 10px corners, the new standard) vs `pill`
166
- (fully-rounded, used sparingly for prominent CTAs / filters).
228
+ ControlsLadder — every variant × every interaction state, pinned. RAC
229
+ overwrites user-supplied data-* state on the live <Button>, so the pinned
230
+ columns are static spans wearing `buttonStyles(...)` — which is exactly why
231
+ the style function is exported. Rendered on the page ground AND on a white
232
+ surface so raised-vs-muted stays reviewable in every theme.
167
233
  ========================================================================= */
168
234
 
169
- export const Rounded: Story = {
170
- render: () => (
171
- <div className="flex flex-wrap items-center gap-3">
172
- <Button variant="ghost">Filter</Button>
173
- <Button variant="ghost">Group</Button>
174
- <Button>+ New shipment</Button>
235
+ const LADDER: ReadonlyArray<NonNullable<ButtonVariants['variant']>> = [
236
+ 'cta',
237
+ 'primary',
238
+ 'secondary',
239
+ 'ghost',
240
+ 'subtle',
241
+ 'link',
242
+ 'danger',
243
+ ]
244
+
245
+ const STATES = [
246
+ { key: 'rest', title: 'rest', attrs: {} },
247
+ { key: 'hovered', title: 'hover', attrs: { 'data-hovered': '' } },
248
+ { key: 'pressed', title: 'press', attrs: { 'data-pressed': '' } },
249
+ { key: 'focus', title: 'focus', attrs: {}, className: 'shadow-[var(--shadow-focus)]' },
250
+ { key: 'disabled', title: 'disabled', attrs: {}, className: 'opacity-45' },
251
+ ] as const
252
+
253
+ function Ladder({ ground }: { ground: 'bg-bg' | 'bg-surface' }) {
254
+ return (
255
+ <div className={`${ground} rounded-lg border border-rule p-5`}>
256
+ <div className="grid grid-cols-[96px_repeat(5,minmax(0,1fr))] items-center gap-x-4 gap-y-3">
257
+ <div className={label}>{ground === 'bg-bg' ? 'on bg' : 'on surface'}</div>
258
+ {STATES.map((state) => (
259
+ <div key={state.key} className={`${label} text-center`}>
260
+ {state.title}
261
+ </div>
262
+ ))}
263
+ {LADDER.map((variant) => (
264
+ <div key={variant} className="contents">
265
+ <div className="font-mono text-[11px] text-ink-3">{variant}</div>
266
+ <div className="flex justify-center">
267
+ <Button variant={variant} size="sm">
268
+ Label
269
+ </Button>
270
+ </div>
271
+ {STATES.slice(1).map((state) => (
272
+ <div key={state.key} className="flex justify-center">
273
+ <span
274
+ {...state.attrs}
275
+ className={buttonStyles({
276
+ variant,
277
+ size: 'sm',
278
+ className: 'className' in state ? state.className : undefined,
279
+ })}
280
+ >
281
+ Label
282
+ </span>
283
+ </div>
284
+ ))}
285
+ </div>
286
+ ))}
287
+ </div>
175
288
  </div>
176
- ),
289
+ )
177
290
  }
178
291
 
179
- export const Pill: Story = {
292
+ export const ControlsLadder: Story = {
293
+ parameters: { controls: { hideNoControlsWarning: true }, layout: 'padded' },
180
294
  render: () => (
181
- <div className="flex flex-wrap items-center gap-3">
182
- <Button shape="pill" variant="ghost">
183
- Filter
184
- </Button>
185
- <Button shape="pill" variant="ghost">
186
- Group
187
- </Button>
188
- <Button shape="pill">+ New shipment</Button>
295
+ <div className="grid gap-4">
296
+ <Ladder ground="bg-bg" />
297
+ <Ladder ground="bg-surface" />
189
298
  </div>
190
299
  ),
191
300
  }
192
301
 
302
+ /* =========================================================================
303
+ Shape — default `rounded` (soft 10px corners) vs `pill` (fully-rounded,
304
+ used sparingly for prominent CTAs / filters).
305
+ ========================================================================= */
306
+
193
307
  export const Shapes: Story = {
194
308
  parameters: { controls: { hideNoControlsWarning: true } },
195
309
  render: () => (
196
310
  <div className={columns}>
197
311
  <div className={label}>Rounded</div>
198
312
  <div className="flex flex-wrap items-center gap-3">
199
- <Button variant="ghost" size="sm">
313
+ <Button variant="secondary" size="sm">
200
314
  Filter
201
315
  </Button>
202
- <Button variant="ghost">Group</Button>
203
- <Button>+ New shipment</Button>
316
+ <Button>Group</Button>
317
+ <Button variant="cta">
318
+ <Plus /> New shipment
319
+ </Button>
204
320
  </div>
205
321
 
206
322
  <div className={label}>Pill</div>
207
323
  <div className="flex flex-wrap items-center gap-3">
208
- <Button shape="pill" variant="ghost" size="sm">
324
+ <Button shape="pill" variant="secondary" size="sm">
209
325
  Filter
210
326
  </Button>
211
- <Button shape="pill" variant="ghost">
212
- Group
327
+ <Button shape="pill">Group</Button>
328
+ <Button shape="pill" variant="cta">
329
+ <Plus /> New shipment
213
330
  </Button>
214
- <Button shape="pill">+ New shipment</Button>
215
331
  </div>
216
332
  </div>
217
333
  ),
@@ -220,68 +336,57 @@ export const Shapes: Story = {
220
336
  /* =========================================================================
221
337
  Icon-only — square dimensions via `size="icon-sm | icon-md | icon-lg"`.
222
338
  `aria-label` is required (not enforced at the type level — add it manually).
339
+ Raised `primary` is the toolbar register: a lighter inner on the page ground.
223
340
  ========================================================================= */
224
341
 
225
- const Plus = () => (
226
- <svg viewBox="0 0 16 16" fill="none" aria-hidden>
227
- <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
228
- </svg>
229
- )
230
-
231
- const ArrowRight = () => (
232
- <svg viewBox="0 0 16 16" fill="none" aria-hidden>
233
- <path
234
- d="M3 8h10m-4-4 4 4-4 4"
235
- stroke="currentColor"
236
- strokeWidth="1.4"
237
- strokeLinecap="round"
238
- strokeLinejoin="round"
239
- />
240
- </svg>
241
- )
242
-
243
342
  export const IconOnly: Story = {
244
343
  parameters: { controls: { hideNoControlsWarning: true } },
245
344
  render: () => (
246
345
  <div className={columns}>
247
346
  <div className={label}>Sizes</div>
248
347
  <div className="flex items-center gap-3">
249
- <Button size="icon-sm" variant="ghost" aria-label="Add">
348
+ <Button size="icon-sm" aria-label="Add">
250
349
  <Plus />
251
350
  </Button>
252
- <Button size="icon-md" variant="ghost" aria-label="Add">
351
+ <Button size="icon-md" aria-label="Add">
253
352
  <Plus />
254
353
  </Button>
255
- <Button size="icon-lg" variant="ghost" aria-label="Add">
354
+ <Button size="icon-lg" aria-label="Add">
256
355
  <Plus />
257
356
  </Button>
258
357
  </div>
259
358
 
260
359
  <div className={label}>Variants</div>
261
360
  <div className="flex items-center gap-3">
361
+ <Button size="icon-md" variant="cta" aria-label="Next">
362
+ <ArrowRight />
363
+ </Button>
262
364
  <Button size="icon-md" variant="primary" aria-label="Add">
263
365
  <Plus />
264
366
  </Button>
265
- <Button size="icon-md" variant="accent" aria-label="Next">
266
- <ArrowRight />
267
- </Button>
268
367
  <Button size="icon-md" variant="secondary" aria-label="Add">
269
368
  <Plus />
270
369
  </Button>
271
370
  <Button size="icon-md" variant="ghost" aria-label="Add">
272
371
  <Plus />
273
372
  </Button>
373
+ <Button size="icon-md" variant="subtle" aria-label="Add">
374
+ <Plus />
375
+ </Button>
376
+ <Button size="icon-md" variant="danger" aria-label="Remove">
377
+ <Plus />
378
+ </Button>
274
379
  </div>
275
380
 
276
381
  <div className={label}>Pill shape</div>
277
382
  <div className="flex items-center gap-3">
278
- <Button size="icon-sm" variant="ghost" shape="pill" aria-label="Add">
383
+ <Button size="icon-sm" variant="secondary" shape="pill" aria-label="Add">
279
384
  <Plus />
280
385
  </Button>
281
386
  <Button size="icon-md" variant="primary" shape="pill" aria-label="Add">
282
387
  <Plus />
283
388
  </Button>
284
- <Button size="icon-lg" variant="accent" shape="pill" aria-label="Next">
389
+ <Button size="icon-lg" variant="cta" shape="pill" aria-label="Next">
285
390
  <ArrowRight />
286
391
  </Button>
287
392
  </div>
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
2
2
  import userEvent from '@testing-library/user-event'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { Button } from './button'
5
+ import { Button, buttonStyles } from './button'
6
6
 
7
7
  describe('Button', () => {
8
8
  it('renders its label', () => {
@@ -46,14 +46,47 @@ describe('Button', () => {
46
46
  expect(onPress).not.toHaveBeenCalled()
47
47
  })
48
48
 
49
- it('applies the requested variant and size classes', () => {
49
+ it('renders every variant as a button', () => {
50
+ const variants = ['cta', 'accent', 'primary', 'secondary', 'ghost', 'subtle', 'link', 'danger'] as const
50
51
  render(
51
- <Button variant="accent" size="lg">
52
- Publish
52
+ <>
53
+ {variants.map((variant) => (
54
+ <Button key={variant} variant={variant}>
55
+ {variant}
56
+ </Button>
57
+ ))}
58
+ </>,
59
+ )
60
+ for (const variant of variants) {
61
+ expect(screen.getByRole('button', { name: variant })).toBeInTheDocument()
62
+ }
63
+ })
64
+
65
+ it('danger fires onPress like any other variant', async () => {
66
+ const onPress = vi.fn()
67
+ const user = userEvent.setup()
68
+
69
+ render(
70
+ <Button variant="danger" onPress={onPress}>
71
+ Delete record
53
72
  </Button>,
54
73
  )
55
- const button = screen.getByRole('button', { name: 'Publish' })
56
- expect(button.className).toContain('bg-accent')
57
- expect(button.className).toContain('px-[22px]')
74
+ await user.click(screen.getByRole('button', { name: 'Delete record' }))
75
+
76
+ expect(onPress).toHaveBeenCalledOnce()
77
+ })
78
+
79
+ // Contract: `accent` is a frozen alias of `cta`. Variant names are part of
80
+ // the runtime contract for module component bundles, so the alias must keep
81
+ // producing the identical chrome rather than silently diverging.
82
+ it('keeps accent as an exact alias of cta', () => {
83
+ expect(buttonStyles({ variant: 'accent' })).toBe(buttonStyles({ variant: 'cta' }))
84
+ expect(buttonStyles({ variant: 'accent', size: 'sm', shape: 'pill' })).toBe(
85
+ buttonStyles({ variant: 'cta', size: 'sm', shape: 'pill' }),
86
+ )
87
+ })
88
+
89
+ it('defaults to the primary variant', () => {
90
+ expect(buttonStyles({})).toBe(buttonStyles({ variant: 'primary' }))
58
91
  })
59
92
  })
@@ -3,10 +3,56 @@ import { tv, type VariantProps } from 'tailwind-variants'
3
3
 
4
4
  import { cn } from '../../lib/cn'
5
5
 
6
+ /* =========================================================================
7
+ Button — emphasis ladder, top to bottom:
8
+
9
+ cta ink fill. The one strong action per view (never two visible).
10
+ primary raised white: `surface` fill, `rule-strong` hairline, rest
11
+ shadow. The everyday button; the default variant.
12
+ secondary muted: `surface-3` fill, no border. Cancel / supporting actions.
13
+ ghost secondary's chrome, only on hover. Invisible at rest.
14
+ subtle borderless text button (toolbars, icon buttons).
15
+ link inline underline.
16
+ danger solid terracotta for destructive confirms.
17
+
18
+ `accent` is a frozen alias of `cta` — variant names are part of the runtime
19
+ contract for module component bundles, so nothing is ever removed.
20
+
21
+ Interaction state comes from React Aria's data attributes (`hovered:`,
22
+ `pressed:` — see the @custom-variant block in styles.css); `focus-visible:`
23
+ and `disabled:` stay on the native pseudo-classes because the RAC root IS
24
+ the focusable <button>. Hover is always a colour step on the surface ramp,
25
+ never opacity; press is translateY(1px) plus one more ramp step.
26
+ ========================================================================= */
27
+
28
+ // Ink fill with the indigo-tinted hover. Kept verbatim from the old primary.
29
+ const ctaClasses = [
30
+ 'bg-ink text-surface border-ink',
31
+ 'hovered:bg-accent-ink hovered:border-accent-ink',
32
+ ]
33
+
34
+ // Raised white: a lighter inner on the darker page ground. On a white card the
35
+ // hairline + rest shadow carry it. Press sinks one step and drops the shadow.
36
+ const raisedClasses = [
37
+ 'bg-surface text-ink border-rule shadow-[var(--shadow-rest)]',
38
+ 'hovered:bg-surface-2 hovered:border-rule-strong',
39
+ 'pressed:bg-surface-3 pressed:shadow-none',
40
+ 'focus-visible:shadow-[var(--shadow-focus),var(--shadow-rest)]',
41
+ ]
42
+
43
+ // Muted gray fill, no border. One step quieter than primary.
44
+ const mutedClasses = [
45
+ 'bg-surface-3 text-ink-2 border-transparent',
46
+ 'hovered:bg-surface-4 hovered:text-ink',
47
+ 'pressed:bg-[color-mix(in_oklab,var(--color-ink)_8%,var(--color-surface-4))]',
48
+ ]
49
+
6
50
  export const buttonStyles = tv({
7
51
  base: [
8
52
  'inline-flex items-center justify-center gap-2 whitespace-nowrap cursor-pointer',
9
- 'font-mono tracking-[-0.005em] border border-transparent',
53
+ // Button labels are human copy, not machine fact — Inter 500 (DESIGN.md
54
+ // typography.body), not the mono reserved for ids, timestamps and badges.
55
+ 'font-sans font-medium tracking-[-0.005em] border border-transparent',
10
56
  'transition-[background-color,color,border-color,box-shadow,transform] duration-150',
11
57
  'outline-none pressed:translate-y-px',
12
58
  'focus-visible:shadow-[var(--shadow-focus)]',
@@ -14,26 +60,31 @@ export const buttonStyles = tv({
14
60
  ],
15
61
  variants: {
16
62
  variant: {
17
- primary: ['bg-ink text-surface border-ink', 'hover:bg-accent-ink hover:border-accent-ink'],
18
- // Hover uses accent-2 — the token DESIGN.md reserves for "hover on
19
- // accent". accent-ink is a text-on-tinted-surface color and flips
20
- // light in dark mode, so it can't double as a hover fill here.
21
- accent: [
22
- 'bg-accent text-[#f0efe8] border-accent',
23
- 'hover:bg-accent-2 hover:border-accent-2',
24
- ],
25
- secondary: ['bg-transparent text-ink border-ink', 'hover:bg-ink hover:text-surface'],
63
+ cta: ctaClasses,
64
+ accent: ctaClasses,
65
+ primary: raisedClasses,
66
+ secondary: mutedClasses,
26
67
  ghost: [
27
- 'bg-bg text-ink-2 border-rule-strong',
28
- 'hover:bg-surface-2 hover:text-ink hover:border-ink-4',
68
+ 'bg-transparent text-ink-2 border-transparent',
69
+ 'hovered:bg-surface-3 hovered:text-ink',
70
+ 'pressed:bg-surface-4',
71
+ ],
72
+ subtle: [
73
+ 'bg-transparent text-ink-3 border-transparent',
74
+ 'hovered:bg-surface-2 hovered:text-ink',
75
+ 'pressed:bg-surface-3',
29
76
  ],
30
- // Transparent at rest, paper-fill on hover. The toolbar / sidebar
31
- // register — pairs with `size="icon-md"` for top-bar icon buttons.
32
- subtle: ['bg-transparent text-ink-3 border-transparent', 'hover:bg-surface-2 hover:text-ink'],
33
77
  link: [
34
78
  'bg-transparent text-ink-2 border-transparent',
35
79
  'px-0.5 border-b border-b-ink-4',
36
- 'hover:text-ink hover:border-b-ink',
80
+ 'hovered:text-ink hovered:border-b-ink',
81
+ ],
82
+ // `text-surface`, not white: dark's --color-danger is a light salmon, and
83
+ // the surface token flips dark with it while white would fail contrast.
84
+ danger: [
85
+ 'bg-danger text-surface border-danger',
86
+ 'hovered:bg-[color-mix(in_oklab,var(--color-danger)_88%,var(--color-ink))]',
87
+ 'hovered:border-[color-mix(in_oklab,var(--color-danger)_88%,var(--color-ink))]',
37
88
  ],
38
89
  },
39
90
  size: {
@@ -46,8 +97,8 @@ export const buttonStyles = tv({
46
97
  'icon-lg': 'size-[42px] p-0 [&_svg]:size-[18px]',
47
98
  },
48
99
  // Corner shape. Default is rounded (soft 10px radius — the standard
49
- // Proteos button), `pill` is the fully-rounded legacy treatment.
50
- // The `link` variant overrides both to a flat underline.
100
+ // Proteos button), `pill` is the fully-rounded treatment for a small set
101
+ // of prominent CTAs. The `link` variant overrides both to a flat underline.
51
102
  shape: {
52
103
  rounded: 'rounded-md',
53
104
  pill: 'rounded-pill',
@@ -60,6 +111,8 @@ export const buttonStyles = tv({
60
111
  // Link never takes a corner radius — it's an underline, not a box.
61
112
  { variant: 'link', shape: 'rounded', class: 'rounded-none' },
62
113
  { variant: 'link', shape: 'pill', class: 'rounded-none' },
114
+ // Destructive confirms get the terracotta halo instead of the indigo one.
115
+ { variant: 'danger', class: 'focus-visible:shadow-[var(--shadow-focus-danger)]' },
63
116
  ],
64
117
  defaultVariants: {
65
118
  variant: 'primary',
@@ -142,8 +142,8 @@ export function Combobox<T extends Key>({
142
142
  'min-w-[var(--trigger-width)]',
143
143
  'rounded-md border border-rule bg-surface p-1',
144
144
  'shadow-[var(--shadow-raised)]',
145
- 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
146
- 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
145
+ 'entering:animate-enter-slide-down',
146
+ 'exiting:animate-exit-slide-up',
147
147
  )}
148
148
  >
149
149
  <ListBox