@sovereignfs/ui 0.54.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 (277) 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/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -1,20 +1,65 @@
1
- import type { ReactNode } from 'react';
1
+ import { Fragment, type ReactNode } from 'react';
2
2
  import { Typography } from '../Typography/Typography';
3
3
  import styles from './Markdown.module.css';
4
4
 
5
5
  export interface MarkdownProps {
6
- /** Raw markdown source. Supports the subset used by Sovereign's own
7
- * long-form content pages: headings (#/##/###), paragraphs, **bold**,
8
- * *italic*, `code`, [links](url), unordered lists (-), and blockquotes
9
- * (>), including soft-wrapped continuation lines within a paragraph or
10
- * list item. Not a general-purpose CommonMark parser — no tables,
11
- * images, ordered lists, or nested blocks. */
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
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;
13
27
  className?: string;
14
28
  }
15
29
 
16
30
  let inlineKey = 0;
17
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
+
18
63
  /** Parses **bold**, *italic*, `code`, and [text](url) within a line of text. */
19
64
  function parseInline(text: string): ReactNode[] {
20
65
  const nodes: ReactNode[] = [];
@@ -32,10 +77,15 @@ function parseInline(text: string): ReactNode[] {
32
77
  const [full, linkText, linkHref, boldText, italicText, codeText] = match;
33
78
  const key = `md-inline-${inlineKey++}`;
34
79
  if (linkHref !== undefined) {
80
+ const safeHref = sanitizeHref(linkHref);
35
81
  nodes.push(
36
- <a key={key} href={linkHref}>
37
- {linkText}
38
- </a>,
82
+ safeHref !== null ? (
83
+ <a key={key} href={safeHref}>
84
+ {linkText}
85
+ </a>
86
+ ) : (
87
+ linkText
88
+ ),
39
89
  );
40
90
  } else if (boldText !== undefined) {
41
91
  nodes.push(<strong key={key}>{boldText}</strong>);
@@ -53,20 +103,31 @@ function parseInline(text: string): ReactNode[] {
53
103
  const isQuote = (l: string) => l.startsWith('> ') || l === '>';
54
104
  const isListItem = (l: string) => l.startsWith('- ');
55
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);
56
111
  /** A line that ends the current *paragraph* run — a blank line, or the
57
- * start of a heading, quote, or list block. Anything else (including an
58
- * indented continuation line) is folded into the paragraph, matching how
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
59
114
  * the source markdown soft-wraps prose across lines. */
60
- const endsParagraph = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l) || isListItem(l);
61
- /** A line that ends the current *list* run — a blank line, or the start of
62
- * a heading or quote. Deliberately does NOT include `isListItem`: a list
63
- * item's own opening line legitimately satisfies `isListItem`, so reusing
64
- * `endsParagraph` here (which does) would treat the first line of every
65
- * list as ending the list before it started — the list-collection loop
66
- * would then never advance, looping forever. */
67
- const endsList = (l: string) => l.trim() === '' || isHeading(l) || isQuote(l);
68
-
69
- function parseBlocks(content: string): ReactNode[] {
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[] {
70
131
  const lines = content.replace(/\r\n/g, '\n').split('\n');
71
132
  const blocks: ReactNode[] = [];
72
133
  let i = 0;
@@ -80,6 +141,34 @@ function parseBlocks(content: string): ReactNode[] {
80
141
  continue;
81
142
  }
82
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
+
83
172
  const heading = /^(#{1,3})\s+(.*)$/.exec(line);
84
173
  if (heading) {
85
174
  const level = (heading[1]?.length ?? 1) as 1 | 2 | 3;
@@ -112,6 +201,41 @@ function parseBlocks(content: string): ReactNode[] {
112
201
  continue;
113
202
  }
114
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
+
115
239
  if (isListItem(line)) {
116
240
  const items: string[] = [];
117
241
  while (i < lines.length && !endsList(lines[i] ?? '')) {
@@ -147,7 +271,14 @@ function parseBlocks(content: string): ReactNode[] {
147
271
  blocks.push(
148
272
  <div key={blockKey++} className={styles.block}>
149
273
  <Typography variant="body" as="p">
150
- {parseInline(paraLines.join(' '))}
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(' '))}
151
282
  </Typography>
152
283
  </div>,
153
284
  );
@@ -157,12 +288,18 @@ function parseBlocks(content: string): ReactNode[] {
157
288
  }
158
289
 
159
290
  /** Renders a constrained markdown subset (see MarkdownProps) as styled React
160
- * elements built from Typography — no HTML string injection. Meant for
161
- * long-form, first-party content pages (privacy policy, terms of service)
162
- * sourced from a single markdown file, not for rendering arbitrary or
163
- * user-supplied markdown. */
164
- export function Markdown({ content, className }: MarkdownProps) {
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) {
165
300
  return (
166
- <div className={[styles.root, className].filter(Boolean).join(' ')}>{parseBlocks(content)}</div>
301
+ <div className={[styles.root, className].filter(Boolean).join(' ')}>
302
+ {parseBlocks(content, preserveLineBreaks)}
303
+ </div>
167
304
  );
168
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);
@@ -19,6 +19,17 @@ const NEAR_BOTTOM_THRESHOLD_PX = 80;
19
19
  * content does not yank them back down — a "New messages" button appears
20
20
  * instead, matching the pattern every chat product (Slack, Discord,
21
21
  * ChatGPT) already uses.
22
+ *
23
+ * A short conversation is bottom-anchored, not left floating at the top of
24
+ * the container with empty space below it — a leading flex-grow spacer
25
+ * absorbs the leftover space and collapses to nothing once messages
26
+ * overflow. Deliberately not `justify-content: flex-end` on the scroll
27
+ * container itself: Chromium miscomputes `scrollHeight` as equal to
28
+ * `clientHeight` for an overflowing flex-end-packed column, which makes the
29
+ * container look fully scrolled to the bottom and permanently hides the
30
+ * start of a long conversation (confirmed live before choosing this
31
+ * approach). The spacer keeps `justify-content` at its default, so overflow
32
+ * scrolling is unaffected.
22
33
  */
23
34
  export function MessageScroller({ children, className }: MessageScrollerProps) {
24
35
  const containerRef = useRef<HTMLDivElement>(null);
@@ -60,6 +71,7 @@ export function MessageScroller({ children, className }: MessageScrollerProps) {
60
71
  return (
61
72
  <div className={[styles.root, className].filter(Boolean).join(' ')}>
62
73
  <div className={styles.scrollContainer} ref={containerRef} onScroll={handleScroll}>
74
+ <div className={styles.spacer} aria-hidden="true" />
63
75
  {children}
64
76
  </div>
65
77
  {hasNewMessages && (