@sovereignfs/ui 0.41.0 → 0.54.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 (256) 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/LegalLinks.module.css +24 -0
  23. package/dist/Markdown.module.css +49 -0
  24. package/dist/Marker.module.css +33 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/MobileAppsDrawer.module.css +81 -0
  29. package/dist/MobileFooter.module.css +73 -0
  30. package/dist/MobileHeader.module.css +63 -0
  31. package/dist/NavigationMenu.module.css +53 -0
  32. package/dist/PageContainer.module.css +20 -0
  33. package/dist/Pagination.module.css +98 -0
  34. package/dist/Progress.module.css +20 -0
  35. package/dist/RadioGroup.module.css +88 -0
  36. package/dist/Resizable.module.css +70 -0
  37. package/dist/ScrollArea.module.css +20 -0
  38. package/dist/Select.module.css +7 -3
  39. package/dist/Slider.module.css +95 -0
  40. package/dist/SwipableMobileCarousel.module.css +55 -0
  41. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  42. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  43. package/dist/Table.module.css +45 -0
  44. package/dist/Tabs.module.css +6 -1
  45. package/dist/Toggle.module.css +6 -1
  46. package/dist/Typography.module.css +47 -0
  47. package/dist/index.d.ts +1268 -8
  48. package/dist/index.js +3046 -975
  49. package/dist/tokens/primitives.css +47 -8
  50. package/package.json +13 -9
  51. package/src/components/Accordion/Accordion.module.css +3 -0
  52. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  53. package/src/components/Accordion/Accordion.tsx +53 -0
  54. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  55. package/src/components/Alert/Alert.module.css +62 -0
  56. package/src/components/Alert/Alert.stories.tsx +96 -0
  57. package/src/components/Alert/Alert.tsx +58 -0
  58. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  59. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  60. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  61. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  62. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  63. package/src/components/Badge/Badge.module.css +75 -9
  64. package/src/components/Badge/Badge.stories.tsx +109 -1
  65. package/src/components/Badge/Badge.tsx +31 -9
  66. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  67. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  68. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  69. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  70. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  71. package/src/components/Button/Button.module.css +12 -3
  72. package/src/components/Button/Button.stories.tsx +3 -2
  73. package/src/components/Button/Button.tsx +1 -1
  74. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  75. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  76. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  77. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  78. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  79. package/src/components/Card/Card.module.css +6 -0
  80. package/src/components/Checkbox/Checkbox.module.css +3 -1
  81. package/src/components/Collapsible/Collapsible.module.css +70 -0
  82. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  83. package/src/components/Collapsible/Collapsible.tsx +66 -0
  84. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  85. package/src/components/Combobox/Combobox.module.css +122 -0
  86. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  87. package/src/components/Combobox/Combobox.tsx +186 -0
  88. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  89. package/src/components/Command/Command.module.css +80 -0
  90. package/src/components/Command/Command.stories.tsx +104 -0
  91. package/src/components/Command/Command.tsx +168 -0
  92. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  93. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  94. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  95. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  96. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  97. package/src/components/DataTable/DataTable.module.css +28 -0
  98. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  99. package/src/components/DataTable/DataTable.tsx +154 -0
  100. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  101. package/src/components/DatePicker/DatePicker.module.css +6 -2
  102. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  103. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  104. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  105. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  106. package/src/components/Dialog/Dialog.module.css +3 -0
  107. package/src/components/Dialog/Dialog.tsx +8 -59
  108. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  109. package/src/components/Drawer/Drawer.tsx +18 -56
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  111. package/src/components/HoverCard/HoverCard.module.css +3 -0
  112. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  113. package/src/components/HoverCard/HoverCard.tsx +111 -0
  114. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  115. package/src/components/Icon/icons/circle-check.tsx +21 -0
  116. package/src/components/Icon/icons/circle-x.tsx +22 -0
  117. package/src/components/Icon/icons/index.ts +16 -0
  118. package/src/components/Icon/icons/menu.tsx +22 -0
  119. package/src/components/Icon/icons/smartphone.tsx +21 -0
  120. package/src/components/Input/Input.module.css +6 -1
  121. package/src/components/Item/Item.module.css +70 -0
  122. package/src/components/Item/Item.stories.tsx +62 -0
  123. package/src/components/Item/Item.tsx +54 -0
  124. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  125. package/src/components/Kbd/Kbd.module.css +14 -0
  126. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  127. package/src/components/Kbd/Kbd.tsx +12 -0
  128. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  129. package/src/components/Label/Label.module.css +11 -0
  130. package/src/components/Label/Label.stories.tsx +40 -0
  131. package/src/components/Label/Label.tsx +24 -0
  132. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  133. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  134. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  135. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  136. package/src/components/Markdown/Markdown.module.css +49 -0
  137. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  138. package/src/components/Markdown/Markdown.tsx +168 -0
  139. package/src/components/Marker/Marker.module.css +33 -0
  140. package/src/components/Marker/Marker.stories.tsx +39 -0
  141. package/src/components/Marker/Marker.tsx +34 -0
  142. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  143. package/src/components/Menu/Menu.module.css +8 -1
  144. package/src/components/Menu/Menu.tsx +30 -8
  145. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  146. package/src/components/Menubar/Menubar.tsx +42 -0
  147. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  148. package/src/components/Message/Message.module.css +99 -0
  149. package/src/components/Message/Message.stories.tsx +79 -0
  150. package/src/components/Message/Message.tsx +41 -0
  151. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  152. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  153. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  154. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  155. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  156. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  157. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  158. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  159. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  160. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  161. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  162. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  163. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  164. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  165. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  166. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  167. package/src/components/NavTabs/NavTabs.tsx +39 -11
  168. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  169. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  170. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  171. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  172. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  173. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  174. package/src/components/PageContainer/PageContainer.module.css +20 -0
  175. package/src/components/PageContainer/PageContainer.tsx +31 -0
  176. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  177. package/src/components/PageHeader/PageHeader.tsx +16 -2
  178. package/src/components/Pagination/Pagination.module.css +98 -0
  179. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  180. package/src/components/Pagination/Pagination.tsx +87 -0
  181. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  182. package/src/components/Progress/Progress.module.css +20 -0
  183. package/src/components/Progress/Progress.stories.tsx +40 -0
  184. package/src/components/Progress/Progress.tsx +35 -0
  185. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  186. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  187. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  188. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  189. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  190. package/src/components/Resizable/Resizable.module.css +70 -0
  191. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  192. package/src/components/Resizable/Resizable.tsx +313 -0
  193. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  194. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  195. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  196. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  197. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  198. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  199. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  200. package/src/components/Select/Select.module.css +7 -3
  201. package/src/components/Sheet/Sheet.tsx +9 -51
  202. package/src/components/Slider/Slider.module.css +95 -0
  203. package/src/components/Slider/Slider.stories.tsx +64 -0
  204. package/src/components/Slider/Slider.tsx +77 -0
  205. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  206. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  207. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  208. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  209. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  210. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  211. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  212. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  213. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  214. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  215. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  216. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  217. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  218. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  219. package/src/components/Table/Table.module.css +45 -0
  220. package/src/components/Table/Table.stories.tsx +125 -0
  221. package/src/components/Table/Table.tsx +51 -0
  222. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  223. package/src/components/Tabs/Tabs.module.css +6 -1
  224. package/src/components/Toggle/Toggle.module.css +6 -1
  225. package/src/components/Typography/Typography.module.css +47 -0
  226. package/src/components/Typography/Typography.stories.tsx +62 -0
  227. package/src/components/Typography/Typography.tsx +30 -0
  228. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  229. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  230. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  231. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  232. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  233. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  234. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  235. package/src/hooks/index.ts +18 -0
  236. package/src/hooks/useCarouselRouteSync.ts +92 -0
  237. package/src/hooks/useIsOffline.ts +41 -0
  238. package/src/hooks/useOfflineTileState.ts +45 -0
  239. package/src/hooks/useResponsiveLayout.ts +36 -0
  240. package/src/hooks/useSnapCarousel.ts +79 -0
  241. package/src/hooks/useSwipeReveal.ts +107 -0
  242. package/src/index.ts +141 -3
  243. package/src/overlay-shell.ts +94 -0
  244. package/src/stories/DesignSystemOverview.stories.tsx +936 -3
  245. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  246. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  247. package/src/stories/InteractionHooks.stories.tsx +483 -0
  248. package/src/stories/MobilePatterns.stories.tsx +255 -4
  249. package/src/stories/NavTabs.stories.tsx +18 -0
  250. package/src/stories/OfflineGate.stories.tsx +42 -0
  251. package/src/stories/PageContainer.stories.tsx +37 -0
  252. package/src/stories/PageHeader.stories.tsx +11 -0
  253. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  254. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  255. package/src/stories/TokenGallery.stories.tsx +177 -16
  256. package/src/tokens/primitives.css +47 -8
