@sovereignfs/ui 0.21.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 (141) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +24 -0
  3. package/dist/Avatar.module.css +38 -0
  4. package/dist/Badge.module.css +88 -0
  5. package/dist/Button.module.css +77 -0
  6. package/dist/Card.module.css +34 -0
  7. package/dist/Dialog.module.css +188 -0
  8. package/dist/Drawer.module.css +30 -0
  9. package/dist/EmptyState.module.css +27 -0
  10. package/dist/FormField.module.css +28 -0
  11. package/dist/Icon.module.css +22 -0
  12. package/dist/Input.module.css +37 -0
  13. package/dist/NavTabs.module.css +48 -0
  14. package/dist/PageHeader.module.css +32 -0
  15. package/dist/Popover.module.css +44 -0
  16. package/dist/SegmentedControl.module.css +53 -0
  17. package/dist/Select.module.css +69 -0
  18. package/dist/Spinner.module.css +39 -0
  19. package/dist/SystemBanner.module.css +67 -0
  20. package/dist/Tabs.module.css +49 -0
  21. package/dist/Toast.module.css +130 -0
  22. package/dist/Toggle.module.css +52 -0
  23. package/dist/Tooltip.module.css +51 -0
  24. package/dist/index.d.ts +327 -0
  25. package/dist/index.js +1370 -0
  26. package/dist/tokens/primitives.css +99 -0
  27. package/dist/tokens/semantic.css +111 -0
  28. package/dist/tokens.css +5 -0
  29. package/package.json +50 -0
  30. package/src/components/Avatar/Avatar.module.css +38 -0
  31. package/src/components/Avatar/Avatar.tsx +42 -0
  32. package/src/components/Badge/Badge.module.css +88 -0
  33. package/src/components/Badge/Badge.stories.tsx +118 -0
  34. package/src/components/Badge/Badge.tsx +59 -0
  35. package/src/components/Badge/__tests__/Badge.test.tsx +64 -0
  36. package/src/components/Button/Button.module.css +77 -0
  37. package/src/components/Button/Button.stories.tsx +76 -0
  38. package/src/components/Button/Button.tsx +36 -0
  39. package/src/components/Button/__tests__/Button.test.tsx +37 -0
  40. package/src/components/Card/Card.module.css +34 -0
  41. package/src/components/Card/Card.tsx +34 -0
  42. package/src/components/Dialog/Dialog.module.css +188 -0
  43. package/src/components/Dialog/Dialog.stories.tsx +97 -0
  44. package/src/components/Dialog/Dialog.tsx +137 -0
  45. package/src/components/Dialog/__tests__/Dialog.test.tsx +73 -0
  46. package/src/components/Drawer/Drawer.module.css +30 -0
  47. package/src/components/Drawer/Drawer.stories.tsx +88 -0
  48. package/src/components/Drawer/Drawer.tsx +103 -0
  49. package/src/components/Drawer/__tests__/Drawer.test.tsx +56 -0
  50. package/src/components/EmptyState/EmptyState.module.css +27 -0
  51. package/src/components/EmptyState/EmptyState.tsx +27 -0
  52. package/src/components/FormField/FormField.module.css +28 -0
  53. package/src/components/FormField/FormField.tsx +53 -0
  54. package/src/components/Icon/Icon.module.css +22 -0
  55. package/src/components/Icon/Icon.stories.tsx +94 -0
  56. package/src/components/Icon/Icon.tsx +43 -0
  57. package/src/components/Icon/__tests__/Icon.test.tsx +40 -0
  58. package/src/components/Icon/icons/activity.tsx +20 -0
  59. package/src/components/Icon/icons/alert-triangle.tsx +22 -0
  60. package/src/components/Icon/icons/bell.tsx +21 -0
  61. package/src/components/Icon/icons/check.tsx +20 -0
  62. package/src/components/Icon/icons/chevron-down.tsx +20 -0
  63. package/src/components/Icon/icons/chevron-left.tsx +20 -0
  64. package/src/components/Icon/icons/chevron-right.tsx +20 -0
  65. package/src/components/Icon/icons/chevron-up.tsx +20 -0
  66. package/src/components/Icon/icons/eye-off.tsx +23 -0
  67. package/src/components/Icon/icons/eye.tsx +21 -0
  68. package/src/components/Icon/icons/grid-2x2.tsx +22 -0
  69. package/src/components/Icon/icons/house.tsx +21 -0
  70. package/src/components/Icon/icons/index.ts +122 -0
  71. package/src/components/Icon/icons/info.tsx +22 -0
  72. package/src/components/Icon/icons/lock.tsx +21 -0
  73. package/src/components/Icon/icons/log-out.tsx +22 -0
  74. package/src/components/Icon/icons/mail.tsx +21 -0
  75. package/src/components/Icon/icons/package.tsx +23 -0
  76. package/src/components/Icon/icons/pencil.tsx +21 -0
  77. package/src/components/Icon/icons/plus.tsx +21 -0
  78. package/src/components/Icon/icons/rotate-ccw.tsx +21 -0
  79. package/src/components/Icon/icons/search.tsx +21 -0
  80. package/src/components/Icon/icons/settings.tsx +21 -0
  81. package/src/components/Icon/icons/shield.tsx +20 -0
  82. package/src/components/Icon/icons/sliders-horizontal.tsx +28 -0
  83. package/src/components/Icon/icons/terminal.tsx +19 -0
  84. package/src/components/Icon/icons/trash-2.tsx +24 -0
  85. package/src/components/Icon/icons/user.tsx +21 -0
  86. package/src/components/Icon/icons/x.tsx +21 -0
  87. package/src/components/Input/Input.module.css +37 -0
  88. package/src/components/Input/Input.stories.tsx +86 -0
  89. package/src/components/Input/Input.tsx +14 -0
  90. package/src/components/Input/__tests__/Input.test.tsx +22 -0
  91. package/src/components/NavTabs/NavTabs.module.css +48 -0
  92. package/src/components/NavTabs/NavTabs.tsx +33 -0
  93. package/src/components/PageHeader/PageHeader.module.css +32 -0
  94. package/src/components/PageHeader/PageHeader.tsx +21 -0
  95. package/src/components/Popover/Popover.module.css +44 -0
  96. package/src/components/Popover/Popover.stories.tsx +232 -0
  97. package/src/components/Popover/Popover.tsx +75 -0
  98. package/src/components/Popover/__tests__/Popover.test.tsx +98 -0
  99. package/src/components/SegmentedControl/SegmentedControl.module.css +53 -0
  100. package/src/components/SegmentedControl/SegmentedControl.stories.tsx +118 -0
  101. package/src/components/SegmentedControl/SegmentedControl.tsx +56 -0
  102. package/src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx +63 -0
  103. package/src/components/Select/Select.module.css +69 -0
  104. package/src/components/Select/Select.stories.tsx +115 -0
  105. package/src/components/Select/Select.tsx +40 -0
  106. package/src/components/Select/__tests__/Select.test.tsx +59 -0
  107. package/src/components/Spinner/Spinner.module.css +39 -0
  108. package/src/components/Spinner/Spinner.tsx +23 -0
  109. package/src/components/SystemBanner/SystemBanner.module.css +67 -0
  110. package/src/components/SystemBanner/SystemBanner.stories.tsx +86 -0
  111. package/src/components/SystemBanner/SystemBanner.tsx +50 -0
  112. package/src/components/SystemBanner/__tests__/SystemBanner.test.tsx +50 -0
  113. package/src/components/Tabs/Tabs.module.css +49 -0
  114. package/src/components/Tabs/Tabs.stories.tsx +100 -0
  115. package/src/components/Tabs/Tabs.tsx +46 -0
  116. package/src/components/Tabs/__tests__/Tabs.test.tsx +45 -0
  117. package/src/components/Toast/Toast.module.css +130 -0
  118. package/src/components/Toast/Toast.stories.tsx +191 -0
  119. package/src/components/Toast/Toast.tsx +139 -0
  120. package/src/components/Toggle/Toggle.module.css +52 -0
  121. package/src/components/Toggle/Toggle.stories.tsx +95 -0
  122. package/src/components/Toggle/Toggle.tsx +39 -0
  123. package/src/components/Toggle/__tests__/Toggle.test.tsx +47 -0
  124. package/src/components/Tooltip/Tooltip.module.css +51 -0
  125. package/src/components/Tooltip/Tooltip.tsx +30 -0
  126. package/src/css-modules.d.ts +4 -0
  127. package/src/index.ts +45 -0
  128. package/src/stories/Avatar.stories.tsx +44 -0
  129. package/src/stories/Card.stories.tsx +35 -0
  130. package/src/stories/DesignSystemOverview.stories.tsx +1683 -0
  131. package/src/stories/EmptyState.stories.tsx +33 -0
  132. package/src/stories/FormField.stories.tsx +47 -0
  133. package/src/stories/MobilePatterns.stories.tsx +1285 -0
  134. package/src/stories/NavTabs.stories.tsx +35 -0
  135. package/src/stories/PageHeader.stories.tsx +31 -0
  136. package/src/stories/Spinner.stories.tsx +25 -0
  137. package/src/stories/TokenGallery.stories.tsx +355 -0
  138. package/src/stories/Tooltip.stories.tsx +59 -0
  139. package/src/tokens/primitives.css +99 -0
  140. package/src/tokens/semantic.css +111 -0
  141. package/src/tokens.css +5 -0
