@dxos/react-ui-feed 0.11.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 (230) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/chunk-src.mjs +2228 -0
  3. package/dist/lib/chunk-src.mjs.map +1 -0
  4. package/dist/lib/index.mjs +2 -0
  5. package/dist/lib/testing.mjs +1923 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
  8. package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
  9. package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
  10. package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
  11. package/dist/types/src/components/Block/block.stories.d.ts +21 -0
  12. package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Block/construction.d.ts +36 -0
  14. package/dist/types/src/components/Block/construction.d.ts.map +1 -0
  15. package/dist/types/src/components/Block/extensions.d.ts +23 -0
  16. package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
  17. package/dist/types/src/components/Block/highlight.d.ts +14 -0
  18. package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
  19. package/dist/types/src/components/Block/index.d.ts +6 -0
  20. package/dist/types/src/components/Block/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Block/selection-group.d.ts +21 -0
  22. package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
  23. package/dist/types/src/components/Block/widget-state.d.ts +33 -0
  24. package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
  25. package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
  26. package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
  27. package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
  28. package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
  29. package/dist/types/src/components/MessageList/index.d.ts +3 -0
  30. package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
  31. package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
  32. package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
  33. package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
  34. package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
  35. package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
  36. package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Minimap/index.d.ts +2 -0
  38. package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Outline/Outline.d.ts +46 -0
  40. package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
  41. package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
  42. package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/Outline/index.d.ts +2 -0
  44. package/dist/types/src/components/Outline/index.d.ts.map +1 -0
  45. package/dist/types/src/components/index.d.ts +5 -0
  46. package/dist/types/src/components/index.d.ts.map +1 -0
  47. package/dist/types/src/debug/Debug.d.ts +17 -0
  48. package/dist/types/src/debug/Debug.d.ts.map +1 -0
  49. package/dist/types/src/debug/Stats.d.ts +30 -0
  50. package/dist/types/src/debug/Stats.d.ts.map +1 -0
  51. package/dist/types/src/debug/debug-model.d.ts +32 -0
  52. package/dist/types/src/debug/debug-model.d.ts.map +1 -0
  53. package/dist/types/src/debug/debug-model.test.d.ts +2 -0
  54. package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
  55. package/dist/types/src/debug/frame-meter.d.ts +48 -0
  56. package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
  57. package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
  58. package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
  59. package/dist/types/src/debug/index.d.ts +6 -0
  60. package/dist/types/src/debug/index.d.ts.map +1 -0
  61. package/dist/types/src/debug/use-debug.d.ts +19 -0
  62. package/dist/types/src/debug/use-debug.d.ts.map +1 -0
  63. package/dist/types/src/hooks/follow.d.ts +89 -0
  64. package/dist/types/src/hooks/follow.d.ts.map +1 -0
  65. package/dist/types/src/hooks/follow.stories.d.ts +25 -0
  66. package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
  67. package/dist/types/src/hooks/follow.test.d.ts +2 -0
  68. package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
  69. package/dist/types/src/hooks/index.d.ts +6 -0
  70. package/dist/types/src/hooks/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useDecorations.d.ts +31 -0
  72. package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
  74. package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useFollow.d.ts +46 -0
  76. package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
  78. package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/model/feed-model.d.ts +67 -0
  82. package/dist/types/src/model/feed-model.d.ts.map +1 -0
  83. package/dist/types/src/model/feed-model.test.d.ts +2 -0
  84. package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
  85. package/dist/types/src/model/feed.d.ts +66 -0
  86. package/dist/types/src/model/feed.d.ts.map +1 -0
  87. package/dist/types/src/model/feed.test.d.ts +2 -0
  88. package/dist/types/src/model/feed.test.d.ts.map +1 -0
  89. package/dist/types/src/model/index.d.ts +5 -0
  90. package/dist/types/src/model/index.d.ts.map +1 -0
  91. package/dist/types/src/model/list-model.d.ts +61 -0
  92. package/dist/types/src/model/list-model.d.ts.map +1 -0
  93. package/dist/types/src/model/list-model.test.d.ts +2 -0
  94. package/dist/types/src/model/list-model.test.d.ts.map +1 -0
  95. package/dist/types/src/model/useListModel.d.ts +22 -0
  96. package/dist/types/src/model/useListModel.d.ts.map +1 -0
  97. package/dist/types/src/stories/assistant.stories.d.ts +38 -0
  98. package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
  99. package/dist/types/src/stories/baseline.stories.d.ts +32 -0
  100. package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
  101. package/dist/types/src/stories/bridge.stories.d.ts +72 -0
  102. package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
  103. package/dist/types/src/stories/construction.stories.d.ts +17 -0
  104. package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
  105. package/dist/types/src/stories/fill.stories.d.ts +51 -0
  106. package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
  107. package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
  108. package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
  109. package/dist/types/src/stories/mount.stories.d.ts +19 -0
  110. package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
  111. package/dist/types/src/stories/navigation.stories.d.ts +33 -0
  112. package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
  113. package/dist/types/src/stories/streaming.stories.d.ts +17 -0
  114. package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
  115. package/dist/types/src/stories/tail.stories.d.ts +47 -0
  116. package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
  117. package/dist/types/src/stories/widget.stories.d.ts +16 -0
  118. package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
  119. package/dist/types/src/testing/FeedStory.d.ts +30 -0
  120. package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
  121. package/dist/types/src/testing/MessageWindow.d.ts +48 -0
  122. package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
  123. package/dist/types/src/testing/controls.d.ts +46 -0
  124. package/dist/types/src/testing/controls.d.ts.map +1 -0
  125. package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
  126. package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
  127. package/dist/types/src/testing/debug/index.d.ts +4 -0
  128. package/dist/types/src/testing/debug/index.d.ts.map +1 -0
  129. package/dist/types/src/testing/debug/sweep.d.ts +18 -0
  130. package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
  131. package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
  132. package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
  133. package/dist/types/src/testing/generator.d.ts +16 -0
  134. package/dist/types/src/testing/generator.d.ts.map +1 -0
  135. package/dist/types/src/testing/index.d.ts +10 -0
  136. package/dist/types/src/testing/index.d.ts.map +1 -0
  137. package/dist/types/src/testing/scenarios.d.ts +58 -0
  138. package/dist/types/src/testing/scenarios.d.ts.map +1 -0
  139. package/dist/types/src/testing/stream.d.ts +22 -0
  140. package/dist/types/src/testing/stream.d.ts.map +1 -0
  141. package/dist/types/src/testing/turn.d.ts +21 -0
  142. package/dist/types/src/testing/turn.d.ts.map +1 -0
  143. package/dist/types/src/testing/widgets.d.ts +3 -0
  144. package/dist/types/src/testing/widgets.d.ts.map +1 -0
  145. package/dist/types/src/virtualizer/Window.d.ts +137 -0
  146. package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
  147. package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
  148. package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
  149. package/dist/types/src/virtualizer/index.d.ts +3 -0
  150. package/dist/types/src/virtualizer/index.d.ts.map +1 -0
  151. package/dist/types/src/virtualizer/placement.d.ts +188 -0
  152. package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
  153. package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
  154. package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
  155. package/dist/types/tsconfig.tsbuildinfo +1 -0
  156. package/package.json +72 -0
  157. package/src/components/Block/HtmlBlock.tsx +26 -0
  158. package/src/components/Block/MarkdownBlock.tsx +157 -0
  159. package/src/components/Block/block.stories.tsx +140 -0
  160. package/src/components/Block/construction.ts +77 -0
  161. package/src/components/Block/extensions.ts +95 -0
  162. package/src/components/Block/highlight.ts +53 -0
  163. package/src/components/Block/index.ts +10 -0
  164. package/src/components/Block/selection-group.ts +53 -0
  165. package/src/components/Block/widget-state.tsx +75 -0
  166. package/src/components/MessageList/MessageList.stories.tsx +111 -0
  167. package/src/components/MessageList/MessageList.tsx +647 -0
  168. package/src/components/MessageList/index.ts +6 -0
  169. package/src/components/MessageList/position-log.ts +167 -0
  170. package/src/components/Minimap/Minimap.stories.tsx +129 -0
  171. package/src/components/Minimap/Minimap.tsx +84 -0
  172. package/src/components/Minimap/index.ts +5 -0
  173. package/src/components/Outline/Outline.stories.tsx +196 -0
  174. package/src/components/Outline/Outline.tsx +383 -0
  175. package/src/components/Outline/index.ts +5 -0
  176. package/src/components/index.ts +8 -0
  177. package/src/debug/Debug.tsx +90 -0
  178. package/src/debug/Stats.tsx +57 -0
  179. package/src/debug/debug-model.test.ts +36 -0
  180. package/src/debug/debug-model.ts +66 -0
  181. package/src/debug/frame-meter.test.ts +49 -0
  182. package/src/debug/frame-meter.ts +188 -0
  183. package/src/debug/index.ts +9 -0
  184. package/src/debug/use-debug.tsx +41 -0
  185. package/src/hooks/follow.stories.tsx +200 -0
  186. package/src/hooks/follow.test.ts +105 -0
  187. package/src/hooks/follow.ts +241 -0
  188. package/src/hooks/index.ts +9 -0
  189. package/src/hooks/useDecorations.tsx +54 -0
  190. package/src/hooks/useFeedNavigation.ts +90 -0
  191. package/src/hooks/useFollow.ts +258 -0
  192. package/src/hooks/useItemSelection.tsx +53 -0
  193. package/src/index.ts +7 -0
  194. package/src/model/feed-model.test.ts +84 -0
  195. package/src/model/feed-model.ts +214 -0
  196. package/src/model/feed.test.ts +65 -0
  197. package/src/model/feed.ts +157 -0
  198. package/src/model/index.ts +8 -0
  199. package/src/model/list-model.test.ts +71 -0
  200. package/src/model/list-model.ts +152 -0
  201. package/src/model/useListModel.ts +53 -0
  202. package/src/stories/assistant.stories.tsx +425 -0
  203. package/src/stories/baseline.stories.tsx +78 -0
  204. package/src/stories/bridge.stories.tsx +376 -0
  205. package/src/stories/construction.stories.tsx +147 -0
  206. package/src/stories/fill.stories.tsx +271 -0
  207. package/src/stories/horizontal.stories.tsx +129 -0
  208. package/src/stories/mount.stories.tsx +233 -0
  209. package/src/stories/navigation.stories.tsx +145 -0
  210. package/src/stories/streaming.stories.tsx +132 -0
  211. package/src/stories/tail.stories.tsx +149 -0
  212. package/src/stories/widget.stories.tsx +107 -0
  213. package/src/testing/FeedStory.tsx +452 -0
  214. package/src/testing/MessageWindow.tsx +139 -0
  215. package/src/testing/controls.tsx +96 -0
  216. package/src/testing/debug/FeedStats.tsx +182 -0
  217. package/src/testing/debug/index.ts +16 -0
  218. package/src/testing/debug/sweep.ts +85 -0
  219. package/src/testing/debug/use-feed-debug.ts +91 -0
  220. package/src/testing/generator.ts +90 -0
  221. package/src/testing/index.ts +13 -0
  222. package/src/testing/scenarios.tsx +552 -0
  223. package/src/testing/stream.ts +70 -0
  224. package/src/testing/turn.ts +91 -0
  225. package/src/testing/widgets.tsx +183 -0
  226. package/src/virtualizer/Window.stories.tsx +683 -0
  227. package/src/virtualizer/Window.tsx +435 -0
  228. package/src/virtualizer/index.ts +6 -0
  229. package/src/virtualizer/placement.test.ts +312 -0
  230. package/src/virtualizer/placement.ts +403 -0
