@sovereignfs/ui 0.41.0 → 0.45.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 (174) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/Marker.module.css +33 -0
  23. package/dist/Menu.module.css +8 -1
  24. package/dist/Message.module.css +99 -0
  25. package/dist/MessageScroller.module.css +54 -0
  26. package/dist/NavigationMenu.module.css +53 -0
  27. package/dist/Pagination.module.css +98 -0
  28. package/dist/Progress.module.css +20 -0
  29. package/dist/RadioGroup.module.css +88 -0
  30. package/dist/Resizable.module.css +70 -0
  31. package/dist/ScrollArea.module.css +20 -0
  32. package/dist/Select.module.css +7 -3
  33. package/dist/Slider.module.css +95 -0
  34. package/dist/Table.module.css +45 -0
  35. package/dist/Tabs.module.css +6 -1
  36. package/dist/Toggle.module.css +6 -1
  37. package/dist/Typography.module.css +47 -0
  38. package/dist/index.d.ts +617 -5
  39. package/dist/index.js +1847 -499
  40. package/dist/tokens/primitives.css +22 -8
  41. package/package.json +1 -1
  42. package/src/components/Accordion/Accordion.module.css +3 -0
  43. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  44. package/src/components/Accordion/Accordion.tsx +53 -0
  45. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  46. package/src/components/Alert/Alert.module.css +62 -0
  47. package/src/components/Alert/Alert.stories.tsx +96 -0
  48. package/src/components/Alert/Alert.tsx +58 -0
  49. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  50. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  51. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  52. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  53. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  54. package/src/components/Badge/Badge.module.css +75 -9
  55. package/src/components/Badge/Badge.stories.tsx +109 -1
  56. package/src/components/Badge/Badge.tsx +30 -2
  57. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  58. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  59. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  60. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  61. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  62. package/src/components/Button/Button.module.css +12 -3
  63. package/src/components/Button/Button.stories.tsx +3 -2
  64. package/src/components/Button/Button.tsx +1 -1
  65. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  66. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  67. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  68. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  69. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  70. package/src/components/Card/Card.module.css +6 -0
  71. package/src/components/Checkbox/Checkbox.module.css +3 -1
  72. package/src/components/Collapsible/Collapsible.module.css +70 -0
  73. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  74. package/src/components/Collapsible/Collapsible.tsx +66 -0
  75. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  76. package/src/components/Combobox/Combobox.module.css +122 -0
  77. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  78. package/src/components/Combobox/Combobox.tsx +186 -0
  79. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  80. package/src/components/Command/Command.module.css +80 -0
  81. package/src/components/Command/Command.stories.tsx +104 -0
  82. package/src/components/Command/Command.tsx +168 -0
  83. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  84. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  85. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  86. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  87. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  88. package/src/components/DataTable/DataTable.module.css +28 -0
  89. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  90. package/src/components/DataTable/DataTable.tsx +154 -0
  91. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  92. package/src/components/DatePicker/DatePicker.module.css +6 -2
  93. package/src/components/Dialog/Dialog.module.css +3 -0
  94. package/src/components/HoverCard/HoverCard.module.css +3 -0
  95. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  96. package/src/components/HoverCard/HoverCard.tsx +111 -0
  97. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  98. package/src/components/Icon/icons/circle-check.tsx +21 -0
  99. package/src/components/Icon/icons/circle-x.tsx +22 -0
  100. package/src/components/Icon/icons/index.ts +8 -0
  101. package/src/components/Input/Input.module.css +6 -1
  102. package/src/components/Item/Item.module.css +70 -0
  103. package/src/components/Item/Item.stories.tsx +62 -0
  104. package/src/components/Item/Item.tsx +54 -0
  105. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  106. package/src/components/Kbd/Kbd.module.css +14 -0
  107. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  108. package/src/components/Kbd/Kbd.tsx +12 -0
  109. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  110. package/src/components/Label/Label.module.css +11 -0
  111. package/src/components/Label/Label.stories.tsx +40 -0
  112. package/src/components/Label/Label.tsx +24 -0
  113. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  114. package/src/components/Marker/Marker.module.css +33 -0
  115. package/src/components/Marker/Marker.stories.tsx +39 -0
  116. package/src/components/Marker/Marker.tsx +34 -0
  117. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  118. package/src/components/Menu/Menu.module.css +8 -1
  119. package/src/components/Menu/Menu.tsx +30 -8
  120. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  121. package/src/components/Menubar/Menubar.tsx +42 -0
  122. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  123. package/src/components/Message/Message.module.css +99 -0
  124. package/src/components/Message/Message.stories.tsx +79 -0
  125. package/src/components/Message/Message.tsx +41 -0
  126. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  127. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  128. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  129. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  130. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  131. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  132. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  133. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  134. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  135. package/src/components/Pagination/Pagination.module.css +98 -0
  136. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  137. package/src/components/Pagination/Pagination.tsx +87 -0
  138. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  139. package/src/components/Progress/Progress.module.css +20 -0
  140. package/src/components/Progress/Progress.stories.tsx +40 -0
  141. package/src/components/Progress/Progress.tsx +35 -0
  142. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  143. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  144. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  145. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  146. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  147. package/src/components/Resizable/Resizable.module.css +70 -0
  148. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  149. package/src/components/Resizable/Resizable.tsx +313 -0
  150. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  151. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  152. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  153. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  154. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  155. package/src/components/Select/Select.module.css +7 -3
  156. package/src/components/Slider/Slider.module.css +95 -0
  157. package/src/components/Slider/Slider.stories.tsx +64 -0
  158. package/src/components/Slider/Slider.tsx +77 -0
  159. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  160. package/src/components/Table/Table.module.css +45 -0
  161. package/src/components/Table/Table.stories.tsx +125 -0
  162. package/src/components/Table/Table.tsx +51 -0
  163. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  164. package/src/components/Tabs/Tabs.module.css +6 -1
  165. package/src/components/Toggle/Toggle.module.css +6 -1
  166. package/src/components/Typography/Typography.module.css +47 -0
  167. package/src/components/Typography/Typography.stories.tsx +62 -0
  168. package/src/components/Typography/Typography.tsx +30 -0
  169. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  170. package/src/index.ts +78 -1
  171. package/src/stories/DesignSystemOverview.stories.tsx +614 -2
  172. package/src/stories/MobilePatterns.stories.tsx +8 -2
  173. package/src/stories/TokenGallery.stories.tsx +112 -16
  174. package/src/tokens/primitives.css +22 -8
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './AspectRatio.module.css';
3
+
4
+ export interface AspectRatioProps {
5
+ /** Width / height, e.g. 16 / 9 or 1. */
6
+ ratio: number;
7
+ children: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ /** AspectRatio — constrains content (image, video, embed) to a fixed ratio. */
12
+ export function AspectRatio({ ratio, children, className }: AspectRatioProps) {
13
+ return (
14
+ <div
15
+ className={[styles.root, className].filter(Boolean).join(' ')}
16
+ style={{ aspectRatio: ratio }}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,27 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { AspectRatio } from '../AspectRatio';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('AspectRatio', () => {
9
+ it('applies the ratio as inline CSS aspect-ratio', () => {
10
+ render(
11
+ <AspectRatio ratio={16 / 9}>
12
+ <img src="/x.png" alt="Preview" />
13
+ </AspectRatio>,
14
+ );
15
+ const root = screen.getByRole('img').parentElement;
16
+ expect(root?.style.aspectRatio).toContain(String(16 / 9));
17
+ });
18
+
19
+ it('renders children', () => {
20
+ render(
21
+ <AspectRatio ratio={1}>
22
+ <div>content</div>
23
+ </AspectRatio>,
24
+ );
25
+ expect(screen.getByText('content')).toBeDefined();
26
+ });
27
+ });
@@ -4,27 +4,28 @@
4
4
  gap: var(--sv-space-1);
5
5
  border-radius: var(--sv-radius-sm);
6
6
  font-family: var(--sv-font-family);
7
- font-size: var(--sv-font-size-xs);
8
7
  line-height: 1;
9
8
  white-space: nowrap;
10
9
  user-select: none;
10
+ }
11
+
12
+ /* Applied by default (uppercase=true) — opt out for a title-case badge that
13
+ renders children exactly as passed in. */
14
+ .uppercase {
11
15
  text-transform: uppercase;
12
16
  letter-spacing: 0.05em;
13
17
  }
14
18
 
15
- /* role — neutral fill, border, title case */
19
+ /* role — neutral fill, border */
16
20
  .role {
17
- padding: 2px var(--sv-space-2);
18
21
  background: var(--sv-color-surface-sunken);
19
22
  border: 1px solid var(--sv-color-border);
20
23
  color: var(--sv-color-text-primary);
21
- font-size: var(--sv-font-size-label);
22
24
  font-weight: var(--sv-font-weight-semibold);
23
25
  }
24
26
 
25
27
  /* status — tinted chip with leading colour dot; colour set by chip* classes below */
26
28
  .status {
27
- padding: 2px var(--sv-space-2) 2px var(--sv-space-1);
28
29
  font-weight: var(--sv-font-weight-medium);
29
30
  }
30
31
 
@@ -54,23 +55,88 @@
54
55
 
55
56
  /* mono — monospace font, neutral surface, for type tags / versions */
56
57
  .mono {
57
- padding: 2px var(--sv-space-2);
58
58
  background: var(--sv-color-surface-sunken);
59
59
  border: 1px solid var(--sv-color-border);
60
60
  color: var(--sv-color-text-muted);
61
61
  font-family: var(--sv-font-family-mono);
62
+ }
63
+
64
+ /* Size × variant — font-size and padding are the two axes that change
65
+ together, so each combination is one compound rule rather than two
66
+ composed partials fighting over the same properties. `.role.md`,
67
+ `.status.md`, and `.mono.md` reproduce the badge's original (and, until
68
+ now, only) sizing exactly, so the default (no `size` prop) renders
69
+ pixel-identical to before this scale existed. */
70
+ .role.sm {
71
+ padding: 1px var(--sv-space-1);
72
+ font-size: var(--sv-font-size-label);
73
+ }
74
+
75
+ .role.md {
76
+ padding: 2px var(--sv-space-2);
77
+ font-size: var(--sv-font-size-label);
78
+ }
79
+
80
+ .role.lg {
81
+ padding: 3px var(--sv-space-3);
82
+ font-size: var(--sv-font-size-sm);
83
+ }
84
+
85
+ .mono.sm {
86
+ padding: 1px var(--sv-space-1);
87
+ font-size: var(--sv-font-size-label);
88
+ }
89
+
90
+ .mono.md {
91
+ padding: 2px var(--sv-space-2);
62
92
  font-size: var(--sv-font-size-xs);
63
93
  }
64
94
 
65
- /* Status dot */
95
+ .mono.lg {
96
+ padding: 3px var(--sv-space-3);
97
+ font-size: var(--sv-font-size-sm);
98
+ }
99
+
100
+ /* status keeps a tighter left edge than right at every size — the dot +
101
+ gap already read as space before the text, so a full padding-inline-start
102
+ would double it up. */
103
+ .status.sm {
104
+ padding: 1px var(--sv-space-1) 1px 3px;
105
+ font-size: var(--sv-font-size-label);
106
+ }
107
+
108
+ .status.md {
109
+ padding: 2px var(--sv-space-2) 2px var(--sv-space-1);
110
+ font-size: var(--sv-font-size-xs);
111
+ }
112
+
113
+ .status.lg {
114
+ padding: 3px var(--sv-space-3) 3px var(--sv-space-2);
115
+ font-size: var(--sv-font-size-sm);
116
+ }
117
+
118
+ /* Status dot — scales with size */
66
119
  .dot {
67
120
  display: inline-block;
68
- width: 6px;
69
- height: 6px;
70
121
  border-radius: var(--sv-radius-full);
71
122
  flex-shrink: 0;
72
123
  }
73
124
 
125
+ .sm .dot {
126
+ width: 5px;
127
+ height: 5px;
128
+ }
129
+
130
+ .md .dot {
131
+ width: 6px;
132
+ height: 6px;
133
+ }
134
+
135
+ .lg .dot {
136
+ width: 8px;
137
+ height: 8px;
138
+ }
139
+
74
140
  .dotGreen {
75
141
  background: var(--sv-color-success-text);
76
142
  }
@@ -10,16 +10,18 @@ const meta = {
10
10
  docs: {
11
11
  description: {
12
12
  component:
13
- 'Compact label for roles (`role`), lifecycle states (`status`), and type/version tags (`mono`). RSC-safe — no state.',
13
+ 'Compact label for roles (`role`), lifecycle states (`status`), and type/version tags (`mono`). Three sizes (`sm`/`md`/`lg`); ALL CAPS by default, or set `uppercase={false}` for title case. RSC-safe — no state.',
14
14
  },
15
15
  },
16
16
  },
17
17
  argTypes: {
18
18
  variant: { control: 'select', options: ['role', 'status', 'mono'] },
19
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
19
20
  status: {
20
21
  control: 'select',
21
22
  options: ['active', 'enabled', 'deactivated', 'failed', 'invited', 'pending', 'neutral'],
22
23
  },
24
+ uppercase: { control: 'boolean' },
23
25
  },
24
26
  args: { children: 'Badge' },
25
27
  } satisfies Meta<typeof Badge>;
@@ -54,6 +56,84 @@ export const MonoPlatform: Story = { args: { variant: 'mono', children: 'platfor
54
56
  export const MonoCommunity: Story = { args: { variant: 'mono', children: 'community' } };
55
57
  export const MonoVersion: Story = { args: { variant: 'mono', children: 'v0.1.0' } };
56
58
 
59
+ /** sm / md (default) / lg side by side, across all three variants. */
60
+ export const Sizes: Story = {
61
+ render: () => {
62
+ const rowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8 };
63
+ const sectionStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8 };
64
+ return (
65
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
66
+ <div style={sectionStyle}>
67
+ <div style={rowStyle}>
68
+ <Badge variant="role" size="sm">
69
+ Owner
70
+ </Badge>
71
+ <Badge variant="role" size="md">
72
+ Owner
73
+ </Badge>
74
+ <Badge variant="role" size="lg">
75
+ Owner
76
+ </Badge>
77
+ </div>
78
+ <div style={rowStyle}>
79
+ <Badge variant="status" status="active" size="sm">
80
+ Active
81
+ </Badge>
82
+ <Badge variant="status" status="active" size="md">
83
+ Active
84
+ </Badge>
85
+ <Badge variant="status" status="active" size="lg">
86
+ Active
87
+ </Badge>
88
+ </div>
89
+ <div style={rowStyle}>
90
+ <Badge variant="mono" size="sm">
91
+ v0.1.0
92
+ </Badge>
93
+ <Badge variant="mono" size="md">
94
+ v0.1.0
95
+ </Badge>
96
+ <Badge variant="mono" size="lg">
97
+ v0.1.0
98
+ </Badge>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ );
103
+ },
104
+ };
105
+
106
+ /** uppercase (default) vs. title case — same text, `uppercase={false}` on the right. */
107
+ export const TitleCase: Story = {
108
+ render: () => {
109
+ const rowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8 };
110
+ return (
111
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
112
+ <div style={rowStyle}>
113
+ <Badge variant="role">Owner</Badge>
114
+ <Badge variant="role" uppercase={false}>
115
+ Owner
116
+ </Badge>
117
+ </div>
118
+ <div style={rowStyle}>
119
+ <Badge variant="status" status="active">
120
+ Active
121
+ </Badge>
122
+ <Badge variant="status" status="active" uppercase={false}>
123
+ Active
124
+ </Badge>
125
+ </div>
126
+ <div style={rowStyle}>
127
+ <Badge variant="mono">Serve Route</Badge>
128
+ <Badge variant="mono" uppercase={false}>
129
+ Serve Route
130
+ </Badge>
131
+ </div>
132
+ </div>
133
+ );
134
+ },
135
+ };
136
+
57
137
  /** All variants and statuses at a glance. */