@@ -0,0 +1,69 @@
1
+ /* Wrapper provides the positioning context for the custom chevron */
2
+ .wrapper {
3
+ position: relative;
4
+ display: block;
5
+ width: 100%;
6
+ }
7
+
8
+ .select {
9
+ display: block;
10
+ width: 100%;
11
+ /* Right-pad to clear the custom chevron */
12
+ padding: var(--sv-space-2) var(--sv-space-8) var(--sv-space-2) var(--sv-space-3);
13
+ font-family: var(--sv-font-family);
14
+ font-size: var(--sv-font-size-sm);
15
+ line-height: 1.5;
16
+ color: var(--sv-color-text-primary);
17
+ background-color: var(--sv-color-surface);
18
+ border: 1px solid var(--sv-color-border-strong);
19
+ border-radius: var(--sv-radius-md);
20
+ cursor: pointer;
21
+ /* Remove native OS chevron */
22
+ appearance: none;
23
+ transition:
24
+ border-color 0.15s ease,
25
+ box-shadow 0.15s ease;
26
+ }
27
+
28
+ /* sm — compact for table cells and inline controls */
29
+ .sm .select {
30
+ padding: 2px var(--sv-space-6) 2px var(--sv-space-2);
31
+ font-size: var(--sv-font-size-sm);
32
+ line-height: 1.4;
33
+ border-radius: var(--sv-radius-sm);
34
+ }
35
+
36
+ .select:focus-visible {
37
+ outline: none;
38
+ border-color: var(--sv-color-accent);
39
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
40
+ }
41
+
42
+ .select:disabled {
43
+ opacity: 0.5;
44
+ cursor: not-allowed;
45
+ }
46
+
47
+ /* Compact size variant — reduced padding for table-cell usage */
48
+ .sm .select {
49
+ padding-top: var(--sv-space-1);
50
+ padding-bottom: var(--sv-space-1);
51
+ font-size: var(--sv-font-size-sm);
52
+ }
53
+
54
+ /* SVG chevron — matches the icon stroke style used across the UI */
55
+ .chevron {
56
+ pointer-events: none;
57
+ position: absolute;
58
+ right: var(--sv-space-3);
59
+ top: 50%;
60
+ transform: translateY(-50%);
61
+ color: var(--sv-color-text-muted);
62
+ flex-shrink: 0;
63
+ }
64
+
65
+ .sm .chevron {
66
+ right: var(--sv-space-2);
67
+ width: 10px;
68
+ height: 10px;
69
+ }
@@ -0,0 +1,115 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Select } from './Select';
3
+
4
+ const meta = {
5
+ title: 'Components/Select',
6
+ component: Select,
7
+ parameters: {
8
+ layout: 'centered',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Styled native `<select>` — same visual language as `Input`. Preserves native picker on mobile for maximum accessibility. RSC-safe.',
13
+ },
14
+ },
15
+ },
16
+ argTypes: {
17
+ disabled: { control: 'boolean' },
18
+ },
19
+ } satisfies Meta<typeof Select>;
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export const Default: Story = {
27
+ render: (_args) => (
28
+ <div style={{ width: 280, fontFamily: 'var(--sv-font-family)' }}>
29
+ <label
30
+ htmlFor="role-select"
31
+ style={{
32
+ display: 'block',
33
+ marginBottom: 4,
34
+ fontSize: 'var(--sv-font-size-sm)',
35
+ fontWeight: 'var(--sv-font-weight-medium)',
36
+ color: 'var(--sv-color-text-primary)',
37
+ }}
38
+ >
39
+ Role
40
+ </label>
41
+ <Select id="role-select" defaultValue="user">
42
+ <option value="user">User</option>
43
+ <option value="admin">Admin</option>
44
+ <option value="owner">Owner</option>
45
+ </Select>
46
+ </div>
47
+ ),
48
+ };
49
+
50
+ export const Disabled: Story = {
51
+ render: (_args) => (
52
+ <div style={{ width: 280 }}>
53
+ <Select disabled defaultValue="user">
54
+ <option value="user">User</option>
55
+ <option value="admin">Admin</option>
56
+ </Select>
57
+ </div>
58
+ ),
59
+ };
60
+
61
+ /** Form context — several fields together matching the spec layout. */
62
+ export const FormContext: Story = {
63
+ render: (_args) => {
64
+ const fieldStyle: React.CSSProperties = {
65
+ display: 'flex',
66
+ flexDirection: 'column',
67
+ gap: 4,
68
+ };
69
+ const labelStyle: React.CSSProperties = {
70
+ fontSize: 'var(--sv-font-size-sm)',
71
+ fontWeight: 'var(--sv-font-weight-medium)',
72
+ color: 'var(--sv-color-text-primary)',
73
+ fontFamily: 'var(--sv-font-family)',
74
+ };
75
+
76
+ return (
77
+ <div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 16 }}>
78
+ <div style={fieldStyle}>
79
+ <label htmlFor="lang-select" style={labelStyle}>
80
+ Language
81
+ </label>
82
+ <Select id="lang-select" defaultValue="en-us">
83
+ <option value="en-us">English (US)</option>
84
+ <option value="en-gb">English (UK)</option>
85
+ <option value="fr">Français</option>
86
+ <option value="de">Deutsch</option>
87
+ <option value="ja">日本語</option>
88
+ </Select>
89
+ </div>
90
+ <div style={fieldStyle}>
91
+ <label htmlFor="tz-select" style={labelStyle}>
92
+ Timezone
93
+ </label>
94
+ <Select id="tz-select" defaultValue="asia-colombo">
95
+ <option value="asia-colombo">Asia/Colombo (UTC+5:30)</option>
96
+ <option value="utc">UTC</option>
97
+ <option value="america-ny">America/New_York (UTC−5)</option>
98
+ <option value="europe-london">Europe/London (UTC+0)</option>
99
+ </Select>
100
+ </div>
101
+ <div style={fieldStyle}>
102
+ <label htmlFor="poll-select" style={labelStyle}>
103
+ Poll interval
104
+ </label>
105
+ <Select id="poll-select" defaultValue="30">
106
+ <option value="15">Every 15 seconds</option>
107
+ <option value="30">Every 30 seconds</option>
108
+ <option value="60">Every minute</option>
109
+ <option value="300">Every 5 minutes</option>
110
+ </Select>
111
+ </div>
112
+ </div>
113
+ );
114
+ },
115
+ };
@@ -0,0 +1,40 @@
1
+ import type { SelectHTMLAttributes } from 'react';
2
+ import styles from './Select.module.css';
3
+
4
+ export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> & {
5
+ /** 'md' (default) — standard form field height. 'sm' — compact for table cells and inline controls. */
6
+ size?: 'sm' | 'md';
7
+ };
8
+
9
+ /**
10
+ * Select — styled wrapper around the native `<select>`. Inherits the same
11
+ * height, border, radius, and focus ring as `Input` for visual consistency.
12
+ * Uses the native picker on mobile — no custom dropdown, maximum a11y coverage.
13
+ *
14
+ * RSC-safe: presentational, no hooks, forwards all native select props.
15
+ */
16
+ export function Select({ className, size = 'md', children, ...rest }: SelectProps) {
17
+ return (
18
+ <div
19
+ className={[styles.wrapper, size === 'sm' ? styles.sm : undefined].filter(Boolean).join(' ')}
20
+ >
21
+ <select className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
22
+ {children}
23
+ </select>
24
+ <svg
25
+ className={styles.chevron}
26
+ aria-hidden="true"
27
+ width="12"
28
+ height="12"
29
+ viewBox="0 0 24 24"
30
+ fill="none"
31
+ stroke="currentColor"
32
+ strokeWidth="2"
33
+ strokeLinecap="round"
34
+ strokeLinejoin="round"
35
+ >
36
+ <polyline points="6 9 12 15 18 9" />
37
+ </svg>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,59 @@
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 { Select } from '../Select';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Select', () => {
9
+ it('renders a combobox', () => {
10
+ render(
11
+ <Select aria-label="Role">
12
+ <option value="user">User</option>
13
+ <option value="admin">Admin</option>
14
+ </Select>,
15
+ );
16
+ expect(screen.getByRole('combobox', { name: 'Role' })).toBeDefined();
17
+ });
18
+
19
+ it('renders provided options', () => {
20
+ render(
21
+ <Select aria-label="Role">
22
+ <option value="user">User</option>
23
+ <option value="admin">Admin</option>
24
+ <option value="owner">Owner</option>
25
+ </Select>,
26
+ );
27
+ expect(screen.getAllByRole('option').length).toBe(3);
28
+ });
29
+
30
+ it('forwards value and onChange', () => {
31
+ const onChange = vi.fn();
32
+ render(
33
+ <Select aria-label="Role" value="user" onChange={onChange}>
34
+ <option value="user">User</option>
35
+ <option value="admin">Admin</option>
36
+ </Select>,
37
+ );
38
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'admin' } });
39
+ expect(onChange).toHaveBeenCalled();
40
+ });
41
+
42
+ it('is disabled when the disabled prop is set', () => {
43
+ render(
44
+ <Select aria-label="Role" disabled>
45
+ <option value="user">User</option>
46
+ </Select>,
47
+ );
48
+ expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(true);
49
+ });
50
+
51
+ it('renders a decorative chevron element', () => {
52
+ const { container } = render(
53
+ <Select aria-label="Role">
54
+ <option value="user">User</option>
55
+ </Select>,
56
+ );
57
+ expect(container.querySelector('[aria-hidden]')).toBeDefined();
58
+ });
59
+ });
@@ -0,0 +1,39 @@
1
+ @keyframes spin {
2
+ to {
3
+ transform: rotate(360deg);
4
+ }
5
+ }
6
+
7
+ .spinner {
8
+ display: inline-block;
9
+ border-radius: 50%;
10
+ border-style: solid;
11
+ border-color: var(--sv-color-border);
12
+ border-top-color: var(--sv-color-accent);
13
+ animation: spin 0.7s linear infinite;
14
+ flex-shrink: 0;
15
+ }
16
+
17
+ @media (prefers-reduced-motion: reduce) {
18
+ .spinner {
19
+ animation-play-state: paused;
20
+ }
21
+ }
22
+
23
+ .sm {
24
+ width: var(--sv-icon-size-sm);
25
+ height: var(--sv-icon-size-sm);
26
+ border-width: 2px;
27
+ }
28
+
29
+ .md {
30
+ width: var(--sv-icon-size-md);
31
+ height: var(--sv-icon-size-md);
32
+ border-width: 2px;
33
+ }
34
+
35
+ .lg {
36
+ width: var(--sv-icon-size-lg);
37
+ height: var(--sv-icon-size-lg);
38
+ border-width: 3px;
39
+ }
@@ -0,0 +1,23 @@
1
+ import styles from './Spinner.module.css';
2
+
3
+ export interface SpinnerProps {
4
+ size?: 'sm' | 'md' | 'lg';
5
+ label?: string;
6
+ className?: string;
7
+ }
8
+
9
+ const sizeClass: Record<NonNullable<SpinnerProps['size']>, string> = {
10
+ sm: styles.sm as string,
11
+ md: styles.md as string,
12
+ lg: styles.lg as string,
13
+ };
14
+
15
+ export function Spinner({ size = 'md', label = 'Loading…', className }: SpinnerProps) {
16
+ return (
17
+ <span
18
+ role="status"
19
+ aria-label={label}
20
+ className={[styles.spinner, sizeClass[size], className].filter(Boolean).join(' ')}
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,67 @@
1
+ .banner {
2
+ position: sticky;
3
+ top: 0;
4
+ /* Sits above sidebar rail (z-index ~10) but below dialogs/overlays (z-index 100) */
5
+ z-index: 50;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ gap: var(--sv-space-3);
10
+ padding: var(--sv-space-2) var(--sv-space-4);
11
+ border-bottom-width: 1px;
12
+ border-bottom-style: solid;
13
+ font-family: var(--sv-font-family);
14
+ font-size: var(--sv-font-size-sm);
15
+ line-height: 1.4;
16
+ }
17
+
18
+ .content {
19
+ flex: 1;
20
+ text-align: center;
21
+ }
22
+
23
+ /* Dismiss button — inherits the variant text colour */
24
+ .dismiss {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ flex-shrink: 0;
29
+ width: 24px;
30
+ height: 24px;
31
+ padding: 0;
32
+ border: none;
33
+ border-radius: var(--sv-radius-sm);
34
+ background: transparent;
35
+ color: inherit;
36
+ cursor: pointer;
37
+ opacity: 0.7;
38
+ transition: opacity 0.12s ease;
39
+ }
40
+
41
+ .dismiss:hover {
42
+ opacity: 1;
43
+ }
44
+
45
+ .dismiss:focus-visible {
46
+ outline: 2px solid currentColor;
47
+ outline-offset: 1px;
48
+ }
49
+
50
+ /* Variants */
51
+ .info {
52
+ background: var(--sv-color-info-surface);
53
+ color: var(--sv-color-info-text);
54
+ border-bottom-color: var(--sv-color-info-border);
55
+ }
56
+
57
+ .warning {
58
+ background: var(--sv-color-warning-surface);
59
+ color: var(--sv-color-warning-text);
60
+ border-bottom-color: var(--sv-color-warning-border);
61
+ }
62
+
63
+ .error {
64
+ background: var(--sv-color-error-surface);
65
+ color: var(--sv-color-error-text);
66
+ border-bottom-color: var(--sv-color-error-border);
67
+ }
@@ -0,0 +1,86 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { SystemBanner } from './SystemBanner';
4
+
5
+ const meta = {
6
+ title: 'Components/SystemBanner',
7
+ component: SystemBanner,
8
+ parameters: {
9
+ layout: 'fullscreen',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Full-width `position: sticky` strip for platform-level notices. Stack multiple banners by rendering multiple `<SystemBanner>` elements. Dismiss button only appears when `onDismiss` is provided.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ variant: { control: 'select', options: ['info', 'warning', 'error'] },
19
+ },
20
+ args: {
21
+ children: 'Notice',
22
+ },
23
+ } satisfies Meta<typeof SystemBanner>;
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const Info: Story = {
31
+ args: {
32
+ variant: 'info',
33
+ children: 'Platform is in read-only mode during migration.',
34
+ },
35
+ };
36
+
37
+ export const Warning: Story = {
38
+ args: {
39
+ variant: 'warning',
40
+ children: 'License for Example: Monetized expires in 7 days.',
41
+ },
42
+ };
43
+
44
+ export const Error: Story = {
45
+ args: {
46
+ variant: 'error',
47
+ children: 'Maintenance mode is active — only admins can sign in.',
48
+ },
49
+ };
50
+
51
+ export const Dismissible: Story = {
52
+ render: (_args) => {
53
+ const [visible, setVisible] = useState(true);
54
+ return visible ? (
55
+ <SystemBanner variant="warning" onDismiss={() => setVisible(false)}>
56
+ License for Example: Monetized expires in 7 days.
57
+ </SystemBanner>
58
+ ) : (
59
+ <div
60
+ style={{
61
+ padding: 16,
62
+ fontFamily: 'var(--sv-font-family)',
63
+ fontSize: 'var(--sv-font-size-sm)',
64
+ color: 'var(--sv-color-text-muted)',
65
+ }}
66
+ >
67
+ Banner dismissed.
68
+ </div>
69
+ );
70
+ },
71
+ };
72
+
73
+ /** All three variants stacked — as they appear when multiple notices are active. */
74
+ export const Stacked: Story = {
75
+ render: (_args) => (
76
+ <div>
77
+ <SystemBanner variant="error">
78
+ Maintenance mode is active — only admins can sign in.
79
+ </SystemBanner>
80
+ <SystemBanner variant="warning">
81
+ License for Example: Monetized expires in 7 days.
82
+ </SystemBanner>
83
+ <SystemBanner variant="info">Platform is in read-only mode during migration.</SystemBanner>
84
+ </div>
85
+ ),
86
+ };
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './SystemBanner.module.css';
3
+
4
+ export type BannerVariant = 'info' | 'warning' | 'error';
5
+
6
+ export interface SystemBannerProps {
7
+ variant?: BannerVariant;
8
+ /** Called when the user clicks ×. If omitted, no dismiss button is shown. */
9
+ onDismiss?: () => void;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * SystemBanner — full-width sticky strip that shifts page content down.
15
+ *
16
+ * Used for platform-level notices: maintenance mode, license warnings, broadcast
17
+ * messages. Stack multiple banners by rendering multiple `<SystemBanner>` elements.
18
+ * The dismiss button is only rendered when `onDismiss` is provided.
19
+ */
20
+ export function SystemBanner({ variant = 'info', onDismiss, children }: SystemBannerProps) {
21
+ return (
22
+ <div role="status" aria-live="polite" className={[styles.banner, styles[variant]].join(' ')}>
23
+ <span className={styles.content}>{children}</span>
24
+ {onDismiss && (
25
+ <button
26
+ type="button"
27
+ className={styles.dismiss}
28
+ onClick={onDismiss}
29
+ aria-label="Dismiss banner"
30
+ >
31
+ <svg
32
+ width="14"
33
+ height="14"
34
+ viewBox="0 0 14 14"
35
+ fill="none"
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ aria-hidden
38
+ >
39
+ <path
40
+ d="M1 1L13 13M13 1L1 13"
41
+ stroke="currentColor"
42
+ strokeWidth="1.5"
43
+ strokeLinecap="round"
44
+ />
45
+ </svg>
46
+ </button>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,50 @@
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 { SystemBanner } from '../SystemBanner';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('SystemBanner', () => {
9
+ it('renders children as content', () => {
10
+ render(<SystemBanner>Maintenance mode is active.</SystemBanner>);
11
+ expect(screen.getByText('Maintenance mode is active.')).toBeDefined();
12
+ });
13
+
14
+ it('defaults to the info variant', () => {
15
+ const { container } = render(<SystemBanner>Notice</SystemBanner>);
16
+ expect((container.firstChild as HTMLElement).className).toContain('info');
17
+ });
18
+
19
+ it('applies the warning variant class', () => {
20
+ const { container } = render(<SystemBanner variant="warning">Warning</SystemBanner>);
21
+ expect((container.firstChild as HTMLElement).className).toContain('warning');
22
+ });
23
+
24
+ it('applies the error variant class', () => {
25
+ const { container } = render(<SystemBanner variant="error">Error</SystemBanner>);
26
+ expect((container.firstChild as HTMLElement).className).toContain('error');
27
+ });
28
+
29
+ it('does not render a dismiss button when onDismiss is omitted', () => {
30
+ render(<SystemBanner>Notice</SystemBanner>);
31
+ expect(screen.queryByRole('button', { name: 'Dismiss banner' })).toBeNull();
32
+ });
33
+
34
+ it('renders a dismiss button when onDismiss is provided', () => {
35
+ render(<SystemBanner onDismiss={() => {}}>Notice</SystemBanner>);
36
+ expect(screen.getByRole('button', { name: 'Dismiss banner' })).toBeDefined();
37
+ });
38
+
39
+ it('calls onDismiss when the dismiss button is clicked', () => {
40
+ const onDismiss = vi.fn();
41
+ render(<SystemBanner onDismiss={onDismiss}>Notice</SystemBanner>);
42
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss banner' }));
43
+ expect(onDismiss).toHaveBeenCalledOnce();
44
+ });
45
+
46
+ it('exposes a status live region', () => {
47
+ render(<SystemBanner>Notice</SystemBanner>);
48
+ expect(screen.getByRole('status')).toBeDefined();
49
+ });
50
+ });
@@ -0,0 +1,49 @@
1
+ .tablist {
2
+ display: flex;
3
+ border-bottom: 1px solid var(--sv-color-border);
4
+ /* Horizontal scroll on mobile — scrollbar hidden for clean appearance */
5
+ overflow-x: auto;
6
+ scrollbar-width: none;
7
+ }
8
+
9
+ .tablist::-webkit-scrollbar {
10
+ display: none;
11
+ }
12
+
13
+ .tab {
14
+ flex-shrink: 0;
15
+ padding: var(--sv-space-2) var(--sv-space-4);
16
+ border: none;
17
+ border-bottom: 2px solid transparent;
18
+ /* Negative margin pulls the 2px border flush with the tablist border-bottom */
19
+ margin-bottom: -1px;
20
+ background: transparent;
21
+ font-family: var(--sv-font-family);
22
+ font-size: var(--sv-font-size-sm);
23
+ line-height: 1;
24
+ cursor: pointer;
25
+ transition:
26
+ color 0.12s ease,
27
+ border-color 0.12s ease;
28
+ white-space: nowrap;
29
+ }
30
+
31
+ .tab:focus-visible {
32
+ outline: 2px solid var(--sv-color-focus-ring);
33
+ outline-offset: -2px;
34
+ }
35
+
36
+ .active {
37
+ color: var(--sv-color-text-primary);
38
+ font-weight: var(--sv-font-weight-semibold);
39
+ border-bottom-color: var(--sv-color-accent);
40
+ }
41
+
42
+ .inactive {
43
+ color: var(--sv-color-text-muted);
44
+ font-weight: var(--sv-font-weight-medium);
45
+ }
46
+
47
+ .inactive:hover {
48
+ color: var(--sv-color-text-primary);
49
+ }