@sovereignfs/ui 0.45.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/LegalLinks.module.css +24 -0
  26. package/dist/Markdown.module.css +73 -0
  27. package/dist/Menu.module.css +1 -0
  28. package/dist/Message.module.css +1 -1
  29. package/dist/MessageScroller.module.css +12 -1
  30. package/dist/MobileAppsDrawer.module.css +82 -0
  31. package/dist/MobileFooter.module.css +73 -0
  32. package/dist/MobileHeader.module.css +76 -0
  33. package/dist/NavList.module.css +130 -0
  34. package/dist/NavTabs.module.css +1 -1
  35. package/dist/NotificationsPanel.module.css +258 -0
  36. package/dist/OverlayHeader.module.css +1 -1
  37. package/dist/PageContainer.module.css +82 -0
  38. package/dist/PageHeader.module.css +54 -0
  39. package/dist/PageLayout.module.css +51 -0
  40. package/dist/Popover.module.css +1 -1
  41. package/dist/QuantityStepper.module.css +1 -1
  42. package/dist/Resizable.module.css +3 -3
  43. package/dist/RootLayout.module.css +12 -0
  44. package/dist/SegmentedControl.module.css +1 -1
  45. package/dist/Select.module.css +1 -1
  46. package/dist/Sheet.module.css +4 -1
  47. package/dist/SplitPane.module.css +4 -4
  48. package/dist/StatusBadge.module.css +1 -1
  49. package/dist/StepStrip.module.css +53 -0
  50. package/dist/SuggestionInput.module.css +2 -2
  51. package/dist/SwipableMobileCarousel.module.css +64 -0
  52. package/dist/SwipableMobileCarouselDots.module.css +64 -0
  53. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  54. package/dist/SwipeStack.module.css +101 -0
  55. package/dist/SystemBanner.module.css +10 -0
  56. package/dist/Table.module.css +2 -2
  57. package/dist/Tabs.module.css +1 -1
  58. package/dist/TagInput.module.css +2 -2
  59. package/dist/Textarea.module.css +9 -1
  60. package/dist/ThreeColumnLayout.module.css +35 -0
  61. package/dist/Toast.module.css +62 -3
  62. package/dist/Tooltip.module.css +1 -1
  63. package/dist/UserMenu.module.css +90 -0
  64. package/dist/index.d.ts +1680 -30
  65. package/dist/index.js +4618 -1443
  66. package/dist/tokens/primitives.css +36 -0
  67. package/dist/tokens/semantic.css +32 -0
  68. package/dist/tokens/theme-presets.css +33 -0
  69. package/package.json +32 -12
  70. package/src/__tests__/package-json.test.ts +8 -0
  71. package/src/components/Accordion/Accordion.module.css +1 -1
  72. package/src/components/Alert/Alert.module.css +1 -1
  73. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  74. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  75. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  76. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  77. package/src/components/Avatar/Avatar.module.css +1 -1
  78. package/src/components/Badge/Badge.module.css +30 -7
  79. package/src/components/Badge/Badge.stories.tsx +16 -4
  80. package/src/components/Badge/Badge.tsx +8 -11
  81. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  82. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  83. package/src/components/Button/Button.module.css +18 -2
  84. package/src/components/Card/Card.module.css +1 -1
  85. package/src/components/CardTile/CardTile.module.css +181 -0
  86. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  87. package/src/components/CardTile/CardTile.tsx +128 -0
  88. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  89. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  90. package/src/components/Collapsible/Collapsible.module.css +1 -1
  91. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  92. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  93. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  94. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  95. package/src/components/Combobox/Combobox.module.css +2 -2
  96. package/src/components/Command/Command.module.css +1 -1
  97. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  98. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  99. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  100. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  101. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  102. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  103. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  104. package/src/components/DatePicker/DatePicker.module.css +1 -1
  105. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  106. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  107. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  108. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  109. package/src/components/Dialog/Dialog.module.css +130 -21
  110. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  111. package/src/components/Dialog/Dialog.tsx +137 -94
  112. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  113. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  114. package/src/components/Drawer/Drawer.module.css +36 -7
  115. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  116. package/src/components/Drawer/Drawer.tsx +27 -66
  117. package/src/components/Drawer/__tests__/Drawer.test.tsx +55 -6
  118. package/src/components/GripIcon/GripIcon.tsx +27 -0
  119. package/src/components/Header/Header.module.css +62 -0
  120. package/src/components/Header/Header.stories.tsx +452 -0
  121. package/src/components/Header/Header.tsx +102 -0
  122. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  123. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  124. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  125. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  126. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  127. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  128. package/src/components/Icon/icons/bold.tsx +20 -0
  129. package/src/components/Icon/icons/calendar.tsx +4 -4
  130. package/src/components/Icon/icons/carrot.tsx +3 -3
  131. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  132. package/src/components/Icon/icons/code.tsx +21 -0
  133. package/src/components/Icon/icons/copy.tsx +21 -0
  134. package/src/components/Icon/icons/credit-card.tsx +21 -0
  135. package/src/components/Icon/icons/database.tsx +22 -0
  136. package/src/components/Icon/icons/download.tsx +22 -0
  137. package/src/components/Icon/icons/file-text.tsx +24 -0
  138. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  139. package/src/components/Icon/icons/folder-open.tsx +20 -0
  140. package/src/components/Icon/icons/folder.tsx +20 -0
  141. package/src/components/Icon/icons/folders.tsx +21 -0
  142. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  143. package/src/components/Icon/icons/history.tsx +22 -0
  144. package/src/components/Icon/icons/inbox.tsx +21 -0
  145. package/src/components/Icon/icons/index.ts +169 -1
  146. package/src/components/Icon/icons/italic.tsx +22 -0
  147. package/src/components/Icon/icons/layers.tsx +22 -0
  148. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  149. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  150. package/src/components/Icon/icons/link.tsx +21 -0
  151. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  152. package/src/components/Icon/icons/list.tsx +25 -0
  153. package/src/components/Icon/icons/luggage.tsx +24 -0
  154. package/src/components/Icon/icons/map-pin.tsx +21 -0
  155. package/src/components/Icon/icons/menu.tsx +22 -0
  156. package/src/components/Icon/icons/minus.tsx +20 -0
  157. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  158. package/src/components/Icon/icons/panel-left.tsx +21 -0
  159. package/src/components/Icon/icons/pin.tsx +21 -0
  160. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  161. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  162. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  163. package/src/components/Icon/icons/route.tsx +22 -0
  164. package/src/components/Icon/icons/send.tsx +21 -0
  165. package/src/components/Icon/icons/share-2.tsx +24 -0
  166. package/src/components/Icon/icons/sheet.tsx +24 -0
  167. package/src/components/Icon/icons/smartphone.tsx +21 -0
  168. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  169. package/src/components/Icon/icons/table.tsx +23 -0
  170. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  171. package/src/components/Icon/icons/users.tsx +23 -0
  172. package/src/components/IconPicker/IconPicker.module.css +1 -1
  173. package/src/components/Input/Input.module.css +1 -1
  174. package/src/components/Input/Input.tsx +8 -2
  175. package/src/components/Kbd/Kbd.module.css +1 -1
  176. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  177. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  178. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  179. package/src/components/Markdown/Markdown.module.css +73 -0
  180. package/src/components/Markdown/Markdown.stories.tsx +40 -0
  181. package/src/components/Markdown/Markdown.tsx +305 -0
  182. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  183. package/src/components/Menu/Menu.module.css +1 -0
  184. package/src/components/Menu/Menu.tsx +72 -22
  185. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  186. package/src/components/Menubar/Menubar.tsx +1 -1
  187. package/src/components/Message/Message.module.css +1 -1
  188. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  189. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  190. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +82 -0
  191. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  192. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  193. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  194. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  195. package/src/components/MobileFooter/MobileFooter.tsx +122 -0
  196. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +129 -0
  197. package/src/components/MobileHeader/MobileHeader.module.css +76 -0
  198. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  199. package/src/components/MobileHeader/MobileHeader.tsx +90 -0
  200. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +113 -0
  201. package/src/components/NavList/NavList.module.css +130 -0
  202. package/src/components/NavList/NavList.tsx +136 -0
  203. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  204. package/src/components/NavTabs/NavTabs.module.css +1 -1
  205. package/src/components/NavTabs/NavTabs.tsx +39 -11
  206. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  207. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  208. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  209. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  210. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  211. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  212. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  213. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  214. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  215. package/src/components/PageContainer/PageContainer.module.css +82 -0
  216. package/src/components/PageContainer/PageContainer.tsx +64 -0
  217. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +70 -0
  218. package/src/components/PageHeader/PageHeader.module.css +54 -0
  219. package/src/components/PageHeader/PageHeader.tsx +157 -2
  220. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  221. package/src/components/PageLayout/PageLayout.module.css +51 -0
  222. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  223. package/src/components/PageLayout/PageLayout.tsx +51 -0
  224. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  225. package/src/components/Popover/Popover.module.css +1 -1
  226. package/src/components/Popover/Popover.tsx +86 -4
  227. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  228. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  229. package/src/components/Resizable/Resizable.module.css +3 -3
  230. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  231. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  232. package/src/components/RootLayout/RootLayout.module.css +12 -0
  233. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  234. package/src/components/RootLayout/RootLayout.tsx +185 -0
  235. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  236. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  237. package/src/components/Select/Select.module.css +1 -1
  238. package/src/components/Sheet/Sheet.module.css +4 -1
  239. package/src/components/Sheet/Sheet.tsx +14 -56
  240. package/src/components/SplitPane/SplitPane.module.css +4 -4
  241. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  242. package/src/components/StepStrip/StepStrip.module.css +53 -0
  243. package/src/components/StepStrip/StepStrip.tsx +74 -0
  244. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  245. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +64 -0
  246. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +243 -0
  247. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  248. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  249. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  250. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  251. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  252. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +214 -0
  253. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  254. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  255. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +64 -0
  256. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +73 -0
  257. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  258. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  259. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  260. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  261. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  262. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  263. package/src/components/Table/Table.module.css +2 -2
  264. package/src/components/Tabs/Tabs.module.css +1 -1
  265. package/src/components/TagInput/TagInput.module.css +2 -2
  266. package/src/components/Textarea/Textarea.module.css +9 -1
  267. package/src/components/Textarea/Textarea.tsx +28 -5
  268. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  269. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  270. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  271. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  272. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  273. package/src/components/Toast/Toast.module.css +62 -3
  274. package/src/components/Toast/Toast.stories.tsx +1 -1
  275. package/src/components/Tooltip/Tooltip.module.css +1 -1
  276. package/src/components/UserMenu/UserMenu.module.css +90 -0
  277. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  278. package/src/components/UserMenu/UserMenu.tsx +132 -0
  279. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  280. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  281. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  282. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  283. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  284. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  285. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  286. package/src/hooks/__tests__/useSnapCarousel.test.tsx +175 -0
  287. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  288. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  289. package/src/hooks/index.ts +27 -0
  290. package/src/hooks/useCarouselRouteSync.ts +92 -0
  291. package/src/hooks/useIsOffline.ts +41 -0
  292. package/src/hooks/useOfflineTileState.ts +45 -0
  293. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  294. package/src/hooks/useReorderSensors.ts +88 -0
  295. package/src/hooks/useResponsiveLayout.ts +36 -0
  296. package/src/hooks/useSnapCarousel.ts +284 -0
  297. package/src/hooks/useSwipeReveal.ts +107 -0
  298. package/src/hooks/useSwipeStack.ts +235 -0
  299. package/src/index.ts +124 -3
  300. package/src/overlay-shell.ts +159 -0
  301. package/src/stories/DesignSystemOverview.stories.tsx +933 -6
  302. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  303. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  304. package/src/stories/GripIcon.stories.tsx +29 -0
  305. package/src/stories/InteractionHooks.stories.tsx +483 -0
  306. package/src/stories/LayoutSystem.stories.tsx +639 -0
  307. package/src/stories/MenuEntries.stories.tsx +86 -0
  308. package/src/stories/MobilePatterns.stories.tsx +247 -2
  309. package/src/stories/NavList.stories.tsx +147 -0
  310. package/src/stories/NavTabs.stories.tsx +18 -0
  311. package/src/stories/OfflineGate.stories.tsx +42 -0
  312. package/src/stories/PageContainer.stories.tsx +82 -0
  313. package/src/stories/PageHeader.stories.tsx +65 -1
  314. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  315. package/src/stories/StepStrip.stories.tsx +76 -0
  316. package/src/stories/SwipableMobileCarousel.stories.tsx +362 -0
  317. package/src/stories/SwipeStack.stories.tsx +200 -0
  318. package/src/stories/Textarea.stories.tsx +12 -0
  319. package/src/stories/TokenGallery.stories.tsx +115 -0
  320. package/src/tokens/primitives.css +36 -0
  321. package/src/tokens/semantic.css +32 -0
  322. package/src/tokens/theme-presets.css +33 -0
  323. package/src/tokens/theme-presets.ts +72 -0