@@ -77,20 +77,44 @@
77
77
  --sv-font-weight-semibold: 600;
78
78
  --sv-font-weight-bold: 700;
79
79
 
80
+ /* In-app text-size control (task 10.2), discharging the pinch-zoom-disabled
81
+ accessibility debt (runtime/app/layout.tsx sets userScalable: false).
82
+ Every --sv-font-size-* above is rem-based, so scaling the root font size
83
+ scales the whole type system with no per-component changes. Driven by
84
+ [data-text-size] on <html>, set pre-paint by runtime/src/theme-script.ts
85
+ from the `sv-text-size` cookie — same mechanism as [data-theme]. Like
86
+ --sv-radius-scale, this is a theme-stable scale token with no separate
87
+ semantic tier. */
88
+ --sv-text-size-scale: 1;
89
+
80
90
  /* Icon sizes — scale tokens used directly by the Icon component */
81
91
  --sv-icon-size-xs: 0.75rem; /* 12px — inline metadata (e.g. a due-date badge) */
82
92
  --sv-icon-size-sm: 1rem; /* 16px — inline with body text */
83
93
  --sv-icon-size-md: 1.25rem; /* 20px — standard affordance */
84
94
  --sv-icon-size-lg: 1.5rem; /* 24px — prominent / standalone */
