Give the pulse its own duration, and let the editor fill the screen

The shared duration tokens had been stretched to slow the pulses down, which
also slowed every panel and dock animation and left `motion.ts` out of step
with the CSS. Both are back to 200/300 ms, and the pulses use a new
`--duration-pulse` (500 ms): a message arriving is a signal, not a state
change, and it is the only thing here that should linger. The edge pulse read
its own 300 ms constant, so it now takes the token through `motion.ts` and the
two sides cannot drift again.

The code editor also expands: the same node panel fills the content region
beside the sidebar, the canvas chrome steps aside rather than floating on top,
and the settings above the editor keep a readable width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 20:03:21 +02:00
co-authored by Claude Fable 5
parent f05831b4a5
commit aa0e760615
8 changed files with 95 additions and 34 deletions
+5 -4
View File
@@ -8,10 +8,11 @@ Deferring because out of scope is fine, but don't mention deferring than.
## Open ## Open
- BUG/UI: the CSS duration tokens were retuned to 250/500 ms but `lib/motion.ts` still - BUG/UI: the sidebar background on dark mode does not match the background of the viewport/flow panel
carries 200/300, so JS animations (panels, dock, tabs) run at a different speed to CSS - BUG/UI: there is some strage dot (li item) sitting next to the appearance button
transitions. They are meant to mirror each other; pick the intended values and update - FEAT/UI: introduce User settings page showing up in the sidebar where the apperance, email password etc can be set (copy UX design from ../../n3xd/app)
`motion.ts` in both repos. - BUG/UI: edge value labels should be opaque
- BUG/UI: the popover which opens when clicking on an edge should be more compact, i.e. make label, value and last updated fit in a single row (truncate decimals)
- BUG/API: `pytest tests/` deletes every user on teardown (`tests/conftest.py`), so running - BUG/API: `pytest tests/` deletes every user on teardown (`tests/conftest.py`), so running
it against the development database logs you out of the running app. Point tests at their it against the development database logs you out of the running app. Point tests at their
own database, or reseed with `init_db` afterwards. own database, or reseed with `init_db` afterwards.
+14 -1
View File
@@ -121,6 +121,8 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const [inspected, setInspected] = useState<InspectedEdge | null>(null) const [inspected, setInspected] = useState<InspectedEdge | null>(null)
const [rebind, setRebind] = useState<Rebind | null>(null) const [rebind, setRebind] = useState<Rebind | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false) const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// The editor at full size covers the canvas, so its chrome steps aside.
const [editorExpanded, setEditorExpanded] = useState(false)
const issues = detail.issues ?? [] const issues = detail.issues ?? []
@@ -234,7 +236,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: (newName: string) => mutationFn: (newName: string) =>
FlowsService.renameFlow({ name: flowName, requestBody: { new_name: newName } }), FlowsService.renameFlow({
name: flowName,
requestBody: { new_name: newName },
}),
onSuccess: (detail) => { onSuccess: (detail) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all }) queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false) setFlowPanelOpen(false)
@@ -442,6 +447,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
}} }}
onPaneClick={() => { onPaneClick={() => {
setSelectedId(null) setSelectedId(null)
setEditorExpanded(false)
setInspected(null) setInspected(null)
}} }}
onEdgeClick={(event, edge) => { onEdgeClick={(event, edge) => {
@@ -476,12 +482,15 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} /> <Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow> </ReactFlow>
{editorExpanded ? null : (
<FlowTabs <FlowTabs
flows={flows.data} flows={flows.data}
active={flowName} active={flowName}
saving={saving.isPending} saving={saving.isPending}
/> />
)}
{editorExpanded ? null : (
<FlowDock <FlowDock
issues={issues} issues={issues}
running={runMutation.isPending} running={runMutation.isPending}
@@ -496,6 +505,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
}} }}
onFocusNode={focusNode} onFocusNode={focusNode}
/> />
)}
{definitions.length === 0 ? ( {definitions.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -534,12 +544,15 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flow={flowName} flow={flowName}
nodeTypes={nodeTypeInfo ?? []} nodeTypes={nodeTypeInfo ?? []}
suggestions={suggestions} suggestions={suggestions}
expanded={editorExpanded}
onToggleExpand={() => setEditorExpanded((wide) => !wide)}
onChange={updateNode} onChange={updateNode}
onSaveSource={(code) => { onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
}} }}
onClose={() => { onClose={() => {
flush() flush()
setEditorExpanded(false)
setSelectedId(null) setSelectedId(null)
}} }}
onDelete={() => selected && deleteNodes([selected.id])} onDelete={() => selected && deleteNodes([selected.id])}
+2 -1
View File
@@ -7,6 +7,7 @@ import {
} from "@xyflow/react" } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react" import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { FlowEdgeData } from "./deriveEdges" import type { FlowEdgeData } from "./deriveEdges"
import { useLiveValue } from "./liveStore" import { useLiveValue } from "./liveStore"
@@ -53,7 +54,7 @@ function LiveEdgeComponent({
if (!live?.ts || live.ts === lastTs.current) return if (!live?.ts || live.ts === lastTs.current) return
lastTs.current = live.ts lastTs.current = live.ts
setPulsing(true) setPulsing(true)
const timer = setTimeout(() => setPulsing(false), 300) const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [live?.ts]) }, [live?.ts])
+28 -2
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { X } from "lucide-react" import { Maximize2, Minimize2, X } from "lucide-react"
import { lazy, Suspense, useEffect, useRef, useState } from "react" import { lazy, Suspense, useEffect, useRef, useState } from "react"
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client" import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
@@ -22,6 +22,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { nodeSourceQueryOptions } from "./queries" import { nodeSourceQueryOptions } from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel" import { PANEL_SECTION, SidePanel } from "./SidePanel"
@@ -276,15 +277,19 @@ function PanelBody({
flow, flow,
nodeType, nodeType,
suggestions, suggestions,
expanded,
onChange, onChange,
onSaveSource, onSaveSource,
onToggleExpand,
}: { }: {
node: NodeDef_Input node: NodeDef_Input
flow: string flow: string
nodeType: NodeTypeInfo | undefined nodeType: NodeTypeInfo | undefined
suggestions: PortSuggestions suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void onSaveSource: (code: string) => void
onToggleExpand: () => void
}) { }) {
const hasSource = nodeType?.has_source ?? node.type === "python" const hasSource = nodeType?.has_source ?? node.type === "python"
const { data: source } = useQuery({ const { data: source } = useQuery({
@@ -320,7 +325,7 @@ function PanelBody({
return ( return (
<> <>
<div className="grid gap-5 p-4"> <div className={cn("grid gap-5 p-4", expanded && "max-w-2xl")}>
<PortList <PortList
title="Consumes" title="Consumes"
specs={node.requires ?? []} specs={node.requires ?? []}
@@ -346,7 +351,21 @@ function PanelBody({
{hasSource ? ( {hasSource ? (
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4"> <div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<div className="flex items-center justify-between">
<span className={SECTION}>Code</span> <span className={SECTION}>Code</span>
<Button
variant="ghost"
size="icon-sm"
className="hidden text-muted-foreground md:inline-flex"
onClick={onToggleExpand}
aria-label={
expanded ? "Collapse the editor" : "Expand the editor"
}
data-testid="toggle-editor-size"
>
{expanded ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border"> <div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense <Suspense
fallback={ fallback={
@@ -377,8 +396,10 @@ export function NodePanel({
flow, flow,
nodeTypes, nodeTypes,
suggestions, suggestions,
expanded,
onChange, onChange,
onSaveSource, onSaveSource,
onToggleExpand,
onClose, onClose,
onDelete, onDelete,
}: { }: {
@@ -386,8 +407,10 @@ export function NodePanel({
flow: string flow: string
nodeTypes: NodeTypeInfo[] nodeTypes: NodeTypeInfo[]
suggestions: PortSuggestions suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void onSaveSource: (code: string) => void
onToggleExpand: () => void
onClose: () => void onClose: () => void
onDelete: () => void onDelete: () => void
}) { }) {
@@ -399,6 +422,7 @@ export function NodePanel({
label="Node settings" label="Node settings"
testId="node-panel" testId="node-panel"
bodyKey={node?.id ?? "none"} bodyKey={node?.id ?? "none"}
expanded={expanded}
onClose={onClose} onClose={onClose}
header={ header={
node ? ( node ? (
@@ -429,8 +453,10 @@ export function NodePanel({
flow={flow} flow={flow}
nodeType={nodeType} nodeType={nodeType}
suggestions={suggestions} suggestions={suggestions}
expanded={expanded}
onChange={onChange} onChange={onChange}
onSaveSource={onSaveSource} onSaveSource={onSaveSource}
onToggleExpand={onToggleExpand}
/> />
) : null} ) : null}
</SidePanel> </SidePanel>
+12 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion" import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
import { cn } from "@/lib/utils"
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */ /** Same grammar as the shared `slideUp`, on the axis this panel travels. */
const panelSlide = { const panelSlide = {
@@ -38,6 +39,7 @@ export function SidePanel({
label, label,
testId, testId,
bodyKey, bodyKey,
expanded = false,
header, header,
footer, footer,
children, children,
@@ -49,6 +51,8 @@ export function SidePanel({
testId: string testId: string
/** Remounts the contents when the thing being edited changes. */ /** Remounts the contents when the thing being edited changes. */
bodyKey: string bodyKey: string
/** Fill the content area instead of floating beside the canvas. */
expanded?: boolean
header: ReactNode header: ReactNode
footer?: ReactNode footer?: ReactNode
children: ReactNode children: ReactNode
@@ -125,7 +129,14 @@ export function SidePanel({
role="complementary" role="complementary"
aria-label={label} aria-label={label}
data-testid={testId} data-testid={testId}
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md" data-expanded={expanded || undefined}
className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden border border-border bg-card/80 shadow-e2 backdrop-blur-md",
expanded
? // Fills the content region, which already starts after the sidebar.
"inset-0 rounded-none"
: "inset-y-4 right-4 w-[400px] rounded-lg",
)}
> >
{contents} {contents}
</motion.aside> </motion.aside>
+2 -2
View File
@@ -34,7 +34,7 @@
/* A message arriving lights its edge, then decays back to rest. */ /* A message arriving lights its edge, then decays back to rest. */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.react-flow__edge-path.edge-live { .react-flow__edge-path.edge-live {
animation: edge-pulse var(--duration-slow) var(--ease-emphasized); animation: edge-pulse var(--duration-pulse) var(--ease-emphasized);
} }
@keyframes edge-pulse { @keyframes edge-pulse {
@@ -58,7 +58,7 @@
border: 2px solid var(--primary); border: 2px solid var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
pointer-events: none; pointer-events: none;
animation: node-pulse var(--duration-slow) var(--ease-emphasized) forwards; animation: node-pulse var(--duration-pulse) var(--ease-emphasized) forwards;
} }
@keyframes node-pulse { @keyframes node-pulse {
+5 -2
View File
@@ -35,8 +35,11 @@
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1); --ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-base: 250ms; --duration-base: 200ms;
--duration-slow: 500ms; --duration-slow: 300ms;
/* One-shot feedback (a message arriving, a node emitting) rather than a UI
state change, so it lingers long enough to be noticed. */
--duration-pulse: 500ms;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
+7 -1
View File
@@ -19,7 +19,13 @@ export const easeEmphasized: [number, number, number, number] = [0.2, 0, 0, 1]
export const easeStandard: [number, number, number, number] = [0.4, 0, 0.2, 1] export const easeStandard: [number, number, number, number] = [0.4, 0, 0.2, 1]
/** Durations in seconds, mirroring the `--duration-*` tokens (ms). */ /** Durations in seconds, mirroring the `--duration-*` tokens (ms). */
export const duration = { fast: 0.15, base: 0.2, slow: 0.3 } as const export const duration = {
fast: 0.15,
base: 0.2,
slow: 0.3,
/** One-shot feedback, not a state change. See `--duration-pulse`. */
pulse: 0.5,
} as const
export const transitions = { export const transitions = {
/** Enter / expressive moves (decelerate). */ /** Enter / expressive moves (decelerate). */