@sovereignfs/ui 0.23.0 → 0.33.1

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 (109) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +14 -2
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +77 -50
  9. package/dist/DragHandleRow.module.css +9 -3
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/Icon.module.css +5 -0
  12. package/dist/Menu.module.css +82 -0
  13. package/dist/NavTabs.module.css +4 -2
  14. package/dist/OverlayHeader.module.css +98 -0
  15. package/dist/Popover.module.css +31 -1
  16. package/dist/SegmentedControl.module.css +4 -2
  17. package/dist/Sheet.module.css +55 -0
  18. package/dist/SplitPane.module.css +85 -0
  19. package/dist/StatusBadge.module.css +59 -0
  20. package/dist/SystemBanner.module.css +4 -2
  21. package/dist/Tabs.module.css +4 -2
  22. package/dist/TagInput.module.css +141 -0
  23. package/dist/Toast.module.css +4 -2
  24. package/dist/Tooltip.module.css +6 -1
  25. package/dist/index.d.ts +493 -17
  26. package/dist/index.js +1390 -209
  27. package/dist/tokens/primitives.css +19 -0
  28. package/dist/tokens/semantic.css +13 -0
  29. package/package.json +1 -1
  30. package/src/__tests__/motion.test.tsx +105 -0
  31. package/src/components/Button/Button.module.css +75 -8
  32. package/src/components/Button/Button.stories.tsx +6 -0
  33. package/src/components/Button/Button.tsx +17 -1
  34. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  35. package/src/components/Calendar/Calendar.module.css +126 -0
  36. package/src/components/Calendar/Calendar.tsx +213 -0
  37. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  38. package/src/components/Calendar/dateUtils.ts +97 -0
  39. package/src/components/Card/Card.module.css +4 -2
  40. package/src/components/Checkbox/Checkbox.module.css +14 -2
  41. package/src/components/Checkbox/Checkbox.tsx +22 -12
  42. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  43. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  44. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  45. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  46. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  47. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  48. package/src/components/DatePicker/DatePicker.module.css +57 -0
  49. package/src/components/DatePicker/DatePicker.tsx +103 -0
  50. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  51. package/src/components/Dialog/Dialog.module.css +77 -50
  52. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  53. package/src/components/Dialog/Dialog.tsx +86 -22
  54. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  55. package/src/components/DragHandleRow/DragHandleRow.module.css +9 -3
  56. package/src/components/Drawer/Drawer.module.css +61 -0
  57. package/src/components/Drawer/Drawer.tsx +117 -13
  58. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  59. package/src/components/Icon/Icon.module.css +5 -0
  60. package/src/components/Icon/Icon.stories.tsx +4 -3
  61. package/src/components/Icon/Icon.tsx +1 -1
  62. package/src/components/Icon/icons/calendar.tsx +23 -0
  63. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  64. package/src/components/Icon/icons/index.ts +8 -4
  65. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  66. package/src/components/Menu/Menu.module.css +82 -0
  67. package/src/components/Menu/Menu.tsx +137 -0
  68. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  69. package/src/components/NavTabs/NavTabs.module.css +4 -2
  70. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  71. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  72. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  73. package/src/components/Popover/Popover.module.css +31 -1
  74. package/src/components/Popover/Popover.stories.tsx +126 -1
  75. package/src/components/Popover/Popover.tsx +140 -7
  76. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  77. package/src/components/Sheet/Sheet.module.css +55 -0
  78. package/src/components/Sheet/Sheet.tsx +151 -0
  79. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  80. package/src/components/SplitPane/SplitPane.module.css +85 -0
  81. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  82. package/src/components/SplitPane/SplitPane.tsx +149 -0
  83. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  84. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  85. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  86. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  87. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  88. package/src/components/Tabs/Tabs.module.css +4 -2
  89. package/src/components/TagInput/TagInput.module.css +141 -0
  90. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  91. package/src/components/TagInput/TagInput.tsx +174 -0
  92. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  93. package/src/components/Toast/Toast.module.css +4 -2
  94. package/src/components/Tooltip/Tooltip.module.css +6 -1
  95. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  96. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  97. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  98. package/src/hooks/index.ts +4 -0
  99. package/src/hooks/useDoubleTap.ts +91 -0
  100. package/src/hooks/useIsMobile.ts +33 -0
  101. package/src/hooks/useLongPress.ts +185 -0
  102. package/src/index.ts +30 -1
  103. package/src/motion.ts +78 -0
  104. package/src/stories/DesignSystemOverview.stories.tsx +292 -4
  105. package/src/stories/InteractionHooks.stories.tsx +388 -0
  106. package/src/stories/TokenGallery.stories.tsx +85 -1
  107. package/src/tokens/primitives.css +19 -0
  108. package/src/tokens/semantic.css +13 -0
  109. package/src/components/Icon/icons/terminal.tsx +0 -19
