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