Browse Source

style: redesign node handles to match reference UI

- Handle colors: cyan (#22d3ee) for image, magenta (#d946ef) for text
- Labels positioned outside node boundary (left of inputs, right of outputs)
- Label text color matches handle color
- Inputs sorted: image handles at top, text handles at bottom
- Visual gap between image and text input groups
- Added CSS variables for handle colors (used by label styling)
- Output handles also have colored labels (Video/Image)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
254cb01fa2
  1. 17
      src/app/globals.css
  2. 101
      src/components/nodes/GenerateImageNode.tsx
  3. 101
      src/components/nodes/GenerateVideoNode.tsx

17
src/app/globals.css

@ -65,14 +65,23 @@ body {
right: -5px;
}
/* Image handles - soft green */
/* Image handles - cyan/teal */
.react-flow__handle[data-handletype="image"] {
background: #10b981;
background: #22d3ee;
border-color: #22d3ee;
}
/* Prompt/text handles - soft blue */
/* Text/prompt handles - magenta/pink */
.react-flow__handle[data-handletype="text"],
.react-flow__handle[data-handletype="prompt"] {
background: #3b82f6;
background: #d946ef;
border-color: #d946ef;
}
/* CSS variables for handle colors (used by labels) */
:root {
--handle-color-image: #22d3ee;
--handle-color-text: #d946ef;
}
.react-flow__edge-path {

101
src/components/nodes/GenerateImageNode.tsx

@ -337,32 +337,49 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
>
{/* Dynamic input handles based on model schema (external providers only) */}
{!isGeminiProvider && nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
// Render handles from schema
nodeData.inputSchema.map((input, index) => {
const total = nodeData.inputSchema!.length;
const topPercent = ((index + 1) / (total + 1)) * 100;
return (
<div key={input.name}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
isConnectable={true}
title={input.description || input.label}
/>
{/* Handle label */}
<div
className="absolute text-[8px] text-neutral-500 whitespace-nowrap pointer-events-none"
style={{ left: 12, top: `calc(${topPercent}% - 6px)` }}
>
{input.label}
{input.required && <span className="text-red-400">*</span>}
// Render handles from schema, sorted by type (images first, text second)
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const sortedInputs = [...imageInputs, ...textInputs];
// Calculate positions with gap between image and text groups
const imageCount = imageInputs.length;
const textCount = textInputs.length;
const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap
return sortedInputs.map((input, index) => {
// Add 1 to index for text inputs if there are image inputs (to account for gap)
const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index;
const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100;
const isImage = input.type === "image";
return (
<div key={input.name}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
isConnectable={true}
title={input.description || input.label}
/>
{/* Handle label - positioned outside node, colored to match handle */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: `calc(${topPercent}% - 7px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
}}
>
{input.label}
</div>
</div>
</div>
);
})
);
});
})()
) : (
// Default handles for Gemini or when no schema
<>
@ -374,6 +391,17 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
data-handletype="image"
isConnectable={true}
/>
{/* Default image label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: "calc(35% - 7px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<Handle
type="target"
position={Position.Left}
@ -381,6 +409,17 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
style={{ top: "65%" }}
data-handletype="text"
/>
{/* Default text label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: "calc(65% - 7px)",
color: "var(--handle-color-text)",
}}
>
Prompt
</div>
</>
)}
{/* Image output */}
@ -388,8 +427,20 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
type="source"
position={Position.Right}
id="image"
style={{ top: "50%" }}
data-handletype="image"
/>
{/* Output label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style={{
left: `calc(100% + 14px)`,
top: "calc(50% - 7px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Preview area */}

101
src/components/nodes/GenerateVideoNode.tsx

@ -172,32 +172,49 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
>
{/* Dynamic input handles based on model schema */}
{nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
// Render handles from schema
nodeData.inputSchema.map((input, index) => {
const total = nodeData.inputSchema!.length;
const topPercent = ((index + 1) / (total + 1)) * 100;
return (
<div key={input.name}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
isConnectable={true}
title={input.description || input.label}
/>
{/* Handle label */}
<div
className="absolute text-[8px] text-neutral-500 whitespace-nowrap pointer-events-none"
style={{ left: 12, top: `calc(${topPercent}% - 6px)` }}
>
{input.label}
{input.required && <span className="text-red-400">*</span>}
// Render handles from schema, sorted by type (images first, text second)
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const sortedInputs = [...imageInputs, ...textInputs];
// Calculate positions with gap between image and text groups
const imageCount = imageInputs.length;
const textCount = textInputs.length;
const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap
return sortedInputs.map((input, index) => {
// Add 1 to index for text inputs if there are image inputs (to account for gap)
const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index;
const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100;
const isImage = input.type === "image";
return (
<div key={input.name}>
<Handle
type="target"
position={Position.Left}
id={input.name}
style={{ top: `${topPercent}%` }}
data-handletype={input.type}
isConnectable={true}
title={input.description || input.label}
/>
{/* Handle label - positioned outside node, colored to match handle */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: `calc(${topPercent}% - 7px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
}}
>
{input.label}
</div>
</div>
</div>
);
})
);
});
})()
) : (
// Default handles when no schema
<>
@ -209,6 +226,17 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
data-handletype="image"
isConnectable={true}
/>
{/* Default image label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: "calc(35% - 7px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<Handle
type="target"
position={Position.Left}
@ -216,6 +244,17 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
style={{ top: "65%" }}
data-handletype="text"
/>
{/* Default text label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 14px)`,
top: "calc(65% - 7px)",
color: "var(--handle-color-text)",
}}
>
Prompt
</div>
</>
)}
{/* Video output */}
@ -223,8 +262,20 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
type="source"
position={Position.Right}
id="image"
style={{ top: "50%" }}
data-handletype="image"
/>
{/* Output label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style={{
left: `calc(100% + 14px)`,
top: "calc(50% - 7px)",
color: "var(--handle-color-image)",
}}
>
Video
</div>
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Preview area */}

Loading…
Cancel
Save