@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,167 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
+
7
+ /** Movement below this is sub-pixel rounding, not a row changing place. */
8
+ const EPSILON = 1;
9
+
10
+ export type PositionStats = {
11
+ /** Rows whose offset changed after they were laid out — every one of these is a visible flicker. */
12
+ shifts: number;
13
+ /** Windows where offsets did not increase with index; a row was placed over its neighbour. */
14
+ breaks: number;
15
+ /** The most recent shift, for reading in the console while scrolling. */
16
+ last?: PositionShift;
17
+ };
18
+
19
+ export type PositionShift = { index: number; from: number; to: number; delta: number };
20
+
21
+ /** Recent shifts kept for inspection — enough to see whether one row type moves or all of them. */
22
+ const TRACE_LIMIT = 40;
23
+
24
+ /** `key` is the virtualizer's item key — the message id, via `getItemKey`. */
25
+ export type PositionKey = number | string | bigint;
26
+
27
+ export type PositionEntry = { key: PositionKey; index: number; start: number };
28
+
29
+ /** Movement of the viewport itself between samples, which every row is expected to follow. */
30
+ export type PositionFrame = { scrollOffset: number };
31
+
32
+ const EMPTY: PositionStats = { shifts: 0, breaks: 0 };
33
+
34
+ /**
35
+ * Records where each row was placed, and reports when a row moves.
36
+ *
37
+ * A virtualized list is only still if a row's offset, once laid out, does not change: rows above the
38
+ * reader are measured as they mount, and any correction there pushes everything below it. Scrolling
39
+ * **up** is where this shows, because that is the direction in which unmeasured rows enter — going
40
+ * down, the rows above have already been measured.
41
+ *
42
+ * Positions are recorded **relative to the viewport** (`start - scrollOffset`), not as absolute
43
+ * document offsets, because those two answer different questions. Measuring a row for the first time
44
+ * re-lays every row below it, and the virtualizer compensates by moving `scrollTop` the same
45
+ * distance — the offsets all change and the reader sees nothing. What a reader sees is a row landing
46
+ * somewhere else on screen, which is what this counts.
47
+ *
48
+ * Two invariants:
49
+ *
50
+ * 1. a row keyed by message id holds its place on screen for as long as it stays mounted;
51
+ * 2. positions within the mounted window increase strictly with index.
52
+ *
53
+ * The count is what a reader means by "flicker". Deliberately kept as a count rather than a log:
54
+ * a scroll produces thousands of samples, and the number is the signal.
55
+ */
56
+ /**
57
+ * Watches for jumps in what was actually painted, once per animation frame.
58
+ *
59
+ * The counting above is done per React render, which cannot separate a row moving from the sampling
60
+ * happening at a different moment than the layout. This reads the scroll and every row's box in the
61
+ * **same** frame and compares each row's travel against the scroll's: a row that keeps its place in
62
+ * the document travels exactly as far as the scroll did, so anything else is the reader seeing a
63
+ * jump. It is the in-page form of measuring a screen recording frame by frame, and it is what a
64
+ * developer should have running while working on the list.
65
+ */
66
+ export const useJumpDetector = (viewport: HTMLElement | null, enabled = true) => {
67
+ const [jumps, setJumps] = useState({ count: 0, worst: 0 });
68
+
69
+ useEffect(() => {
70
+ if (!viewport || !enabled) {
71
+ return;
72
+ }
73
+
74
+ let handle = 0;
75
+ let previous = new Map<string, number>();
76
+ let previousScroll = viewport.scrollTop;
77
+ let count = 0;
78
+ let worst = 0;
79
+
80
+ const tick = () => {
81
+ const scroll = viewport.scrollTop;
82
+ const travelled = previousScroll - scroll;
83
+ const current = new Map<string, number>();
84
+ for (const row of viewport.querySelectorAll<HTMLElement>('[data-index]')) {
85
+ const top = row.getBoundingClientRect().top;
86
+ const index = row.dataset.index!;
87
+ current.set(index, top);
88
+ const before = previous.get(index);
89
+ if (before !== undefined) {
90
+ const residual = Math.abs(top - (before + travelled));
91
+ if (residual > EPSILON) {
92
+ count++;
93
+ worst = Math.max(worst, Math.round(residual));
94
+ }
95
+ }
96
+ }
97
+
98
+ previous = current;
99
+ previousScroll = scroll;
100
+ handle = requestAnimationFrame(tick);
101
+ };
102
+
103
+ handle = requestAnimationFrame(tick);
104
+ const timer = setInterval(() => setJumps({ count, worst }), 250);
105
+ return () => {
106
+ cancelAnimationFrame(handle);
107
+ clearInterval(timer);
108
+ };
109
+ }, [viewport, enabled]);
110
+
111
+ return jumps;
112
+ };
113
+
114
+ export const usePositionLog = () => {
115
+ const positions = useRef(new Map<PositionKey, number>());
116
+ const trace = useRef<PositionShift[]>([]);
117
+ const [stats, setStats] = useState<PositionStats>(EMPTY);
118
+ const pending = useRef<PositionStats>(EMPTY);
119
+
120
+ const reset = useCallback(() => {
121
+ positions.current.clear();
122
+ trace.current = [];
123
+ pending.current = EMPTY;
124
+ setStats(EMPTY);
125
+ }, []);
126
+
127
+ const record = useCallback((entries: readonly PositionEntry[], { scrollOffset }: PositionFrame) => {
128
+ let { shifts, breaks, last } = pending.current;
129
+
130
+ let previousStart = -Infinity;
131
+ for (const { key, index, start: absolute } of entries) {
132
+ const start = absolute - scrollOffset;
133
+ const recorded = positions.current.get(key);
134
+ if (recorded !== undefined && Math.abs(recorded - start) > EPSILON) {
135
+ shifts++;
136
+ last = { index, from: Math.round(recorded), to: Math.round(start), delta: Math.round(start - recorded) };
137
+ trace.current.push(last);
138
+ if (trace.current.length > TRACE_LIMIT) {
139
+ trace.current.shift();
140
+ }
141
+ }
142
+
143
+ positions.current.set(key, start);
144
+ if (start < previousStart) {
145
+ breaks++;
146
+ }
147
+ previousStart = start;
148
+ }
149
+
150
+ // Rows that are no longer mounted are forgotten: their next measurement is a fresh layout, and
151
+ // holding the old offset would report every remount as a shift.
152
+ const mounted = new Set(entries.map(({ key }) => key));
153
+ for (const key of positions.current.keys()) {
154
+ if (!mounted.has(key)) {
155
+ positions.current.delete(key);
156
+ }
157
+ }
158
+
159
+ const next = { shifts, breaks, last };
160
+ pending.current = next;
161
+ setStats((previous) =>
162
+ previous.shifts === next.shifts && previous.breaks === next.breaks ? previous : { ...next },
163
+ );
164
+ }, []);
165
+
166
+ return { ...stats, trace, record, reset };
167
+ };
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo, useRef, useState } from 'react';
7
+ import { expect } from 'storybook/test';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { ListModel } from '../../model';
12
+ import { Window, type WindowController, type WindowState } from '../../virtualizer';
13
+ import { Minimap } from './Minimap';
14
+
15
+ /**
16
+ * The minimap against a live window, because its whole job is to draw what the window publishes:
17
+ * the track is the document, the faint band the mounted rows, the solid band the viewport. Scroll
18
+ * the list and the bands move; click the rail and the list jumps to that fraction.
19
+ */
20
+ type StoryArgs = {
21
+ count?: number;
22
+ width?: number;
23
+ };
24
+
25
+ const EXTENT = (index: number) => 40 + (index % 8) * 30;
26
+
27
+ const DefaultStory = ({ count = 500, width }: StoryArgs) => {
28
+ const model = useMemo(
29
+ () =>
30
+ new ListModel({
31
+ items: Array.from({ length: count }, (_, index) => ({ id: `row-${index}` })),
32
+ getId: (item) => item.id,
33
+ }),
34
+ [count],
35
+ );
36
+ const [state, setState] = useState<WindowState>();
37
+ const controller = useRef<WindowController>(null);
38
+
39
+ return (
40
+ <div className='flex h-full gap-2 p-2'>
41
+ <Window
42
+ classNames='grow min-h-0'
43
+ model={model}
44
+ extents={{ of: EXTENT, exact: true }}
45
+ onChange={setState}
46
+ controllerRef={controller}
47
+ >
48
+ {(index) => (
49
+ <div
50
+ className='flex items-center justify-center border border-separator text-xs tabular-nums'
51
+ style={{ height: EXTENT(index) }}
52
+ >
53
+ {index}
54
+ </div>
55
+ )}
56
+ </Window>
57
+ <Minimap
58
+ state={state}
59
+ width={width}
60
+ onSelect={(fraction) => controller.current?.scrollToIndex(Math.round(fraction * (model.count - 1)))}
61
+ />
62
+ </div>
63
+ );
64
+ };
65
+
66
+ const meta: Meta<StoryArgs> = {
67
+ title: 'ui/react-ui-feed/components/Minimap',
68
+ render: DefaultStory,
69
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[40rem]' }), withTheme()],
70
+ parameters: { layout: 'fullscreen' },
71
+ args: { count: 500 },
72
+ };
73
+
74
+ export default meta;
75
+
76
+ type Story = StoryObj<StoryArgs>;
77
+
78
+ /** Scroll the list, watch the bands; click the rail, the list jumps. No play. */
79
+ export const Default: Story = {};
80
+
81
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
82
+
83
+ const settle = async (frames = 20) => {
84
+ for (let frame = 0; frame < frames; frame++) {
85
+ await nextFrame();
86
+ }
87
+ };
88
+
89
+ /**
90
+ * The viewport band stays inside the mounted band — a viewport showing rows that are not mounted
91
+ * would mean the reader is looking at nothing — and a click moves the list to that fraction.
92
+ */
93
+ export const Tracks: Story = {
94
+ play: async ({ canvasElement }) => {
95
+ await settle();
96
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
97
+ const rail = canvasElement.querySelector<HTMLElement>('[data-testid="minimap"]')!;
98
+ const band = (testid: string) => {
99
+ const element = rail.querySelector<HTMLElement>(`[data-testid="${testid}"]`)!;
100
+ const box = element.getBoundingClientRect();
101
+ return { top: box.top, bottom: box.bottom };
102
+ };
103
+
104
+ const inside = () => {
105
+ const mounted = band('minimap.mounted');
106
+ const viewport = band('minimap.viewport');
107
+ return viewport.top >= mounted.top - 1 && viewport.bottom <= mounted.bottom + 1;
108
+ };
109
+
110
+ // At rest, mid-scroll, and settled again.
111
+ const readings = [inside()];
112
+ scroller.scrollTop = 20_000;
113
+ await settle(5);
114
+ readings.push(inside());
115
+ await settle(20);
116
+ readings.push(inside());
117
+
118
+ // A click near the middle of the rail lands the list near the middle of the document.
119
+ const box = rail.getBoundingClientRect();
120
+ rail.dispatchEvent(
121
+ new MouseEvent('click', { bubbles: true, clientX: box.left + 4, clientY: box.top + box.height / 2 }),
122
+ );
123
+ await settle();
124
+ const total = scroller.scrollHeight - scroller.clientHeight;
125
+ const nearMiddle = Math.abs(scroller.scrollTop / total - 0.5) < 0.1;
126
+
127
+ await expect({ readings, nearMiddle }).toEqual({ readings: [true, true, true], nearMiddle: true });
128
+ },
129
+ };
@@ -0,0 +1,84 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ import { type WindowState } from '../../virtualizer';
11
+
12
+ /** 2rem, matching the outline rail it usually sits opposite. */
13
+ const DEFAULT_WIDTH = 32;
14
+
15
+ /**
16
+ * The whole list at a glance: what exists, what is mounted, and what the reader can see.
17
+ *
18
+ * Drawn in **content space**, not in indices. The three quantities a virtualized list can get wrong
19
+ * are where the mounted rows are, how big the reader's slice of them is, and whether the total the
20
+ * thumb is scaled against is honest — and none of those are visible from a row count. Here they are
21
+ * three bars on one track, so a mounted range that has drifted away from the viewport, or a total
22
+ * that jumps while nothing scrolls, is a picture rather than a number to be noticed.
23
+ *
24
+ * Debug only. It reads the state the window publishes and draws it (§2); it does not participate.
25
+ */
26
+ export type MinimapProps = ThemedClassName<{
27
+ state?: WindowState;
28
+ /** Width of the rail, in px. @default 32 (2rem) */
29
+ width?: number;
30
+ /**
31
+ * Jump to where the reader pointed, as a fraction of the whole list.
32
+ *
33
+ * A fraction and not an index: the map is drawn in content space, so what a click names is a
34
+ * position, and turning that into a row is the host's business — it has the count.
35
+ */
36
+ onSelect?: (fraction: number) => void;
37
+ }>;
38
+
39
+ export const Minimap = ({ classNames, state, width = DEFAULT_WIDTH, onSelect }: MinimapProps) => {
40
+ const total = state?.geometry.total ?? 0;
41
+ const scale = (value: number) => (total > 0 ? `${Math.max(0, Math.min(100, (value / total) * 100))}%` : '0%');
42
+
43
+ return (
44
+ <div
45
+ className={mx('relative h-full bg-input-surface overflow-hidden', onSelect && 'cursor-pointer', classNames)}
46
+ style={{ width }}
47
+ title={total ? `${Math.round(total)}px over ${state?.count} rows` : 'empty'}
48
+ data-testid='minimap'
49
+ onClick={
50
+ onSelect &&
51
+ ((event) => {
52
+ const box = event.currentTarget.getBoundingClientRect();
53
+ onSelect(Math.max(0, Math.min(1, (event.clientY - box.top) / box.height)));
54
+ })
55
+ }
56
+ >
57
+ {state && total > 0 && (
58
+ <>
59
+ {/* Mounted rows: where content actually exists. Should always contain the viewport — if it
60
+ does not, the reader is looking at rows nobody has rendered. */}
61
+ <div
62
+ className='absolute inset-x-0 bg-accent-fill/30'
63
+ style={{ top: scale(state.geometry.window.start), height: scale(state.geometry.window.extent) }}
64
+ data-testid='minimap.mounted'
65
+ />
66
+ {/* Tenths, so a glance says roughly where in the list a bar sits without reading a number. */}
67
+ {Array.from({ length: 9 }, (_, step) => (
68
+ <div
69
+ key={step}
70
+ className='absolute inset-x-0 border-t border-separator/40'
71
+ style={{ top: `${((step + 1) / 10) * 100}%` }}
72
+ />
73
+ ))}
74
+ {/* The viewport, which is the reader. */}
75
+ <div
76
+ className='absolute inset-x-0 border-y border-accent-bg bg-accent-fill/70'
77
+ style={{ top: scale(state.geometry.scroll), height: scale(state.geometry.viewport) }}
78
+ data-testid='minimap.viewport'
79
+ />
80
+ </>
81
+ )}
82
+ </div>
83
+ );
84
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Minimap';
@@ -0,0 +1,196 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo, useState } from 'react';
7
+ import { expect } from 'storybook/test';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { Outline, type OutlineMarker, type OutlineProps } from './Outline';
12
+
13
+ const DOC_LENGTH = 2000;
14
+ const WINDOW = 320;
15
+
16
+ // Synthetic prompt/response markers spread across a document of length `DOC_LENGTH`.
17
+ const createMarkers = (count: number): OutlineMarker[] =>
18
+ Array.from({ length: count }, (_, index) => {
19
+ const from = Math.round((index / count) * DOC_LENGTH);
20
+ const to = Math.round(((index + 1) / count) * DOC_LENGTH) - 8;
21
+ return {
22
+ id: `marker-${index}`,
23
+ title: `Prompt ${index + 1}`,
24
+ description: `Response snippet for prompt ${index + 1}. This is the first few lines of the assistant reply, shown in the popover on hover.`,
25
+ range: { from, to },
26
+ };
27
+ });
28
+
29
+ const defaultMarkers = createMarkers(14);
30
+
31
+ const DefaultStory = ({ markers, ...props }: OutlineProps) => {
32
+ const [start, setStart] = useState(0);
33
+ const [selected, setSelected] = useState<OutlineMarker | null>(null);
34
+ const visibleRange = useMemo(() => ({ from: start, to: start + WINDOW }), [start]);
35
+
36
+ return (
37
+ <div className='relative grid grid-cols-[3rem_1fr] gap-6 p-4'>
38
+ <div>
39
+ <div className='absolute left-4 top-4'>
40
+ <Outline
41
+ {...props}
42
+ markers={markers}
43
+ visibleRange={visibleRange}
44
+ // The wiring a host actually uses: arrows step the *document*, not the rail's ticks,
45
+ // because the rail thins its markers and the two are not one-to-one.
46
+ onNavigate={(delta) =>
47
+ setStart((current) => Math.max(0, Math.min(current + delta * (DOC_LENGTH / 14), DOC_LENGTH - WINDOW)))
48
+ }
49
+ onSelect={(marker) => {
50
+ setSelected(marker);
51
+ // Clicking a tick moves the visible range to that marker.
52
+ setStart(Math.max(0, Math.min(marker.range.from, DOC_LENGTH - WINDOW)));
53
+ }}
54
+ />
55
+ </div>
56
+ </div>
57
+ <div className='flex flex-col gap-3'>
58
+ <label className='flex flex-col gap-1 text-sm'>
59
+ <span className='text-description'>
60
+ Visible range: {visibleRange.from}–{visibleRange.to} (drag to scroll)
61
+ </span>
62
+ <input
63
+ type='range'
64
+ min={0}
65
+ max={DOC_LENGTH - WINDOW}
66
+ value={start}
67
+ onChange={(event) => setStart(Number(event.target.value))}
68
+ />
69
+ </label>
70
+ <p className='text-sm text-description'>
71
+ Hover the rail to see the wave + popover. Ticks intersecting the visible range are brighter.
72
+ </p>
73
+ <p className='text-sm'>Selected: {selected ? selected.title : '(none)'}</p>
74
+ </div>
75
+ </div>
76
+ );
77
+ };
78
+
79
+ const meta = {
80
+ title: 'ui/react-ui-feed/components/Outline',
81
+ component: Outline,
82
+ render: DefaultStory,
83
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
84
+ } satisfies Meta<typeof Outline>;
85
+
86
+ export default meta;
87
+
88
+ type Story = StoryObj<typeof meta>;
89
+
90
+ export const Default: Story = {
91
+ args: {
92
+ markers: defaultMarkers,
93
+ },
94
+ };
95
+
96
+ export const Empty: Story = {
97
+ args: {
98
+ markers: [],
99
+ },
100
+ };
101
+
102
+ /**
103
+ * A click does not leave a position asserted behind it.
104
+ *
105
+ * Clicking a tick focuses it, and the card was gated on focus — so the reader clicked, moved away,
106
+ * and a keyboard position they never asked for kept it up. What may assert a position is the pointer
107
+ * being over a tick, or the keyboard having *navigated* to one; focus is neither, and a click
108
+ * confers focus as a side effect.
109
+ *
110
+ * What this does **not** cover is the pointer actually leaving, because React synthesises enter and
111
+ * leave from over/out pairs and three attempts to drive that from dispatched events did not reach
112
+ * the handler — the state was still set afterwards whether or not the component was right. Asserting
113
+ * it here would be asserting the probe. It needs a real pointer; see `chat-ui/TASKS.md`.
114
+ */
115
+ export const Dismissal: Story = {
116
+ args: { markers: defaultMarkers },
117
+ play: async ({ canvasElement }) => {
118
+ const rail = canvasElement.querySelector<HTMLElement>('[role="navigation"]')!;
119
+ const tick = rail.querySelectorAll('button')[3] as HTMLElement;
120
+ const settle = async () => {
121
+ for (let frame = 0; frame < 20; frame++) {
122
+ await new Promise((resolve) => requestAnimationFrame(resolve));
123
+ }
124
+ };
125
+
126
+ // Focused explicitly as well as clicked: a dispatched `click()` does not move focus, and focus
127
+ // is the thing whose side effect this is about.
128
+ tick.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
129
+ tick.focus();
130
+ tick.click();
131
+ await settle();
132
+
133
+ await expect({
134
+ focused: canvasElement.ownerDocument.activeElement === tick,
135
+ navigated: rail.dataset.navigated,
136
+ }).toEqual({ focused: true, navigated: '' });
137
+ },
138
+ };
139
+
140
+ /**
141
+ * The arrows take the card over from the pointer, and pointing takes it back.
142
+ *
143
+ * A reader clicks a tick and then presses an arrow without moving the mouse — which is the whole
144
+ * point of clicking one. The pointer is still resting on the tick they clicked, so a rule of
145
+ * "the pointer always wins" pins the card to a place they have already left, and the feed scrolls
146
+ * under a card describing somewhere else. Reported from a real session as "card does not move".
147
+ */
148
+ export const KeyboardTakeover: Story = {
149
+ args: { markers: defaultMarkers },
150
+ play: async ({ canvasElement }) => {
151
+ const rail = canvasElement.querySelector<HTMLElement>('[role="navigation"]')!;
152
+ const tick = rail.querySelectorAll('button')[3] as HTMLElement;
153
+ // Polled to the condition rather than settled by frame count: the transitions run through
154
+ // effects whose commit timing differs between a laptop and CI, and a fixed wait encodes
155
+ // whichever machine wrote it.
156
+ const until = async (condition: () => boolean, frames = 300) => {
157
+ for (let frame = 0; frame < frames && !condition(); frame++) {
158
+ await new Promise((resolve) => requestAnimationFrame(resolve));
159
+ }
160
+ return condition();
161
+ };
162
+
163
+ // `pointerover`, not `pointerenter`: React synthesises enter from over/out pairs, so over is the
164
+ // event a handler actually hears.
165
+ tick.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
166
+ const hovered = await until(() => rail.dataset.pointer === '3' && rail.dataset.shown === '3');
167
+
168
+ tick.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
169
+ tick.focus();
170
+ tick.click();
171
+
172
+ // Three presses, with the pointer never moving off the tick that was clicked; the card must
173
+ // move with the keyboard even though the pointer has not moved at all.
174
+ let moved = false;
175
+ for (let press = 0; press < 3; press++) {
176
+ tick.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
177
+ moved = (await until(() => rail.dataset.shown !== '3', 60)) || moved;
178
+ }
179
+
180
+ // Deliberately NOT asserted: that `data-pointer` still reads '3'. Chrome re-hit-tests a
181
+ // stationary cursor when layout shifts under it and fires real pointerover at wherever the
182
+ // runner last left the mouse — so on CI the backstop clears the pointer through no fault of the
183
+ // component's. What the product owes the reader is that the card moved with the keyboard and
184
+ // that pointing again takes over, and both are asserted.
185
+
186
+ // Pointing again hands it back: the pointer is the more direct statement while it is being made.
187
+ tick.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
188
+ const returned = await until(() => rail.dataset.shown === rail.dataset.pointer && rail.dataset.pointer === '3');
189
+
190
+ await expect({ hovered, moved, returned }).toEqual({
191
+ hovered: true,
192
+ moved: true,
193
+ returned: true,
194
+ });
195
+ },
196
+ };