Improve graphfx-ui node UX

This commit is contained in:
2026-06-19 00:22:36 +02:00
parent 9c92ef42a1
commit 80916cb945
5 changed files with 381 additions and 68 deletions

View File

@@ -19,6 +19,8 @@ const edges = ref<Edge[]>([])
const warnings = ref<GraphFxFlowWarning[]>([]) const warnings = ref<GraphFxFlowWarning[]>([])
const layoutDirection = ref<'LR' | 'TB'>('LR') const layoutDirection = ref<'LR' | 'TB'>('LR')
const preferSavedPositions = ref(false) const preferSavedPositions = ref(false)
const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'My first graph')
const fileInput = ref<HTMLInputElement | null>(null)
const { fitView } = useVueFlow() const { fitView } = useVueFlow()
const stats = computed(() => ({ const stats = computed(() => ({
@@ -51,6 +53,49 @@ function loadSample() {
refreshFlow() refreshFlow()
} }
function saveSlotKey(name = selectedSaveSlot.value) {
return `GRAPHFX_UI_SAVE_${name}`
}
function saveGraph() {
refreshFlow()
if (parseError.value) return
localStorage.setItem('GRAPHFX_UI_SELECTED_SAVE_SLOT', selectedSaveSlot.value)
localStorage.setItem(saveSlotKey(), JSON.stringify(graph.value, null, 2))
}
function loadGraph() {
const saved = localStorage.getItem(saveSlotKey())
if (!saved) {
parseError.value = `No saved graph named "${selectedSaveSlot.value}".`
return
}
localStorage.setItem('GRAPHFX_UI_SELECTED_SAVE_SLOT', selectedSaveSlot.value)
editorText.value = saved
refreshFlow()
}
function exportGraph() {
refreshFlow()
if (parseError.value) return
const blob = new Blob([JSON.stringify(graph.value, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${selectedSaveSlot.value || 'graphfx-graph'}.json`
link.click()
URL.revokeObjectURL(url)
}
async function importGraph(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
editorText.value = await file.text()
input.value = ''
refreshFlow()
}
function relayout() { function relayout() {
const model = graphFxToVueFlow(graph.value) const model = graphFxToVueFlow(graph.value)
nodes.value = layoutGraph(model.nodes, model.edges, { nodes.value = layoutGraph(model.nodes, model.edges, {
@@ -86,6 +131,15 @@ refreshFlow()
<button type="button" class="secondary" @click="loadSample">Load sample</button> <button type="button" class="secondary" @click="loadSample">Load sample</button>
</div> </div>
<div class="savebar">
<input v-model="selectedSaveSlot" type="text" aria-label="Save slot name" />
<button type="button" class="secondary" @click="loadGraph">Load</button>
<button type="button" class="secondary" @click="saveGraph">Save</button>
<button type="button" class="secondary" @click="fileInput?.click()">Import</button>
<button type="button" class="secondary" @click="exportGraph">Export</button>
<input ref="fileInput" class="visually-hidden" type="file" accept="application/json,.json" @change="importGraph" />
</div>
<div class="options"> <div class="options">
<label> <label>
Layout Layout

View File

@@ -7,45 +7,64 @@ const props = defineProps<{
data: GraphFxNodeData data: GraphFxNodeData
}>() }>()
function typeClass(port: GraphFxPort) {
return `io-type-${port.type.toLowerCase()}`
}
function valuePreview(port: GraphFxPort) { function valuePreview(port: GraphFxPort) {
if (port.output) return 'connected' if (port.output) return ''
if (port.value === null || port.value === undefined) return 'empty' if (port.value === null || port.value === undefined) return ''
if (typeof port.value === 'string') return port.value.length > 28 ? `${port.value.slice(0, 28)}` : port.value if (typeof port.value === 'string') return port.value.length > 24 ? `${port.value.slice(0, 24)}` : port.value
if (typeof port.value === 'number' || typeof port.value === 'boolean') return String(port.value) if (typeof port.value === 'number' || typeof port.value === 'boolean') return String(port.value)
if (typeof port.value === 'object') return 'object' if (typeof port.value === 'object') return 'object'
return String(port.value) return String(port.value)
} }
function hasCustomLabel(port: GraphFxPort) {
return port.label && port.label !== port.name
}
</script> </script>
<template> <template>
<article class="graphfx-node"> <article class="graphfx-node">
<header class="graphfx-node__header"> <header class="graphfx-node__header">
<p class="graphfx-node__kind">GraphFX</p>
<h3>{{ data.name }}</h3> <h3>{{ data.name }}</h3>
<small>{{ data.id }}</small> <small>{{ data.id }}</small>
</header> </header>
<section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }"> <section class="graphfx-node__ports" :class="{ 'graphfx-node__ports--empty': !data.inputs.length && !data.outputs.length }">
<div class="graphfx-node__column"> <div class="graphfx-node__column graphfx-node__column--inputs">
<p class="graphfx-node__column-title">Inputs</p> <div v-if="!data.inputs.length" class="graphfx-node__empty">no inputs</div>
<div v-if="!data.inputs.length" class="graphfx-node__empty">none</div> <div
<div v-for="port in data.inputs" :key="port.name" class="graphfx-node__port graphfx-node__port--input"> v-for="port in data.inputs"
:key="port.name"
class="graphfx-node__port graphfx-node__port--input"
:class="[typeClass(port), { 'graphfx-node__port--connected': port.output, 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
:title="`${port.name} · ${port.type}${port.output ? ` · ${port.output}` : ''}`"
>
<Handle <Handle
type="target" type="target"
:id="getInputHandleId(data.id, port.name)" :id="getInputHandleId(data.id, port.name)"
:position="Position.Left" :position="Position.Left"
class="graphfx-node__handle graphfx-node__handle--input" class="graphfx-node__handle graphfx-node__handle--input"
/> />
<span class="graphfx-node__port-label">{{ port.label }}</span> <span class="graphfx-node__port-name">{{ port.name }}</span>
<span class="graphfx-node__port-value" :title="String(port.value ?? '')">{{ valuePreview(port) }}</span> <span v-if="hasCustomLabel(port)" class="graphfx-node__port-label">{{ port.label }}</span>
<span v-if="valuePreview(port)" class="graphfx-node__port-value">{{ valuePreview(port) }}</span>
</div> </div>
</div> </div>
<div class="graphfx-node__column graphfx-node__column--outputs"> <div class="graphfx-node__column graphfx-node__column--outputs">
<p class="graphfx-node__column-title">Outputs</p> <div v-if="!data.outputs.length" class="graphfx-node__empty">no outputs</div>
<div v-if="!data.outputs.length" class="graphfx-node__empty">none</div> <div
<div v-for="port in data.outputs" :key="port.name" class="graphfx-node__port graphfx-node__port--output"> v-for="port in data.outputs"
<span class="graphfx-node__port-label">{{ port.label }}</span> :key="port.name"
class="graphfx-node__port graphfx-node__port--output"
:class="[typeClass(port), { 'graphfx-node__port--labeled': hasCustomLabel(port) }]"
:title="`${port.name} · ${port.type}`"
>
<span class="graphfx-node__port-name">{{ port.name }}</span>
<span v-if="hasCustomLabel(port)" class="graphfx-node__port-label">{{ port.label }}</span>
<Handle <Handle
type="source" type="source"
:id="getOutputHandleId(data.id, port.name)" :id="getOutputHandleId(data.id, port.name)"

View File

@@ -1,9 +1,12 @@
import type { Edge, Node } from '@vue-flow/core' import { MarkerType, type Edge, type Node } from '@vue-flow/core'
import { ioTypeFor, type GraphFxIoType } from './nodeDefinitions'
export type GraphFxIoValue = { export type GraphFxIoValue = {
label?: string | null label?: string | null
value?: unknown value?: unknown
output?: string | null output?: string | null
type?: GraphFxIoType
definition?: { type?: GraphFxIoType }
} }
export type GraphFxSerializedNode = { export type GraphFxSerializedNode = {
@@ -27,7 +30,7 @@ export type GraphFxSerializedGraph = GraphFxSerializedNode[]
export type GraphFxPort = { export type GraphFxPort = {
name: string name: string
label: string label: string
type?: string type: GraphFxIoType
output?: string | null output?: string | null
value?: unknown value?: unknown
} }
@@ -63,14 +66,25 @@ export function getOutputHandleId(nodeId: string, outputName: string) {
return `${nodeId}:out:${outputName}` return `${nodeId}:out:${outputName}`
} }
const ioTypeColors: Record<GraphFxIoType, string> = {
Image: '#4dd7ff',
Number: '#f6c85f',
String: '#9ee66f',
Boolean: '#c084fc',
Color: '#ff78b7',
Font: '#fb923c',
Unknown: '#94a3b8',
}
function labelFor(name: string, io?: GraphFxIoValue) { function labelFor(name: string, io?: GraphFxIoValue) {
return io?.label || name return io?.label || name
} }
function toPorts(record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') { function toPorts(nodeName: string, record: Record<string, GraphFxIoValue> | undefined, direction: 'in' | 'out') {
return Object.entries(record || {}).map(([name, io]) => ({ return Object.entries(record || {}).map(([name, io]) => ({
name, name,
label: labelFor(name, io), label: labelFor(name, io),
type: ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type),
output: direction === 'in' ? io?.output || null : null, output: direction === 'in' ? io?.output || null : null,
value: direction === 'in' ? io?.value : undefined, value: direction === 'in' ? io?.value : undefined,
})) }))
@@ -121,8 +135,8 @@ export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowMode
.filter((item) => item?.node?.id && item.node.name) .filter((item) => item?.node?.id && item.node.name)
.map((item, index) => { .map((item, index) => {
const { hasSavedPosition, position } = savedPosition(item, index) const { hasSavedPosition, position } = savedPosition(item, index)
const inputs = toPorts(item.node.options?.in, 'in') const inputs = toPorts(item.node.name, item.node.options?.in, 'in')
const outputs = toPorts(item.node.options?.out, 'out') const outputs = toPorts(item.node.name, item.node.options?.out, 'out')
return { return {
id: item.node.id, id: item.node.id,
@@ -159,15 +173,20 @@ export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowMode
continue continue
} }
const sourceNode = nodes.find((node) => node.id === source.nodeId)
const sourcePort = sourceNode?.data?.outputs.find((output) => output.name === source.outputName)
const edgeColor = ioTypeColors[sourcePort?.type || 'Unknown']
edges.push({ edges.push({
id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`, id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`,
source: source.nodeId, source: source.nodeId,
target: item.node.id, target: item.node.id,
sourceHandle: getOutputHandleId(source.nodeId, source.outputName), sourceHandle: getOutputHandleId(source.nodeId, source.outputName),
targetHandle: getInputHandleId(item.node.id, inputName), targetHandle: getInputHandleId(item.node.id, inputName),
label: inputName,
type: 'smoothstep', type: 'smoothstep',
animated: false, animated: false,
style: { stroke: edgeColor, strokeWidth: 2.8 },
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
}) })
} }
} }

View File

@@ -0,0 +1,137 @@
export type GraphFxIoType = 'Image' | 'Number' | 'String' | 'Boolean' | 'Color' | 'Font' | 'Unknown'
export type GraphFxNodeDefinition = {
in?: Record<string, GraphFxIoType>
out?: Record<string, GraphFxIoType>
}
const imageDimensions = {
image: 'Image',
width: 'Number',
height: 'Number',
} as const
const canvas2dBase = {
in: { image: 'Image', width: 'Number' },
out: imageDimensions,
} as const
const webglBase = {
in: { image: 'Image' },
out: imageDimensions,
} as const
export const graphFxNodeDefinitions: Record<string, GraphFxNodeDefinition> = {
Webcam: { out: { image: 'Image', width: 'Number', height: 'Number', camera: 'String' } },
Resize: { in: { ...canvas2dBase.in, height: 'Number' }, out: canvas2dBase.out },
Compose: {
in: {
...canvas2dBase.in,
height: 'Number',
fg: 'Image',
fgX: 'Number',
fgY: 'Number',
bg: 'Image',
bgX: 'Number',
bgY: 'Number',
background: 'Image',
overlay: 'Image',
qr: 'Image',
},
out: { image: 'Image' },
},
Fill: { in: { ...canvas2dBase.in, height: 'Number', color: 'Color' }, out: canvas2dBase.out },
Flip: { in: { ...canvas2dBase.in, horizontal: 'Boolean', vertical: 'Boolean' }, out: canvas2dBase.out },
Rotate: { in: { ...canvas2dBase.in, angle: 'Number' }, out: canvas2dBase.out },
Text: {
in: {
...canvas2dBase.in,
text: 'String',
fontSize: 'Number',
fontStyle: 'String',
font: 'Font',
color: 'Color',
x: 'Number',
y: 'Number',
},
out: canvas2dBase.out,
},
EmptySpace: { out: { w: 'Number', h: 'Number', top: 'Number', right: 'Number', bottom: 'Number', left: 'Number' } },
BrightnessContrast: { in: { ...webglBase.in, brightness: 'Number', contrast: 'Number' }, out: webglBase.out },
HSV: { in: { ...webglBase.in, hue: 'Number', saturation: 'Number', value: 'Number' }, out: webglBase.out },
Sepia: { in: { ...webglBase.in, amount: 'Number' }, out: webglBase.out },
GreenScreen: {
in: {
...webglBase.in,
mask: 'Image',
feather: 'Number',
balance: 'Number',
screen: 'Color',
screenWeight: 'Number',
clipBlack: 'Number',
clipWhite: 'Number',
},
out: webglBase.out,
},
Channels: { in: { ...webglBase.in, red: 'Number', green: 'Number', blue: 'Number', alpha: 'Number', amount: 'Number' }, out: webglBase.out },
GradientMap: { in: { ...webglBase.in, amount: 'Number', map: 'Image' }, out: webglBase.out },
ColorLookupTable: { in: { ...webglBase.in, amount: 'Number', map: 'Image' }, out: webglBase.out },
Vignette: { in: { ...webglBase.in, amount: 'Number', size: 'Number' }, out: webglBase.out },
Blur: { in: { ...webglBase.in, radius: 'Number', sigma: 'Number', passes: 'Number' }, out: webglBase.out },
SoftEdge: { in: { ...webglBase.in, radius: 'Number', sigma: 'Number', passes: 'Number' }, out: webglBase.out },
Edge: { in: { ...webglBase.in, dx: 'Number', dy: 'Number' }, out: webglBase.out },
KernelPreset: { in: { ...webglBase.in, preset: 'String', passes: 'Number', multiplier: 'Number' }, out: webglBase.out },
Levels: { in: { ...webglBase.in, shadow: 'Number', gamma: 'Number', highlight: 'Number', dark: 'Number', light: 'Number' }, out: webglBase.out },
LevelsAuto: { in: webglBase.in, out: webglBase.out },
Tint: { in: { ...webglBase.in, amount: 'Number', color: 'Color' }, out: webglBase.out },
BodySegmentation: { in: { image: 'Image', maskBlurAmount: 'Number', foregroundThreshold: 'Number' }, out: imageDimensions },
BodyPartSegmentation: { in: { image: 'Image', maskBlurAmount: 'Number', foregroundThreshold: 'Number' }, out: imageDimensions },
FaceFeaturePosition: {
in: { image: 'Image', modelUrl: 'String' },
out: {
image: 'Image',
faceX: 'Number',
faceY: 'Number',
faceWidth: 'Number',
faceHeight: 'Number',
rightEyeX: 'Number',
rightEyeY: 'Number',
leftEyeX: 'Number',
leftEyeY: 'Number',
},
},
Numbers: { in: { x: 'Number', y: 'Number', z: 'Number' }, out: { x: 'Number', y: 'Number', z: 'Number' } },
NumberBinaryOperation: { in: { x: 'Number', y: 'Number', operation: 'String' }, out: { result: 'Number' } },
Font: { in: { fontSize: 'Number', fontStyle: 'String', font: 'Font' }, out: { fontSize: 'Number', fontStyle: 'String', font: 'Font' } },
Disable: { in: { image: 'Image', disabled: 'Boolean' }, out: imageDimensions },
Api: {
in: Object.fromEntries([...Array(8)].flatMap((_, i) => [[`name${i}`, 'String'], [`image${i}`, 'Image']]).concat([['image', 'Image'], ['endpoint', 'String']])) as Record<string, GraphFxIoType>,
out: { image: 'Image', response: 'String' },
},
ApiJson: {
in: Object.fromEntries([...Array(8)].map((_, i) => [`propertyPath${i}`, 'String']).concat([['url', 'String']])) as Record<string, GraphFxIoType>,
out: Object.fromEntries([...Array(8)].map((_, i) => [`value${i}`, 'String'])) as Record<string, GraphFxIoType>,
},
Eval: {
in: Object.fromEntries([['image', 'Image'], ...Array.from({ length: 8 }, (_, i) => [`i${i}`, 'Number']), ['script', 'String']]) as Record<string, GraphFxIoType>,
out: { image: 'Image', result: 'Number', resultString: 'String' },
},
QRCodeGenerator: { in: { text: 'String', size: 'Number', correction: 'String', margin: 'Number' }, out: { image: 'Image', size: 'Number', margin: 'Number' } },
QRCodeDetector: { in: { image: 'Image', filter: 'String' }, out: { image: 'Image', rotation: 'Number', x: 'Number', y: 'Number', size: 'Number' } },
MarkerDetector: { in: { image: 'Image', markerId: 'Number', pivotPoint: 'String' }, out: { image: 'Image', markerId: 'Number', x: 'Number', y: 'Number', rotation: 'Number', x1: 'Number', y1: 'Number', x2: 'Number', y2: 'Number', x3: 'Number', y3: 'Number', x4: 'Number', y4: 'Number' } },
WaitForAll: { in: { image0: 'Image', image1: 'Image' }, out: { image0: 'Image', image1: 'Image' } },
Base64ToImg: { in: { base64: 'String' }, out: imageDimensions },
ImgToBase64: { in: { image: 'Image', type: 'String' }, out: { base64: 'String' } },
}
export function ioTypeFor(nodeName: string, direction: 'in' | 'out', ioName: string, fallback?: unknown): GraphFxIoType {
const explicit = typeof fallback === 'string' ? fallback : undefined
if (explicit && ['Image', 'Number', 'String', 'Boolean', 'Color', 'Font'].includes(explicit)) {
return explicit as GraphFxIoType
}
return graphFxNodeDefinitions[nodeName]?.[direction]?.[ioName] || 'Unknown'
}

View File

@@ -112,6 +112,39 @@ code {
gap: 0.7rem; gap: 0.7rem;
} }
.savebar {
display: grid;
grid-template-columns: 1fr repeat(4, auto);
gap: 0.45rem;
padding: 0.62rem;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 16px;
background: rgb(255 255 255 / 4%);
}
.savebar input[type="text"] {
min-width: 0;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 10px;
padding: 0.55rem 0.68rem;
color: #f5f8ff;
background: #101827;
}
.savebar button {
padding: 0.55rem 0.62rem;
font-size: 0.78rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.options { .options {
display: grid; display: grid;
gap: 0.72rem; gap: 0.72rem;
@@ -266,97 +299,136 @@ code {
} }
.graphfx-node { .graphfx-node {
min-width: 280px; min-width: 276px;
overflow: hidden; overflow: hidden;
border: 1px solid rgb(157 177 255 / 30%); border: 1px solid rgb(157 177 255 / 24%);
border-radius: 20px; border-radius: 18px;
background: linear-gradient(180deg, rgb(28 39 63 / 96%), rgb(13 20 34 / 96%)); background: linear-gradient(180deg, rgb(24 34 55 / 97%), rgb(11 17 29 / 97%));
box-shadow: box-shadow:
0 18px 48px rgb(0 0 0 / 34%), 0 18px 48px rgb(0 0 0 / 34%),
inset 0 1px 0 rgb(255 255 255 / 8%); inset 0 1px 0 rgb(255 255 255 / 8%);
} }
.graphfx-node__header { .graphfx-node__header {
padding: 0.9rem 1rem 0.75rem; padding: 0.72rem 0.86rem 0.66rem;
border-bottom: 1px solid rgb(255 255 255 / 8%); border-bottom: 1px solid rgb(255 255 255 / 8%);
background: background:
radial-gradient(circle at top right, rgb(55 213 255 / 20%), transparent 10rem), radial-gradient(circle at top right, rgb(55 213 255 / 16%), transparent 9rem),
rgb(255 255 255 / 3%); rgb(255 255 255 / 3%);
} }
.graphfx-node__kind {
margin: 0 0 0.18rem;
color: #33e1ba;
font-size: 0.68rem;
font-weight: 950;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.graphfx-node h3 { .graphfx-node h3 {
margin: 0; margin: 0;
color: #fff; color: #fff;
font-size: 1.05rem; font-size: 1.02rem;
line-height: 1.1; line-height: 1.1;
font-weight: 900;
letter-spacing: -0.015em;
} }
.graphfx-node small { .graphfx-node small {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.18rem;
max-width: 240px; max-width: 240px;
overflow: hidden; overflow: hidden;
color: #8fa4c7; color: #8296b8;
font-size: 0.7rem; font-size: 0.66rem;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.graphfx-node__ports { .graphfx-node__ports {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0.55rem; gap: 0.5rem;
padding: 0.75rem 0.6rem 0.85rem; padding: 0.58rem 0.56rem 0.68rem;
} }
.graphfx-node__ports--empty { grid-template-columns: 1fr; } .graphfx-node__ports--empty { grid-template-columns: 1fr; }
.graphfx-node__column-title { .graphfx-node__column {
margin: 0 0 0.35rem; display: grid;
padding: 0 0.4rem; align-content: start;
color: #7f93b6; gap: 0.28rem;
font-size: 0.65rem;
font-weight: 950;
text-transform: uppercase;
letter-spacing: 0.12em;
} }
.graphfx-node__port { .graphfx-node__port {
--io-color: #94a3b8;
position: relative; position: relative;
display: grid; display: grid;
gap: 0.08rem; min-height: 34px;
min-height: 31px; padding: 0.36rem 0.48rem;
padding: 0.38rem 0.45rem; border: 1px solid color-mix(in srgb, var(--io-color) 28%, transparent);
border-radius: 10px; border-radius: 11px;
background: rgb(255 255 255 / 5%); background:
color: #dbe7ff; linear-gradient(90deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
rgb(255 255 255 / 4%);
color: #eef4ff;
font-size: 0.76rem; font-size: 0.76rem;
} }
.graphfx-node__port + .graphfx-node__port { margin-top: 0.25rem; } .graphfx-node__port--input {
padding-left: 0.72rem;
text-align: left;
}
.graphfx-node__port--input { padding-left: 0.62rem; } .graphfx-node__port--output {
.graphfx-node__port--output { padding-right: 0.62rem; text-align: right; } padding-right: 0.72rem;
text-align: right;
background:
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 16%, transparent), transparent 46%),
rgb(255 255 255 / 4%);
}
.graphfx-node__port-label { .graphfx-node__port--labeled {
min-height: 44px;
background:
linear-gradient(90deg, color-mix(in srgb, var(--io-color) 26%, transparent), transparent 58%),
rgb(255 255 255 / 6%);
}
.graphfx-node__port--output.graphfx-node__port--labeled {
background:
linear-gradient(270deg, color-mix(in srgb, var(--io-color) 26%, transparent), transparent 58%),
rgb(255 255 255 / 6%);
}
.graphfx-node__port-name {
overflow: hidden; overflow: hidden;
color: #f8fbff;
font-size: 0.82rem;
font-weight: 950;
letter-spacing: -0.01em;
line-height: 1.08;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 850; }
.graphfx-node__port-label {
display: inline-block;
width: fit-content;
max-width: 100%;
margin-top: 0.2rem;
overflow: hidden;
border-radius: 999px;
padding: 0.11rem 0.42rem;
background: color-mix(in srgb, var(--io-color) 88%, white 12%);
color: #06101f;
font-size: 0.66rem;
font-weight: 950;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.graphfx-node__port--output .graphfx-node__port-label {
justify-self: end;
} }
.graphfx-node__port-value { .graphfx-node__port-value {
overflow: hidden; overflow: hidden;
color: #8397ba; color: color-mix(in srgb, var(--io-color) 68%, white 32%);
font-size: 0.64rem; font-size: 0.66rem;
font-weight: 750;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -370,18 +442,30 @@ code {
} }
.graphfx-node__handle { .graphfx-node__handle {
width: 11px; width: 12px;
height: 11px; height: 12px;
border: 2px solid #0c1322; border: 2px solid #0a1020;
background: #33e1ba; background: transparent;
box-shadow: 0 0 0 2px var(--io-color), 0 0 14px color-mix(in srgb, var(--io-color) 45%, transparent);
} }
.graphfx-node__handle--input { .graphfx-node__handle--input {
left: -6px; left: -6px;
background: #7da2ff;
} }
.graphfx-node__handle--output { .graphfx-node__handle--output {
right: -6px; right: -6px;
background: #33e1ba; background: var(--io-color);
} }
.graphfx-node__port--connected .graphfx-node__handle--input {
background: var(--io-color);
}
.io-type-image { --io-color: #4dd7ff; }
.io-type-number { --io-color: #f6c85f; }
.io-type-string { --io-color: #9ee66f; }
.io-type-boolean { --io-color: #c084fc; }
.io-type-color { --io-color: #ff78b7; }
.io-type-font { --io-color: #fb923c; }
.io-type-unknown { --io-color: #94a3b8; }