diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index e81d324..28cacb8 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,6 +5,7 @@ import { FlowCanvas } from './components/FlowCanvas' import { DataInspectionPanel, BookmarkTab, type DockSide } from './components/DataInspectionPanel' import { FlowEditorModal } from './components/FlowEditorModal' import { buildStarterYaml } from './lib/starter-yaml' +import { isMobileViewport } from './lib/mobile' import { useFlowList, useFlowData } from './hooks/useFlowPolling' import { useFlowMutations } from './hooks/useFlowMutations' import type { FlowNode, FlowStep, FlowData, Flow } from './types' @@ -222,13 +223,16 @@ function App() { } }, []) - // Inspector panel state - const [inspectorOpen, setInspectorOpen] = useState(true) + // Inspector panel state. Starts collapsed on mobile-sized viewports + // (Tailwind `md` breakpoint, 768px) so the canvas gets full width on + // phones; on desktop it stays open as before. + const [inspectorOpen, setInspectorOpen] = useState(() => !isMobileViewport()) const [inspectorSide, setInspectorSide] = useState('right') const [inspectorSize, setInspectorSize] = useState(320) // Sidebar (flow tree) collapsed/expanded state. Bookmark tab on the - // left edge of the canvas toggles it. - const [sidebarOpen, setSidebarOpen] = useState(true) + // left edge of the canvas toggles it. Same mobile-default rule as + // the inspector. + const [sidebarOpen, setSidebarOpen] = useState(() => !isMobileViewport()) // Reset inspected step when flow changes useEffect(() => { diff --git a/packages/web/src/AppFragment.tsx b/packages/web/src/AppFragment.tsx index 96238c0..cd9eeb4 100644 --- a/packages/web/src/AppFragment.tsx +++ b/packages/web/src/AppFragment.tsx @@ -8,6 +8,7 @@ import { Sidebar } from './components/Sidebar' import { buildStarterYaml } from './lib/starter-yaml' import { buildShareUrl, decodeFragment, encodeFragment } from './lib/share-url' import { EXAMPLE_FLOWS } from './lib/example-flows' +import { isMobileViewport } from './lib/mobile' import type { FlowListItem } from './hooks/useFlowPolling' import type { FlowNode, FlowStep, FlowData, Flow } from './types' @@ -204,10 +205,12 @@ export default function AppFragment() { } }, []) - const [inspectorOpen, setInspectorOpen] = useState(true) + // Both side panels start collapsed on narrow viewports so the canvas + // gets full width on phones; on desktop they stay open as before. + const [inspectorOpen, setInspectorOpen] = useState(() => !isMobileViewport()) const [inspectorSide, setInspectorSide] = useState('right') const [inspectorSize, setInspectorSize] = useState(320) - const [sidebarOpen, setSidebarOpen] = useState(true) + const [sidebarOpen, setSidebarOpen] = useState(() => !isMobileViewport()) // Match the current hash against each example's encoded form so the // sidebar can highlight the active example. Stable across re-renders diff --git a/packages/web/src/components/FlowCanvas.tsx b/packages/web/src/components/FlowCanvas.tsx index 818e192..062be43 100644 --- a/packages/web/src/components/FlowCanvas.tsx +++ b/packages/web/src/components/FlowCanvas.tsx @@ -180,6 +180,18 @@ function FlowCanvasInner({ } }, [layoutKey, fitToPane]) + // Auto-zoom guard: tracks the last focus key so the same step doesn't + // re-issue setCenter mid-animation (which stutters). + const lastFocusKeyRef = useRef('') + + // Bumped by the ResizeObserver below so the auto-zoom effect re-runs + // after the pane changes width (e.g. user toggles FLOWS / INSPECT). + // Without this, fitToPane() snaps to overview on resize and the + // auto-zoom effect skips re-applying the active-step focus because + // its focusKey hasn't changed — playback would visibly drop back to + // overview until the next step advance. + const [paneResizeTick, setPaneResizeTick] = useState(0) + // Re-fit when the canvas pane resizes (e.g. user toggles sidebar / // inspector via the bookmark tabs). Without this, the diagram visibly // shifts left when the sidebar collapses because the viewport keeps the @@ -187,15 +199,19 @@ function FlowCanvasInner({ useEffect(() => { const pane = containerRef.current?.querySelector('.react-flow') as HTMLElement | null if (!pane || typeof ResizeObserver === 'undefined') return - const observer = new ResizeObserver(() => fitToPane()) + const observer = new ResizeObserver(() => { + fitToPane() + // Force the auto-zoom effect to re-apply: clear the focus guard + // and bump the tick that the effect depends on. Together they + // make resize-triggered fitToPane() not the last word during + // playback. + lastFocusKeyRef.current = '' + setPaneResizeTick((t) => t + 1) + }) observer.observe(pane) return () => observer.disconnect() }, [fitToPane]) - // Auto-zoom guard: tracks the last focus key so the same step doesn't - // re-issue setCenter mid-animation (which stutters). - const lastFocusKeyRef = useRef('') - const pairEdgeMap = useMemo(() => { const map = new Map() for (const edge of baseEdges) { @@ -378,6 +394,10 @@ function FlowCanvasInner({ animState.activeToIds, baseNodes, reactFlow, + // paneResizeTick: re-runs this effect after a sidebar/inspector + // toggle so the camera re-locks onto the active step instead of + // staying at the overview fitToPane() applied. + paneResizeTick, ]) // Report step changes to parent diff --git a/packages/web/src/lib/mobile.ts b/packages/web/src/lib/mobile.ts new file mode 100644 index 0000000..af4ab16 --- /dev/null +++ b/packages/web/src/lib/mobile.ts @@ -0,0 +1,13 @@ +/** + * Mobile-viewport detection for collapse-by-default chrome. + * + * Tailwind's `md` breakpoint is 768px, so we treat anything below + * that as "mobile" — narrow enough that the FLOWS sidebar + INSPECT + * panel can't sit alongside the canvas without crushing it. Used + * once at mount, via `useState(() => !isMobileViewport())`, to set + * the initial open/closed state of both bookmark-tabbed panels. + */ +export function isMobileViewport(): boolean { + if (typeof window === 'undefined') return false + return window.matchMedia('(max-width: 767px)').matches +}