Date: 2026-05-13 Status: Draft, pending review Author: Claude (with Matti)
Modernize the NodeTool editor UI — content-forward node bodies, cleaner port/edge styling, a curated left panel, an evolved inspector, and bespoke editing-node bodies — while preserving NodeTool’s existing graph engine, execution model, and Python bridge. The redesign reorganizes how nodes present content and how the user configures them — it does not change the workflow runtime, the WebSocket protocol, or the node-author API.
This is the new editor. No opt-in mode, no preview flag. Each PR replaces a piece of the existing UI directly.
process()/genProcess() contracts.| Track | Scope summary |
|---|---|
| A | Port + edge polish |
| B | ContentCardBody (parameterized by output type) |
| C | Left-panel quick-access categories |
| D | Inspector evolution + show-as-input toggle |
| E | 9 bespoke editing-node bodies |
| F | Group node label redesign |
NodeContent.tsx already chooses a body component by node type. Extend the registry:
resolveBody(nodeType):
if nodeType in BESPOKE_BODY_REGISTRY: // Track E
return BESPOKE_BODY_REGISTRY[nodeType]
if nodeType in CONTENT_CARD_REGISTRY: // Track B
return ContentCardBody
return GenericBody (basic_fields + advanced toggle)
The two registries are permanent — they’re how nodes opt into a specific body type, not transitional gates. Utility nodes (Constant…, If, Loop, Map, control-flow) intentionally stay on the generic body forever.
BaseNode.tsx’s outer shellNodeHeader.tsx (subtle polish only — already close to target look)basic_fields metadata — already the right spliteditableDynamicInputs infrastructure — already supports “+ Add another X”CompareImagesNode, image_editor, sketch, CommentNode)Today: each property field shows a permanent label next to its handle. After this track: labels are hidden by default and become visible while the cursor is over the node card (anywhere on the node, not just the handle). Handle shape and styling stay as they are today — only the label visibility changes.
web/src/components/node/BaseNode.tsx, web/src/components/node/PropertyField.tsxdata-show-labels (or equivalent CSS class) that toggles via the node’s existing hover state (:hover on the node root, or a state flag if hover via CSS doesn’t survive ReactFlow’s pointer model). Property labels are rendered with opacity: 0 / visibility: hidden by default; when the node is hovered, fade them in. Use a short transition (~120 ms) so labels don’t pop.The static label tags at edge endpoints (“Input*”, “Result”, “Prompt”, etc.) — already present in NodeTool to a degree near connection points; needs polish to render as small floating chips at edge tips.
web/src/core/graph/ (locate via ConnectionLine.tsx and ReactFlow edge components)Each handle gets a small inline pill containing its type icon (a “T” pill for text, image icon for image, etc.).
web/src/components/node/TypedPortChip.tsx, integrated into PropertyField.tsxIconForType(propertyType). Color from colorForType(propertyType). Handle itself is unchanged.Audit the edge renderer; ensure every edge’s stroke color equals colorForType(sourceHandle.type). Today, ConnectionLine.tsx (drag-in-progress) uses colorForType but the static edges may not.
web/src/core/graph/colorForType(source.type). Selected edge gets stroke 2px with a subtle outer glow. Unselected: 1.5px.NodeTool already supports data.collapsed. Fan-out workflows benefit from small header-only nodes as routing hubs (e.g., a single text prompt fanning out to many image generators). Tighten the collapsed visual:
web/src/components/node/BaseNode.tsx, NodeHeader.tsxdata.collapsed is true: hide body completely (display: none), header height clamped to ~40 px, ports remain on header strip top/bottom edges and stay hoverable/connectable. Width matches header content width (icon + title), not the previous expanded width.web/src/components/node_types/ContentCardBody.tsx
interface ContentCardBodyProps {
nodeId: string;
nodeMetadata: NodeMetadata;
data: NodeData;
primaryOutput: OutputSlot; // determines body variant
basicFields: string[]; // sub-set of properties rendered inline below preview
}
Renders a fixed-size card with three regions:
NodeHeader (provider icon + title + “…” menu)primaryOutput.type<DynamicInputButton /> (bottom-left) + primary action <RunModelButton /> (bottom-right) — optional, present only when relevant| Output type | Empty state | Populated state |
|---|---|---|
image / tensor[H,W,C] |
<CheckerDropzone /> with subtle “Run Model” hint |
Full-bleed image, object-fit: contain, falls back to cover when card width forces it |
image_mask / alpha |
Black background with white “no mask yet” glyph | Mask rendered against checker bg (alpha) or against source image if a compositing context exists |
video |
Checker dropzone with play-icon overlay | <VideoPlayer /> primitive (see §11) |
str / text |
Empty multiline area, “Run Model” prompt subtle | Readonly multiline TextField, auto-resizes to card; scroll if overflow |
model_3d |
Checker dropzone with cube icon | Static thumbnail; full 3D viewer not in scope |
audio |
Reuse existing AudioPlayer empty state |
AudioPlayer (already exists) |
| Output type | Default width × height (px) |
|---|---|
image (square) |
280 × 280 |
image (variable aspect) |
280 × 320 |
video |
320 × 220 (16:9 + player) |
str / text |
320 × 200 |
model_3d |
280 × 280 |
audio |
320 × 120 |
These are applied at node creation time (via nodeFactory or equivalent on drop). User resize remains available. The defaults exist only to make freshly dropped grids feel uniform.
The registry starts with explicit node types so we don’t accidentally affect utility nodes. Initial set:
*.GenerateImage*, *.TextToImage*, *.ImageToImage* in fal-nodes, replicate-nodes, base-nodes, openai/anthropic/google providersPrompt, PromptConcatenator, ImageDescriber, AnyLLM, ChatNodeRegistry lives in web/src/components/node_types/contentCardRegistry.ts. Authors of new nodes opt in by adding their node type.
web/src/components/ui_primitives/DynamicInputButton.tsx
+ Add another <thing> at bottom-left of the card bodyeditableDynamicInputs flow: clicks call addDynamicProperty(nodeId, baseName) which already exists for nodes that opt inPrompt and template nodes (variables are dynamic string properties referenced as `` in the prompt body)web/src/components/ui_primitives/RunModelButton.tsx
RUN_MODEL capability in metadata, or matches the generator pattern)WorkflowRunner.runNode(nodeId) or equivalent (verify exact API name during implementation)useStatusStore)Audit IconForType / brand icon registry. Ensure every provider/category has a crisp SVG mark at 20×20 and 14×14:
T glyph), Mask (alpha glyph), 3D (cube glyph), Video (filmstrip glyph), Audio (waveform glyph)Missing icons added under web/src/components/icons/providers/.
Today: PanelLeft.tsx is the workflow/asset panel; node picking happens via NodeMenu.tsx (full registry browser, modal or always-open). The full registry is good for finding, but the left panel needs to surface curated quick-access instead.
The left edge gets a two-column structure:
Each tile shows:
New chip if node.is_new| Sidebar item | Content |
|---|---|
| Search | Global node search (current NodeMenu collapsed into this column) |
| History | Recently-used nodes (already implemented as RecentNodesTiles.tsx) |
| Workflows | Saved workflows + favorites |
| Image Models | All Track-B image generators |
| Video Models | All Track-B video generators |
| 3D Models | All Track-B 3D generators |
| Quick access | Prompt, Import, Export, Preview, Import Model, Import LoRA — useful primitives |
| Tools | Editing nodes (Levels, Crop, Channels, Blur, Compositor, Painter, etc.) |
PanelLeft.tsx (resizable, existing)
├── Sidebar (new: QuickAccessSidebar.tsx) — icon stack
└── Main column (selected category)
├── CategorySearchBar.tsx
└── QuickAccessGrid.tsx — 2-col tile grid
└── QuickAccessTile.tsx — single node card
Stays available as a secondary panel or modal triggered from the Search sidebar item. Reuses existing NodeMenu.tsx rendering. The full menu does not disappear — it becomes the deep-search entry point.
Categories defined in web/src/config/quickAccessCategories.ts as a static array of { id, label, icon, filter }. Tags-based filter against node metadata (namespace, category, tags).
Inspector.tsx exists and renders the selected node’s full property list with Property* field renderers. This track shifts the split between what renders on the node body vs. inspector.
For each property on the selected node:
metadata.basic_fields): renders inside the node body (existing behavior) and in the inspectorbasic_fields): renders only in the inspector. On the node body, hidden unless either:
NodeInputs.tsx)Today’s “show all advanced” toggle on the node is removed — the inspector becomes the canonical place to find advanced props.
Inspector.tsx
├── Header: provider icon · node title · "..." menu
├── PropertyList (basic first, then advanced, grouped)
│ └── For each property:
│ ├── Label · (i) info tooltip · [⇢ show as input] toggle
│ └── PropertyField (existing renderers)
└── (footer reserved; intentionally empty — no cost/run section)
Info tooltip (i) reads from existing property.description.
New per-property toggle in the inspector, available on advanced properties only:
Implementation:
nodeData.exposedInputs: string[] — list of advanced property names the user has promotedNodeInputs.tsx’s existing visibility predicate (currently isBasicField || isConnected || showAdvancedFields) becomes isBasicField || isConnected || exposedInputs.includes(propertyName)exposedInputs so the handle persists even if the edge is later removed (the user clearly wanted it as an input)exposedInputs. If currently connected, prompt confirmation (would disconnect edge).web/src/components/Inspector.tsx — evolve in placeweb/src/components/properties/PropertyVisibilityToggle.tsx — newweb/src/stores/NodeData.ts — extend with exposedInputsweb/src/components/node/NodeInputs.tsx — extend visibility predicateNine nodes get bespoke bodies under web/src/components/node_types/editing/. Each is a self-contained component receiving the standard node props.
LevelsBody.tsx)useMemoCurves node; we may add a Levels alias node or extend Curves metadata with display_as: levelsCropBody.tsx)CropNode in base-nodes<img>ChannelsBody.tsx)ExtractChannel or adds a thin Channels node if missingBlurBody.tsx)Blur / GaussianBlur nodes — verify presentCompositorBody.tsx)Compositor node (or extend existing image-overlay node) accepting List[Image] + List[BlendMode] + List[float opacity]CompositorBody.tsx + LayerRow.tsxMasksExtractorBody.tsx)RotateAndFlipBody.tsx)RotateNode and FlipNode — may combine into a single front-end body that targets one node type with both rotate and flip paramsResizeBody.tsx)ResizeNode in base-nodesPainterBody.tsx)Painter node that outputs an alpha mask (paint = mask) stored as an image asset. May extend the existing sketch-related node if one exists, or add a new node.web/src/components/sketch/ infrastructure (hardened recently via cfc7b6702).web/src/components/node_types/editing/bespokeRegistry.ts maps node type → body component. Ships one node at a time; partial rollout is fine because un-registered nodes fall through to ContentCardBody then to the generic body.
Before each bespoke body lands, confirm the backend node exists with the right outputs:
| Body | Backend node | Status |
|---|---|---|
| Levels | Curves (rename or alias OK) |
Exists |
| Crop | CropNode |
Exists |
| Channels | extract-channel | Verify |
| Blur | Blur |
Verify in lib-image-filter.ts |
| Compositor | New Compositor |
Need to add |
| Masks Extractor | SAM / Bria | Verify per provider |
| Rotate-and-Flip | RotateNode + FlipNode |
Combine FE-side |
| Resize | ResizeNode |
Exists |
| Painter | New Painter node |
Need to add |
Two confirmed backend additions (Compositor, Painter). Both small (image-in, image/mask-out), no model dependency.
web/src/components/node/GroupNode.tsx already exists with title input + ColorPicker + RunGroupButton + BypassGroupButton.
Changes:
c_bg_group token; border removed (or reduced to a 1px subtle line at the same color, 30% opacity).Reuses existing ColorPicker, RunGroupButton, BypassGroupButton unchanged.
New primitives in web/src/components/ui_primitives/:
| Primitive | Purpose | Used by |
|---|---|---|
VideoPlayer.tsx |
Embeddable video player with custom controls (timestamp, seek, play/pause, speed) | ContentCardBody (video variant) |
CheckerDropzone.tsx |
Empty-state preview with optional drop handling | ContentCardBody (empty states) |
DynamicInputButton.tsx |
+ Add another X bottom-left button |
ContentCardBody, editing-node bodies |
RunModelButton.tsx |
Bottom-right primary action button | ContentCardBody, some editing-node bodies |
TypedPortChip.tsx |
Small type-icon pill at handle endpoints | PropertyField, edge endpoints |
EdgeEndpointLabel.tsx |
Floating chip near edge endpoints showing property name | Edge renderer (if not coverable via existing label slot) |
All primitives follow STRATEGY.md rules: no raw MUI components outside ui_primitives/ or editor_ui/, theme tokens only, default size constants live next to the primitive.
Each PR replaces a piece of the existing UI directly. No flag, no parallel paths — each one ships as the new default.
Sequence keeps risk low: small isolated tracks first; biggest visual changes once the primitives are in place; bespoke editor nodes last (one PR each).
exposedInputs data fieldBackend additions (Compositor and Painter nodes) ship in their respective Track-E PRs as paired backend + frontend changes.
If a PR introduces a visible regression, it gets reverted on its own — each PR is independently revertable because tracks are narrow.
CONTENT_CARD_REGISTRY scope drift. If we add too many nodes too fast, we’ll hit edge cases (nodes with multiple outputs, weird input shapes, side-effects). Mitigation: PR 4 ships with ~3 nodes; PR 6 expands only after manual QA per generator family.colorForType for non-trivial reasons (e.g., theme-aware mixing). Mitigation: spike A4 first; if it’s > 1 day, split into its own PR.exposedInputs migration on existing workflows. Workflows saved before this change have no exposedInputs field. Migration: treat missing field as []. No backfill needed; existing connected edges already auto-reveal via the isConnected predicate.sketch/ infrastructure. Recent fix (cfc7b6702) hardened sketch persistence, but Painter’s data shape (alpha mask output) may not match what sketch/ produces. Mitigation: read sketch code carefully before designing Painter’s data flow.Prompt node with `` templating — does this template grammar already exist in NodeTool, or are we inventing it? Action: Search for template-string handling before Track B implementation.ContentCardBody displays one primary preview. Which output is “primary”? Suggestion: First output in metadata.outputs unless metadata marks one as is_primary: true. Add this metadata field if not present.web/src/components/node_types/ContentCardBody.tsxweb/src/components/node_types/contentCardRegistry.tsweb/src/components/node_types/editing/{LevelsBody,CropBody,ChannelsBody,BlurBody,CompositorBody,MasksExtractorBody,RotateAndFlipBody,ResizeBody,PainterBody}.tsxweb/src/components/node_types/editing/bespokeRegistry.tsweb/src/components/node/TypedPortChip.tsxweb/src/components/ui_primitives/{VideoPlayer,CheckerDropzone,DynamicInputButton,RunModelButton,EdgeEndpointLabel}.tsxweb/src/components/node_menu/{QuickAccessSidebar,QuickAccessGrid,QuickAccessTile,CategorySearchBar}.tsxweb/src/components/properties/PropertyVisibilityToggle.tsxweb/src/config/quickAccessCategories.tsweb/src/components/icons/providers/* (icon fill-in pass)Compositor and Painter nodes in packages/base-nodes/src/nodes/web/src/components/node/BaseNode.tsx (collapsed polish, body routing)web/src/components/node/NodeContent.tsx (body resolution)web/src/components/node/NodeHeader.tsx (subtle polish only — already close)web/src/components/node/NodeInputs.tsx (exposedInputs visibility predicate)web/src/components/node/PropertyField.tsx (hover-only labels, type chip)web/src/components/node/GroupNode.tsx (pill header + tinted bg)web/src/components/Inspector.tsx (visibility toggle, info tooltips, layout)web/src/components/panels/PanelLeft.tsx (new sidebar + main column)web/src/core/graph/ edge renderer (typed colors, endpoint labels)web/src/stores/NodeData.ts (exposedInputs field)packages/kernel, packages/runtime)packages/node-sdk)