The flow toolbar beside a panel sheds by the lane it sits in: below 440px the fit control, the separators and Run's caption, below 336px the console. Below 988px wide the sidebar folds while a panel is open, without touching the saved preference, and comes back when it closes; the view is aimed again once the sidebar has moved
Docs / docs (push) Successful in 1m33s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m28s
Playwright Tests / test-playwright (2, 2) (push) Successful in 3m9s
pre-commit / pre-commit (push) Successful in 3m36s
Test Backend / test-backend (3.12) (push) Failing after 6m45s
Test Backend / test-backend (3.14) (push) Failing after 5m51s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Successful in 1m23s

This commit is contained in:
2026-11-10 22:54:28 +01:00
parent 53d7545c11
commit b16d83005b
5 changed files with 85 additions and 14 deletions
+5 -2
View File
@@ -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 (
<Separator orientation="vertical" className="mx-0.5 !h-5 hidden md:block" />
<Separator
orientation="vertical"
className={cn("mx-0.5 !h-5 hidden md:block", className)}
/>
)
}
+27 -9
View File
@@ -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.
<div
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 flex flex-col items-center md:inset-x-auto md:bottom-4 md:left-1/2 md:w-max md:max-w-[calc(100vw-2rem)] md:-translate-x-1/2",
"@container pointer-events-none absolute inset-x-0 bottom-0 z-10 flex flex-col items-center md:bottom-4",
className,
)}
>
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3 md:w-max">
<IssuesPanel
open={issuesOpen}
issues={issues}
@@ -153,7 +164,7 @@ export function FlowDock({
<TooltipContent>Add a node (⌘P)</TooltipContent>
</Tooltip>
<DockSeparator />
<DockSeparator className={SHED} />
{/* 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({
<Button
variant="ghost"
size="icon-touch"
className="hidden text-muted-foreground md:inline-flex"
className={cn(
"hidden text-muted-foreground md:inline-flex",
SHED,
)}
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
@@ -174,7 +188,7 @@ export function FlowDock({
{issues.length > 0 ? (
<>
<DockSeparator />
<DockSeparator className={SHED} />
<Button
variant="ghost"
size="sm-touch"
@@ -197,9 +211,13 @@ export function FlowDock({
</>
) : null}
<DockSeparator />
<DockSeparator className={SHED} />
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
<LogsTrigger
open={logs.open}
onOpenChange={logs.onOpenChange}
className={SHED_MORE}
/>
<Tooltip>
<TooltipTrigger asChild>
@@ -261,7 +279,7 @@ export function FlowDock({
) : (
<Play className="size-4" />
)}
<span className="hidden md:inline">Run</span>
<span className={cn("hidden md:inline", SHED)}>Run</span>
</Button>
</span>
</TooltipTrigger>
@@ -270,7 +288,7 @@ export function FlowDock({
</TooltipContent>
</Tooltip>
<DockSeparator />
<DockSeparator className={SHED} />
<Tooltip>
<TooltipTrigger asChild>
@@ -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<string | null>(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({
+7 -2
View File
@@ -51,14 +51,19 @@ export type LogsFilter = {
export function LogsTrigger({
open,
onOpenChange,
}: Pick<LogsFilter, "open" | "onOpenChange">) {
className,
}: Pick<LogsFilter, "open" | "onOpenChange"> & { className?: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className={cn("text-muted-foreground", open && "text-primary")}
className={cn(
"text-muted-foreground",
open && "text-primary",
className,
)}
onClick={() => onOpenChange(!open)}
aria-pressed={open}
aria-label="Logs"
+8 -1
View File
@@ -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,