@@ -0,0 +1,78 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+
7
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
8
+
9
+ import { FeedStory, type FeedStoryProps } from '../testing';
10
+
11
+ /**
12
+ * The bisection ladder for scroll stability.
13
+ *
14
+ * Each rung adds one thing to the one before it, so a defect can be attributed rather than guessed
15
+ * at: if a rung is still and the next one moves, the difference between them is the cause. The
16
+ * statusbar's `jumps` (rows moving on screen against the scroll, sampled per frame) and `shifts`
17
+ * (rows whose offset changed after layout) are the readings; both should be zero on a still feed.
18
+ *
19
+ * 1. **Plain** — no editor at all, every row a fixed height declared in advance. Nothing here can be
20
+ * measured wrong, so any movement belongs to the list itself.
21
+ * 2. **Uniform** — one editor per row, every row the identical document, so a single estimate is
22
+ * exactly right for all of them. What this adds over Plain is CodeMirror construction.
23
+ * 3. **Varied** — one editor per row, contents of different lengths, so the estimate is wrong for
24
+ * most rows and the list corrects as it goes. What this adds is measurement error.
25
+ *
26
+ * Above the ladder sit the call-site stories (`MessageList/Assistant` and friends), which add
27
+ * per-message renderers, block widgets and streaming.
28
+ */
29
+ const meta: Meta<FeedStoryProps> = {
30
+ title: 'ui/react-ui-feed/stories/baseline',
31
+ render: FeedStory,
32
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
33
+ parameters: { layout: 'fullscreen' },
34
+ argTypes: {
35
+ // Off by default and offered as a control rather than turned on: reserving space past the end
36
+ // is what lets the last messages be read at the top of the screen, and it is also what stops a
37
+ // short feed settling (`baseline/fill`). Worth toggling by hand, not worth defaulting to.
38
+ debug: { control: 'boolean' },
39
+ scrollPastEnd: { control: 'boolean' },
40
+ },
41
+ // Listed in `args`, not only in `argTypes`: the meta names no `component`, so storybook has no
42
+ // props to infer controls from and shows exactly the args that are set.
43
+ args: {
44
+ count: 500,
45
+ debug: false,
46
+ scrollPastEnd: true,
47
+ },
48
+ };
49
+
50
+ export default meta;
51
+
52
+ type Story = StoryObj<FeedStoryProps>;
53
+
54
+ /** The feed at rest — the varied rung, which is the one that matters. No play. */
55
+ export const Default: Story = {
56
+ args: { scenario: 'thread' },
57
+ };
58
+
59
+ /** Rung 1: fixed heights, no editor. If this moves, the list is at fault. */
60
+ export const Plain: Story = {
61
+ args: {
62
+ scenario: 'plain',
63
+ },
64
+ };
65
+
66
+ /** Rung 2: an editor per row, all identical — adds CodeMirror construction. */
67
+ export const Uniform: Story = {
68
+ args: {
69
+ scenario: 'uniform',
70
+ },
71
+ };
72
+
73
+ /** Rung 3: an editor per row, varied lengths — adds measurement error. */
74
+ export const Varied: Story = {
75
+ args: {
76
+ scenario: 'thread',
77
+ },
78
+ };
@@ -0,0 +1,376 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
7
+ import { expect } from 'storybook/test';
8
+
9
+ import { IconButton, Toolbar } from '@dxos/react-ui';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+ import { Message } from '@dxos/types';
12
+
13
+ import { type FeedScenario, MessageWindow, createScenario } from '../testing';
14
+ import { type WindowController } from '../virtualizer';
15
+
16
+ /**
17
+ * Real messages, placed by the new module.
18
+ *
19
+ * `placement/*` proves the shape against boxes that cannot lie about their size, and `baseline/*`
20
+ * proves the current engine against real editors. This is where they meet: the same fixtures, the
21
+ * same renderers and the same chrome as a feed, placed by `Window`. The invariants are deliberately
22
+ * the ones `baseline/*` already asserts — a replacement is ready when it satisfies the tests the
23
+ * thing it replaces satisfies, not when it satisfies tests written for it.
24
+ *
25
+ * Not wired into `MessageList.Root`: Root owns the follow, the sticky tail, the anchors and the
26
+ * cursor as well as the virtualizer, so swapping the placement inside it is a reimplementation. This
27
+ * is what that reimplementation will be checked against.
28
+ */
29
+ /** A row's worth of space kept at the bottom, so the reserve cannot swallow the tail entirely. */
30
+ const NOMINAL_ROW = 64;
31
+
32
+ const DefaultStory = ({
33
+ scenario,
34
+ count,
35
+ sticky,
36
+ scrollPastEnd,
37
+ }: {
38
+ scenario: FeedScenario;
39
+ count: number;
40
+ sticky?: boolean;
41
+ scrollPastEnd?: boolean;
42
+ }) => {
43
+ const definition = useMemo(() => createScenario({ scenario, count }), [scenario, count]);
44
+ const [extra, setExtra] = useState<Message.Message[]>([]);
45
+ const controller = useRef<WindowController>(null);
46
+ const messages = useMemo(() => [...definition.messages, ...extra], [definition.messages, extra]);
47
+
48
+ // Measured from the container the host owns, never read back out of the layout: the reserve is an
49
+ // input to the sizer, and anything derived from the sizer oscillates.
50
+ const bodyRef = useRef<HTMLDivElement>(null);
51
+ const [available, setAvailable] = useState(0);
52
+ useEffect(() => {
53
+ const element = bodyRef.current;
54
+ if (!element) {
55
+ return;
56
+ }
57
+
58
+ const read = () => setAvailable(element.clientHeight);
59
+ read();
60
+ const observer = new ResizeObserver(read);
61
+ observer.observe(element);
62
+ return () => observer.disconnect();
63
+ }, []);
64
+
65
+ return (
66
+ <div className='flex flex-col h-full'>
67
+ <Toolbar.Root>
68
+ <IconButton
69
+ icon='ph--plus--regular'
70
+ iconOnly
71
+ label='Append'
72
+ data-testid='bridge.append'
73
+ onClick={() =>
74
+ setExtra((all) => [
75
+ ...all,
76
+ Message.make({
77
+ sender: { role: 'assistant', name: 'Assistant' },
78
+ blocks: [{ _tag: 'text', text: `Arrived ${all.length}` }],
79
+ }),
80
+ ])
81
+ }
82
+ />
83
+ <IconButton
84
+ icon='ph--arrow-line-down--regular'
85
+ iconOnly
86
+ label='Bottom'
87
+ data-testid='bridge.bottom'
88
+ onClick={() => controller.current?.scrollToIndex(messages.length - 1, 'end')}
89
+ />
90
+ </Toolbar.Root>
91
+ <div ref={bodyRef} className='grow min-h-0'>
92
+ <MessageWindow
93
+ messages={messages}
94
+ renderer={definition.renderer}
95
+ Chrome={definition.Chrome}
96
+ Custom={definition.Custom}
97
+ estimateSize={definition.estimateSize}
98
+ sticky={sticky}
99
+ reserve={scrollPastEnd ? Math.max(0, available - NOMINAL_ROW) : 0}
100
+ controllerRef={controller}
101
+ />
102
+ </div>
103
+ </div>
104
+ );
105
+ };
106
+
107
+ const meta: Meta<typeof DefaultStory> = {
108
+ title: 'ui/react-ui-feed/stories/bridge',
109
+ render: DefaultStory,
110
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
111
+ parameters: { layout: 'fullscreen' },
112
+ };
113
+
114
+ export default meta;
115
+
116
+ type Story = StoryObj<typeof DefaultStory>;
117
+
118
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
119
+
120
+ const settle = async (frames = 40) => {
121
+ for (let frame = 0; frame < frames; frame++) {
122
+ await nextFrame();
123
+ }
124
+ };
125
+
126
+ /**
127
+ * Nothing moves once it has settled.
128
+ *
129
+ * The same reading `baseline/fill` takes of the current engine: rows are tracked by index, and with
130
+ * nothing scrolling the feed, a mounted row that moves is a defect. It is the first invariant the
131
+ * replacement has to satisfy, and the one the old engine could only satisfy by correcting itself
132
+ * afterwards.
133
+ */
134
+ const holdsStill = (scenario: FeedScenario, count: number): Story => ({
135
+ args: { scenario, count },
136
+ play: async ({ canvasElement }) => {
137
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
138
+ const read = () =>
139
+ new Map(
140
+ [...scroller.querySelectorAll<HTMLElement>('[data-index]')].map((row) => [
141
+ Number(row.dataset.index),
142
+ Math.round(row.getBoundingClientRect().top),
143
+ ]),
144
+ );
145
+
146
+ // Settle first: the first fill measures, and measuring is allowed to move things.
147
+ await settle();
148
+ const before = read();
149
+ await settle(60);
150
+ const after = read();
151
+
152
+ const moved = [...after].filter(([index, top]) => before.has(index) && Math.abs(before.get(index)! - top) > 1);
153
+
154
+ // And the end has to be reachable, which is what says the extents are real.
155
+ //
156
+ // "Nothing moved" alone is satisfied by a list that never measures anything — verified by
157
+ // disabling measurement, at which point the story still passed. Scrolling to the end exercises
158
+ // the sizer, and the sizer is only right if the measured extents reached it.
159
+ // Driven to the end rather than sent there once. The extents are estimates, so measuring the
160
+ // rows the scroll reveals grows the document under it — one assignment lands short of an end
161
+ // that was not yet as far away as it turned out to be. A reader dragging a scrollbar does the
162
+ // same thing repeatedly; what has to be true is that the end is reachable, and that it settles.
163
+ let previous = -1;
164
+ for (let attempt = 0; attempt < 8 && scroller.scrollTop !== previous; attempt++) {
165
+ previous = scroller.scrollTop;
166
+ scroller.scrollTop = scroller.scrollHeight;
167
+ await settle(20);
168
+ }
169
+
170
+ const last = scroller.querySelector<HTMLElement>(`[data-index="${count - 1}"]`);
171
+ const rests =
172
+ !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
173
+
174
+ await expect({ mounted: after.size > 0, moved: moved.map(([index]) => index), rests }).toEqual({
175
+ mounted: true,
176
+ moved: [],
177
+ rests: true,
178
+ });
179
+ },
180
+ });
181
+
182
+ /** The bridge passively: real messages on the window, nothing asserted. */
183
+ export const Default: Story = {
184
+ args: { scenario: 'assistant', count: 200, sticky: true },
185
+ };
186
+
187
+ /** Fixed-height rows, no editor: whatever moves here is the placement and nothing else. */
188
+ export const Plain: Story = holdsStill('plain', 200);
189
+
190
+ /** One editor per row, all identical. */
191
+ export const Uniform: Story = holdsStill('uniform', 200);
192
+
193
+ /** A chat's shape: per-message renderers, block widgets, a per-message estimate. */
194
+ export const Assistant: Story = holdsStill('assistant', 200);
195
+
196
+ /**
197
+ * A chat's tail: the last message rests on the bottom, and stays there as answers arrive.
198
+ *
199
+ * The invariant `baseline/tail` holds the current engine to, pointed at the new one. Read from the
200
+ * last row's own bottom edge rather than the scroll offset: an offset can be at the document's end
201
+ * while the last message is nowhere near the screen, which is what both of that story's defects
202
+ * looked like from outside.
203
+ *
204
+ * It failed for a while, and what it caught was not the tail arithmetic either time. First a stale
205
+ * `getId` — `Window` held the closure it was constructed with, so an appended row's measurement was
206
+ * filed under one id and read back under another and the row was re-measured every commit, which
207
+ * React ends by exceeding the update limit. Then a follow re-derived from proximity, which its own
208
+ * corrections disengage. Both presented as "the tail is in the wrong place".
209
+ */
210
+ export const Tail: Story = {
211
+ args: { scenario: 'assistant', count: 200, sticky: true },
212
+ play: async ({ canvasElement }) => {
213
+ await settle();
214
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
215
+ const rests = (count: number) => {
216
+ const last = canvasElement.querySelector<HTMLElement>(`[data-index="${count - 1}"]`);
217
+ return !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
218
+ };
219
+
220
+ (canvasElement.querySelector('[data-testid="bridge.bottom"]') as HTMLElement).click();
221
+ // Arrival is by correction rounds — the jump lands on estimates and the follow finishes the
222
+ // journey against the rendered edge — so it is polled, not sampled.
223
+ let arrived = false;
224
+ for (let frame = 0; frame < 300 && !arrived; frame++) {
225
+ await nextFrame();
226
+ arrived = rests(200);
227
+ }
228
+
229
+ // An answer arrives while the reader is at the tail. The follow glides (~2 rows/s), so the
230
+ // reading polls to landing — arrival by deceleration is the contract, not instant arrival.
231
+ (canvasElement.querySelector('[data-testid="bridge.append"]') as HTMLElement).click();
232
+ let followed = false;
233
+ for (let frame = 0; frame < 300 && !followed; frame++) {
234
+ await nextFrame();
235
+ followed = rests(201);
236
+ }
237
+
238
+ await expect({ arrived, followed }).toEqual({ arrived: true, followed: true });
239
+ },
240
+ };
241
+
242
+ /**
243
+ * The tail, with a viewport's worth of space reserved past it.
244
+ *
245
+ * The case the old engine cannot do at all: its reserve is a DOM spacer, so the element and the
246
+ * virtualizer disagree about where the document ends and an offset inside the reserve is beyond the
247
+ * virtualizer's maximum, where `scrollToOffset` declines to move. The feed opened at the document's
248
+ * end with its last message near the top of an empty screen — reported from a real session, and the
249
+ * reason `baseline/tail` has no `VariedPastEnd`.
250
+ *
251
+ * Here the reserve is a term in the sizer and `endOffset` is the last row's own end, so "the end of
252
+ * the content" and "the end of the document" are two numbers in one coordinate system rather than
253
+ * two systems.
254
+ */
255
+ export const VariedPastEnd: Story = {
256
+ args: { scenario: 'thread', count: 500, sticky: true, scrollPastEnd: true },
257
+ play: async ({ canvasElement }) => {
258
+ await settle(60);
259
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
260
+
261
+ // Read from the last row's own bottom edge: an offset can be at the document's end while the
262
+ // last message is nowhere near the screen, which is exactly what the defect looked like.
263
+ // Polled to landing: the follow glides over measurement corrections rather than teleporting.
264
+ const restingOf = () => {
265
+ const last = canvasElement.querySelector<HTMLElement>('[data-index="499"]');
266
+ return last && Math.round(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom);
267
+ };
268
+ let resting = restingOf();
269
+ for (let frame = 0; frame < 300 && Math.abs(resting ?? 999) > 2; frame++) {
270
+ await nextFrame();
271
+ resting = restingOf();
272
+ }
273
+ const last = canvasElement.querySelector<HTMLElement>('[data-index="499"]');
274
+
275
+ // And the reserve is still there to be scrolled into — the point of reserving it.
276
+ const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
277
+
278
+ await expect({ mounted: !!last, resting: Math.abs(resting ?? 999) <= 2, reserved: room > 100 }).toEqual({
279
+ mounted: true,
280
+ resting: true,
281
+ reserved: true,
282
+ });
283
+ },
284
+ };
285
+
286
+ /**
287
+ * Scrolling moves the content by exactly what was scrolled, and by nothing else.
288
+ *
289
+ * Reported from a real session as the feed "jumping as soon as it starts to scroll". Measurement
290
+ * *is* allowed to move things — a row that turns out taller pushes the rows after it, and that is
291
+ * the list working — so the reading is the row the reader is looking at: whatever is under the top
292
+ * of the viewport when a step begins must be exactly one step higher when it ends.
293
+ *
294
+ * That is what a re-base breaks and what an anchor cannot: re-basing the document rewrites every
295
+ * position including the ones above the reader, so a correction anywhere lands as a jump here.
296
+ */
297
+ export const Scrolling: Story = {
298
+ args: { scenario: 'thread', count: 500 },
299
+ play: async ({ canvasElement }) => {
300
+ await settle();
301
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
302
+ const top = () => scroller.getBoundingClientRect().top;
303
+ // The row under the top of the viewport, and where it is relative to that edge.
304
+ const at = () => {
305
+ const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
306
+ const edge = top();
307
+ const row = rows.find((element) => element.getBoundingClientRect().bottom > edge + 1);
308
+ return row && { index: row.dataset.index!, offset: row.getBoundingClientRect().top - edge };
309
+ };
310
+
311
+ // Upward, from the tail, which is the only direction that can tell the two designs apart. Going
312
+ // down, every row above the reader has already been measured, so a prefix sum from index 0 and
313
+ // an anchor-relative position agree exactly. Going up, the rows arriving above are estimates
314
+ // being replaced — and under a prefix sum each replacement pushes everything below it, including
315
+ // what the reader is reading.
316
+ (canvasElement.querySelector('[data-testid="bridge.bottom"]') as HTMLElement).click();
317
+ await settle(40);
318
+
319
+ const jumps: { from: number; index: string; moved: number }[] = [];
320
+ for (let step = 0; step < 12; step++) {
321
+ const before = at();
322
+ const from = scroller.scrollTop;
323
+ scroller.scrollTop = from - 240;
324
+ await settle(8);
325
+ const row = before && scroller.querySelector<HTMLElement>(`[data-index="${before.index}"]`);
326
+ if (!before || !row) {
327
+ continue;
328
+ }
329
+
330
+ // Against the reader's own write, not the net scrollTop delta: repaying the start edge moves
331
+ // content and scroll together, so the offset absorbs the machine's shift while the row under
332
+ // the eye moves by exactly what the reader asked — which is the invariant.
333
+ const moved = before.offset - (row.getBoundingClientRect().top - top());
334
+ if (Math.abs(moved - -240) > 2) {
335
+ jumps.push({ from, index: before.index, moved: Math.round(moved) });
336
+ }
337
+ }
338
+
339
+ await expect(jumps).toEqual([]);
340
+ },
341
+ };
342
+
343
+ /**
344
+ * The top of the conversation exists.
345
+ *
346
+ * A feed opened at its tail derives every position above the reader from estimates; rows measuring
347
+ * taller than assumed push row 0 negative, where no scroll can reach it — the reader arrives at the
348
+ * top and the first messages are simply not there (seen live: the flagship opened with its first
349
+ * prompt missing). The start edge is repaid by shifting content and scroll together, so this drives
350
+ * to the top and asks the one absolute fact of content space: row 0 starts at zero.
351
+ */
352
+ export const Top: Story = {
353
+ args: { scenario: 'thread', count: 300, sticky: true },
354
+ play: async ({ canvasElement }) => {
355
+ await settle(40);
356
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
357
+
358
+ // Driven repeatedly: each ascent mounts rows whose measurement re-bases the top.
359
+ let previous = -1;
360
+ for (let attempt = 0; attempt < 10 && scroller.scrollTop !== previous; attempt++) {
361
+ previous = scroller.scrollTop;
362
+ scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }));
363
+ scroller.scrollTop = 0;
364
+ await settle(20);
365
+ }
366
+
367
+ const first = scroller.querySelector<HTMLElement>('[data-index="0"]');
368
+ const resting = !!first && Math.abs(first.getBoundingClientRect().top - scroller.getBoundingClientRect().top) <= 2;
369
+
370
+ await expect({ mounted: !!first, resting, atZero: scroller.scrollTop === 0 }).toEqual({
371
+ mounted: true,
372
+ resting: true,
373
+ atZero: true,
374
+ });
375
+ },
376
+ };
@@ -0,0 +1,147 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EditorState, type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
+ import React, { useEffect, useRef, useState } from 'react';
9
+
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+ import {
12
+ createBasicExtensions,
13
+ createMarkdownExtensions,
14
+ createThemeExtensions,
15
+ decorateMarkdown,
16
+ } from '@dxos/ui-editor';
17
+
18
+ import { type ConstructionCase, type ConstructionResult, profileConstruction } from '../components/Block/construction';
19
+ import { createBlockExtensions } from '../components/Block/extensions';
20
+ import { chatRegistry } from '../testing/widgets';
21
+
22
+ /** The uniform ladder's document: one short paragraph, one line at the story's width. */
23
+ const SHORT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do.';
24
+
25
+ /** What an answer actually looks like: a bolded lead, a paragraph, a list. */
26
+ const LONG = [
27
+ '**Lead.** Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.',
28
+ '',
29
+ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo',
30
+ 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.',
31
+ '',
32
+ '- Excepteur sint occaecat cupidatat non proident.',
33
+ '- Sunt in culpa qui officia deserunt mollit anim id est laborum.',
34
+ ].join('\n');
35
+
36
+ /**
37
+ * Cumulative: each case is the one above it plus one thing, so the difference between two rows is
38
+ * the cost of exactly what was added.
39
+ */
40
+ const buildCases = (): ConstructionCase[] => [
41
+ {
42
+ name: 'wrapping only',
43
+ extensions: [EditorView.editable.of(false), EditorState.readOnly.of(true), EditorView.lineWrapping],
44
+ },
45
+ {
46
+ name: '+ theme',
47
+ extensions: [
48
+ EditorView.editable.of(false),
49
+ EditorState.readOnly.of(true),
50
+ EditorView.lineWrapping,
51
+ createThemeExtensions({ themeMode: 'light' }),
52
+ ],
53
+ },
54
+ {
55
+ name: '+ basic',
56
+ extensions: [
57
+ createBasicExtensions({ readOnly: true, editable: false, lineWrapping: true }),
58
+ createThemeExtensions({ themeMode: 'light' }),
59
+ ],
60
+ },
61
+ {
62
+ name: '+ markdown',
63
+ extensions: [
64
+ createBasicExtensions({ readOnly: true, editable: false, lineWrapping: true }),
65
+ createThemeExtensions({ themeMode: 'light' }),
66
+ createMarkdownExtensions(),
67
+ ],
68
+ },
69
+ {
70
+ name: '+ decorate (= item)',
71
+ extensions: [
72
+ createBasicExtensions({ readOnly: true, editable: false, lineWrapping: true }),
73
+ createThemeExtensions({ themeMode: 'light' }),
74
+ createMarkdownExtensions(),
75
+ decorateMarkdown(),
76
+ ] as Extension[],
77
+ },
78
+ { name: 'item', extensions: createBlockExtensions() },
79
+ { name: 'item + registry', extensions: createBlockExtensions({ registry: chatRegistry }) },
80
+ ];
81
+
82
+ type Row = ConstructionResult & { doc: string };
83
+
84
+ const ConstructionProfile = ({ runs = 20 }: { runs?: number }) => {
85
+ const parentRef = useRef<HTMLDivElement>(null);
86
+ const [rows, setRows] = useState<Row[]>([]);
87
+
88
+ useEffect(() => {
89
+ const parent = parentRef.current;
90
+ if (!parent) {
91
+ return;
92
+ }
93
+
94
+ const measured = (['short', 'long'] as const).flatMap((doc) =>
95
+ profileConstruction({ parent, cases: buildCases(), doc: doc === 'short' ? SHORT : LONG, runs }).map((result) => ({
96
+ ...result,
97
+ doc,
98
+ })),
99
+ );
100
+
101
+ setRows(measured);
102
+ // eslint-disable-next-line no-console
103
+ console.log('[item construction]\n' + format(measured));
104
+ }, [runs]);
105
+
106
+ return (
107
+ <div className='p-4 flex flex-col gap-4'>
108
+ {/* Offscreen but laid out: a `display:none` parent has no layout, and the layout is the cost. */}
109
+ <div ref={parentRef} className='absolute -top-[10000px] w-[46rem]' aria-hidden />
110
+ <pre className='text-xs whitespace-pre'>{rows.length ? format(rows) : 'measuring…'}</pre>
111
+ </div>
112
+ );
113
+ };
114
+
115
+ const format = (rows: Row[]): string => {
116
+ const header = ['doc', 'case', 'mean ms', 'worst ms', '×22 rows'];
117
+ const body = rows.map(({ doc, name, mean, worst }) => [
118
+ doc,
119
+ name,
120
+ mean.toFixed(2),
121
+ worst.toFixed(2),
122
+ (mean * 22).toFixed(0),
123
+ ]);
124
+ const widths = header.map((_, column) => Math.max(...[header, ...body].map((row) => row[column].length)));
125
+
126
+ return [header, ...body].map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ')).join('\n');
127
+ };
128
+
129
+ const meta: Meta<typeof ConstructionProfile> = {
130
+ title: 'ui/react-ui-feed/stories/construction',
131
+ component: ConstructionProfile,
132
+ decorators: [withLayout({ layout: 'column' }), withTheme()],
133
+ parameters: { layout: 'fullscreen' },
134
+ };
135
+
136
+ export default meta;
137
+
138
+ type Story = StoryObj<typeof ConstructionProfile>;
139
+
140
+ /**
141
+ * How long one item takes to build, by extension set.
142
+ *
143
+ * The first fill mounts a viewport of rows in one go, so the `×22 rows` column is the wall-clock
144
+ * cost of that fill — the number to compare against the >1s `baseline/Uniform` takes to appear.
145
+ * Read it from the story, or from the `[item construction]` line the storybook test run prints.
146
+ */
147
+ export const Default: Story = {};