feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s

This commit is contained in:
2026-06-12 00:24:56 +02:00
parent 3672e56994
commit 2d6e3537e8
4 changed files with 1262 additions and 0 deletions
@@ -0,0 +1,284 @@
<script setup lang="ts">
/**
* AgentNode — Einzelner Agenten-Knoten im FlowCanvas
*
* Props:
* agent AgentNodeData
* left x-Position in % (0100)
* top y-Position in % (0100)
* entering true wenn Node gerade frisch ins DOM kam (Enter-Animation)
*
* Emits:
* select Agent ausgewählt (id)
*/
import type { AgentNodeData } from '../../../composables/useFlowLayout'
const props = defineProps<{
agent: AgentNodeData
left: number
top: number
entering?: boolean
}>()
defineEmits<{
select: [id: string]
}>()
</script>
<template>
<div
:class="[
'node',
agent.id === 'iris' ? 'is-iris' : `is-${agent.status}`,
{ entering }
]"
:style="{ left: left + '%', top: top + '%' }"
@click="$emit('select', agent.id)"
>
<div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot -->
<div class="nc-top">
<div :class="['nc-av', { 'iris-av': agent.id === 'iris' }]">
<span v-html="agent.avatar === '</>' ? '&lt;/&gt;' : agent.avatar"></span>
</div>
<div class="nc-info">
<div class="nc-name">{{ agent.name }}</div>
<div class="nc-role">{{ agent.role }}</div>
</div>
<span :class="['nc-stat', 'dot', agent.status]"></span>
</div>
<!-- Task (2-line clamp) -->
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
<!-- Progress Bar -->
<div class="nc-bar">
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
</div>
<!-- Meta-Zeile -->
<div class="nc-meta">
<span
class="st"
:style="{ color: `var(--st-${agent.status})` }"
>
{{ agent.statusLabel }}
</span>
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.node {
position: absolute;
transform: translate(-50%, -50%);
z-index: 3;
width: 188px;
transition:
left 0.55s cubic-bezier(.4, 0, .2, 1),
top 0.55s cubic-bezier(.4, 0, .2, 1),
opacity 0.35s,
scale 0.35s;
cursor: pointer;
user-select: none;
}
.node.entering {
opacity: 0;
scale: 0.7;
}
.ncard {
padding: 11px 12px;
border-radius: 13px;
background: var(--glass-2);
border: 1px solid var(--line-2);
backdrop-filter: blur(10px);
transition: transform 0.18s;
}
.ncard:hover {
transform: translateY(-2px);
}
/* Status-glow border */
.node.is-work .ncard {
border-color: rgba(61, 220, 151, 0.45);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.2), 0 0 26px -6px rgba(61, 220, 151, 0.6);
}
.node.is-think .ncard {
border-color: rgba(52, 214, 245, 0.45);
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55);
}
.node.is-iris .ncard {
border-color: rgba(124, 108, 255, 0.55);
box-shadow: var(--glow);
background: linear-gradient(160deg, rgba(124, 108, 255, 0.2), rgba(28, 24, 64, 0.6));
}
.node.is-idle .ncard {
opacity: 0.7;
}
/* ── Card Content ────────────────────────────── */
.nc-top {
display: flex;
align-items: center;
gap: 9px;
}
.nc-av {
width: 32px;
height: 32px;
border-radius: 9px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 11px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.nc-av.iris-av {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.nc-av :deep(svg) {
display: block;
}
.nc-info {
min-width: 0;
flex: 1;
}
.nc-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
}
.nc-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nc-stat {
margin-left: auto;
flex: 0 0 auto;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
display: block;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55);
animation: pulse-block 1.8s infinite;
}
/* ── Task ────────────────────────────────────── */
.nc-task {
font-size: 11px;
color: var(--tx-2);
margin-top: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 28px;
}
/* ── Progress Bar ────────────────────────────── */
.nc-bar {
height: 4px;
border-radius: 4px;
background: rgba(124, 108, 255, 0.12);
overflow: hidden;
margin-top: 7px;
}
.nc-bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--grad);
transition: width 0.4s ease;
}
.node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
}
/* ── Meta ────────────────────────────────────── */
.nc-meta {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
margin-top: 5px;
font-variant-numeric: tabular-nums;
}
.nc-meta .st {
font-weight: 600;
}
@keyframes pulse-work {
0% { box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(61, 220, 151, 0); }
100% { box-shadow: 0 0 0 0 rgba(61, 220, 151, 0); }
}
@keyframes pulse-think {
0% { box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(52, 214, 245, 0); }
100% { box-shadow: 0 0 0 0 rgba(52, 214, 245, 0); }
}
@keyframes pulse-block {
0% { box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(251, 113, 133, 0); }
100% { box-shadow: 0 0 0 0 rgba(251, 113, 133, 0); }
}
</style>
@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* AlertBar — Status-Übersicht im V2 Dashboard
*
* Props:
* activeCount Agents mit status 'work'
* thinkCount Agents mit status 'think'
* idleCount Agents mit status 'idle'
* blockerCount Blocker-Anzahl
* todayCost Kosten heute (z.B. "$6.40")
* todayTokens Token heute (z.B. "282k")
*/
import { icons } from '../../../composables/icons'
defineProps<{
activeCount: number
thinkCount: number
idleCount: number
blockerCount: number
todayCost: string
todayTokens: string
}>()
defineEmits<{
blockerClick: []
}>()
</script>
<template>
<div class="alertbar glass-panel">
<!-- Active (arbeitet) -->
<div class="seg">
<span class="dot work"></span>
<span class="seg-label">{{ activeCount }} arbeiten</span>
</div>
<!-- Think (plant) -->
<div class="seg">
<span class="dot think"></span>
<span class="seg-label">{{ thinkCount }} planen</span>
</div>
<!-- Idle (bereit) -->
<div class="seg">
<span class="dot idle"></span>
<span class="seg-label">{{ idleCount }} bereit</span>
</div>
<!-- Separator -->
<div class="sep"></div>
<!-- Kosten heute -->
<div class="seg tx2">
<span class="seg-icon" v-html="icons.coin || ''"></span>
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
</div>
<!-- Blocker Alert (rechts) -->
<button
v-if="blockerCount > 0"
class="blk"
@click="$emit('blockerClick')"
>
<span class="dot block"></span>
{{ blockerCount }} {{ blockerCount === 1 ? 'Blocker' : 'Blocker' }}
</button>
</div>
</template>
<style scoped>
.alertbar {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
border-radius: var(--r);
flex-wrap: wrap;
}
.seg {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
}
.seg-label {
color: var(--tx-2);
}
.seg-icon :deep(svg) {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--a-mid);
}
.tx2 .seg-icon :deep(svg) {
color: var(--tx-3);
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
animation: pulse-block 1.8s infinite;
}
.sep {
width: 1px;
height: 20px;
background: var(--line-2);
flex: 0 0 auto;
}
.cost-value {
font-family: 'JetBrains Mono', monospace;
font-variant-numeric: tabular-nums;
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.blk {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 6px 12px;
border-radius: 9px;
background: rgba(251,113,133,.12);
border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px;
font-weight: 600;
color: #fda4b0;
cursor: pointer;
transition: background .15s;
font-family: 'Manrope', sans-serif;
white-space: nowrap;
}
.blk:hover {
background: rgba(251,113,133,.22);
}
</style>
@@ -0,0 +1,485 @@
<script setup lang="ts">
/**
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
*
* Props:
* agents Liste der AgentNodeData
* positions Record<id, {x,y}> mit aktuellen Positionen
*
* Emits:
* select Agent ausgewählt (id)
* add Neuen Agent hinzufügen
* updatePositions Positionsänderung
*/
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
const props = defineProps<{
agents: AgentNodeData[]
positions: Record<string, { x: number; y: number }>
enteringIds: string[]
}>()
const emit = defineEmits<{
select: [id: string]
add: []
resetLayout: []
updatePositions: [positions: Record<string, { x: number; y: number }>]
}>()
/* ── Refs ───────────────────────────────────────── */
const flowRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
const edgesDefs = ref('')
const edgesPaths = ref('')
const edgesPulses = ref('')
/* ── Computed ───────────────────────────────────── */
const agentCount = computed(() => props.agents.length)
const autoPositions = computed(() => autoLayout(props.agents))
// Layout label
const layoutLabel = computed(() => {
const n = props.agents.length - 1
if (n <= 0) return `${props.agents.length} Agents`
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / maxPerRow)
const hasCustom = Object.keys(props.positions).length > 0
if (hasCustom) {
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
}
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
})
/* ── Edge Rendering ────────────────────────────── */
function isActive(status: string) {
return status === 'work' || status === 'think'
}
function renderEdges() {
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const svg = svgRef.value
if (!svg) return
svg.setAttribute('width', String(fr.width))
svg.setAttribute('height', String(fr.height))
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
// Node centers in pixel coordinates
function center(id: string): { x: number; y: number } | null {
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
if (!el) return null
const nr = el.getBoundingClientRect()
return {
x: nr.left - fr.left + nr.width / 2,
y: nr.top - fr.top + nr.height / 2,
}
}
const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
let paths = ''
let pulses = ''
let idCounter = 0
edgeList.forEach((e) => {
const c1 = center(e.a)
const c2 = center(e.b)
if (!c1 || !c2) return
const d = curve(c1, c2)
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
const live = isActive(a1Status) && isActive(a2Status)
const pathId = `ep${idCounter++}`
if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') {
// Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else {
// Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
}
})
edgesDefs.value = defs
edgesPaths.value = paths
edgesPulses.value = pulses
}
/* ── Resize Observer ──────────────────────────── */
let resizeObserver: ResizeObserver | null = null
function setupObserver() {
if (!flowRef.value) return
resizeObserver = new ResizeObserver(() => {
// Debounce via requestAnimationFrame
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
debounceRaf = null
renderEdges()
})
})
resizeObserver.observe(flowRef.value)
}
let debounceRaf: number | null = null
function teardownObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (debounceRaf) {
cancelAnimationFrame(debounceRaf)
debounceRaf = null
}
}
onMounted(() => {
setupObserver()
// Initial render after DOM settles
requestAnimationFrame(() => renderEdges())
})
onUnmounted(() => {
teardownObserver()
})
// Re-render edges when agents or positions change
watch(
() => [props.agents.length, props.positions],
() => {
// Wait for DOM update (AgentNode transitions)
setTimeout(() => renderEdges(), 200)
},
{ deep: true }
)
/* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
let drag: DragState | null = null
function onPointerDown(e: PointerEvent) {
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag) return
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
if (!drag.moved && dist < DRAG_THRESHOLD) return
if (!drag.moved) {
drag.moved = true
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
// Direct DOM manipulation for responsiveness
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
// Update positions state
const newPos = { ...props.positions }
newPos[drag.id] = { x, y }
emit('updatePositions', newPos)
// Debounced edge re-render
if (!drag.raf) {
drag.raf = requestAnimationFrame(() => {
renderEdges()
if (drag) drag.raf = null
})
}
}
function onPointerUp() {
if (!drag) return
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (!drag.moved) {
// Was a click — emit select
emit('select', drag.id)
}
drag = null
}
/* ── Keyboard handler for Enter key on buttons ── */
function handleReset() {
emit('resetLayout')
nextTick(() => renderEdges())
}
</script>
<template>
<div
ref="flowRef"
class="flow"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<!-- Header -->
<div class="flow-h">
<span class="header-icon" v-html="icons.flow || ''"></span>
<h3>Live-Orchestrierung</h3>
<span class="flow-count">{{ agentCount }} Agents</span>
<button
class="reset-btn"
title="Auto-Layout wiederherstellen"
@click="handleReset"
>
<span class="btn-icon" v-html="icons.flow || ''"></span>
Reset
</button>
<button class="add-btn" @click="$emit('add')">
<span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen
</button>
</div>
<!-- SVG Layer -->
<svg
ref="svgRef"
class="edges"
v-html="edgesDefs + edgesPaths + edgesPulses"
></svg>
<!-- Agent Nodes -->
<div class="nodes-layer" v-html="''"></div>
<AgentNode
v-for="agent in agents"
:key="agent.id"
:agent="agent"
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
:entering="enteringIds.includes(agent.id)"
:data-id="agent.id"
@select="(id: string) => $emit('select', id)"
/>
<!-- Layout Label -->
<div class="layout-label">{{ layoutLabel }}</div>
</div>
</template>
<style scoped>
.flow {
flex: 1;
position: relative;
border-radius: var(--r);
overflow: hidden;
min-height: 0;
border: 1px solid var(--line);
background:
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
}
.flow-h {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 4;
display: flex;
align-items: center;
gap: 9px;
padding: 13px 16px;
}
.flow-h h3 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
}
.header-icon :deep(svg) {
width: 18px;
height: 18px;
color: var(--a-mid);
}
.flow-count {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
background: rgba(124, 108, 255, 0.14);
color: var(--tx-2);
font-variant-numeric: tabular-nums;
}
.reset-btn {
height: 30px;
padding: 0 11px;
border-radius: 9px;
background: rgba(124, 108, 255, 0.1);
border: 1px solid var(--line-2);
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s;
}
.reset-btn:hover {
background: rgba(124, 108, 255, 0.18);
color: var(--tx);
}
.reset-btn .btn-icon :deep(svg) {
width: 13px;
height: 13px;
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 14px;
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--glow-purple);
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-btn .btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
/* ── SVG Layer ────────────────────────────────── */
.edges {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Layout Label ─────────────────────────────── */
.layout-label {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: var(--tx-3);
background: rgba(10, 8, 24, 0.7);
padding: 5px 14px;
border-radius: 20px;
border: 1px solid var(--line);
backdrop-filter: blur(8px);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ── Drag state ───────────────────────────────── */
:deep(.node.dragging) {
cursor: grabbing;
transition: none !important;
z-index: 10;
}
:deep(.node.dragging .ncard) {
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
transform: scale(1.04);
}
/* Dash animation */
@keyframes dashmove {
to {
stroke-dashoffset: -28;
}
}
/* Node cursor */
:deep(.node) {
cursor: grab;
}
:deep(.node.dragging) {
cursor: grabbing;
}
</style>