diff --git a/DESIGN-GUIDELINES.md b/DESIGN-GUIDELINES.md
index 06b132a..1fb28f9 100644
--- a/DESIGN-GUIDELINES.md
+++ b/DESIGN-GUIDELINES.md
@@ -160,6 +160,9 @@ Use `md` at 768px as the mobile boundary. Keep `useIsMobile()` and Tailwind's
`overflow-x-auto` region. Use `min-w-0` on flex and grid children when
content could stretch the page. `frontend/tests/mobile.spec.ts` checks this.
2. Cap floating controls with `max-w-[calc(100vw-2rem)]` and allow wrapping.
+ The editors' dock is the exception below `md`: a bar across the full width,
+ flush with the bottom edge (`bg-card`, top border, no shadow, safe-area
+ inset), whose brand button drops its caption so the usual row fits at 44px.
3. Use 44px touch targets below `md`, 32px above. Use `icon-touch`, `sm-touch`
or matching responsive utilities.
4. Turn floating panels into full-screen sheets on phones with the shared
diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx
index b1b62c0..79b11cf 100644
--- a/frontend/src/components/Common/Dock.tsx
+++ b/frontend/src/components/Common/Dock.tsx
@@ -22,11 +22,14 @@ import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
/**
- * The action bar floating at the bottom of an editor canvas, flow or dashboard.
+ * The action bar at the bottom of an editor canvas, flow or dashboard.
*
- * Capped and wrapping: the canvas shell clips, so an uncapped row would put the
- * buttons at its ends out of reach on a phone rather than merely look wrong.
- * See DESIGN-GUIDELINES.md → Responsive.
+ * A phone has no room to float: there it is a bar across the full width, flush
+ * with the bottom edge, with the buttons packed to fit one row. From `md` it is
+ * a pill floating over the canvas, capped and wrapping, since the canvas shell
+ * clips and an uncapped row would put the buttons at its ends out of reach.
+ * The caller places it, with the same split. See DESIGN-GUIDELINES.md →
+ * Responsive.
*/
export function Dock({
className,
@@ -43,7 +46,7 @@ export function Dock({
exit="exit"
transition={transitions.emphasized}
className={cn(
- "pointer-events-auto flex w-max max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]",
+ "pointer-events-auto flex w-full flex-wrap items-center justify-around border-t border-border bg-card pt-1 pb-[max(0.25rem,env(safe-area-inset-bottom))] md:w-max md:max-w-[calc(100vw-2rem)] md:justify-center md:gap-1 md:rounded-full md:border md:bg-card/80 md:px-1.5 md:py-1 md:shadow-e2 md:backdrop-blur-md",
className,
)}
>
diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx
index 983b3e7..8845c1d 100644
--- a/frontend/src/components/Dashboard/DashboardEditor.tsx
+++ b/frontend/src/components/Dashboard/DashboardEditor.tsx
@@ -801,10 +801,11 @@ export function DashboardEditor({
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
{
@@ -835,7 +836,7 @@ export function DashboardEditor({
arranging never mounts the lock, so the controls are live. */}
{edit ? null : }
-
+
{edit ? (
<>
{/* A canvas can be shrunk to less than what is arranged on it,
diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx
index 6c2ec0f..46e58b7 100644
--- a/frontend/src/components/Flow/EdgeInspector.tsx
+++ b/frontend/src/components/Flow/EdgeInspector.tsx
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query"
+import { ViewportPortal } from "@xyflow/react"
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
import { FlowsService } from "@/client"
@@ -37,6 +38,7 @@ export type InspectedEdge = {
to: string
/** The producing node's id, which is what replaying the message goes through. */
sourceId: string
+ /** Where it was clicked, in flow coordinates, so it stays on the edge. */
x: number
y: number
}
@@ -75,12 +77,18 @@ export function EdgeInspector({
return (
!open && onClose()}>
-
+ {/* In the viewport, so the pan and zoom carry it. Re-measured every
+ frame while open, and out of sight once the point is. */}
+
+
+
diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index 0d404df..8b3b731 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -61,8 +61,9 @@ export const FIT_VIEW_PANEL = {
* saved, the flow's own settings, and putting it live or throwing it away —
* so the top of the canvas is left to say which flow this is.
*
- * It wraps rather than overflows, and drops the fit control on a phone; see
- * DESIGN-GUIDELINES.md → Responsive.
+ * 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.
*/
export function FlowDock({
flow,
@@ -121,7 +122,7 @@ export function FlowDock({
// gives the two panels a row to share, instead of one landing on the other.
@@ -178,6 +179,8 @@ export function FlowDock({
variant="ghost"
size="sm-touch"
className={cn(
+ // Narrower on a phone, where the bar is the width of the screen.
+ "max-md:has-[>svg]:px-1.5",
faults.length > 0
? "text-destructive"
: issuesOpen
@@ -246,8 +249,11 @@ export function FlowDock({
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index bee7b12..b2a6e60 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -270,7 +270,7 @@ function FlowEditorInner({
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast, showSuccessToast } = useCustomToast()
- const { fitView } = useReactFlow()
+ const { fitView, screenToFlowPosition } = useReactFlow()
const updateNodeInternals = useUpdateNodeInternals()
const { resolvedTheme } = useTheme()
@@ -565,8 +565,7 @@ function FlowEditorInner({
)
const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
if (!declared || !label) return openEndpoint(id)
- const { left, top, width, height } = label.getBoundingClientRect()
- setInspectedInput({ declared, rect: { left, top, width, height } })
+ setInspectedInput({ declared, label })
},
[flowName, openEndpoint],
)
@@ -748,6 +747,15 @@ function FlowEditorInner({
[renderedNodes, externalNodes, positions],
)
+ // A label that has left the canvas, because its input is gone or a dashboard
+ // now accounts for it, leaves the popover nothing to hang off.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the nodes are what take a label away.
+ useEffect(() => {
+ if (inspectedInput && !inspectedInput.label.isConnected) {
+ setInspectedInput(null)
+ }
+ }, [shownNodes, inspectedInput])
+
// Editing ports adds and removes handles, and flipping direction moves them
// to the other side. React Flow measures those once, so it has to be told,
// or an edge to a brand-new handle never gets drawn.
@@ -1399,8 +1407,7 @@ function FlowEditorInner({
from: label(edge.source),
to: label(edge.target),
sourceId: edge.source,
- x: event.clientX,
- y: event.clientY,
+ ...screenToFlowPosition({ x: event.clientX, y: event.clientY }),
})
}}
onConnect={onConnect}
diff --git a/frontend/src/components/Flow/InputInspector.tsx b/frontend/src/components/Flow/InputInspector.tsx
index 5fc3a20..748ac58 100644
--- a/frontend/src/components/Flow/InputInspector.tsx
+++ b/frontend/src/components/Flow/InputInspector.tsx
@@ -13,8 +13,8 @@ import { ValuePreview } from "./ValuePreview"
export type InspectedInput = {
/** The declared input, as the flow document holds it. */
declared: FlowInput_Input
- /** The label clicked, in viewport pixels, which the popover hangs off. */
- rect: { left: number; top: number; width: number; height: number }
+ /** The label's element: the popover hangs off it wherever the canvas goes. */
+ label: Element
}
/**
@@ -43,13 +43,14 @@ export function InputInspector({
return (
!open && onClose()}>
-
+
+ {/* Re-measured every frame while open, so it rides along with a pan or a
+ zoom, and out of sight once the label is. */}
diff --git a/frontend/tests/mobile.spec.ts b/frontend/tests/mobile.spec.ts
index a1c019b..dbb99c2 100644
--- a/frontend/tests/mobile.spec.ts
+++ b/frontend/tests/mobile.spec.ts
@@ -334,6 +334,25 @@ async function nodeAt(page: Page, id: string) {
return { x, y }
}
+/**
+ * A phone's dock is a bar across the whole width, flush with the bottom edge,
+ * not a pill floating over the canvas. The id is any button in it.
+ */
+async function expectBar(page: Page, id: string) {
+ const bar = await page.getByTestId(id).locator("..").boundingBox()
+ const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
+ expect(bar, "the dock is not on screen").not.toBeNull()
+ expect(bar!.x, "the dock starts off the left edge").toBeCloseTo(0, 0)
+ expect(bar!.width, "the dock is not as wide as the phone").toBeCloseTo(
+ width,
+ 0,
+ )
+ expect(bar!.y + bar!.height, "the dock floats above the bottom").toBeCloseTo(
+ height,
+ 0,
+ )
+}
+
test("the flow editor fits, and its dock is reachable", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
@@ -373,14 +392,7 @@ test("the flow editor fits, and its dock is reachable", async ({ page }) => {
).toBeLessThanOrEqual(width)
}
- // It is centred on `left-1/2`, where a shrink-wrapped box gets only the half
- // of the screen to the right of that, and wraps into a pill as tall as it is
- // wide. Whatever the buttons need, it is never confined to that half.
- const dock = await page.getByTestId("add-node").locator("..").boundingBox()
- expect(
- dock!.width,
- "the dock is squeezed into half the screen",
- ).toBeGreaterThan((page.viewportSize()?.width ?? 0) / 2)
+ await expectBar(page, "add-node")
})
test("a dashboard stacks instead of shrinking", async ({ page }) => {
@@ -395,6 +407,8 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1)
+
+ await expectBar(page, "toggle-edit")
})
/**
diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts
index 731dcf5..db9744d 100644
--- a/frontend/tests/search.spec.ts
+++ b/frontend/tests/search.spec.ts
@@ -61,6 +61,37 @@ test("finds a flow input and reads it where its label sits", async ({
await expect(page.getByTestId("flow-panel")).toBeVisible()
})
+test("the label popover rides along when the canvas is panned", async ({
+ page,
+}) => {
+ const id = `input:${flowName}.findme_input`
+ await page.goto(`/flows/${flowName}?node=${encodeURIComponent(id)}`)
+ const popover = page.getByTestId("input-inspector")
+ const label = page.locator(`.react-flow__node[data-id="${id}"]`)
+ await expect(popover).toBeVisible()
+ // Its own entrance moves it too, which would read as part of the offset.
+ await expect
+ .poll(() => popover.evaluate((el) => el.getAnimations().length))
+ .toBe(0)
+
+ // Where the popover hangs off the label, which a pan must not change.
+ const offset = async () => {
+ const [from, to] = [await label.boundingBox(), await popover.boundingBox()]
+ return [Math.round(to!.x - from!.x), Math.round(to!.y - from!.y)]
+ }
+ const [before, at] = [await offset(), await label.boundingBox()]
+
+ const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
+ await page.mouse.move(width * 0.8, height * 0.2)
+ await page.mouse.down()
+ await page.mouse.move(width * 0.8 - 90, height * 0.2 + 50, { steps: 6 })
+ await page.mouse.up()
+
+ await expect.poll(async () => (await label.boundingBox())!.x).not.toBe(at!.x)
+ await expect(popover).toBeVisible()
+ await expect.poll(offset).toEqual(before)
+})
+
test("opens on the keyboard from anywhere", async ({ page }) => {
await page.goto("/dashboards")
// The binding lives in the sidebar, so wait for it to be there to press at.