@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,70 @@
1
+ .root {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-3);
5
+ width: 100%;
6
+ padding: var(--sv-space-3);
7
+ border: none;
8
+ border-radius: var(--sv-radius-md);
9
+ background: transparent;
10
+ text-align: left;
11
+ font-family: var(--sv-font-family);
12
+ color: inherit;
13
+ }
14
+
15
+ .interactive {
16
+ cursor: pointer;
17
+ }
18
+
19
+ @media (hover: hover) {
20
+ .interactive:not(:disabled):hover {
21
+ background: var(--sv-color-surface-sunken);
22
+ }
23
+ }
24
+
25
+ .interactive:focus-visible {
26
+ outline: 2px solid var(--sv-color-focus-ring);
27
+ outline-offset: -2px;
28
+ }
29
+
30
+ .interactive:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ @media (pointer: coarse) {
36
+ .root {
37
+ min-height: var(--sv-touch-target-min, 44px);
38
+ }
39
+ }
40
+
41
+ .leading,
42
+ .trailing {
43
+ flex-shrink: 0;
44
+ display: flex;
45
+ align-items: center;
46
+ }
47
+
48
+ .content {
49
+ display: flex;
50
+ flex-direction: column;
51
+ min-width: 0;
52
+ flex: 1;
53
+ }
54
+
55
+ .title {
56
+ font-size: var(--sv-font-size-sm);
57
+ font-weight: var(--sv-font-weight-medium);
58
+ color: var(--sv-color-text-primary);
59
+ overflow: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ }
63
+
64
+ .description {
65
+ font-size: var(--sv-font-size-caption);
66
+ color: var(--sv-color-text-muted);
67
+ overflow: hidden;
68
+ text-overflow: ellipsis;
69
+ white-space: nowrap;
70
+ }
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Item } from './Item';
3
+ import { Icon } from '../Icon/Icon';
4
+
5
+ const meta = {
6
+ title: 'Components/Item',
7
+ component: Item,
8
+ parameters: { layout: 'padded' },
9
+ args: {
10
+ title: 'Notifications',
11
+ },
12
+ } satisfies Meta<typeof Item>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const Default: Story = {};
20
+
21
+ export const WithDescription: Story = {
22
+ args: { description: 'Manage email and push alerts' },
23
+ };
24
+
25
+ export const Interactive: Story = {
26
+ args: {
27
+ description: 'Manage email and push alerts',
28
+ leading: <Icon name="bell" aria-hidden />,
29
+ trailing: <Icon name="chevron-right" size="sm" aria-hidden />,
30
+ onClick: () => {},
31
+ },
32
+ };
33
+
34
+ export const Disabled: Story = {
35
+ args: {
36
+ description: 'Requires admin role',
37
+ onClick: () => {},
38
+ disabled: true,
39
+ },
40
+ };
41
+
42
+ export const List: Story = {
43
+ render: () => (
44
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
45
+ <Item
46
+ title="Notifications"
47
+ description="Manage email and push alerts"
48
+ leading={<Icon name="bell" aria-hidden />}
49
+ trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
50
+ onClick={() => {}}
51
+ />
52
+ <Item
53
+ title="Privacy"
54
+ description="Control what others can see"
55
+ leading={<Icon name="shield" aria-hidden />}
56
+ trailing={<Icon name="chevron-right" size="sm" aria-hidden />}
57
+ onClick={() => {}}
58
+ />
59
+ <Item title="Sign out" leading={<Icon name="log-out" aria-hidden />} onClick={() => {}} />
60
+ </div>
61
+ ),
62
+ };
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './Item.module.css';
3
+
4
+ export interface ItemProps {
5
+ leading?: ReactNode;
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ trailing?: ReactNode;
9
+ /** Renders as a button when given; a plain row otherwise. */
10
+ onClick?: () => void;
11
+ disabled?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Item — generic row primitive: leading slot, title + optional description,
17
+ * trailing slot. The building block for settings rows, list rows, and
18
+ * menu-adjacent rows that don't fit `Menu`'s own item shape.
19
+ */
20
+ export function Item({
21
+ leading,
22
+ title,
23
+ description,
24
+ trailing,
25
+ onClick,
26
+ disabled,
27
+ className,
28
+ }: ItemProps) {
29
+ const interactive = Boolean(onClick);
30
+ const classes = [styles.root, interactive ? styles.interactive : '', className]
31
+ .filter(Boolean)
32
+ .join(' ');
33
+
34
+ const content = (
35
+ <>
36
+ {leading && <span className={styles.leading}>{leading}</span>}
37
+ <span className={styles.content}>
38
+ <span className={styles.title}>{title}</span>
39
+ {description && <span className={styles.description}>{description}</span>}
40
+ </span>
41
+ {trailing && <span className={styles.trailing}>{trailing}</span>}
42
+ </>
43
+ );
44
+
45
+ if (interactive) {
46
+ return (
47
+ <button type="button" onClick={onClick} disabled={disabled} className={classes}>
48
+ {content}
49
+ </button>
50
+ );
51
+ }
52
+
53
+ return <div className={classes}>{content}</div>;
54
+ }
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { Item } from '../Item';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Item', () => {
9
+ it('renders as a plain row when no onClick is given', () => {
10
+ render(<Item title="Notifications" description="Manage email and push alerts" />);
11
+ expect(screen.queryByRole('button')).toBeNull();
12
+ expect(screen.getByText('Notifications')).toBeDefined();
13
+ expect(screen.getByText('Manage email and push alerts')).toBeDefined();
14
+ });
15
+
16
+ it('renders as a button and fires onClick when given', () => {
17
+ const onClick = vi.fn();
18
+ render(<Item title="Notifications" onClick={onClick} />);
19
+ fireEvent.click(screen.getByRole('button', { name: 'Notifications' }));
20
+ expect(onClick).toHaveBeenCalled();
21
+ });
22
+
23
+ it('renders leading and trailing slots', () => {
24
+ render(
25
+ <Item
26
+ title="Notifications"
27
+ leading={<span data-testid="leading">icon</span>}
28
+ trailing={<span data-testid="trailing">chevron</span>}
29
+ />,
30
+ );
31
+ expect(screen.getByTestId('leading')).toBeDefined();
32
+ expect(screen.getByTestId('trailing')).toBeDefined();
33
+ });
34
+
35
+ it('disables the button when disabled is set', () => {
36
+ render(<Item title="Notifications" onClick={() => {}} disabled />);
37
+ expect(screen.getByRole('button')).toHaveProperty('disabled', true);
38
+ });
39
+ });
@@ -0,0 +1,14 @@
1
+ .kbd {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ min-width: 1.5em;
6
+ padding: var(--sv-space-1) var(--sv-space-2);
7
+ border: 1px solid var(--sv-color-border-strong);
8
+ border-radius: var(--sv-radius-sm);
9
+ background: var(--sv-color-surface-sunken);
10
+ color: var(--sv-color-text-primary);
11
+ font-family: var(--sv-font-family-mono);
12
+ font-size: var(--sv-font-size-xs);
13
+ line-height: 1;
14
+ }
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Kbd } from './Kbd';
3
+
4
+ const meta = {
5
+ title: 'Components/Kbd',
6
+ component: Kbd,
7
+ parameters: { layout: 'padded' },
8
+ args: { children: '⌘K' },
9
+ } satisfies Meta<typeof Kbd>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const Default: Story = {};
17
+
18
+ export const Letter: Story = {
19
+ args: { children: 'S' },
20
+ };
21
+
22
+ export const Sequence: Story = {
23
+ render: () => (
24
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
25
+ <Kbd>Ctrl</Kbd>
26
+ <span>+</span>
27
+ <Kbd>Shift</Kbd>
28
+ <span>+</span>
29
+ <Kbd>P</Kbd>
30
+ </span>
31
+ ),
32
+ };
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './Kbd.module.css';
3
+
4
+ export interface KbdProps {
5
+ children: ReactNode;
6
+ className?: string;
7
+ }
8
+
9
+ /** Kbd — inline keyboard-key styling. Renders a real `<kbd>` element. */
10
+ export function Kbd({ children, className }: KbdProps) {
11
+ return <kbd className={[styles.kbd, className].filter(Boolean).join(' ')}>{children}</kbd>;
12
+ }
@@ -0,0 +1,14 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Kbd } from '../Kbd';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Kbd', () => {
9
+ it('renders a real <kbd> element', () => {
10
+ render(<Kbd>⌘K</Kbd>);
11
+ const el = screen.getByText('⌘K');
12
+ expect(el.tagName).toBe('KBD');
13
+ });
14
+ });
@@ -0,0 +1,11 @@
1
+ .label {
2
+ font-size: var(--sv-font-size-sm);
3
+ font-weight: var(--sv-font-weight-medium);
4
+ color: var(--sv-color-text-primary);
5
+ cursor: default;
6
+ }
7
+
8
+ .disabled {
9
+ opacity: 0.5;
10
+ cursor: not-allowed;
11
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Label } from './Label';
3
+
4
+ const meta = {
5
+ title: 'Components/Label',
6
+ component: Label,
7
+ parameters: { layout: 'padded' },
8
+ args: { children: 'Label text' },
9
+ } satisfies Meta<typeof Label>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const Default: Story = {};
17
+
18
+ export const Disabled: Story = {
19
+ args: { disabled: true },
20
+ };
21
+
22
+ export const WithControl: Story = {
23
+ render: () => (
24
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
25
+ <Label htmlFor="example-input">Email address</Label>
26
+ <input
27
+ id="example-input"
28
+ type="email"
29
+ placeholder="you@example.com"
30
+ style={{
31
+ padding: 'var(--sv-space-2) var(--sv-space-3)',
32
+ border: '1px solid var(--sv-color-border-strong)',
33
+ borderRadius: 'var(--sv-radius-md)',
34
+ fontFamily: 'var(--sv-font-family)',
35
+ fontSize: 'var(--sv-font-size-sm)',
36
+ }}
37
+ />
38
+ </div>
39
+ ),
40
+ };
@@ -0,0 +1,24 @@
1
+ import type { LabelHTMLAttributes } from 'react';
2
+ import styles from './Label.module.css';
3
+
4
+ export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
5
+ disabled?: boolean;
6
+ }
7
+
8
+ /**
9
+ * Label — standalone accessible form label, independent of `FormField`.
10
+ * Use `FormField` when a control needs hint/error text too; use `Label`
11
+ * directly for a bare label (e.g. next to a `Checkbox` group heading).
12
+ */
13
+ export function Label({ disabled, className, children, ...rest }: LabelProps) {
14
+ return (
15
+ <label
16
+ className={[styles.label, disabled ? styles.disabled : '', className]
17
+ .filter(Boolean)
18
+ .join(' ')}
19
+ {...rest}
20
+ >
21
+ {children}
22
+ </label>
23
+ );
24
+ }
@@ -0,0 +1,32 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Label } from '../Label';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Label', () => {
9
+ it('renders a real <label> element', () => {
10
+ render(<Label htmlFor="agree">I agree</Label>);
11
+ expect(screen.getByText('I agree').tagName).toBe('LABEL');
12
+ });
13
+
14
+ it('associates with a control via htmlFor', () => {
15
+ render(
16
+ <>
17
+ <Label htmlFor="agree">I agree to the terms</Label>
18
+ <input id="agree" type="checkbox" />
19
+ </>,
20
+ );
21
+ expect(screen.getByLabelText('I agree to the terms')).toBeDefined();
22
+ });
23
+
24
+ it('applies disabled styling without disabling the underlying element', () => {
25
+ render(
26
+ <Label htmlFor="agree" disabled>
27
+ I agree
28
+ </Label>,
29
+ );
30
+ expect(screen.getByText('I agree').className).toContain('disabled');
31
+ });
32
+ });
@@ -0,0 +1,33 @@
1
+ .marker {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ min-width: 16px;
6
+ height: 16px;
7
+ padding: 0 4px;
8
+ margin: 0 1px;
9
+ border: none;
10
+ border-radius: var(--sv-radius-full);
11
+ background: var(--sv-color-accent-subtle);
12
+ color: var(--sv-color-accent);
13
+ font-family: var(--sv-font-family);
14
+ font-size: var(--sv-font-size-label);
15
+ font-weight: var(--sv-font-weight-semibold);
16
+ line-height: 1;
17
+ vertical-align: super;
18
+ }
19
+
20
+ button.marker {
21
+ cursor: pointer;
22
+ }
23
+
24
+ button.marker:focus-visible {
25
+ outline: 2px solid var(--sv-color-focus-ring);
26
+ outline-offset: 1px;
27
+ }
28
+
29
+ @media (hover: hover) {
30
+ button.marker:hover {
31
+ background: color-mix(in srgb, var(--sv-color-accent-subtle) 70%, var(--sv-color-accent) 30%);
32
+ }
33
+ }
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Marker } from './Marker';
3
+
4
+ const meta = {
5
+ title: 'Components/Marker',
6
+ component: Marker,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Inline citation/reference marker, for attributing part of an assistant answer to a source (Sovereign Harness "source trace").',
13
+ },
14
+ },
15
+ },
16
+ args: { index: 1, label: 'Source: Notes' },
17
+ } satisfies Meta<typeof Marker>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export const Static: Story = {};
25
+
26
+ export const Clickable: Story = {
27
+ args: { onClick: () => {} },
28
+ };
29
+
30
+ export const InContext: Story = {
31
+ render: () => (
32
+ <p style={{ fontSize: 14, maxWidth: 400 }}>
33
+ Your next task deadline is Thursday
34
+ <Marker index={1} label="Source: Tasks" onClick={() => {}} />, and you have two unread
35
+ messages from the team
36
+ <Marker index={2} label="Source: Notes" onClick={() => {}} />.
37
+ </p>
38
+ ),
39
+ };
@@ -0,0 +1,34 @@
1
+ import styles from './Marker.module.css';
2
+
3
+ export interface MarkerProps {
4
+ /** The number shown in the marker, e.g. 1, 2, 3. */
5
+ index: number;
6
+ /** Accessible label describing the citation, e.g. "Source: Notes". */
7
+ label: string;
8
+ /** Reveals source details (e.g. opens a source trace panel) when given. */
9
+ onClick?: () => void;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Marker — inline citation/reference marker, for attributing part of an
15
+ * assistant's answer to a source (matches RFC 0040's "source trace" —
16
+ * Harness shows which plugin/data source informed an answer).
17
+ */
18
+ export function Marker({ index, label, onClick, className }: MarkerProps) {
19
+ const classes = [styles.marker, className].filter(Boolean).join(' ');
20
+
21
+ if (onClick) {
22
+ return (
23
+ <button type="button" onClick={onClick} aria-label={label} className={classes}>
24
+ {index}
25
+ </button>
26
+ );
27
+ }
28
+
29
+ return (
30
+ <span aria-label={label} className={classes}>
31
+ {index}
32
+ </span>
33
+ );
34
+ }
@@ -0,0 +1,23 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Marker } from '../Marker';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Marker', () => {
9
+ it('renders as a static span with an accessible label when no onClick is given', () => {
10
+ render(<Marker index={1} label="Source: Notes" />);
11
+ const el = screen.getByLabelText('Source: Notes');
12
+ expect(el.tagName).toBe('SPAN');
13
+ expect(el.textContent).toBe('1');
14
+ });
15
+
16
+ it('renders as a button and fires onClick when given', () => {
17
+ const onClick = vi.fn();
18
+ render(<Marker index={2} label="Source: Tasks" onClick={onClick} />);
19
+ const el = screen.getByRole('button', { name: 'Source: Tasks' });
20
+ el.click();
21
+ expect(onClick).toHaveBeenCalled();
22
+ });
23
+ });
@@ -1,6 +1,13 @@
1
1
  /* Menu — the item list shared by both presentations (Popover panel on
2
2
  * desktop, Drawer panel on mobile); the presentational chrome around it is
3
- * entirely owned by those two components, not duplicated here. */
3
+ * entirely owned by those two components, not duplicated here.
4
+ *
5
+ * Sizing audit: reviewed against this scale's other compact controls —
6
+ * --sv-radius-sm (6px) and --sv-font-size-sm (14px) already fit. The 44px
7
+ * min-height is a deliberate design choice, kept unconditional (not gated
8
+ * to pointer:coarse like Button/Checkbox) because this menu doubles as a
9
+ * full Drawer sheet on mobile, where every item must stay comfortably
10
+ * tappable. */
4
11
 
