From 6ba24b0f4ab56a346fab1727461ab82cdd677dcc Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 9 Aug 2026 17:37:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(federation):=20inbound=20peer=20requests?= =?UTF-8?q?=20live=20on=20the=20map=20=E2=80=94=20blinking=20nodes=20+=20a?= =?UTF-8?q?ccept/reject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inbound pending requests render as blinking yellow point-globes on an orbit outside the peers, dotted-linked to self, with their own dashed guide ring and a Request legend chip. - Tapping one opens a black-glass popover (name, request message, Accept/Reject) centred over the scene; tap-away or ✕ dismisses. - Reject: the node swells and pops out of existence, taking its link. - Accept: green burst ring, the point cloud/link morph to the trusted colour and the globe glides inward onto the peer orbit, then the data refresh replaces it with the real peer node. - Wired to the same approve/reject RPCs as the pending panel; new motionTokens.color.pending (#facc15) token. Co-Authored-By: Claude Fable 5 --- .../components/federation/NetworkMap3D.vue | 313 +++++++++++++++++- neode-ui/src/utils/motion.ts | 2 + neode-ui/src/views/Federation.vue | 19 +- 3 files changed, 331 insertions(+), 3 deletions(-) diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue index d6700ce1..31834fac 100644 --- a/neode-ui/src/components/federation/NetworkMap3D.vue +++ b/neode-ui/src/components/federation/NetworkMap3D.vue @@ -12,6 +12,7 @@ Trusted Observer Untrusted + Request @@ -26,7 +27,22 @@ >{{ m.toUpperCase() }} -
+ + + + + +

No peers yet

Invite a peer or discover nodes to grow your federation

@@ -52,13 +68,24 @@ export interface MapLink { target: string } +/** Inbound peer request awaiting a decision — rendered as a blinking yellow + * globe on the outermost orbit. */ +export interface MapRequest { + id: string + label: string + message: string | null +} + const props = defineProps<{ nodes: MapNode[] links: MapLink[] + requests?: MapRequest[] }>() const emit = defineEmits<{ (e: 'select', did: string): void + (e: 'approve', id: string): void + (e: 'reject', id: string): void }>() const containerRef = ref() @@ -116,7 +143,31 @@ interface PeerVis { depth: number } +/** A pending request on the map. Shares the peer projection maths; adds a + * blink phase and decision-animation fields (deathScale for the reject pop, + * a tweenable ringRadius for the accept glide). */ +interface RequestVis { + req: MapRequest + angle: number + ringRadius: number + yJitter: number + bobPhase: number + p: number + blinking: boolean + deathScale: number + el: SVGGElement + outline: SVGCircleElement + halo: SVGCircleElement + coreGlow: SVGCircleElement + label: SVGTextElement + linkEl: SVGPathElement + dots: GlobeDot[] + radiusPx: number + depth: number +} + let peers: PeerVis[] = [] +let requestVis: RequestVis[] = [] let selfEl: SVGGElement | null = null const selfState = { p: 0 } let ringPaths: { el: SVGPathElement; radius: number }[] = [] @@ -193,9 +244,11 @@ function clearScene() { const svg = svgRef.value if (svg) while (svg.firstChild) svg.removeChild(svg.firstChild) peers = [] + requestVis = [] ringPaths = [] selfEl = null selfDots = [] + activeRequest.value = null nodesLayer = null linksLayer = null ringsLayer = null @@ -390,6 +443,81 @@ function buildScene() { depth: 0, }) }) + + // Inbound peer requests: blinking yellow globes on an orbit OUTSIDE the + // peers — visually "knocking at the door", linked to self by a dotted line. + const reqs = props.requests ?? [] + if (reqs.length) { + const maxPeerRingRadius = peerNodes.length + ? 1 + ringFor(peerNodes.length - 1, peerNodes.length).ring * 0.65 + : 1 + const reqRing = maxPeerRingRadius + 0.65 + const color = motionTokens.color.pending + const ringEl = el('path', { + fill: 'none', + stroke: color, + 'stroke-opacity': '0.12', + 'stroke-width': '1', + 'stroke-dasharray': '4 5', + opacity: '0', + }) + ringsLayer.appendChild(ringEl) + ringPaths.push({ el: ringEl, radius: reqRing }) + + reqs.forEach((req, i) => { + const r = 7.5 + const g = el('g', { cursor: 'pointer' }) as SVGGElement + const halo = el('circle', { r: String(r * 2), fill: color, opacity: '0.14' }) + const outline = el('circle', { + r: String(r), fill: 'none', stroke: color, + 'stroke-width': '1', 'stroke-opacity': '0.5', 'stroke-dasharray': '3 3', + }) + const coreGlow = el('circle', { r: String(r * 0.4), fill: color, opacity: '0.25' }) + g.append(halo, outline, coreGlow) + const dots = makeGlobe(g, r, color, 24) + const label = el('text', { + dy: String(r + 15), + 'text-anchor': 'middle', + class: 'nm-label', + fill: motionTokens.color.textSecondary, + 'font-size': '11px', + 'font-family': "'Avenir Next', system-ui, sans-serif", + }) + label.textContent = req.label + const title = el('title') + title.textContent = `Peer request from ${req.label}` + g.append(label, title) + // stopPropagation: the same click must not bubble to the container's + // popover-dismiss handler and immediately close what it just opened + g.addEventListener('click', (e) => { e.stopPropagation(); activeRequest.value = req }) + nodesLayer!.appendChild(g) + const linkEl = el('path', { + fill: 'none', stroke: color, + 'stroke-width': '1.2', 'stroke-opacity': '0.3', 'stroke-dasharray': '2 5', + }) + linksLayer!.appendChild(linkEl) + + requestVis.push({ + req, + angle: (i / reqs.length) * Math.PI * 2 + 0.35, + ringRadius: reqRing, + yJitter: (hash01(i + 40) - 0.5) * 0.2, + bobPhase: hash01(i + 140) * Math.PI * 2, + p: 0, + blinking: true, + deathScale: 1, + el: g, + outline, + halo, + coreGlow, + label, + linkEl, + dots, + radiusPx: r, + depth: 0, + }) + }) + } } /** One frame: project every element through the camera and write attrs. */ @@ -455,12 +583,34 @@ function render() { } } + // Pending requests: same projection as peers, plus the attention blink + // (skipped once a decision animation has started or under reduced motion) + for (const rv of requestVis) { + const dist = rv.ringRadius * (1.9 - 0.9 * rv.p) + const sway = staticMode ? 0 : Math.sin(elapsed * 0.55 + rv.bobPhase) * 0.04 + const a = rv.angle + sway + const x = Math.cos(a) * dist + const z = Math.sin(a) * dist + const y = rv.yJitter + const pt = project(x, y, z) + rv.depth = pt.z + if (updateDots) renderGlobe(rv.dots, rv.radiusPx, cam.rotY + elapsed * 0.18 + rv.bobPhase, 0.95) + const blink = rv.blinking && !staticMode ? 0.55 + 0.45 * Math.sin(elapsed * 3.2 + rv.bobPhase) : 1 + const s = pt.s * cam.zoom * (0.4 + 0.6 * rv.p) * rv.deathScale + rv.el.setAttribute('transform', `translate(${pt.x},${pt.y}) scale(${Math.max(s, 0.001)})`) + rv.el.setAttribute('opacity', String(rv.p * blink)) + const mid = project(x * 0.5, y * 0.5 - 0.12, z * 0.5) + rv.linkEl.setAttribute('d', `M${origin.x.toFixed(1)} ${origin.y.toFixed(1)} Q${mid.x.toFixed(1)} ${mid.y.toFixed(1)} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`) + rv.linkEl.setAttribute('opacity', String(rv.p * Math.min(blink + 0.2, 1))) + } + // Painter's order: farthest first so near nodes overlap far ones. The self // node sits at z=0 (mid-plane) and takes part in the same sort, so peers // orbiting in front genuinely pass over it. Only touch the DOM when the // order actually changed. - if (nodesLayer && peers.length > 0) { + if (nodesLayer && (peers.length > 0 || requestVis.length > 0)) { const drawables: { el: SVGGElement; depth: number }[] = peers.map(p => ({ el: p.el, depth: p.depth })) + for (const rv of requestVis) drawables.push({ el: rv.el, depth: rv.depth }) if (selfEl) drawables.push({ el: selfEl, depth: 0 }) drawables.sort((a, b) => b.depth - a.depth) let dirty = false @@ -500,6 +650,7 @@ function playIntro() { cam.zoom = 1 selfState.p = 1 for (const p of peers) p.p = 1 + for (const rv of requestVis) rv.p = 1 for (const r of ringPaths) r.el.setAttribute('opacity', '1') render() return @@ -523,6 +674,59 @@ function playIntro() { stagger: { each: 0.07, from: 'random' }, }, 0.45) } + if (requestVis.length) { + intro.to(requestVis, { + p: 1, + duration: 0.7, + ease: motionTokens.ease.arrive, + stagger: 0.08, + }, 0.7) + } +} + +/* ----------------------- request popover ------------------------- */ + +const activeRequest = ref(null) + +function decideRequest(decision: 'approve' | 'reject') { + const req = activeRequest.value + if (!req) return + activeRequest.value = null + const rv = requestVis.find(v => v.req.id === req.id) + if (rv && !staticMode) { + rv.blinking = false + if (decision === 'reject') { + // Pop out of existence: a quick swell, then collapse — the dotted + // link dies with the node. + gsap.timeline() + .to(rv, { deathScale: 1.22, duration: 0.14, ease: 'power2.out' }) + .to(rv, { deathScale: 0.001, p: 0.0001, duration: 0.32, ease: 'back.in(2.4)' }) + } else { + // Join: green burst ring, the point cloud and link morph to the + // trusted colour, and the globe glides inward onto the peer orbit. + const trusted = motionTokens.color.trusted + const burst = el('circle', { + r: String(rv.radiusPx), fill: 'none', + stroke: trusted, 'stroke-width': '2', opacity: '0.8', + }) + rv.el.appendChild(burst) + gsap.to(burst, { attr: { r: rv.radiusPx * 3.2 }, opacity: 0, duration: 0.9, ease: 'sine.out' }) + rv.outline.removeAttribute('stroke-dasharray') + rv.linkEl.removeAttribute('stroke-dasharray') + gsap.to(rv.dots.map(d => d.el), { attr: { fill: trusted }, duration: 0.6, ease: motionTokens.ease.inOut }) + gsap.to([rv.halo, rv.coreGlow], { attr: { fill: trusted }, duration: 0.6, ease: motionTokens.ease.inOut }) + gsap.to([rv.outline, rv.linkEl], { attr: { stroke: trusted }, duration: 0.6, ease: motionTokens.ease.inOut }) + gsap.to(rv, { ringRadius: 1, duration: 0.9, ease: motionTokens.ease.inOut, delay: 0.15 }) + } + } + if (decision === 'approve') emit('approve', req.id) + else emit('reject', req.id) +} + +/** Tapping empty map space dismisses the popover (request-node clicks + * stopPropagation, and the popover itself uses @click.stop). */ +function onStageClick() { + if (activeRequest.value) activeRequest.value = null } /* --------------------------- interaction --------------------------- */ @@ -668,6 +872,7 @@ function measure() { const graphSignature = computed(() => JSON.stringify({ nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]), links: props.links.map(l => [l.source, l.target]), + requests: (props.requests ?? []).map(r => [r.id, r.label]), })) let hasIntroPlayed = false @@ -684,8 +889,12 @@ function rebuild(replayIntro: boolean) { selfState.p = 1 if (staticMode) { for (const p of peers) p.p = 1 + for (const rv of requestVis) rv.p = 1 } else { gsap.to(peers, { p: 1, duration: 0.5, ease: motionTokens.ease.arrive, stagger: 0.03 }) + if (requestVis.length) { + gsap.to(requestVis, { p: 1, duration: 0.5, ease: motionTokens.ease.arrive, stagger: 0.05 }) + } } for (const r of ringPaths) r.el.setAttribute('opacity', '1') render() @@ -700,6 +909,7 @@ onMounted(() => { resizeObserver.observe(containerRef.value) containerRef.value.addEventListener('pointerdown', onPointerDown) containerRef.value.addEventListener('click', onClickCapture, true) + containerRef.value.addEventListener('click', onStageClick) // Window-level so a drag that leaves the container keeps tracking window.addEventListener('pointermove', onPointerMove) window.addEventListener('pointerup', onPointerUp) @@ -826,6 +1036,105 @@ watch(graphSignature, () => rebuild(false)) pointer-events: none; margin: 0; } +.node-map-popover { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(300px, calc(100% - 32px)); + padding: 18px 16px 14px; + border-radius: 1rem; + background: rgba(0, 0, 0, 0.78); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.16); + text-align: center; +} +.node-map-popover-close { + position: absolute; + top: 6px; + right: 8px; + min-height: 0 !important; + padding: 4px 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.4); +} +.node-map-popover-close:hover { + color: rgba(255, 255, 255, 0.85); +} +.node-map-popover-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #facc15; + background: rgba(250, 204, 21, 0.12); + margin-bottom: 8px; +} +.node-map-popover-title { + font-size: 0.95rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow-wrap: anywhere; +} +.node-map-popover-sub { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + margin: 2px 0 0; +} +.node-map-popover-msg { + font-size: 0.8rem; + font-style: italic; + color: rgba(255, 255, 255, 0.75); + margin: 10px 0 0; + overflow-wrap: anywhere; +} +.node-map-popover-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} +.node-map-popover-btn { + flex: 1; + padding: 9px 0; + border-radius: 0.6rem; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.12); + transition: background-color 0.2s ease, border-color 0.2s ease; +} +.node-map-popover-btn.nm-accept { + color: #4ade80; + background: rgba(74, 222, 128, 0.12); +} +.node-map-popover-btn.nm-accept:hover { + background: rgba(74, 222, 128, 0.22); + border-color: rgba(74, 222, 128, 0.4); +} +.node-map-popover-btn.nm-reject { + color: #f87171; + background: rgba(239, 68, 68, 0.10); +} +.node-map-popover-btn.nm-reject:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); +} +/* Popover enter/leave: quick glass pop */ +.nm-pop-enter-active, +.nm-pop-leave-active { + transition: opacity 0.22s ease, transform 0.22s ease; +} +.nm-pop-enter-from, +.nm-pop-leave-to { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); +} + .node-map-empty { position: absolute; inset: 0; diff --git a/neode-ui/src/utils/motion.ts b/neode-ui/src/utils/motion.ts index c9478921..74257aba 100644 --- a/neode-ui/src/utils/motion.ts +++ b/neode-ui/src/utils/motion.ts @@ -22,6 +22,8 @@ export const motionTokens = { observer: '#fb923c', untrusted: '#ef4444', neutral: '#9ca3af', + /** Pending/attention — inbound peer requests awaiting a decision. */ + pending: '#facc15', /** Text/line opacities on the dark glass ground. */ textPrimary: 'rgba(255, 255, 255, 0.95)', textSecondary: 'rgba(255, 255, 255, 0.7)', diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue index e69793af..3a451ff4 100644 --- a/neode-ui/src/views/Federation.vue +++ b/neode-ui/src/views/Federation.vue @@ -46,7 +46,14 @@
- +