feat(federation): inbound peer requests live on the map — blinking nodes + accept/reject

- 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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 17:37:39 -04:00
co-authored by Claude Fable 5
parent e0aebf50f8
commit 6ba24b0f4a
3 changed files with 331 additions and 3 deletions
@@ -12,6 +12,7 @@
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
<span v-if="requests?.length" class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.pending }"></span>Request</span>
</div>
<!-- 2D/3D projection toggle tweens the camera between the flat radial
layout and the depth view -->
@@ -26,7 +27,22 @@
>{{ m.toUpperCase() }}</button>
</div>
<div v-if="peerCount === 0" class="node-map-empty">
<!-- Peer request popover: black glass, centred over the scene -->
<Transition name="nm-pop">
<div v-if="activeRequest" class="node-map-popover" role="dialog" aria-label="Peer request" @click.stop>
<button class="node-map-popover-close" aria-label="Dismiss" @click="activeRequest = null"></button>
<span class="node-map-popover-badge">Peer request</span>
<p class="node-map-popover-title">{{ activeRequest.label }}</p>
<p class="node-map-popover-sub">wants to peer with your node</p>
<p v-if="activeRequest.message" class="node-map-popover-msg">{{ activeRequest.message }}</p>
<div class="node-map-popover-actions">
<button class="node-map-popover-btn nm-reject" @click="decideRequest('reject')">Reject</button>
<button class="node-map-popover-btn nm-accept" @click="decideRequest('approve')">Accept</button>
</div>
</div>
</Transition>
<div v-if="peerCount === 0 && (requests?.length ?? 0) === 0" class="node-map-empty">
<p class="node-map-empty-title">No peers yet</p>
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
</div>
@@ -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<HTMLDivElement>()
@@ -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<MapRequest | null>(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;
+2
View File
@@ -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)',
+18 -1
View File
@@ -46,7 +46,14 @@
<!-- Network Map View fills all remaining height to the bottom edge -->
<div v-if="mapActive" class="flex-1 min-h-0">
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
<NetworkMap3D
:nodes="mapNodes"
:links="mapLinks"
:requests="mapRequests"
@select="onMapSelect"
@approve="approvePending"
@reject="rejectPending"
/>
</div>
<template v-if="activeView === 'list'">
@@ -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<DwnStatus>({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),