@@ -1,41 +1,84 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode, useEffect, useRef } from 'react';
3
+ import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef } from 'react';
4
4
  import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
5
+ import { useMountTransition, usePrefersReducedMotion } from '../../motion';
5
6
  import styles from './Drawer.module.css';
6
7
 
8
+ // Matches --sv-motion-duration-base (Drawer.module.css) — see Dialog.tsx's
9
+ // identical constant for why this stays a plain JS number instead of being
10
+ // read from the CSS custom property.
11
+ const MOTION_DURATION_MS = 250;
12
+
7
13
  export interface DrawerProps {
8
14
  /** Whether the drawer is shown. When false, nothing renders. */
9
15
  open: boolean;
10
- /** Called on Esc or scrim click. */
16
+ /** Called on Esc, scrim click, or a swipe-down past the dismiss threshold. */
11
17
  onClose: () => void;
12
18
  /** Accessible name for the drawer panel (sets `aria-label`). */
13
19
  'aria-label'?: string;
20
+ /** `'content'` (default) sizes to content, capped at 80dvh. `'half'` fixes
21
+ * the panel to 50dvh regardless of content — per the design-system's
22
+ * "half screen or less" convention for Drawer, this is the only other
23
+ * supported size; taller content should use `Sheet` instead. */
24
+ snapHeight?: 'content' | 'half';
14
25
  children: ReactNode;
15
26
  }
16
27
 
17
28
  const FOCUSABLE =
18
29
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
19
30
 
31
+ // Downward drag distance (px) past which releasing dismisses the drawer
32
+ // instead of snapping back open. Distance-only, no velocity tracking — matches
33
+ // the swipe-to-reveal gesture already established in consuming plugins (see
34
+ // the mobile design-system plan), not a separate technique invented here.
35
+ const SWIPE_DISMISS_THRESHOLD_PX = 100;
36
+
20
37
  /**
21
38
  * Drawer — a dismissable bottom-sheet panel. Used by the mobile shell for
22
39
  * plugin navigation; also available to plugins for any bottom-up surface.
23
40
  *
24
- * Behaviour: Esc and scrim-click dismiss; focus moves to the first focusable
25
- * element on open and is restored on close; Tab is trapped within the panel.
26
- * The panel respects `env(safe-area-inset-bottom)` so it clears the home
27
- * indicator in standalone/fullscreen mode. Shares dismissal conventions and
28
- * tokens (`--sv-color-scrim`, `--sv-shadow-overlay`) with `Dialog`.
41
+ * Behaviour: Esc, scrim click, or dragging the grab handle down past
42
+ * `SWIPE_DISMISS_THRESHOLD_PX` all dismiss; focus moves to the first
43
+ * focusable element on open and is restored on close; Tab is trapped within
44
+ * the panel. The panel respects `env(safe-area-inset-bottom)` so it clears
45
+ * the home indicator in standalone/fullscreen mode. Shares dismissal
46
+ * conventions and tokens (`--sv-color-scrim`, `--sv-shadow-overlay`) with
47
+ * `Dialog`.
48
+ *
49
+ * Animated open/close: slides up from the bottom, scrim fades. The `open`/
50
+ * `onClose` API is unchanged — closing stays mounted internally for the exit
51
+ * transition before actually unmounting; `prefers-reduced-motion: reduce`
52
+ * collapses it to near-instant.
53
+ *
54
+ * The grab handle is the *only* drag-initiation region (not the whole panel)
55
+ * — dragging from body content would fight that content's own internal
56
+ * scroll, the same reasoning behind the tasks plugin's edge-zone-only
57
+ * swipe-to-reveal technique.
29
58
  */
