fix(web): dock comment side panel outside preview (#2073)

* fix: dock comment board without clipping inspect

When the comment-side dock falls back to the stacked layout in narrow
panes, collapsing the side panel now shrinks the bottom strip to a
horizontal rail height instead of keeping the full panel-height row.
commentPreviewCanvasSize() also stops over-deducting the expanded
panel height in the stacked-collapsed path so the canvas sizing stays
in sync with what is rendered.

* fix(web): address docked comment panel review follow-ups

* Fix non-docked comment tool tablet scaling

* test(web): align comment panel tests with collapse API
This commit is contained in:
kami 2026-05-31 12:36:15 +08:00 committed by GitHub
parent 729ce2b0cb
commit def2e9fd2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 773 additions and 339 deletions

View file

@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
};
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number };
type CommentPreviewCanvasOptions = {
boardMode: boolean;
sidePanelCollapsed: boolean;
viewport?: PreviewViewportId;
};
type PreviewScaleOptions = {
canvasPadding?: number;
};
type PreviewViewportPreset = {
id: PreviewViewportId;
width: number | null;
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
},
];
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
const COMMENT_SIDE_DOCK_WIDTH = 320;
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
const COMMENT_SIDE_DOCK_GAP = 12;
const COMMENT_SIDE_DOCK_PADDING = 8;
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
// The five basic style facets the inspect panel exposes. Kept narrow on
// purpose — open-slide's design tokens panel only edits global tokens, so
@ -500,10 +520,11 @@ function previewViewportStyle(
viewport: PreviewViewportId,
previewScale = 1,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
): CSSProperties & Record<string, string | number> {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
if (!preset.width) return {};
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
return {
'--preview-viewport-width': `${preset.width}px`,
'--preview-viewport-height': `${preset.height}px`,
@ -512,15 +533,54 @@ function previewViewportStyle(
};
}
export function commentPreviewCanvasSize(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
): PreviewCanvasSize | undefined {
if (!canvasSize || !options.boardMode) return canvasSize;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
if (usesStackedCommentSideDock(canvasSize, options)) {
const stackedHeightDeduction = options.sidePanelCollapsed
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
return {
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
};
}
return {
width: Math.max(1, dockedWidth),
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
};
}
function usesStackedCommentSideDock(
canvasSize: PreviewCanvasSize | undefined,
options: CommentPreviewCanvasOptions,
) {
if (!canvasSize || !options.boardMode) return false;
const dockPadding = options.viewport && options.viewport !== 'desktop'
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
: COMMENT_SIDE_DOCK_PADDING;
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
}
export function effectivePreviewScale(
viewport: PreviewViewportId,
previewScale: number,
canvasSize?: PreviewCanvasSize,
options?: PreviewScaleOptions,
) {
if (viewport === 'desktop') return previewScale;
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
const canvasPadding = 48;
const canvasPadding = options?.canvasPadding ?? 48;
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
activeCommentId,
collapsed,
onCollapsedChange,
onClose,
onToggleSelect,
onSelectAll,
onClearSelection,
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onClose: () => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
const selectedCount = visibleSelectedIds.size;
const allSelected = comments.length > 0 && selectedCount === comments.length;
const commentsLabel = t('chat.tabComments');
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
const panelId = useId();
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
const submitNewComment = async () => {
if (!onCreateComment || !newCommentDraft.trim()) return;
const saved = await onCreateComment(newCommentDraft.trim());
if (saved) setNewCommentDraft('');
};
useEffect(() => {
const target =
pendingToggleFocusRef.current === 'collapsed'
? collapsedRailRef.current
: pendingToggleFocusRef.current === 'expanded'
? expandedToggleRef.current
: null;
if (!target) return;
pendingToggleFocusRef.current = null;
target.focus();
}, [collapsed]);
const handleCollapsedChange = (
nextCollapsed: boolean,
nextFocusTarget: 'collapsed' | 'expanded',
) => {
pendingToggleFocusRef.current = nextFocusTarget;
onCollapsedChange(nextCollapsed);
};
if (collapsed) {
return (
<button
ref={collapsedRailRef}
type="button"
className="comment-side-rail"
data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })}
aria-expanded={false}
title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)}
onClick={() => handleCollapsedChange(false, 'expanded')}
>
<RemixIcon name="message-3-line" size={15} />
<span>{commentsLabel}</span>
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
}
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<div className="comment-side-header">
<div className="comment-side-title">
<RemixIcon name="message-3-line" size={15} />
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
{t('chat.comments.selectAll')}
</button>
) : null}
<button
type="button"
className="comment-side-close"
aria-label={t('common.close')}
title={t('common.close')}
onClick={onClose}
>
<Icon name="close" size={12} />
</button>
<button
ref={expandedToggleRef}
type="button"
className="comment-side-collapse"
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
aria-controls={panelId}
aria-expanded={true}
title={t('preview.hideSidebar', { label: commentsLabel })}
onClick={() => handleCollapsedChange(true, 'collapsed')}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
</div>
<div className="comment-side-list">
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
);
}
function CommentSideDock({
comments,
selectedIds,
activeCommentId,
collapsed,
onCollapsedChange,
onToggleSelect,
onSelectAll,
onClearSelection,
onReply,
onSendSelected,
onCreateComment,
sending,
t,
composer,
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
activeCommentId: string | null;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onToggleSelect: (commentId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
onSendSelected: () => void | Promise<void>;
onCreateComment?: (note: string) => boolean | Promise<boolean>;
sending: boolean;
t: TranslateFn;
composer?: ReactNode;
}) {
return (
<div
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
data-testid="comment-side-dock"
>
<CommentSidePanel
comments={comments}
selectedIds={selectedIds}
activeCommentId={activeCommentId}
collapsed={collapsed}
onCollapsedChange={onCollapsedChange}
onToggleSelect={onToggleSelect}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
onReply={onReply}
onSendSelected={onSendSelected}
onCreateComment={onCreateComment}
sending={sending}
t={t}
composer={composer}
/>
</div>
);
}
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
boardMode: localCommentSideDockActive,
sidePanelCollapsed: commentSidePanelCollapsed,
viewport: previewViewport,
});
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
const overlayPreviewScale = overlayPreviewTransform.scale;
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
const overlayPreviewScale = effectivePreviewScale(
previewViewport,
previewScale,
boardPreviewCanvasSize,
boardPreviewScaleOptions,
);
const overlayPreviewTransform: PreviewOverlayTransform = {
scale: overlayPreviewScale,
offsetX: 0,
offsetY: 0,
};
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
};
const boardAvailable = mode === 'preview' && source !== null;
const showPreviewToolbarControls = mode === 'preview';
const commentPreviewLayoutClass = [
'comment-preview-layer',
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
].filter(Boolean).join(' ');
const manualEditPanel = manualEditMode ? (
<ManualEditPanel
targets={manualEditTargets}
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
/>
) : null;
const commentSidePanel = commentPanelOpen ? (
<CommentSidePanel
<CommentSideDock
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
activeCommentId={activeSideCommentId}
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed}
onClose={() => {
setCommentPanelOpen(false);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
}}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div
className={manualEditMode
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
: [
'comment-preview-layer',
`preview-viewport preview-viewport-${previewViewport}`,
].filter(Boolean).join(' ')}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
>
{manualEditPanel}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
<div
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
>
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
<div
style={
manualEditMode
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
: previewScaleShellStyle(previewViewport, previewScale)
}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<PreviewDrawOverlay
active={drawOverlayOpen}
onActiveChange={setDrawOverlayOpen}
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason={t('chat.annotationSendDisabledReason')}
>
<div className="artifact-preview-transport-stack">
{OD_PREVIEW_KEEP_ALIVE ? (
<PooledIframe
ref={urlPreviewIframeRef}
cacheKey={urlPreviewKeepAliveKey}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
) : (
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
ref={urlPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
data-od-render-mode="url-load"
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
aria-hidden={useUrlLoadPreview ? undefined : true}
tabIndex={useUrlLoadPreview ? 0 : -1}
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
src={urlTransportSrc}
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = urlPreviewIframeRef.current;
if (useUrlLoadPreview) iframeRef.current = frame;
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (useUrlLoadPreview) restorePreviewScrollPosition();
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
)}
<iframe
key={srcDocTransportResetKey}
ref={srcDocPreviewIframeRef}
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
data-od-render-mode="srcdoc"
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
aria-hidden={useUrlLoadPreview ? true : undefined}
tabIndex={useUrlLoadPreview ? -1 : 0}
title={file.name}
sandbox="allow-scripts allow-downloads"
srcDoc={srcDocTransportContent}
onLoad={() => {
const frame = srcDocPreviewIframeRef.current;
if (!useUrlLoadPreview) iframeRef.current = frame;
// Reset the activation dedupe exactly ONCE per
// freshly mounted iframe DOM node, never on the
// subsequent load events that the same node
// emits during normal srcDoc rendering.
//
// The iframe's load event fires twice for one
// successful activation: once when the lazy
// transport shell HTML loads, and again when
// our own document.open/write/close inside the
// shell finishes. PR #2699 reset the dedupe on
// every load so that switching
// preview -> source -> preview (which remounts
// this iframe as a fresh DOM node) would
// re-activate the new shell. But resetting on
// every load also re-activated on the SECOND
// load of a non-remounted frame, which
// re-triggered document.open/write/close, which
// re-fired the load event, ad infinitum. The
// dedupe ref oscillated between null and the
// current srcDoc thousands of times per render
// and each iteration restarted every CSS
// animation from its `from` keyframe. Designs
// using `animation-fill-mode: both` with
// `from { opacity: 0 }` stayed at opacity 0
// forever and the preview read as blank.
// That is issue #2361.
//
// Tracking the last frame we reset for lets us
// keep PR #2699's "remount after Source toggle"
// fix while breaking the loop on plain renders.
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
srcDocFrameDedupeResetForRef.current = frame;
activatedSrcDocTransportHtmlRef.current = null;
}
if (useLazySrcDocTransport) setSrcDocShellReady(true);
activateLoadedSrcDocTransport(frame);
dcViewportRestoreAtRef.current = Date.now();
frame?.contentWindow?.postMessage({
type: '__dc_set_viewport',
...dcViewportRef.current,
}, '*');
replayInspectOverridesToIframe(frame);
syncBridgeModes(frame);
if (!useUrlLoadPreview) restorePreviewScrollPosition();
}}
/>
</div>
</PreviewDrawOverlay>
</div>
</PreviewDrawOverlay>
</div>
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className="inspect-empty-hint-container"
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={commentCreateMode ? visibleSideComments : []}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
hoveredPodMemberId={hoveredPodMemberId}
activeTarget={activeCommentTarget}
boardTool={boardTool}
showActivePin={commentCreateMode}
scale={overlayPreviewScale}
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);
setCommentCreateMode(true);
setBoardMode(true);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setActivePreviewCommentId(comment.id);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{exportToast ? (
<div className="comment-toast-anchor">
<Toast
message={exportToast}
ttlMs={2200}
onDismiss={() => setExportToast(null)}
/>
</div>
) : null}
{commentSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={commentSavedToast}
ttlMs={2200}
onDismiss={() => setCommentSavedToast(null)}
/>
</div>
) : null}
{templateSavedToast ? (
<div className="comment-toast-anchor">
<Toast
message={templateSavedToast}
ttlMs={2200}
onDismiss={() => setTemplateSavedToast(null)}
/>
</div>
) : null}
{commentComposer}
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
) : null}
{commentPortalHost && commentSidePanel
? createPortal(commentSidePanel, commentPortalHost)
: commentPortalId
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
error={inspectError}
/>
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
element-pick annotation mode because the failure surface is identical.
*/}
{inspectMode
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div
className={`inspect-empty-hint-container${
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
}`}
data-testid="inspect-empty-hint-container"
>
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
{inspectMode
? t('chat.inspect.noEditableTargets')
: t('chat.inspect.noCommentTargets')}
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>

View file

@ -978,6 +978,53 @@
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
display: none;
}
.comment-preview-layer {
--comment-side-dock-width: 320px;
--comment-side-dock-rail-width: 42px;
--comment-side-dock-stacked-height: 220px;
--comment-side-dock-stacked-rail-height: 48px;
}
.comment-preview-layer-with-side-dock {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-width);
gap: 12px;
padding: 8px;
overflow: hidden;
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock {
display: grid;
align-items: stretch;
justify-content: stretch;
padding: 24px;
overflow: hidden;
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-side-dock-stacked {
padding: 8px;
}
.comment-preview-layer-with-side-dock.comment-preview-layer-dock-collapsed {
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-rail-width);
}
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-height);
}
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked.comment-preview-layer-dock-collapsed {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-rail-height);
}
.comment-preview-canvas {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.comment-preview-layer-with-side-dock .comment-preview-canvas {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
}
.preview-viewport:not(.preview-viewport-desktop) {
overflow: auto;
display: flex;
@ -995,7 +1042,7 @@
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
.preview-viewport:not(.preview-viewport-desktop):not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
@ -1008,19 +1055,23 @@
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
background: var(--bg-panel);
}
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock .comment-preview-canvas {
width: auto;
height: auto;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop) .comment-preview-canvas > .comment-frame-clip > div,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
will-change: transform;
}
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
position: relative;
inset: auto;
}
.preview-viewport-mobile .preview-frame-clip,
.preview-viewport-mobile .comment-frame-clip,
.preview-viewport-mobile:not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
border-radius: 28px;
}
@ -1324,29 +1375,59 @@
color: var(--red);
}
/* Right-side comment thread panel. Shown while board (comment) mode
is on; takes the place of the chat sidebar's removed Comments tab.
Floats over the artifact preview at the right edge. */
is on; it docks beside the artifact preview so canvas clicks remain
available for placing comments. */
.comment-side-dock {
position: relative;
min-width: 0;
min-height: 0;
width: var(--comment-side-dock-width);
height: 100%;
display: flex;
}
.comment-side-dock.collapsed {
width: var(--comment-side-dock-rail-width);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock {
width: 100%;
height: var(--comment-side-dock-stacked-height);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed {
width: 100%;
height: var(--comment-side-dock-stacked-rail-height);
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail {
width: 100%;
height: 100%;
flex-direction: row;
justify-content: center;
padding: 0 12px;
gap: 12px;
}
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail span {
writing-mode: horizontal-tb;
}
.comment-side-panel {
--comment-accent: #ff5a3c;
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
position: relative;
width: 320px;
max-width: calc(100% - 16px);
height: 100%;
max-width: 100%;
background: var(--bg-panel, #fff);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
z-index: 30;
overflow: hidden;
}
.comment-preview-layer-side-dock-stacked .comment-side-panel {
width: 100%;
}
.comment-side-header {
display: flex;
align-items: center;
@ -1394,7 +1475,7 @@
cursor: default;
opacity: 0.45;
}
.comment-side-close {
.comment-side-collapse {
width: 26px;
height: 26px;
flex: 0 0 auto;
@ -1408,18 +1489,15 @@
color: var(--text-muted);
cursor: pointer;
}
.comment-side-close:hover {
.comment-side-collapse:hover {
background: var(--bg-subtle);
border-color: var(--border);
color: var(--text);
}
.comment-side-rail {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
z-index: 30;
position: relative;
width: 42px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
@ -1689,16 +1767,15 @@
color: #fff;
}
/* Inspect panel sibling of the comment popover. Anchored to the
right side of the preview surface. Width is fixed so layout doesn't
reflow as the user scrubs slider values; controls reserve space for
their numeric readouts. */
/* Inspect panel sibling of the preview canvas and comment popover.
Keep the usual 296px width, but allow narrow board layouts to shrink it
inside the unclipped preview layer instead of losing controls. */
.inspect-panel {
position: absolute;
top: 14px;
right: 14px;
z-index: 5;
width: 296px;
width: min(296px, calc(100% - 28px));
max-height: calc(100% - 28px);
overflow-y: auto;
display: flex;
@ -1885,20 +1962,6 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
pointer-events: none;
}
.inspect-empty-hint-container.comment-side-panel-open {
right: 340px;
max-width: min(480px, calc(100% - 368px));
}
@media (max-width: 760px) {
.inspect-empty-hint-container.comment-side-panel-open {
left: 14px;
right: 14px;
top: 54px;
max-width: none;
}
}
.inspect-empty-hint-container button,
.inspect-empty-hint-container .close-button {
pointer-events: auto;

View file

@ -29,6 +29,7 @@ import {
LiveArtifactRefreshHistoryPanel,
SvgViewer,
applyInspectOverridesToSource,
commentPreviewCanvasSize,
effectivePreviewScale,
parseInspectOverridesFromSource,
previewOverlayTransform,
@ -75,6 +76,30 @@ function deferredResponse() {
return { promise, resolve };
}
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
return calls
.map(([message]) => message)
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; html?: unknown };
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
});
}
function testRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
width,
height,
top,
left,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
@ -95,7 +120,10 @@ describe('FileViewer preview scale', () => {
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
);
expect(css).toMatch(
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\) \.comment-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\):not\(\.comment-preview-layer-with-side-dock\) \.comment-preview-canvas,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*width: calc\(var\(--preview-viewport-width\) \* var\(--preview-scale, 1\)\);/,
);
expect(css).toMatch(
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
);
});
@ -121,6 +149,60 @@ describe('FileViewer preview scale', () => {
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
});
it('uses the reduced board canvas size when the side dock is open', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
expect(dockedCanvas).toEqual({ width: 552, height: 684 });
expect(effectivePreviewScale('tablet', 1, dockedCanvas)).toBeLessThan(
effectivePreviewScale('tablet', 1, { width: 900, height: 700 }),
);
});
it('uses stacked canvas sizing for narrow board panes instead of a 1px docked canvas', () => {
const narrowCanvas = commentPreviewCanvasSize(
{ width: 400, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
expect(narrowCanvas).toEqual({ width: 384, height: 452 });
});
it('subtracts only the collapsed stacked rail height when the side dock is collapsed in the stacked layout', () => {
const expandedStackedCanvas = commentPreviewCanvasSize(
{ width: 300, height: 700 },
{ boardMode: true, sidePanelCollapsed: false },
);
const collapsedStackedCanvas = commentPreviewCanvasSize(
{ width: 300, height: 700 },
{ boardMode: true, sidePanelCollapsed: true },
);
expect(expandedStackedCanvas).toEqual({ width: 284, height: 452 });
expect(collapsedStackedCanvas).toEqual({ width: 284, height: 624 });
expect(collapsedStackedCanvas!.height).toBeGreaterThan(expandedStackedCanvas!.height);
});
it('matches the rendered non-desktop dock padding in board canvas sizing', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
);
expect(dockedCanvas).toEqual({ width: 520, height: 652 });
});
it('fits non-desktop board previews against the inner canvas without subtracting viewport padding again', () => {
const dockedCanvas = commentPreviewCanvasSize(
{ width: 900, height: 700 },
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
);
expect(effectivePreviewScale('tablet', 1, dockedCanvas, { canvasPadding: 0 })).toBeCloseTo(652 / 1180);
});
it('offsets tablet and mobile overlays to the centered viewport card', () => {
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
scale: 1.25,
@ -2034,23 +2116,69 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
});
it('exits comment mode when closing the comments side panel', () => {
const openComment: PreviewComment = {
id: 'comment-open',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'pin-open',
selector: '[data-od-pin="pin-open"]',
label: 'pin-open',
text: '',
htmlHint: '',
position: { x: 24, y: 32, width: 18, height: 18 },
note: 'Open comment',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
};
it('keeps the picker hint inside the canvas and clear of the open comment side panel', () => {
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
const canvas = screen.getByTestId('comment-preview-canvas');
const dock = screen.getByTestId('comment-side-dock');
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
});
it('keeps non-docked tablet comment-tool previews fitted to the padded canvas', async () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByLabelText('Preview viewport'));
fireEvent.click(screen.getByRole('option', { name: 'Tablet' }));
clickAgentTool('board-mode-toggle');
const layout = screen.getByTestId('comment-preview-layout');
await waitFor(() => {
expect(layout.className).not.toContain('comment-preview-layer-with-side-dock');
expect(Number(layout.style.getPropertyValue('--preview-scale'))).toBeCloseTo((700 - 48) / 1180);
});
});
it('docks the comment side panel outside the clickable preview canvas', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
if (this.dataset.testid === 'comment-preview-canvas') return testRect(8, 8, 552, 684);
if (this.dataset.testid === 'comment-side-dock') return testRect(572, 8, 320, 684);
if (this.dataset.testid === 'comment-side-panel') return testRect(572, 8, 320, 684);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
@ -2058,22 +2186,53 @@ describe('FileViewer tweaks toolbar', () => {
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
previewComments={[openComment]}
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy();
const canvas = screen.getByTestId('comment-preview-canvas');
const dock = screen.getByTestId('comment-side-dock');
const panel = screen.getByTestId('comment-side-panel');
const canvasBox = canvas.getBoundingClientRect();
const dockBox = dock.getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
expect(dock.contains(panel)).toBe(true);
expect(canvas.contains(panel)).toBe(false);
expect(screen.getByTestId('comment-preview-layout').className).toContain(
'comment-preview-layer-with-side-dock',
);
expect(dockBox.left).toBeGreaterThanOrEqual(canvasBox.right);
expect(panelBox.left).toBeGreaterThanOrEqual(canvasBox.right);
});
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull();
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
it('uses the narrow board layout when docking would leave too little canvas', async () => {
const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
if (this.classList.contains('viewer-body')) return testRect(0, 0, 400, 700);
return testRect(0, 0, 0, 0);
});
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
await waitFor(() => {
expect(screen.getByTestId('comment-preview-layout').className).toContain(
'comment-preview-layer-side-dock-stacked',
);
});
getBoundingClientRectSpy.mockRestore();
});
it('keeps saved comment pins visible while adding another comment', async () => {
@ -2543,16 +2702,14 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
});
it('closes the comment side panel from the header close button', () => {
it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => {
const onCollapseChange = vi.fn();
const onClose = vi.fn();
const onSelectAll = vi.fn();
const onReply = vi.fn();
function Harness() {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState(true);
return open ? (
return (
<CommentSidePanel
comments={[
{
@ -2579,10 +2736,6 @@ describe('FileViewer tweaks toolbar', () => {
onCollapseChange(next);
setCollapsed(next);
}}
onClose={() => {
onClose();
setOpen(false);
}}
onToggleSelect={() => {}}
onSelectAll={onSelectAll}
onClearSelection={() => {}}
@ -2591,7 +2744,7 @@ describe('FileViewer tweaks toolbar', () => {
sending={false}
t={t}
/>
) : null;
);
}
render(<Harness />);
@ -2603,13 +2756,69 @@ describe('FileViewer tweaks toolbar', () => {
fireEvent.click(screen.getByText('不要github换成微信').closest('[data-testid="comment-side-item"]')!);
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
fireEvent.click(screen.getByRole('button', { name: /close/i }));
const hideComments = screen.getByRole('button', { name: /hide comments/i });
expect(onClose).toHaveBeenCalledOnce();
expect(onCollapseChange).not.toHaveBeenCalled();
fireEvent.click(hideComments);
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
expect(screen.queryByText('不要github换成微信')).toBeNull();
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
const showComments = screen.getByTestId('comment-side-collapsed-rail');
await waitFor(() => expect(document.activeElement).toBe(showComments));
fireEvent.click(showComments);
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole('button', { name: /hide comments/i }));
});
});
it('announces comment side dock disclosure state without pointing at an unmounted panel', () => {
function Harness() {
const [collapsed, setCollapsed] = useState(false);
return (
<CommentSidePanel
comments={[]}
selectedIds={new Set()}
activeCommentId={null}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
onToggleSelect={() => {}}
onSelectAll={() => {}}
onClearSelection={() => {}}
onReply={() => {}}
onSendSelected={() => {}}
sending={false}
t={t}
/>
);
}
render(<Harness />);
const panel = screen.getByTestId('comment-side-panel');
const hideComments = screen.getByRole('button', { name: /hide comments/i });
const panelId = panel.id;
expect(panelId).toBeTruthy();
expect(hideComments.getAttribute('aria-controls')).toBe(panelId);
expect(hideComments.getAttribute('aria-expanded')).toBe('true');
fireEvent.click(hideComments);
const showComments = screen.getByTestId('comment-side-collapsed-rail');
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
expect(document.getElementById(panelId)).toBeNull();
expect(showComments.getAttribute('aria-controls')).toBeNull();
expect(showComments.getAttribute('aria-expanded')).toBe('false');
});
it('lets the inspect panel shrink inside narrow preview layouts', () => {
const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8');
const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? '';
expect(rule).toContain('width: min(296px, calc(100% - 28px));');
});
it('does not classify text labels containing a standalone article as links', () => {
@ -2637,7 +2846,6 @@ describe('FileViewer tweaks toolbar', () => {
activeCommentId={null}
collapsed={false}
onCollapsedChange={() => {}}
onClose={() => {}}
onToggleSelect={() => {}}
onSelectAll={() => {}}
onClearSelection={() => {}}