feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile
- New self-contained HopVizModal.vue (Teleport to body): 560px balanced panel, glowing endpoint medallions ringed with EQ segments (ScreensaverRing motif), per-transport accent track with staggered relay markers and an animated packet traveling sender → recipient - Vertical stacked chain below 560px (sender top → recipient bottom, packet travels downward); prefers-reduced-motion disables all loops - Accent colors match the chat transport pills exactly (meshtastic mint, meshcore orange, reticulum blue, lora amber, fips violet, tor indigo) - Tor (3 anonymous relays), FIPS (direct P2P) and unknown-transport shapes preserved, as are SNR/RSSI + E2E/delivery metadata (now glass chips) - Old inline modal markup removed from Mesh.vue; .mesh-hopviz-* rules removed from mesh-styles.css (shared transport-modal classes untouched) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e9741b013
commit
ac09fc5ded
@ -10,6 +10,7 @@ import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
||||
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||
import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
|
||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||
import HopVizModal from '@/views/mesh/HopVizModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
||||
@ -1298,11 +1299,6 @@ const hopVizPeer = computed<MeshPeer | null>(() => {
|
||||
if (!m) return null
|
||||
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
|
||||
})
|
||||
function hopVizHops(): number | null {
|
||||
const p = hopVizPeer.value
|
||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
||||
return p.hops
|
||||
}
|
||||
function signalQualityLabel(snr: number | null, rssi: number | null): string {
|
||||
if (snr == null && rssi == null) return 'signal unknown'
|
||||
if (snr != null) {
|
||||
@ -2625,57 +2621,17 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
</Teleport>
|
||||
|
||||
<!-- Hop visualization modal: click a message's transport pill to see how
|
||||
it traveled — radio hops + live signal for LoRa transports, overlay/
|
||||
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
|
||||
<Teleport to="body">
|
||||
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) || 'Message' }} route</h3>
|
||||
<p class="mesh-transport-sub">
|
||||
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
|
||||
</p>
|
||||
<div class="mesh-hopviz-chain">
|
||||
<div class="mesh-hopviz-node">
|
||||
<span class="mesh-hopviz-icon">🏝️</span>
|
||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
|
||||
</div>
|
||||
<template v-if="hopVizMsg.transport === 'tor'">
|
||||
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
|
||||
</template>
|
||||
<template v-else-if="hopVizMsg.transport === 'fips'">
|
||||
<div class="mesh-hopviz-link">⚡ FIPS overlay · direct peer-to-peer</div>
|
||||
</template>
|
||||
<template v-else-if="!hopVizMsg.transport">
|
||||
<div class="mesh-hopviz-link">🛰 transport wasn't recorded for this message</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mesh-hopviz-link">
|
||||
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
|
||||
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
|
||||
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node">•</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mesh-hopviz-node">
|
||||
<span class="mesh-hopviz-icon">🏝️</span>
|
||||
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
|
||||
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
|
||||
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
|
||||
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
|
||||
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
||||
</div>
|
||||
<div class="mesh-hopviz-signal">
|
||||
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
|
||||
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
it traveled — branded medallion + animated packet design, vertical on
|
||||
mobile. Self-contained component (Teleports to body itself). -->
|
||||
<HopVizModal
|
||||
v-if="hopVizMsg"
|
||||
:msg="hopVizMsg"
|
||||
:peer="hopVizPeer"
|
||||
:transport-label="transportLabel(hopVizMsg)"
|
||||
:signal-label="signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null)"
|
||||
:time-label="timeAgo(hopVizMsg.timestamp)"
|
||||
@close="hopVizMsg = null"
|
||||
/>
|
||||
|
||||
<!-- The "mesh device detected" setup flow is now the global
|
||||
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
||||
|
||||
438
neode-ui/src/views/mesh/HopVizModal.vue
Normal file
438
neode-ui/src/views/mesh/HopVizModal.vue
Normal file
@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<!-- Hop-route visualization modal: click a message's transport pill to see
|
||||
how it traveled. Teleported to body (full-screen backdrop, project rule).
|
||||
Branded redesign: glowing endpoint medallions ringed with EQ segments
|
||||
(ScreensaverRing motif), accent-colored track with staggered relay
|
||||
markers and an animated packet traveling sender → recipient. Vertical
|
||||
stacked layout below 560px; prefers-reduced-motion disables all loops. -->
|
||||
<Teleport to="body">
|
||||
<div class="mesh-transport-modal-backdrop" @click.self="emit('close')">
|
||||
<div class="glass-card hopviz-panel" :style="panelStyle">
|
||||
<h3 class="hopviz-title">{{ transportLabel || 'Message' }} route</h3>
|
||||
<p class="hopviz-sub">{{ senderName }} → {{ recipientName }}</p>
|
||||
|
||||
<div class="hopviz-chain">
|
||||
<div class="hopviz-endpoint">
|
||||
<div class="hopviz-medallion">
|
||||
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
||||
<span class="hopviz-glyph">🏝️</span>
|
||||
</div>
|
||||
<span class="hopviz-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="hopviz-path">
|
||||
<div class="hopviz-track" :class="{ 'hopviz-track-unknown': !msg.transport }">
|
||||
<div class="hopviz-track-line"></div>
|
||||
<span
|
||||
v-for="i in relayCount"
|
||||
:key="i"
|
||||
class="hopviz-relay"
|
||||
:style="relayStyle(i)"
|
||||
title="relay node"
|
||||
>
|
||||
<template v-if="msg.transport === 'tor'">🧅</template>
|
||||
<template v-else>
|
||||
<i v-for="b in 3" :key="b" class="hopviz-relay-bar" :style="{ '--bar-i': b }" />
|
||||
</template>
|
||||
</span>
|
||||
<div class="hopviz-packet"></div>
|
||||
</div>
|
||||
<div class="hopviz-track-label">{{ routeLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="hopviz-endpoint">
|
||||
<div class="hopviz-medallion">
|
||||
<span v-for="i in RING_SEGMENTS" :key="i" class="hopviz-seg" :style="ringSegStyle(i)" />
|
||||
<span class="hopviz-glyph">🏝️</span>
|
||||
</div>
|
||||
<span class="hopviz-name">{{ recipientName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSignal" class="hopviz-meta-row">
|
||||
<span class="hopviz-chip hopviz-chip-strong">{{ signalLabel }}</span>
|
||||
<span v-if="peer?.snr != null" class="hopviz-chip">SNR {{ peer.snr.toFixed(1) }} dB</span>
|
||||
<span v-if="peer?.rssi != null" class="hopviz-chip">RSSI {{ peer.rssi }} dBm</span>
|
||||
<p class="hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
|
||||
</div>
|
||||
<div class="hopviz-meta-row">
|
||||
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
|
||||
<span class="hopviz-chip">{{ deliveryLabel }}</span>
|
||||
<span class="hopviz-chip">{{ timeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<button class="mesh-transport-cancel" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { MeshMessage, MeshPeer } from '@/stores/mesh'
|
||||
|
||||
const props = defineProps<{
|
||||
msg: MeshMessage
|
||||
peer: MeshPeer | null
|
||||
/** Pre-computed by Mesh.vue's transportLabel() — don't duplicate the logic. */
|
||||
transportLabel: string | null
|
||||
/** Pre-computed by Mesh.vue's signalQualityLabel(). */
|
||||
signalLabel: string
|
||||
/** Pre-computed by Mesh.vue's timeAgo(). */
|
||||
timeLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
/** EQ segments around each endpoint medallion (compact ScreensaverRing echo). */
|
||||
const RING_SEGMENTS = 14
|
||||
|
||||
/** Accent per transport — MUST match the chat transport pill colors in
|
||||
* mesh-styles.css (.mesh-chat-transport.transport-*). */
|
||||
const TRANSPORT_ACCENTS: Record<string, string> = {
|
||||
meshtastic: '#3eb489', // mint
|
||||
meshcore: '#fb923c', // orange
|
||||
reticulum: '#60a5fa', // blue
|
||||
lora: '#f59e0b', // amber
|
||||
fips: '#a78bfa', // violet
|
||||
tor: '#818cf8', // indigo
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const accent = computed(() => TRANSPORT_ACCENTS[props.msg.transport ?? ''] ?? '#fb923c')
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
'--hop-accent': accent.value,
|
||||
'--hop-accent-soft': hexToRgba(accent.value, 0.35),
|
||||
'--hop-accent-faint': hexToRgba(accent.value, 0.14),
|
||||
}))
|
||||
|
||||
const peerName = computed(() => props.peer?.advert_name || props.msg.peer_name || 'Peer')
|
||||
const senderName = computed(() => (props.msg.direction === 'sent' ? 'You' : peerName.value))
|
||||
const recipientName = computed(() => (props.msg.direction === 'sent' ? peerName.value : 'You'))
|
||||
|
||||
/** LoRa transports only know a hop COUNT (peer.hops; 0 or 0xff/null = direct). */
|
||||
const hops = computed<number | null>(() => {
|
||||
const p = props.peer
|
||||
if (!p || p.hops == null || p.hops === 0xff) return null
|
||||
return p.hops
|
||||
})
|
||||
|
||||
const showSignal = computed(() => props.msg.transport !== 'tor' && props.msg.transport !== 'fips')
|
||||
|
||||
const relayCount = computed(() => {
|
||||
const t = props.msg.transport
|
||||
if (t === 'tor') return 3 // fixed circuit shape, anonymous
|
||||
if (t === 'fips' || !t) return 0 // direct P2P / not recorded
|
||||
return hops.value ? Math.min(hops.value, 6) : 0
|
||||
})
|
||||
|
||||
const routeLabel = computed(() => {
|
||||
const t = props.msg.transport
|
||||
if (t === 'tor') return '🧅 3 anonymous relays'
|
||||
if (t === 'fips') return '⚡ FIPS overlay · direct peer-to-peer'
|
||||
if (!t) return "🛰 transport wasn't recorded for this message"
|
||||
const h = hops.value
|
||||
return `📡 ${h === null || h === 0 ? 'direct radio link' : `${h} hop${h === 1 ? '' : 's'}`}`
|
||||
})
|
||||
|
||||
const deliveryLabel = computed(() =>
|
||||
props.msg.delivered && props.msg.direction === 'sent'
|
||||
? 'delivered ✓✓'
|
||||
: props.msg.direction === 'sent'
|
||||
? 'sent'
|
||||
: 'received',
|
||||
)
|
||||
|
||||
function ringSegStyle(i: number) {
|
||||
return {
|
||||
'--seg-deg': `${((i - 1) / RING_SEGMENTS) * 360}deg`,
|
||||
'--seg-i': String(i - 1),
|
||||
}
|
||||
}
|
||||
|
||||
function relayStyle(i: number) {
|
||||
return {
|
||||
'--relay-pos': `${Math.round((i / (relayCount.value + 1)) * 100)}%`,
|
||||
'--relay-i': String(i - 1),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hopviz-panel {
|
||||
width: min(560px, 94vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 26px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hopviz-title {
|
||||
margin: 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--hop-accent);
|
||||
text-shadow: 0 0 18px var(--hop-accent-faint);
|
||||
}
|
||||
.hopviz-sub {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Chain: sender medallion → track → recipient medallion ─────────────── */
|
||||
.hopviz-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 4px 10px;
|
||||
}
|
||||
/* Staggered route reveal: sender, then track+relays, then recipient. */
|
||||
.hopviz-chain > * {
|
||||
opacity: 0;
|
||||
animation: hopviz-appear 0.4s ease forwards;
|
||||
}
|
||||
.hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
|
||||
.hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
|
||||
.hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
|
||||
@keyframes hopviz-appear {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.hopviz-endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
/* Endpoint medallion: island glyph inside a compact EQ-segment ring
|
||||
(ScreensaverRing technique: rotate + translateY(-radius) + scaleY pulse). */
|
||||
.hopviz-medallion {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
--ring-radius: 30px;
|
||||
}
|
||||
.hopviz-glyph {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--hop-accent-faint) 0%, rgba(255, 255, 255, 0.03) 70%);
|
||||
border: 1px solid var(--hop-accent-faint);
|
||||
box-shadow: 0 0 26px var(--hop-accent-soft);
|
||||
}
|
||||
.hopviz-seg {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 11px;
|
||||
margin-left: -1.5px;
|
||||
margin-top: -5.5px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.85), var(--hop-accent-soft));
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius)));
|
||||
animation: hopviz-seg-pulse 2.6s ease-in-out infinite;
|
||||
animation-delay: calc(var(--seg-i) * 0.12s);
|
||||
}
|
||||
@keyframes hopviz-seg-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(0.5);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.95;
|
||||
transform: rotate(var(--seg-deg)) translateY(calc(-1 * var(--ring-radius))) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hopviz-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Path: accent track + relay markers + traveling packet + label ─────── */
|
||||
.hopviz-path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.hopviz-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
.hopviz-track-line {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(to right, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
||||
opacity: 0.75;
|
||||
box-shadow: 0 0 8px var(--hop-accent-soft);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.hopviz-track-unknown .hopviz-track-line { opacity: 0.3; box-shadow: none; }
|
||||
|
||||
/* Relay markers sit ON the track: tiny EQ clusters (🧅 for Tor's circuit). */
|
||||
.hopviz-relay {
|
||||
position: absolute;
|
||||
left: var(--relay-pos);
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 6px var(--hop-accent-soft));
|
||||
}
|
||||
.hopviz-relay-bar {
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, #fff, var(--hop-accent));
|
||||
transform-origin: center;
|
||||
animation: hopviz-bar-pulse 1.8s ease-in-out infinite;
|
||||
animation-delay: calc((var(--relay-i, 0) * 0.3s) + (var(--bar-i, 0) * 0.15s));
|
||||
}
|
||||
.hopviz-relay-bar:nth-child(2) { height: 16px; }
|
||||
@keyframes hopviz-bar-pulse {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.5; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* The packet: a bright glowing dot traveling sender → recipient. */
|
||||
.hopviz-packet {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 6px 1px #fff,
|
||||
0 0 14px 4px var(--hop-accent),
|
||||
0 0 26px 8px var(--hop-accent-soft);
|
||||
opacity: 0;
|
||||
animation: hopviz-packet-x 2.2s ease-in-out infinite;
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
@keyframes hopviz-packet-x {
|
||||
0% { left: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
100% { left: 100%; opacity: 0; }
|
||||
}
|
||||
@keyframes hopviz-packet-y {
|
||||
0% { top: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
88% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.hopviz-track-label {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ── Metadata footer: glass chips ──────────────────────────────────────── */
|
||||
.hopviz-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hopviz-chip {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.hopviz-chip-strong {
|
||||
color: #fff;
|
||||
border-color: var(--hop-accent-soft);
|
||||
background: var(--hop-accent-faint);
|
||||
}
|
||||
.hopviz-note {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
/* ── Mobile: vertical chain, sender top → recipient bottom ─────────────── */
|
||||
@media (max-width: 560px) {
|
||||
.hopviz-panel { width: 94vw; padding: 22px 18px; }
|
||||
.hopviz-chain { flex-direction: column; gap: 8px; padding: 14px 0 8px; }
|
||||
.hopviz-medallion { width: 56px; height: 56px; --ring-radius: 23px; }
|
||||
.hopviz-glyph { inset: 9px; font-size: 1.3rem; }
|
||||
.hopviz-endpoint { min-width: 0; }
|
||||
.hopviz-name { max-width: 80vw; }
|
||||
.hopviz-path { width: 100%; min-width: 0; }
|
||||
.hopviz-track { height: 110px; }
|
||||
.hopviz-track-line {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
height: auto;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to bottom, transparent, var(--hop-accent) 12%, var(--hop-accent) 88%, transparent);
|
||||
}
|
||||
.hopviz-relay { left: 50%; top: var(--relay-pos); }
|
||||
.hopviz-packet {
|
||||
left: 50%;
|
||||
top: 0%;
|
||||
animation-name: hopviz-packet-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: static layout, no loops, no entrance ──────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hopviz-chain > * { animation: none; opacity: 1; }
|
||||
.hopviz-seg { animation: none; opacity: 0.7; }
|
||||
.hopviz-relay-bar { animation: none; }
|
||||
.hopviz-packet { display: none; }
|
||||
}
|
||||
</style>
|
||||
@ -591,50 +591,10 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
||||
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
|
||||
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
|
||||
|
||||
/* Hop visualization modal + clickable transport pill */
|
||||
/* Clickable transport pill — opens the hop-route modal (HopVizModal.vue,
|
||||
which carries its own scoped hopviz styles). */
|
||||
.mesh-chat-transport-clickable { cursor: pointer; }
|
||||
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
|
||||
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
|
||||
/* Animated route reveal: endpoints and link fade/slide in sequence, then a
|
||||
pulse travels the link continuously to show the direction of travel. */
|
||||
.mesh-hopviz-chain > * { opacity: 0; animation: mesh-hopviz-appear 0.4s ease forwards; }
|
||||
.mesh-hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
|
||||
.mesh-hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
|
||||
.mesh-hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
|
||||
@keyframes mesh-hopviz-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.mesh-hopviz-link { position: relative; overflow: hidden; }
|
||||
.mesh-hopviz-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -18%;
|
||||
width: 18%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(251,146,60,0.95), transparent);
|
||||
animation: mesh-hopviz-travel 1.6s linear infinite;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
@keyframes mesh-hopviz-travel { from { left: -18%; } to { left: 100%; } }
|
||||
.mesh-hopviz-dot { animation: mesh-hopviz-blink 1.6s ease-in-out infinite; }
|
||||
.mesh-hopviz-dot:nth-child(2) { animation-delay: 0.25s; }
|
||||
.mesh-hopviz-dot:nth-child(3) { animation-delay: 0.5s; }
|
||||
.mesh-hopviz-dot:nth-child(4) { animation-delay: 0.75s; }
|
||||
.mesh-hopviz-dot:nth-child(5) { animation-delay: 1s; }
|
||||
.mesh-hopviz-dot:nth-child(6) { animation-delay: 1.25s; }
|
||||
@keyframes mesh-hopviz-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mesh-hopviz-chain > * { animation: none; opacity: 1; }
|
||||
.mesh-hopviz-link::before { animation: none; display: none; }
|
||||
.mesh-hopviz-dot { animation: none; }
|
||||
}
|
||||
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
|
||||
.mesh-hopviz-icon { font-size: 1.8rem; }
|
||||
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
|
||||
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
|
||||
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
|
||||
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
|
||||
|
||||
/* Per-message "more" button — opens the route/hops modal (same target as the
|
||||
transport pill, but always visible and discoverable). */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user