85
95
 
86
- /* Border radii (v3.1 spec values) */
87
- --sv-radius-sm: 0.375rem; /* 6px — badge, tag */
88
- --sv-radius-md: 0.5rem; /* 8px — button, input */
89
- --sv-radius-lg: 0.6875rem; /* 11px — sidebar icon, card */
90
- --sv-radius-xl: 0.75rem; /* 12px — card, panel */
91
- --sv-radius-2xl: 0.875rem; /* 14px — popover */
92
- --sv-radius-3xl: 1.25rem; /* 20px — bottom sheet (Drawer top corners) */
93
- --sv-radius-full: 9999px; /* pill / avatar */
96
+ /* Border radii (v3.1 spec values). --sv-radius-scale is the one
97
+ instance-controllable exception to "scale tokens are theme-stable"
98
+ (RFC 0077) — an operator's corner-radius preset (None/XS/S/M/L)
99
+ overrides it at :root via the InstanceProvider style injection
100
+ (runtime/src/instance-provider.tsx), same pipeline as --sv-color-accent.
101
+ Every corner token below is proportional to it, so a plugin author never
102
+ references --sv-radius-scale directly — they keep using --sv-radius-sm
103
+ etc. exactly as before. --sv-radius-full is deliberately NOT derived
104
+ from the scale: it's a shape token (circle/pill), not a corner-rounding
105
+ one, and stays fixed regardless of the instance preset. */
106
+ --sv-radius-scale: 1;
107
+ --sv-radius-sm: calc(var(--sv-radius-scale) * 0.375rem); /* 6px @ scale 1 — badge, tag */
108
+ --sv-radius-md: calc(var(--sv-radius-scale) * 0.5rem); /* 8px @ scale 1 — button, input */
109
+ --sv-radius-lg: calc(
110
+ var(--sv-radius-scale) * 0.6875rem
111
+ ); /* 11px @ scale 1 — sidebar icon, card */
112
+ --sv-radius-xl: calc(var(--sv-radius-scale) * 0.75rem); /* 12px @ scale 1 — card, panel */
113
+ --sv-radius-2xl: calc(var(--sv-radius-scale) * 0.875rem); /* 14px @ scale 1 — popover */
114
+ --sv-radius-3xl: calc(
115
+ var(--sv-radius-scale) * 1.25rem
116
+ ); /* 20px @ scale 1 — bottom sheet (Drawer top corners) */
117
+ --sv-radius-full: 9999px; /* pill / avatar — unaffected by --sv-radius-scale */
94
118
 
95
119
  /* Touch targets — minimum hit-area dimension for interactive controls.
96
120
  44px follows the WCAG 2.5.5 / Apple HIG / Material guideline for reliable
@@ -116,3 +140,18 @@
116
140
  1
117
141
  ); /* slight overshoot — playful emphasis, use sparingly */