58
138
  export const AllVariants: Story = {
59
139
  render: (_args) => {
@@ -112,6 +192,34 @@ export const AllVariants: Story = {
112
192
  ))}
113
193
  </div>
114
194
  </div>
195
+ <div style={sectionStyle}>
196
+ <span style={headingStyle}>Sizes (sm / md / lg)</span>
197
+ <div style={{ ...groupStyle, alignItems: 'center' }}>
198
+ <Badge variant="role" size="sm">
199
+ Owner
200
+ </Badge>
201
+ <Badge variant="role" size="md">
202
+ Owner
203
+ </Badge>
204
+ <Badge variant="role" size="lg">
205
+ Owner
206
+ </Badge>
207
+ </div>
208
+ </div>
209
+ <div style={sectionStyle}>
210
+ <span style={headingStyle}>Title case (uppercase=false)</span>
211
+ <div style={{ ...groupStyle, alignItems: 'center' }}>
212
+ <Badge variant="role" uppercase={false}>
213
+ Owner
214
+ </Badge>
215
+ <Badge variant="status" status="active" uppercase={false}>
216
+ Active
217
+ </Badge>
218
+ <Badge variant="mono" uppercase={false}>
219
+ Serve Route
220
+ </Badge>
221
+ </div>
222
+ </div>
115
223
  </div>
