@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,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { useState } from 'react'
3
3
 
4
- import { SegmentedControl } from './segmented-control'
4
+ import { SegmentedControl, segmentedControlStyles } from './segmented-control'
5
5
 
6
6
  const meta: Meta<typeof SegmentedControl> = {
7
7
  title: 'Components/SegmentedControl',
@@ -19,8 +19,8 @@ export const AndOrJoiner: Story = {
19
19
  <SegmentedControl
20
20
  aria-label="Joiner"
21
21
  options={[
22
- { id: 'and', label: 'AND' },
23
- { id: 'or', label: 'OR' },
22
+ { id: 'and', label: 'and' },
23
+ { id: 'or', label: 'or' },
24
24
  ]}
25
25
  value={value}
26
26
  onChange={setValue}
@@ -49,15 +49,15 @@ export const BooleanTrueFalse: Story = {
49
49
 
50
50
  export const ThreeSegments: Story = {
51
51
  render: () => {
52
- const [value, setValue] = useState<'compact' | 'cozy' | 'comfy'>('cozy')
52
+ const [value, setValue] = useState<'list' | 'board' | 'timeline'>('list')
53
53
  return (
54
54
  <SegmentedControl
55
55
  size="md"
56
- aria-label="Density"
56
+ aria-label="View"
57
57
  options={[
58
- { id: 'compact', label: 'Compact' },
59
- { id: 'cozy', label: 'Cozy' },
60
- { id: 'comfy', label: 'Comfy' },
58
+ { id: 'list', label: 'List' },
59
+ { id: 'board', label: 'Board' },
60
+ { id: 'timeline', label: 'Timeline' },
61
61
  ]}
62
62
  value={value}
63
63
  onChange={setValue}
@@ -65,3 +65,129 @@ export const ThreeSegments: Story = {
65
65
  )
66
66
  },
67
67
  }
68
+
69
+ const label = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
70
+
71
+ export const Sizes: Story = {
72
+ render: () => {
73
+ const [value, setValue] = useState<'list' | 'board'>('list')
74
+ const options = [
75
+ { id: 'list' as const, label: 'List' },
76
+ { id: 'board' as const, label: 'Board' },
77
+ ]
78
+ return (
79
+ <div className="grid grid-cols-[80px_1fr] items-center gap-x-6 gap-y-4">
80
+ <div className={label}>sm</div>
81
+ <SegmentedControl aria-label="View (sm)" size="sm" options={options} value={value} onChange={setValue} />
82
+ <div className={label}>md</div>
83
+ <SegmentedControl aria-label="View (md)" size="md" options={options} value={value} onChange={setValue} />
84
+ </div>
85
+ )
86
+ },
87
+ }
88
+
89
+ const Glyph = ({ d }: { d: string }) => (
90
+ <svg viewBox="0 0 16 16" fill="none" className="size-3" aria-hidden>
91
+ <path d={d} stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
92
+ </svg>
93
+ )
94
+
95
+ export const WithIcons: Story = {
96
+ render: () => {
97
+ const [value, setValue] = useState<'base' | 'sm'>('base')
98
+ return (
99
+ <SegmentedControl
100
+ aria-label="Viewport"
101
+ options={[
102
+ { id: 'base', label: <Glyph d="M2 4h12v8H2zM6 14h4" />, 'aria-label': 'Desktop' },
103
+ { id: 'sm', label: <Glyph d="M5 2h6v12H5zM7 12h2" />, 'aria-label': 'Phone' },
104
+ ]}
105
+ value={value}
106
+ onChange={setValue}
107
+ />
108
+ )
109
+ },
110
+ }
111
+
112
+ export const Wrap: Story = {
113
+ parameters: { layout: 'padded' },
114
+ render: () => {
115
+ const [value, setValue] = useState<string>('1/2')
116
+ const options = ['auto', '1/4', '1/3', '1/2', '2/3', '3/4', 'full', 'fit', 'fill', 'grow', 'shrink', 'none'].map(
117
+ (id) => ({ id, label: id }),
118
+ )
119
+ return (
120
+ <div className="w-[240px]">
121
+ <SegmentedControl aria-label="Column span" wrap options={options} value={value} onChange={setValue} />
122
+ </div>
123
+ )
124
+ },
125
+ }
126
+
127
+ export const DisabledOption: Story = {
128
+ render: () => {
129
+ const [value, setValue] = useState<'draft' | 'live' | 'archived'>('draft')
130
+ return (
131
+ <SegmentedControl
132
+ size="md"
133
+ aria-label="Status"
134
+ options={[
135
+ { id: 'draft', label: 'Draft' },
136
+ { id: 'live', label: 'Live' },
137
+ { id: 'archived', label: 'Archived', isDisabled: true },
138
+ ]}
139
+ value={value}
140
+ onChange={setValue}
141
+ />
142
+ )
143
+ },
144
+ }
145
+
146
+ export const Unselected: Story = {
147
+ render: () => {
148
+ const [value, setValue] = useState<'yes' | 'no' | null>(null)
149
+ return (
150
+ <SegmentedControl
151
+ aria-label="Answer"
152
+ options={[
153
+ { id: 'yes', label: 'Yes' },
154
+ { id: 'no', label: 'No' },
155
+ ]}
156
+ value={value}
157
+ onChange={setValue}
158
+ />
159
+ )
160
+ },
161
+ }
162
+
163
+ /* Pinned states on static spans (RAC owns the live data-* attributes). */
164
+ export const States: Story = {
165
+ parameters: { controls: { hideNoControlsWarning: true } },
166
+ render: () => {
167
+ const styles = segmentedControlStyles({ size: 'md' })
168
+ const pinned = [
169
+ { title: 'rest', attrs: {} },
170
+ { title: 'hover', attrs: { 'data-hovered': '' } },
171
+ { title: 'press', attrs: { 'data-pressed': '' } },
172
+ { title: 'selected', attrs: { 'data-selected': '' } },
173
+ { title: 'selected + focus', attrs: { 'data-selected': '', 'data-focus-visible': '' } },
174
+ { title: 'disabled', attrs: { 'data-disabled': '' } },
175
+ ]
176
+ return (
177
+ <div className="grid grid-cols-[140px_1fr] items-center gap-x-6 gap-y-3">
178
+ {pinned.map((state) => (
179
+ <div key={state.title} className="contents">
180
+ <div className={label}>{state.title}</div>
181
+ <div className={styles.group()}>
182
+ <span className={styles.segment()}>List</span>
183
+ <span {...state.attrs} className={styles.segment()}>
184
+ Board
185
+ </span>
186
+ <span className={styles.segment()}>Timeline</span>
187
+ </div>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ )
192
+ },
193
+ }
@@ -5,14 +5,22 @@ import { describe, expect, it, vi } from 'vitest'
5
5
 
6
6
  import { SegmentedControl } from './segmented-control'
7
7
 
8
- function Harness({ onChange }: { onChange?: (v: 'and' | 'or') => void }) {
9
- const [value, setValue] = useState<'and' | 'or'>('and')
8
+ type Joiner = 'and' | 'or'
9
+
10
+ function Harness({
11
+ onChange,
12
+ initial = 'and',
13
+ }: {
14
+ onChange?: (v: Joiner) => void
15
+ initial?: Joiner | null
16
+ }) {
17
+ const [value, setValue] = useState<Joiner | null>(initial)
10
18
  return (
11
19
  <SegmentedControl
12
20
  aria-label="Joiner"
13
21
  options={[
14
- { id: 'and', label: 'AND' },
15
- { id: 'or', label: 'OR' },
22
+ { id: 'and', label: 'and' },
23
+ { id: 'or', label: 'or' },
16
24
  ]}
17
25
  value={value}
18
26
  onChange={(next) => {
@@ -32,16 +40,81 @@ describe('SegmentedControl', () => {
32
40
 
33
41
  it('reflects the controlled value', () => {
34
42
  render(<Harness />)
35
- expect(screen.getByRole('radio', { name: 'AND' })).toBeChecked()
36
- expect(screen.getByRole('radio', { name: 'OR' })).not.toBeChecked()
43
+ expect(screen.getByRole('radio', { name: 'and' })).toBeChecked()
44
+ expect(screen.getByRole('radio', { name: 'or' })).not.toBeChecked()
37
45
  })
38
46
 
39
47
  it('changes value on click', async () => {
40
48
  const user = userEvent.setup()
41
49
  const onChange = vi.fn()
42
50
  render(<Harness onChange={onChange} />)
43
- await user.click(screen.getByRole('radio', { name: 'OR' }))
51
+ await user.click(screen.getByRole('radio', { name: 'or' }))
52
+ expect(onChange).toHaveBeenLastCalledWith('or')
53
+ expect(screen.getByRole('radio', { name: 'or' })).toBeChecked()
54
+ })
55
+
56
+ it('moves selection with the arrow keys', async () => {
57
+ const user = userEvent.setup()
58
+ const onChange = vi.fn()
59
+ render(<Harness onChange={onChange} />)
60
+ await user.tab()
61
+ expect(screen.getByRole('radio', { name: 'and' })).toHaveFocus()
62
+ await user.keyboard('{ArrowRight}')
44
63
  expect(onChange).toHaveBeenLastCalledWith('or')
45
- expect(screen.getByRole('radio', { name: 'OR' })).toBeChecked()
64
+ expect(screen.getByRole('radio', { name: 'or' })).toBeChecked()
65
+ })
66
+
67
+ it('renders no selected segment for a null value', () => {
68
+ render(<Harness initial={null} />)
69
+ for (const radio of screen.getAllByRole('radio')) {
70
+ expect(radio).not.toBeChecked()
71
+ }
72
+ })
73
+
74
+ it('does not select a disabled option', async () => {
75
+ const user = userEvent.setup()
76
+ const onChange = vi.fn()
77
+ function Disabled() {
78
+ const [value, setValue] = useState<'a' | 'b'>('a')
79
+ return (
80
+ <SegmentedControl
81
+ aria-label="Mode"
82
+ options={[
83
+ { id: 'a', label: 'A' },
84
+ { id: 'b', label: 'B', isDisabled: true },
85
+ ]}
86
+ value={value}
87
+ onChange={(next) => {
88
+ setValue(next)
89
+ onChange(next)
90
+ }}
91
+ />
92
+ )
93
+ }
94
+ render(<Disabled />)
95
+ await user.click(screen.getByRole('radio', { name: 'B' }))
96
+ expect(onChange).not.toHaveBeenCalled()
97
+ expect(screen.getByRole('radio', { name: 'A' })).toBeChecked()
98
+ })
99
+
100
+ it('names icon-only segments through aria-label', () => {
101
+ function Icons() {
102
+ const [value, setValue] = useState<'list' | 'board'>('list')
103
+ return (
104
+ <SegmentedControl
105
+ aria-label="View"
106
+ wrap
107
+ options={[
108
+ { id: 'list', label: <svg aria-hidden />, 'aria-label': 'List' },
109
+ { id: 'board', label: <svg aria-hidden />, 'aria-label': 'Board' },
110
+ ]}
111
+ value={value}
112
+ onChange={setValue}
113
+ />
114
+ )
115
+ }
116
+ render(<Icons />)
117
+ expect(screen.getByRole('radio', { name: 'List' })).toBeChecked()
118
+ expect(screen.getByRole('radio', { name: 'Board' })).toBeInTheDocument()
46
119
  })
47
120
  })
@@ -9,11 +9,16 @@ import { tv, type VariantProps } from 'tailwind-variants'
9
9
  import { cn } from '../../lib/cn'
10
10
 
11
11
  /* =========================================================================
12
- SegmentedControl — small horizontal exclusive-choice group with pill
13
- chrome. Wraps RAC RadioGroup so keyboard nav and focus management are
14
- correct (arrow keys move between segments). Used for compact toggles
15
- like AND/OR joiners and true/false booleans where a full RadioGroup
12
+ SegmentedControl — small horizontal exclusive-choice group: a recessed
13
+ `surface-2` track with the selected segment lifted as a white pill. Wraps
14
+ RAC RadioGroup so keyboard nav and focus management are correct (arrow keys
15
+ move between segments, one tab stop). Used for compact toggles like view
16
+ switchers, AND/OR joiners and true/false booleans where a full RadioGroup
16
17
  feels heavy.
18
+
19
+ Radii are concentric: 9px (chip) track − 2px padding − 1px hairline = 6px
20
+ (sm) pill. Labels are mode names, so they follow buttons: Inter 500,
21
+ sentence case — not the uppercase mono reserved for eyebrows.
17
22
  ========================================================================= */
18
23
 
19
24
  export const segmentedControlStyles = tv({
@@ -22,28 +27,35 @@ export const segmentedControlStyles = tv({
22
27
  // stretching parent (e.g. a `flex flex-col` card body, whose default
23
28
  // align-items:stretch would otherwise pull it to full width). Consumers
24
29
  // can still opt into full width by passing `w-full` via className.
25
- group: 'inline-flex w-fit rounded-sm border border-rule-strong bg-surface p-0.5 gap-0.5',
30
+ group: 'inline-flex w-fit gap-px p-0.5 rounded-chip bg-surface-2 border border-rule',
26
31
  segment: [
27
- 'inline-flex items-center justify-center cursor-pointer',
28
- 'font-mono tracking-[0.04em] rounded-[3px] outline-none select-none',
29
- 'transition-[background-color,color] duration-150',
30
- 'data-[hovered]:text-ink',
31
- 'data-[selected]:bg-ink data-[selected]:text-surface',
32
+ 'inline-flex items-center justify-center gap-1 cursor-pointer select-none',
33
+ 'font-sans font-medium tracking-[-0.005em] rounded-sm outline-none text-ink-3',
34
+ 'transition-[background-color,color,box-shadow] duration-150',
35
+ // Unselected: hover/press tint into the track colour. `selected` is
36
+ // declared after `hovered` in styles.css, so the pill wins over these.
37
+ 'hovered:text-ink hovered:bg-[color-mix(in_oklab,var(--color-ink)_6%,var(--color-surface-2))]',
38
+ 'pressed:bg-[color-mix(in_oklab,var(--color-ink)_10%,var(--color-surface-2))]',
39
+ // Selected: lifted white pill, static.
40
+ 'selected:bg-surface selected:text-ink selected:shadow-[var(--shadow-rest)]',
32
41
  'data-[focus-visible]:shadow-[var(--shadow-focus)]',
42
+ 'selected:data-[focus-visible]:shadow-[var(--shadow-focus),var(--shadow-rest)]',
33
43
  'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
34
44
  ],
35
45
  },
36
46
  variants: {
37
47
  size: {
38
- sm: { segment: 'px-2 py-0.5 text-[10.5px] tracking-[0.08em]' },
39
- md: { segment: 'px-2.5 py-1 text-[12px]' },
48
+ sm: { segment: 'px-2 py-0.5 text-[11.5px] min-h-[22px]' },
49
+ md: { segment: 'px-2.5 py-1 text-[12.5px] min-h-[26px]' },
40
50
  },
41
- tone: {
42
- // Default: ink fill on selected.
43
- default: { segment: 'text-ink-3' },
51
+ // Dense flow-wrapping register (designer inspector): the group becomes a
52
+ // wrapping flex row and every segment grows to share the line.
53
+ wrap: {
54
+ true: { group: 'flex flex-wrap w-auto', segment: 'flex-1 min-w-7 px-1.5' },
55
+ false: {},
44
56
  },
45
57
  },
46
- defaultVariants: { size: 'sm', tone: 'default' },
58
+ defaultVariants: { size: 'sm', wrap: false },
47
59
  })
48
60
 
49
61
  export type SegmentedControlVariants = VariantProps<typeof segmentedControlStyles>
@@ -52,6 +64,8 @@ export type SegmentedControlOption<T extends Key = Key> = {
52
64
  id: T
53
65
  label: ReactNode
54
66
  isDisabled?: boolean
67
+ /** Accessible name for icon-only segments. */
68
+ 'aria-label'?: string
55
69
  }
56
70
 
57
71
  export type SegmentedControlProps<T extends Key = Key> = Omit<
@@ -59,9 +73,12 @@ export type SegmentedControlProps<T extends Key = Key> = Omit<
59
73
  'children' | 'value' | 'onChange'
60
74
  > & {
61
75
  options: ReadonlyArray<SegmentedControlOption<T>>
62
- value: T
76
+ /** `null` renders no selected segment. */
77
+ value: T | null
63
78
  onChange: (value: T) => void
64
79
  size?: SegmentedControlVariants['size']
80
+ /** Flow-wrap long option lists; segments share the line equally. */
81
+ wrap?: boolean
65
82
  /** Focus the selected segment on mount. RAC's RadioGroup doesn't surface
66
83
  * `autoFocus` itself — it's a per-Radio prop — so we apply it to the
67
84
  * Radio whose value matches `value`. */
@@ -75,17 +92,18 @@ export function SegmentedControl<T extends Key>({
75
92
  value,
76
93
  onChange,
77
94
  size,
95
+ wrap = false,
78
96
  autoFocus = false,
79
97
  className,
80
98
  orientation = 'horizontal',
81
99
  ...props
82
100
  }: SegmentedControlProps<T>) {
83
- const styles = segmentedControlStyles({ size })
101
+ const styles = segmentedControlStyles({ size, wrap })
84
102
  return (
85
103
  <AriaRadioGroup
86
104
  {...props}
87
105
  orientation={orientation}
88
- value={String(value)}
106
+ value={value === null ? null : String(value)}
89
107
  onChange={(next) => onChange(next as T)}
90
108
  className={cn(styles.group(), className)}
91
109
  >
@@ -94,6 +112,7 @@ export function SegmentedControl<T extends Key>({
94
112
  key={String(option.id)}
95
113
  value={String(option.id)}
96
114
  {...(option.isDisabled ? { isDisabled: true } : {})}
115
+ {...(option['aria-label'] !== undefined ? { 'aria-label': option['aria-label'] } : {})}
97
116
  {...(autoFocus && option.id === value ? { autoFocus: true } : {})}
98
117
  className={styles.segment()}
99
118
  >
@@ -116,8 +116,8 @@ export function Select<T extends Key>({
116
116
  'z-[1300]',
117
117
  'rounded-md border border-rule bg-surface p-1',
118
118
  'shadow-[var(--shadow-raised)]',
119
- 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
120
- 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
119
+ 'entering:animate-enter-slide-down',
120
+ 'exiting:animate-exit-slide-up',
121
121
  popoverClassName,
122
122
  )}
123
123
  >
@@ -78,7 +78,7 @@ export function TimeField<T extends TimeValue = TimeValue>({
78
78
  'data-[placeholder]:text-ink-4',
79
79
  'data-[type=literal]:text-ink-4',
80
80
  // segment focus treatment: indigo fill, paper text
81
- 'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
81
+ 'data-[focused]:bg-accent data-[focused]:text-tile-fg data-[focused]:rounded-[3px]',
82
82
  'data-[disabled]:cursor-not-allowed',
83
83
  )}
84
84
  />
@@ -45,8 +45,8 @@ export const toastStyles = tv({
45
45
  'rounded-lg border border-rule bg-surface shadow-float',
46
46
  'px-3.5 py-3 outline-none',
47
47
  'focus-visible:shadow-focus',
48
- 'entering:animate-in entering:fade-in-0 entering:slide-in-from-bottom-2 entering:duration-200',
49
- 'exiting:animate-out exiting:fade-out-0 exiting:duration-150',
48
+ 'entering:animate-enter-slide-up',
49
+ 'exiting:animate-exit-fade',
50
50
  ],
51
51
  icon: 'size-4 shrink-0 mt-0.5',
52
52
  title: 'font-sans text-[13px] font-medium leading-[1.4] text-ink',
@@ -46,8 +46,8 @@ export function Tooltip({ className, offset = 6, ...props }: TooltipProps) {
46
46
  'rounded-md border border-rule bg-surface shadow-[var(--shadow-raised)]',
47
47
  'px-2.5 py-1.5',
48
48
  'font-mono text-[11.5px] text-ink-2',
49
- 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1 entering:duration-150',
50
- 'exiting:animate-out exiting:fade-out-0 exiting:duration-100',
49
+ 'entering:animate-enter-slide-down',
50
+ 'exiting:animate-exit-fade',
51
51
  typeof className === 'function' ? className(renderProps) : className,
52
52
  )
53
53
  }
package/src/styles.css CHANGED
@@ -14,6 +14,18 @@
14
14
  to *this* file. */
15
15
  @source "./components/**/*.{ts,tsx}";
16
16
 
17
+ /* React Aria state variants. RAC exposes interaction state as data-* attributes
18
+ (pointer-correct: no sticky :hover on touch; press follows usePress). Same names
19
+ as tailwindcss-react-aria-components, without the dependency. `focus-visible:`
20
+ and `disabled:` stay core. Order matters: `selected` is declared after `hovered`
21
+ so a selected segment's fill wins over its hover fill at equal specificity. */
22
+ @custom-variant hovered (&[data-hovered]);
23
+ @custom-variant pressed (&[data-pressed]);
24
+ @custom-variant selected (&[data-selected]);
25
+ @custom-variant focused (&[data-focused]);
26
+ @custom-variant entering (&[data-entering]);
27
+ @custom-variant exiting (&[data-exiting]);
28
+
17
29
  /* ==========================================================
18
30
  PROTEOS · INDIGO — design tokens
19
31
  Mirrors Proteos Design System v0.1 (spec: revision 2026.04.24).
@@ -37,6 +49,8 @@
37
49
  --color-surface: #ffffff;
38
50
  --color-surface-2: #f0f0f2;
39
51
  --color-surface-3: #e5e5e9;
52
+ /* Deepest recessed step — hover on gray fills (muted buttons). */
53
+ --color-surface-4: #d9d9df;
40
54
 
41
55
  /* Ink — indigo-tinted slate */
42
56
  --color-ink: #1a1d2b;
@@ -124,6 +138,17 @@
124
138
  status icons. Soft ease, no overshoot (nothing bounces). */
125
139
  --animate-status-pop: status-pop var(--duration-slow) var(--ease-out-soft) both;
126
140
 
141
+ /* Overlay motion — own keyframes. tw-animate-css was never installed, so the
142
+ `animate-in fade-in-0 …` strings compiled to nothing. `both` holds the end
143
+ frame while RAC keeps the element in data-exiting until unmount. */
144
+ --animate-enter-fade: enter-fade var(--duration-fast) ease both;
145
+ --animate-enter-slide-down: enter-slide-down var(--duration-fast) ease both;
146
+ --animate-enter-slide-up: enter-slide-up var(--duration-base) ease both;
147
+ --animate-enter-zoom: enter-zoom var(--duration-fast) ease both;
148
+ --animate-exit-fade: exit-fade 100ms ease both;
149
+ --animate-exit-slide-up: exit-slide-up 100ms ease both;
150
+ --animate-exit-zoom: exit-zoom 100ms ease both;
151
+
127
152
  }
128
153
 
129
154
  @keyframes indeterminate-progress {
@@ -149,6 +174,37 @@
149
174
  }
150
175
  }
151
176
 
177
+ @keyframes enter-fade {
178
+ from { opacity: 0; }
179
+ }
180
+ @keyframes enter-slide-down {
181
+ from { opacity: 0; transform: translateY(-4px); }
182
+ }
183
+ @keyframes enter-slide-up {
184
+ from { opacity: 0; transform: translateY(8px); }
185
+ }
186
+ @keyframes enter-zoom {
187
+ from { opacity: 0; transform: scale(0.95); }
188
+ }
189
+ @keyframes exit-fade {
190
+ to { opacity: 0; }
191
+ }
192
+ @keyframes exit-slide-up {
193
+ to { opacity: 0; transform: translateY(-4px); }
194
+ }
195
+ @keyframes exit-zoom {
196
+ to { opacity: 0; transform: scale(0.95); }
197
+ }
198
+
199
+ @media (prefers-reduced-motion: reduce) {
200
+ [data-entering],
201
+ [data-exiting],
202
+ [class*="animate-enter-"],
203
+ [class*="animate-exit-"] {
204
+ animation: none;
205
+ }
206
+ }
207
+
152
208
  /* Raw variables (non-color) for direct component consumption.
153
209
  Tailwind v4 mirrors @theme into :root for colors/radii/shadows, but we
154
210
  also expose spacing tokens here for components that need raw var() access. */
@@ -201,6 +257,7 @@
201
257
  --color-surface: #f2f0ea;
202
258
  --color-surface-2: #e7e4db;
203
259
  --color-surface-3: #dcd8cc;
260
+ --color-surface-4: #d0ccbf;
204
261
 
205
262
  --color-ink: #1a1d2b;
206
263
  --color-ink-2: #353a4b;
@@ -240,6 +297,7 @@
240
297
  --color-surface: #191c24;
241
298
  --color-surface-2: #222633;
242
299
  --color-surface-3: #2c3140;
300
+ --color-surface-4: #363b4c;
243
301
 
244
302
  --color-ink: #e9eaf0;
245
303
  --color-ink-2: #c5c8d4;
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/lib/cn.ts","../src/components/button/button.tsx","../src/components/skeleton/skeleton.tsx"],"names":["AriaButton","tv","jsx"],"mappings":"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","file":"chunk-IKAX76MB.js","sourcesContent":["import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs))\n}\n","import { Button as AriaButton, type ButtonProps as AriaButtonProps } from 'react-aria-components'\nimport { tv, type VariantProps } from 'tailwind-variants'\n\nimport { cn } from '../../lib/cn'\n\nexport const buttonStyles = tv({\n base: [\n 'inline-flex items-center justify-center gap-2 whitespace-nowrap cursor-pointer',\n 'font-mono tracking-[-0.005em] border border-transparent',\n 'transition-[background-color,color,border-color,box-shadow,transform] duration-150',\n 'outline-none pressed:translate-y-px',\n 'focus-visible:shadow-[var(--shadow-focus)]',\n 'disabled:opacity-45 disabled:cursor-not-allowed disabled:pointer-events-none',\n ],\n variants: {\n variant: {\n primary: ['bg-ink text-surface border-ink', 'hover:bg-accent-ink hover:border-accent-ink'],\n // Hover uses accent-2 — the token DESIGN.md reserves for \"hover on\n // accent\". accent-ink is a text-on-tinted-surface color and flips\n // light in dark mode, so it can't double as a hover fill here.\n accent: [\n 'bg-accent text-[#f0efe8] border-accent',\n 'hover:bg-accent-2 hover:border-accent-2',\n ],\n secondary: ['bg-transparent text-ink border-ink', 'hover:bg-ink hover:text-surface'],\n ghost: [\n 'bg-bg text-ink-2 border-rule-strong',\n 'hover:bg-surface-2 hover:text-ink hover:border-ink-4',\n ],\n // Transparent at rest, paper-fill on hover. The toolbar / sidebar\n // register — pairs with `size=\"icon-md\"` for top-bar icon buttons.\n subtle: ['bg-transparent text-ink-3 border-transparent', 'hover:bg-surface-2 hover:text-ink'],\n link: [\n 'bg-transparent text-ink-2 border-transparent',\n 'px-0.5 border-b border-b-ink-4',\n 'hover:text-ink hover:border-b-ink',\n ],\n },\n size: {\n sm: 'text-xs px-3 py-1.5 [&_svg]:size-3',\n md: 'text-[13px] px-4 py-[9px] [&_svg]:size-3',\n lg: 'text-sm px-[22px] py-3 [&_svg]:size-3.5',\n // Square dimensions for icon-only buttons. Requires an `aria-label`.\n 'icon-sm': 'size-7 p-0 [&_svg]:size-3.5',\n 'icon-md': 'size-[34px] p-0 [&_svg]:size-4',\n 'icon-lg': 'size-[42px] p-0 [&_svg]:size-[18px]',\n },\n // Corner shape. Default is rounded (soft 10px radius — the standard\n // Proteos button), `pill` is the fully-rounded legacy treatment.\n // The `link` variant overrides both to a flat underline.\n shape: {\n rounded: 'rounded-md',\n pill: 'rounded-pill',\n },\n },\n compoundVariants: [\n { variant: 'link', size: 'sm', class: 'px-0.5 py-0' },\n { variant: 'link', size: 'md', class: 'px-0.5 py-0' },\n { variant: 'link', size: 'lg', class: 'px-0.5 py-0' },\n // Link never takes a corner radius — it's an underline, not a box.\n { variant: 'link', shape: 'rounded', class: 'rounded-none' },\n { variant: 'link', shape: 'pill', class: 'rounded-none' },\n ],\n defaultVariants: {\n variant: 'primary',\n size: 'md',\n shape: 'rounded',\n },\n})\n\nexport type ButtonVariants = VariantProps<typeof buttonStyles>\nexport type ButtonProps = AriaButtonProps & ButtonVariants\n\nexport function Button({ variant, size, shape, className, ...props }: ButtonProps) {\n return (\n <AriaButton\n {...props}\n className={(renderProps) =>\n cn(\n buttonStyles({ variant, size, shape }),\n typeof className === 'function' ? className(renderProps) : className,\n )\n }\n />\n )\n}\n","import type { HTMLAttributes } from 'react'\nimport { tv, type VariantProps } from 'tailwind-variants'\n\nimport { cn } from '../../lib/cn'\n\n/* =========================================================================\n Skeleton — content placeholder for loading states.\n Purely presentational, so it doesn't wrap a React Aria primitive (RAC has\n no Skeleton — interactive primitives only). Accessibility lives in props:\n - decorative skeletons get `aria-hidden` so screen readers ignore them\n - skeletons that *announce* the loading state should be wrapped in a\n `role=\"status\"` region by the consumer (e.g. AppLayoutSkeleton).\n ========================================================================= */\n\nexport const skeletonStyles = tv({\n base: ['animate-pulse', 'bg-surface-3'],\n variants: {\n shape: {\n rect: 'rounded-md',\n circle: 'rounded-full',\n // `text` matches a typical line of body text — width is up to the\n // consumer; the rounded-sm + height keep proportions sensible.\n text: 'h-4 w-full rounded-sm',\n },\n },\n defaultVariants: { shape: 'rect' },\n})\n\nexport type SkeletonVariants = VariantProps<typeof skeletonStyles>\nexport type SkeletonProps = HTMLAttributes<HTMLDivElement> & SkeletonVariants\n\nexport function Skeleton({ shape, className, ...props }: SkeletonProps) {\n return (\n <div\n // Decorative by default. Consumers that want screen readers to announce\n // the loading state should wrap a Skeleton group in `role=\"status\"`\n // and pass `aria-hidden={false}` here, or just rely on the wrapper.\n aria-hidden=\"true\"\n {...props}\n className={cn(skeletonStyles({ shape }), className)}\n />\n )\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/lib/cn.ts","../src/components/button/button.tsx","../src/components/skeleton/skeleton.tsx"],"names":["twMerge","clsx","tv","jsx","AriaButton"],"mappings":"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","file":"chunk-MWAXK66H.cjs","sourcesContent":["import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs))\n}\n","import { Button as AriaButton, type ButtonProps as AriaButtonProps } from 'react-aria-components'\nimport { tv, type VariantProps } from 'tailwind-variants'\n\nimport { cn } from '../../lib/cn'\n\nexport const buttonStyles = tv({\n base: [\n 'inline-flex items-center justify-center gap-2 whitespace-nowrap cursor-pointer',\n 'font-mono tracking-[-0.005em] border border-transparent',\n 'transition-[background-color,color,border-color,box-shadow,transform] duration-150',\n 'outline-none pressed:translate-y-px',\n 'focus-visible:shadow-[var(--shadow-focus)]',\n 'disabled:opacity-45 disabled:cursor-not-allowed disabled:pointer-events-none',\n ],\n variants: {\n variant: {\n primary: ['bg-ink text-surface border-ink', 'hover:bg-accent-ink hover:border-accent-ink'],\n // Hover uses accent-2 — the token DESIGN.md reserves for \"hover on\n // accent\". accent-ink is a text-on-tinted-surface color and flips\n // light in dark mode, so it can't double as a hover fill here.\n accent: [\n 'bg-accent text-[#f0efe8] border-accent',\n 'hover:bg-accent-2 hover:border-accent-2',\n ],\n secondary: ['bg-transparent text-ink border-ink', 'hover:bg-ink hover:text-surface'],\n ghost: [\n 'bg-bg text-ink-2 border-rule-strong',\n 'hover:bg-surface-2 hover:text-ink hover:border-ink-4',\n ],\n // Transparent at rest, paper-fill on hover. The toolbar / sidebar\n // register — pairs with `size=\"icon-md\"` for top-bar icon buttons.\n subtle: ['bg-transparent text-ink-3 border-transparent', 'hover:bg-surface-2 hover:text-ink'],\n link: [\n 'bg-transparent text-ink-2 border-transparent',\n 'px-0.5 border-b border-b-ink-4',\n 'hover:text-ink hover:border-b-ink',\n ],\n },\n size: {\n sm: 'text-xs px-3 py-1.5 [&_svg]:size-3',\n md: 'text-[13px] px-4 py-[9px] [&_svg]:size-3',\n lg: 'text-sm px-[22px] py-3 [&_svg]:size-3.5',\n // Square dimensions for icon-only buttons. Requires an `aria-label`.\n 'icon-sm': 'size-7 p-0 [&_svg]:size-3.5',\n 'icon-md': 'size-[34px] p-0 [&_svg]:size-4',\n 'icon-lg': 'size-[42px] p-0 [&_svg]:size-[18px]',\n },\n // Corner shape. Default is rounded (soft 10px radius — the standard\n // Proteos button), `pill` is the fully-rounded legacy treatment.\n // The `link` variant overrides both to a flat underline.\n shape: {\n rounded: 'rounded-md',\n pill: 'rounded-pill',\n },\n },\n compoundVariants: [\n { variant: 'link', size: 'sm', class: 'px-0.5 py-0' },\n { variant: 'link', size: 'md', class: 'px-0.5 py-0' },\n { variant: 'link', size: 'lg', class: 'px-0.5 py-0' },\n // Link never takes a corner radius — it's an underline, not a box.\n { variant: 'link', shape: 'rounded', class: 'rounded-none' },\n { variant: 'link', shape: 'pill', class: 'rounded-none' },\n ],\n defaultVariants: {\n variant: 'primary',\n size: 'md',\n shape: 'rounded',\n },\n})\n\nexport type ButtonVariants = VariantProps<typeof buttonStyles>\nexport type ButtonProps = AriaButtonProps & ButtonVariants\n\nexport function Button({ variant, size, shape, className, ...props }: ButtonProps) {\n return (\n <AriaButton\n {...props}\n className={(renderProps) =>\n cn(\n buttonStyles({ variant, size, shape }),\n typeof className === 'function' ? className(renderProps) : className,\n )\n }\n />\n )\n}\n","import type { HTMLAttributes } from 'react'\nimport { tv, type VariantProps } from 'tailwind-variants'\n\nimport { cn } from '../../lib/cn'\n\n/* =========================================================================\n Skeleton — content placeholder for loading states.\n Purely presentational, so it doesn't wrap a React Aria primitive (RAC has\n no Skeleton — interactive primitives only). Accessibility lives in props:\n - decorative skeletons get `aria-hidden` so screen readers ignore them\n - skeletons that *announce* the loading state should be wrapped in a\n `role=\"status\"` region by the consumer (e.g. AppLayoutSkeleton).\n ========================================================================= */\n\nexport const skeletonStyles = tv({\n base: ['animate-pulse', 'bg-surface-3'],\n variants: {\n shape: {\n rect: 'rounded-md',\n circle: 'rounded-full',\n // `text` matches a typical line of body text — width is up to the\n // consumer; the rounded-sm + height keep proportions sensible.\n text: 'h-4 w-full rounded-sm',\n },\n },\n defaultVariants: { shape: 'rect' },\n})\n\nexport type SkeletonVariants = VariantProps<typeof skeletonStyles>\nexport type SkeletonProps = HTMLAttributes<HTMLDivElement> & SkeletonVariants\n\nexport function Skeleton({ shape, className, ...props }: SkeletonProps) {\n return (\n <div\n // Decorative by default. Consumers that want screen readers to announce\n // the loading state should wrap a Skeleton group in `role=\"status\"`\n // and pass `aria-hidden={false}` here, or just rely on the wrapper.\n aria-hidden=\"true\"\n {...props}\n className={cn(skeletonStyles({ shape }), className)}\n />\n )\n}\n"]}