diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx
index 79b11cf..f690d00 100644
--- a/frontend/src/components/Common/Dock.tsx
+++ b/frontend/src/components/Common/Dock.tsx
@@ -56,9 +56,12 @@ export function Dock({
}
/** Groups the dock's buttons; a phone wraps instead. */
-export function DockSeparator() {
+export function DockSeparator({ className }: { className?: string }) {
return (
-
+
)
}
diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index 8b3b731..2aeca40 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -53,6 +53,11 @@ export const FIT_VIEW_PANEL = {
padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const,
}
+/** Hidden in a lane narrower than the whole bar: the phone's cuts. */
+const SHED = "md:@max-[27.5rem]:hidden"
+/** Hidden in a lane narrower than that shorter bar. */
+const SHED_MORE = "md:@max-[21rem]:hidden"
+
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
@@ -64,6 +69,11 @@ export const FIT_VIEW_PANEL = {
* On a phone it is a bar across the bottom edge, which drops the fit control
* and the caption on Run to leave the row room; otherwise it floats and wraps
* rather than overflows. See DESIGN-GUIDELINES.md → Responsive.
+ *
+ * Beside a panel it is measured against the lane it sits in, not the screen.
+ * A lane too narrow for the editing bar (the issues badge and discard showing)
+ * sheds what a phone sheds, then the console. Below that the editor folds the
+ * sidebar away while the panel is open (`CRAMPED` in FlowEditor).
*/
export function FlowDock({
flow,
@@ -120,13 +130,14 @@ export function FlowDock({
// half-pixel centring translate or its backdrop filter inside the other —
// which is what Firefox was drawing the console's text through. It also
// gives the two panels a row to share, instead of one landing on the other.
+ // The column spans the canvas lane, which is what `SHED` measures.
-
+
Add a node (⌘P)
-
+
{/* A phone pinches to zoom and the graph fits itself, so this would only
be taking room the rest of the bar needs. */}
@@ -162,7 +173,10 @@ export function FlowDock({
@@ -270,7 +288,7 @@ export function FlowDock({
-
+
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index 9e5b0ae..aeac0ae 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -51,6 +51,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
+import { useSidebar } from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
@@ -190,6 +191,13 @@ type History = {
const HISTORY_LIMIT = 50
const TYPING_WINDOW = 500
+/**
+ * Where the dock beside a panel is short of room even with all it sheds
+ * (`FlowDock`): 285px of bar and a margin, the 16rem sidebar and the 27rem
+ * panel. The sidebar folds away while a panel is open here.
+ */
+const CRAMPED = "(min-width: 48rem) and (max-width: 61.75rem)"
+
/** Nodes copied here, and the code of the ones carrying their own. */
type NodeClipboard = {
nodes: NodeDef_Input[]
@@ -867,6 +875,22 @@ function FlowEditorInner({
// the lanes the bars sit in: publishing is most wanted right after editing.
const panelOpen = Boolean(selected) || flowPanelOpen
+ const [cramped, setCramped] = useState(
+ () => window.matchMedia(CRAMPED).matches,
+ )
+ useEffect(() => {
+ const query = window.matchMedia(CRAMPED)
+ const onChange = () => setCramped(query.matches)
+ query.addEventListener("change", onChange)
+ return () => query.removeEventListener("change", onChange)
+ }, [])
+ const { holdCollapsed } = useSidebar()
+ useEffect(() => {
+ if (!panelOpen || !cramped) return
+ holdCollapsed(true)
+ return () => holdCollapsed(false)
+ }, [panelOpen, cramped, holdCollapsed])
+
// One effect owns the viewport, so nothing fights over it. Selecting a node
// brings that node into the lane the panel leaves; every other change to the
// graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow
@@ -879,6 +903,19 @@ function FlowEditorInner({
// somewhere the user was already looking, so those stay animated.
const fitted = useRef(false)
const centred = useRef(null)
+ // The sidebar folding or unfolding resizes the canvas under the view, the
+ // hold above included, so the view is aimed again once it has settled.
+ const [laneMoved, setLaneMoved] = useState(0)
+ useEffect(() => {
+ const onEnd = (event: TransitionEvent) => {
+ const slot = (event.target as HTMLElement).dataset?.slot
+ if (slot !== "sidebar-gap" || event.propertyName !== "width") return
+ centred.current = null
+ setLaneMoved((n) => n + 1)
+ }
+ window.addEventListener("transitionend", onEnd)
+ return () => window.removeEventListener("transitionend", onEnd)
+ }, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render.
useEffect(() => {
// Recorded as centred only once the fit below has run: arriving from
@@ -927,6 +964,7 @@ function FlowEditorInner({
editorExpanded,
isMobile,
fitView,
+ laneMoved,
])
const runMutation = useMutation({
diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx
index 58f40e7..f5c3bbf 100644
--- a/frontend/src/components/Flow/LogsPanel.tsx
+++ b/frontend/src/components/Flow/LogsPanel.tsx
@@ -51,14 +51,19 @@ export type LogsFilter = {
export function LogsTrigger({
open,
onOpenChange,
-}: Pick) {
+ className,
+}: Pick & { className?: string }) {
return (
onOpenChange(!open)}
aria-pressed={open}
aria-label="Logs"
diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx
index e7b1d15..69291f5 100644
--- a/frontend/src/components/ui/sidebar.tsx
+++ b/frontend/src/components/ui/sidebar.tsx
@@ -34,6 +34,8 @@ type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
+ /** Folds the sidebar away while held, without touching the saved state. */
+ holdCollapsed: (held: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
@@ -82,10 +84,14 @@ function SidebarProvider({
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(getInitialOpen)
- const open = openProp ?? _open
+ // A screen short of room holds the sidebar folded. Letting go brings back
+ // whatever was saved, and a toggle meanwhile is the user's and wins.
+ const [held, holdCollapsed] = React.useState(false)
+ const open = (openProp ?? _open) && !held
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
+ holdCollapsed(false)
if (setOpenProp) {
setOpenProp(openState)
} else {
@@ -128,6 +134,7 @@ function SidebarProvider({
state,
open,
setOpen,
+ holdCollapsed,
isMobile,
openMobile,
setOpenMobile,