116
224
  );
117
225
  },
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import styles from './Badge.module.css';
3
3
 
4
4
  export type BadgeVariant = 'role' | 'status' | 'mono';
5
+ export type BadgeSize = 'sm' | 'md' | 'lg';
5
6
  export type BadgeStatus =
6
7
  | 'active'
7
8
  | 'enabled'
@@ -13,8 +14,16 @@ export type BadgeStatus =
13
14
 
14
15
  export interface BadgeProps {
15
16
  variant?: BadgeVariant;
17
+ /** 'sm' | 'md' (default) | 'lg'. 'md' is the badge's original — and until
18
+ * now, only — size, so the default renders unchanged. */
19
+ size?: BadgeSize;
16
20
  /** Only relevant when variant="status" — determines the dot colour. */
17
21
  status?: BadgeStatus;
22
+ /** Forces ALL CAPS display regardless of the case `children` is passed
23
+ * in. Defaults to `true` — every existing badge in the app relies on
24
+ * this. Set `false` for a title-case badge that renders exactly the text
25
+ * passed in (e.g. "Owner" instead of "OWNER"). */
26
+ uppercase?: boolean;
18
27
  children: ReactNode;
19
28
  }
20
29
 
@@ -44,14 +53,33 @@ const STATUS_CHIP_CLASS: Record<BadgeStatus, string> = {
44
53
  * - `role` neutral surface + border, semibold — for Owner / Admin / User
45
54
  * - `status` tinted chip with leading colour dot — for Active / Deactivated / etc.
46
55
  * - `mono` monospace font, neutral surface — for platform / community / v0.1.0
56
+ *
57
+ * Three sizes (`sm`/`md`/`lg`, default `md`). ALL CAPS by default
58
+ * (`uppercase`) — set `uppercase={false}` for a title-case badge instead.
47
59
  */
48
- export function Badge({ variant = 'role', status = 'neutral', children }: BadgeProps) {
60
+ export function Badge({
61
+ variant = 'role',
62
+ size = 'md',
63
+ status = 'neutral',
64
+ uppercase = true,
65
+ children,
66
+ }: BadgeProps) {
49
67
  const isStatus = variant === 'status';
50
68
  const dotClass = isStatus ? STATUS_DOT_CLASS[status] : undefined;
51
69
  const chipClass = isStatus ? STATUS_CHIP_CLASS[status] : undefined;
52
70
 
53
71
  return (
54
- <span className={[styles.badge, styles[variant], chipClass].filter(Boolean).join(' ')}>
72
+ <span
73
+ className={[
74
+ styles.badge,
75
+ styles[variant],
76
+ styles[size],
77
+ chipClass,
78
+ uppercase && styles.uppercase,
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ')}
82
+ >
55
83
  {isStatus && <span className={[styles.dot, dotClass].join(' ')} aria-hidden />}
56
84
  {children}
57
85
  </span>
@@ -1,8 +1,10 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, expect, it } from 'vitest';
3
- import { render, screen } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
4
  import { Badge } from '../Badge';
5
5
 
6
+ afterEach(cleanup);
7
+
6
8
  describe('Badge', () => {
7
9
  it('renders its children', () => {
8
10
  render(<Badge>Owner</Badge>);
@@ -61,4 +63,26 @@ describe('Badge', () => {
61
63
  const { container } = render(<Badge variant="role">Owner</Badge>);
62
64
  expect(container.querySelectorAll('[aria-hidden]').length).toBe(0);
63
65
  });
66
+
67
+ it('defaults to md size', () => {
68
+ render(<Badge>Admin</Badge>);
69
+ expect(screen.getByText('Admin').closest('span')?.className).toContain('md');
70
+ });
71
+
72
+ it('applies sm and lg size classes', () => {
73
+ const { rerender } = render(<Badge size="sm">Admin</Badge>);
74
+ expect(screen.getByText('Admin').closest('span')?.className).toContain('sm');
75
+ rerender(<Badge size="lg">Admin</Badge>);
76
+ expect(screen.getByText('Admin').closest('span')?.className).toContain('lg');
77
+ });
78
+
79
+ it('defaults to uppercase display', () => {
80
+ render(<Badge>Owner</Badge>);
81
+ expect(screen.getByText('Owner').closest('span')?.className).toContain('uppercase');
82
+ });
83
+
84
+ it('omits the uppercase class when uppercase is false', () => {
85
+ render(<Badge uppercase={false}>Owner</Badge>);
86
+ expect(screen.getByText('Owner').closest('span')?.className).not.toContain('uppercase');
87
+ });
64
88
  });
@@ -0,0 +1,49 @@
1
+ .nav {
2
+ min-width: 0;
3
+ }
4
+
5
+ .list {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ gap: var(--sv-space-1);
10
+ margin: 0;
11
+ padding: 0;
12
+ list-style: none;
13
+ }
14
+
15
+ .item {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--sv-space-1);
19
+ }
20
+
21
+ .link {
22
+ font-size: var(--sv-font-size-sm);
23
+ color: var(--sv-color-text-muted);
24
+ text-decoration: none;
25
+ }
26
+
27
+ @media (hover: hover) {
28
+ .link:hover {
29
+ color: var(--sv-color-text-primary);
30
+ text-decoration: underline;
31
+ }
32
+ }
33
+
34
+ .link:focus-visible {
35
+ outline: 2px solid var(--sv-color-focus-ring);
36
+ outline-offset: 2px;
37
+ border-radius: var(--sv-radius-sm);
38
+ }
39
+
40
+ .current {
41
+ font-size: var(--sv-font-size-sm);
42
+ font-weight: var(--sv-font-weight-medium);
43
+ color: var(--sv-color-text-primary);
44
+ }
45
+
46
+ .separator {
47
+ color: var(--sv-color-text-muted);
48
+ flex-shrink: 0;
49
+ }
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Breadcrumb } from './Breadcrumb';
3
+
4
+ const meta = {
5
+ title: 'Components/Breadcrumb',
6
+ component: Breadcrumb,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Link trail. The last item (no href) renders as plain text with aria-current="page". Pass renderLink to use a client-side router Link instead of a plain <a> — matters inside overlay-shell plugins, where a full page reload breaks the dismiss flow.',
13
+ },
14
+ },
15
+ },
16
+ args: {
17
+ items: [
18
+ { label: 'Console', href: '/console' },
19
+ { label: 'Plugins', href: '/console/plugins' },
20
+ { label: 'sovereign-tasks' },
21
+ ],
22
+ },
23
+ } satisfies Meta<typeof Breadcrumb>;
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const Default: Story = {};
31
+
32
+ export const TwoLevels: Story = {
33
+ args: {
34
+ items: [{ label: 'Console', href: '/console' }, { label: 'Settings' }],
35
+ },
36
+ };
37
+
38
+ export const WithRenderLink: Story = {
39
+ name: 'With custom renderLink',
40
+ args: {
41
+ renderLink: (item, children) => (
42
+ <a href={item.href} data-client-side-nav="true">
43
+ {children}
44
+ </a>
45
+ ),
46
+ },
47
+ };
@@ -0,0 +1,57 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Icon } from '../Icon/Icon';
3
+ import styles from './Breadcrumb.module.css';
4
+
5
+ export interface BreadcrumbItem {
6
+ label: string;
7
+ /** Omit for the current page — rendered as plain text with aria-current. */
8
+ href?: string;
9
+ }
10
+
11
+ export interface BreadcrumbProps {
12
+ items: BreadcrumbItem[];
13
+ /** Renders a linked item. Defaults to a plain `<a href>`, which causes a
14
+ * full page reload — pass Next's `<Link>` (or equivalent) to keep
15
+ * navigation client-side, which matters inside overlay-shell plugins
16
+ * where a full reload breaks the router.back()-driven dismiss flow. */
17
+ renderLink?: (item: BreadcrumbItem, children: ReactNode) => ReactNode;
18
+ className?: string;
19
+ 'aria-label'?: string;
20
+ }
21
+
22
+ export function Breadcrumb({
23
+ items,
24
+ renderLink,
25
+ className,
26
+ 'aria-label': ariaLabel = 'Breadcrumb',
27
+ }: BreadcrumbProps) {
28
+ return (
29
+ <nav aria-label={ariaLabel} className={[styles.nav, className].filter(Boolean).join(' ')}>
30
+ <ol className={styles.list}>
31
+ {items.map((item, index) => {
32
+ const isLast = index === items.length - 1;
33
+ return (
34
+ <li key={item.label + index} className={styles.item}>
35
+ {item.href ? (
36
+ renderLink ? (
37
+ renderLink(item, item.label)
38
+ ) : (
39
+ <a href={item.href} className={styles.link}>
40
+ {item.label}
41
+ </a>
42
+ )
43
+ ) : (
44
+ <span className={styles.current} aria-current="page">
45
+ {item.label}
46
+ </span>
47
+ )}
48
+ {!isLast && (
49
+ <Icon name="chevron-right" size="xs" aria-hidden className={styles.separator} />
50
+ )}
51
+ </li>
52
+ );
53
+ })}
54
+ </ol>
55
+ </nav>
56
+ );
57
+ }
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Breadcrumb } from '../Breadcrumb';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const items = [
9
+ { label: 'Console', href: '/console' },
10
+ { label: 'Plugins', href: '/console/plugins' },
11
+ { label: 'sovereign-tasks' },
12
+ ];
13
+
14
+ describe('Breadcrumb', () => {
15
+ it('renders a link for every item with an href', () => {
16
+ render(<Breadcrumb items={items} />);
17
+ expect(screen.getByRole('link', { name: 'Console' })).toHaveProperty(
18
+ 'href',
19
+ expect.stringContaining('/console'),
20
+ );
21
+ expect(screen.getByRole('link', { name: 'Plugins' })).toBeDefined();
22
+ });
23
+
24
+ it('renders the item without href as plain text with aria-current', () => {
25
+ render(<Breadcrumb items={items} />);
26
+ expect(screen.queryByRole('link', { name: 'sovereign-tasks' })).toBeNull();
27
+ expect(screen.getByText('sovereign-tasks').getAttribute('aria-current')).toBe('page');
28
+ });
29
+
30
+ it('uses a caller-supplied renderLink instead of a plain <a> when given', () => {
31
+ render(
32
+ <Breadcrumb
33
+ items={items}
34
+ renderLink={(item, children) => (
35
+ <button type="button" data-href={item.href}>
36
+ {children}
37
+ </button>
38
+ )}
39
+ />,
40
+ );
41
+ expect(screen.queryByRole('link', { name: 'Console' })).toBeNull();
42
+ expect(screen.getByRole('button', { name: 'Console' })).toHaveProperty(
43
+ 'dataset.href',
44
+ '/console',
45
+ );
46
+ });
47
+
48
+ it('defaults to an accessible nav label', () => {
49
+ render(<Breadcrumb items={items} />);
50
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeDefined();
51
+ });
52
+ });
@@ -37,14 +37,23 @@
37
37
  }