30
- export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: DrawerProps) {
59
+ export function Drawer({
60
+ open,
61
+ onClose,
62
+ 'aria-label': ariaLabel,
63
+ snapHeight = 'content',
64
+ children,
65
+ }: DrawerProps) {
31
66
  const panelRef = useRef<HTMLDivElement>(null);
32
67
  const previouslyFocused = useRef<HTMLElement | null>(null);
68
+ const reducedMotion = usePrefersReducedMotion();
69
+ const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
70
+ // Tracks an in-progress handle drag. Kept in a ref (not state) so pointermove
71
+ // updates the DOM directly at 60fps instead of re-rendering on every event —
72
+ // same technique as the tasks plugin's swipe-to-reveal rows.
73
+ const dragStartY = useRef<number | null>(null);
33
74
 
75
+ // Locked for the whole mounted lifetime (including the exit animation), not
76
+ // just while `open` — see Dialog.tsx's identical comment for why.
34
77
  useEffect(() => {
35
- if (!open) return;
78
+ if (!mounted) return;
36
79
  lockBodyScroll();
37
80
  return unlockBodyScroll;
38
- }, [open]);
81
+ }, [mounted]);
39
82
 
40
83
  useEffect(() => {
41
84
  if (!open) return;
@@ -80,13 +123,56 @@ export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: Dra
80
123
  return () => document.removeEventListener('keydown', handleKeyDown);
81
124
  }, [open, onClose]);
82
125
 
83
- if (!open) return null;
126
+ // Swipe-down-to-dismiss, initiated only from the grab handle (see the
127
+ // component doc comment for why not the whole panel). Drags the panel
128
+ // directly via inline style while active (transition: none, so it tracks
129
+ // the finger 1:1 with no lag) and lets go on release: clearing the inline
130
+ // transform in the same commit that re-enables the CSS transition makes
131
+ // the panel animate from wherever the drag ended to its resolved position
132
+ // — open (snap back) or closed (the exit transition, same as a
133
+ // programmatic close) — rather than jump-cutting there. Same technique as
134
+ // the tasks plugin's row swipe-to-reveal.
135
+ function handleHandlePointerDown(e: ReactPointerEvent) {
136
+ e.currentTarget.setPointerCapture(e.pointerId);
137
+ dragStartY.current = e.clientY;
138
+ }
139
+
140
+ function handleHandlePointerMove(e: ReactPointerEvent) {
141
+ const startY = dragStartY.current;
142
+ if (startY === null) return;
143
+ const dy = Math.max(0, e.clientY - startY); // upward drag has no effect — already fully open
144
+ const panel = panelRef.current;
145
+ if (!panel) return;
146
+ panel.style.transition = 'none';
147
+ panel.style.transform = `translateY(${dy}px)`;
148
+ }
149
+
150
+ // Releases the drag and resolves it to open (snap back) or closed, based on
151
+ // distance. Shared by pointerup (a completed gesture) and pointercancel
152
+ // (the browser took the gesture away, e.g. for a system gesture) — cancel
153
+ // always snaps back rather than honoring the distance, since it wasn't a
154
+ // deliberate release.
155
+ function releaseDrag(e: ReactPointerEvent, honorDistance: boolean) {
156
+ const startY = dragStartY.current;
157
+ dragStartY.current = null;
158
+ if (startY === null) return;
159
+ const dy = Math.max(0, e.clientY - startY);
160
+ const panel = panelRef.current;
161
+ if (panel) {
162
+ panel.style.transform = '';
163
+ panel.style.transition = '';
164
+ }
165
+ if (honorDistance && dy > SWIPE_DISMISS_THRESHOLD_PX) onClose();
166
+ }
167
+
168
+ if (!mounted) return null;
169
+ const isOpenPhase = phase === 'open';
84
170
 
