Rework the dashboards onto the flow canvas

One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.

Dashboards gain the rest of M4's visualization work:

- widgets are picked by clicking them, with the header as the drag
  handle so a slider still slides and a switch still flips while
  editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
  mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
  from the stored history plus the live socket tail, coloured from the
  new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
  footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
  at all, carries the same red dot a failing node does; the picker
  records the type it bound and WidgetDef refuses a mismatch on save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 17:13:10 +02:00
co-authored by Claude Fable 5
parent 74bb956805
commit 0af09eedbe
24 changed files with 1799 additions and 1558 deletions
+8 -5
View File
@@ -90,6 +90,7 @@ async function captureDashboards(page, dir) {
await page.getByTestId("create-dashboard").click()
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
// A widget, so the grid has something in it worth photographing.
await page.getByTestId("add-widget").click()
await page.getByTestId("add-widget-stat").click()
await page.waitForSelector("[data-testid=widget-settings]")
await page.getByTestId("toggle-edit").click()
@@ -109,11 +110,13 @@ async function captureDashboards(page, dir) {
async function captureFlows(page, dir) {
await page.goto(`${APP_URL}/flows`, { waitUntil: "networkidle" })
const seed = page.getByTestId("create-first-flow")
if (await seed.count()) {
await seed.click()
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
if (await page.getByTestId("flow-card").count()) {
await page.getByTestId("flow-card").first().click()
} else {
await page.getByTestId("new-flow-name").fill("first_flow")
await page.getByTestId("create-flow").click()
}
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
if (!(await page.locator(".react-flow__node").count())) {
await page.getByTestId("add-node").click()
@@ -126,7 +129,7 @@ async function captureFlows(page, dir) {
// Adding a node opens its panel; the canvas shot wants it out of the way.
await page.keyboard.press("Escape")
// The dock and tabs slide in; let them land before the shutter.
// The dock and the title bar slide in; let them land before the shutter.
await page.waitForTimeout(1200)
await page.screenshot({ path: `${dir}/app-flows.png` })