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.