-
-
- }
- >
- {getModeIcon()}
-
- {getModeLabel()}
-
+
+
+ {/* Left: Navigation Controls */}
+
+
+
+ }
+ >
+ {getModeIcon()}
+
+ {getModeLabel()}
+
-
-
- }
- >
- {playbackSpeed}x
-
- {t('playback.speed')}
-
+
+
+ }
+ >
+ {playbackSpeed}x
+
+ {t('playback.speed')}
+
-
- setFrameIndex(Math.max(0, getFrameIndex() - 1))}
- disabled={isLoading || subtaskDialogOpen}
- aria-label={t('panels.keyboardShortcuts.prevFrame.action')}
- />
- }
- >
-
-
- {t('panels.keyboardShortcuts.prevFrame.action')}
-
+
+ setFrameIndex(Math.max(0, getFrameIndex() - 1))}
+ disabled={isLoading || subtaskDialogOpen}
+ aria-label={t('panels.keyboardShortcuts.prevFrame.action')}
+ />
+ }
+ >
+
+
+ {t('panels.keyboardShortcuts.prevFrame.action')}
+
-
-
- }
- >
- {isPlaying ? : }
-
-
- {annotationEnabled && isPlaying
- ? t('subtask.pauseToLabel', 'Pause to label')
- : t('panels.keyboardShortcuts.playPause.action')}
-
-
+
+
+ }
+ >
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {annotationEnabled && isPlaying
+ ? t('subtask.pauseToLabel', 'Pause to label')
+ : t('panels.keyboardShortcuts.playPause.action')}
+
+
-
- setFrameIndex(Math.min(totalFrames - 1, getFrameIndex() + 1))}
- disabled={isLoading || subtaskDialogOpen}
- aria-label={t('panels.keyboardShortcuts.nextFrame.action')}
- />
- }
- >
-
-
- {t('panels.keyboardShortcuts.nextFrame.action')}
-
-
+
+ setFrameIndex(Math.min(totalFrames - 1, getFrameIndex() + 1))}
+ disabled={isLoading || subtaskDialogOpen}
+ aria-label={t('panels.keyboardShortcuts.nextFrame.action')}
+ />
+ }
+ >
+
+
+ {t('panels.keyboardShortcuts.nextFrame.action')}
+
+
-
+
-
- {trimSuggestion ? (
-
-
{t('batchTrim.previewNotice')}
-
returnFromTrimPreview()}>
- {t('batchTrim.return')}
+
+ {trimSuggestion ? (
+
+
{t('batchTrim.previewNotice')}
+
returnFromTrimPreview()}>
+ {t('batchTrim.return')}
+
+
cancelTrimPreview()}>
+ {t('common.cancel')}
+
+
+ ) : null}
+ {!suggestion && episodeEditMode && versionCapability?.status === 'supported' ? (
+
{
+ setPlaying(false);
+ setTrimEditMode(!trimming);
+ }}
+ >
+ {t('trim.title')}
-
cancelTrimPreview()}>
- {t('common.cancel')}
-
-
- ) : null}
- {!suggestion && episodeEditMode && versionCapability?.status === 'supported' ? (
- {
- setPlaying(false);
- setTrimEditMode(!trimming);
- }}
- >
- {t('trim.title')}
-
- ) : null}
- {trimming && selectedEpisodeIndex != null ? (
- {
+ userPausedRef.current = true;
+ setPlaying(false);
+ }}
+ onChange={(range) => {
+ if (suggestion)
+ setTrimSuggestion({
+ ...suggestion,
+ range: range ?? { startFrame: 0, endFrame: totalFrames - 1 },
+ });
+ else trimEpisode(selectedEpisodeIndex, range);
+ }}
+ preview={!!suggestion || previewTrim}
+ onPreviewChange={suggestion ? undefined : setPreviewTrim}
+ />
+ ) : null}
+ {!trimming && trimRange ? (
+
+ {t('trim.summary', {
+ original: totalFrames,
+ kept: trimRange.endFrame - trimRange.startFrame + 1,
+ start: (trimRange.startFrame / fps).toFixed(2),
+ end: ((trimRange.endFrame + 1) / fps).toFixed(2),
+ })}
+
+ ) : null}
+ {
- userPausedRef.current = true;
- setPlaying(false);
+ subscribeFrameIndex={subscribeFrameIndex}
+ segments={currentSegments}
+ pendingRange={annotationEnabled ? pendingRange : null}
+ editRanges={annotationEnabled && !subtaskDialogOpen}
+ knownLabels={knownLabels}
+ onReplaceSegments={(next) => {
+ try {
+ replaceEpisodeSegments(next);
+ } catch (error) {
+ console.warn('Could not save subtask ranges', error);
+ }
}}
- onChange={(range) => {
- if (suggestion)
- setTrimSuggestion({
- ...suggestion,
- range: range ?? { startFrame: 0, endFrame: totalFrames - 1 },
- });
- else trimEpisode(selectedEpisodeIndex, range);
- }}
- preview={!!suggestion || previewTrim}
- onPreviewChange={suggestion ? undefined : setPreviewTrim}
- />
- ) : null}
- {!trimming && trimRange ? (
-
- {t('trim.summary', {
- original: totalFrames,
- kept: trimRange.endFrame - trimRange.startFrame + 1,
- start: (trimRange.startFrame / fps).toFixed(2),
- end: ((trimRange.endFrame + 1) / fps).toFixed(2),
- })}
-
- ) : null}
- {
- try {
- replaceEpisodeSegments(next);
- } catch (error) {
- console.warn('Could not save subtask ranges', error);
+ onDeleteSegment={removeSegment}
+ onFillGap={
+ annotationEnabled
+ ? (startFrame, endFrame) => {
+ setRenameIndex(null);
+ userPausedRef.current = true;
+ setPlaying(false);
+ if (beginPendingRange(startFrame, endFrame)) {
+ setSubtaskDialogOpen(true);
+ }
+ }
+ : undefined
}
- }}
- onDeleteSegment={removeSegment}
- onFillGap={
- annotationEnabled
- ? (startFrame, endFrame) => {
- setRenameIndex(null);
- userPausedRef.current = true;
- setPlaying(false);
- if (beginPendingRange(startFrame, endFrame)) {
+ onRenameSegment={
+ annotationEnabled
+ ? (index) => {
+ cancelPending();
+ setRenameIndex(index);
+ userPausedRef.current = true;
+ setPlaying(false);
setSubtaskDialogOpen(true);
}
- }
- : undefined
- }
- onRenameSegment={
- annotationEnabled
- ? (index) => {
- cancelPending();
- setRenameIndex(index);
- userPausedRef.current = true;
- setPlaying(false);
- setSubtaskDialogOpen(true);
- }
- : undefined
- }
- />
-
+ : undefined
+ }
+ />
+
-
+
- {/* Right: Meta Info */}
-
-
-
- {t('playback.meta.fps')}
-
-
- {fps}
-
-
-
-
- {t('playback.meta.version')}
-
-
- {info?.codebase_version || 'N/A'}
-
+ {/* Right: Meta Info */}
+
+
+
+ {t('playback.meta.fps')}
+
+
+ {fps}
+
+
+
+
+ {t('playback.meta.version')}
+
+
+ {info?.codebase_version || 'N/A'}
+
+
-
-
{
- setSubtaskDialogOpen(open);
- if (!open) {
- cancelPending();
- setRenameIndex(null);
- }
- }}
- startFrame={dialogStartFrame}
- endFrame={dialogEndFrame}
- startSeconds={frameTimeSeconds(
- currentFrames[dialogStartFrame]?.timestamp,
- dialogStartFrame,
- fps,
- )}
- endSeconds={frameTimeSeconds(currentFrames[dialogEndFrame]?.timestamp, dialogEndFrame, fps)}
- initialLabel={
- pendingRange
- ? ''
- : ((renameIndex != null ? currentSegments[renameIndex]?.label : '') ?? '')
- }
- knownLabels={knownLabels}
- onSave={(label) => {
- if (renameIndex != null && !pendingRange) {
- updateSegment(renameIndex, { ...currentSegments[renameIndex], label });
- setRenameIndex(null);
- return;
+ {
+ setSubtaskDialogOpen(open);
+ if (!open) {
+ cancelPending();
+ setRenameIndex(null);
+ }
+ }}
+ startFrame={dialogStartFrame}
+ endFrame={dialogEndFrame}
+ startSeconds={frameTimeSeconds(
+ currentFrames[dialogStartFrame]?.timestamp,
+ dialogStartFrame,
+ fps,
+ )}
+ endSeconds={frameTimeSeconds(
+ currentFrames[dialogEndFrame]?.timestamp,
+ dialogEndFrame,
+ fps,
+ )}
+ initialLabel={
+ pendingRange
+ ? ''
+ : ((renameIndex != null ? currentSegments[renameIndex]?.label : '') ?? '')
}
- commitPending(label, { startFrame: dialogStartFrame, endFrame: dialogEndFrame });
- }}
- />
+ knownLabels={knownLabels}
+ onSave={(label) => {
+ if (renameIndex != null && !pendingRange) {
+ updateSegment(renameIndex, { ...currentSegments[renameIndex], label });
+ setRenameIndex(null);
+ return;
+ }
+ commitPending(label, { startFrame: dialogStartFrame, endFrame: dialogEndFrame });
+ }}
+ />
+
);
};
diff --git a/src/react/components/Sidebar/ResizableSidebar.tsx b/src/react/components/Sidebar/ResizableSidebar.tsx
index e10796a..4e261fc 100644
--- a/src/react/components/Sidebar/ResizableSidebar.tsx
+++ b/src/react/components/Sidebar/ResizableSidebar.tsx
@@ -1,4 +1,6 @@
-import React, { useState, useRef, useEffect, useCallback } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+export type ResizableSidebarOrientation = 'horizontal' | 'vertical';
interface ResizableSidebarProps {
children: React.ReactNode;
@@ -7,8 +9,24 @@ interface ResizableSidebarProps {
defaultWidth?: number;
onWidthChange?: (width: number) => void;
className?: string;
+ /**
+ * `horizontal`: the sidebar sits left of the content and its width is dragged
+ * from the right edge. `vertical`: the sidebar sits under the content (narrow
+ * containers) and its height is dragged from the top edge.
+ */
+ orientation?: ResizableSidebarOrientation;
+ /** Accessible name of the drag handle. */
+ handleLabel?: string;
}
+/** Smallest content area kept beside or above the sidebar while dragging (px). */
+const MIN_MAIN_CONTENT_PX = 320;
+const MIN_STACKED_MAIN_PX = 200;
+const MIN_STACKED_HEIGHT_PX = 120;
+/** Initial stacked share of the container height, before the user drags. */
+const STACKED_DEFAULT_SHARE = 0.38;
+const KEYBOARD_STEP_PX = 16;
+
export const ResizableSidebar: React.FC
= ({
children,
minWidth = 200,
@@ -16,176 +34,175 @@ export const ResizableSidebar: React.FC = ({
defaultWidth = 256,
onWidthChange,
className = '',
+ orientation = 'horizontal',
+ handleLabel,
}) => {
+ const vertical = orientation === 'vertical';
const [width, setWidth] = useState(defaultWidth);
+ /** Stacked height in px once the user has dragged; null keeps the default share. */
+ const [height, setHeight] = useState(null);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
- const splitterRef = useRef(null);
- const isResizingRef = useRef(false);
- const startXRef = useRef(0);
- const startWidthRef = useRef(defaultWidth);
- const rafIdRef = useRef(null);
- const minMainContentWidth = 320;
-
- // 使用 ref 存储最新的值,避免闭包问题
- const minWidthRef = useRef(minWidth);
- const maxWidthRef = useRef(maxWidth);
+ const dragRef = useRef<{ start: number; startSize: number } | null>(null);
const onWidthChangeRef = useRef(onWidthChange);
- const getEffectiveMaxWidth = useCallback(() => {
- const viewportLimit = Math.max(minWidthRef.current, window.innerWidth - minMainContentWidth);
- return Math.min(maxWidthRef.current, viewportLimit);
- }, [minMainContentWidth]);
useEffect(() => {
- minWidthRef.current = minWidth;
- maxWidthRef.current = maxWidth;
onWidthChangeRef.current = onWidthChange;
- }, [minWidth, maxWidth, onWidthChange]);
+ }, [onWidthChange]);
+
+ /** Bounds come from the container the sidebar lives in, not the window. */
+ const containerSize = useCallback(() => {
+ const parent = sidebarRef.current?.parentElement;
+ const rect = parent?.getBoundingClientRect();
+ return {
+ width: rect?.width || window.innerWidth,
+ height: rect?.height || window.innerHeight,
+ };
+ }, []);
+
+ const clampWidth = useCallback(
+ (value: number) => {
+ const limit = Math.max(minWidth, containerSize().width - MIN_MAIN_CONTENT_PX);
+ return Math.max(minWidth, Math.min(Math.min(maxWidth, limit), value));
+ },
+ [containerSize, maxWidth, minWidth],
+ );
- // 高性能拖动处理:使用 requestAnimationFrame 和直接 DOM 操作
- const handleMouseMove = useCallback(
- (e: MouseEvent) => {
- if (!isResizingRef.current || !sidebarRef.current) return;
+ const clampHeight = useCallback(
+ (value: number) => {
+ const limit = Math.max(MIN_STACKED_HEIGHT_PX, containerSize().height - MIN_STACKED_MAIN_PX);
+ return Math.max(MIN_STACKED_HEIGHT_PX, Math.min(limit, value));
+ },
+ [containerSize],
+ );
- // 取消之前的 RAF
- if (rafIdRef.current !== null) {
- cancelAnimationFrame(rafIdRef.current);
+ const applySize = useCallback(
+ (next: number) => {
+ if (vertical) {
+ setHeight(clampHeight(next));
+ return;
}
+ const clamped = clampWidth(next);
+ setWidth(clamped);
+ onWidthChangeRef.current?.(clamped);
+ },
+ [clampHeight, clampWidth, vertical],
+ );
+
+ const currentSize = useCallback(() => {
+ const rect = sidebarRef.current?.getBoundingClientRect();
+ if (vertical) return rect?.height ?? height ?? 0;
+ return rect?.width ?? width;
+ }, [height, vertical, width]);
- // 使用 requestAnimationFrame 确保流畅的动画
- rafIdRef.current = requestAnimationFrame(() => {
- const deltaX = e.clientX - startXRef.current;
- const effectiveMaxWidth = getEffectiveMaxWidth();
- const newWidth = Math.max(
- minWidthRef.current,
- Math.min(effectiveMaxWidth, startWidthRef.current + deltaX),
- );
-
- // 直接操作 DOM,避免拖拽时频繁重渲染
- if (sidebarRef.current) {
- sidebarRef.current.style.width = `${newWidth}px`;
- }
-
- // 更新 state(用于同步,但不触发重新渲染)
- setWidth(newWidth);
- onWidthChangeRef.current?.(newWidth);
- });
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ e.preventDefault();
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ dragRef.current = { start: vertical ? e.clientY : e.clientX, startSize: currentSize() };
+ setIsResizing(true);
+ document.body.style.cursor = vertical ? 'row-resize' : 'col-resize';
+ document.body.style.userSelect = 'none';
},
- [getEffectiveMaxWidth],
+ [currentSize, vertical],
);
- const handleMouseUp = useCallback(() => {
- if (!isResizingRef.current) return;
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ // Vertical: the handle is above the sidebar, so dragging up grows it.
+ const delta = vertical ? drag.start - e.clientY : e.clientX - drag.start;
+ applySize(drag.startSize + delta);
+ },
+ [applySize, vertical],
+ );
- isResizingRef.current = false;
+ const endDrag = useCallback(() => {
+ if (!dragRef.current) return;
+ dragRef.current = null;
setIsResizing(false);
-
- // 恢复全局样式
document.body.style.cursor = '';
document.body.style.userSelect = '';
-
- // 恢复过渡动画
- if (sidebarRef.current) {
- sidebarRef.current.style.transition = '';
- }
- if (splitterRef.current) {
- splitterRef.current.style.transition = '';
- }
-
- // 取消 RAF
- if (rafIdRef.current !== null) {
- cancelAnimationFrame(rafIdRef.current);
- rafIdRef.current = null;
- }
}, []);
- const handleMouseDown = useCallback(
- (e: React.MouseEvent) => {
+ useEffect(() => endDrag, [endDrag]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ const grow = vertical ? 'ArrowUp' : 'ArrowRight';
+ const shrink = vertical ? 'ArrowDown' : 'ArrowLeft';
+ if (e.key !== grow && e.key !== shrink) return;
e.preventDefault();
e.stopPropagation();
-
- isResizingRef.current = true;
- setIsResizing(true);
- startXRef.current = e.clientX;
- startWidthRef.current = width;
-
- // 设置全局样式
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
-
- // 移除过渡动画(拖动时不应该有动画)
- if (sidebarRef.current) {
- sidebarRef.current.style.transition = 'none';
- }
- if (splitterRef.current) {
- splitterRef.current.style.transition = 'none';
- }
+ applySize(currentSize() + (e.key === grow ? KEYBOARD_STEP_PX : -KEYBOARD_STEP_PX));
},
- [width],
+ [applySize, currentSize, vertical],
);
- // 管理全局事件监听器
+ // Keep the horizontal width inside the container when the host shrinks.
useEffect(() => {
- if (isResizing) {
- document.addEventListener('mousemove', handleMouseMove, { passive: true });
- document.addEventListener('mouseup', handleMouseUp);
- }
-
- return () => {
- document.removeEventListener('mousemove', handleMouseMove);
- document.removeEventListener('mouseup', handleMouseUp);
- if (rafIdRef.current !== null) {
- cancelAnimationFrame(rafIdRef.current);
- }
-
- // 兜底:如果组件在调整大小时卸载,恢复全局样式
- if (isResizingRef.current) {
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
+ if (vertical) return;
+ const parent = sidebarRef.current?.parentElement;
+ if (!parent || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(() => {
+ setWidth((prev) => clampWidth(prev));
+ });
+ observer.observe(parent);
+ return () => observer.disconnect();
+ }, [clampWidth, vertical]);
+
+ const sidebarStyle: React.CSSProperties = vertical
+ ? {
+ width: '100%',
+ flex: height == null ? `0 0 ${STACKED_DEFAULT_SHARE * 100}%` : `0 0 ${height}px`,
+ minHeight: MIN_STACKED_HEIGHT_PX,
}
- };
- }, [isResizing, handleMouseMove, handleMouseUp]);
-
- useEffect(() => {
- const handleResize = () => {
- const effectiveMaxWidth = getEffectiveMaxWidth();
- if (width > effectiveMaxWidth) {
- setWidth(effectiveMaxWidth);
- if (sidebarRef.current) {
- sidebarRef.current.style.width = `${effectiveMaxWidth}px`;
- }
- onWidthChangeRef.current?.(effectiveMaxWidth);
- }
- };
-
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, [width, getEffectiveMaxWidth]);
+ : {
+ width: `${width}px`,
+ minWidth: `${minWidth}px`,
+ maxWidth: `${maxWidth}px`,
+ transition: isResizing ? 'none' : 'width 0.2s ease-out',
+ };
+
+ const sidebar = (
+
+ {children}
+
+ );
- return (
- <>
+ const splitter = (
+
- {children}
-
-
- >
+ className={vertical ? 'absolute inset-x-0 -top-1.5 h-4' : 'absolute inset-y-0 left-0 w-4'}
+ />
+
);
+
+ // The handle sits between content and sidebar in both layouts. Keyed, so React
+ // moves (not remounts) the sidebar when the orientation flips.
+ return <>{vertical ? [splitter, sidebar] : [sidebar, splitter]}>;
};
diff --git a/src/react/features/viewer/ViewerLayout.tsx b/src/react/features/viewer/ViewerLayout.tsx
index 448c408..9137314 100644
--- a/src/react/features/viewer/ViewerLayout.tsx
+++ b/src/react/features/viewer/ViewerLayout.tsx
@@ -1,4 +1,4 @@
-import React, { Suspense, type ReactNode } from 'react';
+import React, { Suspense, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/ui';
import { ResizableSidebar } from '../../components/Sidebar/ResizableSidebar';
@@ -7,6 +7,7 @@ import { Toaster } from '../../components/ui/toaster';
import { useLeRobotData } from '../../contexts/LeRobotContext';
import { useLoading } from '../../contexts/LoadingContext';
import { ErrorState } from '../../components/Commons/ErrorState';
+import { useStackedLayout } from '../../hooks/useStackedLayout';
const LazyDockviewLayout = React.lazy(async () => {
const mod = await import('../../components/DockviewLayout');
@@ -45,6 +46,8 @@ export function ViewerLayout({
const { t } = useTranslation();
const { episodes, info, clearError, error } = useLeRobotData();
const { tasks } = useLoading();
+ const layoutRef = useRef(null);
+ const stacked = useStackedLayout(layoutRef);
const hasData = info !== null && episodes.length > 0;
const shouldShowSidebar = hasData && showSidebar && Boolean(sidebar);
@@ -60,97 +63,118 @@ export function ViewerLayout({
clearError();
});
- return (
-
-
- {shouldShowSidebar && (
-
- {sidebarHeader}
- {sidebar}
-
- )}
+ const sidebarElement = shouldShowSidebar ? (
+
+ {sidebarHeader}
+ {sidebar}
+
+ ) : null;
-
-
- {info ? (
- <>
- {error ? (
-
- {error}
-
- ) : null}
-
-
-
-
-
{t('common.loadingLayout')}
-
-
{t('common.firstLoadNote')}
-
-
- }
- >
-
-
- >
- ) : activeTask ? (
-
-
-
-
-
- {t(`loading.phase.${activeTask.phase}`)}
-
-
- {activeTask.message || t('common.loading')}
-
-
-
+ const mainElement = (
+
+
+ {info ? (
+ <>
+ {error ? (
+
+ {error}
- ) : errorTask ? (
-
- ) : (
- (emptyState ?? (
+ ) : null}
+
-
-
{t('common.loading')}
+
+
+
+
{t('common.loadingLayout')}
+
+
{t('common.firstLoadNote')}
- ))
- )}
+ }
+ >
+
+
+ >
+ ) : activeTask ? (
+
+
+
+
+
+ {t(`loading.phase.${activeTask.phase}`)}
+
+
+ {activeTask.message || t('common.loading')}
+
+
+
- {showPlaybackBar && hasData &&
}
-
+ ) : errorTask ? (
+
+ ) : (
+ (emptyState ?? (
+
+
+
{t('common.loading')}
+
+
+ ))
+ )}
+
+ {showPlaybackBar && hasData &&
}
+
+ );
+
+ return (
+
+ {/*
+ * Below 640px of container width the episode sidebar stacks under the
+ * player so the video, timeline and transport keep the full width. The
+ * keyed children are reordered (not remounted), so DOM and focus order
+ * always match the visual order.
+ */}
+
+ {stacked ? [mainElement, sidebarElement] : [sidebarElement, mainElement]}
{showToaster ?
: null}
diff --git a/src/react/hooks/useStackedLayout.ts b/src/react/hooks/useStackedLayout.ts
new file mode 100644
index 0000000..8ccbaf9
--- /dev/null
+++ b/src/react/hooks/useStackedLayout.ts
@@ -0,0 +1,32 @@
+import { useEffect, useLayoutEffect, useState, type RefObject } from 'react';
+
+/** Container width (px) below which the episode sidebar stacks under the player. */
+export const STACKED_LAYOUT_MAX_WIDTH_PX = 640;
+
+export function isStackedLayoutWidth(width: number): boolean {
+ return Number.isFinite(width) && width > 0 && width < STACKED_LAYOUT_MAX_WIDTH_PX;
+}
+
+const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
+
+/**
+ * True while the observed container is narrower than 640px. Measured in a
+ * layout effect so the first paint already uses the right layout, then kept in
+ * sync with a ResizeObserver (disconnected on unmount).
+ */
+export function useStackedLayout(ref: RefObject
): boolean {
+ const [stacked, setStacked] = useState(false);
+ useIsomorphicLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ setStacked(isStackedLayoutWidth(el.getBoundingClientRect().width));
+ if (typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (entry) setStacked(isStackedLayoutWidth(entry.contentRect.width));
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [ref]);
+ return stacked;
+}
diff --git a/src/react/locales/en.json b/src/react/locales/en.json
index b5616d5..903cc07 100644
--- a/src/react/locales/en.json
+++ b/src/react/locales/en.json
@@ -435,7 +435,8 @@
"selectEpisodeForBulk": "Select episode",
"deselectEpisode": "Deselect episode",
"exportSubset": "Export uses selected subset when any selected",
- "advanced": "Advanced"
+ "advanced": "Advanced",
+ "resizeHandle": "Resize episode list"
},
"editTask": {
"title": "Edit Episode Task",
diff --git a/src/react/locales/ja.json b/src/react/locales/ja.json
index caed6c5..ab44b7e 100644
--- a/src/react/locales/ja.json
+++ b/src/react/locales/ja.json
@@ -435,7 +435,8 @@
"selectEpisodeForBulk": "エピソードを選択",
"deselectEpisode": "選択を解除",
"exportSubset": "選択がある場合、エクスポートは選択サブセットのみ含みます",
- "advanced": "詳細"
+ "advanced": "詳細",
+ "resizeHandle": "エピソード一覧のサイズを変更"
},
"editTask": {
"title": "エピソードタスクを編集",
diff --git a/src/react/locales/zh.json b/src/react/locales/zh.json
index 92f86b2..6e6073e 100644
--- a/src/react/locales/zh.json
+++ b/src/react/locales/zh.json
@@ -435,7 +435,8 @@
"selectEpisodeForBulk": "勾选 Episode",
"deselectEpisode": "取消勾选 Episode",
"exportSubset": "有选择时导出将仅包含所选子集",
- "advanced": "高级"
+ "advanced": "高级",
+ "resizeHandle": "调整 episode 列表大小"
},
"editTask": {
"title": "编辑 Episode 任务",
diff --git a/src/ui/globals.css b/src/ui/globals.css
index 2dd8799..a54569c 100644
--- a/src/ui/globals.css
+++ b/src/ui/globals.css
@@ -130,4 +130,20 @@
html {
@apply font-sans;
}
+ /*
+ * Embedded root sizing. The package CSS is scoped to descendants of
+ * `.lerobot-root`, so the utility classes on the root element itself never
+ * match. Without this rule the viewer collapses to its content height and
+ * leaves a blank band in the host container. Kept in `base` so host CSS on
+ * `className` still wins.
+ */
+ .lerobot-root {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ background-color: var(--background);
+ color: var(--foreground);
+ }
}
diff --git a/tests/browser/embed-layout.browser.spec.tsx b/tests/browser/embed-layout.browser.spec.tsx
new file mode 100644
index 0000000..97d2f94
--- /dev/null
+++ b/tests/browser/embed-layout.browser.spec.tsx
@@ -0,0 +1,169 @@
+import React, { act, useRef } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import { LeRobotViewer } from '@/components/LeRobotViewer';
+import { useStackedLayout } from '../../src/react/hooks/useStackedLayout';
+import { FetchDataSource } from './fixtures';
+import '../../src/react/index.css';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+function waitFor(predicate: () => boolean, timeoutMs = 30_000): Promise {
+ const startedAt = performance.now();
+ return new Promise((resolve, reject) => {
+ const tick = () => {
+ if (predicate()) resolve();
+ else if (performance.now() - startedAt > timeoutMs) reject(new Error('waitFor timeout'));
+ else requestAnimationFrame(tick);
+ };
+ tick();
+ });
+}
+
+const HOST_HEIGHT = 600;
+
+describe('browser: embedded layout follows the host container', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(async () => {
+ await page.viewport(1_280, 800);
+ host = document.createElement('div');
+ host.style.height = `${HOST_HEIGHT}px`;
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ });
+
+ async function mount(width: number) {
+ host.style.width = `${width}px`;
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ await waitFor(() => host.querySelector('[data-testid="lerobot-viewer-layout"]') !== null);
+ await waitFor(() => host.querySelector('[role="separator"]') !== null);
+ return host.querySelector('[data-testid="lerobot-viewer-layout"]')!;
+ }
+
+ function separator() {
+ return host.querySelector('[aria-label="Resize episode list"]')!;
+ }
+
+ function sidebar() {
+ return host.querySelector('[data-testid="lerobot-resizable-sidebar"]')!;
+ }
+
+ it('fills the host and keeps the sidebar beside the player at 900px', async () => {
+ const layout = await mount(900);
+ await waitFor(() => layout.getAttribute('data-layout') === 'split');
+
+ const rootEl = host.firstElementChild as HTMLElement;
+ const hostBox = host.getBoundingClientRect();
+ const rootBox = rootEl.getBoundingClientRect();
+ expect(Math.round(rootBox.width)).toBe(Math.round(hostBox.width));
+ expect(Math.round(rootBox.height)).toBe(HOST_HEIGHT);
+
+ expect(sidebar().getAttribute('data-orientation')).toBe('horizontal');
+ const sideBox = sidebar().getBoundingClientRect();
+ expect(sideBox.height).toBeGreaterThan(HOST_HEIGHT / 2);
+ expect(separator().getAttribute('aria-label')).toBe('Resize episode list');
+ expect(separator().getAttribute('aria-orientation')).toBe('vertical');
+ }, 60_000);
+
+ it('stacks the sidebar below the player under 640px, in DOM order', async () => {
+ const layout = await mount(500);
+ await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
+
+ const rootEl = host.firstElementChild as HTMLElement;
+ expect(Math.round(rootEl.getBoundingClientRect().height)).toBe(HOST_HEIGHT);
+
+ const side = sidebar();
+ expect(side.getAttribute('data-orientation')).toBe('vertical');
+ const row = side.parentElement!;
+ const children = Array.from(row.children);
+ const main = children[0] as HTMLElement;
+ // Main first, then the handle, then the sidebar: visual and focus order agree.
+ expect(children.indexOf(side)).toBe(children.length - 1);
+ expect(children.indexOf(separator())).toBe(children.length - 2);
+ expect(side.getBoundingClientRect().top).toBeGreaterThanOrEqual(
+ main.getBoundingClientRect().bottom - 1,
+ );
+ expect(Math.round(side.getBoundingClientRect().width)).toBe(500);
+ expect(separator().getAttribute('aria-label')).toBe('Resize episode list');
+ expect(separator().getAttribute('aria-orientation')).toBe('horizontal');
+ }, 60_000);
+
+ it('moves the sidebar without remounting it when the host crosses 640px', async () => {
+ const layout = await mount(900);
+ await waitFor(() => layout.getAttribute('data-layout') === 'split');
+ const before = sidebar();
+ before.setAttribute('data-probe', 'kept');
+
+ host.style.width = '500px';
+ await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
+ expect(sidebar()).toBe(before);
+ expect(sidebar().getAttribute('data-probe')).toBe('kept');
+
+ host.style.width = '900px';
+ await waitFor(() => layout.getAttribute('data-layout') === 'split');
+ expect(sidebar()).toBe(before);
+ }, 60_000);
+
+ it('resizes the stacked sidebar with the keyboard', async () => {
+ const layout = await mount(500);
+ await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
+ const start = sidebar().getBoundingClientRect().height;
+ await act(async () => {
+ separator().focus();
+ separator().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
+ });
+ await waitFor(() => sidebar().getBoundingClientRect().height > start);
+ expect(sidebar().getBoundingClientRect().height).toBeGreaterThan(start);
+ }, 60_000);
+});
+
+describe('browser: useStackedLayout', () => {
+ function Probe() {
+ const ref = useRef(null);
+ const stacked = useStackedLayout(ref);
+ return
;
+ }
+
+ it('measures before paint, follows resizes, and disconnects on unmount', async () => {
+ const observe = vi.spyOn(ResizeObserver.prototype, 'observe');
+ const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect');
+ const host = document.createElement('div');
+ host.style.width = '500px';
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ try {
+ await act(async () => root.render( ));
+ const probe = host.querySelector('[data-layout]')!;
+ expect(probe.getAttribute('data-layout')).toBe('stacked');
+ expect(observe).toHaveBeenCalledWith(probe);
+
+ host.style.width = '800px';
+ await waitFor(() => probe.getAttribute('data-layout') === 'split');
+
+ const before = disconnect.mock.calls.length;
+ await act(async () => root.unmount());
+ expect(disconnect.mock.calls.length).toBe(before + 1);
+ } finally {
+ host.remove();
+ observe.mockRestore();
+ disconnect.mockRestore();
+ }
+ });
+});
diff --git a/tests/embed-root-sizing.spec.ts b/tests/embed-root-sizing.spec.ts
new file mode 100644
index 0000000..d050cfd
--- /dev/null
+++ b/tests/embed-root-sizing.spec.ts
@@ -0,0 +1,42 @@
+import { existsSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { scopeCssForTests } from '../scripts/vite/cssScopePlugin';
+
+const globalsCss = readFileSync(path.resolve(__dirname, '../src/ui/globals.css'), 'utf8');
+const builtCssPath = path.resolve(__dirname, '../dist-lib/lerobot-studio.css');
+
+describe('embedded root sizing contract', () => {
+ it('sizes .lerobot-root itself, since scoped utilities only reach descendants', () => {
+ const out = scopeCssForTests(`
+.lerobot-root { width: 100%; height: 100%; min-height: 0; }
+.h-full { height: 100%; }
+`);
+ // Utilities are scoped to descendants, so `h-full` on the root never matches it...
+ expect(out).toContain('.lerobot-root .h-full');
+ // ...and the root rule must survive scoping unchanged.
+ expect(out).toMatch(/(^|\n)\.lerobot-root\s*\{[^}]*height:\s*100%/);
+ });
+
+ it('keeps the root sizing rule in the package stylesheet', () => {
+ const rule = /\.lerobot-root\s*\{([^}]*)\}/g;
+ const bodies = [...globalsCss.matchAll(rule)].map((m) => m[1]);
+ const sizing = bodies.find((body) => /height:\s*100%/.test(body));
+ expect(sizing).toBeDefined();
+ expect(sizing).toMatch(/width:\s*100%/);
+ expect(sizing).toMatch(/min-height:\s*0/);
+ });
+
+ // Runs after `npm run build:lib`; skipped on a clean checkout.
+ it.skipIf(!existsSync(builtCssPath))(
+ 'ships the root rule and scoped 640px viewer container queries in dist-lib',
+ () => {
+ const css = readFileSync(builtCssPath, 'utf8');
+ expect(css).toMatch(/\.lerobot-root\s*\{[^}]*height:\s*100%/);
+ expect(css).toMatch(
+ /\.lerobot-root \.\\@container\\\/viewer\s*\{\s*container:\s*viewer \/ inline-size/,
+ );
+ expect(css).toMatch(/@container viewer not \(width >= 640px\)\s*\{\s*\.lerobot-root \./);
+ },
+ );
+});
diff --git a/tests/resizable-sidebar.spec.ts b/tests/resizable-sidebar.spec.ts
index 91c02b7..09544cc 100644
--- a/tests/resizable-sidebar.spec.ts
+++ b/tests/resizable-sidebar.spec.ts
@@ -3,14 +3,42 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ResizableSidebar } from '../src/react/components/Sidebar/ResizableSidebar';
+function render(props: React.ComponentProps | null = null) {
+ return renderToStaticMarkup(
+ React.createElement(
+ ResizableSidebar,
+ { handleLabel: 'Resize episode list', ...props },
+ React.createElement('div', null, 'content'),
+ ),
+ );
+}
+
describe('ResizableSidebar', () => {
it('renders a constrained splitter hit area to avoid scrollbar overlap', () => {
- const markup = renderToStaticMarkup(
- React.createElement(ResizableSidebar, null, React.createElement('div', null, 'content')),
- );
+ const markup = render();
expect(markup).toContain('absolute inset-y-0 left-0 w-4');
- expect(markup).toContain('relative z-10 flex w-1');
+ expect(markup).toMatch(/role="separator"[^>]*class="relative z-10 flex[^"]*\bw-1\b/);
expect(markup).not.toContain('left-1/2 -translate-x-1/2 w-8 -mx-4');
+ // Horizontal: sidebar first, then the handle on its right edge.
+ expect(markup.indexOf('lerobot-resizable-sidebar')).toBeLessThan(
+ markup.indexOf('role="separator"'),
+ );
+ });
+
+ it('puts a named, focusable row handle above the sidebar when vertical', () => {
+ const markup = render({ orientation: 'vertical' } as React.ComponentProps<
+ typeof ResizableSidebar
+ >);
+
+ expect(markup).toContain('aria-label="Resize episode list"');
+ expect(markup).toContain('aria-orientation="horizontal"');
+ expect(markup).toContain('tabindex="0"');
+ expect(markup).toContain('cursor-row-resize');
+ expect(markup).toContain('border-t');
+ expect(markup).not.toContain('border-r');
+ expect(markup.indexOf('role="separator"')).toBeLessThan(
+ markup.indexOf('lerobot-resizable-sidebar'),
+ );
});
});
From 0889fd9b3a3ba1f73fe96027db21c4e46ea4ef78 Mon Sep 17 00:00:00 2001
From: joaner <1726541+joaner@users.noreply.github.com>
Date: Sat, 17 Oct 2026 20:54:38 +0900
Subject: [PATCH 2/6] fix(viewer): keep the chosen sidebar width when the host
narrows and widens again
---
scripts/check-bundle-size.mjs | 4 +-
.../components/Sidebar/ResizableSidebar.tsx | 40 ++++++++++++++++---
tests/browser/embed-layout.browser.spec.tsx | 36 ++++++++++++++++-
3 files changed, 72 insertions(+), 8 deletions(-)
diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs
index d2f05d8..176aee4 100644
--- a/scripts/check-bundle-size.mjs
+++ b/scripts/check-bundle-size.mjs
@@ -27,7 +27,9 @@ if (!Number.isFinite(scale) || scale <= 0 || scale > 1) {
// ceilings only move to cover upstream's growth. Heads are ~1-3% (web initial
// JS gzip is the tightest at ~0.2% and will need the next review).
const budgets = {
- libEntry: { raw: 400_000, gzip: 125_000 },
+ // +5 KB raw head for the splitter ARIA values and Space-on-button handling
+ // (measured ~400.1 KB raw, just over the old 400,000-byte ceiling).
+ libEntry: { raw: 405_000, gzip: 125_000 },
libCss: { raw: 360_000, gzip: 110_000 },
webInitialJs: { raw: 2_700_000, gzip: 740_000 },
webInitialCss: { raw: 200_000, gzip: 25_000 },
diff --git a/src/react/components/Sidebar/ResizableSidebar.tsx b/src/react/components/Sidebar/ResizableSidebar.tsx
index 4e261fc..0f8e82c 100644
--- a/src/react/components/Sidebar/ResizableSidebar.tsx
+++ b/src/react/components/Sidebar/ResizableSidebar.tsx
@@ -140,17 +140,42 @@ export const ResizableSidebar: React.FC = ({
[applySize, currentSize, vertical],
);
- // Keep the horizontal width inside the container when the host shrinks.
+ // Track the container box so a narrow host caps the rendered width without
+ // overwriting the width the user chose (it comes back when the host widens),
+ // and so the separator can report its range to assistive technology.
+ const [containerBox, setContainerBox] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
- if (vertical) return;
const parent = sidebarRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') return;
- const observer = new ResizeObserver(() => {
- setWidth((prev) => clampWidth(prev));
+ const observer = new ResizeObserver((entries) => {
+ const rect = entries[0]?.contentRect;
+ if (rect && rect.width) setContainerBox({ width: rect.width, height: rect.height });
});
observer.observe(parent);
return () => observer.disconnect();
- }, [clampWidth, vertical]);
+ }, []);
+
+ const renderedWidth =
+ containerBox == null
+ ? width
+ : Math.max(minWidth, Math.min(width, containerBox.width - MIN_MAIN_CONTENT_PX));
+
+ // ARIA window-splitter values, in px of the sidebar's resizable dimension.
+ const ariaRange = vertical
+ ? {
+ min: MIN_STACKED_HEIGHT_PX,
+ max: Math.max(MIN_STACKED_HEIGHT_PX, (containerBox?.height ?? 0) - MIN_STACKED_MAIN_PX),
+ now: height ?? Math.round(STACKED_DEFAULT_SHARE * (containerBox?.height ?? 0)),
+ }
+ : {
+ min: minWidth,
+ max:
+ containerBox == null
+ ? maxWidth
+ : Math.max(minWidth, Math.min(maxWidth, containerBox.width - MIN_MAIN_CONTENT_PX)),
+ now: renderedWidth,
+ };
+ const ariaNow = Math.round(Math.min(ariaRange.max, Math.max(ariaRange.min, ariaRange.now)));
const sidebarStyle: React.CSSProperties = vertical
? {
@@ -159,7 +184,7 @@ export const ResizableSidebar: React.FC = ({
minHeight: MIN_STACKED_HEIGHT_PX,
}
: {
- width: `${width}px`,
+ width: `${renderedWidth}px`,
minWidth: `${minWidth}px`,
maxWidth: `${maxWidth}px`,
transition: isResizing ? 'none' : 'width 0.2s ease-out',
@@ -187,6 +212,9 @@ export const ResizableSidebar: React.FC = ({
tabIndex={0}
aria-orientation={vertical ? 'horizontal' : 'vertical'}
aria-label={handleLabel}
+ aria-valuemin={Math.round(ariaRange.min)}
+ aria-valuemax={Math.round(ariaRange.max)}
+ aria-valuenow={ariaNow}
className={`relative z-10 flex shrink-0 touch-none items-center justify-center bg-border transition-colors duration-200 hover:bg-primary/30 focus-visible:bg-primary/60 focus-visible:outline-none ${
vertical ? 'h-1 w-full cursor-row-resize' : 'w-1 cursor-col-resize'
} ${isResizing ? 'bg-primary/60' : ''}`}
diff --git a/tests/browser/embed-layout.browser.spec.tsx b/tests/browser/embed-layout.browser.spec.tsx
index 97d2f94..d9ce475 100644
--- a/tests/browser/embed-layout.browser.spec.tsx
+++ b/tests/browser/embed-layout.browser.spec.tsx
@@ -111,7 +111,7 @@ describe('browser: embedded layout follows the host container', () => {
const before = sidebar();
before.setAttribute('data-probe', 'kept');
- host.style.width = '500px';
+ host.style.width = '390px';
await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
expect(sidebar()).toBe(before);
expect(sidebar().getAttribute('data-probe')).toBe('kept');
@@ -121,6 +121,40 @@ describe('browser: embedded layout follows the host container', () => {
expect(sidebar()).toBe(before);
}, 60_000);
+ it('restores the width the user chose after the host narrows and widens again', async () => {
+ const layout = await mount(900);
+ await waitFor(() => layout.getAttribute('data-layout') === 'split');
+ const width = () => Math.round(sidebar().getBoundingClientRect().width);
+ await waitFor(() => width() === 256, 5_000);
+
+ // Choose 400px with the keyboard (256 + 9 x 16), waiting out the width transition per step.
+ for (let step = 1; step <= 9; step++) {
+ await act(async () => {
+ separator().focus();
+ separator().dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
+ );
+ });
+ await waitFor(() => width() === 256 + step * 16, 5_000);
+ }
+ expect(separator().getAttribute('aria-valuenow')).toBe('400');
+ expect(separator().getAttribute('aria-valuemin')).toBe('200');
+ expect(separator().getAttribute('aria-valuemax')).toBe('580');
+
+ // A narrower host caps the rendered width (700 - 320) without forgetting the choice.
+ host.style.width = '700px';
+ await waitFor(() => width() === 380, 5_000);
+ expect(separator().getAttribute('aria-valuenow')).toBe('380');
+
+ // Through the stacked layout and back: the chosen 400px returns.
+ host.style.width = '390px';
+ await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
+ host.style.width = '900px';
+ await waitFor(() => layout.getAttribute('data-layout') === 'split');
+ await waitFor(() => width() === 400, 5_000);
+ expect(separator().getAttribute('aria-valuenow')).toBe('400');
+ }, 60_000);
+
it('resizes the stacked sidebar with the keyboard', async () => {
const layout = await mount(500);
await waitFor(() => layout.getAttribute('data-layout') === 'stacked');
From a37012a26f53ccd6e03df68f8830ce94c9349122 Mon Sep 17 00:00:00 2001
From: joaner <1726541+joaner@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:13:30 +0900
Subject: [PATCH 3/6] fix(navbar): keep the dataset title clear of the controls
on phones
---
scripts/check-bundle-size.mjs | 4 +-
src/react/components/Navbar.tsx | 17 ++---
tests/browser/navbar-title.browser.spec.tsx | 77 +++++++++++++++++++++
3 files changed, 89 insertions(+), 9 deletions(-)
create mode 100644 tests/browser/navbar-title.browser.spec.tsx
diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs
index 176aee4..048d716 100644
--- a/scripts/check-bundle-size.mjs
+++ b/scripts/check-bundle-size.mjs
@@ -31,7 +31,9 @@ const budgets = {
// (measured ~400.1 KB raw, just over the old 400,000-byte ceiling).
libEntry: { raw: 405_000, gzip: 125_000 },
libCss: { raw: 360_000, gzip: 110_000 },
- webInitialJs: { raw: 2_700_000, gzip: 740_000 },
+ // +5 KB gzip head for the embed layout hook, resizable stacked sidebar and
+ // phone navbar (measured 722.7 KiB gzip, level with the old 740,000-byte ceiling).
+ webInitialJs: { raw: 2_700_000, gzip: 745_000 },
webInitialCss: { raw: 200_000, gzip: 25_000 },
libDataLoader: { raw: 280_000, gzip: 60_000 },
webDataLoader: { raw: 40_000, gzip: 10_000 },
diff --git a/src/react/components/Navbar.tsx b/src/react/components/Navbar.tsx
index bd81f0b..4ded480 100644
--- a/src/react/components/Navbar.tsx
+++ b/src/react/components/Navbar.tsx
@@ -108,9 +108,9 @@ export const Navbar: React.FC = ({
return (
-
+
{/* Left: Open & History */}
-
+
{datasetName && onGoHome && (
= ({
{/* Center: Title & Logo */}
@@ -282,6 +282,7 @@ export const Navbar: React.FC
= ({
) : null}
@@ -290,11 +291,11 @@ export const Navbar: React.FC = ({
{/* Right: Controls */}
-
+
{/* View Controls */}
{(onToggleSidebar || onTogglePlaybackBar) && (
<>
-
+
{onToggleSidebar && (
= ({
}
@@ -345,7 +346,7 @@ export const Navbar: React.FC = ({
)}
-
+
>
)}
@@ -356,7 +357,7 @@ export const Navbar: React.FC
= ({
{
+ let host: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ });
+
+ for (const width of [390, 1280]) {
+ it(`never overlaps the controls at ${width}px and keeps the full name as a title`, async () => {
+ await page.viewport(width, 800);
+ await act(async () => {
+ root.render(
+
+
+
+
+
+ undefined}
+ onOpenDirectory={() => undefined}
+ onExport={() => undefined}
+ onOpenHealth={() => undefined}
+ onToggleSidebar={() => undefined}
+ onTogglePlaybackBar={() => undefined}
+ />
+
+
+
+
+ ,
+ );
+ });
+
+ const title = host.querySelector('[data-testid="lerobot-navbar-title"]')!;
+ expect(title).toBeTruthy();
+ expect(title.getAttribute('title')).toBe(LONG_NAME);
+ expect(getComputedStyle(title).textOverflow).toBe('ellipsis');
+ expect(title.scrollWidth).toBeGreaterThan(title.clientWidth);
+
+ const t = title.getBoundingClientRect();
+ const overlapping = [...host.querySelectorAll('nav button, nav a')]
+ .map((el) => ({
+ label: el.getAttribute('aria-label') ?? el.textContent ?? '',
+ r: el.getBoundingClientRect(),
+ }))
+ .filter(({ r }) => r.width > 0 && r.left < t.right - 0.5 && r.right > t.left + 0.5)
+ .map(({ label }) => label);
+ expect(overlapping).toEqual([]);
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
+ });
+ }
+});
From 97c354d279f59465cbe3e6df79e134ef76e5a6fa Mon Sep 17 00:00:00 2001
From: joaner <1726541+joaner@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:36:41 +0900
Subject: [PATCH 4/6] fix(shortcuts): leave Space and arrows to widgets that
own them
---
src/react/hooks/shortcutTarget.ts | 82 +++++++++++++++---
src/react/hooks/useKeyboardShortcuts.ts | 2 +-
tests/browser/shortcut-target.browser.spec.ts | 64 ++++++++++++++
.../space-on-controls.browser.spec.tsx | 84 +++++++++++++++++++
4 files changed, 221 insertions(+), 11 deletions(-)
create mode 100644 tests/browser/shortcut-target.browser.spec.ts
create mode 100644 tests/browser/space-on-controls.browser.spec.tsx
diff --git a/src/react/hooks/shortcutTarget.ts b/src/react/hooks/shortcutTarget.ts
index 1de6835..dc6f1c1 100644
--- a/src/react/hooks/shortcutTarget.ts
+++ b/src/react/hooks/shortcutTarget.ts
@@ -4,21 +4,83 @@ export function isDetachedFocusTarget(target: EventTarget | null): boolean {
return target === document || target === document.body || target === document.documentElement;
}
-/** True when playback shortcuts must not consume the key (typing or a modal). */
-export function isPlaybackShortcutBlocked(target: EventTarget | null): boolean {
+/**
+ * Widgets whose own keyboard model uses Space and the arrow keys (WAI-ARIA
+ * composite roles). While one has focus, playback shortcuts stay out of its way.
+ */
+const KEY_OWNING_ROLES = new Set([
+ 'textbox',
+ 'combobox',
+ 'searchbox',
+ 'tab',
+ 'tablist',
+ 'slider',
+ 'spinbutton',
+ 'radio',
+ 'radiogroup',
+ 'checkbox',
+ 'switch',
+ 'listbox',
+ 'option',
+ 'menu',
+ 'menubar',
+ 'menuitem',
+ 'menuitemcheckbox',
+ 'menuitemradio',
+ 'tree',
+ 'treeitem',
+ 'grid',
+ 'gridcell',
+ 'separator',
+]);
+
+type ElementLike = {
+ nodeType: number;
+ tagName: string;
+ isContentEditable?: boolean;
+ getAttribute(name: string): string | null;
+ closest(selector: string): unknown;
+};
+
+/** Realm-safe element check: a host may render the viewer into another window. */
+function asElement(target: EventTarget | null): ElementLike | null {
+ const node = target as Partial | null;
+ if (!node || node.nodeType !== 1 || typeof node.tagName !== 'string') return null;
+ if (typeof node.getAttribute !== 'function' || typeof node.closest !== 'function') return null;
+ return node as ElementLike;
+}
+
+/** Elements that Space activates natively (a click on key release). */
+const SPACE_ACTIVATED_TAGS = new Set(['BUTTON', 'A', 'SUMMARY']);
+const SPACE_ACTIVATED_ROLES = new Set(['button', 'link']);
+
+/**
+ * True when playback shortcuts must not consume the key: typing, a widget that
+ * owns Space and the arrows, a modal, or Space on a focused button or link
+ * (Space activates the control, as everywhere else on the platform; the
+ * arrows still step playback there).
+ *
+ * @param key `KeyboardEvent.key`; omit to check only the target.
+ */
+export function isPlaybackShortcutBlocked(target: EventTarget | null, key?: string): boolean {
if (isDetachedFocusTarget(target)) return false;
- if (!(target instanceof HTMLElement)) return true;
- if (target.isContentEditable) return true;
+ const el = asElement(target);
+ if (!el) return true;
+ if (el.isContentEditable) return true;
- const tag = target.tagName;
+ const tag = el.tagName.toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
- const role = target.getAttribute('role');
- if (role === 'textbox' || role === 'combobox' || role === 'searchbox') return true;
+ const role = el.getAttribute('role');
+ if (role && KEY_OWNING_ROLES.has(role)) return true;
+ if (
+ key === ' ' &&
+ (SPACE_ACTIVATED_TAGS.has(tag) || (role !== null && SPACE_ACTIVATED_ROLES.has(role)))
+ ) {
+ return true;
+ }
- return Boolean(
- target.closest('[data-slot="dialog-content"], [role="dialog"], [role="alertdialog"]'),
- );
+ return Boolean(el.closest('[data-slot="dialog-content"], [role="dialog"], [role="alertdialog"]'));
}
/** True when the event belongs to this Studio instance (or focus was left on the document). */
diff --git a/src/react/hooks/useKeyboardShortcuts.ts b/src/react/hooks/useKeyboardShortcuts.ts
index e67e7bc..b75a4cf 100644
--- a/src/react/hooks/useKeyboardShortcuts.ts
+++ b/src/react/hooks/useKeyboardShortcuts.ts
@@ -102,7 +102,7 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
const target = e.target;
if (!isEventInsideStudio(target, portalContainer)) return;
- if (isPlaybackShortcutBlocked(target)) return;
+ if (isPlaybackShortcutBlocked(target, e.key)) return;
const preventDefaultKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '];
if (preventDefaultKeys.includes(e.key)) {
diff --git a/tests/browser/shortcut-target.browser.spec.ts b/tests/browser/shortcut-target.browser.spec.ts
new file mode 100644
index 0000000..6e08396
--- /dev/null
+++ b/tests/browser/shortcut-target.browser.spec.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { isPlaybackShortcutBlocked } from '../../src/react/hooks/shortcutTarget';
+
+function el(html: string): HTMLElement {
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ document.body.appendChild(host);
+ return host.firstElementChild as HTMLElement;
+}
+
+describe('isPlaybackShortcutBlocked', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('leaves Space and arrows to playback when focus is on the document or a plain row', () => {
+ expect(isPlaybackShortcutBlocked(document.body)).toBe(false);
+ expect(isPlaybackShortcutBlocked(el('episode 3
'))).toBe(false);
+ });
+
+ it('leaves Space to a focused button or link but lets the arrows step playback', () => {
+ for (const html of [
+ 'Next frame ',
+ 'Docs ',
+ '
',
+ 'More ',
+ ]) {
+ const node = el(html);
+ expect(isPlaybackShortcutBlocked(node, ' '), html).toBe(true);
+ expect(isPlaybackShortcutBlocked(node, 'ArrowRight'), html).toBe(false);
+ }
+ });
+
+ it('blocks text entry and selects', () => {
+ expect(isPlaybackShortcutBlocked(el(' '))).toBe(true);
+ expect(isPlaybackShortcutBlocked(el(''))).toBe(true);
+ expect(isPlaybackShortcutBlocked(el('a '))).toBe(true);
+ expect(isPlaybackShortcutBlocked(el('
'))).toBe(true);
+ });
+
+ it('leaves keys to widgets that own them: tabs, sliders, radios, menus, lists, separators', () => {
+ for (const role of ['tab', 'slider', 'radio', 'menuitem', 'option', 'separator', 'checkbox']) {
+ expect(isPlaybackShortcutBlocked(el(`
`))).toBe(true);
+ }
+ });
+
+ it('blocks keys inside dialogs', () => {
+ const dialog = el('OK
');
+ expect(isPlaybackShortcutBlocked(dialog.querySelector('button'))).toBe(true);
+ });
+
+ it('accepts elements from another realm (iframe) by node type, not instanceof', () => {
+ const frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ const doc = frame.contentDocument!;
+ const row = doc.createElement('div');
+ doc.body.appendChild(row);
+ const tab = doc.createElement('div');
+ tab.setAttribute('role', 'tab');
+ doc.body.appendChild(tab);
+ expect(isPlaybackShortcutBlocked(row)).toBe(false);
+ expect(isPlaybackShortcutBlocked(tab)).toBe(true);
+ });
+});
diff --git a/tests/browser/space-on-controls.browser.spec.tsx b/tests/browser/space-on-controls.browser.spec.tsx
new file mode 100644
index 0000000..0c21023
--- /dev/null
+++ b/tests/browser/space-on-controls.browser.spec.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { expect, it } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import { LeRobotViewer } from '@/components/LeRobotViewer';
+import { FetchDataSource } from './fixtures';
+import '../../src/react/index.css';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = false;
+
+const isPlaying = (button: HTMLElement) => Boolean(button.querySelector('.lucide-pause'));
+
+it('Space activates a focused button once and toggles playback only when no control has focus', async () => {
+ await page.viewport(1280, 900);
+ const host = document.createElement('div');
+ host.style.cssText = 'width:1280px;height:900px';
+ document.body.append(host);
+ const root = createRoot(host);
+ try {
+ root.render(
+ ,
+ );
+ const playButton = () =>
+ host.querySelector('button[aria-label="Play/pause"]')!;
+ const nextButton = () =>
+ host.querySelector('button[aria-label="Next frame"]')!;
+ await expect.poll(() => playButton()?.disabled, { timeout: 60_000 }).toBe(false);
+ await expect.poll(() => nextButton()?.disabled, { timeout: 60_000 }).toBe(false);
+
+ // Loop the (three-frame) episode so playback cannot end on its own mid-test,
+ // and pause the autoplay that starts once the episode loads.
+ const modeButton = host.querySelector(
+ 'button[aria-label="Toggle playback mode"]',
+ )!;
+ modeButton.click(); // sequential -> shuffle
+ await expect.poll(() => modeButton.querySelector('.lucide-shuffle')).not.toBeNull();
+ modeButton.click(); // shuffle -> loop
+ await expect
+ .poll(() => modeButton.querySelector('[class*="repeat-1"], [class*="repeat1"]'))
+ .not.toBeNull();
+ if (isPlaying(playButton())) playButton().click();
+ await expect.poll(() => isPlaying(playButton())).toBe(false);
+ playButton().blur();
+
+ // A focused "Next frame" button: Space clicks it exactly once and never toggles playback.
+ let nextClicks = 0;
+ nextButton().addEventListener('click', () => nextClicks++);
+ nextButton().focus();
+ await userEvent.keyboard(' ');
+ await expect.poll(() => nextClicks).toBe(1);
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(nextClicks).toBe(1);
+ expect(isPlaying(playButton())).toBe(false);
+
+ // A focused Play/pause button: one press is one toggle, not a shortcut plus a click.
+ let playClicks = 0;
+ playButton().addEventListener('click', () => playClicks++);
+ playButton().focus();
+ await userEvent.keyboard(' ');
+ await expect.poll(() => isPlaying(playButton())).toBe(true);
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(playClicks).toBe(1);
+ expect(isPlaying(playButton())).toBe(true);
+ await userEvent.keyboard(' ');
+ await expect.poll(() => isPlaying(playButton())).toBe(false);
+ expect(playClicks).toBe(2);
+
+ // Nothing focused: Space is the play/pause shortcut.
+ playButton().blur();
+ await userEvent.keyboard(' ');
+ await expect.poll(() => isPlaying(playButton())).toBe(true);
+ expect(playClicks).toBe(2);
+ await userEvent.keyboard(' ');
+ await expect.poll(() => isPlaying(playButton())).toBe(false);
+ } finally {
+ root.unmount();
+ host.remove();
+ }
+});
From ceb425cd5352b877115cfe5b7e7a5378e31646dc Mon Sep 17 00:00:00 2001
From: joaner <1726541+joaner@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:25:58 +0900
Subject: [PATCH 5/6] fix(deps): bump source-map-js to 1.2.2
(GHSA-68fv-2mgg-jv7q)
---
package-lock.json | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/package-lock.json b/package-lock.json
index ac155c8..7366b97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7675,7 +7675,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.1",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
From 277c5bb314a667101c398c8219cc2f9b93dfd1e7 Mon Sep 17 00:00:00 2001
From: joaner <1726541+joaner@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:25:59 +0900
Subject: [PATCH 6/6] chore: add changesets for the embed layout, shortcuts and
navbar fixes
The source-map-js bump is dev tooling only and not user-visible, so it has
no changeset (.changeset/README.md: one per user-visible package change).
---
.changeset/embed-fill-host.md | 7 +++++++
.changeset/navbar-phone-title.md | 5 +++++
.changeset/shortcuts-own-keys.md | 5 +++++
3 files changed, 17 insertions(+)
create mode 100644 .changeset/embed-fill-host.md
create mode 100644 .changeset/navbar-phone-title.md
create mode 100644 .changeset/shortcuts-own-keys.md
diff --git a/.changeset/embed-fill-host.md b/.changeset/embed-fill-host.md
new file mode 100644
index 0000000..e8efbca
--- /dev/null
+++ b/.changeset/embed-fill-host.md
@@ -0,0 +1,7 @@
+---
+'@ioai/lerobot-studio': minor
+---
+
+The embedded viewer now fills its host container. The package stylesheet is scoped to descendants of `.lerobot-root`, so the `h-full w-full` utilities on the root element itself never matched and the viewer collapsed to its content height. A base-layer `.lerobot-root` rule now sets `position: relative`, `width` / `height: 100%`, `min-width` / `min-height: 0`, and the theme `background-color` and `color`; host CSS passed through `className` still wins. In a host whose wrapper has a fixed height the viewer now stretches to it.
+
+Below 640px of container width (the `@container/viewer` box, not the window) the episode sidebar moves under the player and stays resizable (drag the handle, or focus it and use the arrow keys; it is named "Resize episode list" and reports its size range through `aria-valuenow`, `aria-valuemin` and `aria-valuemax`). The sidebar follows the player in DOM and focus order, is moved rather than remounted when the container crosses 640px, and keeps the width the user chose. At the same width the playback bar wraps: progress on its own row, transport centred, FPS and version hidden.
diff --git a/.changeset/navbar-phone-title.md b/.changeset/navbar-phone-title.md
new file mode 100644
index 0000000..abe1d5e
--- /dev/null
+++ b/.changeset/navbar-phone-title.md
@@ -0,0 +1,5 @@
+---
+'@ioai/lerobot-studio': patch
+---
+
+The standalone navbar keeps the dataset name clear of the controls on phones: the name truncates with an ellipsis and shows the full name on hover, and the playback-bar toggle and GitHub link hide below 640px of window width.
diff --git a/.changeset/shortcuts-own-keys.md b/.changeset/shortcuts-own-keys.md
new file mode 100644
index 0000000..2844378
--- /dev/null
+++ b/.changeset/shortcuts-own-keys.md
@@ -0,0 +1,5 @@
+---
+'@ioai/lerobot-studio': patch
+---
+
+Playback shortcuts (Space, arrow keys) no longer fire while a widget that owns those keys has focus: tabs, sliders, radios, checkboxes, menus, listboxes and resize handles. A focused button or link keeps Space for its own activation (one press, one click), while the arrow keys still step playback there; with focus on the page or an episode row, Space plays and pauses. Element checks are realm-safe for hosts that render into another window.