Computed flow layout, and mobile written into the design

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
2026-08-17 17:35:14 +02:00
co-authored by Claude Opus 5
parent e7a1466d7b
commit 39ee0e0aa5
37 changed files with 998 additions and 527 deletions
@@ -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<string | undefined>(
() => 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<ReturnType<typeof setTimeout> | 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) => (
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip={grip}
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
)
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
@@ -346,6 +390,17 @@ export function DashboardEditor({
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div className="h-full overflow-y-auto">
<DashboardView
dashboard={draft}
pageId={page.id}
stacked
renderWidget={edit ? pickable : undefined}
/>
</div>
) : (
<CanvasSurface dashboard={draft} dots={edit}>
{(scale) =>
@@ -372,25 +427,7 @@ export function DashboardEditor({
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
</div>
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
)
@@ -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({
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
{/* Only while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => setDiscardOpen(true)}
aria-label="Discard the unpublished changes"
data-testid="discard-dashboard"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>
Discard the unpublished changes
</TooltipContent>
</Tooltip>
) : null}
{/* Saved state and publish are one control, as in the flow dock:
the glyph stays put and simply stops being pressable. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
<span>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => void publishDashboard()}
disabled={
!hasDraft || publish.isPending || save.isPending
}
aria-label="Publish this dashboard"
data-testid="publish-dashboard"
>
{save.isPending || publish.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{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"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={() => void publishDashboard()}
disabled={publish.isPending}
data-testid="publish-dashboard"
>
{publish.isPending ? "Publishing…" : "Publish"}
</Button>
) : null}
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
</>
) : null}
@@ -590,6 +656,38 @@ export function DashboardEditor({
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The dashboard goes back to what the panels are showing. What
you edited since is dropped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
// Leaving edit mode remounts the editor, which is how the
// published document replaces the working copy.
discard.mutate(undefined, {
onSuccess: () => setEdit(false),
})
}}
data-testid="confirm-discard-dashboard"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : null}
</>
@@ -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 className="grid gap-3">
{section.title ? (
@@ -187,7 +199,7 @@ export function SectionGrid({
</h2>
) : null}
<div
className={cn("widget-grid", className)}
className={cn("widget-grid", stacked && "widget-stacked", className)}
data-placed={isPlaced(widgets) || undefined}
style={{ "--widget-cols": columns } as React.CSSProperties}
>
@@ -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}
/>
))}
</div>
@@ -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
@@ -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
@@ -201,6 +201,7 @@ export function WidgetFrame({
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
data-testid="widget-frame"
className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,