From bb90a24b90369f5098211a1f2f3051b667b53271 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 17 Aug 2026 17:35:14 +0200 Subject: [PATCH] Computed flow layout, and mobile written into the design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto --- DESIGN.md | 7 + NOTEPAD.md | 16 +- ROADMAP.md | 9 + backend/app/flow/schemas.py | 15 +- backend/tests/api/routes/test_flows.py | 2 - bun.lock | 5 + frontend/package.json | 1 + frontend/playwright.config.ts | 21 +- frontend/scripts/capture-screenshots.mjs | 78 +++-- frontend/src/client/schemas.gen.ts | 44 +-- frontend/src/client/types.gen.ts | 22 +- .../components/Dashboard/DashboardEditor.tsx | 192 +++++++--- .../components/Dashboard/DashboardView.tsx | 20 +- .../src/components/Dashboard/dashboard.css | 26 +- frontend/src/components/Dashboard/queries.ts | 13 + frontend/src/components/Dashboard/widgets.tsx | 1 + frontend/src/components/Flow/BrainNode.tsx | 9 +- frontend/src/components/Flow/BrainView.tsx | 27 +- frontend/src/components/Flow/EndpointNode.tsx | 11 +- frontend/src/components/Flow/FlowDock.tsx | 120 +++++-- frontend/src/components/Flow/FlowEditor.tsx | 331 +++++++++--------- frontend/src/components/Flow/FlowNode.tsx | 15 +- frontend/src/components/Flow/FlowPanel.tsx | 45 +-- frontend/src/components/Flow/NodePanel.tsx | 4 +- frontend/src/components/Flow/endpoints.ts | 81 +---- frontend/src/components/Flow/layout.ts | 80 +++++ .../src/components/Health/HealthActivity.tsx | 26 +- .../src/components/Health/HealthOverview.tsx | 15 +- frontend/src/components/ui/sidebar.tsx | 5 +- frontend/src/routes/_layout.tsx | 2 +- frontend/src/routes/_layout/index.tsx | 6 +- frontend/src/routes/view.$name.tsx | 26 +- frontend/tests/drafts.spec.ts | 14 +- frontend/tests/endpoints.spec.ts | 36 +- frontend/tests/flows.spec.ts | 5 +- frontend/tests/mobile.spec.ts | 193 ++++++++++ frontend/tests/runtime.spec.ts | 2 - 37 files changed, 998 insertions(+), 527 deletions(-) create mode 100644 frontend/src/components/Flow/layout.ts create mode 100644 frontend/tests/mobile.spec.ts diff --git a/DESIGN.md b/DESIGN.md index 6baf62b..8d3219f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,3 +28,10 @@ translucent `AppSidebar` (`variant="floating"`, `bg-card/80 backdrop-blur-md`). The flow editor floats its chrome over a full-bleed node canvas using the same frosted-surface model. See DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content chips. + +## Mobile + +One breakpoint, `md` (768px), and a phone inspects rather than arranges: no +dragging, no placing, no resizing. The rules that keep it that way — and the +"nothing scrolls horizontally" check that enforces them — are in +DESIGN-GUIDELINES.md → Responsive. diff --git a/NOTEPAD.md b/NOTEPAD.md index 5d794c3..a19f73a 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -13,19 +13,21 @@ should reopen it. ### To be sorted +- BUG/UI dots and rings of nodes in the brain graph are not pixel-perfect centered +- BUG/UI rings of ndoes in the brain graph should be come a bit thinner and edges a bit thicker. Also ensure that trigger animations of nodes are still visible. We could use make the ring pulse instead of the existing effect +- BUG/UI HEALTH section shows 1 flow(s) cannot run but boxes below show e.g. 5/5 flows running +- FEAT/UI we should highlight failing nodes accordingly in the flow view - BUG/UI when a dashboard widget is selectd, the border does not cleanly draw on the left side of the widget -- BUG/UI there is currently no option to discard changes (in dashboard or flow viewport); we should make add a "X" button to discard and replace "Publish" by the already existing checkmark button which is either clickable when there are unpublished changes or unclickable when the changes have been applied (but the icon stays). The discard button should only show when there are changes (hidden else) - INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian - INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure -- BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static (holds true for desktop as well); always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length. This should also remove the ability to drag nodes around; their position is fixed by an algorithm. This design choice is what enforces small atomic flows (different from nodered) 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it - FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that - FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain) - FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms" -- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there) - FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted. -- CHORE/UI the brain's hover labels have no touch equivalent — a tap navigates to the flow, and there is no hover to reveal a name first. The native `title` carries it on desktop only. -- BUG/UI slightly increase the margin between the top of a graph in the node/flow panel and the consumer/producer field - FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound +- CHORE/UI: `layoutGraph` treats every node as 220×56 rather than measuring, because feeding a measurement back into the layout oscillates. A node wider than that crowds its neighbours; take the sizes from `node.measured` once they have settled if it shows. +- FEAT/UI/MOBILE: a rank of many nodes — a connector feeding eight dashboard tiles — is thousands of pixels wide however the graph is turned, so on a phone the fit shrinks it past reading. The layout is right and the flow is simply too big for the screen; a "one rank at a time" reading mode, or wrapping a wide rank, is what would make it legible. +- CHORE/UI: an edge's value chip sits at the bezier midpoint while the layout reserves its room at dagre's label rank. The two agree closely enough today; if chips ever pile up, take the position from the layout instead. - FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s) ### Persistence and databases @@ -163,10 +165,9 @@ as an em dash. - CHORE/UX: dropping a widget also selects it, which opens its panel — which rescales the canvas the instant you let go. Correct, but it lurches; either leave the panel closed on a drag-release or animate the scale. - CHORE/UI: `ROW_HEIGHT` is a fixed 80px while column width follows the canvas, so a 1920-wide panel at 12 columns has 160×80 cells. If that reads too wide, the row height could derive from the canvas too. - FEAT/UI: multi-page and multi-section dashboards have no UI. The backend has `PageDef`/`SectionDef` and rename; the editor only ever edits `sectionsOf(page)[0]`, so nothing can create a second page. -- FEAT/UI: only `layout.lg` is ever written. Below `lg` the view stacks widgets full width in CSS, so `md`/`sm` stay unused until a per-breakpoint editor exists. +- CHORE/UI: only `layout.lg` is ever written, and `md`/`sm` stay unwritten by decision — a phone stacks the widgets (`.widget-stacked`) rather than carrying an arrangement of its own, since arranging is not a phone feature. The keys stay in the schema for a panel that one day wants a second size. - PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer. - CHORE/UI: opening edit mode on a dashboard whose widgets predate placement writes the migrated positions immediately, bumping the version once. -- FEAT/UI: `POST /dashboards/{name}/discard` has no button. The flow settings panel offers "discard draft"; the dashboard settings panel does not, so an unwanted edit can only be undone by hand or by publishing it. - CHORE/API: creating a dashboard publishes it straight away (an empty document goes to the panels), while a new flow starts as a draft. Keeps `read`/`list` free of a never-published case, at the cost of the asymmetry. - PERF/UI: "Publish all" reads each document's detail for the version its publish must match, so a click is 2N requests. A bulk endpoint, or a `version` on the summaries, would make it one. @@ -208,7 +209,6 @@ Open on purpose. Each names what should bring it back. - CHORE/FLOW: shared node sources bypass the draft/publish split. Editing one writes the library copy and reloads immediately, since the code is not any single flow's to hold back. Deliberate, but it means a shared node is the one thing publish does not gate. - CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves to 2.x. - CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks. -- FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a node that sits between the lane and the node it wires to. Readable, but a routed edge would be tidier. - FEAT/UI: the node-panel and edge trend curves take no range, unlike the health block. They are drawn from a Redis ring of the last 120 values per message, which has no window to ask for — a hover caption names what the curve covers instead of a picker promising a span nothing can serve. Reopen if per-message history ever gains a time window. - FEAT/UI: an e-ink rendering profile for a dashboard — motion off, hover-only affordances resolved to something visible, high-contrast palette, thick strokes, and a repaint cadence low enough for a display that takes a second to settle. Reopen when a panel with such a display is actually hung. - CHORE/INFRA: Postgres stays. The 2026-08 review rejected YugabyteDB/CockroachDB (multi-node cluster systems, ~4 GB+ RAM per node, against the small-server target — the scaling story is remote workers, not a distributed DB) and found merging Postgres into Redis or vice versa buys little: the stores hold disjoint data and both sit behind abstractions. SQLite would fit the single-instance design and drop a container; reopen if the home-install footprint becomes a product concern. diff --git a/ROADMAP.md b/ROADMAP.md index 9062c36..c524f25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,6 +175,15 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str errors, with a switch per flow - [x] Logs panel in the canvas dock, pause/resume beside Run, and replaying an edge's last message from the inspector +- [x] The canvas lays itself out — a layered graph, left to right on a desktop + and top to bottom on a phone, with room reserved for the value each edge + carries. Nodes cannot be dragged and a flow document holds no positions: + a graph nobody can arrange is one worth keeping small, which is what + keeps flows atomic +- [x] Usable on a phone, and written down so it stays that way: one breakpoint + (`md`), a stacked dashboard instead of a shrunken wall panel, a dock that + wraps rather than overflows, and a Playwright project that fails the + build when a screen no longer fits. See DESIGN-GUIDELINES.md → Responsive - [ ] Device assignment per node, selectable from compatible devices - [ ] Test-node affordance on the canvas - [ ] User management screens diff --git a/backend/app/flow/schemas.py b/backend/app/flow/schemas.py index 1a96fba..b598726 100644 --- a/backend/app/flow/schemas.py +++ b/backend/app/flow/schemas.py @@ -24,20 +24,17 @@ def _validate_name(value: str) -> str: return value -class Position(BaseModel): - """Where a node sits on the canvas.""" - - x: float = 0 - y: float = 0 - - class NodeDef(BaseModel): - """A node as stored: identity, placement, configuration and ports.""" + """A node as stored: identity, configuration and ports. + + Deliberately no canvas position. The editor lays a flow out itself, so + where a node sits is a fact about the drawing rather than about the flow — + and a graph nobody can arrange is one worth keeping small. + """ id: str type: str = "python" title: str = "" - position: Position = Position() params: dict[str, Any] = Field(default_factory=dict) requires: list[MessageSpec] = Field(default_factory=list) provides: list[MessageSpec] = Field(default_factory=list) diff --git a/backend/tests/api/routes/test_flows.py b/backend/tests/api/routes/test_flows.py index 27fbdc2..ec7d832 100644 --- a/backend/tests/api/routes/test_flows.py +++ b/backend/tests/api/routes/test_flows.py @@ -23,13 +23,11 @@ def a_flow(name: str = "demo") -> dict: { "id": "sensor", "type": "python", - "position": {"x": 0, "y": 0}, "provides": [{"name": "reading", "dtype": "float"}], }, { "id": "logger", "type": "python", - "position": {"x": 240, "y": 0}, "requires": [{"name": "reading", "dtype": "float"}], }, ], diff --git a/bun.lock b/bun.lock index 959774c..7def5be 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^3.1.1", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-avatar": "^1.1.11", @@ -130,6 +131,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], + "@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], diff --git a/frontend/package.json b/frontend/package.json index 84d2a6c..94e2fb1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test:ui": "bunx playwright test --ui" }, "dependencies": { + "@dagrejs/dagre": "^3.1.1", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-avatar": "^1.1.11", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 8618607..bd0ac79 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -42,6 +42,22 @@ export default defineConfig({ ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json', }, + /* The mobile suite is the same app at a phone's width; running it here + too would only re-assert the desktop layout. */ + testIgnore: /mobile\.spec\.ts/, + dependencies: ['setup'], + }, + + /* Every screen at 393px, checking the one thing that is easy to break and + hard to notice: a page that scrolls sideways. See the Responsive section + of the root DESIGN-GUIDELINES.md. */ + { + name: 'mobile', + use: { + ...devices['Pixel 5'], + storageState: 'playwright/.auth/user.json', + }, + testMatch: /mobile\.spec\.ts/, dependencies: ['setup'], }, @@ -63,11 +79,6 @@ export default defineConfig({ // dependencies: ['setup'], // }, - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index c5337fc..7b08326 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -25,11 +25,23 @@ if (!EMAIL || !PASSWORD) { process.exit(1) } +/** + * The two shapes the app is drawn for: a desktop, and a phone. Below `md` it + * is a different layout rather than a narrower one — see the Responsive + * section of the root DESIGN-GUIDELINES.md — so it wants its own shots. + */ +const VIEWPORTS = [ + { name: "", viewport: { width: 1440, height: 900 } }, + { name: "mobile", viewport: { width: 390, height: 844 } }, +] + /** Force the theme through the same storage key the pre-paint script reads. */ -async function withTheme(browser, theme) { +async function withTheme(browser, theme, viewport) { const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, + viewport, colorScheme: theme, + isMobile: viewport.width < 768, + hasTouch: viewport.width < 768, }) await context.addInitScript((t) => { localStorage.setItem("fluksio-ui-theme", t) @@ -46,41 +58,43 @@ const browser = await chromium.launch( ) for (const theme of ["light", "dark"]) { - const dir = `${OUT}/${theme}` - await mkdir(dir, { recursive: true }) - const context = await withTheme(browser, theme) - const page = await context.newPage() + for (const { name, viewport } of VIEWPORTS) { + const dir = name ? `${OUT}/${theme}/${name}` : `${OUT}/${theme}` + await mkdir(dir, { recursive: true }) + const context = await withTheme(browser, theme, viewport) + const page = await context.newPage() - await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" }) - // networkidle fires before the staggered entrance animations settle, which - // would capture buttons mid-fade and make contrast look broken. - await page.waitForTimeout(1500) - await page.screenshot({ path: `${dir}/website-hero.png` }) + await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" }) + // networkidle fires before the staggered entrance animations settle, which + // would capture buttons mid-fade and make contrast look broken. + await page.waitForTimeout(1500) + await page.screenshot({ path: `${dir}/website-hero.png` }) - await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) - await page.screenshot({ path: `${dir}/app-login.png` }) + await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) + await page.screenshot({ path: `${dir}/app-login.png` }) - await page.getByTestId("email-input").fill(EMAIL) - await page.getByTestId("password-input").fill(PASSWORD) - await page.getByRole("button", { name: /log in/i }).click() - await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) - await page.waitForLoadState("networkidle") - // Home's sections fetch independently, so networkidle can fall between them - // and photograph the skeletons. The flow table is the last of them to land. - await page - .getByText(/Flow activity/i) - .first() - .waitFor({ timeout: 15000 }) - await page.waitForTimeout(1500) - await page.screenshot({ path: `${dir}/app-dashboard.png` }) + await page.getByTestId("email-input").fill(EMAIL) + await page.getByTestId("password-input").fill(PASSWORD) + await page.getByRole("button", { name: /log in/i }).click() + await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) + await page.waitForLoadState("networkidle") + // Home's sections fetch independently, so networkidle can fall between them + // and photograph the skeletons. The flow table is the last of them to land. + await page + .getByText(/Flow activity/i) + .first() + .waitFor({ timeout: 15000 }) + await page.waitForTimeout(1500) + await page.screenshot({ path: `${dir}/app-dashboard.png` }) - await captureFlows(page, dir) - await captureDashboards(page, dir) + await captureFlows(page, dir) + await captureDashboards(page, dir) - await context.close() - console.log( - ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`, - ) + await context.close() + console.log( + ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`, + ) + } } await browser.close() diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 04c9633..287afac 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1306,13 +1306,6 @@ export const NodeDef_InputSchema = { title: 'Title', default: '' }, - position: { - '$ref': '#/components/schemas/Position', - default: { - x: 0, - y: 0 - } - }, params: { additionalProperties: true, type: 'object', @@ -1360,7 +1353,11 @@ export const NodeDef_InputSchema = { type: 'object', required: ['id'], title: 'NodeDef', - description: 'A node as stored: identity, placement, configuration and ports.' + description: `A node as stored: identity, configuration and ports. + +Deliberately no canvas position. The editor lays a flow out itself, so +where a node sits is a fact about the drawing rather than about the flow — +and a graph nobody can arrange is one worth keeping small.` } as const; export const NodeDef_OutputSchema = { @@ -1379,13 +1376,6 @@ export const NodeDef_OutputSchema = { title: 'Title', default: '' }, - position: { - '$ref': '#/components/schemas/Position', - default: { - x: 0, - y: 0 - } - }, params: { additionalProperties: true, type: 'object', @@ -1433,7 +1423,11 @@ export const NodeDef_OutputSchema = { type: 'object', required: ['id'], title: 'NodeDef', - description: 'A node as stored: identity, placement, configuration and ports.' + description: `A node as stored: identity, configuration and ports. + +Deliberately no canvas position. The editor lays a flow out itself, so +where a node sits is a fact about the drawing rather than about the flow — +and a graph nobody can arrange is one worth keeping small.` } as const; export const NodeSourceSchema = { @@ -1782,24 +1776,6 @@ export const PlacementSchema = { description: "Where a widget sits in its section's grid, in grid units." } as const; -export const PositionSchema = { - properties: { - x: { - type: 'number', - title: 'X', - default: 0 - }, - y: { - type: 'number', - title: 'Y', - default: 0 - } - }, - type: 'object', - title: 'Position', - description: 'Where a node sits on the canvas.' -} as const; - export const PrivateUserCreateSchema = { properties: { email: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 1a6b205..7c421d8 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -447,13 +447,16 @@ export type NewPassword = { }; /** - * A node as stored: identity, placement, configuration and ports. + * A node as stored: identity, configuration and ports. + * + * Deliberately no canvas position. The editor lays a flow out itself, so + * where a node sits is a fact about the drawing rather than about the flow — + * and a graph nobody can arrange is one worth keeping small. */ export type NodeDef_Input = { id: string; type?: string; title?: string; - position?: Position; params?: { [key: string]: unknown; }; @@ -467,13 +470,16 @@ export type NodeDef_Input = { }; /** - * A node as stored: identity, placement, configuration and ports. + * A node as stored: identity, configuration and ports. + * + * Deliberately no canvas position. The editor lays a flow out itself, so + * where a node sits is a fact about the drawing rather than about the flow — + * and a graph nobody can arrange is one worth keeping small. */ export type NodeDef_Output = { id: string; type?: string; title?: string; - position?: Position; params?: { [key: string]: unknown; }; @@ -585,14 +591,6 @@ export type Placement = { h?: number; }; -/** - * Where a node sits on the canvas. - */ -export type Position = { - x?: number; - y?: number; -}; - export type PrivateUserCreate = { email: string; password: string; diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 26abd35..e39a59c 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -7,6 +7,7 @@ import { Pencil, Plus, Settings2, + X, } from "lucide-react" import { motion } from "motion/react" import { type CSSProperties, useEffect, useRef, useState } from "react" @@ -27,6 +28,14 @@ import { } from "@/client" import { CanvasTitle } from "@/components/Flow/CanvasTitle" import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Popover, PopoverContent, @@ -40,6 +49,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" +import { useIsMobile } from "@/hooks/useMobile" import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" import { handleError } from "@/utils" @@ -59,7 +69,12 @@ import { widgetsOf, } from "./DashboardView" import { DashboardPanel, WidgetPanel } from "./panels" -import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries" +import { + dashboardKeys, + useDiscardDashboardDraft, + usePublishDashboard, + useSaveDashboard, +} from "./queries" import { WIDGET_LABELS, WIDGET_SIZES, @@ -167,7 +182,10 @@ const same = (a: Layout, b: Layout) => * A dashboard, viewed or edited, over the same dotted canvas the flows use. * * View mode never mounts the grid library: a wall panel that only displays - * should not pay for the code that lets someone drag things around. + * should not pay for the code that lets someone drag things around. Nor does a + * phone — arranging is not a phone feature, and `applyLayout` below writes + * whatever the grid reports, so a stacked layout reaching it would overwrite + * the arrangement the panel is meant to show. */ export function DashboardEditor({ dashboard, @@ -182,10 +200,15 @@ export function DashboardEditor({ const [pageId, setPageId] = useState( () => pagesOf(dashboard)[0]?.id, ) + // A phone reads the dashboard rather than arranges it, so the grid library + // never mounts there. See DESIGN-GUIDELINES.md → Responsive. + const stacked = useIsMobile() const navigate = useNavigate() const queryClient = useQueryClient() const save = useSaveDashboard(dashboard.name) const publish = usePublishDashboard(dashboard.name) + const discard = useDiscardDashboardDraft(dashboard.name) + const [discardOpen, setDiscardOpen] = useState(false) const { showErrorToast } = useCustomToast() const timer = useRef | null>(null) // The save that is already on its way, so a publish waits for it instead of @@ -338,6 +361,27 @@ export function DashboardEditor({ }) } + /** A widget that can be picked to open its settings. */ + const pickable = (widget: WidgetDef, grip = false) => ( + { + if (!(event.target as Element).closest(INTERACTIVE)) { + setSettingsOpen(false) + setSelected(widget.id) + } + }} + > + + + ) + const body = !page ? (

