feat(web): add manual removal for captured Pod components (#1951)

* feat(web): add manual removal for captured Pod components

Adds `removePodMember` helper and a per-chip × in `BoardComposerPopover`; leaves `comments.ts` untouched (avoid-zone from #1127).

Closes #802
Contract: runs/2026-05-16T08-08-52_open-design_issue-feat/contract.md

* style(web): hide Pod chip × until chip hover

Swaps the unicode × for the existing `Icon name="close"` SVG so the
hit target stays centered, and fades the button in only on chip
hover / keyboard focus for a quieter resting state.

* fix(web): auto-close Pod composer when last chip is removed

Removing the last chip leaves a stale anchor; close so Send cannot attach to elements no longer visible.

* refactor(web): extract BoardComposerPopover and Pod-member reducer

Moves the popover to its own module and lifts the chip-removal reducer into a pure `applyPodMemberRemoval` so unit tests exercise the real code path and the popover's export is no longer test-only.

* fix(web): rebuild Pod anchor when a member is removed

Without this, the popover keeps the original union bbox / selector / label after each chip removal, so a subsequent Send to chat anchors the comment to elements no longer in the Pod.

* fix(web): render every captured chip and scroll on overflow

The previous slice(0, 6) cap left chips beyond the sixth invisible and undeletable. Render the full list inside a 132px-tall scrollable strip.
This commit is contained in:
Ethan Guo 2026-05-17 20:13:56 +08:00 committed by GitHub
parent 10c62bf6e4
commit 324eca27ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 637 additions and 160 deletions

View file

@ -0,0 +1,180 @@
import { useId } from 'react';
import { selectionKindLabel, type PreviewCommentSnapshot } from '../comments';
import type { Dict } from '../i18n/types';
import type { PreviewComment, PreviewCommentMember } from '../types';
import { Icon } from './Icon';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
function summarizeMember(member: PreviewCommentMember): string {
const text = String(member.text || '').trim();
if (text) {
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
return `${member.label || member.elementId} · ${trimmed}`;
}
return member.label || member.elementId;
}
export function BoardComposerPopover({
target,
existing,
draft,
notes,
onDraft,
onAddDraft,
onRemoveQueuedNote,
onClose,
onSaveComment,
onSendBatch,
onRemove,
onRemoveMember,
sending,
t,
}: {
target: PreviewCommentSnapshot;
existing: PreviewComment | null;
draft: string;
notes: string[];
onDraft: (value: string) => void;
onAddDraft: () => void;
onRemoveQueuedNote: (index: number) => void;
onClose: () => void;
onSaveComment: () => void | Promise<void>;
onSendBatch: () => void | Promise<void>;
onRemove: (commentId: string) => void | Promise<void>;
onRemoveMember: (elementId: string) => void;
sending: boolean;
t: TranslateFn;
}) {
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const podMembers = target.podMembers ?? [];
const titleId = useId();
const isFreePin = target.elementId.startsWith('pin-');
return (
<div
className="comment-popover"
data-testid="comment-popover"
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}}
>
<div className="comment-popover-head">
<div title={target.elementId}>
{isFreePin ? (
<>
<strong id={titleId}>{t('chat.comments.pin')}</strong>
<span>{t('chat.comments.pinAtCoords', { x: target.position.x + 12, y: target.position.y + 12 })}</span>
</>
) : (
<>
<strong id={titleId}>{target.label || target.elementId}</strong>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
</>
)}
</div>
<button
type="button"
className="comment-popover-close"
onClick={onClose}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={12} />
</button>
</div>
{podMembers.length > 0 ? (
<div className="board-pod-summary">
<strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong>
<div className="board-pod-members">
{podMembers.map((member) => (
<span key={member.elementId} className="board-pod-chip">
{summarizeMember(member)}
<button
type="button"
className="board-pod-chip-remove"
onClick={() => onRemoveMember(member.elementId)}
aria-label={t('chat.comments.remove')}
title={t('chat.comments.remove')}
>
<Icon name="close" size={10} />
</button>
</span>
))}
</div>
</div>
) : null}
{notes.length > 0 ? (
<div className="board-note-list">
{notes.map((note, index) => (
<div key={`${target.elementId}-${index}`} className="board-note-item">
<span>{note}</span>
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
{t('chat.comments.remove')}
</button>
</div>
))}
</div>
) : null}
<textarea
data-testid="comment-popover-input"
value={draft}
autoFocus
aria-label={t('chat.comments.placeholder')}
placeholder={t('chat.comments.placeholder')}
onChange={(event) => onDraft(event.target.value)}
/>
<div className="comment-popover-actions">
{existing ? (
<button
type="button"
className="comment-popover-remove"
onClick={() => onRemove(existing.id)}
title={t('chat.comments.remove')}
>
{t('chat.comments.remove')}
</button>
) : null}
<div className="comment-popover-actions-end">
{target.selectionKind === 'pod' ? (
<button
type="button"
className="ghost"
data-testid="comment-popover-add-note"
disabled={!draft.trim()}
onClick={onAddDraft}
>
{t('chat.comments.addNote')}
</button>
) : (
<button
type="button"
className="ghost"
data-testid="comment-popover-save"
disabled={!draft.trim()}
onClick={() => void onSaveComment()}
>
{t('chat.comments.comment')}
</button>
)}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={pendingCount === 0 || sending}
onClick={() => void onSendBatch()}
>
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
</button>
</div>
</div>
</div>
);
}

