@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,383 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, {
6
+ type KeyboardEvent as ReactKeyboardEvent,
7
+ useCallback,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+
15
+ import { Popover, type ThemedClassName } from '@dxos/react-ui';
16
+ import { mx } from '@dxos/ui-theme';
17
+
18
+ // Rest tick width (px) and the wave radius (in rows) over which the hover extension falls off. The
19
+ // peak is the rail's own width: a tick at full extension spans it.
20
+ const REST_WIDTH = 8;
21
+ /** 2rem, matching the debug minimap it usually sits opposite. */
22
+ const DEFAULT_WIDTH = 32;
23
+ const WAVE_SPREAD = 2;
24
+
25
+ /** Height of one tick, and so the pitch the rail thins its markers to. */
26
+ const TICK_SIZE = 8;
27
+
28
+ const intersects = (range: { from: number; to: number }, visible: { from: number; to: number }): boolean =>
29
+ range.from < visible.to && range.to > visible.from;
30
+
31
+ /**
32
+ * A single anchor marker within the mapped document.
33
+ * `range` is expressed in the document's own position space (e.g. CodeMirror offsets).
34
+ */
35
+ export type OutlineMarker = {
36
+ id: string;
37
+ title: string;
38
+ description?: string;
39
+ range: { from: number; to: number };
40
+ };
41
+
42
+ export type OutlineProps = ThemedClassName<{
43
+ markers: OutlineMarker[];
44
+ /** Currently-visible document range; markers intersecting it render brighter ("active"). */
45
+ visibleRange?: { from: number; to: number };
46
+ /**
47
+ * Height of one tick, in px.
48
+ *
49
+ * Fixed rather than shared out over the rail's height: ticks that stretch to fill make a short
50
+ * document's markers enormous and a long one's hairline, so the same rail reads as a different
51
+ * control depending on what is in it. A fixed pitch means the rail thins to what fits and every
52
+ * tick looks the same. @default 8
53
+ */
54
+ tickSize?: number;
55
+ /** Width of the rail, in px, which is also how far a tick extends at the peak of the wave. @default 32 (2rem) */
56
+ width?: number;
57
+ onSelect?: (marker: OutlineMarker, index: number) => void;
58
+ /**
59
+ * Step one item, in whatever the host counts items in.
60
+ *
61
+ * Not one tick: the rail thins its markers to what fits, so the next tick can be ten items away,
62
+ * and a reader pressing an arrow means "the next one" rather than "the next thing I can see". The
63
+ * host steps it with the same mechanism its toolbar uses, which is why this is a callback rather
64
+ * than something the rail decides.
65
+ */
66
+ onNavigate?: (delta: number) => void;
67
+ }>;
68
+
69
+ /**
70
+ * A vertical rail of horizontal ticks, each representing an anchor marker in a
71
+ * scrollable document. The rows tile the full height, so hovering anywhere in the rail activates
72
+ * the nearest tick: it (and its neighbours, with a distance falloff) extends rightward in a wave.
73
+ * A single popover — anchored to the tick the reader is at — shows the
74
+ * marker's title/description. Markers whose range intersects `visibleRange` render at full
75
+ * opacity; the rest are dimmed.
76
+ */
77
+ /** A rendered tick: the marker it points at, and the span of the document it stands for. */
78
+ type Row = { marker: OutlineMarker; index: number; span: { from: number; to: number } };
79
+
80
+ /**
81
+ * Evenly spreads `markers` over at most `capacity` rows, keeping the first and last.
82
+ *
83
+ * Each rendered tick then stands for the span up to the next one, so a thinned rail still covers
84
+ * the whole document — a position between two ticks belongs to the one above it, rather than to
85
+ * nothing.
86
+ */
87
+ const thin = (markers: OutlineMarker[], capacity: number): Row[] => {
88
+ const step = capacity > 1 ? (markers.length - 1) / (capacity - 1) : 0;
89
+ const indexes =
90
+ markers.length <= capacity
91
+ ? markers.map((_, index) => index)
92
+ : Array.from({ length: capacity }, (_, row) => Math.round(row * step));
93
+
94
+ return indexes.map((index, row) => ({
95
+ marker: markers[index],
96
+ index,
97
+ span: { from: markers[index].range.from, to: markers[indexes[row + 1]]?.range.from ?? markers[index].range.to },
98
+ }));
99
+ };
100
+
101
+ export const Outline = ({
102
+ classNames,
103
+ markers,
104
+ visibleRange,
105
+ tickSize = TICK_SIZE,
106
+ width = DEFAULT_WIDTH,
107
+ onSelect,
108
+ onNavigate,
109
+ }: OutlineProps) => {
110
+ const containerRef = useRef<HTMLDivElement>(null);
111
+ const rowsRef = useRef<Array<HTMLButtonElement | null>>([]);
112
+ // Two ways to be *at* a tick, kept apart: the pointer is over one, or the rail has focus and the
113
+ // reader has navigated to one. Conflating them means leaving with the mouse cannot dismiss the
114
+ // card — the keyboard position immediately puts it back.
115
+ const [pointer, setPointer] = useState<number | null>(null);
116
+ const [navigated, setNavigated] = useState<number | null>(null);
117
+ // The rail is bounded by whatever contains it. Measured rather than assumed, because the same
118
+ // component is used both in a sized grid cell and floating over a document, where nothing
119
+ // constrains it and its natural height is the right one.
120
+ const [available, setAvailable] = useState(0);
121
+ useEffect(() => {
122
+ const parent = containerRef.current?.parentElement;
123
+ if (!parent) {
124
+ return;
125
+ }
126
+
127
+ const observer = new ResizeObserver(([entry]) => setAvailable(entry.contentRect.height));
128
+ observer.observe(parent);
129
+ setAvailable(parent.getBoundingClientRect().height);
130
+ return () => observer.disconnect();
131
+ }, []);
132
+
133
+ const natural = markers.length * tickSize;
134
+ // Below two rows the measurement is a transient (an unlaid-out parent), not a real constraint.
135
+ const bounded = available >= tickSize * 2;
136
+ const rows = useMemo(
137
+ () => thin(markers, bounded ? Math.min(markers.length, Math.floor(available / tickSize)) : markers.length),
138
+ [markers, bounded, available, tickSize],
139
+ );
140
+
141
+ // Rest is full opacity when there is no visible range to compare against. Compared against the
142
+ // row's span rather than its marker, so a thinned rail still lights the tick the reader is under.
143
+ const isActive = useCallback(
144
+ (row: Row) => (visibleRange ? intersects(row.span, visibleRange) : true),
145
+ [visibleRange],
146
+ );
147
+
148
+ // Not "has focus" — "is being driven by the keyboard". A click focuses a tick too, and gating on
149
+ // focus left the card up after the pointer had gone: the reader clicked, moved away, and the
150
+ // keyboard position they never asked for kept it open.
151
+ const [keyboard, setKeyboard] = useState(false);
152
+
153
+ // Whichever the reader is actually using. The pointer is the more direct statement, so it wins by
154
+ // default — but not once the arrows are in play: clicking a tick and then pressing an arrow leaves
155
+ // the pointer resting where it was clicked, and a pointer that always wins pins the card to a tick
156
+ // the reader has already navigated away from. Pointing again takes it back (`onPointerEnter`).
157
+ const shown = keyboard ? (navigated ?? pointer) : (pointer ?? navigated);
158
+
159
+ // A backstop while a card is up: the pointer arriving anywhere outside the rail clears it.
160
+ //
161
+ // The tick's own leave is enough for a plain hover, and is not enough after a click — the popover
162
+ // that opens brings focus guards that sit over the page, and the tick never sees the pointer go.
163
+ // Verified with a real pointer: hovering away cleared it, clicking and then hovering away did not.
164
+ // Asking the document is the one question no overlay can intercept.
165
+ useEffect(() => {
166
+ if (pointer == null) {
167
+ return;
168
+ }
169
+
170
+ const rail = containerRef.current;
171
+ const owner = rail?.ownerDocument;
172
+ if (!rail || !owner) {
173
+ return;
174
+ }
175
+
176
+ const onOver = (event: PointerEvent) => {
177
+ if (!rail.contains(event.target as Node | null)) {
178
+ setPointer(null);
179
+ }
180
+ };
181
+
182
+ owner.addEventListener('pointerover', onOver);
183
+ return () => owner.removeEventListener('pointerover', onOver);
184
+ }, [pointer]);
185
+
186
+ // Offset of the shown tick, read from the tick itself: the rail centres its ticks, so the position
187
+ // is not `index * tickSize` and asking the element is the only answer that stays right.
188
+ const [anchorOffset, setAnchorOffset] = useState(0);
189
+ useLayoutEffect(() => {
190
+ const row = shown == null ? null : rowsRef.current[shown];
191
+ if (row) {
192
+ setAnchorOffset(row.offsetTop + row.offsetHeight / 2);
193
+ }
194
+ }, [shown]);
195
+
196
+ // Wave falloff: a Gaussian (normal-distribution) bell centred on the tick shown — a rounded
197
+ // peak with inflected shoulders that flatten toward rest, rather than a dome. `WAVE_SPREAD` is
198
+ // the standard deviation (in rows).
199
+ const widthFor = useCallback(
200
+ (index: number) => {
201
+ if (shown == null) {
202
+ return REST_WIDTH;
203
+ }
204
+
205
+ const distance = index - shown;
206
+ const falloff = Math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD));
207
+ return REST_WIDTH + (width - REST_WIDTH) * falloff;
208
+ },
209
+ [shown, width],
210
+ );
211
+
212
+ // Arrow keys walk the rail. The ticks are buttons, so tab reaches the rail and the arrows then
213
+ // move within it — the roving behaviour a vertical list of controls is expected to have. The step
214
+ // while the keyboard is still on a tick; and clamped to `rows`, since thinning can leave the ref
215
+ // array longer than the rail it now renders.
216
+ const handleKeyDown = useCallback(
217
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
218
+ const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
219
+ if (!delta) {
220
+ return;
221
+ }
222
+
223
+ // Navigation wins where the host offers it: a focused tick is a place in the document, so the
224
+ // arrows move through the document rather than through the rail's own ticks — which are
225
+ // thinned, and so are not one-to-one with anything the reader is counting.
226
+ if (onNavigate) {
227
+ event.preventDefault();
228
+ setKeyboard(true);
229
+ onNavigate(delta);
230
+ return;
231
+ }
232
+
233
+ // Otherwise they walk the rail, the roving behaviour a vertical list of controls is expected
234
+ // to have. Taken from the focused element rather than from the shown tick, and clamped to
235
+ // `rows`, since thinning can leave the ref array longer than the rail it now renders.
236
+ const current = rowsRef.current.findIndex((element) => element === event.target);
237
+ if (current < 0) {
238
+ return;
239
+ }
240
+
241
+ const next = Math.min(Math.max(current + delta, 0), rows.length - 1);
242
+ event.preventDefault();
243
+ rowsRef.current[next]?.focus();
244
+ },
245
+ [rows.length, onNavigate],
246
+ );
247
+
248
+ // While the rail has focus, the shown tick follows where the reader has got to.
249
+ //
250
+ // Navigation steps items and the rail shows ticks, and the two are not one-to-one — so after an
251
+ // arrow press the popover would otherwise describe the tick the pointer last touched rather than
252
+ // the place the reader is now. Derived from the visible range, so it tracks the list however the
253
+ // reader moved: the arrows, the toolbar, or the scrollbar.
254
+ const visibleFrom = visibleRange?.from;
255
+ useEffect(() => {
256
+ if (!keyboard || visibleFrom == null) {
257
+ setNavigated(null);
258
+ return;
259
+ }
260
+
261
+ const index = rows.findIndex((row) => visibleFrom < row.span.to);
262
+ setNavigated(index < 0 ? rows.length - 1 : index);
263
+ }, [keyboard, visibleFrom, rows]);
264
+
265
+ const hoveredMarker = shown == null ? undefined : rows[shown]?.marker;
266
+
267
+ return (
268
+ <Popover.Root open={hoveredMarker != null}>
269
+ <div
270
+ role='navigation'
271
+ className={mx('relative flex flex-col justify-center overflow-hidden', classNames)}
272
+ style={{
273
+ width: `${width}px`,
274
+ height: bounded ? '100%' : `${natural}px`,
275
+ }}
276
+ // Published so a test can ask *why* a card is up — the pointer being over a tick, or the
277
+ // keyboard having navigated to one — rather than only that it is (§12).
278
+ data-pointer={pointer ?? ''}
279
+ data-navigated={navigated ?? ''}
280
+ // And which of the two is actually being shown, since that is the invariant — the reader
281
+ // sees one card, not two states (§12).
282
+ data-shown={shown ?? ''}
283
+ // A pointer anywhere in the rail ends the keyboard's claim on what is shown: the reader has
284
+ // gone back to pointing, and the two should not both assert a position.
285
+ onPointerDown={() => setKeyboard(false)}
286
+ onBlurCapture={(event) => {
287
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
288
+ setKeyboard(false);
289
+ }
290
+ }}
291
+ onKeyDown={handleKeyDown}
292
+ ref={containerRef}
293
+ >
294
+ {/* Ticks are a fixed height, so a document with fewer markers than the rail can hold does not
295
+ fill it — and they are centred rather than stacked at the top, which reads as the rail
296
+ having been cut short. Centring is the rail's own business; where the *rail* sits is its
297
+ container's. */}
298
+ {rows.map((row, index) => {
299
+ const marker = row.marker;
300
+ const active = isActive(row);
301
+ const tick = (
302
+ <button
303
+ key={marker.id}
304
+ type='button'
305
+ aria-label={marker.title}
306
+ // Full-width row; flexes to fill a bounded rail height (min height keeps it hoverable
307
+ // when the rail is unbounded), so any point in the rail maps to a tick. The focus
308
+ // ring is suppressed because focus already shows as the tick extending and
309
+ // darkening — an outline around an invisible full-width row reads as a stray box.
310
+ // `flex-1` with `min-h-0`: the rows share whatever height the rail has, so N markers
311
+ // spread over the full range rather than stacking at their natural pitch and leaving
312
+ // the rest blank. Thinning above guarantees they never need less than the pitch.
313
+ className='flex items-center w-full shrink-0 cursor-pointer outline-none'
314
+ style={{ height: tickSize }}
315
+ ref={(element) => {
316
+ rowsRef.current[index] = element;
317
+ }}
318
+ onPointerEnter={() => {
319
+ setPointer(index);
320
+ // The reader has gone back to pointing, so the keyboard's claim on what is shown
321
+ // ends here rather than at a blur that a mouse never causes.
322
+ setKeyboard(false);
323
+ }}
324
+ // Cleared on the tick that set it, rather than on the rail. A rail-level leave is
325
+ // synthesised from over/out pairs and was observed not firing at all — the card
326
+ // stayed up after the pointer had gone, in the browser and in a test. Moving between
327
+ // ticks does not flicker: the leave and the next enter land in one batch.
328
+ onPointerLeave={() => setPointer((current) => (current === index ? null : current))}
329
+ onFocus={() => keyboard && setNavigated(index)}
330
+ onClick={() => onSelect?.(marker, row.index)}
331
+ >
332
+ <div
333
+ className={mx(
334
+ 'h-[3px] rounded-full transition-all duration-200 ease-out',
335
+ shown === index ? 'bg-neutral-800 dark:bg-neutral-200' : 'bg-neutral-400 dark:bg-neutral-600',
336
+ active ? 'opacity-100' : 'opacity-30',
337
+ )}
338
+ style={{ width: widthFor(index) }}
339
+ />
340
+ </button>
341
+ );
342
+
343
+ return tick;
344
+ })}
345
+
346
+ {/* A separate anchor, and the ticks are never wrapped.
347
+ Wrapping the shown tick in `Popover.Anchor` changes the element type at that position, so
348
+ React unmounts and remounts that button — and a destroyed element never receives
349
+ `pointerleave`, which is why the card would not dismiss when the pointer left. Verified
350
+ with a real pointer: `data-pointer` stayed on the tick after the pointer had gone.
351
+ A zero-height anchor moved to the shown tick's offset keeps every tick stable; the
352
+ popover is keyed to the marker so it still re-measures when the anchor moves. */}
353
+ <Popover.Anchor asChild>
354
+ <div className='absolute left-0' style={{ top: anchorOffset, width, height: 0 }} />
355
+ </Popover.Anchor>
356
+ </div>
357
+ {hoveredMarker && (
358
+ <Popover.Content
359
+ // Keyed to the tick: the popover measures its anchor when it mounts, and moving the anchor
360
+ // to a different element does not make it measure again — the card stayed put while the
361
+ // pointer walked the rail, drifting further from the tick with every step. Remounting per
362
+ // tick is what makes it re-measure.
363
+ key={hoveredMarker.id}
364
+ side='right'
365
+ align='center'
366
+ // Pinned to the anchor point rather than flipped into view, so it tracks the tick.
367
+ avoidCollisions={false}
368
+ onOpenAutoFocus={(event) => event.preventDefault()}
369
+ >
370
+ <Popover.Viewport>
371
+ <div className='px-2 py-1 max-w-[24rem] w-[24rem]'>
372
+ <p className='truncate font-medium'>{hoveredMarker.title}</p>
373
+ {hoveredMarker.description && (
374
+ <p className='mt-1 text-sm text-description line-clamp-3'>{hoveredMarker.description}</p>
375
+ )}
376
+ </div>
377
+ </Popover.Viewport>
378
+ <Popover.Arrow />
379
+ </Popover.Content>
380
+ )}
381
+ </Popover.Root>
382
+ );
383
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Outline';
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Block';
6
+ export * from './MessageList';
7
+ export * from './Minimap';
8
+ export * from './Outline';
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useMemo, useReducer, useRef, useState } from 'react';
6
+
7
+ import { type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ import { type DebugProbe } from './debug-model';
11
+ import { type Stat, Stats } from './Stats';
12
+ import { useDebugModel } from './use-debug';
13
+
14
+ export type DebugProps = ThemedClassName<{
15
+ /** Readouts side by side. @default 1 */
16
+ columns?: number;
17
+ title?: string;
18
+ }>;
19
+
20
+ /**
21
+ * The table over whatever probes are registered (SPEC follow-up: generic debug mechanism, built
22
+ * once and reused by future react-ui components rather than re-invented per surface).
23
+ *
24
+ * Values are sampled once per animation frame while mounted — the model is not reactive over
25
+ * values by design, so the table is the only thing paying for sixty reads a second. Grouped by the
26
+ * probe's `group`, in fixed tracks so a changing digit moves nothing beside it.
27
+ */
28
+ export const Debug = ({ columns = 1, title, classNames }: DebugProps) => {
29
+ const model = useDebugModel();
30
+ const [, bump] = useReducer((version: number) => version + 1, 0);
31
+ useEffect(() => model?.subscribe(bump), [model]);
32
+
33
+ const probes = model?.probes() ?? [];
34
+ const groups = useMemo(() => {
35
+ const map = new Map<string, DebugProbe[]>();
36
+ for (const probe of probes) {
37
+ const group = probe.group ?? '';
38
+ map.set(group, [...(map.get(group) ?? []), probe]);
39
+ }
40
+
41
+ return map;
42
+ // Registration bumps the reducer; the list identity follows it.
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [model, probes.length]);
45
+
46
+ const [values, setValues] = useState<Record<string, number | string>>({});
47
+ const frame = useRef(0);
48
+ useEffect(() => {
49
+ if (!model) {
50
+ return;
51
+ }
52
+
53
+ const tick = () => {
54
+ setValues(model.read());
55
+ frame.current = requestAnimationFrame(tick);
56
+ };
57
+
58
+ frame.current = requestAnimationFrame(tick);
59
+ return () => cancelAnimationFrame(frame.current);
60
+ }, [model]);
61
+
62
+ if (!model || !probes.length) {
63
+ return null;
64
+ }
65
+
66
+ return (
67
+ <div
68
+ className={mx(
69
+ 'z-20 absolute bottom-2 right-2 p-2 rounded-md bg-base-surface/90 border border-separator w-[18rem]',
70
+ classNames,
71
+ )}
72
+ data-testid='debug.panel'
73
+ >
74
+ {[...groups].map(([group, members]) => (
75
+ <Stats
76
+ key={group}
77
+ title={group || title}
78
+ columns={columns}
79
+ stats={members.map(({ id, label, unit, alarm }): Stat => ({
80
+ id,
81
+ label: label ?? id,
82
+ unit,
83
+ classNames: alarm ? (value) => alarm(value) && 'text-error' : undefined,
84
+ }))}
85
+ values={values}
86
+ />
87
+ ))}
88
+ </div>
89
+ );
90
+ };
@@ -0,0 +1,57 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { Fragment } from 'react';
6
+
7
+ import { type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ /** Readouts are mostly counts, but a range (`463–499`) is one reading and reads as one cell. */
11
+ export type StatValue = number | string;
12
+
13
+ export type Stat = {
14
+ id: string;
15
+ label?: string;
16
+ unit?: string;
17
+ /** Emphasis for a value that means something is wrong; called with the current value. */
18
+ classNames?: (value: StatValue) => string | false | undefined;
19
+ };
20
+
21
+ export type StatsProps = ThemedClassName<{
22
+ 'stats': Stat[];
23
+ 'values': Record<string, StatValue>;
24
+ /** Readouts side by side, for a panel that is wider than it is tall. @default 1 */
25
+ 'columns'?: number;
26
+ 'title'?: string;
27
+ 'data-testid'?: string;
28
+ }>;
29
+
30
+ /**
31
+ * A block of readouts, in fixed tracks.
32
+ *
33
+ * Every number here changes while the feed scrolls, so the value and unit columns are given widths
34
+ * rather than sized to their contents: a column that fits itself to `4` and then to `1054` moves
35
+ * every digit beside it, and an instrument that moves cannot be read at a glance.
36
+ */
37
+ export const Stats = ({ stats, values, columns = 1, classNames, title, ...props }: StatsProps) => (
38
+ <div
39
+ {...props}
40
+ title={title}
41
+ className={mx('grid gap-x-1 tabular-nums whitespace-nowrap', classNames)}
42
+ style={{ gridTemplateColumns: `repeat(${columns}, 1fr 5rem 1.5rem)` }}
43
+ >
44
+ {stats.map(({ id, label, unit, classNames }) => (
45
+ <Fragment key={id}>
46
+ <span className='text-subdued'>{label}</span>
47
+ <span className={mx('text-right', classNames?.(values[id]))} data-testid={`feed.${id}`}>
48
+ {values[id]}
49
+ </span>
50
+ <span className='text-subdued'>{unit ?? ''}</span>
51
+ </Fragment>
52
+ ))}
53
+ </div>
54
+ );
55
+
56
+ /** Anything above zero is a defect, not a reading. */
57
+ export const warnAbove = (value: StatValue) => Number(value) > 0 && 'text-warning-text';
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ import { DebugModel } from './debug-model';
8
+
9
+ describe('DebugModel', () => {
10
+ test('reads every registered probe in one pass', () => {
11
+ const model = new DebugModel();
12
+ let fps = 60;
13
+ model.register({ id: 'fps', read: () => fps });
14
+ model.register({ id: 'rows', read: () => 22 });
15
+
16
+ expect(model.read()).to.deep.eq({ fps: 60, rows: 22 });
17
+ fps = 12;
18
+ // Values are pulled, never pushed: the probe reads live state through its closure.
19
+ expect(model.read()).to.deep.eq({ fps: 12, rows: 22 });
20
+ });
21
+
22
+ test('registration is the only event', () => {
23
+ const model = new DebugModel();
24
+ let events = 0;
25
+ model.subscribe(() => events++);
26
+
27
+ const unregister = model.register({ id: 'fps', read: () => 60 });
28
+ model.read();
29
+ model.read();
30
+ unregister();
31
+
32
+ // Two registrations changes, zero value events — sixty reads a second are the table's cost.
33
+ expect(events).to.eq(2);
34
+ expect(model.probes()).to.deep.eq([]);
35
+ });
36
+ });
@@ -0,0 +1,66 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * One live readout: a name and how to read it. Reading must be cheap — the table reads every probe
7
+ * once per animation frame while it is shown.
8
+ */
9
+ export type DebugProbe = {
10
+ id: string;
11
+ /** Probes render grouped; a group is a component's or an aspect's set. */
12
+ group?: string;
13
+ label?: string;
14
+ unit?: string;
15
+ read: () => number | string;
16
+ /** Emphasis when the value means something is wrong. */
17
+ alarm?: (value: number | string) => boolean;
18
+ };
19
+
20
+ /**
21
+ * A registry of live readouts, so a debug table can render *whatever is currently instrumented*
22
+ * rather than a hard-coded panel per surface (SPEC follow-up: the generic debug mechanism).
23
+ *
24
+ * Deliberately not reactive state: probes are read by whoever renders them, per frame, and a value
25
+ * that changed sixty times a second is not worth sixty renders of anything but the table itself.
26
+ * Registration is the only event.
27
+ */
28
+ export class DebugModel {
29
+ readonly #probes = new Map<string, DebugProbe>();
30
+ readonly #listeners = new Set<() => void>();
31
+
32
+ register(probe: DebugProbe): () => void {
33
+ this.#probes.set(probe.id, probe);
34
+ this.#publish();
35
+ return () => {
36
+ this.#probes.delete(probe.id);
37
+ this.#publish();
38
+ };
39
+ }
40
+
41
+ probes(): DebugProbe[] {
42
+ return [...this.#probes.values()];
43
+ }
44
+
45
+ /** Every probe's current value, in one pass. */
46
+ read(): Record<string, number | string> {
47
+ const values: Record<string, number | string> = {};
48
+ for (const probe of this.#probes.values()) {
49
+ values[probe.id] = probe.read();
50
+ }
51
+
52
+ return values;
53
+ }
54
+
55
+ /** Fires on registration changes only, never on values. */
56
+ subscribe(listener: () => void): () => void {
57
+ this.#listeners.add(listener);
58
+ return () => this.#listeners.delete(listener);
59
+ }
60
+
61
+ #publish(): void {
62
+ for (const listener of this.#listeners) {
63
+ listener();
64
+ }
65
+ }
66
+ }