@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.
- package/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { rateAtQuantile } from './frame-meter';
|
|
8
|
+
|
|
9
|
+
/** A pass as a list of frame durations (ms), in the histogram the meter keeps. */
|
|
10
|
+
const histogram = (durations: number[]): { buckets: Uint32Array; frames: number } => {
|
|
11
|
+
const buckets = new Uint32Array(512);
|
|
12
|
+
for (const duration of durations) {
|
|
13
|
+
buckets[Math.min(Math.round(duration), 511)]++;
|
|
14
|
+
}
|
|
15
|
+
return { buckets, frames: durations.length };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const rate = (durations: number[], quantile: number) => {
|
|
19
|
+
const { buckets, frames } = histogram(durations);
|
|
20
|
+
return rateAtQuantile(buckets, frames, quantile);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('rateAtQuantile', () => {
|
|
24
|
+
test('reports the display rate for a pass that never missed a frame', () => {
|
|
25
|
+
const smooth = Array.from({ length: 600 }, () => 16);
|
|
26
|
+
expect(rate(smooth, 0.5)).toEqual(63);
|
|
27
|
+
expect(rate(smooth, 0.95)).toEqual(63);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('separates a single stall from the pass that contains it', () => {
|
|
31
|
+
// The case the whole meter exists for: 60fps throughout, one 200ms stall. A mean would call
|
|
32
|
+
// this smooth; the reader would not.
|
|
33
|
+
const stalled = [...Array.from({ length: 599 }, () => 16), 200];
|
|
34
|
+
expect(rate(stalled, 0.5)).toEqual(63);
|
|
35
|
+
expect(rate(stalled, 0.95)).toEqual(63);
|
|
36
|
+
expect(rate(stalled, 1)).toEqual(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the slow tail drags the 95th percentile down once a twentieth of the pass is bad', () => {
|
|
40
|
+
const janky = [...Array.from({ length: 90 }, () => 16), ...Array.from({ length: 10 }, () => 50)];
|
|
41
|
+
expect(rate(janky, 0.5)).toEqual(63);
|
|
42
|
+
expect(rate(janky, 0.95)).toEqual(20);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an empty pass reports nothing rather than dividing by zero', () => {
|
|
46
|
+
expect(rate([], 0.5)).toEqual(0);
|
|
47
|
+
expect(rateAtQuantile(new Uint32Array(512), 0, 0.95)).toEqual(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
/** How often the live readout is published. Per-frame state would itself be the thing dropping frames. */
|
|
8
|
+
const REPORT_INTERVAL = 250;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A frame longer than this counts as a hitch: two intervals of a 60Hz display, the point at which a
|
|
12
|
+
* pause stops reading as motion and starts reading as a stutter. Deliberately absolute rather than
|
|
13
|
+
* derived from the display's rate — the question is whether a reader notices, not whether the
|
|
14
|
+
* hardware's budget was met.
|
|
15
|
+
*/
|
|
16
|
+
const HITCH_MS = 32;
|
|
17
|
+
|
|
18
|
+
/** Frame durations are bucketed by the millisecond; anything slower than this lands in the last bucket. */
|
|
19
|
+
const MAX_BUCKET = 512;
|
|
20
|
+
|
|
21
|
+
/** Hitches traced per pass. A pass that produces more than this has already failed. */
|
|
22
|
+
const MAX_TRACE = 100;
|
|
23
|
+
|
|
24
|
+
export type FrameStats = {
|
|
25
|
+
/** Frames per second over the last reporting window — what the eye checks while a gesture runs. */
|
|
26
|
+
fps: number;
|
|
27
|
+
/** Rate at the median frame: the pass's typical smoothness. */
|
|
28
|
+
p50: number;
|
|
29
|
+
/** Rate at the 95th-percentile (slowest) frame: what the pass felt like at its worst fifth. */
|
|
30
|
+
p95: number;
|
|
31
|
+
/** Longest frame (ms) since the pass began. */
|
|
32
|
+
worst: number;
|
|
33
|
+
/** Frames over {@link HITCH_MS} since the pass began. */
|
|
34
|
+
hitches: number;
|
|
35
|
+
/** Frames sampled, so a reading of zero hitches can be weighed against how long it was measured. */
|
|
36
|
+
frames: number;
|
|
37
|
+
/** Length of the pass so far (ms). */
|
|
38
|
+
duration: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const EMPTY: FrameStats = { fps: 0, p50: 0, p95: 0, worst: 0, hitches: 0, frames: 0, duration: 0 };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rate (fps) at the frame standing at `quantile` of a pass, counting from the fast end — so 0.95 is
|
|
45
|
+
* the slow tail, the fifth of the pass a reader would call the stutter.
|
|
46
|
+
*
|
|
47
|
+
* `buckets` counts frames by their duration in whole milliseconds. Exported for its test: this is
|
|
48
|
+
* the number the engine's verdict rests on, and an off-by-one in the walk would flatter it.
|
|
49
|
+
*/
|
|
50
|
+
export const rateAtQuantile = (buckets: Uint32Array, frames: number, quantile: number): number => {
|
|
51
|
+
if (!frames) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const target = frames * quantile;
|
|
56
|
+
let seen = 0;
|
|
57
|
+
for (let ms = 0; ms < buckets.length; ++ms) {
|
|
58
|
+
seen += buckets[ms];
|
|
59
|
+
if (seen >= target) {
|
|
60
|
+
return ms > 0 ? Math.round(1000 / ms) : 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return 0;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type FrameMeterOptions = {
|
|
68
|
+
/** Names the pass in the recorded line — the story's arguments, since they are what varies. */
|
|
69
|
+
label?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Live statistics plus the control that ends one pass and begins the next. */
|
|
73
|
+
export type FrameMeter = FrameStats & { label?: string; record: () => string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Samples animation-frame intervals and records a pass on demand.
|
|
77
|
+
*
|
|
78
|
+
* This exists because scroll smoothness is the deciding criterion for the feed engine and no
|
|
79
|
+
* automated harness can report it: an agent's browser throttles `requestAnimationFrame`, so any
|
|
80
|
+
* number collected there describes the harness. Averages hide the defect — a scroll that holds
|
|
81
|
+
* 60fps and stalls once for 200ms reads as smooth in a mean and as broken to a reader — so the
|
|
82
|
+
* percentiles, the worst frame and the hitch count are reported together, and each hitch is traced
|
|
83
|
+
* to the console as it happens so a bad number can be turned into a cause.
|
|
84
|
+
*
|
|
85
|
+
* Durations are bucketed by the millisecond rather than kept, so a pass costs the same however long
|
|
86
|
+
* it runs; percentiles come out of the histogram.
|
|
87
|
+
*/
|
|
88
|
+
export const useFrameMeter = ({ label }: FrameMeterOptions = {}): FrameMeter => {
|
|
89
|
+
const [stats, setStats] = useState<FrameStats>(EMPTY);
|
|
90
|
+
|
|
91
|
+
const buckets = useRef(new Uint32Array(MAX_BUCKET));
|
|
92
|
+
const worst = useRef(0);
|
|
93
|
+
const hitches = useRef(0);
|
|
94
|
+
const frames = useRef(0);
|
|
95
|
+
const started = useRef(performance.now());
|
|
96
|
+
const labelRef = useRef(label);
|
|
97
|
+
labelRef.current = label;
|
|
98
|
+
|
|
99
|
+
const clear = useCallback(() => {
|
|
100
|
+
buckets.current.fill(0);
|
|
101
|
+
worst.current = 0;
|
|
102
|
+
hitches.current = 0;
|
|
103
|
+
frames.current = 0;
|
|
104
|
+
started.current = performance.now();
|
|
105
|
+
setStats(EMPTY);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const rateAt = useCallback((quantile: number) => rateAtQuantile(buckets.current, frames.current, quantile), []);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Ends the pass: returns its one-line summary, copies it to the clipboard, logs it, and starts a
|
|
112
|
+
* fresh pass. One control, because a pass is exactly the interval between two of these — start a
|
|
113
|
+
* clean one by clicking, run the gesture, click again to record it.
|
|
114
|
+
*/
|
|
115
|
+
const record = useCallback((): string => {
|
|
116
|
+
const seconds = (performance.now() - started.current) / 1000;
|
|
117
|
+
const summary = [
|
|
118
|
+
labelRef.current ?? 'pass',
|
|
119
|
+
`p50 ${rateAt(0.5)}`,
|
|
120
|
+
`p95 ${rateAt(0.95)}`,
|
|
121
|
+
`worst ${Math.round(worst.current)}ms`,
|
|
122
|
+
`${hitches.current} hitches`,
|
|
123
|
+
`${frames.current} frames`,
|
|
124
|
+
`${seconds.toFixed(1)}s`,
|
|
125
|
+
].join(' · ');
|
|
126
|
+
|
|
127
|
+
// eslint-disable-next-line no-console
|
|
128
|
+
console.log('[feed] pass', summary);
|
|
129
|
+
void navigator.clipboard?.writeText(summary).catch(() => {
|
|
130
|
+
// Clipboard access is denied in some embedding contexts; the log above is the fallback.
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
clear();
|
|
134
|
+
return summary;
|
|
135
|
+
}, [rateAt, clear]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
let handle = 0;
|
|
139
|
+
let last = performance.now();
|
|
140
|
+
let windowStart = last;
|
|
141
|
+
let windowFrames = 0;
|
|
142
|
+
let traced = 0;
|
|
143
|
+
|
|
144
|
+
const tick = (now: number) => {
|
|
145
|
+
const delta = now - last;
|
|
146
|
+
last = now;
|
|
147
|
+
frames.current++;
|
|
148
|
+
windowFrames++;
|
|
149
|
+
buckets.current[Math.min(Math.round(delta), MAX_BUCKET - 1)]++;
|
|
150
|
+
if (delta > worst.current) {
|
|
151
|
+
worst.current = delta;
|
|
152
|
+
}
|
|
153
|
+
if (delta > HITCH_MS) {
|
|
154
|
+
hitches.current++;
|
|
155
|
+
if (traced++ < MAX_TRACE) {
|
|
156
|
+
// eslint-disable-next-line no-console
|
|
157
|
+
console.debug('[feed] hitch', {
|
|
158
|
+
ms: Math.round(delta),
|
|
159
|
+
at: Math.round(now - started.current),
|
|
160
|
+
label: labelRef.current,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const elapsed = now - windowStart;
|
|
166
|
+
if (elapsed >= REPORT_INTERVAL) {
|
|
167
|
+
setStats({
|
|
168
|
+
fps: Math.round((windowFrames * 1000) / elapsed),
|
|
169
|
+
p50: rateAt(0.5),
|
|
170
|
+
p95: rateAt(0.95),
|
|
171
|
+
worst: Math.round(worst.current),
|
|
172
|
+
hitches: hitches.current,
|
|
173
|
+
frames: frames.current,
|
|
174
|
+
duration: Math.round(now - started.current),
|
|
175
|
+
});
|
|
176
|
+
windowStart = now;
|
|
177
|
+
windowFrames = 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
handle = requestAnimationFrame(tick);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
handle = requestAnimationFrame(tick);
|
|
184
|
+
return () => cancelAnimationFrame(handle);
|
|
185
|
+
}, [rateAt]);
|
|
186
|
+
|
|
187
|
+
return { ...stats, label, record };
|
|
188
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type PropsWithChildren, createContext, useContext, useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { DebugModel, type DebugProbe } from './debug-model';
|
|
8
|
+
|
|
9
|
+
const DebugContext = createContext<DebugModel | undefined>(undefined);
|
|
10
|
+
|
|
11
|
+
export type DebugProviderProps = PropsWithChildren<{ model?: DebugModel }>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scope for probes. Anything under it may instrument itself; a `Debug` table under the same
|
|
15
|
+
* provider renders whatever registered. No provider, no cost: the hooks below no-op.
|
|
16
|
+
*/
|
|
17
|
+
export const DebugProvider = ({ model: provided, children }: DebugProviderProps) => {
|
|
18
|
+
const model = useMemo(() => provided ?? new DebugModel(), [provided]);
|
|
19
|
+
return <DebugContext.Provider value={model}>{children}</DebugContext.Provider>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const useDebugModel = (): DebugModel | undefined => useContext(DebugContext);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register live readouts for as long as the component is mounted.
|
|
26
|
+
*
|
|
27
|
+
* The probe reads state through a closure, so the component never re-renders for the table's sake;
|
|
28
|
+
* `deps` re-registers when the closure would go stale (a rebuilt model, a new element).
|
|
29
|
+
*/
|
|
30
|
+
export const useDebugProbes = (probes: () => DebugProbe[], deps: unknown[] = []): void => {
|
|
31
|
+
const model = useDebugModel();
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!model) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const unregister = probes().map((probe) => model.register(probe));
|
|
38
|
+
return () => unregister.forEach((remove) => remove());
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [model, ...deps]);
|
|
41
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { IconButton, Panel, ScrollArea, Toolbar } from '@dxos/react-ui';
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
import { mx } from '@dxos/ui-theme';
|
|
11
|
+
|
|
12
|
+
import { ScrollFollower } from './follow';
|
|
13
|
+
|
|
14
|
+
/** Fixed so the readout in rows/s is directly checkable against the pixels travelled. */
|
|
15
|
+
const ROW_HEIGHT = 48;
|
|
16
|
+
|
|
17
|
+
type StoryProps = {
|
|
18
|
+
count?: number;
|
|
19
|
+
/** rows/s */
|
|
20
|
+
maxSpeed?: number;
|
|
21
|
+
/** rows/s² */
|
|
22
|
+
acceleration?: number;
|
|
23
|
+
/** rows/s²; defaults to `acceleration` */
|
|
24
|
+
deceleration?: number;
|
|
25
|
+
/** Append an item every `growInterval` ms while following, to test a moving target. */
|
|
26
|
+
grow?: boolean;
|
|
27
|
+
growInterval?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The scroll mechanism on its own.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately free of messages, items and virtualization: plain rows in a `ScrollArea`, so what is
|
|
34
|
+
* on screen is only the follow — its ramp up, its cruise, and its landing. When the feed's scrolling
|
|
35
|
+
* feels wrong, this is where to tell a follow problem apart from a measurement one.
|
|
36
|
+
*/
|
|
37
|
+
const DefaultStory = ({
|
|
38
|
+
count = 100,
|
|
39
|
+
maxSpeed,
|
|
40
|
+
acceleration,
|
|
41
|
+
deceleration,
|
|
42
|
+
grow = false,
|
|
43
|
+
growInterval = 300,
|
|
44
|
+
}: StoryProps) => {
|
|
45
|
+
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
|
46
|
+
const [extra, setExtra] = useState(0);
|
|
47
|
+
const [running, setRunning] = useState(false);
|
|
48
|
+
const [readout, setReadout] = useState({ top: 0, target: 0, velocity: 0, peak: 0 });
|
|
49
|
+
|
|
50
|
+
const items = useMemo(() => Array.from({ length: count + extra }, (_, index) => ({ index })), [count, extra]);
|
|
51
|
+
|
|
52
|
+
// Rows are a fixed size here, which is the point: the mechanism is measured against a known
|
|
53
|
+
// geometry so a speed in rows/s means exactly what it says.
|
|
54
|
+
const follower = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
viewport
|
|
57
|
+
? new ScrollFollower(viewport, { maxSpeed, acceleration, deceleration, rowHeight: () => ROW_HEIGHT })
|
|
58
|
+
: undefined,
|
|
59
|
+
[viewport, maxSpeed, acceleration, deceleration],
|
|
60
|
+
);
|
|
61
|
+
useEffect(() => () => follower?.cancel(), [follower]);
|
|
62
|
+
|
|
63
|
+
const handleToggle = useCallback(() => {
|
|
64
|
+
if (follower?.running) {
|
|
65
|
+
// `running` stays true through the coast; the sampler below clears it once the follow has
|
|
66
|
+
// actually come to rest, so the control reflects the motion rather than the intent.
|
|
67
|
+
follower.stop();
|
|
68
|
+
} else {
|
|
69
|
+
follower?.start();
|
|
70
|
+
setRunning(true);
|
|
71
|
+
}
|
|
72
|
+
}, [follower]);
|
|
73
|
+
|
|
74
|
+
const handleTop = useCallback(() => {
|
|
75
|
+
follower?.cancel();
|
|
76
|
+
setRunning(false);
|
|
77
|
+
setExtra(0);
|
|
78
|
+
if (viewport) {
|
|
79
|
+
viewport.scrollTop = 0;
|
|
80
|
+
}
|
|
81
|
+
}, [follower, viewport]);
|
|
82
|
+
|
|
83
|
+
// A moving target: content arriving while the follow is under way is the case a single smooth
|
|
84
|
+
// scroll cannot serve, since each arrival would restart its easing.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!grow || !running) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const interval = setInterval(() => setExtra((value) => value + 1), growInterval);
|
|
91
|
+
return () => clearInterval(interval);
|
|
92
|
+
}, [grow, running, growInterval]);
|
|
93
|
+
|
|
94
|
+
// Sampled rather than pushed: the follower writes `scrollTop` every frame, and re-rendering the
|
|
95
|
+
// list at that rate would measure React instead of the scroll.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!viewport || !follower) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
const velocity = Math.round(follower.velocity * 10) / 10;
|
|
103
|
+
setReadout((prev) => ({
|
|
104
|
+
top: Math.round(viewport.scrollTop),
|
|
105
|
+
target: Math.round(viewport.scrollHeight - viewport.clientHeight),
|
|
106
|
+
velocity,
|
|
107
|
+
// Peak is what says whether the ceiling was ever reached; a travel that never cruises reads
|
|
108
|
+
// as abrupt for a reason that has nothing to do with the landing.
|
|
109
|
+
peak: follower.running ? Math.max(prev.peak, velocity) : prev.peak,
|
|
110
|
+
}));
|
|
111
|
+
setRunning(follower.running);
|
|
112
|
+
}, 50);
|
|
113
|
+
return () => clearInterval(timer);
|
|
114
|
+
}, [viewport, follower]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Panel.Root>
|
|
118
|
+
<Panel.Toolbar asChild>
|
|
119
|
+
<Toolbar.Root>
|
|
120
|
+
<IconButton
|
|
121
|
+
icon={running ? 'ph--stop--regular' : 'ph--play--regular'}
|
|
122
|
+
iconOnly
|
|
123
|
+
label={running ? 'Stop' : 'Start'}
|
|
124
|
+
data-testid='follow.toggle'
|
|
125
|
+
onClick={handleToggle}
|
|
126
|
+
/>
|
|
127
|
+
<IconButton
|
|
128
|
+
icon='ph--arrow-line-up--regular'
|
|
129
|
+
iconOnly
|
|
130
|
+
label='Top'
|
|
131
|
+
data-testid='follow.top'
|
|
132
|
+
onClick={handleTop}
|
|
133
|
+
/>
|
|
134
|
+
</Toolbar.Root>
|
|
135
|
+
</Panel.Toolbar>
|
|
136
|
+
|
|
137
|
+
<Panel.Content asChild>
|
|
138
|
+
<ScrollArea.Root orientation='vertical'>
|
|
139
|
+
<ScrollArea.Viewport data-testid='follow.viewport' ref={setViewport}>
|
|
140
|
+
{items.map(({ index }) => (
|
|
141
|
+
<div
|
|
142
|
+
key={index}
|
|
143
|
+
style={{ height: ROW_HEIGHT }}
|
|
144
|
+
className={mx('flex items-center px-3', index % 2 === 0 && 'bg-input-surface')}
|
|
145
|
+
>
|
|
146
|
+
<span className='text-sm text-description'>{index}</span>
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</ScrollArea.Viewport>
|
|
150
|
+
</ScrollArea.Root>
|
|
151
|
+
</Panel.Content>
|
|
152
|
+
|
|
153
|
+
<Panel.Statusbar>
|
|
154
|
+
<div className='h-6 grid grid-cols-5 items-center gap-4 px-2 text-xs text-description tabular-nums'>
|
|
155
|
+
<span data-testid='follow.items'>{items.length} items</span>
|
|
156
|
+
<span data-testid='follow.position'>
|
|
157
|
+
{readout.top} / {readout.target}
|
|
158
|
+
</span>
|
|
159
|
+
<span data-testid='follow.remaining'>
|
|
160
|
+
{((readout.target - readout.top) / ROW_HEIGHT).toFixed(1)} rows left
|
|
161
|
+
</span>
|
|
162
|
+
<span data-testid='follow.velocity'>
|
|
163
|
+
{readout.velocity} rows/s (peak {readout.peak})
|
|
164
|
+
</span>
|
|
165
|
+
<span className='text-right'>{running ? 'following' : 'idle'}</span>
|
|
166
|
+
</div>
|
|
167
|
+
</Panel.Statusbar>
|
|
168
|
+
</Panel.Root>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const meta: Meta<StoryProps> = {
|
|
173
|
+
title: 'ui/react-ui-feed/hooks/follow',
|
|
174
|
+
render: DefaultStory,
|
|
175
|
+
decorators: [withLayout({ layout: 'column' }), withTheme()],
|
|
176
|
+
parameters: { layout: 'fullscreen' },
|
|
177
|
+
args: { count: 100 },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default meta;
|
|
181
|
+
|
|
182
|
+
type Story = StoryObj<StoryProps>;
|
|
183
|
+
|
|
184
|
+
/** Fixed target: press play and watch it ramp up, cruise, then brake onto the last row. */
|
|
185
|
+
export const Default: Story = {};
|
|
186
|
+
|
|
187
|
+
/** Moving target: rows keep arriving, so the follow should hold its cruise speed rather than stutter. */
|
|
188
|
+
export const Growing: Story = {
|
|
189
|
+
args: { grow: true },
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Slower and gentler — the dials, for comparing feel. */
|
|
193
|
+
export const Gentle: Story = {
|
|
194
|
+
args: { maxSpeed: 4, acceleration: 8 },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Brakes harder than it accelerates: a quick pick-up with a long, soft landing. */
|
|
198
|
+
export const LateBraking: Story = {
|
|
199
|
+
args: { maxSpeed: 16, acceleration: 40, deceleration: 8 },
|
|
200
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { stepVelocity } from './follow';
|
|
8
|
+
|
|
9
|
+
const maxSpeed = 1_600;
|
|
10
|
+
const acceleration = 3_200;
|
|
11
|
+
const frame = 1 / 60;
|
|
12
|
+
|
|
13
|
+
const step = (velocity: number, distance: number) =>
|
|
14
|
+
stepVelocity({ velocity, distance, dt: frame, maxSpeed, acceleration });
|
|
15
|
+
|
|
16
|
+
describe('stepVelocity', () => {
|
|
17
|
+
test('ramps up from rest rather than jumping to full speed', () => {
|
|
18
|
+
const first = step(0, 10_000);
|
|
19
|
+
expect(first).toBeGreaterThan(0);
|
|
20
|
+
expect(first).toBeLessThan(maxSpeed / 2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('reaches the ceiling and stays there while the target is far', () => {
|
|
24
|
+
let velocity = 0;
|
|
25
|
+
for (let index = 0; index < 120; index++) {
|
|
26
|
+
velocity = step(velocity, 10_000);
|
|
27
|
+
}
|
|
28
|
+
expect(velocity).toEqual(maxSpeed);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('decelerates as the target approaches', () => {
|
|
32
|
+
const near = step(maxSpeed, 20);
|
|
33
|
+
expect(near).toBeLessThan(maxSpeed);
|
|
34
|
+
expect(step(near, 5)).toBeLessThan(near);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('brakes onto the curve rather than easing towards it', () => {
|
|
38
|
+
// Rate-limiting the slow-down would leave the speed above what the remaining distance can shed,
|
|
39
|
+
// and the follow would reach the target still moving — an abrupt stop instead of a landing.
|
|
40
|
+
for (const distance of [1, 10, 100, 400]) {
|
|
41
|
+
const velocity = step(maxSpeed, distance);
|
|
42
|
+
expect((velocity * velocity) / (2 * acceleration)).toBeLessThanOrEqual(distance + 1e-6);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('arrives at rest', () => {
|
|
47
|
+
expect(step(0, 0)).toEqual(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a whole travel accelerates, cruises and lands gently', () => {
|
|
51
|
+
// Simulated rather than asserted step-by-step: deceleration is rate-limited like acceleration,
|
|
52
|
+
// so a single step from an arbitrary speed can exceed its own braking distance. What matters is
|
|
53
|
+
// the shape of the complete journey.
|
|
54
|
+
let velocity = 0;
|
|
55
|
+
let position = 0;
|
|
56
|
+
const target = 4_000;
|
|
57
|
+
const speeds: number[] = [];
|
|
58
|
+
const steps: number[] = [];
|
|
59
|
+
for (let index = 0; index < 600 && target - position > 0.5; index++) {
|
|
60
|
+
velocity = step(velocity, target - position);
|
|
61
|
+
const next = Math.min(target, position + velocity * frame);
|
|
62
|
+
steps.push(next - position);
|
|
63
|
+
position = next;
|
|
64
|
+
speeds.push(velocity);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(position).toBeCloseTo(target, 0);
|
|
68
|
+
expect(Math.max(...speeds)).toBeLessThanOrEqual(maxSpeed);
|
|
69
|
+
expect(Math.max(...speeds)).toBeGreaterThan(maxSpeed * 0.9);
|
|
70
|
+
// Landing measured in pixels travelled, not as a fraction of the ceiling: the last frame before
|
|
71
|
+
// arrival is what the eye reads as a glide or a stop, and a couple of pixels is a glide.
|
|
72
|
+
expect(steps[steps.length - 1]).toBeLessThan(4);
|
|
73
|
+
expect(steps[0]).toBeLessThan(steps[Math.floor(steps.length / 2)]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a moving target keeps it at cruise speed', () => {
|
|
77
|
+
// Content arriving faster than the follow travels: the distance never shrinks.
|
|
78
|
+
let velocity = 0;
|
|
79
|
+
for (let index = 0; index < 120; index++) {
|
|
80
|
+
velocity = step(velocity, 5_000);
|
|
81
|
+
}
|
|
82
|
+
expect(velocity).toEqual(maxSpeed);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('deceleration', () => {
|
|
87
|
+
const at = (distance: number, deceleration: number) =>
|
|
88
|
+
stepVelocity({ velocity: maxSpeed, distance, dt: frame, maxSpeed, acceleration, deceleration });
|
|
89
|
+
|
|
90
|
+
test('a gentler rate starts braking further out', () => {
|
|
91
|
+
// Braking distance is v²/2d: at a third of the rate the follow is already slowing where the
|
|
92
|
+
// matched rate is still at full speed, which is what makes the landing visible.
|
|
93
|
+
// 800px is inside the gentler rate's runway (v²/2d = 1200px) and outside the matched one's
|
|
94
|
+
// (400px), so the two disagree exactly where it matters.
|
|
95
|
+
const distance = 800;
|
|
96
|
+
expect(at(distance, acceleration)).toEqual(maxSpeed);
|
|
97
|
+
expect(at(distance, acceleration / 3)).toBeLessThan(maxSpeed);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('defaults to the acceleration when unset', () => {
|
|
101
|
+
expect(stepVelocity({ velocity: maxSpeed, distance: 100, dt: frame, maxSpeed, acceleration })).toEqual(
|
|
102
|
+
at(100, acceleration),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|