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() }}
-
+
+
+
+
+
Peer request
+
{{ activeRequest.label }}
+
wants to peer with your node
+
“{{ activeRequest.message }}”
+
+
+
+
+
+
+
+
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 @@
-
+
@@ -367,6 +374,16 @@ const mapLinks = computed(() => {
}))
})
+/** Inbound pending requests for the map — blinking yellow nodes the user can
+ * accept/reject in place (same RPCs as the pending panel). */
+const mapRequests = computed(() => pendingRequests.value
+ .filter(r => !r.outbound && r.state === 'pending')
+ .map(r => ({
+ id: r.id,
+ label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}…`,
+ message: r.message,
+ })))
+
const dwnStatusRes = useCachedResource({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),