@@ -0,0 +1,305 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+ import { Typography } from '../Typography/Typography';
3
+ import styles from './Markdown.module.css';
4
+
5
+ export interface MarkdownProps {
6
+ /** Raw markdown source. Supports: headings (#/##/###), paragraphs,
7
+ * **bold**, *italic*, `code`, [links](url), unordered lists (-), ordered
8
+ * lists (1. / 1)), fenced code blocks (```), and blockquotes (>),
9
+ * including soft-wrapped continuation lines within a paragraph or list
10
+ * item. Not a general-purpose CommonMark parser — no tables, images, or
11
+ * nested blocks. */
12
+ content: string;
13
+ /** Renders every line within a paragraph on its own visual line (a
14
+ * `<br>` between them) instead of the default CommonMark-style soft-wrap
15
+ * (consecutive lines joined with a single space, requiring a blank line
16
+ * for a new paragraph). Off by default — the soft-wrap behavior is
17
+ * correct for this component's primary use (long-form, first-party
18
+ * content authored *as* markdown, where a hard line break is a
19
+ * deliberate `\` ` or blank-line choice). Turn this on when rendering
20
+ * plain, user-typed multi-line text that happens to be passed through
21
+ * this component (e.g. a card description or comment body edited in an
22
+ * ordinary `<textarea>`) — there, every Enter press is a real,
23
+ * intentional line break the user typed, and silently collapsing it
24
+ * into one run-on paragraph reads as broken, not as "markdown
25
+ * formatting applied." */
26
+ preserveLineBreaks?: boolean;
27
+ className?: string;
28
+ }
29
+
30
+ let inlineKey = 0;
31
+
32
+ /** Schemes allowed in a rendered `[text](url)` link's `href`. Anything else —
33
+ * most importantly `javascript:` — is rejected: `content` here can be
34
+ * LLM-generated (Warden's chat replies), so a prompt-injected document or a
35
+ * compromised model is a realistic way for an attacker-chosen URL to end up
36
+ * in this position. */
37
+ const SAFE_HREF_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
38
+
39
+ function isSafeHref(href: string): boolean {
40
+ const scheme = /^([a-zA-Z][a-zA-Z\d+.-]*):/.exec(href)?.[1];
41
+ return scheme === undefined || SAFE_HREF_SCHEMES.has(scheme.toLowerCase());
42
+ }
43
+
44
+ /** Returns a link's `href` value ready to render, or `null` to reject it.
45
+ * Rejecting is what keeps an unsafe URL out of the DOM; `encodeURI` on top
46
+ * is the contextual output-encoding this class of finding calls for. The
47
+ * sink below reads this function's *return value*, never the raw `href` —
48
+ * a boolean guard around the original string still leaves the tainted
49
+ * value itself flowing into `href`. Browsers ignore tabs/newlines when
50
+ * parsing a URL's scheme, so those are stripped first to close a
51
+ * `jav\tascript:`-style bypass; `encodeURI` throws on a lone unpaired
52
+ * surrogate, hence the try/catch. */
53
+ function sanitizeHref(href: string): string | null {
54
+ const normalized = href.replace(/[\t\n\r]/g, '').trim();
55
+ if (!normalized || !isSafeHref(normalized)) return null;
56
+ try {
57
+ return encodeURI(normalized);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Parses **bold**, *italic*, `code`, and [text](url) within a line of text. */
64
+ function parseInline(text: string): ReactNode[] {
65
+ const nodes: ReactNode[] = [];
66
+ const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/;
67
+ let remaining = text;
68
+
69
+ while (remaining.length > 0) {
70
+ const match = pattern.exec(remaining);
71
+ if (!match) {
72
+ nodes.push(remaining);
73
+ break;
74
+ }
75
+ if (match.index > 0) nodes.push(remaining.slice(0, match.index));
76
+
77
+ const [full, linkText, linkHref, boldText, italicText, codeText] = match;
78
+ const key = `md-inline-${inlineKey++}`;
79
+ if (linkHref !== undefined) {
80
+ const safeHref = sanitizeHref(linkHref);
81
+ nodes.push(
82
+ safeHref !== null ? (
83
+ <a key={key} href={safeHref}>
84
+ {linkText}
85
+ </a>
86
+ ) : (
87
+ linkText
88
+ ),
89
+ );
90
+ } else if (boldText !== undefined) {
91
+ nodes.push(<strong key={key}>{boldText}</strong>);
92
+ } else if (italicText !== undefined) {
93
+ nodes.push(<em key={key}>{italicText}</em>);
94
+ } else if (codeText !== undefined) {
95
+ nodes.push(<code key={key}>{codeText}</code>);
96
+ }
97
+ remaining = remaining.slice(match.index + full.length);
98
+ }
99
+
100
+ return nodes;
101
+ }
102
+
103
+ const isQuote = (l: string) => l.startsWith('> ') || l === '>';
104
+ const isListItem = (l: string) => l.startsWith('- ');
105
+ const isHeading = (l: string) => /^#{1,3}\s+/.test(l);
106
+ /** ```` ```lang ```` — an opening (or closing) code fence. */
107
+ const isFence = (l: string) => /^\s*`{3,}/.test(l);
108
+ /** `1. item` or `1) item`. Bounded digit count so a long numeric line
109
+ * can't be mistaken for a list marker. */
110
+ const isOrderedItem = (l: string) => /^\d{1,9}[.)]\s+/.test(l);
111
+ /** A line that ends the current *paragraph* run — a blank line, or the
112
+ * start of a heading, quote, list, or code block. Anything else (including
113
+ * an indented continuation line) is folded into the paragraph, matching how
114
+ * the source markdown soft-wraps prose across lines. */
115
+ const endsParagraph = (l: string) =>
116
+ l.trim() === '' || isHeading(l) || isQuote(l) || isListItem(l) || isOrderedItem(l) || isFence(l);
117
+ /** A line that ends the current *unordered list* run — a blank line, or the
118
+ * start of a heading, quote, code block, or an *ordered* item. Deliberately
119
+ * does NOT include `isListItem`: a list item's own opening line legitimately
120
+ * satisfies `isListItem`, so reusing `endsParagraph` here (which does) would
121
+ * treat the first line of every list as ending the list before it started —
122
+ * the list-collection loop would then never advance, looping forever. */
123
+ const endsList = (l: string) =>
124
+ l.trim() === '' || isHeading(l) || isQuote(l) || isFence(l) || isOrderedItem(l);
125
+ /** The ordered-list mirror of `endsList` — excludes `isOrderedItem` for the
126
+ * same reason, and treats an unordered item as the start of a new block. */
127
+ const endsOrderedList = (l: string) =>
128
+ l.trim() === '' || isHeading(l) || isQuote(l) || isFence(l) || isListItem(l);
129
+
130
+ function parseBlocks(content: string, preserveLineBreaks: boolean): ReactNode[] {
131
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
132
+ const blocks: ReactNode[] = [];
133
+ let i = 0;
134
+ let blockKey = 0;
135
+
136
+ while (i < lines.length) {
137
+ const line = lines[i] ?? '';
138
+
139
+ if (line.trim() === '') {
140
+ i++;
141
+ continue;
142
+ }
143
+
144
+ // Checked before every other block type: everything between the fences
145
+ // is verbatim, including blank lines and lines that would otherwise
146
+ // read as headings, quotes, or list items.
147
+ if (isFence(line)) {
148
+ const fenceMatch = /^\s*(`{3,})(.*)$/.exec(line);
149
+ const fence = fenceMatch?.[1] ?? '```';
150
+ const language = (fenceMatch?.[2] ?? '').trim();
151
+ const closing = new RegExp(`^\\s*${fence}\\s*$`);
152
+ i++;
153
+ const codeLines: string[] = [];
154
+ while (i < lines.length && !closing.test(lines[i] ?? '')) {
155
+ codeLines.push(lines[i] ?? '');
156
+ i++;
157
+ }
158
+ // Steps past the closing fence; a no-op at end of input, so an
159
+ // unterminated fence renders the remainder as code (as CommonMark
160
+ // does) rather than looping.
161
+ i++;
162
+ blocks.push(
163
+ <div key={blockKey++} className={styles.block}>
164
+ <pre className={styles.codeBlock}>
165
+ <code data-language={language || undefined}>{codeLines.join('\n')}</code>
166
+ </pre>
167
+ </div>,
168
+ );
169
+ continue;
170
+ }
171
+
172
+ const heading = /^(#{1,3})\s+(.*)$/.exec(line);
173
+ if (heading) {
174
+ const level = (heading[1]?.length ?? 1) as 1 | 2 | 3;
175
+ blocks.push(
176
+ <div key={blockKey++} className={styles.block} data-heading>
177
+ <Typography variant={`h${level}` as 'h1' | 'h2' | 'h3'}>
178
+ {parseInline(heading[2] ?? '')}
179
+ </Typography>
180
+ </div>,
181
+ );
182
+ i++;
183
+ continue;
184
+ }
185
+
186
+ if (isQuote(line)) {
187
+ const quoteLines: string[] = [];
188
+ while (i < lines.length && isQuote(lines[i] ?? '')) {
189
+ quoteLines.push((lines[i] ?? '').slice(2));
190
+ i++;
191
+ }
192
+ blocks.push(
193
+ <div key={blockKey++} className={styles.block}>
194
+ <blockquote className={styles.blockquote}>
195
+ <Typography variant="body" as="p">
196
+ {parseInline(quoteLines.join(' '))}
197
+ </Typography>
198
+ </blockquote>
199
+ </div>,
200
+ );
201
+ continue;
202
+ }
203
+
204
+ if (isOrderedItem(line)) {
205
+ const items: string[] = [];
206
+ let start: number | undefined;
207
+ while (i < lines.length && !endsOrderedList(lines[i] ?? '')) {
208
+ const current = lines[i] ?? '';
209
+ const match = /^(\d{1,9})[.)]\s+(.*)$/.exec(current);
210
+ if (match) {
211
+ // Honour the author's own starting number (a list resuming at 4),
212
+ // but let the browser number the rest — markdown's own numbers
213
+ // are conventionally ignored after the first.
214
+ if (start === undefined) start = Number(match[1]);
215
+ items.push(match[2] ?? '');
216
+ } else {
217
+ const lastIndex = items.length - 1;
218
+ items[lastIndex] = `${items[lastIndex] ?? ''} ${current.trim()}`;
219
+ }
220
+ i++;
221
+ }
222
+ blocks.push(
223
+ <div key={blockKey++} className={styles.block}>
224
+ <ol
225
+ className={styles.list}
226
+ start={start !== undefined && start !== 1 ? start : undefined}
227
+ >
228
+ {items.map((item, idx) => (
229
+ <Typography key={idx} variant="body" as="li">
230
+ {parseInline(item)}
231
+ </Typography>
232
+ ))}
233
+ </ol>
234
+ </div>,
235
+ );
236
+ continue;
237
+ }
238
+
239
+ if (isListItem(line)) {
240
+ const items: string[] = [];
241
+ while (i < lines.length && !endsList(lines[i] ?? '')) {
242
+ const current = lines[i] ?? '';
243
+ if (isListItem(current)) {
244
+ items.push(current.slice(2));
245
+ } else {
246
+ // Continuation of the previous item's wrapped text.
247
+ const lastIndex = items.length - 1;
248
+ items[lastIndex] = `${items[lastIndex] ?? ''} ${current.trim()}`;
249
+ }
250
+ i++;
251
+ }
252
+ blocks.push(
253
+ <div key={blockKey++} className={styles.block}>
254
+ <ul className={styles.list}>
255
+ {items.map((item, idx) => (
256
+ <Typography key={idx} variant="body" as="li">
257
+ {parseInline(item)}
258
+ </Typography>
259
+ ))}
260
+ </ul>
261
+ </div>,
262
+ );
263
+ continue;
264
+ }
265
+
266
+ const paraLines: string[] = [];
267
+ while (i < lines.length && !endsParagraph(lines[i] ?? '')) {
268
+ paraLines.push((lines[i] ?? '').trim());
269
+ i++;
270
+ }
271
+ blocks.push(
272
+ <div key={blockKey++} className={styles.block}>
273
+ <Typography variant="body" as="p">
274
+ {preserveLineBreaks
275
+ ? paraLines.map((paraLine, lineIdx) => (
276
+ <Fragment key={lineIdx}>
277
+ {lineIdx > 0 && <br />}
278
+ {parseInline(paraLine)}
279
+ </Fragment>
280
+ ))
281
+ : parseInline(paraLines.join(' '))}
282
+ </Typography>
283
+ </div>,
284
+ );
285
+ }
286
+
287
+ return blocks;
288
+ }
289
+
290
+ /** Renders a constrained markdown subset (see MarkdownProps) as styled React
291
+ * elements built from Typography — no HTML string injection. Primarily
292
+ * meant for long-form, first-party content pages (privacy policy, terms of
293
+ * service) sourced from a single markdown file — not a general-purpose
294
+ * CommonMark renderer for arbitrary markdown. Also fine for plain,
295
+ * user-typed multi-line text (a card description, a comment body) edited
296
+ * in an ordinary `<textarea>` and passed through unchanged, as long as
297
+ * `preserveLineBreaks` is set — see that prop's own doc comment for why it
298
+ * isn't the default. */
299
+ export function Markdown({ content, preserveLineBreaks = false, className }: MarkdownProps) {
300
+ return (
301
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
302
+ {parseBlocks(content, preserveLineBreaks)}
303
+ </div>
304
+ );
305
+ }
@@ -0,0 +1,144 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render } from '@testing-library/react';
4
+ import { Markdown } from '../Markdown';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Markdown', () => {
9
+ it('joins consecutive lines into one paragraph by default (soft-wrap)', () => {
10
+ const { container } = render(<Markdown content={'Line one.\nLine two.'} />);
11
+ const p = container.querySelector('p');
12
+ expect(p?.textContent).toBe('Line one. Line two.');
13
+ expect(p?.querySelector('br')).toBeNull();
14
+ });
15
+
16
+ it('preserveLineBreaks keeps each line on its own line via <br>', () => {
17
+ const { container } = render(
18
+ <Markdown content={'Line one.\nLine two.\nLine three.'} preserveLineBreaks />,
19
+ );
20
+ const p = container.querySelector('p');
21
+ expect(p?.querySelectorAll('br')).toHaveLength(2);
22
+ expect(p?.textContent).toBe('Line one.Line two.Line three.');
23
+ });
24
+
25
+ it('preserveLineBreaks still starts a new paragraph on a blank line', () => {
26
+ const { container } = render(
27
+ <Markdown content={'First paragraph.\n\nSecond paragraph.'} preserveLineBreaks />,
28
+ );
29
+ const paragraphs = container.querySelectorAll('p');
30
+ expect(paragraphs).toHaveLength(2);
31
+ expect(paragraphs[0]?.textContent).toBe('First paragraph.');
32
+ expect(paragraphs[1]?.textContent).toBe('Second paragraph.');
33
+ });
34
+
35
+ it('preserveLineBreaks does not affect headings, quotes, or lists', () => {
36
+ const { container } = render(
37
+ <Markdown content={'# Heading\n\n- item one\n- item two'} preserveLineBreaks />,
38
+ );
39
+ expect(container.querySelector('h1')?.textContent).toBe('Heading');
40
+ expect(container.querySelectorAll('li')).toHaveLength(2);
41
+ });
42
+ });
43
+
44
+ describe('Markdown — ordered lists and fenced code', () => {
45
+ it('renders an ordered list, numbering from the browser', () => {
46
+ const { container } = render(<Markdown content={'1. first\n2. second\n3. third'} />);
47
+
48
+ const list = container.querySelector('ol');
49
+ expect(list).not.toBeNull();
50
+ expect([...(list?.querySelectorAll('li') ?? [])].map((li) => li.textContent)).toEqual([
51
+ 'first',
52
+ 'second',
53
+ 'third',
54
+ ]);
55
+ expect(list?.getAttribute('start')).toBeNull();
56
+ });
57
+
58
+ it('honours a list that starts at a number other than 1', () => {
59
+ const { container } = render(<Markdown content={'4. fourth\n5. fifth'} />);
60
+ expect(container.querySelector('ol')?.getAttribute('start')).toBe('4');
61
+ });
62
+
63
+ it('accepts the 1) marker style and applies inline formatting inside items', () => {
64
+ const { container } = render(<Markdown content={'1) do **this**'} />);
65
+
66
+ expect(container.querySelector('ol')).not.toBeNull();
67
+ expect(container.querySelector('ol strong')?.textContent).toBe('this');
68
+ });
69
+
70
+ it('keeps ordered and unordered lists as separate blocks', () => {
71
+ const { container } = render(<Markdown content={'- bullet\n1. number'} />);
72
+
73
+ expect(container.querySelectorAll('ul')).toHaveLength(1);
74
+ expect(container.querySelectorAll('ol')).toHaveLength(1);
75
+ });
76
+
77
+ it('renders a fenced code block verbatim, without parsing its contents', () => {
78
+ const content = ['```ts', 'const a = 1; // **not bold**', '', '# not a heading', '```'].join(
79
+ '\n',
80
+ );
81
+ const { container } = render(<Markdown content={content} />);
82
+
83
+ const code = container.querySelector('pre code');
84
+ expect(code?.textContent).toBe('const a = 1; // **not bold**\n\n# not a heading');
85
+ expect(container.querySelector('strong')).toBeNull();
86
+ expect(container.querySelector('h1')).toBeNull();
87
+ });
88
+
89
+ it('exposes the fence language for styling hooks', () => {
90
+ const { container } = render(<Markdown content={'```python\nx = 1\n```'} />);
91
+ expect(container.querySelector('pre code')?.getAttribute('data-language')).toBe('python');
92
+ });
93
+
94
+ it('terminates on an unclosed fence instead of looping', () => {
95
+ const { container } = render(<Markdown content={'text\n\n```\nnever closed'} />);
96
+ expect(container.querySelector('pre code')?.textContent).toBe('never closed');
97
+ });
98
+
99
+ it('separates a code block from the paragraph before it', () => {
100
+ const { container } = render(<Markdown content={'Try this:\n```\nrun me\n```'} />);
101
+
102
+ expect(container.querySelector('p')?.textContent).toBe('Try this:');
103
+ expect(container.querySelector('pre code')?.textContent).toBe('run me');
104
+ });
105
+ });
106
+
107
+ describe('Markdown — link hrefs', () => {
108
+ it('renders an http(s) link as a real anchor', () => {
109
+ const { container } = render(<Markdown content={'[docs](https://example.com/docs)'} />);
110
+ const link = container.querySelector('a');
111
+ expect(link?.getAttribute('href')).toBe('https://example.com/docs');
112
+ expect(link?.textContent).toBe('docs');
113
+ });
114
+
115
+ it('renders a relative link as a real anchor', () => {
116
+ const { container } = render(<Markdown content={'[here](/warden/models)'} />);
117
+ expect(container.querySelector('a')?.getAttribute('href')).toBe('/warden/models');
118
+ });
119
+
120
+ it('renders a mailto: link as a real anchor', () => {
121
+ const { container } = render(<Markdown content={'[email](mailto:a@example.com)'} />);
122
+ expect(container.querySelector('a')?.getAttribute('href')).toBe('mailto:a@example.com');
123
+ });
124
+
125
+ it('neutralizes a javascript: URL instead of making it clickable', () => {
126
+ const { container } = render(
127
+ <Markdown content={'[click me](javascript:alert(document.cookie))'} />,
128
+ );
129
+ expect(container.querySelector('a')).toBeNull();
130
+ expect(container.textContent).toContain('click me');
131
+ });
132
+
133
+ it('neutralizes a javascript: URL disguised with an embedded tab', () => {
134
+ const { container } = render(<Markdown content={'[x](jav\tascript:alert(1))'} />);
135
+ expect(container.querySelector('a')).toBeNull();
136
+ });
137
+
138
+ it('neutralizes a data: URL', () => {
139
+ const { container } = render(
140
+ <Markdown content={'[x](data:text/html,<script>alert(1)</script>)'} />,
141
+ );
142
+ expect(container.querySelector('a')).toBeNull();
143
+ });
144
+ });
@@ -33,6 +33,7 @@
33
33
  font: inherit;