118
142
  }
143
+
144
+ /* Root font size scales with --sv-text-size-scale — see the token comment
145
+ above. calc(... * 100%) keeps the browser/OS text-zoom default intact at
146
+ scale 1 rather than hardcoding a px value. */
147
+ html {
148
+ font-size: calc(var(--sv-text-size-scale) * 100%);
149
+ }
150
+
151
+ :root[data-text-size='large'] {
152
+ --sv-text-size-scale: 1.125; /* +12.5% */
153
+ }
154
+
155
+ :root[data-text-size='larger'] {
156
+ --sv-text-size-scale: 1.25; /* +25% */
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.41.0",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -36,16 +36,17 @@
36
36
  "react-dom": "^19.0.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@storybook/addon-a11y": "^10.4.6",
40
- "@storybook/addon-themes": "^10.4.6",
41
- "@storybook/react-vite": "^10.4.6",
39
+ "@playwright/test": "^1.62.1",
40
+ "@storybook/addon-a11y": "^10.5.5",
41
+ "@storybook/addon-themes": "^10.5.5",
42
+ "@storybook/react-vite": "^10.5.5",
42
43
  "@testing-library/dom": "^10.4.1",
43
44
  "@testing-library/react": "^16.3.2",
44
- "@types/react": "^19.0.0",
45
- "@types/react-dom": "^19.0.0",
46
- "react": "^19.0.0",
47
- "react-dom": "^19.0.0",
48
- "storybook": "^10.4.6",
45
+ "@types/react": "^19.2.18",
46
+ "@types/react-dom": "^19.2.4",
47
+ "react": "^19.2.8",
48
+ "react-dom": "^19.2.8",
49
+ "storybook": "^10.5.5",
49
50
  "tsup": "^8.5.1",
50
51
  "typescript": "^5.9.3",
51
52
  "@sovereignfs/tsconfig": "0.0.0"
@@ -53,7 +54,10 @@
53
54
  "scripts": {
54
55
  "build": "tsup",
55
56
  "storybook": "storybook dev -p 6006",
57
+ "storybook:visual": "storybook dev -p 6100 --ci --quiet",
56
58
  "build-storybook": "storybook build --output-dir storybook-static",
59
+ "test:visual": "playwright test --config=playwright.visual.config.ts",
60
+ "test:visual:update": "playwright test --config=playwright.visual.config.ts --update-snapshots",
57
61
  "typecheck": "tsc --noEmit"
58
62
  },
59
63
  "types": "./dist/index.d.ts"
@@ -0,0 +1,3 @@
1
+ .root {
2
+ border-top: 1px solid var(--sv-color-border);
3
+ }
@@ -0,0 +1,110 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { expect, userEvent, within } from 'storybook/test';
4
+ import { Accordion } from './Accordion';
5
+
6
+ const meta = {
7
+ title: 'Components/Accordion',
8
+ component: Accordion,
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'One or more Collapsible sections. type="single" closes other sections when one opens; type="multiple" allows any number open at once.',
15
+ },
16
+ },
17
+ },
18
+ args: { items: [], type: 'single', openIds: [], onOpenIdsChange: () => {} },
19
+ } satisfies Meta<typeof Accordion>;
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const FAQ_ITEMS = [
27
+ {
28
+ id: 'plugins',
29
+ trigger: 'What plugins ship by default?',
30
+ content: 'Only the platform plugins (Console, Launcher, Account) plus Sovereign Tasks.',
31
+ },
32
+ {
33
+ id: 'hosting',
34
+ trigger: 'Can I self-host this?',
35
+ content: 'Yes — Sovereign is designed to be self-hosted via Docker Compose.',
36
+ },
37
+ {
38
+ id: 'pricing',
39
+ trigger: 'Is it free?',
40
+ content: 'The platform is open source. Individual plugins may have their own pricing.',
41
+ },
42
+ ];
43
+
44
+ export const Default: Story = {
45
+ render: () => {
46
+ const [openIds, setOpenIds] = useState<string[]>(['plugins']);
47
+ return (
48
+ <Accordion items={FAQ_ITEMS} type="single" openIds={openIds} onOpenIdsChange={setOpenIds} />
49
+ );
50
+ },
51
+ };
52
+
53
+ export const MultipleOpen: Story = {
54
+ render: () => {
55
+ const [openIds, setOpenIds] = useState<string[]>(['plugins', 'hosting']);
56
+ return (
57
+ <Accordion items={FAQ_ITEMS} type="multiple" openIds={openIds} onOpenIdsChange={setOpenIds} />
58
+ );
59
+ },
60
+ };
61
+
62
+ export const LongContent: Story = {
63
+ render: () => {
64
+ const [openIds, setOpenIds] = useState<string[]>(['changelog']);
65
+ return (
66
+ <Accordion
67
+ items={[
68
+ {
69
+ id: 'changelog',
70
+ trigger: 'Full changelog',
71
+ content: (
72
+ <ul style={{ margin: 0, paddingLeft: 20 }}>
73
+ <li>Added component sizing alignment across the design system</li>
74
+ <li>Added RadioGroup, Slider, Progress, Table, Alert primitives</li>
75
+ <li>Added Breadcrumb, Pagination, and Kbd primitives</li>
76
+ <li>Added Accordion and Collapsible primitives</li>
77
+ </ul>
78
+ ),
79
+ },
80
+ ]}
81
+ type="single"
82
+ openIds={openIds}
83
+ onOpenIdsChange={setOpenIds}
84
+ />
85
+ );
86
+ },
87
+ };
88
+
89
+ export const KeyboardInteraction: Story = {
90
+ render: () => {
91
+ const [openIds, setOpenIds] = useState<string[]>([]);
92
+ return (
93
+ <Accordion items={FAQ_ITEMS} type="single" openIds={openIds} onOpenIdsChange={setOpenIds} />
94
+ );
95
+ },
96
+ play: async ({ canvasElement }) => {
97
+ const canvas = within(canvasElement);
98
+ const trigger = canvas.getByRole('button', { name: 'What plugins ship by default?' });
99
+
100
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false');
101
+
102
+ trigger.focus();
103
+ await userEvent.keyboard('{Enter}');
104
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
105
+ await expect(canvas.getByText(/Only the platform plugins/)).toBeVisible();
106
+
107
+ await userEvent.keyboard(' ');
108
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false');
109
+ },
110
+ };
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Collapsible } from '../Collapsible/Collapsible';
3
+ import styles from './Accordion.module.css';
4
+
5
+ export interface AccordionItem {
6
+ id: string;
7
+ trigger: ReactNode;
8
+ content: ReactNode;
9
+ }
10
+
11
+ export interface AccordionProps {
12
+ items: AccordionItem[];
13
+ /** `single` closes other sections when one opens. `multiple` allows any
14
+ * number of sections open at once. */
15
+ type: 'single' | 'multiple';
16
+ openIds: string[];
17
+ onOpenIdsChange: (openIds: string[]) => void;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Accordion — one or more `Collapsible` sections.
23
+ *
24
+ * Keyboard support (Enter/Space toggles the focused trigger) comes from
25
+ * `Collapsible`'s real `<button>` — no arrow-key roving-tabindex handling
26
+ * here, since accordion sections aren't a roving-tabindex widget per the
27
+ * WAI-ARIA accordion pattern (each trigger is independently Tab-focusable).
28
+ */
29
+ export function Accordion({ items, type, openIds, onOpenIdsChange, className }: AccordionProps) {
30
+ function handleOpenChange(id: string, open: boolean) {
31
+ if (type === 'single') {
32
+ onOpenIdsChange(open ? [id] : []);
33
+ return;
34
+ }
35
+ onOpenIdsChange(open ? [...openIds, id] : openIds.filter((openId) => openId !== id));
36
+ }
37
+
38
+ return (
39
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
40
+ {items.map((item) => (
41
+ <Collapsible
42
+ key={item.id}
43
+ id={item.id}
44
+ trigger={item.trigger}
45
+ open={openIds.includes(item.id)}
46
+ onOpenChange={(open) => handleOpenChange(item.id, open)}
47
+ >
48
+ {item.content}
49
+ </Collapsible>
50
+ ))}
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,61 @@
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 { Accordion } from '../Accordion';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const items = [
9
+ { id: 'a', trigger: 'Section A', content: 'Content A' },
10
+ { id: 'b', trigger: 'Section B', content: 'Content B' },
11
+ ];
12
+
13
+ describe('Accordion', () => {
14
+ it('renders a trigger button per item', () => {
15
+ render(<Accordion items={items} type="single" openIds={[]} onOpenIdsChange={() => {}} />);
16
+ expect(screen.getByRole('button', { name: 'Section A' })).toBeDefined();
17
+ expect(screen.getByRole('button', { name: 'Section B' })).toBeDefined();
18
+ });
19
+
20
+ it('type="single": opening one section replaces the open set entirely', () => {
21
+ const onOpenIdsChange = vi.fn();
22
+ render(
23
+ <Accordion items={items} type="single" openIds={['a']} onOpenIdsChange={onOpenIdsChange} />,
24
+ );
25
+ fireEvent.click(screen.getByRole('button', { name: 'Section B' }));
26
+ expect(onOpenIdsChange).toHaveBeenCalledWith(['b']);
27
+ });
28
+
29
+ it('type="multiple": opening a section adds to the open set', () => {
30
+ const onOpenIdsChange = vi.fn();
31
+ render(
32
+ <Accordion items={items} type="multiple" openIds={['a']} onOpenIdsChange={onOpenIdsChange} />,
33
+ );
34
+ fireEvent.click(screen.getByRole('button', { name: 'Section B' }));
35
+ expect(onOpenIdsChange).toHaveBeenCalledWith(['a', 'b']);
36
+ });
37
+
38
+ it('type="multiple": closing an open section removes only that id', () => {
39
+ const onOpenIdsChange = vi.fn();
40
+ render(
41
+ <Accordion
42
+ items={items}
43
+ type="multiple"
44
+ openIds={['a', 'b']}
45
+ onOpenIdsChange={onOpenIdsChange}
46
+ />,
47
+ );
48
+ fireEvent.click(screen.getByRole('button', { name: 'Section A' }));
49
+ expect(onOpenIdsChange).toHaveBeenCalledWith(['b']);
50
+ });
51
+
52
+ it('reflects openIds via aria-expanded on each trigger', () => {
53
+ render(<Accordion items={items} type="single" openIds={['a']} onOpenIdsChange={() => {}} />);
54
+ expect(screen.getByRole('button', { name: 'Section A' }).getAttribute('aria-expanded')).toBe(
55
+ 'true',
56
+ );
57
+ expect(screen.getByRole('button', { name: 'Section B' }).getAttribute('aria-expanded')).toBe(
58
+ 'false',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,62 @@
1
+ .alert {
2
+ display: flex;
3
+ align-items: flex-start;
4
+ gap: var(--sv-space-2);
5
+ padding: var(--sv-space-3) var(--sv-space-4);
6
+ border: 1px solid transparent;
7
+ border-radius: var(--sv-radius-md);
8
+ font-size: var(--sv-font-size-sm);
9
+ }
10
+
11
+ .icon {
12
+ flex-shrink: 0;
13
+ /* Optically align to the first line of text rather than the box's
14
+ vertical center — matches how the heading/body cap-height sits. */
15
+ margin-top: 2px;
16
+ }
17
+
18
+ .content {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--sv-space-1);
22
+ min-width: 0;
23
+ }
24
+
25
+ .heading {
26
+ margin: 0;
27
+ font-weight: var(--sv-font-weight-semibold);
28
+ }
29
+
30
+ .body {
31
+ margin: 0;
32
+ }
33
+
34
+ .info {
35
+ background: var(--sv-color-info-surface);
36
+ border-color: var(--sv-color-info-border);
37
+ color: var(--sv-color-info-text);
38
+ }
39
+
40
+ .success {
41
+ background: var(--sv-color-success-surface);
42
+ border-color: var(--sv-color-success-border);
43
+ color: var(--sv-color-success-text);
44
+ }
45
+
46
+ .warning {
47
+ background: var(--sv-color-warning-surface);
48
+ border-color: var(--sv-color-warning-border);
49
+ color: var(--sv-color-warning-text);
50
+ }
51
+
52
+ .error {
53
+ background: var(--sv-color-error-surface);
54
+ border-color: var(--sv-color-error-border);
55
+ color: var(--sv-color-error-text);
56
+ }
57
+
58
+ .neutral {
59
+ background: var(--sv-color-surface-sunken);
60
+ border-color: var(--sv-color-border);
61
+ color: var(--sv-color-text-primary);
62
+ }
@@ -0,0 +1,96 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Alert } from './Alert';
3
+
4
+ const meta = {
5
+ title: 'Components/Alert',
6
+ component: Alert,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Inline, non-dismissible banner. Distinct from Toast (transient) and SystemBanner (instance-wide) — for form-level errors or explaining an empty/blocked state. A leading icon defaults per variant (none for neutral) — pass `icon` to override it or `icon={false}` to suppress it.',
13
+ },
14
+ },
15
+ },
16
+ argTypes: {
17
+ variant: { control: 'select', options: ['info', 'success', 'warning', 'error', 'neutral'] },
18
+ },
19
+ args: {
20
+ variant: 'info',
21
+ children: 'This is an informational message.',
22
+ },
23
+ } satisfies Meta<typeof Alert>;
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const Info: Story = {
31
+ args: { variant: 'info' },
32
+ };
33
+
34
+ export const Success: Story = {
35
+ args: { variant: 'success', children: 'Your changes have been saved.' },
36
+ };
37
+
38
+ export const Warning: Story = {
39
+ args: { variant: 'warning', children: 'This action cannot be undone.' },
40
+ };
41
+
42
+ export const ErrorVariant: Story = {
43
+ name: 'Error',
44
+ args: { variant: 'error', children: 'Select a size to continue.' },
45
+ };
46
+
47
+ export const Neutral: Story = {
48
+ args: { variant: 'neutral', children: 'This project has no members yet.' },
49
+ };
50
+
51
+ export const WithHeading: Story = {
52
+ args: {
53
+ variant: 'error',
54
+ heading: 'Something went wrong',
55
+ children: 'We couldn’t save your changes. Check your connection and try again.',
56
+ },
57
+ };
58
+
59
+ export const WithoutHeading: Story = {
60
+ args: { variant: 'success', children: 'Invite sent.' },
61
+ };
62
+
63
+ /** Icon + heading + body — the pattern most alert examples use. */
64
+ export const IconHeadingAndBody: Story = {
65
+ args: {
66
+ variant: 'success',
67
+ heading: 'Payment successful',
68
+ children:
69
+ 'Your payment of $29.99 has been processed. A receipt has been sent to your email address.',
70
+ },
71
+ };
72
+
73
+ export const CustomIcon: Story = {
74
+ args: { variant: 'neutral', icon: 'bell', children: 'You have 3 unread notifications.' },
75
+ };
76
+
77
+ export const NoIcon: Story = {
78
+ args: { variant: 'success', icon: false, children: 'Your changes have been saved.' },
79
+ };
80
+
81
+ /** Every variant with its default icon, at a glance. */
82
+ export const AllVariants: Story = {
83
+ render: () => (
84
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 480 }}>
85
+ <Alert variant="info" heading="New feature available">
86
+ We’ve added dark mode support. You can enable it in your account settings.
87
+ </Alert>
88
+ <Alert variant="success" heading="Payment successful">
89
+ Your payment of $29.99 has been processed. A receipt has been sent to your email address.
90
+ </Alert>
91
+ <Alert variant="warning">This action cannot be undone.</Alert>
92
+ <Alert variant="error">Select a size to continue.</Alert>
93
+ <Alert variant="neutral">This project has no members yet.</Alert>
94
+ </div>
95
+ ),
96
+ };
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Icon, type IconName } from '../Icon/Icon';
3
+ import styles from './Alert.module.css';
4
+
5
+ export type AlertVariant = 'info' | 'success' | 'warning' | 'error' | 'neutral';
6
+
7
+ /** Sensible default icon per variant. `neutral` has none — there's no
8
+ * universal "neutral" glyph, so it stays icon-less unless `icon` is set
9
+ * explicitly. */
10
+ const DEFAULT_ICON: Record<AlertVariant, IconName | undefined> = {
11
+ info: 'info',
12
+ success: 'circle-check',
13
+ warning: 'alert-triangle',
14
+ error: 'circle-x',
15
+ neutral: undefined,
16
+ };
17
+
18
+ export interface AlertProps {
19
+ variant: AlertVariant;
20
+ heading?: string;
21
+ /** Leading icon next to the heading. Defaults to a per-variant icon (see
22
+ * `DEFAULT_ICON`) — pass an `IconName` to override it, or `false` to
23
+ * suppress the icon entirely. Purely decorative: the variant's colour and
24
+ * `heading`/`children` text already carry the meaning. */
25
+ icon?: IconName | false;
26
+ children: ReactNode;
27
+ id?: string;
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * Alert — inline, non-dismissible banner.
33
+ *
34
+ * Distinct from `Toast` (transient, auto-dismissing) and `SystemBanner`
35
+ * (instance-wide): Alert is for in-context messaging — form-level errors,
36
+ * or explaining an empty/blocked state.
37
+ *
38
+ * `role="alert"` for `error` (interrupts screen readers immediately, since
39
+ * an error demands attention); `role="status"` for the other variants
40
+ * (announced politely, without interrupting).
41
+ */
42
+ export function Alert({ variant, heading, icon, children, id, className }: AlertProps) {
43
+ const resolvedIcon = icon === undefined ? DEFAULT_ICON[variant] : icon || undefined;
44
+
45
+ return (
46
+ <div
47
+ id={id}
48
+ role={variant === 'error' ? 'alert' : 'status'}
49
+ className={[styles.alert, styles[variant], className].filter(Boolean).join(' ')}
50
+ >
51
+ {resolvedIcon && <Icon name={resolvedIcon} size="sm" className={styles.icon} aria-hidden />}
52
+ <div className={styles.content}>
53
+ {heading && <p className={styles.heading}>{heading}</p>}
54
+ <div className={styles.body}>{children}</div>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Alert } from '../Alert';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Alert', () => {
9
+ it('uses role="alert" for the error variant', () => {
10
+ render(<Alert variant="error">Something went wrong.</Alert>);
11
+ expect(screen.getByRole('alert')).toBeDefined();
12
+ });
13
+
14
+ it.each(['info', 'success', 'warning', 'neutral'] as const)(
15
+ 'uses role="status" for the %s variant',
16
+ (variant) => {
17
+ render(<Alert variant={variant}>Message</Alert>);
18
+ expect(screen.getByRole('status')).toBeDefined();
19
+ },
20
+ );
21
+
22
+ it('renders a heading when given one', () => {
23
+ render(
24
+ <Alert variant="warning" heading="Heads up">
25
+ Body text.
26
+ </Alert>,
27
+ );
28
+ expect(screen.getByText('Heads up')).toBeDefined();
29
+ expect(screen.getByText('Body text.')).toBeDefined();
30
+ });
31
+
32
+ it('renders without a heading', () => {
33
+ render(<Alert variant="info">Just the body.</Alert>);
34
+ expect(screen.getByText('Just the body.')).toBeDefined();
35
+ });
36
+
37
+ it('renders a default icon for info, success, warning, and error, but none for neutral', () => {
38
+ const info = render(<Alert variant="info">Message</Alert>);
39
+ expect(info.container.querySelector('svg')).not.toBeNull();
40
+ info.unmount();
41
+
42
+ const success = render(<Alert variant="success">Message</Alert>);
43
+ expect(success.container.querySelector('svg')).not.toBeNull();
44
+ success.unmount();
45
+
46
+ const warning = render(<Alert variant="warning">Message</Alert>);
47
+ expect(warning.container.querySelector('svg')).not.toBeNull();
48
+ warning.unmount();
49
+
50
+ const error = render(<Alert variant="error">Message</Alert>);
51
+ expect(error.container.querySelector('svg')).not.toBeNull();
52
+ error.unmount();
53
+
54
+ const neutral = render(<Alert variant="neutral">Message</Alert>);
55
+ expect(neutral.container.querySelector('svg')).toBeNull();
56
+ });
57
+
58
+ it('suppresses the default icon when icon={false}', () => {
59
+ const { container } = render(
60
+ <Alert variant="success" icon={false}>
61
+ Message
62
+ </Alert>,
63
+ );
64
+ expect(container.querySelector('svg')).toBeNull();
65
+ });
66
+
67
+ it('overrides the default icon when icon is set explicitly', () => {
68
+ const { container } = render(
69
+ <Alert variant="neutral" icon="info">
70
+ Message
71
+ </Alert>,
72
+ );
73
+ expect(container.querySelector('svg')).not.toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,15 @@
1
+ .root {
2
+ position: relative;
3
+ width: 100%;
4
+ overflow: hidden;
5
+ border-radius: var(--sv-radius-md);
6
+ }
7
+
8
+ /* A single direct child (img/video/iframe) fills the ratio box. */
9
+ .root > :global(*) {
10
+ position: absolute;
11
+ inset: 0;
12
+ width: 100%;
13
+ height: 100%;
14
+ object-fit: cover;
15
+ }