View file

@ -87,6 +87,8 @@ import {
targetFromSnapshot,
type PreviewCommentSnapshot,
} from '../comments';
import { applyPodMemberRemoval } from '../lib/pod-members';
import { BoardComposerPopover } from './BoardComposerPopover';
import type {
ChatCommentAttachment,
PreviewComment,
@ -1826,157 +1828,6 @@ function FileActions({
);
}
function BoardComposerPopover({
target,
existing,
draft,
notes,
onDraft,
onAddDraft,
onRemoveQueuedNote,
onClose,
onSaveComment,
onSendBatch,
onRemove,
sending,
t,
}: {
target: PreviewCommentSnapshot;
existing: PreviewComment | null;
draft: string;
notes: string[];
onDraft: (value: string) => void;
onAddDraft: () => void;
onRemoveQueuedNote: (index: number) => void;
onClose: () => void;
onSaveComment: () => void | Promise<void>;
onSendBatch: () => void | Promise<void>;
onRemove: (commentId: string) => void | Promise<void>;
sending: boolean;
t: TranslateFn;
}) {
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const podMembers = target.podMembers ?? [];
const titleId = useId();
const isFreePin = target.elementId.startsWith('pin-');
return (
<div
className="comment-popover"
data-testid="comment-popover"
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}}
>
<div className="comment-popover-head">
<div title={target.elementId}>
{isFreePin ? (
<>
<strong id={titleId}>{t('chat.comments.pin')}</strong>
<span>{t('chat.comments.pinAtCoords', { x: target.position.x + 12, y: target.position.y + 12 })}</span>
</>
) : (
<>
<strong id={titleId}>{target.label || target.elementId}</strong>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
</>
)}
</div>
<button
type="button"
className="comment-popover-close"
onClick={onClose}
title={t('common.close')}
aria-label={t('common.close')}
>
<Icon name="close" size={12} />
</button>
</div>
{podMembers.length > 0 ? (
<div className="board-pod-summary">
<strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong>
<div className="board-pod-members">
{podMembers.slice(0, 6).map((member) => (
<span key={member.elementId} className="board-pod-chip">
{summarizeMember(member)}
</span>
))}
</div>
</div>
) : null}
{notes.length > 0 ? (
<div className="board-note-list">
{notes.map((note, index) => (
<div key={`${target.elementId}-${index}`} className="board-note-item">
<span>{note}</span>
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
{t('chat.comments.remove')}
</button>
</div>
))}
</div>
) : null}
<textarea
data-testid="comment-popover-input"
value={draft}
autoFocus
aria-label={t('chat.comments.placeholder')}
placeholder={t('chat.comments.placeholder')}
onChange={(event) => onDraft(event.target.value)}
/>
<div className="comment-popover-actions">
{existing ? (
<button
type="button"
className="comment-popover-remove"
onClick={() => onRemove(existing.id)}
title={t('chat.comments.remove')}
>
{t('chat.comments.remove')}
</button>
) : null}
<div className="comment-popover-actions-end">
{target.selectionKind === 'pod' ? (
<button
type="button"
className="ghost"
data-testid="comment-popover-add-note"
disabled={!draft.trim()}
onClick={onAddDraft}
>
{t('chat.comments.addNote')}
</button>
) : (
<button
type="button"
className="ghost"
data-testid="comment-popover-save"
disabled={!draft.trim()}
onClick={() => void onSaveComment()}
>
{t('chat.comments.comment')}
</button>
)}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={pendingCount === 0 || sending}
onClick={() => void onSendBatch()}
>
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
</button>
</div>
</div>
</div>
);
}
function formatCommentTime(ts: number, t: TranslateFn): string {
const diff = Date.now() - ts;
if (diff < 60_000) return t('common.justNow');
@ -2815,15 +2666,6 @@ export function applyInspectOverridesToSource(source: string, css: string): stri
return block + out;
}
function summarizeMember(member: PreviewCommentMember): string {
const text = String(member.text || '').trim();
if (text) {
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
return `${member.label || member.elementId} · ${trimmed}`;
}
return member.label || member.elementId;
}
function CommentPreviewOverlays({
comments,
liveTargets,
@ -6100,6 +5942,13 @@ function HtmlViewer({
await onRemovePreviewComment(commentId);
clearBoardComposer();
}}
onRemoveMember={(elementId) => {
setActiveCommentTarget((current) => {
const { next, shouldClose } = applyPodMemberRemoval(current, elementId);
if (shouldClose) clearBoardComposer();
return next;
});
}}
sending={sendingBoardBatch || streaming}
t={t}
/>

View file

@ -10901,8 +10901,13 @@ button.connector-action.is-loading {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 132px;
overflow-y: auto;
}
.board-pod-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-pill);
@ -10910,6 +10915,29 @@ button.connector-action.is-loading {
color: var(--text-muted);
font-size: 11px;
}
.board-pod-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), color 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.board-pod-chip:hover .board-pod-chip-remove,
.board-pod-chip-remove:focus-visible {
opacity: 1;
}
.board-pod-chip-remove:hover,
.board-pod-chip-remove:focus-visible {
color: var(--text-default, var(--text-strong));
}
.board-note-list {
display: grid;
gap: 6px;

View file

@ -0,0 +1,117 @@
import type { PreviewCommentMember } from '@open-design/contracts';
import type { PreviewCommentSnapshot } from '../comments';
export function removePodMember(
members: PreviewCommentMember[],
elementId: string,
): PreviewCommentMember[] {
return members.filter((member) => member.elementId !== elementId);
}
export type PodMemberRemovalResult = {
next: PreviewCommentSnapshot | null;
shouldClose: boolean;
};
export function applyPodMemberRemoval(
current: PreviewCommentSnapshot | null,
elementId: string,
): PodMemberRemovalResult {
if (!current || current.selectionKind !== 'pod' || !current.podMembers) {
return { next: current, shouldClose: false };
}
const nextMembers = removePodMember(current.podMembers, elementId);
if (nextMembers.length === 0) {
return { next: null, shouldClose: true };
}
const anchor = recomputePodAnchor(nextMembers);
if (!anchor) {
return { next: null, shouldClose: true };
}
return {
next: {
...current,
...anchor,
podMembers: nextMembers,
memberCount: nextMembers.length,
},
shouldClose: false,
};
}
type PodMemberLike = Pick<
PreviewCommentMember,
'elementId' | 'selector' | 'label' | 'text' | 'position' | 'htmlHint'
>;
export type PodAnchorFields = Pick<
PreviewCommentSnapshot,
'selector' | 'label' | 'text' | 'position' | 'htmlHint'
>;
// Recomputes the snapshot-level fields that address a multi-member Pod as
// one region. Output shape (slice limits, htmlHint cap, position rounding)
// mirrors `buildPodSnapshot` so an anchor rebuilt after removal stays
// structurally identical to the one created at capture time.
export function recomputePodAnchor(
members: readonly PodMemberLike[],
): PodAnchorFields | null {
if (members.length === 0) return null;
const xs = members.map((m) => m.position.x);
const ys = members.map((m) => m.position.y);
const rights = members.map((m) => m.position.x + m.position.width);
const bottoms = members.map((m) => m.position.y + m.position.height);
const left = Math.min(...xs);
const top = Math.min(...ys);
const right = Math.max(...rights);
const bottom = Math.max(...bottoms);
const selector =
members
.slice(0, 8)
.map((m) => m.selector)
.filter((s): s is string => Boolean(s))
.join(', ') || 'body *';
const summaryParts = members
.slice(0, 3)
.map(summarizeAnchorMember)
.filter((s) => s.length > 0);
const label = summaryParts.join(' · ') || `Pod of ${members.length} items`;
const text = members
.slice(0, 4)
.map((m) => m.text)
.filter((s): s is string => Boolean(s))
.join(' · ');
const htmlHint = members
.slice(0, 4)
.map((m) => m.htmlHint)
.filter((s): s is string => Boolean(s))
.join(' ')
.slice(0, 180);
return {
selector,
label,
text,
position: {
x: Math.round(left),
y: Math.round(top),
width: Math.max(1, Math.round(right - left)),
height: Math.max(1, Math.round(bottom - top)),
},
htmlHint,
};
}
// 28-char truncation matches `buildPodSnapshot`'s label-summary rule; the
// chip-level summary lives in BoardComposerPopover with a tighter 24-char cap.
function summarizeAnchorMember(member: PodMemberLike): string {
const raw = String(member.text || '').trim();
if (!raw) return member.label || member.elementId;
const trimmed = raw.length > 28 ? `${raw.slice(0, 25)}...` : raw;
return `${member.label || member.elementId} · ${trimmed}`;
}

View file

@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
import type { PreviewCommentSnapshot } from '../../src/comments';
import type { PreviewCommentMember } from '../../src/types';
afterEach(() => {
cleanup();
});
function member(elementId: string, label = elementId): PreviewCommentMember {
return {
elementId,
selector: `#${elementId}`,
label,
text: '',
position: { x: 0, y: 0, width: 10, height: 10 },
htmlHint: '',
};
}
function podTarget(members: PreviewCommentMember[]): PreviewCommentSnapshot {
return {
filePath: 'index.html',
elementId: 'pod-1',
selector: '',
label: 'Pod',
text: '',
position: { x: 0, y: 0, width: 100, height: 60 },
htmlHint: '',
selectionKind: 'pod',
memberCount: members.length,
podMembers: members,
};
}
function renderPopover(overrides: {
target: PreviewCommentSnapshot;
onRemoveMember: (elementId: string) => void;
}) {
return render(
<BoardComposerPopover
target={overrides.target}
existing={null}
draft=""
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={() => {}}
onRemove={() => {}}
onRemoveMember={overrides.onRemoveMember}
sending={false}
t={((key: string) => String(key)) as never}
/>,
);
}
describe('BoardComposerPopover captured-component removal', () => {
it('calls onRemoveMember with the chip elementId when the chip × is clicked', () => {
const onRemoveMember = vi.fn();
renderPopover({
target: podTarget([member('alpha', 'Alpha'), member('beta', 'Beta')]),
onRemoveMember,
});
const alphaChip = screen.getByText('Alpha').closest('.board-pod-chip');
if (!alphaChip) throw new Error('Alpha chip not rendered');
fireEvent.click(within(alphaChip as HTMLElement).getByRole('button'));
expect(onRemoveMember).toHaveBeenCalledTimes(1);
expect(onRemoveMember).toHaveBeenCalledWith('alpha');
});
it('renders every captured chip so members beyond the sixth stay reachable', () => {
const members = Array.from({ length: 10 }, (_, i) => member(`m${i}`, `Member ${i}`));
renderPopover({ target: podTarget(members), onRemoveMember: () => {} });
expect(document.querySelectorAll('.board-pod-chip')).toHaveLength(10);
expect(screen.queryByText('Member 9')).not.toBeNull();
});
});

View file

@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import type { PreviewCommentMember } from '@open-design/contracts';
import type { PreviewCommentSnapshot } from '../../src/comments';
import { applyPodMemberRemoval, recomputePodAnchor, removePodMember } from '../../src/lib/pod-members';
function member(
elementId: string,
label = elementId,
overrides: Partial<PreviewCommentMember> = {},
): PreviewCommentMember {
return {
elementId,
selector: `#${elementId}`,
label,
text: '',
position: { x: 0, y: 0, width: 10, height: 10 },
htmlHint: '',
...overrides,
};
}
describe('removePodMember', () => {
it('removes the matching member while preserving order of the remaining items', () => {
const a = member('a');
const b = member('b');
const c = member('c');
const result = removePodMember([a, b, c], 'b');
expect(result).toEqual([a, c]);
});
it('returns an equivalent array when the elementId is absent', () => {
const a = member('a');
const b = member('b');
const input = [a, b];
const result = removePodMember(input, 'missing');
expect(result).toEqual([a, b]);
expect(result).not.toBe(input);
});
it('returns an empty array for empty input', () => {
expect(removePodMember([], 'anything')).toEqual([]);
});
it('does not mutate the caller\'s array', () => {
const a = member('a');
const b = member('b');
const input = [a, b];
removePodMember(input, 'a');
expect(input).toEqual([a, b]);
expect(input).toHaveLength(2);
});
it('removes every entry when the same elementId appears more than once', () => {
const a1 = member('a', 'first');
const a2 = member('a', 'second');
const b = member('b');
const result = removePodMember([a1, b, a2], 'a');
expect(result).toEqual([b]);
});
});
function podSnapshot(members: PreviewCommentMember[]): PreviewCommentSnapshot {
return {
filePath: 'index.html',
elementId: 'pod-1',
selector: 'stale, stale, stale',
label: 'stale label',
text: 'stale text',
position: { x: 999, y: 999, width: 999, height: 999 },
htmlHint: '<stale>',
selectionKind: 'pod',
memberCount: members.length,
podMembers: members,
};
}
describe('recomputePodAnchor', () => {
it('returns null for an empty member list', () => {
expect(recomputePodAnchor([])).toBeNull();
});
it('joins selectors from the first 8 members and falls back to body * when all are empty', () => {
const empty = recomputePodAnchor([member('a', 'a', { selector: '' })]);
expect(empty?.selector).toBe('body *');
const many = Array.from({ length: 10 }, (_, i) => member(`m${i}`));
const joined = recomputePodAnchor(many);
expect(joined?.selector.split(', ')).toHaveLength(8);
});
it('computes the tightest bounding rect across every member position', () => {
const result = recomputePodAnchor([
member('a', 'a', { position: { x: 10, y: 20, width: 30, height: 40 } }),
member('b', 'b', { position: { x: 100, y: 200, width: 50, height: 60 } }),
]);
expect(result?.position).toEqual({ x: 10, y: 20, width: 140, height: 240 });
});
it('rounds position fields and enforces minimum 1x1 dimensions', () => {
const result = recomputePodAnchor([
member('a', 'a', { position: { x: 0.49, y: 0.49, width: 0.49, height: 0.49 } }),
]);
expect(result?.position).toEqual({ x: 0, y: 0, width: 1, height: 1 });
});
it('renders a "Pod of N items" label when every member summary collapses to empty', () => {
const result = recomputePodAnchor([
member('', '', { elementId: '', label: '' }),
member('', '', { elementId: '', label: '' }),
]);
expect(result?.label).toBe('Pod of 2 items');
});
it('caps htmlHint at 180 chars to match the buildPodSnapshot creation path', () => {
const longHint = 'x'.repeat(300);
const result = recomputePodAnchor([member('a', 'a', { htmlHint: longHint })]);
expect(result?.htmlHint).toHaveLength(180);
});
});
describe('applyPodMemberRemoval', () => {
it('signals shouldClose when the last member is removed', () => {
const result = applyPodMemberRemoval(podSnapshot([member('only')]), 'only');
expect(result.shouldClose).toBe(true);
expect(result.next).toBeNull();
});
it('returns the trimmed snapshot when other members remain', () => {
const a = member('a');
const b = member('b');
const result = applyPodMemberRemoval(podSnapshot([a, b]), 'a');
expect(result.shouldClose).toBe(false);
expect(result.next?.podMembers).toEqual([b]);
expect(result.next?.memberCount).toBe(1);
});
it('keeps memberCount in sync with podMembers.length', () => {
const result = applyPodMemberRemoval(podSnapshot([member('a'), member('b'), member('c')]), 'b');
expect(result.next?.memberCount).toBe(2);
expect(result.next?.podMembers).toHaveLength(2);
});
it('is a no-op when current is null', () => {
expect(applyPodMemberRemoval(null, 'a')).toEqual({ next: null, shouldClose: false });
});
it('is a no-op when the target is not a pod', () => {
const elementTarget: PreviewCommentSnapshot = {
...podSnapshot([member('a')]),
selectionKind: 'element',
};
const result = applyPodMemberRemoval(elementTarget, 'a');
expect(result.shouldClose).toBe(false);
expect(result.next).toBe(elementTarget);
});
it('is a no-op when the elementId is absent', () => {
const a = member('a');
const input = podSnapshot([a]);
const result = applyPodMemberRemoval(input, 'missing');
expect(result.shouldClose).toBe(false);
expect(result.next?.podMembers).toEqual([a]);
});
it('rebuilds selector, label, position, text, htmlHint from the remaining members', () => {
const hero = member('hero', 'hero', {
selector: '[data-od-id="hero"]',
label: 'section.hero',
text: 'Hero title',
position: { x: 10, y: 20, width: 200, height: 100 },
htmlHint: '<section data-od-id="hero">',
});
const chart = member('chart', 'chart', {
selector: '[data-od-id="chart"]',
label: 'section.chart',
text: 'Chart value',
position: { x: 120, y: 80, width: 190, height: 120 },
htmlHint: '<section data-od-id="chart">',
});
const result = applyPodMemberRemoval(podSnapshot([hero, chart]), 'hero');
expect(result.next).toMatchObject({
selector: '[data-od-id="chart"]',
label: 'section.chart · Chart value',
text: 'Chart value',
position: { x: 120, y: 80, width: 190, height: 120 },
htmlHint: '<section data-od-id="chart">',
memberCount: 1,
});
expect(JSON.stringify(result.next)).not.toContain('hero');
expect(JSON.stringify(result.next)).not.toContain('stale');
});
});