Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

1223 lines
28 KiB
Svelte

<script lang="ts">
import { getContext } from "svelte";
import { resizeNode, workflow } from "./workflow-store";
import NodeWidget from "./NodeWidget.svelte";
import PlayIcon from "./icons/PlayIcon.svelte";
import OpenLinkIcon from "./icons/OpenLinkIcon.svelte";
import {
PORT_COLOR,
PORT_COLOR_DIM,
ports_compatible
} from "./workflow-types";
import type {
WFNode,
PortType,
NodeDataValue,
NodeStatus
} from "./workflow-types";
interface Props {
id: string;
data: WFNode;
selected?: boolean;
}
let { id, data: node, selected = false }: Props = $props();
interface WfCtx {
pending: {
from_node_id: string;
from_port_id: string;
type: PortType;
reversed?: boolean;
} | null;
nodeStatus: Record<string, NodeStatus>;
nodeErrors: Record<string, string>;
staleNodes: Set<string>;
connectedPorts: Set<string>;
readOnly: boolean;
ondatachange: (
nodeId: string,
portId: string,
value: NodeDataValue
) => void;
onremove: (id: string) => void;
onopenpicker: (id: string) => void;
onswitchendpoint: (id: string, endpointName: string) => void;
onhydratendpoints: (id: string, spaceId: string) => void;
onrunnode: (id: string) => void;
onselect: (id: string, additive?: boolean) => void;
onnodepointerdown: (e: PointerEvent, id: string) => void;
onportpointerdown: (
e: PointerEvent,
nodeId: string,
portId: string,
type: PortType,
isInput: boolean
) => void;
onpopout: (
nodeId: string,
portId: string,
type: PortType,
isInput: boolean
) => void;
}
const ctx = getContext<WfCtx>("wf");
const pending = $derived(ctx.pending);
const readOnly = $derived(ctx.readOnly);
const status = $derived((ctx.nodeStatus[id] ?? "idle") as NodeStatus);
const error = $derived(ctx.nodeErrors[id] ?? "");
const isStale = $derived(ctx.staleNodes.has(id));
const connectedPorts = $derived(ctx.connectedPorts);
// Only operator nodes have meaningful per-node execution. References just
// hold values; subjects just display passthrough.
const canRunSolo = $derived(node.kind === "transform");
let nodeEl: HTMLDivElement;
let editingLabel = $state(false);
let labelInput: HTMLInputElement;
let showAllInputs = $state(false);
function castChoiceValue(v: string, portType: PortType): NodeDataValue {
if (portType === "number") {
const n = Number(v);
return Number.isNaN(n) ? v : n;
}
if (portType === "boolean") {
if (v === "true") return true;
if (v === "false") return false;
}
return v;
}
function renameNode(newLabel: string): void {
const trimmed = newLabel.trim();
if (!readOnly && trimmed && trimmed !== node.label) {
workflow.update((wf) => ({
...wf,
references: wf.references.map((n) =>
n.id === node.id ? { ...n, label: trimmed } : n
),
operators: wf.operators.map((n) =>
n.id === node.id ? { ...n, label: trimmed } : n
),
subjects: wf.subjects.map((n) =>
n.id === node.id ? { ...n, label: trimmed } : n
)
}));
}
editingLabel = false;
}
const primaryType = $derived<PortType>(
node.outputs[0]?.type ?? node.inputs[0]?.type ?? "any"
);
const accentColor = $derived(PORT_COLOR[primaryType]);
const accentDim = $derived(PORT_COLOR_DIM[primaryType]);
const isComponent = $derived(node.kind === "component");
const componentIsOutput = $derived(
isComponent && node.inputs[0]
? connectedPorts.has(`${node.id}:${node.inputs[0].id}:input`)
: false
);
const mode = $derived(
isComponent ? (componentIsOutput ? "output" : "input") : node.kind
);
const hasWidget = $derived(mode === "input" || mode === "output");
const widgetPortId = $derived(
mode === "input"
? (node.outputs[0]?.id ?? null)
: mode === "output"
? (node.inputs[0]?.id ?? null)
: null
);
const widgetType = $derived<PortType | null>(
mode === "input"
? (node.outputs[0]?.type ?? null)
: mode === "output"
? (node.inputs[0]?.type ?? null)
: null
);
const isReadonly = $derived(mode === "output");
function sourceHFUrl(n: WFNode): string {
if (n.space_id) return `https://huggingface.co/spaces/${n.space_id}`;
if (n.model_id) return `https://huggingface.co/${n.model_id}`;
if (n.dataset_id) return `https://huggingface.co/datasets/${n.dataset_id}`;
return "";
}
const isDropTarget = $derived(
pending !== null &&
pending.from_node_id !== node.id &&
node.inputs.some(
(p) =>
ports_compatible(pending.type, p.type) &&
!connectedPorts.has(`${node.id}:${p.id}:input`)
)
);
$effect(() => {
if (!nodeEl) return;
const ro = new ResizeObserver(([entry]) => {
const h = Math.ceil(entry.borderBoxSize[0].blockSize);
if (Math.abs(h - node.height) > 1) {
resizeNode(node.id, node.width, h);
}
});
ro.observe(nodeEl);
return () => ro.disconnect();
});
</script>
<div
class="wf-node"
class:node-transform={node.kind === "transform"}
class:node-running={status === "running"}
class:node-done={status === "done"}
class:node-error={status === "error"}
class:node-stale={isStale}
class:node-selected={selected}
class:node-droptarget={isDropTarget}
class:has-pending={pending !== null}
bind:this={nodeEl}
onclick={(e) => ctx.onselect(node.id, e.shiftKey)}
style="
width: {node.width}px;
--accent: {accentColor};
--accent-dim: {accentDim};
"
>
<div class="node-header" role="button" tabindex="-1">
<div class="node-header-top">
{#if status === "running"}
<span class="node-status-spinner"></span>
{/if}
{#if editingLabel}
<input
bind:this={labelInput}
class="node-label-input"
value={node.label}
onblur={(e) => renameNode(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === "Enter") renameNode(e.currentTarget.value);
if (e.key === "Escape") editingLabel = false;
e.stopPropagation();
}}
onmousedown={(e) => e.stopPropagation()}
/>
{:else}
<span
class="node-label"
ondblclick={(e) => {
e.stopPropagation();
if (readOnly) return;
editingLabel = true;
requestAnimationFrame(() => labelInput?.select());
}}>{node.label}</span
>
{/if}
{#if canRunSolo}
<button
class="node-run"
class:node-run-stale={isStale}
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
ctx.onrunnode(node.id);
}}
title={isStale ? "Run this node (inputs changed)" : "Run this node"}
aria-label="Run this node"
>
<PlayIcon />
</button>
{/if}
{#if !readOnly}
<button
class="node-delete"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
ctx.onremove(node.id);
}}
title="Delete node">&times;</button
>
{/if}
</div>
</div>
<!-- Source label for transform nodes — floats above the card.
Components are pure data containers and have no source label. -->
{#if node.kind === "transform"}
{#if node.space_id || node.model_id || node.dataset_id}
{@const itemId = node.space_id ?? node.model_id ?? node.dataset_id ?? ""}
<div class="node-outside-label-wrap">
{#if readOnly}
<span class="node-outside-label"
>{itemId.split("/").pop() ?? itemId}</span
>
{:else}
<button
class="node-outside-label"
title="Click to change source"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
ctx.onopenpicker(node.id);
}}>{itemId.split("/").pop() ?? itemId}</button
>
{/if}
<a
class="node-outside-link"
href={sourceHFUrl(node)}
target="_blank"
rel="noopener noreferrer"
title="Open on HuggingFace"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
aria-label="Open on HuggingFace"
>
<OpenLinkIcon />
</a>
</div>
{:else if node.fn}
<!-- Python fn nodes already have a "source" — their function
name. Show that above the card; no picker swap because
fn nodes can't be replaced by a Space/Model in place. -->
<div class="node-outside-label-wrap">
<span class="node-outside-label node-outside-label-fn">{node.fn}()</span
>
</div>
{:else if !readOnly}
<button
class="node-outside-label node-outside-label-empty"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
ctx.onopenpicker(node.id);
}}>+ source</button
>
{/if}
{/if}
{#if node.space_id}
<div
class="node-endpoint-row"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
>
{#if node.endpoints && node.endpoints.length > 1}
<select
class="node-endpoint-select"
disabled={readOnly}
value={node.endpoint ?? ""}
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
onchange={(e) => {
e.stopPropagation();
ctx.onswitchendpoint(
node.id,
(e.currentTarget as HTMLSelectElement).value
);
}}
>
{#each node.endpoints as ep}
<option value={ep.name}>{ep.name}</option>
{/each}
</select>
{:else if !node.endpoints}
<button
class="node-endpoint-load"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
ctx.onhydratendpoints(node.id, node.space_id ?? "");
}}
title="Discover this Space's other endpoints"
>
{node.endpoint ?? "/predict"}
</button>
{/if}
</div>
{/if}
<!-- Input ports -->
{#if node.inputs.length > 0}
{@const hiddenCount = node.inputs.filter(
(p) =>
p.required === false && !connectedPorts.has(`${node.id}:${p.id}:input`)
).length}
{@const collapsible = hiddenCount > 0}
<div class="ports" class:widget-ports={hasWidget}>
{#each node.inputs as port}
{@const portConnected = connectedPorts.has(
`${node.id}:${port.id}:input`
)}
{@const visible =
showAllInputs || portConnected || port.required !== false}
{#if visible}
<div class="port-row input-row" class:widget-port={hasWidget}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="port-handle-sf input-handle-sf"
class:connected={portConnected}
class:incompatible={pending !== null &&
!ports_compatible(pending.type, port.type)}
class:pulse={pending !== null &&
ports_compatible(pending.type, port.type)}
class:popout-hint={!portConnected && pending === null}
data-node-id={node.id}
data-port-id={port.id}
data-port-type={port.type}
data-port-direction="input"
style="--port-color: {PORT_COLOR[port.type]}"
title={!portConnected && pending === null
? "Click to create an input node"
: undefined}
onpointerdown={(e) =>
ctx.onportpointerdown(e, node.id, port.id, port.type, true)}
onclick={(e) => {
if (!portConnected && pending === null) {
e.stopPropagation();
ctx.onpopout(node.id, port.id, port.type, true);
}
}}
></div>
{#if !hasWidget}
<span
class="port-label"
class:port-label-optional={port.required === false}
>{port.label}</span
>
<span class="port-type-tag" style="color: {PORT_COLOR[port.type]}"
>{port.type}</span
>
{/if}
</div>
{#if !portConnected && node.kind === "transform" && (port.type === "text" || port.type === "number" || port.type === "boolean" || port.type === "any" || port.type === "json")}
<div
class="port-inline-config"
onmousedown={(e) => e.stopPropagation()}
>
{#if port.choices && port.choices.length > 0 && port.multiselect}
{@const selected = Array.isArray(node.data?.[port.id])
? (node.data[port.id] as string[])
: []}
<div class="inline-choices">
{#each port.choices as choice}
<label class="inline-checkbox">
<input
type="checkbox"
checked={selected.includes(choice)}
onchange={(e) => {
const next = e.currentTarget.checked
? [...selected, choice]
: selected.filter((c) => c !== choice);
ctx.ondatachange(node.id, port.id, next);
}}
/>
<span>{choice}</span>
</label>
{/each}
</div>
{:else if port.choices && port.choices.length > 0}
{@const raw = node.data?.[port.id] ?? port.default_value}
{@const current = raw != null ? String(raw) : ""}
{@const hasCurrent = port.choices.includes(current)}
<select
class="inline-input inline-select"
value={hasCurrent ? current : ""}
onchange={(e) => {
const v = e.currentTarget.value;
if (v === "") return;
ctx.ondatachange(
node.id,
port.id,
castChoiceValue(v, port.type)
);
}}
>
{#if !hasCurrent}
<option value="" disabled>Select {port.label}</option>
{/if}
{#each port.choices as choice}
<option value={choice}>{choice}</option>
{/each}
</select>
{:else if port.type === "number"}
<input
class="inline-input inline-number"
type="number"
step="any"
placeholder={port.default_value != null
? String(port.default_value)
: "0"}
value={node.data?.[port.id] ?? ""}
oninput={(e) =>
ctx.ondatachange(
node.id,
port.id,
parseFloat(e.currentTarget.value) || 0
)}
/>
{:else if port.type === "boolean"}
<label class="inline-checkbox">
<input
type="checkbox"
checked={!!node.data?.[port.id]}
onchange={(e) =>
ctx.ondatachange(
node.id,
port.id,
e.currentTarget.checked
)}
/>
<span>{node.data?.[port.id] ? "On" : "Off"}</span>
</label>
{:else}
<input
class="inline-input"
type="text"
placeholder={port.default_value != null
? String(port.default_value)
: port.label}
value={typeof node.data?.[port.id] === "string"
? (node.data[port.id] as string)
: ""}
oninput={(e) =>
ctx.ondatachange(node.id, port.id, e.currentTarget.value)}
/>
{/if}
</div>
{/if}
{/if}
{/each}
{#if collapsible}
<button
class="ports-toggle"
onpointerdown={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => {
e.stopPropagation();
showAllInputs = !showAllInputs;
}}
>
{#if showAllInputs}▴ show less{:else}{hiddenCount} optional param{hiddenCount ===
1
? ""
: "s"}{/if}
</button>
{/if}
</div>
{/if}
<!-- Widget zone -->
{#if hasWidget && widgetPortId && widgetType}
<NodeWidget
{node}
{widgetPortId}
{widgetType}
{isReadonly}
ondatachange={ctx.ondatachange}
/>
{/if}
<!-- Output ports -->
{#if node.outputs.length > 0}
<div class="ports" class:widget-ports={hasWidget}>
{#each node.outputs as port}
{@const portConnected = connectedPorts.has(
`${node.id}:${port.id}:output`
)}
<div class="port-row output-row" class:widget-port={hasWidget}>
{#if !hasWidget}
<span class="port-type-tag" style="color: {PORT_COLOR[port.type]}"
>{port.type}</span
>
<span class="port-label">{port.label}</span>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="port-handle-sf output-handle-sf"
class:connected={portConnected}
class:popout-hint={!readOnly && !portConnected && pending === null}
data-node-id={node.id}
data-port-id={port.id}
data-port-type={port.type}
data-port-direction="output"
style="--port-color: {PORT_COLOR[port.type]}"
title={!readOnly && !portConnected && pending === null
? "Click to create an output node"
: undefined}
onpointerdown={(e) =>
ctx.onportpointerdown(e, node.id, port.id, port.type, false)}
onclick={(e) => {
if (!readOnly && !portConnected && pending === null) {
e.stopPropagation();
ctx.onpopout(node.id, port.id, port.type, false);
}
}}
></div>
</div>
{/each}
</div>
{/if}
{#if status === "error" && error}
<div class="node-error-banner">{error}</div>
{/if}
</div>
<style>
/* Prevent SvelteFlow from adding its own drag cursor while we use the node header */
.wf-node {
position: relative;
background: #16171f;
border: 1px solid #24252e;
border-radius: 10px;
min-height: 90px;
user-select: none;
font-family: "Manrope", sans-serif;
overflow: visible;
transition:
box-shadow 0.2s,
border-color 0.3s;
box-sizing: border-box;
}
/* Space/transform nodes get a subtle top accent to distinguish from component nodes */
.wf-node.node-transform {
background: #13141c;
border-color: #2a2b38;
}
.wf-node.node-transform .node-header {
border-bottom-color: #22233a;
}
.wf-node:hover {
box-shadow:
0 0 0 1px var(--accent-dim),
0 4px 20px rgba(0, 0, 0, 0.4);
}
.wf-node.node-selected {
border-color: #f97316;
box-shadow:
0 0 0 1px #f97316,
0 0 16px rgba(249, 115, 22, 0.15);
}
.wf-node.node-droptarget {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 20px var(--accent-dim);
}
.wf-node.node-running {
border-color: #f5a623;
box-shadow: 0 0 12px rgba(245, 166, 35, 0.2);
}
.wf-node.node-done {
border-color: #4fd1a5;
box-shadow: 0 0 12px rgba(79, 209, 165, 0.15);
}
.wf-node.node-error {
border-color: #ef4444;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
}
/* Stale: ran successfully, but inputs have since changed. Subtle dashed
* outline so the user knows the displayed output no longer reflects
* current upstream state. Overridden by running/error which are stronger
* signals. */
.wf-node.node-stale:not(.node-running):not(.node-error) {
border-color: #f97316;
border-style: dashed;
box-shadow: 0 0 10px rgba(249, 115, 22, 0.12);
}
.node-status-spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top-color: #f5a623;
border-radius: 50%;
animation: node-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes node-spin {
to {
transform: rotate(360deg);
}
}
.node-header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 9px 12px 8px;
cursor: grab;
border-bottom: 1px solid #1e1f2a;
}
.node-header:active {
cursor: grabbing;
}
.node-header-top {
display: flex;
align-items: center;
gap: 7px;
}
.node-label {
font-size: 12.5px;
font-weight: 600;
color: #d5d6de;
white-space: nowrap;
cursor: text;
flex: 1;
min-width: 0;
overflow: hidden;
padding: 1px 4px;
border: 1px solid transparent;
border-radius: 4px;
box-sizing: border-box;
mask-image: linear-gradient(
to right,
black calc(100% - 24px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
black calc(100% - 24px),
transparent 100%
);
}
.node-label-input {
font-family: "Manrope", sans-serif;
font-size: 12.5px;
font-weight: 600;
color: #d5d6de;
background: #101118;
border: 1px solid #f97316;
border-radius: 4px;
padding: 1px 4px;
outline: none;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.node-outside-label-wrap {
position: absolute;
bottom: calc(100% + 6px);
/* Anchor to the card's right edge; label sits to the LEFT of the
* open-link icon with a small gap. */
right: 0;
display: flex;
align-items: center;
gap: 6px;
pointer-events: auto;
}
.node-outside-label {
position: absolute;
bottom: calc(100% + 6px);
right: 12px;
white-space: nowrap;
font-family: "Manrope", sans-serif;
font-size: 11.5px;
font-weight: 500;
color: #6b6e78;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
transition: color 0.15s;
pointer-events: auto;
}
.node-outside-label-wrap .node-outside-label {
position: static;
bottom: auto;
right: auto;
}
.node-outside-label:hover {
color: #c8c9d2;
}
.node-outside-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #4a4d57;
opacity: 0;
text-decoration: none;
border-radius: 3px;
transition:
opacity 0.15s,
color 0.15s,
background 0.15s;
flex-shrink: 0;
}
.wf-node:hover .node-outside-link,
.node-outside-label-wrap:focus-within .node-outside-link {
opacity: 1;
}
.node-outside-link:hover {
color: #f97316;
background: rgba(249, 115, 22, 0.08);
}
.node-outside-label-empty {
color: #3a3b47;
font-size: 10.5px;
}
.node-outside-label-empty:hover {
color: #f97316;
}
.node-outside-label-fn {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
color: #8b8d98;
cursor: default;
}
.node-delete {
display: none;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: #5c5e6a;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
margin-left: auto;
align-items: center;
justify-content: center;
padding: 0;
text-align: center;
}
.wf-node:hover .node-delete {
display: flex;
}
.node-delete:hover {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* Per-node run button — hidden by default, revealed on hover, mirrors
* the node-delete affordance pattern. Stale state pulses faintly to
* signal "this needs re-running". */
.node-run {
display: none;
width: 20px;
height: 20px;
margin-left: auto;
border: none;
border-radius: 4px;
background: transparent;
color: #5c5e6a;
cursor: pointer;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
}
.wf-node:hover .node-run {
display: flex;
}
.node-run + .node-delete {
margin-left: 2px;
}
.node-run:hover {
background: rgba(249, 115, 22, 0.18);
color: #f97316;
}
.node-run.node-run-stale {
display: flex;
color: #f97316;
animation: node-run-pulse 1.8s ease-in-out infinite;
}
@keyframes node-run-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.ports {
padding: 6px 0 8px;
}
.ports.widget-ports {
padding: 0;
}
.port-row.widget-port {
padding: 0;
min-height: 0;
position: static;
}
.port-row {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
position: relative;
min-height: 22px;
}
.input-row {
padding-left: 20px;
}
.output-row {
justify-content: flex-end;
padding-right: 20px;
}
.port-label {
font-size: 11.5px;
font-weight: 500;
color: #8b8d98;
white-space: nowrap;
}
.port-label-optional {
opacity: 0.5;
}
.port-type-tag {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
font-weight: 500;
opacity: 0.5;
}
/* Port handles (plain divs, positioned on each port-row) */
.port-handle-sf {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--port-color);
background: #0c0d10;
cursor: crosshair;
opacity: 0.5;
transition:
opacity 0.15s,
transform 0.15s,
background 0.15s,
box-shadow 0.15s;
position: absolute;
top: 50%;
transform: translateY(-50%);
touch-action: none;
}
.wf-node:hover .port-handle-sf,
.wf-node.node-selected .port-handle-sf,
.wf-node.has-pending .port-handle-sf {
opacity: 1;
}
.port-handle-sf.incompatible {
border-color: #ef4444;
opacity: 0.4;
animation: none;
}
.port-handle-sf.connected {
background: var(--port-color);
opacity: 1;
}
.input-handle-sf {
left: -24px;
right: auto;
}
.output-handle-sf {
right: -24px;
left: auto;
border-radius: 2px;
transform: translateY(-50%) rotate(45deg);
}
.port-handle-sf:hover {
opacity: 1;
background: var(--port-color);
box-shadow: 0 0 6px var(--port-color);
}
.output-handle-sf:hover {
transform: translateY(-50%) rotate(45deg) scale(1.4);
}
.port-handle-sf.pulse {
animation: port-pulse 1s ease-in-out infinite;
}
@keyframes port-pulse {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
50% {
box-shadow: 0 0 0 4px var(--port-color);
opacity: 0.3;
}
}
.node-error-banner {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border-top: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0 0 10px 10px;
padding: 6px 12px;
line-height: 1.4;
word-break: break-word;
overflow: hidden;
max-height: 4.5em;
overflow-y: auto;
}
.ports-toggle {
display: block;
width: 100%;
padding: 4px 12px;
border: none;
background: transparent;
font-family: "JetBrains Mono", monospace;
font-size: 9px;
font-weight: 600;
color: #3e3f4d;
cursor: pointer;
text-align: left;
letter-spacing: 0.03em;
transition: color 0.15s;
}
.ports-toggle:hover {
color: #8b8d98;
}
.port-inline-config {
padding: 2px 12px 4px 20px;
}
.inline-input {
width: 100%;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
padding: 4px 8px;
border: 1px solid #1e1f2a;
border-radius: 4px;
background: #101118;
color: #c8c9d2;
outline: none;
box-sizing: border-box;
}
.inline-input:focus {
border-color: #3e3f4d;
}
.inline-input::placeholder {
color: #4a4b58;
}
.popout-hint {
cursor: cell;
}
.inline-number {
width: 80px;
}
.inline-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: #8b8d98;
cursor: pointer;
}
.inline-select {
cursor: pointer;
}
.inline-choices {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.inline-choices input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
appearance: auto;
-webkit-appearance: checkbox;
}
/* Light mode */
:global(body:not(.dark)) .wf-node {
background: #ffffff;
border-color: #e2e4ea;
}
:global(body:not(.dark)) .wf-node:hover {
box-shadow:
0 0 0 1px var(--accent-dim),
0 4px 20px rgba(0, 0, 0, 0.08);
}
:global(body:not(.dark)) .node-header {
border-bottom-color: #e2e4ea;
}
:global(body:not(.dark)) .node-label {
color: #1a1b25;
}
:global(body:not(.dark)) .node-label-input {
color: #1a1b25;
background: #ffffff;
}
:global(body:not(.dark)) .port-label {
color: #6b6e78;
}
:global(body:not(.dark)) .node-outside-label {
color: #9a9caa;
}
:global(body:not(.dark)) .node-outside-label:hover {
color: #1a1b25;
}
:global(body:not(.dark)) .node-outside-label-empty {
color: #c0c2cc;
}
:global(body:not(.dark)) .ports-toggle {
color: #b0b2bc;
}
:global(body:not(.dark)) .node-delete {
color: #9a9caa;
}
:global(body:not(.dark)) .port-handle-sf {
background: #ffffff;
}
:global(body:not(.dark)) .inline-input {
background: #f8f9fb;
border-color: #e2e4ea;
color: #1a1b25;
}
:global(body:not(.dark)) .inline-input::placeholder {
color: #c0c2cc;
}
:global(body:not(.dark)) .inline-checkbox {
color: #6b6e78;
}
.node-endpoint-row {
padding: 2px 12px 0 0;
display: flex;
justify-content: flex-end;
}
.node-endpoint-select,
.node-endpoint-load {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
padding: 1px 0 1px 4px;
border: none;
background: transparent;
color: #5c5e6a;
cursor: pointer;
outline: none;
opacity: 0.6;
transition:
opacity 0.15s,
color 0.15s;
-webkit-appearance: none;
appearance: none;
text-align: right;
text-align-last: right;
}
.node-endpoint-select:hover,
.node-endpoint-load:hover {
opacity: 1;
color: #a0a2ae;
}
.wf-node:hover .node-endpoint-select,
.wf-node:hover .node-endpoint-load {
opacity: 1;
}
:global(body:not(.dark)) .node-endpoint-select,
:global(body:not(.dark)) .node-endpoint-load {
color: #8b8d98;
}
:global(body:not(.dark)) .node-endpoint-select:hover,
:global(body:not(.dark)) .node-endpoint-load:hover {
color: #3e4050;
}
</style>