This dashboard has no pages yet. @@ -346,6 +390,17 @@ export function DashboardEditor({

Nothing on this page yet. Add a widget from the bar below.

+ ) : stacked ? ( + // One column at the viewport's width. Edit mode still picks a widget and + // opens its settings; only the arrangement is missing. +
+ +
) : ( {(scale) => @@ -372,25 +427,7 @@ export function DashboardEditor({ resizeConfig={{ handles: ["e", "s", "se"] }} > {widgets.map((widget) => ( -
- { - if (!(event.target as Element).closest(INTERACTIVE)) { - setSettingsOpen(false) - setSelected(widget.id) - } - }} - > - - -
+
{pickable(widget, true)}
))} ) @@ -439,7 +476,9 @@ export function DashboardEditor({ animate="visible" exit="exit" transition={transitions.emphasized} - className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-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))]" + // Capped and wrapping, like the flow dock; see DESIGN-GUIDELINES.md + // → Responsive. + className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 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))]" > {edit ? ( <> @@ -494,39 +533,66 @@ export function DashboardEditor({ Dashboard settings + {/* Only while there is something to throw away. */} + {hasDraft ? ( + + + + + + Discard the unpublished changes + + + ) : null} + + {/* Saved state and publish are one control, as in the flow dock: + the glyph stays put and simply stops being pressable. */} - - {save.isPending ? ( - - ) : ( - - )} + + - {save.isPending - ? "Saving" - : hasDraft - ? "Saved — publish to put it on the panels" - : "All changes saved"} + {publish.isPending + ? "Publishing" + : save.isPending + ? "Saving" + : hasDraft + ? "Saved — publish to put it on the panels" + : "All changes saved"} - {hasDraft ? ( - - ) : null} - - + ) : null} @@ -590,6 +656,38 @@ export function DashboardEditor({ onDelete={() => remove.mutate()} onClose={() => setSettingsOpen(false)} /> + + + + + Discard the unpublished changes? + + The dashboard goes back to what the panels are showing. What + you edited since is dropped. + + + + + + + + ) : null} diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 2dc62fb..f817db2 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -169,6 +169,7 @@ export function SectionGrid({ dashboard, columns = DEFAULT_COLUMNS, renderWidget, + stacked, className, }: { section: SectionDef_Output @@ -176,9 +177,20 @@ export function SectionGrid({ dashboard: string columns?: number renderWidget?: (widget: WidgetDef) => React.ReactNode + /** One column at the viewport's width, for a phone. */ + stacked?: boolean className?: string }) { - const widgets = widgetsOf(section) + const all = widgetsOf(section) + // Stacked, the arrangement becomes a reading order, so it follows the rows + // the panel shows rather than the order widgets happened to be added in. + const widgets = stacked + ? [...all].sort((a, b) => { + const left = placement(a) + const right = placement(b) + return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0) + }) + : all return (
{section.title ? ( @@ -187,7 +199,7 @@ export function SectionGrid({ ) : null}
@@ -215,10 +227,13 @@ export function DashboardView({ dashboard, pageId, renderWidget, + stacked, }: { dashboard: Dashboard pageId?: string renderWidget?: (widget: WidgetDef) => React.ReactNode + /** One column at the viewport's width, for a phone. */ + stacked?: boolean }) { const pages = pagesOf(dashboard) const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] @@ -254,6 +269,7 @@ export function DashboardView({ dashboard={dashboard.name} columns={columnsOf(dashboard)} renderWidget={renderWidget} + stacked={stacked} /> ))}
diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index 00a53ad..1e73314 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -22,9 +22,10 @@ /* * View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall - * panel can be matched to its own width. There is no responsive fallback: the - * grid lives on a canvas of the panel's own pixel size, which is scaled to the - * viewport rather than reflowed into it. + * panel can be matched to its own width. It does not reflow: the grid lives on + * a canvas of the panel's own pixel size, which is scaled to the viewport. + * A phone gets `.widget-stacked` below instead, which is a different surface + * rather than a narrower version of this one. */ .widget-grid { display: grid; @@ -46,6 +47,25 @@ grid-row: var(--y) / span var(--h); } +/* + * One column, in reading order, at the viewport's own width — what a phone + * gets instead of a wall panel shrunk to 18%. Arranging is not a phone + * feature, so the stored x/w are dropped and only the height a widget asked + * for survives: a chart still needs its room, a stat still does not. + * See DESIGN-GUIDELINES.md → Responsive. + */ +.widget-grid.widget-stacked, +.widget-grid.widget-stacked[data-placed] { + display: flex; + flex-direction: column; +} + +.widget-grid.widget-stacked .widget-cell { + grid-column: auto; + grid-row: auto; + height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem); +} + /* * uPlot, routed through the tokens. Its own legend is the hover readout as * well — the value each line carried at the cursor — so it is styled as chart diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index a37fe4d..82950b3 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -75,6 +75,19 @@ export function usePublishDashboard(name: string) { }) } +/** Throw the unpublished edit away; the panels keep showing what they had. */ +export function useDiscardDashboardDraft(name: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => DashboardsService.discardDashboardDraft({ name }), + onSuccess: (published) => { + queryClient.setQueryData(dashboardKeys.detail(name, true), published) + queryClient.setQueryData(dashboardKeys.detail(name), published) + queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) + }, + }) +} + /** What an input widget does: put a value into the graph. * * The widget names itself so the flow canvas can show the value arriving from diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index f372389..12fa97a 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -201,6 +201,7 @@ export function WidgetFrame({ // biome-ignore lint/a11y/useKeyWithClickEvents: see above. // biome-ignore lint/a11y/noStaticElementInteractions: see above.
+ {label} diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx index 9081dba..2d236b1 100644 --- a/frontend/src/components/Flow/BrainView.tsx +++ b/frontend/src/components/Flow/BrainView.tsx @@ -20,9 +20,10 @@ import { type SimulationNodeDatum, } from "d3-force" import { motion } from "motion/react" -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, useState } from "react" import type { BrainGraph } from "@/client" +import { useIsMobile } from "@/hooks/useMobile" import { scaleIn } from "@/lib/motion" import { BrainEdge, type BrainEdgeData } from "./BrainEdge" import { BrainNode, type BrainNodeData } from "./BrainNode" @@ -162,6 +163,23 @@ function BrainCanvas() { [data], ) + // A name is revealed on hover, which a finger does not have. On a phone the + // first tap says which neuron this is and the second follows it — kept out + // of the layout memo so revealing one does not re-run the simulation. + const isMobile = useIsMobile() + const [revealed, setRevealed] = useState(null) + const shown = useMemo( + () => + revealed + ? nodes.map((node) => + node.id === revealed + ? { ...node, data: { ...node.data, revealed: true } } + : node, + ) + : nodes, + [nodes, revealed], + ) + // A rebuild lays the whole graph out afresh, so the viewport someone was // looking through no longer frames anything. Only when the set of neurons // actually changed: a value arriving must not move the canvas. @@ -186,7 +204,7 @@ function BrainCanvas() { className="h-full w-full" > { + if (isMobile && revealed !== node.id) { + setRevealed(node.id) + return + } const [flow] = (node.data as BrainNodeData).flows if (flow) navigate({ to: "/flows/$flowName", params: { flowName: flow } }) }} + onPaneClick={() => setRevealed(null)} className="brain-flat h-full w-full" > {/* The same dot grid the editor's canvas uses, quieter and masked back diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx index 6ab7247..81d945e 100644 --- a/frontend/src/components/Flow/EndpointNode.tsx +++ b/frontend/src/components/Flow/EndpointNode.tsx @@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react" import { LayoutDashboard, Workflow } from "lucide-react" import { memo } from "react" +import { useIsMobile } from "@/hooks/useMobile" import { cn } from "@/lib/utils" import type { EndpointNodeData } from "./endpoints" @@ -22,15 +23,17 @@ function EndpointNodeComponent({ data, selected }: NodeProps) { const { label, kind, detail, provides, requires } = data as EndpointNodeData const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow const messages = [...provides, ...requires] + // Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive. + const vertical = useIsMobile() return (
))} @@ -50,7 +53,7 @@ function EndpointNodeComponent({ data, selected }: NodeProps) { key={`out-${message}`} type="source" id={message} - position={Position.Right} + position={vertical ? Position.Bottom : Position.Right} className="!border-border !bg-card" /> ))} diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index 3f72d90..f352dad 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -11,6 +11,7 @@ import { Plus, StepForward, WifiOff, + X, ZoomIn, ZoomOut, } from "lucide-react" @@ -47,8 +48,11 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 } * affordance on this view; everything else stays quiet. * * Everything the flow bar used to carry is here too — whether the work is - * saved, the flow's own settings, and putting it live — so the top of the - * canvas is left to say which flow this is. + * 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 zoom controls on a phone; see + * DESIGN-GUIDELINES.md → Responsive. */ export function FlowDock({ flow, @@ -68,6 +72,7 @@ export function FlowDock({ onFocusNode, onEditFlow, onPublish, + onDiscard, }: { flow: string issues: ValidationIssue[] @@ -87,6 +92,7 @@ export function FlowDock({ onFocusNode: (nodeId: string) => void onEditFlow: () => void onPublish: () => void + onDiscard: () => void }) { const { zoomIn, zoomOut, fitView } = useReactFlow() const connected = useLiveConnection() @@ -98,7 +104,10 @@ export function FlowDock({ animate="visible" exit="exit" transition={transitions.emphasized} - className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-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))]" + // 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. + className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 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))]" > @@ -116,12 +125,17 @@ export function FlowDock({ Add a node (⌘K) - + + {/* A phone pinches to zoom and the graph fits itself, so these three + would only be taking room the rest of the bar needs. */} + + Discard the unpublished changes + + ) : null} + + {/* + * Saved state and publish are one control: the glyph never moves, it + * simply stops being something you can press once there is nothing left + * to put live. A button that appears and disappears moved everything + * beside it just as the work was finished. + */} - - {!connected ? ( - - ) : saving ? ( - - ) : ( - - )} + + {!connected ? "Reconnecting to the engine" - : saving - ? "Saving" - : hasDraft - ? "Saved — publish to put it live" - : "All changes saved"} + : publishing + ? "Publishing" + : saving + ? "Saving" + : hasDraft + ? "Saved — publish to put it live" + : "All changes saved"} - - {hasDraft ? ( - - ) : null} ) } diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index cfe0fef..454550a 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -39,6 +39,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import useCustomToast from "@/hooks/useCustomToast" +import { useIsMobile } from "@/hooks/useMobile" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" @@ -46,17 +47,12 @@ import { CommandPalette } from "./CommandPalette" import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" -import { - deriveEndpoints, - ENDPOINT_TYPE, - isEndpointNode, - placementsFor, - rememberPlacement, -} from "./endpoints" +import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" import { FIT_VIEW, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" +import { type Direction, layoutGraph } from "./layout" import { NodePanel } from "./NodePanel" import "./flow.css" import { liveStore, useFlowPaused } from "./liveStore" @@ -119,8 +115,8 @@ const CLIPBOARD_KEY = "fluksio.nodeClipboard" * Remember the document as it was before a change. * * Fields commit on every keystroke, so consecutive edits that leave the same - * nodes in place fold into the entry already on the stack. Anything carrying - * positions — a drag, a new node — is a finished action and starts its own. + * nodes in place fold into the entry already on the stack. Anything that adds + * or removes a node is a finished action and starts its own. */ function record( history: History, @@ -145,11 +141,12 @@ function record( history.future = [] } +/** Positions come from the layout, so xyflow's own state only tracks identity. */ function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] { return definitions.map((node) => ({ id: node.id, type: "flow", - position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 }, + position: { x: 0, y: 0 }, data: {}, })) } @@ -174,26 +171,6 @@ function CanvasBackground() { ) } -/** Step a new node off any node already sitting at that spot. */ -function freePosition( - nodes: NodeDef_Input[], - start: { x: number; y: number }, -): { x: number; y: number } { - const position = { ...start } - // Roughly a node's footprint, so a nudged node clears the one below it. - const occupied = () => - nodes.some( - (node) => - Math.abs((node.position?.x ?? 0) - position.x) < 220 && - Math.abs((node.position?.y ?? 0) - position.y) < 80, - ) - while (occupied()) { - position.x += 48 - position.y += 96 - } - return position -} - /** A name that does not collide with the nodes already on the canvas. */ function uniqueNodeId(existing: NodeDef_Input[], type: string): string { const taken = new Set(existing.map((node) => node.id)) @@ -213,7 +190,7 @@ function FlowEditorInner({ const navigate = useNavigate() const queryClient = useQueryClient() const { showErrorToast, showSuccessToast } = useCustomToast() - const { screenToFlowPosition, fitView } = useReactFlow() + const { fitView } = useReactFlow() const updateNodeInternals = useUpdateNodeInternals() const { data: flows } = useSuspenseQuery(flowsQueryOptions()) @@ -243,6 +220,9 @@ function FlowEditorInner({ const [rebind, setRebind] = useState(null) const [renamed, setRenamed] = useState(null) const [flowPanelOpen, setFlowPanelOpen] = useState(false) + // Throwing an edit away is offered from the dock, so its confirmation lives + // here rather than inside the settings panel. + const [discardOpen, setDiscardOpen] = useState(false) // The editor at full size takes the width the canvas chrome does not need. const [editorExpanded, setEditorExpanded] = useState(false) // The dock hosts the logs, but a failing node opens them too, at its own @@ -278,14 +258,10 @@ function FlowEditorInner({ /** The same, for the changes that only touch the nodes. */ const commit = useCallback( - (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => { - const placed = nodes.map((node) => { - const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id) - return canvas ? { ...node, position: canvas.position } : node - }) - commitDoc({ ...latest.current, nodes: placed }, Boolean(positions)) + (nodes: NodeDef_Input[]) => { + commitDoc({ ...latest.current, nodes }) }, - [canvasNodes, commitDoc], + [commitDoc], ) /** @@ -383,8 +359,8 @@ function FlowEditorInner({ ], ) - // A cheap fingerprint of the wiring: it changes when a name does, but not - // when a node merely moves. + // A cheap fingerprint of the wiring, and the only thing the layout depends + // on: what the graph looks like follows from what is wired to what. const key = bindingsKey(definitions) // Offer the names already in play: everything published is worth reading, @@ -407,31 +383,6 @@ function FlowEditorInner({ } }, [key]) - // Endpoints are movable but are not the flow's to store, so where they were - // put lives in the browser rather than in flow.json. - const [moved, setMoved] = useState>( - () => placementsFor(flowName), - ) - - // React Flow measures a node once and keeps the size on it. Endpoints are - // rebuilt on every drag frame, so unless the measurement is carried over - // they arrive unmeasured and React Flow drops the edges attached to them - // until it has measured again — remounting those edges, which makes them - // pulse as if a value had just landed. Their own drag lit up the canvas. - const measured = useRef(new Map()) - const trackMeasured = useCallback( - (changes: NodeChange[]) => { - for (const change of changes) { - if (change.type !== "dimensions" || !change.dimensions) continue - if (isEndpointNode({ id: change.id })) { - measured.current.set(change.id, change.dimensions) - } - } - onNodesChange(changes) - }, - [onNodesChange], - ) - /** Where clicking an endpoint takes you: the thing it stands for. */ const openEndpoint = useCallback( (id: string) => { @@ -451,18 +402,31 @@ function FlowEditorInner({ [navigate], ) + // 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 + // the next render, and React Flow draws an unmeasured node hidden, taking + // the edges attached to it with it. + const measured = useRef(new Map()) + const trackMeasured = useCallback( + (changes: NodeChange[]) => { + for (const change of changes) { + if (change.type !== "dimensions" || !change.dimensions) continue + if (isEndpointNode({ id: change.id })) { + measured.current.set(change.id, change.dimensions) + } + } + onNodesChange(changes) + }, + [onNodesChange], + ) + // Dashboards and other flows wired into this one. They are drawn but never // stored: they join at render, after everything that reads or writes // canvasNodes, so an autosave, an undo or a delete cannot reach them. - // biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring. + // biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on. const external = useMemo(() => { - const built = deriveEndpoints( - detail.endpoints ?? [], - definitions, - flowName, - new Map(canvasNodes.map((node) => [node.id, node.position])), - moved, - ) + const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName) return { ...built, nodes: built.nodes.map((node) => { @@ -470,30 +434,83 @@ function FlowEditorInner({ return size ? { ...node, measured: size, ...size } : node }), } - // biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring. - }, [detail.endpoints, key, flowName, moved]) + }, [detail.endpoints, key, flowName]) // Edges follow from the name bindings, so they are derived, never stored. - // Kept off `external` deliberately: an endpoint's edges depend on which - // messages it touches, never on where it sits, so dragging one must not - // rebuild the edge array on every frame. - // biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame. + // biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render. const edges = useMemo( () => [...deriveEdges(definitions, flowName), ...external.edges], [key, flowName, detail.endpoints], ) - const shownNodes = useMemo( - () => [...renderedNodes, ...external.nodes], - [renderedNodes, external], + // Which way the graph runs. A phone has height to spare and no width, so it + // reads top to bottom; everything else reads left to right. + const direction: Direction = useIsMobile() ? "TB" : "LR" + + /** + * Nobody places a node here — the graph lays itself out, endpoints included, + * so a producer lands upstream of what it feeds without a lane of its own. + * + * Keyed on which nodes exist and how they are wired, never on the node + * objects: React Flow writes measurements back through `onNodesChange`, so + * their identity changes constantly and the layout would run on every frame. + */ + const ids = [ + ...canvasNodes.map((node) => node.id), + ...external.nodes.map((node) => node.id), + ] + const shapeKey = `${direction}|${key}|${ids.join(",")}` + // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. + const positions = useMemo( + () => layoutGraph(ids, edges, direction), + [shapeKey, edges], ) - // Editing ports adds and removes handles. React Flow measures those once, so - // it has to be told, or an edge to a brand-new handle never gets drawn. + /** + * The endpoints, placed. + * + * Memoised rather than mapped at render: React Flow keeps a node's + * measurement against the object it measured, and an endpoint is not in + * `canvasNodes`, so handing over a fresh one every render would leave it + * permanently unmeasured — which React Flow draws as hidden. + */ + const externalNodes = useMemo( + () => + external.nodes.map((node) => ({ + ...node, + position: positions.get(node.id) ?? node.position, + })), + [external, positions], + ) + + const shownNodes = useMemo( + () => [ + ...renderedNodes.map((node) => ({ + ...node, + position: positions.get(node.id) ?? node.position, + })), + ...externalNodes, + ], + [renderedNodes, externalNodes, positions], + ) + + // 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. // biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles. useEffect(() => { - updateNodeInternals(definitions.map((node) => node.id)) - }, [key, updateNodeInternals]) + updateNodeInternals([ + ...definitions.map((node) => node.id), + ...external.nodes.map((node) => node.id), + ]) + }, [key, direction, external, updateNodeInternals]) + + // A relayout can put a new node outside the viewport, and turning the graph + // on its side moves everything. Both want the whole flow back in view. + // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render. + useEffect(() => { + fitView({ ...FIT_VIEW, duration: 300 }) + }, [direction, definitions.length, external.nodes.length, fitView]) const runMutation = useMutation({ mutationFn: () => @@ -565,18 +582,9 @@ function FlowEditorInner({ const addNode = useCallback( (type: string, sourceRef?: string) => { const id = uniqueNodeId(definitions, sourceRef ?? type) - // Drop it where the user is looking, but never on top of another node. - const position = freePosition( - definitions, - screenToFlowPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 2, - }), - ) const node: NodeDef_Input = { id, type, - position, params: {}, requires: [], provides: [], @@ -584,16 +592,15 @@ function FlowEditorInner({ // flow's own. ...(sourceRef ? { source_ref: sourceRef } : {}), } - const nextDefinitions = [...definitions, node] - const nextCanvas = [ + // Unwired, so the layout puts it in a rank of its own until it is bound. + setCanvasNodes([ ...canvasNodes, - { id, type: "flow", position, data: {} } as FlowCanvasNode, - ] - setCanvasNodes(nextCanvas) - commit(nextDefinitions, nextCanvas) + { id, type: "flow", position: { x: 0, y: 0 }, data: {} }, + ]) + commit([...definitions, node]) setSelectedId(id) }, - [canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes], + [canvasNodes, commit, definitions, setCanvasNodes], ) const updateNode = useCallback( @@ -819,31 +826,22 @@ function FlowEditorInner({ let pool = definitions const pasted: NodeDef_Input[] = [] for (const node of clipboard.nodes ?? []) { - const position = freePosition(pool, { - // Offset, so a copy of a node in this flow is visibly its own. - x: (node.position?.x ?? 0) + 48, - y: (node.position?.y ?? 0) + 48, - }) - const copy = { ...node, id: uniqueNodeId(pool, node.id), position } + const copy = { ...node, id: uniqueNodeId(pool, node.id) } pool = [...pool, copy] pasted.push(copy) } if (!pasted.length) return - const nextCanvas = [ + setCanvasNodes([ ...canvasNodes, - ...pasted.map( - (node) => - ({ - id: node.id, - type: "flow", - position: node.position, - data: {}, - }) as FlowCanvasNode, - ), - ] - setCanvasNodes(nextCanvas) - commit(pool, nextCanvas) + ...pasted.map((node) => ({ + id: node.id, + type: "flow", + position: { x: 0, y: 0 }, + data: {}, + })), + ]) + commit(pool) setSelectedId(pasted[pasted.length - 1].id) clipboard.nodes.forEach((node, index) => { @@ -883,25 +881,6 @@ function FlowEditorInner({ nodes={shownNodes} edges={edges} onNodesChange={trackMeasured} - onNodeDrag={(_event, _node, dragged) => { - // An endpoint's position is ours, not React Flow's, so it only - // follows the pointer if we move it every frame. - const endpoints = dragged.filter(isEndpointNode) - if (!endpoints.length) return - setMoved((current) => { - const next = { ...current } - for (const node of endpoints) next[node.id] = node.position - return next - }) - }} - onNodeDragStop={(_event, _node, dragged) => { - for (const node of dragged.filter(isEndpointNode)) { - // Written once at the end; every frame would be a write per pixel. - rememberPlacement(flowName, node.id, node.position) - } - const own = dragged.filter(isDocumentNode) - if (own.length) commit(definitions, mergeDragged(canvasNodes, own)) - }} onNodesDelete={(deleted) => deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } @@ -943,9 +922,14 @@ function FlowEditorInner({ proOptions={{ hideAttribution: true }} fitView fitViewOptions={FIT_VIEW} - minZoom={0.25} + // Low enough that the fit can always show the whole graph. A phone is + // 390px wide and a rank of several nodes is thousands, so a floor of + // 0.25 left the fit silently short and the flow running off screen. + minZoom={0.1} maxZoom={2} - nodeDragThreshold={5} + // The graph places itself. Nothing here is arranged by hand, which is + // what keeps a flow small enough to read at a glance. + nodesDraggable={false} connectionRadius={30} connectOnClick autoPanOnConnect @@ -987,6 +971,7 @@ function FlowEditorInner({ setFlowPanelOpen(true) }} onPublish={() => void publishFlow()} + onDiscard={() => setDiscardOpen(true)} logs={{ open: logsOpen, node: logsNode, @@ -1038,17 +1023,6 @@ function FlowEditorInner({ toggling={enableMutation.isPending} onToggleEnabled={(next) => enableMutation.mutate(next)} hasDraft={detail.has_draft ?? false} - discarding={discard.isPending} - onDiscardDraft={() => { - discard.mutate(undefined, { - // The published document replaces what is on the canvas, and the - // version counter goes back with it. - onSuccess: () => { - setFlowPanelOpen(false) - onReload() - }, - }) - }} onClose={() => setFlowPanelOpen(false)} /> @@ -1164,6 +1138,42 @@ function FlowEditorInner({ + + + + Discard the unpublished changes? + + The canvas goes back to the version the engine is running. What + you edited since is dropped, though the flow store's git history + keeps it. + + + + + + + + + {/* * Not dismissable: until one version wins, every further save fails, so * there is nothing useful to go back to. @@ -1203,17 +1213,10 @@ function FlowEditorInner({ ) } -function mergeDragged( - nodes: FlowCanvasNode[], - dragged: FlowCanvasNode[], -): FlowCanvasNode[] { - const moved = new Map(dragged.map((node) => [node.id, node.position])) - return nodes.map((node) => - moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node, - ) -} - -/** Seed xyflow's own node state once; it owns positions while you drag. */ +/** + * Seed xyflow's own node state once. It tracks which nodes exist and which are + * selected; the positions on it are placeholders the layout replaces at render. + */ function useUnpositionedNodes(definitions: NodeDef_Input[]) { return useNodesState(toCanvasNodes(definitions)) } diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index 65beceb..e7cdc51 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -28,6 +28,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" +import { useIsMobile } from "@/hooks/useMobile" import { cn } from "@/lib/utils" import { portOf } from "./deriveEdges" import { useNodeEmits, useNodeStatus } from "./liveStore" @@ -69,7 +70,7 @@ export type FlowNodeData = { [key: string]: unknown } -/** Vertically distribute handles so several ports stay reachable. */ +/** Spread handles along the node's edge so several ports stay reachable. */ function handleOffset(index: number, total: number): string { if (total <= 1) return "50%" const span = 60 @@ -85,10 +86,13 @@ function PortHandles({ type: "source" | "target" position: Position }) { + // The ports run across whichever edge they sit on. + const along = position === Position.Top || position === Position.Bottom return ( <> {specs.map((spec, index) => { const port = portOf(spec) + const offset = handleOffset(index, specs.length) return ( ) })} @@ -112,6 +116,9 @@ function FlowNodeComponent({ data, selected }: NodeProps) { data as FlowNodeData const live = useNodeStatus(`${flow}.${definition.id}`) const emits = useNodeEmits(`${flow}.${definition.id}`) + // The graph runs top to bottom on a phone, so the ports have to face that + // way too — see DESIGN-GUIDELINES.md → Responsive. + const vertical = useIsMobile() const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? // A connector's own type cannot be in the map above, and a device is @@ -143,7 +150,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
@@ -226,7 +233,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
) diff --git a/frontend/src/components/Flow/FlowPanel.tsx b/frontend/src/components/Flow/FlowPanel.tsx index c638313..8e5e6b6 100644 --- a/frontend/src/components/Flow/FlowPanel.tsx +++ b/frontend/src/components/Flow/FlowPanel.tsx @@ -24,8 +24,6 @@ export function FlowPanel({ onChange, onDelete, hasDraft, - discarding, - onDiscardDraft, enabled, toggling, onToggleEnabled, @@ -37,15 +35,12 @@ export function FlowPanel({ onChange: (next: FlowDef_Input) => void onDelete: () => void hasDraft: boolean - discarding: boolean - onDiscardDraft: () => void enabled: boolean toggling: boolean onToggleEnabled: (next: boolean) => void onClose: () => void }) { const [confirmOpen, setConfirmOpen] = useState(false) - const [discardOpen, setDiscardOpen] = useState(false) return ( <> @@ -110,18 +105,8 @@ export function FlowPanel({ Unpublished changes

The engine is still running the last published version of this - flow. + flow. The bar below publishes it, or throws the edit away.

-
) : null}
@@ -156,34 +141,6 @@ export function FlowPanel({ - - - - - Discard the unpublished changes? - - The canvas goes back to the version the engine is running. What - you edited since is dropped, though the flow store's git history - keeps it. - - - - - - - - ) } diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index dcbfeb4..073a502 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -256,7 +256,9 @@ function PortList({ ) : null} {specs.map((spec, index) => ( -
+ // The curve reads as its own thing rather than as part of the row + // above it, so it gets a little air. +
> - -function readPlacements(): Placements { - try { - return JSON.parse(localStorage.getItem(POSITION_KEY) ?? "{}") as Placements - } catch { - return {} - } -} - -export function placementsFor( - flow: string, -): Record { - return readPlacements()[flow] ?? {} -} - -export function rememberPlacement( - flow: string, - id: string, - position: { x: number; y: number }, -): void { - const all = readPlacements() - all[flow] = { ...(all[flow] ?? {}), [id]: position } - try { - localStorage.setItem(POSITION_KEY, JSON.stringify(all)) - } catch { - // A full or disabled store just means positions reset; not worth failing. - } -} - -/** - * Place the endpoints and wire them to the nodes they touch. - * - * Positions are computed rather than stored: an endpoint is not part of the - * flow, so there is nowhere to keep a position that would not be a lie about - * what the document contains. A producer sits left of what it feeds, a - * consumer right of what feeds it. + * They carry no position: the caller lays them out together with the flow's + * own nodes (see `layout.ts`), so an endpoint that publishes lands upstream of + * what it feeds and one that reads lands downstream of what feeds it, by the + * same rule that orders everything else. */ export function deriveEndpoints( endpoints: Endpoint[], definitions: NodeDef_Input[], flow: string, - positions: Map, - moved: Record = {}, ): { nodes: FlowCanvasNode[]; edges: Edge[] } { if (endpoints.length === 0) return { nodes: [], edges: [] } @@ -118,38 +71,18 @@ export function deriveEndpoints( } } - // A lane either side of the graph. Anchoring each label to the node it - // feeds put them on top of the nodes, so they live outside the whole thing - // instead: producers to the left of everything, consumers to the right. - const placed = [...positions.values()] - const bounds = { - left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0, - right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0, - top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0, - } - const nodes: FlowCanvasNode[] = [] const edges: Edge[] = [] - // How many labels already sit on each side, so they stack instead of overlap. - const stacked = { left: 0, right: 0 } for (const endpoint of endpoints) { const produces = endpoint.provides ?? [] const reads = endpoint.requires ?? [] - // A producer belongs upstream of what it feeds; everything else downstream. - const side = produces.length > 0 ? "left" : "right" - const index = stacked[side] - stacked[side] += 1 nodes.push({ id: endpoint.id, type: ENDPOINT_TYPE, - position: moved[endpoint.id] ?? { - x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X, - y: bounds.top + index * STACK_Y, - }, - // Movable, so a canvas can be arranged; still not the flow's to delete. - draggable: true, + // Filled in by the layout, along with the flow's own nodes. + position: { x: 0, y: 0 }, selectable: true, deletable: false, data: { diff --git a/frontend/src/components/Flow/layout.ts b/frontend/src/components/Flow/layout.ts new file mode 100644 index 0000000..e93921b --- /dev/null +++ b/frontend/src/components/Flow/layout.ts @@ -0,0 +1,80 @@ +import dagre from "@dagrejs/dagre" + +/** + * Where the nodes of a flow go. + * + * Nothing on this canvas is placed by hand: a flow is a graph the editor draws, + * not a picture someone arranges. That is the design decision — a canvas nobody + * can rearrange is one worth keeping small, which is what "atomic flow" means + * here — and it also means a flow document carries no positions to go stale. + * + * Left to right on a desktop, top to bottom on a phone, which is the direction + * each screen has room to grow in. + */ +export type Direction = "LR" | "TB" + +/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */ +const NODE_W = 220 +/** Icon row plus two text lines, as measured. */ +const NODE_H = 56 +/** + * Room for the live value an edge carries (`LiveEdge`'s chip is + * `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around + * the chip rather than through it. + */ +const LABEL_W = 150 +const LABEL_H = 24 + +/** + * Lay the graph out and return each node's top-left corner. + * + * ponytail: every node is treated as 220×56 rather than measured. Measuring + * would feed the result back into the layout and oscillate; if nodes ever grow + * past that box, take the sizes from `node.measured` once they have settled. + */ +export function layoutGraph( + ids: string[], + edges: { source: string; target: string }[], + direction: Direction, +): Map { + const graph = new dagre.graphlib.Graph() + graph.setDefaultEdgeLabel(() => ({})) + graph.setGraph({ + rankdir: direction, + // Along the rank, and between ranks. A left-to-right graph needs the wider + // gap between ranks because the nodes themselves are wide. + nodesep: 40, + ranksep: direction === "LR" ? 110 : 80, + marginx: 40, + marginy: 40, + }) + + // Insertion order is what makes the result deterministic, so it follows the + // document rather than whatever order the edges happen to mention nodes in. + for (const id of ids) { + graph.setNode(id, { width: NODE_W, height: NODE_H }) + } + for (const edge of edges) { + if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue + graph.setEdge(edge.source, edge.target, { + width: LABEL_W, + height: LABEL_H, + labelpos: "c", + }) + } + + dagre.layout(graph) + + // dagre places centres; React Flow wants top-left corners. + return new Map( + ids.map((id) => { + const node = graph.node(id) + return [ + id, + node + ? { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 } + : { x: 0, y: 0 }, + ] + }), + ) +} diff --git a/frontend/src/components/Health/HealthActivity.tsx b/frontend/src/components/Health/HealthActivity.tsx index 4e2f54e..f908deb 100644 --- a/frontend/src/components/Health/HealthActivity.tsx +++ b/frontend/src/components/Health/HealthActivity.tsx @@ -157,7 +157,9 @@ function Failure({ event }: { event: EventRow }) { ) : ( )} - + {/* A traceback's first line runs long; it wraps rather than widening + the card, and the rest is behind the chevron anyway. */} + {event.node || event.flow || "engine"} @@ -289,24 +291,28 @@ export function HealthActivity({ range }: { range: Range }) { {run.flow} - + {/* Five fixed columns do not fit a phone. What caused a run + is the one a narrow row can do without — the status, the + duration and when it ran are why anyone reads this. */} + {run.source} {run.status} - + {si(run.duration_ms)} ms - + {ago(run.started_at)}
@@ -362,8 +368,10 @@ export function HealthActivity({ range }: { range: Range }) { {item.node} - {item.reason} - + + {item.reason} + + {ago(item.ts)}
diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index 2088225..6870898 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -172,8 +172,10 @@ export function HealthOverview({ Lag {/* A bounded share rather than all the slack: the columns beside it grow with their own content, so the numbers - spread across the middle instead of huddling on the left. */} - + spread across the middle instead of huddling on the left. + Its 128px floor is more than a phone has to spare, and a + curve that narrow says nothing, so it goes below `sm`. */} + Trend @@ -181,11 +183,14 @@ export function HealthOverview({ {(flows ?? []).map((row: FlowRollup) => ( - + + {/* `max-w-0` is what lets a cell truncate at all: without + it the table sizes to the longest name and pushes the + page sideways. */} {row.flow || "—"} @@ -210,7 +215,7 @@ export function HealthOverview({ {/* The dot straddles the curve's right edge, so the cell keeps a little room for the half that hangs out. */} - + diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index 6a44a6b..e7b1d15 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -320,7 +320,10 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
-
+
diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index fbc85e3..934d5a1 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -114,7 +114,11 @@ function Dashboard() { const running = flows.filter((flow) => flow.enabled ?? true).length return ( -
+ // `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one + // long name or wide table widens the whole column and the page with it. + // Every section here is free to shrink instead. See DESIGN-GUIDELINES.md + // → Responsive. +

Hi, {currentUser?.full_name || currentUser?.email} 👋 diff --git a/frontend/src/routes/view.$name.tsx b/frontend/src/routes/view.$name.tsx index 2240eea..a6c1dbe 100644 --- a/frontend/src/routes/view.$name.tsx +++ b/frontend/src/routes/view.$name.tsx @@ -9,6 +9,7 @@ import { import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { isLoggedIn } from "@/hooks/useAuth" +import { useIsMobile } from "@/hooks/useMobile" /** * What a wall panel is pointed at. @@ -31,16 +32,27 @@ function PanelView() { const { name } = Route.useParams() useFlowSocket() const { data: dashboard } = useQuery(dashboardQueryOptions(name)) + // A landscape arrangement scaled onto a phone comes out at about a fifth of + // its size, which reads as nothing at all. Stack it instead. + const stacked = useIsMobile() + + if (!dashboard) return
+ + if (stacked) { + return ( +
+ +
+ ) + } return (
- {dashboard ? ( - // The panel's own surface, scaled to whatever screen it landed on. No - // dots: nothing is being arranged here. - - {() => } - - ) : null} + {/* The panel's own surface, scaled to whatever screen it landed on. No + dots: nothing is being arranged here. */} + + {() => } +
) } diff --git a/frontend/tests/drafts.spec.ts b/frontend/tests/drafts.spec.ts index 69de4c6..6011ba5 100644 --- a/frontend/tests/drafts.spec.ts +++ b/frontend/tests/drafts.spec.ts @@ -33,7 +33,9 @@ test("a draft stays off the engine until it is published", async ({ page }) => { // Adding a node opens its panel, and the floating chrome steps aside for it. await page.keyboard.press("Escape") - await expect(page.getByTestId("publish-flow")).toBeVisible() + // The publish glyph never moves; it is enabled only while there is + // something to put live. + await expect(page.getByTestId("publish-flow")).toBeEnabled() // Nothing of this flow is loaded while it is only a draft. await page.waitForTimeout(1500) @@ -41,7 +43,11 @@ test("a draft stays off the engine until it is published", async ({ page }) => { expect(before.nodes).toHaveLength(0) await page.getByTestId("publish-flow").click() - await expect(page.getByTestId("publish-flow")).toBeHidden() + // Discard is what marks a draft: it goes when the publish has landed. The + // publish glyph disables while the request is in flight too, so waiting on + // that would not wait for anything. + await expect(page.getByTestId("discard-draft")).toBeHidden() + await expect(page.getByTestId("publish-flow")).toBeDisabled() const after = await (await api(page, `/flows/${flowName}/state`)).json() expect(after.nodes.length).toBeGreaterThan(0) @@ -76,11 +82,11 @@ test("discarding a draft goes back to the published flow", async ({ page }) => { await page.goto(`/flows/${flowName}`) await page.waitForSelector(".react-flow__node") - await page.getByTestId("edit-flow").click() await page.getByTestId("discard-draft").click() await page.getByTestId("confirm-discard-draft").click() - await expect(page.getByTestId("publish-flow")).toBeHidden() + await expect(page.getByTestId("discard-draft")).toBeHidden() + await expect(page.getByTestId("publish-flow")).toBeDisabled() const detail = await (await api(page, `/flows/${flowName}`)).json() expect(detail.has_draft).toBe(false) expect(detail.definition.title).not.toBe("Theirs") diff --git a/frontend/tests/endpoints.spec.ts b/frontend/tests/endpoints.spec.ts index 6e384f1..733b495 100644 --- a/frontend/tests/endpoints.spec.ts +++ b/frontend/tests/endpoints.spec.ts @@ -88,20 +88,12 @@ test("a dashboard control is drawn on the flow it feeds", async ({ page }) => { await expect(page.getByText("Lever")).toBeVisible() }) -test("moving a node does not save the dashboard into the flow", async ({ +test("the dashboard is never stored as a node of the flow", async ({ page, }) => { await page.goto(`/flows/${flowName}`) - const node = page.locator(".react-flow__node-flow").first() - await node.waitFor() - - const box = await node.boundingBox() - if (!box) throw new Error("the node has no position to drag from") - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - await page.mouse.down() - await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40) - await page.mouse.up() - // The autosave is debounced; give it room to land. + await page.waitForSelector(".react-flow__node") + // Long enough for an autosave to have landed if the canvas had queued one. await page.waitForTimeout(2000) const detail = await (await api(page, `/flows/${flowName}`)).json() @@ -114,3 +106,25 @@ test("moving a node does not save the dashboard into the flow", async ({ "Lever", ]) }) + +test("a node cannot be dragged: the graph places itself", async ({ page }) => { + await page.goto(`/flows/${flowName}`) + const node = page.locator(".react-flow__node-flow").first() + await node.waitFor() + + // React Flow writes a node's place in the graph as the transform on its + // wrapper. The screen position is no good here: dragging the canvas pans + // the viewport, which moves every node on screen without moving any of them + // in the graph — which is the whole point. + const at = () => node.evaluate((el) => (el as HTMLElement).style.transform) + const before = await at() + + const box = await node.boundingBox() + if (!box) throw new Error("the node was not rendered") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40) + await page.mouse.up() + + expect(await at()).toBe(before) +}) diff --git a/frontend/tests/flows.spec.ts b/frontend/tests/flows.spec.ts index 214df4e..f734be5 100644 --- a/frontend/tests/flows.spec.ts +++ b/frontend/tests/flows.spec.ts @@ -110,7 +110,10 @@ test("running a flow puts values on its edges", async ({ page }) => { ) await page.reload() - await page.waitForSelector(".react-flow__edge") + // Attached, not visible: the layout puts a two-node chain on one rank, so + // the edge between them is a straight horizontal line — correct, and a + // zero-height box as far as a visibility check is concerned. + await page.waitForSelector(".react-flow__edge", { state: "attached" }) await page.getByTestId("run-flow").click() await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText( diff --git a/frontend/tests/mobile.spec.ts b/frontend/tests/mobile.spec.ts new file mode 100644 index 0000000..1005b04 --- /dev/null +++ b/frontend/tests/mobile.spec.ts @@ -0,0 +1,193 @@ +import { expect, type Page, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * Every screen at a phone's width, checking the one thing that is easy to + * break and hard to notice: a page that scrolls sideways. + * + * The rules this enforces are in the root `DESIGN-GUIDELINES.md` → Responsive. + * A wide table, chart or dock is allowed to scroll inside its own box; none of + * them may widen the page around it. + */ + +const flowName = `test_mobile_${Date.now().toString(36)}` +const dashboardName = `${flowName}_panel` + +test.describe.configure({ mode: "serial" }) + +/** + * Does anything on this page stick out past the viewport? + * + * Two signals, because either can hide the other. A page that scrolls + * sideways fails `scrollWidth`; one that merely *contains* something too wide + * makes the browser widen the layout viewport and zoom the whole page out + * instead, which shows up only as `innerWidth` no longer being the device's. + */ +async function fits(page: Page): Promise<{ inner: number; scroll: number }> { + return page.evaluate(() => ({ + inner: window.innerWidth, + scroll: document.documentElement.scrollWidth, + })) +} + +async function expectFits(page: Page, where: string) { + const width = page.viewportSize()?.width ?? 0 + const { inner, scroll } = await fits(page) + expect(inner, `${where} is too wide for the viewport`).toBe(width) + expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner) +} + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + + // A chain and a fork, so the canvas has a graph worth laying out rather + // than a single node that fits anywhere. + await api(page, `/flows/${flowName}`, { + method: "PUT", + data: { + name: flowName, + title: "Mobile", + nodes: [ + { + id: "source", + type: "python", + provides: [{ name: "reading", dtype: "float" }], + }, + { + id: "scale", + type: "python", + requires: [{ name: "reading", dtype: "float" }], + provides: [{ name: "scaled", dtype: "float" }], + }, + { + id: "left", + type: "python", + requires: [{ name: "scaled", dtype: "float" }], + }, + { + id: "right", + type: "python", + requires: [{ name: "scaled", dtype: "float" }], + }, + ], + version: 1, + }, + }) + const saved = await (await api(page, `/flows/${flowName}`)).json() + await api(page, `/flows/${flowName}/publish`, { + method: "POST", + data: { version: saved.definition.version }, + }) + + await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) + const dashboard = await ( + await api(page, `/dashboards/${dashboardName}`) + ).json() + dashboard.pages[0].sections[0].widgets = [ + { + id: "top", + type: "stat", + title: "Top", + layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, + config: { message: `${flowName}.reading` }, + }, + { + id: "beside", + type: "stat", + title: "Beside", + layout: { lg: { x: 3, y: 0, w: 3, h: 2 } }, + config: { message: `${flowName}.scaled` }, + }, + ] + const draft = await ( + await api(page, `/dashboards/${dashboardName}`, { + method: "PUT", + data: dashboard, + }) + ).json() + await api(page, `/dashboards/${dashboardName}/publish`, { + method: "POST", + data: { version: draft.version }, + }) + await page.close() +}) + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [ + `/dashboards/${dashboardName}`, + `/flows/${flowName}`, + ]) +}) + +test("home fits the viewport", async ({ page }) => { + await page.goto("/") + await page + .getByText(/Flow activity/i) + .first() + .waitFor({ timeout: 15000 }) + await expectFits(page, "home") +}) + +test("the overviews fit the viewport", async ({ page }) => { + for (const path of ["/flows", "/dashboards"]) { + await page.goto(path) + await page.waitForLoadState("networkidle") + await expectFits(page, path) + } +}) + +/** Where React Flow put a node in the graph, out of its wrapper transform. */ +async function nodeAt(page: Page, id: string) { + const style = await page + .locator(`.react-flow__node[data-id="${id}"]`) + .evaluate((el) => (el as HTMLElement).style.transform) + const [x, y] = [...style.matchAll(/-?[\d.]+/g)].map((m) => Number(m[0])) + return { x, y } +} + +test("the flow editor fits, and its dock is reachable", async ({ page }) => { + await page.goto(`/flows/${flowName}`) + await page.waitForSelector(".react-flow__node") + await expectFits(page, "the flow editor") + + // A phone has height to spare and no width, so the graph runs downwards: + // what a node feeds sits below it, not beside it. + const source = await nodeAt(page, "source") + const scale = await nodeAt(page, "scale") + expect(scale.y, "the graph does not run top to bottom").toBeGreaterThan( + source.y, + ) + expect(Math.abs(scale.x - source.x)).toBeLessThan(200) + + // The dock used to overflow a phone, which put the buttons at its ends + // outside the shell's `overflow-hidden` and made them unclickable. + for (const id of ["add-node", "run-flow", "edit-flow", "publish-flow"]) { + const box = await page.getByTestId(id).boundingBox() + expect(box, `${id} is not on screen`).not.toBeNull() + const width = page.viewportSize()?.width ?? 0 + expect(box!.x, `${id} starts off the left edge`).toBeGreaterThanOrEqual(0) + expect( + box!.x + box!.width, + `${id} runs off the right edge`, + ).toBeLessThanOrEqual(width) + } +}) + +test("a dashboard stacks instead of shrinking", async ({ page }) => { + await page.goto(`/dashboards/${dashboardName}`) + await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) + await expectFits(page, "the dashboard editor") + + // Side by side on a panel, one under the other here. + const first = await page.getByTestId("widget-frame").first().boundingBox() + const second = await page.getByTestId("widget-frame").nth(1).boundingBox() + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1) +}) + +test("the panel view fits the viewport", async ({ page }) => { + await page.goto(`/view/${dashboardName}`) + await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) + await expectFits(page, "the panel view") +}) diff --git a/frontend/tests/runtime.spec.ts b/frontend/tests/runtime.spec.ts index 25b5006..2d1dd01 100644 --- a/frontend/tests/runtime.spec.ts +++ b/frontend/tests/runtime.spec.ts @@ -35,13 +35,11 @@ test.beforeAll(async ({ browser }) => { { id: "sensor", type: "python", - position: { x: 0, y: 0 }, provides: [{ name: "reading", dtype: "float" }], }, { id: "logger", type: "python", - position: { x: 260, y: 0 }, requires: [{ name: "reading", dtype: "float" }], }, ],