From 5a831ba7582bd9b72fff02823f21c091c0191d45 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 27 Oct 2026 21:50:18 +0100 Subject: [PATCH] Flow canvas: second value restarts the edge pulse, dock uses the phone's width, search arrives on an input's label popover - LiveEdge: a value inside a running pulse restarts the animation by hand, since the class is already on and would show nothing. - Dock, FlowDock: the bar is centred on left-1/2, where a shrink-wrapped box gets only the half of the screen to the right and wrapped into a 195px pill. w-max with the viewport cap keeps one row whenever the buttons fit. The dashboard editor's dock had the same squeeze. - FlowEditor: arriving on an input drawn on the canvas centres its label and opens the label popover once the view is fit, as clicking it does. An input the canvas does not draw still opens the flow settings. - Tests: the phone dock may not be confined to half the screen; the search spec reads the popover first and reaches the settings through its pencil. --- frontend/src/components/Common/Dock.tsx | 2 +- frontend/src/components/Flow/FlowDock.tsx | 2 +- frontend/src/components/Flow/FlowEditor.tsx | 63 ++++++++++++++------- frontend/src/components/Flow/LiveEdge.tsx | 24 ++++++-- frontend/tests/mobile.spec.ts | 9 +++ frontend/tests/search.spec.ts | 6 +- 6 files changed, 76 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx index ae2c505..b1b62c0 100644 --- a/frontend/src/components/Common/Dock.tsx +++ b/frontend/src/components/Common/Dock.tsx @@ -43,7 +43,7 @@ export function Dock({ exit="exit" transition={transitions.emphasized} className={cn( - "pointer-events-auto flex 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-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))]", className, )} > diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index c477f67..0d404df 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -121,7 +121,7 @@ export function FlowDock({ // gives the two panels a row to share, instead of one landing on the other.
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index c5547aa..bee7b12 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -553,6 +553,24 @@ function FlowEditorInner({ [navigate, flowName], ) + /** + * Read an input where its label sits: what it is declared as and what it + * holds now. With no label to anchor to it opens the flow settings, where + * inputs are declared. + */ + const inspectInput = useCallback( + (id: string) => { + const declared = (latest.current.inputs ?? []).find( + (one) => `input:${qualify(flowName, one.spec?.name ?? "")}` === id, + ) + 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 } }) + }, + [flowName, openEndpoint], + ) + // React Flow measures a node once and keeps the size on it. An endpoint is // not in `canvasNodes`, so the measurement it reports back has nowhere to // land: without carrying it over by hand the endpoint arrives unmeasured on @@ -741,6 +759,14 @@ function FlowEditorInner({ ]) }, [key, direction, external, updateNodeInternals]) + // An input the canvas draws is read where its label sits, as clicking it + // does, so arriving on one waits for the layout to place the label. + const arrival = + focus?.startsWith("input:") && external.nodes.some((n) => n.id === focus) + ? focus + : null + const arrived = useRef(null) + // The canvas is not remounted per node — that would throw the session away // on every click in the brain graph — so arriving at a flow already open // only changes the address. Seeded above for the first arrival, set here for @@ -753,10 +779,10 @@ function FlowEditorInner({ if (landsOn(focus)) { setFlowPanelOpen(false) setSelectedId(focus) - } else { + } else if (!arrival) { openEndpoint(focus) } - }, [focus, openEndpoint]) + }, [focus, arrival, openEndpoint]) const selected = definitions.find((node) => node.id === selectedId) ?? null // A panel is the view you are working in, so it takes the room — but never @@ -782,7 +808,10 @@ function FlowEditorInner({ // selection on the early returns is what fitted the whole flow instead. // Another flow's label is centred once it has been found on this canvas. const target = selectedId?.startsWith("flow:") ? landing : selectedId - const focus = target && selectedId !== centred.current ? target : null + // A label found by the search is centred, and read once it is there. + const label = arrival && arrival !== arrived.current ? arrival : null + const focus = + label ?? (target && selectedId !== centred.current ? target : null) // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return @@ -792,16 +821,23 @@ function FlowEditorInner({ const frame = requestAnimationFrame(() => { const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW const duration = fitted.current ? 300 : 0 - fitView( + const fit = fitView( focus ? { ...view, nodes: [{ id: focus }], duration } : { ...view, duration }, ) fitted.current = true centred.current = selectedId + if (label) { + arrived.current = label + // The frame lets the canvas draw the view it was just given. + void fit.then(() => requestAnimationFrame(() => inspectInput(label))) + } }) return () => cancelAnimationFrame(frame) }, [ + arrival, + inspectInput, direction, definitions.length, external.nodes.length, @@ -1326,26 +1362,11 @@ function FlowEditorInner({ onNodesDelete={(deleted) => deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } - onNodeClick={(event, node) => { + onNodeClick={(_, node) => { // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. // An input is read where it sits; editing it is one press on. - const declared = node.id.startsWith("input:") - ? (flowDoc.inputs ?? []).find( - (one) => - `input:${qualify(flowName, one.spec?.name ?? "")}` === - node.id, - ) - : undefined - if (declared) { - const { left, top, width, height } = - event.currentTarget.getBoundingClientRect() - setInspectedInput({ - declared, - rect: { left, top, width, height }, - }) - return - } + if (node.id.startsWith("input:")) return inspectInput(node.id) if (!isDocumentNode(node)) { const wire = edges.find( (edge) => edge.source === node.id || edge.target === node.id, diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 527c1ad..798da4f 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -210,6 +210,7 @@ function LiveEdgeComponent({ return at(source, route.from) && at(target, route.to) }) const [pulsing, setPulsing] = useState(false) + const lit = useRef(null) const lastTs = useRef(null) // Owned by the pulse rather than by the effect: a value from another // producer re-runs the effect too, and must not cancel a reset it does not @@ -259,18 +260,29 @@ function LiveEdgeComponent({ // No source at all is an older engine; pulse rather than go silent. if (from && from.id !== producerId) return setPulsing(true) + // A CSS animation starts when its class arrives, and while a pulse is still + // running the class is already on, so a second value within it would show + // nothing. Start the running one over by hand. + for (const animation of lit.current?.getAnimations({ subtree: true }) ?? + []) { + if ((animation as CSSAnimation).animationName === "edge-pulse") { + animation.currentTime = 0 + } + } clearTimeout(timer.current) timer.current = setTimeout(() => setPulsing(false), duration.pulse * 1000) }, [live?.ts, live?.source, live?.prior, producerId]) return ( <> - + + + {live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
{ `${id} runs off the right edge`, ).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) }) test("a dashboard stacks instead of shrinking", async ({ page }) => { diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts index 6d93d39..731dcf5 100644 --- a/frontend/tests/search.spec.ts +++ b/frontend/tests/search.spec.ts @@ -42,7 +42,7 @@ test("finds a node by name and opens its flow on it", async ({ page }) => { await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`)) }) -test("finds a flow input and opens the settings declaring it", async ({ +test("finds a flow input and reads it where its label sits", async ({ page, }) => { await page.goto("/") @@ -54,6 +54,10 @@ test("finds a flow input and opens the settings declaring it", async ({ await expect(page).toHaveURL( new RegExp(`/flows/${flowName}\\?node=input%3A${flowName}\\.findme_input$`), ) + // What clicking the label shows, and the settings one press on. + await expect(page.getByTestId("input-inspector")).toBeVisible() + await expect(page.getByTestId("flow-panel")).toBeHidden() + await page.getByTestId("edit-input").click() await expect(page.getByTestId("flow-panel")).toBeVisible() })