5
12
  .list {
6
13
  list-style: none;
@@ -70,10 +70,21 @@ export interface MenuProps {
70
70
  * Selecting an item both closes the menu and calls its `onSelect` — a
71
71
  * consumer's `onSelect` never needs to call `onClose` itself.
72
72
  */
73
- export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, align }: MenuProps) {
74
- const isMobile = useIsMobile();
75
-
76
- const list = (
73
+ /**
74
+ * MenuEntries — the `<ul role="menu">` item list itself, factored out of
75
+ * `Menu` so other adaptive-surface components (e.g. `ContextMenu`) can
76
+ * render the exact same items/destructive/checked/icon markup inside their
77
+ * own positioning shell, instead of re-deriving it and risking drift.
78
+ */
79
+ export function MenuEntries({
80
+ items,
81
+ onSelect,
82
+ }: {
83
+ items: MenuEntry[];
84
+ /** Called after an item's own onSelect — typically the surface's onClose. */
85
+ onSelect: (entry: MenuItem) => void;
86
+ }) {
87
+ return (
77
88
  <ul className={styles.list} role="menu">
78
89
  {items.map((entry, index) => {
79
90
  // Entries are a static, order-stable list per render — nothing
@@ -99,10 +110,7 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
99
110
  .filter(Boolean)
100
111
  .join(' ')}
101
112
  disabled={entry.disabled}
102
- onClick={() => {
103
- onClose();
104
- entry.onSelect();
105
- }}
113
+ onClick={() => onSelect(entry)}
106
114
  >
107
115
  {isCheckable && (
108
116
  <span className={styles.check} aria-hidden>
@@ -117,6 +125,20 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
117
125
  })}
118
126
  </ul>
119
127
  );
128
+ }
129
+
130
+ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, align }: MenuProps) {
131
+ const isMobile = useIsMobile();
132
+
133
+ const list = (
134
+ <MenuEntries
135
+ items={items}
136
+ onSelect={(entry) => {
137
+ onClose();
138
+ entry.onSelect();
139
+ }}
140
+ />
141
+ );
120
142
 
121
143
  if (isMobile) {
122
144
  return (
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Menubar } from './Menubar';
3
+
4
+ const meta = {
5
+ title: 'Components/Menubar',
6
+ component: Menubar,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Desktop app-style menu bar (File/Edit/View...). A thin composition of NavigationMenu with MenuEntries as each item's flyout content — no positioning or list-rendering logic duplicated.",
13
+ },
14
+ },
15
+ },
16
+ } satisfies Meta<typeof Menubar>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const Default: Story = {
24
+ args: {
25
+ menus: [
26
+ {
27
+ label: 'File',
28
+ items: [
29
+ { label: 'New conversation', onSelect: () => {} },
30
+ { label: 'Export', onSelect: () => {} },
31
+ { type: 'separator' },
32
+ { label: 'Close', onSelect: () => {} },
33
+ ],
34
+ },
35
+ {
36
+ label: 'Edit',
37
+ items: [
38
+ { label: 'Undo', onSelect: () => {} },
39
+ { label: 'Redo', onSelect: () => {} },
40
+ ],
41
+ },
42
+ {
43
+ label: 'View',
44
+ items: [
45
+ { type: 'label', label: 'Layout' },
46
+ { label: 'Compact', checked: false, onSelect: () => {} },
47
+ { label: 'Comfortable', checked: true, onSelect: () => {} },
48
+ ],
49
+ },
50
+ ],
51
+ },
52
+ };