34
34
  font-size: var(--sv-font-size-sm);
35
35
  text-align: left;
36
+ text-decoration: none;
36
37
  cursor: pointer;
37
38
  }
38
39
 
@@ -10,7 +10,16 @@ import styles from './Menu.module.css';
10
10
  export interface MenuItem {
11
11
  type?: 'item';
12
12
  label: string;
13
- onSelect: () => void;
13
+ /** Optional when `href` is provided (a pure navigation entry needs no
14
+ * extra callback) — required otherwise. Called before the surface closes
15
+ * is the caller's job to avoid; `Menu`'s own wiring always closes first,
16
+ * then calls this. */
17
+ onSelect?: () => void;
18
+ /** Renders the entry as a link (`<a href>`, `role="menuitem"`) instead of
19
+ * a `<button>` — same href-vs-onClick convention as `MobileAppsDrawer`'s
20
+ * items. Framework-agnostic (a plain anchor, not `next/link`), matching
21
+ * every other `packages/ui` href slot. */
22
+ href?: string;
14
23
  icon?: IconName;
15
24
  /** Styles the item as a destructive action (e.g. "Delete list"). */
16
25
  destructive?: boolean;
@@ -50,6 +59,15 @@ export interface MenuProps {
50
59
  /** Forwarded to `Popover` on desktop. Has no effect on mobile, where the
51
60
  * menu is always a full-width `Drawer`. */
52
61
  align?: 'left' | 'right';
62
+ /** Forwarded to `Popover` on desktop (same default: 288). Has no effect
63
+ * on mobile. Narrow this when the trigger lives inside a column
64
+ * narrower than the default width — e.g. a per-row "⋯" menu in a
65
+ * sidebar list — so the panel fits within that column's own bounds
66
+ * instead of overflowing past its edge, where a scrollable ancestor's
67
+ * `overflow-y: auto` (which the CSS Overflow spec computes the paired
68
+ * `overflow-x` to `auto` as well, not `visible`, once either axis is
69
+ * set) would clip it. */
70
+ width?: number | 'trigger';
53
71
  }
54
72
 
55
73
  /**
@@ -100,26 +118,43 @@ export function MenuEntries({
100
118
  );
101
119
  }
102
120
  const isCheckable = entry.checked !== undefined;
121
+ const className = [styles.item, entry.destructive ? styles.itemDestructive : '']
122
+ .filter(Boolean)
123
+ .join(' ');
124
+ const content = (
125
+ <>
126
+ {isCheckable && (
127
+ <span className={styles.check} aria-hidden>
128
+ {entry.checked && <Icon name="check" size="sm" aria-hidden />}
129
+ </span>
130
+ )}
131
+ {entry.icon && <Icon name={entry.icon} size="sm" aria-hidden />}
132
+ {entry.label}
133
+ </>
134
+ );
103
135
  return (
104
136
  <li key={index} role="none">
105
- <button
106
- role={isCheckable ? 'menuitemradio' : 'menuitem'}
107
- aria-checked={isCheckable ? entry.checked : undefined}
108
- type="button"
109
- className={[styles.item, entry.destructive ? styles.itemDestructive : '']
110
- .filter(Boolean)
111
- .join(' ')}
112
- disabled={entry.disabled}
113
- onClick={() => onSelect(entry)}
114
- >
115
- {isCheckable && (
116
- <span className={styles.check} aria-hidden>
117
- {entry.checked && <Icon name="check" size="sm" aria-hidden />}
118
- </span>
119
- )}
120
- {entry.icon && <Icon name={entry.icon} size="sm" aria-hidden />}
121
- {entry.label}
122
- </button>
137
+ {entry.href ? (
138
+ <a
139
+ role="menuitem"
140
+ href={entry.href}
141
+ className={className}
142
+ onClick={() => onSelect(entry)}
143
+ >
144
+ {content}
145
+ </a>
146
+ ) : (
147
+ <button
148
+ role={isCheckable ? 'menuitemradio' : 'menuitem'}
149
+ aria-checked={isCheckable ? entry.checked : undefined}
150
+ type="button"
151
+ className={className}
152
+ disabled={entry.disabled}
153
+ onClick={() => onSelect(entry)}
154
+ >
155
+ {content}
156
+ </button>
157
+ )}
123
158
  </li>
124
159
  );
125
160
  })}
@@ -127,7 +162,15 @@ export function MenuEntries({
127
162
  );
128
163
  }
129
164
 
130
- export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, align }: MenuProps) {
165
+ export function Menu({
166
+ trigger,
167
+ open,
168
+ onClose,
169
+ items,
170
+ 'aria-label': ariaLabel,
171
+ align,
172
+ width,
173
+ }: MenuProps) {
131
174
  const isMobile = useIsMobile();
132
175
 
133
176
  const list = (
@@ -135,7 +178,7 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
135
178
  items={items}
136
179
  onSelect={(entry) => {
137
180
  onClose();
138
- entry.onSelect();
181
+ entry.onSelect?.();
139
182
  }}
140
183
  />
141
184
  );
@@ -152,7 +195,14 @@ export function Menu({ trigger, open, onClose, items, 'aria-label': ariaLabel, a
152
195
  }
153
196
 
154
197
  return (
155
- <Popover trigger={trigger} open={open} onClose={onClose} aria-label={ariaLabel} align={align}>
198
+ <Popover
199
+ trigger={trigger}
200
+ open={open}
201
+ onClose={onClose}
202
+ aria-label={ariaLabel}
203
+ align={align}
204
+ width={width}
205
+ >
156
206
  {list}
157
207
  </Popover>
158
208
  );
@@ -194,4 +194,34 @@ describe('Menu', () => {
194
194
  expect(onSelect).toHaveBeenCalledOnce();
195
195
  expect(onClose).toHaveBeenCalledOnce();
196
196
  });
197
+
198
+ it('renders an item with href as a link, not a button', () => {
199
+ render(
200
+ <Menu
201
+ trigger={<button type="button">Open menu</button>}
202
+ open
203
+ onClose={() => {}}
204
+ items={[{ label: 'Account', href: '/account' }]}
205
+ aria-label="List actions"
206
+ />,
207
+ );
208
+ const link = screen.getByRole('menuitem', { name: 'Account' });
209
+ expect(link.tagName).toBe('A');
210
+ expect(link.getAttribute('href')).toBe('/account');
211
+ });
212
+
213
+ it('closes on click of an href item even with no onSelect', () => {
214
+ const onClose = vi.fn();
215
+ render(
216
+ <Menu
217
+ trigger={<button type="button">Open menu</button>}
218
+ open
219
+ onClose={onClose}
220
+ items={[{ label: 'Account', href: '/account' }]}
221
+ aria-label="List actions"
222
+ />,
223
+ );
224
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Account' }));
225
+ expect(onClose).toHaveBeenCalledOnce();
226
+ });
197
227
  });
@@ -32,7 +32,7 @@ export function Menubar({ menus, 'aria-label': ariaLabel = 'Menu bar' }: Menubar
32
32
  items={menu.items}
33
33
  onSelect={(entry) => {
34
34
  close();
35
- entry.onSelect();
35
+ entry.onSelect?.();
36
36
  }}
37
37
  />
38
38
  ),
@@ -32,7 +32,7 @@
32
32
 
33
33
  .assistant .bubble {
34
34
  background: var(--sv-color-surface-raised);
35
- border: 1px solid var(--sv-color-border);
35
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
36
36
  color: var(--sv-color-text-primary);
37
37
  border-bottom-left-radius: var(--sv-radius-sm);
38
38
  }
@@ -25,13 +25,24 @@
25
25
  border-radius: var(--sv-radius-full);
26
26
  }
27
27
 
28
+ /* Bottom-anchors a conversation that doesn't fill the container: this
29
+ leading item claims all leftover main-axis space, pushing the real
30
+ messages down to the bottom edge. Once messages overflow, it has no
31
+ free space left to grow into and collapses to 0 — normal top-to-bottom
32
+ scrolling is unaffected either way, since `justify-content` never
33
+ changes from its default `flex-start`. */
34
+ .spacer {
35
+ flex: 1 0 0%;
36
+ min-height: 0;
37
+ }
38
+
28
39
  .jumpButton {
29
40
  position: absolute;
30
41
  bottom: var(--sv-space-3);
31
42
  left: 50%;
32
43
  transform: translateX(-50%);
33
44
  padding: var(--sv-space-1) var(--sv-space-3);
34
- border: 1px solid var(--sv-color-border);
45
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
35
46
  border-radius: var(--sv-radius-full);
36
47
  background: var(--sv-color-surface-raised);
37
48
  box-shadow: var(--sv-shadow-popover);