38
38
  }
39
39
 
40
- /* Sizes */
40
+ /* Sizes — 32px / 36px / 40px rendered height (font-size + padding*2 + border*2).
41
+ Vertical padding is hardcoded where the --sv-space-* scale can't hit the
42
+ exact target height; horizontal padding stays token-based, growing with
43
+ size for visual rhythm with the rest of this scale. */
41
44
  .sm {
42
- padding: var(--sv-space-1) var(--sv-space-3);
45
+ padding: var(--sv-space-2) var(--sv-space-3);
43
46
  font-size: var(--sv-font-size-sm);
44
47
  }
45
48
 
46
49
  .md {
47
- padding: 13px var(--sv-space-4);
50
+ padding: 10px var(--sv-space-4);
51
+ font-size: var(--sv-font-size-sm);
52
+ font-weight: var(--sv-font-weight-semibold);
53
+ }
54
+
55
+ .lg {
56
+ padding: var(--sv-space-3) var(--sv-space-5);
48
57
  font-size: var(--sv-font-size-sm);
49
58
  font-weight: var(--sv-font-weight-semibold);
50
59
  }
@@ -16,7 +16,7 @@ const meta = {
16
16
  },
17
17
  argTypes: {
18
18
  variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'destructive'] },
19
- size: { control: 'select', options: ['sm', 'md'] },
19
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
20
20
  disabled: { control: 'boolean' },
