From aa0e760615816097bf06dee521dd82c4bc8de7d6 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sat, 15 Aug 2026 20:03:21 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i --- NOTEPAD.md | 9 ++-- frontend/src/components/Flow/FlowEditor.tsx | 53 +++++++++++++-------- frontend/src/components/Flow/LiveEdge.tsx | 3 +- frontend/src/components/Flow/NodePanel.tsx | 32 +++++++++++-- frontend/src/components/Flow/SidePanel.tsx | 13 ++++- frontend/src/components/Flow/flow.css | 4 +- frontend/src/index.css | 7 ++- frontend/src/lib/motion.ts | 8 +++- 8 files changed, 95 insertions(+), 34 deletions(-) diff --git a/NOTEPAD.md b/NOTEPAD.md index 3e1e2fe..ed19531 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -8,10 +8,11 @@ Deferring because out of scope is fine, but don't mention deferring than. ## Open -- BUG/UI: the CSS duration tokens were retuned to 250/500 ms but `lib/motion.ts` still - carries 200/300, so JS animations (panels, dock, tabs) run at a different speed to CSS - transitions. They are meant to mirror each other; pick the intended values and update - `motion.ts` in both repos. +- BUG/UI: the sidebar background on dark mode does not match the background of the viewport/flow panel +- BUG/UI: there is some strage dot (li item) sitting next to the appearance button +- 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) +- 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 it against the development database logs you out of the running app. Point tests at their own database, or reseed with `init_db` afterwards. diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index aeb634d..8b1572f 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -121,6 +121,8 @@ function FlowEditorInner({ flowName }: { flowName: string }) { const [inspected, setInspected] = useState(null) const [rebind, setRebind] = useState(null) 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 ?? [] @@ -234,7 +236,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) { const renameMutation = useMutation({ mutationFn: (newName: string) => - FlowsService.renameFlow({ name: flowName, requestBody: { new_name: newName } }), + FlowsService.renameFlow({ + name: flowName, + requestBody: { new_name: newName }, + }), onSuccess: (detail) => { queryClient.invalidateQueries({ queryKey: flowKeys.all }) setFlowPanelOpen(false) @@ -442,6 +447,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) { }} onPaneClick={() => { setSelectedId(null) + setEditorExpanded(false) setInspected(null) }} onEdgeClick={(event, edge) => { @@ -476,26 +482,30 @@ function FlowEditorInner({ flowName }: { flowName: string }) { - + {editorExpanded ? null : ( + + )} - setPaletteOpen(true)} - onEditFlow={() => { - setSelectedId(null) - setFlowPanelOpen(true) - }} - onRun={() => { - flush() - runMutation.mutate() - }} - onFocusNode={focusNode} - /> + {editorExpanded ? null : ( + setPaletteOpen(true)} + onEditFlow={() => { + setSelectedId(null) + setFlowPanelOpen(true) + }} + onRun={() => { + flush() + runMutation.mutate() + }} + onFocusNode={focusNode} + /> + )} {definitions.length === 0 ? (
@@ -534,12 +544,15 @@ function FlowEditorInner({ flowName }: { flowName: string }) { flow={flowName} nodeTypes={nodeTypeInfo ?? []} suggestions={suggestions} + expanded={editorExpanded} + onToggleExpand={() => setEditorExpanded((wide) => !wide)} onChange={updateNode} onSaveSource={(code) => { if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) }} onClose={() => { flush() + setEditorExpanded(false) setSelectedId(null) }} onDelete={() => selected && deleteNodes([selected.id])} diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 8b47c91..45d3bf3 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -7,6 +7,7 @@ import { } from "@xyflow/react" import { memo, useEffect, useRef, useState } from "react" +import { duration } from "@/lib/motion" import { cn } from "@/lib/utils" import type { FlowEdgeData } from "./deriveEdges" import { useLiveValue } from "./liveStore" @@ -53,7 +54,7 @@ function LiveEdgeComponent({ if (!live?.ts || live.ts === lastTs.current) return lastTs.current = live.ts setPulsing(true) - const timer = setTimeout(() => setPulsing(false), 300) + const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000) return () => clearTimeout(timer) }, [live?.ts]) diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 881aa10..38c2896 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -1,5 +1,5 @@ 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 type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client" @@ -22,6 +22,7 @@ import { SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" import { nodeSourceQueryOptions } from "./queries" import { PANEL_SECTION, SidePanel } from "./SidePanel" @@ -276,15 +277,19 @@ function PanelBody({ flow, nodeType, suggestions, + expanded, onChange, onSaveSource, + onToggleExpand, }: { node: NodeDef_Input flow: string nodeType: NodeTypeInfo | undefined suggestions: PortSuggestions + expanded: boolean onChange: (next: NodeDef_Input) => void onSaveSource: (code: string) => void + onToggleExpand: () => void }) { const hasSource = nodeType?.has_source ?? node.type === "python" const { data: source } = useQuery({ @@ -320,7 +325,7 @@ function PanelBody({ return ( <> -
+
- Code +
+ Code + +
void onSaveSource: (code: string) => void + onToggleExpand: () => void onClose: () => void onDelete: () => void }) { @@ -399,6 +422,7 @@ export function NodePanel({ label="Node settings" testId="node-panel" bodyKey={node?.id ?? "none"} + expanded={expanded} onClose={onClose} header={ node ? ( @@ -429,8 +453,10 @@ export function NodePanel({ flow={flow} nodeType={nodeType} suggestions={suggestions} + expanded={expanded} onChange={onChange} onSaveSource={onSaveSource} + onToggleExpand={onToggleExpand} /> ) : null} diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index f0a1e25..27fc9e4 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { useIsMobile } from "@/hooks/useMobile" import { duration, easeEmphasized, easeStandard } from "@/lib/motion" +import { cn } from "@/lib/utils" /** Same grammar as the shared `slideUp`, on the axis this panel travels. */ const panelSlide = { @@ -38,6 +39,7 @@ export function SidePanel({ label, testId, bodyKey, + expanded = false, header, footer, children, @@ -49,6 +51,8 @@ export function SidePanel({ testId: string /** Remounts the contents when the thing being edited changes. */ bodyKey: string + /** Fill the content area instead of floating beside the canvas. */ + expanded?: boolean header: ReactNode footer?: ReactNode children: ReactNode @@ -125,7 +129,14 @@ export function SidePanel({ role="complementary" aria-label={label} 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} diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index ba8220c..5428cd4 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -34,7 +34,7 @@ /* A message arriving lights its edge, then decays back to rest. */ @media (prefers-reduced-motion: no-preference) { .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 { @@ -58,7 +58,7 @@ border: 2px solid var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); 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 { diff --git a/frontend/src/index.css b/frontend/src/index.css index b8307e7..5913a02 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -35,8 +35,11 @@ --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --duration-fast: 150ms; - --duration-base: 250ms; - --duration-slow: 500ms; + --duration-base: 200ms; + --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-foreground: var(--foreground); --color-card: var(--card); diff --git a/frontend/src/lib/motion.ts b/frontend/src/lib/motion.ts index f8a85dc..739b423 100644 --- a/frontend/src/lib/motion.ts +++ b/frontend/src/lib/motion.ts @@ -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] /** 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 = { /** Enter / expressive moves (decelerate). */