85
171
  return (
86
172
  // role="presentation" removes the scrim from the AT (it is purely visual).
87
173
  // e.target check lets clicks inside the panel bubble without triggering dismiss.
88
174
  <div
89
- className={styles.scrim}
175
+ className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
90
176
  role="presentation"
91
177
  onClick={(e) => {
92
178
  if (e.target === e.currentTarget) onClose();
@@ -101,8 +187,26 @@ export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: Dra
101
187
  aria-modal="true"
102
188
  aria-label={ariaLabel}
103
189
  tabIndex={-1}
104
- className={styles.panel}
190
+ className={[
191
+ styles.panel,
192
+ snapHeight === 'half' ? styles.panelHalf : '',
193
+ isOpenPhase ? styles.panelOpen : '',
194
+ ]
195
+ .filter(Boolean)
196
+ .join(' ')}
105
197
  >
198
+ {/* The only drag-initiation region for swipe-down-to-dismiss — see the
199
+ component doc comment for why not the whole panel. Decorative +
200
+ functional, so no aria-label of its own; Esc and the scrim remain
201
+ the accessible dismiss paths. */}
202
+ <div
203
+ className={styles.handle}
204
+ aria-hidden="true"
205
+ onPointerDown={handleHandlePointerDown}
206
+ onPointerMove={handleHandlePointerMove}
207
+ onPointerUp={(e) => releaseDrag(e, true)}
208
+ onPointerCancel={(e) => releaseDrag(e, false)}
209
+ />
106
210
  {children}
107
211
  </div>
108
212
  </div>
@@ -1,10 +1,48 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
4
  import { Drawer } from '../Drawer';
5
5
 
6
+ // Drawer's exit animation reads prefers-reduced-motion via matchMedia, which
7
+ // jsdom does not implement. `matches: false` (motion enabled) exercises the
8
+ // normal animated path; the actual reduced-motion behaviour is covered by
9
+ // motion.ts's own tests.
10
+ function installMatchMedia() {
11
+ vi.stubGlobal(
12
+ 'matchMedia',
13
+ vi.fn().mockImplementation((query: string) => ({
14
+ matches: false,
15
+ media: query,
16
+ addEventListener: vi.fn(),
17
+ removeEventListener: vi.fn(),
18
+ addListener: vi.fn(),
19
+ removeListener: vi.fn(),
20
+ dispatchEvent: vi.fn(),
21
+ })),
22
+ );
23
+ }
24
+
25
+ // jsdom does not implement Element.setPointerCapture/releasePointerCapture
26
+ // (another documented gap, alongside matchMedia and HTMLDialogElement) — the
27
+ // grab handle's pointerdown handler calls setPointerCapture unconditionally.
28
+ function installPointerCapture() {
29
+ if (!Element.prototype.setPointerCapture) {
30
+ Element.prototype.setPointerCapture = () => {};
31
+ }
32
+ if (!Element.prototype.releasePointerCapture) {
33
+ Element.prototype.releasePointerCapture = () => {};
34
+ }
35
+ }
36
+
6
37
  describe('Drawer', () => {
7
- afterEach(cleanup);
38
+ beforeEach(() => {
39
+ installMatchMedia();
40
+ installPointerCapture();
41
+ });
42
+ afterEach(() => {
43
+ vi.unstubAllGlobals();
44
+ cleanup();
45
+ });
8
46
 
9
47
  it('renders nothing when closed', () => {
10
48
  render(
@@ -53,4 +91,87 @@ describe('Drawer', () => {
53
91
  fireEvent.click(scrim);
54
92
  expect(onClose).toHaveBeenCalledOnce();
55
93
  });
94
+
95
+ it('applies the half snapHeight class', () => {
96
+ render(
97
+ <Drawer open onClose={() => {}} aria-label="Nav" snapHeight="half">
98
+ Body
99
+ </Drawer>,
100
+ );
101
+ expect(screen.getByRole('dialog').className).toMatch(/panelHalf/);
102
+ });
103
+
104
+ it('does not apply the half snapHeight class by default (content-sized)', () => {
105
+ render(
106
+ <Drawer open onClose={() => {}} aria-label="Nav">
107
+ Body
108
+ </Drawer>,
109
+ );
110
+ expect(screen.getByRole('dialog').className).not.toMatch(/panelHalf/);
111
+ });
112
+
113
+ describe('swipe-down-to-dismiss (grab handle)', () => {
114
+ // The handle is the panel's first child, aria-hidden and unlabeled —
115
+ // same "select via DOM position" approach the scrim-click test above
116
+ // already uses for a similarly unlabeled element.
117
+ function getHandle() {
118
+ return screen.getByRole('dialog').firstElementChild as HTMLElement;
119
+ }
120
+
121
+ it('dismisses when dragged past the threshold', () => {
122
+ const onClose = vi.fn();
123
+ render(
124
+ <Drawer open onClose={onClose} aria-label="Nav">
125
+ Body
126
+ </Drawer>,
127
+ );
128
+ const handle = getHandle();
129
+ fireEvent.pointerDown(handle, { clientY: 0 });
130
+ fireEvent.pointerMove(handle, { clientY: 150 });
131
+ fireEvent.pointerUp(handle, { clientY: 150 }); // 150px > 100px threshold
132
+ expect(onClose).toHaveBeenCalledOnce();
133
+ });
134
+
135
+ it('snaps back without dismissing when released under the threshold', () => {
136
+ const onClose = vi.fn();
137
+ render(
138
+ <Drawer open onClose={onClose} aria-label="Nav">
139
+ Body
140
+ </Drawer>,
141
+ );
142
+ const handle = getHandle();
143
+ fireEvent.pointerDown(handle, { clientY: 0 });
144
+ fireEvent.pointerMove(handle, { clientY: 40 });
145
+ fireEvent.pointerUp(handle, { clientY: 40 }); // 40px < 100px threshold
146
+ expect(onClose).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('snaps back without dismissing on pointercancel, regardless of distance', () => {
150
+ const onClose = vi.fn();
151
+ render(
152
+ <Drawer open onClose={onClose} aria-label="Nav">
153
+ Body
154
+ </Drawer>,
155
+ );
156
+ const handle = getHandle();
157
+ fireEvent.pointerDown(handle, { clientY: 0 });
158
+ fireEvent.pointerMove(handle, { clientY: 200 }); // well past the threshold
159
+ fireEvent.pointerCancel(handle, { clientY: 200 });
160
+ expect(onClose).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('ignores upward drags (already fully open)', () => {
164
+ const onClose = vi.fn();
165
+ render(
166
+ <Drawer open onClose={onClose} aria-label="Nav">
167
+ Body
168
+ </Drawer>,
169
+ );
170
+ const handle = getHandle();
171
+ fireEvent.pointerDown(handle, { clientY: 100 });
172
+ fireEvent.pointerMove(handle, { clientY: 0 }); // upward
173
+ fireEvent.pointerUp(handle, { clientY: 0 });
174
+ expect(onClose).not.toHaveBeenCalled();
175
+ });
176
+ });
56
177
  });
@@ -6,6 +6,11 @@
6
6
  /* Color follows the surrounding text via currentColor — no token needed. */
7
7
  }
8
8
 
9
+ .xs {
10
+ width: var(--sv-icon-size-xs);
11
+ height: var(--sv-icon-size-xs);
12
+ }
13
+
9
14
  .sm {
10
15
  width: var(--sv-icon-size-sm);
11
16
  height: var(--sv-icon-size-sm);
@@ -18,7 +18,7 @@ const meta = {
18
18
  },
19
19
  argTypes: {
20
20
  name: { control: 'select', options: ALL_NAMES },
21
- size: { control: 'select', options: ['sm', 'md', 'lg'] },
21
+ size: { control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
22
22
  },
23
23
  } satisfies Meta<typeof Icon>;
24
24
 
@@ -35,17 +35,18 @@ export const Meaningful: Story = {
35
35
  args: { name: 'settings', size: 'md', 'aria-label': 'Open settings' },
36
36
  };
37
37
 
38
+ export const SizeExtraSmall: Story = { args: { name: 'bell', size: 'xs', 'aria-hidden': true } };
38
39
  export const SizeSmall: Story = { args: { name: 'bell', size: 'sm', 'aria-hidden': true } };
39
40
  export const SizeMedium: Story = { args: { name: 'bell', size: 'md', 'aria-hidden': true } };
40
41
  export const SizeLarge: Story = { args: { name: 'bell', size: 'lg', 'aria-hidden': true } };
41
42
 
42
- /** Full icon grid — every name across all three sizes. */
43
+ /** Full icon grid — every name across all four sizes. */
43
44
  export const AllIcons: Story = {
44
45
  parameters: { layout: 'padded' },
45
46
  args: { name: 'house', 'aria-hidden': true },
46
47
  render: (_args) => (
47
48
  <div>
48
- {(['sm', 'md', 'lg'] as const).map((sz) => (
49
+ {(['xs', 'sm', 'md', 'lg'] as const).map((sz) => (
49
50
  <section key={sz} style={{ marginBottom: 32 }}>
50
51
  <h3
51
52
  style={{
@@ -12,7 +12,7 @@ export interface IconProps {
12
12
  /** Name of the icon from the curated Sovereign set. */
13
13
  name: IconName;
14
14
  /** Visual size. Defaults to `"md"` (20 px). */
15
- size?: 'sm' | 'md' | 'lg';
15
+ size?: 'xs' | 'sm' | 'md' | 'lg';
16
16
  /** Additional CSS class applied to the SVG element. */
17
17
  className?: string;
18
18
  }
@@ -0,0 +1,23 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CalendarIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M8 2v4" />
18
+ <path d="M16 2v4" />
19
+ <rect width="18" height="18" x="3" y="4" rx="2" />
20
+ <path d="M3 10h18" />
21
+ </svg>
22
+ );
23
+ }
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function EllipsisVerticalIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="1" />
18
+ <circle cx="12" cy="5" r="1" />
19
+ <circle cx="12" cy="19" r="1" />
20
+ </svg>
21
+ );
22
+ }
@@ -26,8 +26,9 @@ import { PackageIcon } from './package';
26
26
  import { Grid2x2Icon } from './grid-2x2';
27
27
  import { InfoIcon } from './info';
28
28
  import { AlertTriangleIcon } from './alert-triangle';
29
+ import { CalendarIcon } from './calendar';
29
30
  import { SlidersHorizontalIcon } from './sliders-horizontal';
30
- import { TerminalIcon } from './terminal';
31
+ import { EllipsisVerticalIcon } from './ellipsis-vertical';
31
32
 
32
33
  export type IconName =
33
34
  | 'house'
@@ -56,8 +57,9 @@ export type IconName =
56
57
  | 'grid-2x2'
57
58
  | 'info'
58
59
  | 'alert-triangle'
60
+ | 'calendar'
59
61
  | 'sliders-horizontal'
60
- | 'terminal';
62
+ | 'ellipsis-vertical';
61
63
 
62
64
  export type IconComponent = (props: SVGProps<SVGSVGElement>) => ReactElement;
63
65
 
@@ -88,8 +90,9 @@ export const ICONS: Record<IconName, IconComponent> = {
88
90
  'grid-2x2': Grid2x2Icon,
89
91
  info: InfoIcon,
90
92
  'alert-triangle': AlertTriangleIcon,
93
+ calendar: CalendarIcon,
91
94
  'sliders-horizontal': SlidersHorizontalIcon,
92
- terminal: TerminalIcon,
95
+ 'ellipsis-vertical': EllipsisVerticalIcon,
93
96
  };
94
97
 
95
98
  export { HouseIcon } from './house';
@@ -118,5 +121,6 @@ export { PackageIcon } from './package';
118
121
  export { Grid2x2Icon } from './grid-2x2';
119
122
  export { InfoIcon } from './info';
120
123
  export { AlertTriangleIcon } from './alert-triangle';
124
+ export { CalendarIcon } from './calendar';
121
125
  export { SlidersHorizontalIcon } from './sliders-horizontal';
122
- export { TerminalIcon } from './terminal';
126
+ export { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -14,15 +14,15 @@ export function SlidersHorizontalIcon(props: SVGProps<SVGSVGElement>) {
14
14
  strokeLinejoin="round"
15
15
  {...props}
16
16
  >
17
- <line x1="21" x2="14" y1="4" y2="4" />
18
- <line x1="10" x2="3" y1="4" y2="4" />
19
- <line x1="21" x2="12" y1="12" y2="12" />
20
- <line x1="8" x2="3" y1="12" y2="12" />
21
- <line x1="21" x2="16" y1="20" y2="20" />
22
- <line x1="12" x2="3" y1="20" y2="20" />
23
- <line x1="14" x2="14" y1="2" y2="6" />
24
- <line x1="8" x2="8" y1="10" y2="14" />
25
- <line x1="16" x2="16" y1="18" y2="22" />
17
+ <path d="M10 5H3" />
18
+ <path d="M12 19H3" />
19
+ <path d="M14 3v4" />
20
+ <path d="M16 17v4" />
21
+ <path d="M21 12h-9" />
22
+ <path d="M21 19h-5" />
23
+ <path d="M21 5h-7" />
24
+ <path d="M8 10v4" />
25
+ <path d="M8 12H3" />
26
26
  </svg>
27
27
  );
28
28
  }
@@ -0,0 +1,82 @@
1
+ /* Menu — the item list shared by both presentations (Popover panel on
2
+ * desktop, Drawer panel on mobile); the presentational chrome around it is
3
+ * entirely owned by those two components, not duplicated here. */
4
+
5
+ .list {
6
+ list-style: none;
7
+ margin: 0;
8
+ padding: var(--sv-space-1);
9
+ display: flex;
10
+ flex-direction: column;
11
+ }
12
+
13
+ .item {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: var(--sv-space-2);
17
+ width: 100%;
18
+ /* Min touch target (WCAG 2.5.5 / Apple HIG) — this menu renders as a
19
+ Drawer sheet on mobile, so every item must be comfortably tappable. */
20
+ min-height: var(--sv-touch-target-min, 44px);
21
+ padding: var(--sv-space-2) var(--sv-space-3);
22
+ border: none;
23
+ border-radius: var(--sv-radius-sm);
24
+ background: transparent;
25
+ color: var(--sv-color-text-primary);
26
+ font: inherit;
27
+ font-size: var(--sv-font-size-sm);
28
+ text-align: left;
29
+ cursor: pointer;
30
+ }
31
+
32
+ @media (hover: hover) {
33
+ .item:hover:not(:disabled) {
34
+ background: var(--sv-color-surface-sunken);
35
+ }
36
+ }
37
+
38
+ .item:focus-visible {
39
+ outline: 2px solid var(--sv-color-focus-ring);
40
+ outline-offset: -2px;
41
+ }
42
+
43
+ .item:disabled {
44
+ opacity: 0.5;
45
+ cursor: not-allowed;
46
+ }
47
+
48
+ .itemDestructive {
49
+ color: var(--sv-color-error-text);
50
+ }
51
+
52
+ @media (hover: hover) {
53
+ .itemDestructive:hover:not(:disabled) {
54
+ background: var(--sv-color-error-surface);
55
+ }
56
+ }
57
+
58
+ /* Reserved checkmark gutter for a checkable item — sized so unchecked
59
+ siblings in the same group still align with the checked one's icon. */
60
+ .check {
61
+ display: inline-flex;
62
+ flex-shrink: 0;
63
+ width: var(--sv-icon-size-sm);
64
+ height: var(--sv-icon-size-sm);
65
+ align-items: center;
66
+ justify-content: center;
67
+ }
68
+
69
+ .label {
70
+ padding: var(--sv-space-2) var(--sv-space-3) var(--sv-space-1);
71
+ font-size: var(--sv-font-size-label);
72
+ font-weight: var(--sv-font-weight-semibold);
73
+ color: var(--sv-color-text-muted);
74
+ text-transform: uppercase;
75
+ letter-spacing: 0.04em;
76
+ }
77
+
78
+ .separator {
79
+ height: 1px;
80
+ margin: var(--sv-space-1) var(--sv-space-2);
81
+ background: var(--sv-color-border);
82
+ }