21
21
  loading: { control: 'boolean' },
22
22
  children: { control: 'text' },
@@ -38,6 +38,7 @@ export const Destructive: Story = { args: { variant: 'destructive', children: 'D
38
38
 
39
39
  export const Small: Story = { args: { size: 'sm' } };
40
40
  export const Medium: Story = { args: { size: 'md' } };
41
+ export const Large: Story = { args: { size: 'lg' } };
41
42
 
42
43
  export const Disabled: Story = { args: { disabled: true } };
43
44
  export const DisabledSecondary: Story = { args: { variant: 'secondary', disabled: true } };
@@ -71,7 +72,7 @@ export const AllVariants: Story = {
71
72
  render: (_args) => (
72
73
  <div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'flex-start' }}>
73
74
  {(['primary', 'secondary', 'ghost', 'destructive'] as const).map((v) =>
74
- (['md', 'sm'] as const).map((s) => (
75
+ (['lg', 'md', 'sm'] as const).map((s) => (
75
76
  <Button key={`${v}-${s}`} variant={v} size={s}>
76
77
  {v} / {s}
77
78
  </Button>
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
2
  import styles from './Button.module.css';
3
3
 
4
4
  export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
5
- export type ButtonSize = 'sm' | 'md';
5
+ export type ButtonSize = 'sm' | 'md' | 'lg';
6
6
 
7
7
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8
8
  variant?: ButtonVariant;