@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,99 @@
1
+ /*
2
+ * Sovereign Design System — primitive tokens.
3
+ *
4
+ * Raw, context-free scales: the palette, spacing, type, and radii. These are
5
+ * fixed — tenant theming never overrides primitives (it overrides the semantic
6
+ * layer in semantic.css). Plugin code should reference SEMANTIC colour tokens,
7
+ * not these primitives directly.
8
+ */
9
+ :root {
10
+ /* Neutral grey scale (monochrome identity) */
11
+ --sv-white: #ffffff;
12
+
13
+ /* Status colour primitives — amber (warning), green (success), red (error), blue (info).
14
+ Kept minimal: only the swatches the semantic status tokens reference.
15
+ Hex values align with the v3.1 design spec. */
16
+ --sv-amber-100: #fff7ed;
17
+ --sv-amber-200: #fed7aa;
18
+ --sv-amber-800: #b45309;
19
+ --sv-amber-900: #92400e;
20
+ --sv-green-100: #ecfdf3;
21
+ --sv-green-200: #bbf7d0;
22
+ --sv-green-800: #167c4a;
23
+ --sv-green-900: #14532d;
24
+ --sv-red-100: #fef2f2;
25
+ --sv-red-200: #fecaca;
26
+ --sv-red-700: #b91c1c;
27
+ --sv-red-800: #991b1b;
28
+ --sv-red-900: #7f1d1d;
29
+ --sv-blue-100: #eff6ff;
30
+ --sv-blue-200: #dbeafe;
31
+ --sv-blue-800: #1d4ed8;
32
+ --sv-blue-900: #1e3a8a;
33
+ --sv-black: #000000;
34
+ --sv-grey-50: #fafafa;
35
+ --sv-grey-100: #f4f4f5;
36
+ --sv-grey-200: #e4e4e7;
37
+ --sv-grey-300: #d4d4d8;
38
+ --sv-grey-400: #a1a1aa;
39
+ --sv-grey-500: #71717a;
40
+ --sv-grey-600: #52525b;
41
+ --sv-grey-700: #3f3f46;
42
+ --sv-grey-800: #27272a;
43
+ --sv-grey-900: #18181b;
44
+ --sv-grey-950: #09090b;
45
+
46
+ /* Spacing scale (4px base) */
47
+ --sv-space-1: 0.25rem;
48
+ --sv-space-2: 0.5rem;
49
+ --sv-space-3: 0.75rem;
50
+ --sv-space-4: 1rem;
51
+ --sv-space-5: 1.25rem;
52
+ --sv-space-6: 1.5rem;
53
+ --sv-space-8: 2rem;
54
+ --sv-space-10: 2.5rem;
55
+ --sv-space-12: 3rem;
56
+ --sv-space-16: 4rem;
57
+
58
+ /* Typography
59
+ Default pair: Hanken Grotesk (body) + JetBrains Mono (code/identifiers).
60
+ Operators supply the web font via <link> or instance CSS; the fallback
61
+ stack applies when the font is not loaded. */
62
+ --sv-font-family:
63
+ 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
64
+ sans-serif;
65
+ --sv-font-family-mono:
66
+ 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
67
+ --sv-font-size-label: 0.6875rem; /* 11px — all-caps section labels */
68
+ --sv-font-size-xs: 0.75rem; /* 12px — mono identifiers */
69
+ --sv-font-size-caption: 0.8125rem; /* 13px — secondary / supporting copy */
70
+ --sv-font-size-sm: 0.875rem; /* 14px — body copy */
71
+ --sv-font-size-md: 1rem; /* 16px */
72
+ --sv-font-size-lg: 1.125rem; /* 18px */
73
+ --sv-font-size-xl: 1.25rem; /* 20px */
74
+ --sv-font-size-2xl: 1.5rem; /* 24px */
75
+ --sv-font-weight-regular: 400;
76
+ --sv-font-weight-medium: 500;
77
+ --sv-font-weight-semibold: 600;
78
+ --sv-font-weight-bold: 700;
79
+
80
+ /* Icon sizes — scale tokens used directly by the Icon component */
81
+ --sv-icon-size-sm: 1rem; /* 16px — inline with body text */
82
+ --sv-icon-size-md: 1.25rem; /* 20px — standard affordance */
83
+ --sv-icon-size-lg: 1.5rem; /* 24px — prominent / standalone */
84
+
85
+ /* Border radii (v3.1 spec values) */
86
+ --sv-radius-sm: 0.375rem; /* 6px — badge, tag */
87
+ --sv-radius-md: 0.5rem; /* 8px — button, input */
88
+ --sv-radius-lg: 0.6875rem; /* 11px — sidebar icon, card */
89
+ --sv-radius-xl: 0.75rem; /* 12px — card, panel */
90
+ --sv-radius-2xl: 0.875rem; /* 14px — popover */
91
+ --sv-radius-3xl: 1.25rem; /* 20px — bottom sheet (Drawer top corners) */
92
+ --sv-radius-full: 9999px; /* pill / avatar */
93
+
94
+ /* Touch targets — minimum hit-area dimension for interactive controls.
95
+ 44px follows the WCAG 2.5.5 / Apple HIG / Material guideline for reliable
96
+ touch without a pointing device. Applied to icon-only controls on mobile
97
+ (avatar, footer button, Drawer nav items). */
98
+ --sv-touch-target-min: 44px;
99
+ }
@@ -0,0 +1,111 @@
1
+ /*
2
+ * Sovereign Design System — semantic tokens.
3
+ *
4
+ * Contextual roles mapped from primitives. THIS is the theming surface: dark
5
+ * mode and tenant theming work by overriding these values at :root (or under
6
+ * [data-theme]) — no component changes required. Every value here references a
7
+ * primitive token; none is a hardcoded literal (except shadows and the scrim,
8
+ * which compose rgba layers).
9
+ *
10
+ * Identity is monochrome: the accent is near-black on light, near-white on dark.
11
+ * A tenant gives Sovereign its own colour by overriding --sv-color-accent.
12
+ */
13
+ :root {
14
+ --sv-color-surface: var(--sv-white);
15
+ --sv-color-surface-sunken: var(--sv-grey-50);
16
+ --sv-color-surface-raised: var(--sv-white);
17
+
18
+ --sv-color-text-primary: var(--sv-grey-950);
19
+ --sv-color-text-muted: var(--sv-grey-500);
20
+ --sv-color-text-subtle: var(--sv-grey-400);
21
+ --sv-color-text-on-accent: var(--sv-white);
22
+
23
+ --sv-color-border: var(--sv-grey-200);
24
+ --sv-color-border-strong: var(--sv-grey-300);
25
+
26
+ --sv-color-accent: var(--sv-grey-900);
27
+ --sv-color-accent-hover: var(--sv-grey-700);
28
+
29
+ --sv-color-focus-ring: var(--sv-grey-900);
30
+
31
+ /* Status surfaces — error (red), warning (amber), success (green).
32
+ Use these for banners, badges, and inline validation UI.
33
+ Never hardcode colour hex in components — reference these tokens. */
34
+ --sv-color-error-surface: var(--sv-red-100);
35
+ --sv-color-error-text: var(--sv-red-800);
36
+ --sv-color-error-border: var(--sv-red-200);
37
+ --sv-color-warning-surface: var(--sv-amber-100);
38
+ --sv-color-warning-text: var(--sv-amber-800);
39
+ --sv-color-warning-border: var(--sv-amber-200);
40
+ --sv-color-success-surface: var(--sv-green-100);
41
+ --sv-color-success-text: var(--sv-green-800);
42
+ --sv-color-success-border: var(--sv-green-200);
43
+
44
+ /* Info surfaces — blue palette. Use for informational banners and badges.
45
+ Never hardcode colour hex in components — reference these tokens. */
46
+ --sv-color-info-surface: var(--sv-blue-100);
47
+ --sv-color-info-text: var(--sv-blue-800);
48
+ --sv-color-info-border: var(--sv-blue-200);
49
+
50
+ /* Overlay surfaces (Dialog/Sheet): the scrim behind a dialog and the panel's
51
+ elevation shadow. Composed rgba layers, like --sv-shadow-card. */
52
+ --sv-color-scrim: rgba(0, 0, 0, 0.5);
53
+
54
+ --sv-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.1);
55
+ --sv-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.08); /* e1 — card hover lift */
56
+ --sv-shadow-popover:
57
+ 0 4px 16px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08); /* e2 — floating panels */
58
+ --sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.18), 0 6px 12px rgba(0, 0, 0, 0.12);
59
+
60
+ /*
61
+ * Instance identity tokens (RFC 0027 / RFC 0032 rename). Distinct namespace from --sv-color-*:
62
+ * these hold URLs (not colours) and are set once by the operator, not swapped
63
+ * with dark mode. Set by InstanceProvider server component from instance_config DB.
64
+ * CSS: background-image: var(--sv-instance-logo);
65
+ * or <img src="var(--sv-instance-logo)"> is NOT correct — use the InstanceProvider
66
+ * prop `instanceName` and the /api/instance/* routes for the actual URLs.
67
+ */
68
+ --sv-instance-logo: none;
69
+ --sv-instance-logo-dark: none;
70
+ --sv-instance-favicon: none;
71
+ }
72
+
73
+ [data-theme='dark'] {
74
+ --sv-color-surface: var(--sv-grey-950);
75
+ --sv-color-surface-sunken: var(--sv-grey-900);
76
+ --sv-color-surface-raised: var(--sv-grey-900);
77
+
78
+ --sv-color-text-primary: var(--sv-grey-50);
79
+ --sv-color-text-muted: var(--sv-grey-400);
80
+ --sv-color-text-subtle: var(--sv-grey-600);
81
+ --sv-color-text-on-accent: var(--sv-grey-950);
82
+
83
+ --sv-color-border: var(--sv-grey-800);
84
+ --sv-color-border-strong: var(--sv-grey-700);
85
+
86
+ --sv-color-accent: var(--sv-grey-50);
87
+ --sv-color-accent-hover: var(--sv-grey-200);
88
+
89
+ --sv-color-focus-ring: var(--sv-grey-100);
90
+
91
+ --sv-color-error-surface: var(--sv-red-900);
92
+ --sv-color-error-text: var(--sv-red-200);
93
+ --sv-color-error-border: var(--sv-red-700);
94
+ --sv-color-warning-surface: var(--sv-amber-900);
95
+ --sv-color-warning-text: var(--sv-amber-200);
96
+ --sv-color-warning-border: var(--sv-amber-800);
97
+ --sv-color-success-surface: var(--sv-green-900);
98
+ --sv-color-success-text: var(--sv-green-200);
99
+ --sv-color-success-border: var(--sv-green-800);
100
+
101
+ --sv-color-info-surface: var(--sv-blue-900);
102
+ --sv-color-info-text: var(--sv-blue-100);
103
+ --sv-color-info-border: var(--sv-blue-800);
104
+
105
+ --sv-color-scrim: rgba(0, 0, 0, 0.65);
106
+
107
+ --sv-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
108
+ --sv-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.3);
109
+ --sv-shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
110
+ --sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.6), 0 6px 12px rgba(0, 0, 0, 0.5);
111
+ }
@@ -0,0 +1,5 @@
1
+ /* Convenience entry that loads both token tiers. The runtime shell imports this
2
+ * once so --sv-* variables are available globally (primitives first, then the
3
+ * semantic layer that maps them). */
4
+ @import './tokens/primitives.css';
5
+ @import './tokens/semantic.css';
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sovereignfs/ui",
3
+ "version": "0.21.0",
4
+ "type": "module",
5
+ "description": "Sovereign Design System — design tokens and React components.",
6
+ "license": "AGPL-3.0-or-later",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./tokens.css": "./dist/tokens.css",
16
+ "./tokens/primitives.css": "./dist/tokens/primitives.css",
17
+ "./tokens/semantic.css": "./dist/tokens/semantic.css"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "NOTICE"
23
+ ],
24
+ "peerDependencies": {
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@storybook/addon-a11y": "^10.4.6",
30
+ "@storybook/addon-themes": "^10.4.6",
31
+ "@storybook/react-vite": "^10.4.6",
32
+ "@testing-library/dom": "^10.4.1",
33
+ "@testing-library/react": "^16.3.2",
34
+ "@types/react": "^19.0.0",
35
+ "@types/react-dom": "^19.0.0",
36
+ "react": "^19.0.0",
37
+ "react-dom": "^19.0.0",
38
+ "storybook": "^10.4.6",
39
+ "tsup": "^8.5.1",
40
+ "typescript": "^5.9.3",
41
+ "@sovereignfs/tsconfig": "0.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "storybook": "storybook dev -p 6006",
46
+ "build-storybook": "storybook build --output-dir storybook-static",
47
+ "typecheck": "tsc --noEmit"
48
+ },
49
+ "types": "./dist/index.d.ts"
50
+ }
@@ -0,0 +1,38 @@
1
+ .avatar {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ border-radius: 50%;
6
+ background: var(--sv-color-surface-raised);
7
+ border: 1px solid var(--sv-color-border);
8
+ overflow: hidden;
9
+ flex-shrink: 0;
10
+ font-weight: var(--sv-font-weight-medium);
11
+ color: var(--sv-color-text-primary);
12
+ font-family: var(--sv-font-family);
13
+ }
14
+
15
+ .sm {
16
+ width: 24px;
17
+ height: 24px;
18
+ font-size: var(--sv-font-size-label);
19
+ }
20
+
21
+ .md {
22
+ width: 32px;
23
+ height: 32px;
24
+ font-size: var(--sv-font-size-xs);
25
+ }
26
+
27
+ .lg {
28
+ width: 40px;
29
+ height: 40px;
30
+ font-size: var(--sv-font-size-sm);
31
+ }
32
+
33
+ .img {
34
+ width: 100%;
35
+ height: 100%;
36
+ object-fit: cover;
37
+ display: block;
38
+ }
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import styles from './Avatar.module.css';
5
+
6
+ export interface AvatarProps {
7
+ name: string;
8
+ src?: string;
9
+ size?: 'sm' | 'md' | 'lg';
10
+ className?: string;
11
+ }
12
+
13
+ function initials(name: string): string {
14
+ const parts = name.trim().split(/\s+/);
15
+ if (parts.length === 1) return (parts[0] ?? '').slice(0, 2).toUpperCase();
16
+ return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase();
17
+ }
18
+
19
+ const sizeClass: Record<NonNullable<AvatarProps['size']>, string> = {
20
+ sm: styles.sm as string,
21
+ md: styles.md as string,
22
+ lg: styles.lg as string,
23
+ };
24
+
25
+ export function Avatar({ name, src, size = 'md', className }: AvatarProps) {
26
+ const [imgFailed, setImgFailed] = useState(false);
27
+ const cls = [styles.avatar, sizeClass[size], className].filter(Boolean).join(' ');
28
+
29
+ if (src && !imgFailed) {
30
+ return (
31
+ <span className={cls} aria-label={name}>
32
+ <img src={src} alt={name} className={styles.img} onError={() => setImgFailed(true)} />
33
+ </span>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <span className={cls} aria-label={name}>
39
+ {initials(name)}
40
+ </span>
41
+ );
42
+ }
@@ -0,0 +1,88 @@
1
+ .badge {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-1);
5
+ border-radius: var(--sv-radius-sm);
6
+ font-family: var(--sv-font-family);
7
+ font-size: var(--sv-font-size-xs);
8
+ line-height: 1;
9
+ white-space: nowrap;
10
+ user-select: none;
11
+ text-transform: uppercase;
12
+ letter-spacing: 0.05em;
13
+ }
14
+
15
+ /* role — neutral fill, border, title case */
16
+ .role {
17
+ padding: 2px var(--sv-space-2);
18
+ background: var(--sv-color-surface-sunken);
19
+ border: 1px solid var(--sv-color-border);
20
+ color: var(--sv-color-text-primary);
21
+ font-size: var(--sv-font-size-label);
22
+ font-weight: var(--sv-font-weight-semibold);
23
+ }
24
+
25
+ /* status — tinted chip with leading colour dot; colour set by chip* classes below */
26
+ .status {
27
+ padding: 2px var(--sv-space-2) 2px var(--sv-space-1);
28
+ font-weight: var(--sv-font-weight-medium);
29
+ }
30
+
31
+ .chipGreen {
32
+ background: var(--sv-color-success-surface);
33
+ border: 1px solid var(--sv-color-success-border);
34
+ color: var(--sv-color-success-text);
35
+ }
36
+
37
+ .chipAmber {
38
+ background: var(--sv-color-warning-surface);
39
+ border: 1px solid var(--sv-color-warning-border);
40
+ color: var(--sv-color-warning-text);
41
+ }
42
+
43
+ .chipRed {
44
+ background: var(--sv-color-error-surface);
45
+ border: 1px solid var(--sv-color-error-border);
46
+ color: var(--sv-color-error-text);
47
+ }
48
+
49
+ .chipNeutral {
50
+ background: var(--sv-color-surface-sunken);
51
+ border: 1px solid var(--sv-color-border);
52
+ color: var(--sv-color-text-muted);
53
+ }
54
+
55
+ /* mono — monospace font, neutral surface, for type tags / versions */
56
+ .mono {
57
+ padding: 2px var(--sv-space-2);
58
+ background: var(--sv-color-surface-sunken);
59
+ border: 1px solid var(--sv-color-border);
60
+ color: var(--sv-color-text-muted);
61
+ font-family: var(--sv-font-family-mono);
62
+ font-size: var(--sv-font-size-xs);
63
+ }
64
+
65
+ /* Status dot */
66
+ .dot {
67
+ display: inline-block;
68
+ width: 6px;
69
+ height: 6px;
70
+ border-radius: var(--sv-radius-full);
71
+ flex-shrink: 0;
72
+ }
73
+
74
+ .dotGreen {
75
+ background: var(--sv-color-success-text);
76
+ }
77
+
78
+ .dotRed {
79
+ background: var(--sv-color-error-text);
80
+ }
81
+
82
+ .dotAmber {
83
+ background: var(--sv-color-warning-text);
84
+ }
85
+
86
+ .dotGrey {
87
+ background: var(--sv-color-text-muted);
88
+ }
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Badge } from './Badge';
3
+ import type { BadgeStatus } from './Badge';
4
+
5
+ const meta = {
6
+ title: 'Components/Badge',
7
+ component: Badge,
8
+ parameters: {
9
+ layout: 'centered',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Compact label for roles (`role`), lifecycle states (`status`), and type/version tags (`mono`). RSC-safe — no state.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ variant: { control: 'select', options: ['role', 'status', 'mono'] },
19
+ status: {
20
+ control: 'select',
21
+ options: ['active', 'enabled', 'deactivated', 'failed', 'invited', 'pending', 'neutral'],
22
+ },
23
+ },
24
+ args: { children: 'Badge' },
25
+ } satisfies Meta<typeof Badge>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const Role: Story = { args: { variant: 'role', children: 'Owner' } };
33
+ export const RoleAdmin: Story = { args: { variant: 'role', children: 'Admin' } };
34
+ export const RoleUser: Story = { args: { variant: 'role', children: 'User' } };
35
+ export const RoleAuditor: Story = { args: { variant: 'role', children: 'Auditor' } };
36
+
37
+ export const StatusActive: Story = {
38
+ args: { variant: 'status', status: 'active', children: 'Active' },
39
+ };
40
+ export const StatusDeactivated: Story = {
41
+ args: { variant: 'status', status: 'deactivated', children: 'Deactivated' },
42
+ };
43
+ export const StatusInvited: Story = {
44
+ args: { variant: 'status', status: 'invited', children: 'Invited' },
45
+ };
46
+ export const StatusFailed: Story = {
47
+ args: { variant: 'status', status: 'failed', children: 'Failed' },
48
+ };
49
+ export const StatusPending: Story = {
50
+ args: { variant: 'status', status: 'pending', children: 'Pending' },
51
+ };
52
+
53
+ export const MonoPlatform: Story = { args: { variant: 'mono', children: 'platform' } };
54
+ export const MonoCommunity: Story = { args: { variant: 'mono', children: 'community' } };
55
+ export const MonoVersion: Story = { args: { variant: 'mono', children: 'v0.1.0' } };
56
+
57
+ /** All variants and statuses at a glance. */
58
+ export const AllVariants: Story = {
59
+ render: (_args) => {
60
+ const roleLabels = ['Owner', 'Admin', 'User', 'Auditor'];
61
+ const statuses: { status: BadgeStatus; label: string }[] = [
62
+ { status: 'active', label: 'Active' },
63
+ { status: 'enabled', label: 'Enabled' },
64
+ { status: 'deactivated', label: 'Deactivated' },
65
+ { status: 'failed', label: 'Failed' },
66
+ { status: 'invited', label: 'Invited' },
67
+ { status: 'pending', label: 'Pending' },
68
+ { status: 'neutral', label: 'Neutral' },
69
+ ];
70
+ const monoLabels = ['platform', 'community', 'serve-route', 'v0.1.0', 'admin-only'];
71
+
72
+ const groupStyle: React.CSSProperties = { display: 'flex', flexWrap: 'wrap', gap: 8 };
73
+ const sectionStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8 };
74
+ const headingStyle: React.CSSProperties = {
75
+ fontSize: 11,
76
+ fontWeight: 600,
77
+ letterSpacing: '0.05em',
78
+ textTransform: 'uppercase',
79
+ color: 'var(--sv-color-text-muted)',
80
+ fontFamily: 'var(--sv-font-family)',
81
+ };
82
+
83
+ return (
84
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: 16 }}>
85
+ <div style={sectionStyle}>
86
+ <span style={headingStyle}>Role</span>
87
+ <div style={groupStyle}>
88
+ {roleLabels.map((l) => (
89
+ <Badge key={l} variant="role">
90
+ {l}
91
+ </Badge>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ <div style={sectionStyle}>
96
+ <span style={headingStyle}>Status</span>
97
+ <div style={groupStyle}>
98
+ {statuses.map(({ status, label }) => (
99
+ <Badge key={status} variant="status" status={status}>
100
+ {label}
101
+ </Badge>
102
+ ))}
103
+ </div>
104
+ </div>
105
+ <div style={sectionStyle}>
106
+ <span style={headingStyle}>Mono</span>
107
+ <div style={groupStyle}>
108
+ {monoLabels.map((l) => (
109
+ <Badge key={l} variant="mono">
110
+ {l}
111
+ </Badge>
112
+ ))}
113
+ </div>
114
+ </div>
115
+ </div>
116
+ );
117
+ },
118
+ };
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './Badge.module.css';
3
+
4
+ export type BadgeVariant = 'role' | 'status' | 'mono';
5
+ export type BadgeStatus =
6
+ | 'active'
7
+ | 'enabled'
8
+ | 'deactivated'
9
+ | 'failed'
10
+ | 'invited'
11
+ | 'pending'
12
+ | 'neutral';
13
+
14
+ export interface BadgeProps {
15
+ variant?: BadgeVariant;
16
+ /** Only relevant when variant="status" — determines the dot colour. */
17
+ status?: BadgeStatus;
18
+ children: ReactNode;
19
+ }
20
+
21
+ const STATUS_DOT_CLASS: Record<BadgeStatus, string> = {
22
+ active: styles.dotGreen as string,
23
+ enabled: styles.dotGreen as string,
24
+ deactivated: styles.dotRed as string,
25
+ failed: styles.dotRed as string,
26
+ invited: styles.dotAmber as string,
27
+ pending: styles.dotAmber as string,
28
+ neutral: styles.dotGrey as string,
29
+ };
30
+
31
+ const STATUS_CHIP_CLASS: Record<BadgeStatus, string> = {
32
+ active: styles.chipGreen as string,
33
+ enabled: styles.chipGreen as string,
34
+ deactivated: styles.chipRed as string,
35
+ failed: styles.chipRed as string,
36
+ invited: styles.chipAmber as string,
37
+ pending: styles.chipAmber as string,
38
+ neutral: styles.chipNeutral as string,
39
+ };
40
+
41
+ /**
42
+ * Badge — compact label for roles, lifecycle states, and type/version tags.
43
+ *
44
+ * - `role` neutral surface + border, semibold — for Owner / Admin / User
45
+ * - `status` tinted chip with leading colour dot — for Active / Deactivated / etc.
46
+ * - `mono` monospace font, neutral surface — for platform / community / v0.1.0
47
+ */
48
+ export function Badge({ variant = 'role', status = 'neutral', children }: BadgeProps) {
49
+ const isStatus = variant === 'status';
50
+ const dotClass = isStatus ? STATUS_DOT_CLASS[status] : undefined;
51
+ const chipClass = isStatus ? STATUS_CHIP_CLASS[status] : undefined;
52
+
53
+ return (
54
+ <span className={[styles.badge, styles[variant], chipClass].filter(Boolean).join(' ')}>
55
+ {isStatus && <span className={[styles.dot, dotClass].join(' ')} aria-hidden />}
56
+ {children}
57
+ </span>
58
+ );
59
+ }
@@ -0,0 +1,64 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { Badge } from '../Badge';
5
+
6
+ describe('Badge', () => {
7
+ it('renders its children', () => {
8
+ render(<Badge>Owner</Badge>);
9
+ expect(screen.getByText('Owner')).toBeDefined();
10
+ });
11
+
12
+ it('defaults to role variant', () => {
13
+ render(<Badge>Admin</Badge>);
14
+ expect(screen.getByText('Admin').closest('span')?.className).toContain('role');
15
+ });
16
+
17
+ it('applies mono variant class', () => {
18
+ render(<Badge variant="mono">platform</Badge>);
19
+ expect(screen.getByText('platform').closest('span')?.className).toContain('mono');
20
+ });
21
+
22
+ it('renders status variant with a dot element', () => {
23
+ const { container } = render(
24
+ <Badge variant="status" status="active">
25
+ Active
26
+ </Badge>,
27
+ );
28
+ const dots = container.querySelectorAll('[aria-hidden]');
29
+ expect(dots.length).toBe(1);
30
+ expect(dots.item(0).className).toContain('dotGreen');
31
+ });
32
+
33
+ it('uses red dot for deactivated status', () => {
34
+ const { container } = render(
35
+ <Badge variant="status" status="deactivated">
36
+ Deactivated
37
+ </Badge>,
38
+ );
39
+ expect(container.querySelector('[aria-hidden]')?.className).toContain('dotRed');
40
+ });
41
+
42
+ it('uses amber dot for invited status', () => {
43
+ const { container } = render(
44
+ <Badge variant="status" status="invited">
45
+ Invited
46
+ </Badge>,
47
+ );
48
+ expect(container.querySelector('[aria-hidden]')?.className).toContain('dotAmber');
49
+ });
50
+
51
+ it('uses grey dot for neutral status', () => {
52
+ const { container } = render(
53
+ <Badge variant="status" status="neutral">
54
+ Neutral
55
+ </Badge>,
56
+ );
57
+ expect(container.querySelector('[aria-hidden]')?.className).toContain('dotGrey');
58
+ });
59
+
60
+ it('does not render a dot for role variant', () => {
61
+ const { container } = render(<Badge variant="role">Owner</Badge>);
62
+ expect(container.querySelectorAll('[aria-hidden]').length).toBe(0);
63
+ });
64
+ });