Compare commits

...

2 commits

Author SHA1 Message Date
Scott Reimers
be253e8001 Our Info panel, hole punch race fix, NAT profiles in relay introduction
- Network Diagnostics: "Our Info" button shows addresses with NAT status,
  device role, UPnP, HTTP capability. Addresses stacked for mobile.
- Hole punch race: re-check for existing connection before and after relay
  introduction to avoid wasting minutes on redundant punch attempts.
- Relay introduction now carries requester/target NAT mapping+filtering
  so hole punch strategy uses fresh profiles instead of stale stored ones.
  Critical for phones that switch between WiFi/cellular/VPN.
- STUN fix: filter DNS results to IPv4 (was resolving to IPv6 first on
  dual-stack, causing silent send failure and "NAT unknown").
- Welcome screen: Ready button with loading bar for instant feed access.
- LAN addresses show just "LAN" (no misleading punchability label).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:57:41 -04:00
Scott Reimers
e354ccc388 Welcome screen: add Ready button with loading bar for instant feed access
Progress bar animates during backend readiness check. Once local feed is
loaded from SQLite, button enables with teal highlight. Click switches to
feed tab with cached content — no network wait needed for returning users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:41:50 -04:00
8 changed files with 303 additions and 11 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "itsgoin-cli"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"anyhow",
"hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]]
name = "itsgoin-core"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2767,7 +2767,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.4.4"
version = "0.5.0"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -3868,12 +3868,15 @@ impl ConnectionManager {
}
}
let our_profile = self.our_nat_profile();
let payload = RelayIntroducePayload {
intro_id,
target: *target,
requester: self.our_node_id,
requester_addresses: our_addrs,
ttl,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
};
let (mut send, mut recv) = pc.connection.open_bi().await?;
@ -3909,6 +3912,8 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some("duplicate intro".to_string()),
nat_mapping: None,
nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -3932,12 +3937,15 @@ impl ConnectionManager {
}
}
let our_profile = self.our_nat_profile();
let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id,
accepted: true,
target_addresses: our_addrs,
relay_available: false,
reject_reason: None,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -3963,7 +3971,20 @@ impl ConnectionManager {
let our_http_capable = self.http_capable;
let our_http_addr = self.http_addr.clone();
let our_nat_profile = self.our_nat_profile();
let peer_nat_profile = {
// Prefer fresh NAT profile from relay payload over stale stored profile
let peer_nat_profile = if payload.nat_mapping.is_some() || payload.nat_filtering.is_some() {
let mapping = payload.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = payload.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
// Update storage with fresh profile
let s = self.storage.get().await;
let _ = s.set_peer_nat_profile(&requester, &fresh);
fresh
} else {
let s = self.storage.get().await;
s.get_peer_nat_profile(&requester)
};
@ -4081,6 +4102,7 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some(format!("relay forward failed: {}", e)),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -4155,6 +4177,7 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some(format!("relay forward to session failed: {}", e)),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -4194,6 +4217,8 @@ impl ConnectionManager {
requester: payload.requester,
requester_addresses: req_addrs,
ttl: payload.ttl - 1,
nat_mapping: payload.nat_mapping.clone(),
nat_filtering: payload.nat_filtering.clone(),
};
let forward_result = async {
@ -4246,6 +4271,7 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some("target not reachable through relay".to_string()),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -4273,6 +4299,7 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some("relay at capacity".to_string()),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
requester_send.finish()?;
@ -4293,6 +4320,7 @@ impl ConnectionManager {
target_addresses: vec![],
relay_available: false,
reject_reason: Some("target not connected to relay".to_string()),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
requester_send.finish()?;
@ -5811,6 +5839,7 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: false, target_addresses: vec![],
relay_available: false, reject_reason: Some("duplicate intro".to_string()),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -5836,7 +5865,22 @@ impl ConnectionManager {
let our_http_capable = cm.http_capable;
let our_http_addr = cm.http_addr.clone();
let our_nat_profile = cm.our_nat_profile();
let peer_nat_profile = { let s = cm.storage.get().await; s.get_peer_nat_profile(&payload.requester) };
// Prefer fresh NAT profile from relay payload over stale stored profile
let peer_nat_profile = if payload.nat_mapping.is_some() || payload.nat_filtering.is_some() {
let mapping = payload.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = payload.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
let s = cm.storage.get().await;
let _ = s.set_peer_nat_profile(&payload.requester, &fresh);
fresh
} else {
let s = cm.storage.get().await;
s.get_peer_nat_profile(&payload.requester)
};
Some(RelayGathered::WeAreTarget { our_addrs, endpoint, storage, our_node_id, our_nat_type, our_http_capable, our_http_addr, our_nat_profile, peer_nat_profile })
} else {
// We are relay — gather target connection, requester observed addr, etc.
@ -5866,6 +5910,8 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: true, target_addresses: our_addrs,
relay_available: false, reject_reason: None,
nat_mapping: Some(our_nat_profile.mapping.to_string()),
nat_filtering: Some(our_nat_profile.filtering.to_string()),
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;
@ -5954,6 +6000,7 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: false, target_addresses: vec![],
relay_available: false, reject_reason: Some(format!("relay forward failed: {}", e_msg)),
nat_mapping: None, nat_filtering: None,
};
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?;

View file

@ -349,6 +349,11 @@ impl Network {
self.device_role
}
/// Get the explicit bind address (from --bind flag), if any.
pub fn bind_addr(&self) -> Option<std::net::SocketAddr> {
self.bind_addr
}
/// Whether this node can serve HTTP (has TCP reachability).
pub fn is_http_capable(&self) -> bool {
self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some()
@ -1952,7 +1957,13 @@ impl Network {
}
}
// 4. Try relay introduction + hole punch (no lock during I/O)
// 4. Re-check connection — peer may have connected to us while we were trying direct
if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await {
debug!(peer = hex::encode(peer_id), "Peer connected to us while we were trying — using existing connection");
return Ok(conn);
}
// 5. Try relay introduction + hole punch (no lock during I/O)
let relay_candidates = self.conn_handle.find_relays_for(peer_id).await;
if relay_candidates.is_empty() {
@ -1975,10 +1986,28 @@ impl Network {
self.send_relay_introduce_standalone(relay_peer, peer_id, *ttl),
).await;
// Re-check: peer may have connected while relay intro was in flight
if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await {
debug!(peer = hex::encode(peer_id), "Peer connected during relay intro — using existing");
return Ok(conn);
}
match intro_result {
Ok(Ok(result)) if result.accepted => {
Ok(Ok(ref result)) if result.accepted => {
let our_profile = self.conn_handle.our_nat_profile().await;
let peer_profile = {
// Prefer fresh NAT profile from relay response over stale stored profile
let peer_profile = if result.nat_mapping.is_some() || result.nat_filtering.is_some() {
let mapping = result.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = result.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
let s = self.storage.get().await;
let _ = s.set_peer_nat_profile(peer_id, &fresh);
fresh
} else {
let s = self.storage.get().await;
s.get_peer_nat_profile(peer_id)
};
@ -2274,12 +2303,15 @@ impl Network {
}
}
let our_profile = self.conn_handle.our_nat_profile().await;
let payload = crate::protocol::RelayIntroducePayload {
intro_id,
target: *target,
requester: self.our_node_id,
requester_addresses: our_addrs,
ttl,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
};
let (mut send, mut recv) = conn.open_bi().await?;

View file

@ -461,6 +461,12 @@ pub struct RelayIntroducePayload {
pub requester_addresses: Vec<String>,
/// Max forwarding hops remaining (0 = relay must know target directly)
pub ttl: u8,
/// Requester's current NAT mapping type (for hole punch strategy)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_mapping: Option<String>,
/// Requester's current NAT filtering type
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_filtering: Option<String>,
}
/// Target's response to a relay introduction (bi-stream response)
@ -472,6 +478,12 @@ pub struct RelayIntroduceResultPayload {
/// Relay is willing to serve as stream relay fallback
pub relay_available: bool,
pub reject_reason: Option<String>,
/// Target's current NAT mapping type (for hole punch strategy)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_mapping: Option<String>,
/// Target's current NAT filtering type
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_filtering: Option<String>,
}
/// Open a relay pipe — intermediary splices two bi-streams (bi-stream)
@ -858,6 +870,8 @@ mod tests {
requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()],
ttl: 1,
nat_mapping: Some("eim".to_string()),
nat_filtering: Some("open".to_string()),
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: RelayIntroducePayload = serde_json::from_str(&json).unwrap();
@ -876,6 +890,8 @@ mod tests {
target_addresses: vec!["10.0.0.1:4433".to_string(), "192.168.1.1:4433".to_string()],
relay_available: true,
reject_reason: None,
nat_mapping: Some("eim".to_string()),
nat_filtering: Some("open".to_string()),
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: RelayIntroduceResultPayload = serde_json::from_str(&json).unwrap();
@ -892,6 +908,8 @@ mod tests {
target_addresses: vec![],
relay_available: false,
reject_reason: Some("target not reachable".to_string()),
nat_mapping: None,
nat_filtering: None,
};
let json2 = serde_json::to_string(&rejected).unwrap();
let decoded2: RelayIntroduceResultPayload = serde_json::from_str(&json2).unwrap();

View file

@ -103,8 +103,9 @@ fn parse_xor_mapped_address(resp: &[u8], txn_id: &[u8; 12]) -> Option<SocketAddr
/// Query a single STUN server and return the mapped address.
async fn stun_query(sock: &UdpSocket, server: &str) -> Option<SocketAddr> {
use std::net::ToSocketAddrs;
// Filter for IPv4 since our socket is bound to 0.0.0.0 (IPv4)
let server_addr = match server.to_socket_addrs() {
Ok(mut addrs) => addrs.next()?,
Ok(addrs) => addrs.filter(|a| a.is_ipv4()).next()?,
Err(e) => {
debug!(server, error = %e, "STUN DNS resolution failed");
return None;

View file

@ -1623,6 +1623,120 @@ async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummary
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OurInfoDto {
node_id: String,
addresses: Vec<AddressInfoDto>,
nat_type: String,
device_role: String,
upnp: bool,
http_capable: bool,
http_addr: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AddressInfoDto {
addr: String,
family: String, // "IPv4" or "IPv6"
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
}
#[tauri::command]
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
let node = get_node(&state).await;
let net = &node.network;
let nat_type = net.conn_handle().nat_type().await;
let has_upnp = net.has_upnp();
let _has_public_v6 = net.has_public_v6();
let bind_addr = net.bind_addr();
let mut addresses = Vec::new();
// Collect bound socket addresses (these are our local interfaces)
let bound: std::collections::HashSet<String> = net.bound_sockets()
.iter()
.map(|s| s.to_string())
.collect();
// Gather bound (local) addresses with classification
for sock in net.bound_sockets() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let status = classify_addr(&sock, &nat_type, has_upnp, bind_addr.is_some(), false);
addresses.push(AddressInfoDto {
addr: sock.to_string(),
family: family.to_string(),
status,
});
}
// Add UPnP external address if different from bound
if let Some(ref mapping) = net.upnp_mapping() {
let ext = mapping.external_addr;
if !addresses.iter().any(|a| a.addr == ext.to_string()) {
addresses.insert(0, AddressInfoDto {
addr: ext.to_string(),
family: if ext.ip().is_ipv4() { "IPv4" } else { "IPv6" }.to_string(),
status: "UPnP external".to_string(),
});
}
}
// Add iroh-discovered addresses not already listed (STUN-observed externals)
for sock in net.endpoint_addr().ip_addrs() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let s = sock.to_string();
if addresses.iter().any(|a| a.addr == s) { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let is_observed = !bound.contains(&s);
let status = classify_addr(sock, &nat_type, has_upnp, bind_addr.is_some(), is_observed);
addresses.push(AddressInfoDto {
addr: s,
family: family.to_string(),
status,
});
}
Ok(OurInfoDto {
node_id: hex::encode(net.node_id_bytes()),
addresses,
nat_type: nat_type.to_string(),
device_role: format!("{:?}", net.device_role()),
upnp: has_upnp,
http_capable: net.is_http_capable(),
http_addr: net.http_addr(),
})
}
fn classify_addr(sock: &std::net::SocketAddr, nat_type: &itsgoin_core::types::NatType, has_upnp: bool, is_server: bool, is_observed: bool) -> String {
use std::net::IpAddr;
let ip = sock.ip();
let is_v6 = ip.is_ipv6();
let is_pub = match ip {
IpAddr::V4(v4) => !v4.is_private() && !v4.is_loopback() && !v4.is_link_local(),
IpAddr::V6(v6) => !v6.is_loopback() && {
let seg = v6.segments();
seg[0] & 0xfe00 != 0xfe00 && seg[0] & 0xffc0 != 0xfe80
},
};
if is_server { return "Server".to_string(); }
if is_pub {
if is_observed && !is_v6 {
return match nat_type {
itsgoin_core::types::NatType::Public |
itsgoin_core::types::NatType::Easy => "External · easy punch".to_string(),
itsgoin_core::types::NatType::Hard => "External · hard punch (scan)".to_string(),
itsgoin_core::types::NatType::Unknown => "External".to_string(),
};
}
return "Public".to_string();
}
if is_v6 { return "Link-local".to_string(); }
"LAN".to_string()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityEventDto {
@ -2387,6 +2501,7 @@ pub fn run() {
sync_all,
sync_from_peer,
get_network_summary,
get_our_info,
get_activity_log,
trigger_rebalance,
request_referrals,

View file

@ -2986,9 +2986,14 @@ function openDiagnostics() {
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
<button id="show-connections-btn" class="btn btn-ghost btn-sm">Show Connections</button>
<button id="show-anchors-btn" class="btn btn-ghost btn-sm">Stored Anchors</button>
</div>
<div id="ourinfo-section" class="hidden">
<h4 class="subsection-title">Our Info</h4>
<div id="ourinfo-content"></div>
</div>
<div id="connections-section" class="hidden">
<h4 class="subsection-title">Mesh &amp; Session Connections</h4>
<div id="connections-list"></div>
@ -3006,6 +3011,45 @@ function openDiagnostics() {
networkSummaryEl = $('#network-summary');
connectionsList = $('#connections-list');
peersList = null; // Known peers removed
// Wire Our Info toggle
$('#show-ourinfo-btn').addEventListener('click', async () => {
const section = $('#ourinfo-section');
const btn = $('#show-ourinfo-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Hide Our Info';
try {
const info = await invoke('get_our_info');
const httpVal = info.httpAddr || 'No';
let html = `<div class="diag-grid" style="margin-bottom:0.5rem">
<div class="diag-item"><span class="diag-label">NAT Type</span><span class="diag-value" style="font-size:0.85rem">${info.natType}</span></div>
<div class="diag-item"><span class="diag-label">Role</span><span class="diag-value" style="font-size:0.85rem">${info.deviceRole}</span></div>
<div class="diag-item"><span class="diag-label">UPnP</span><span class="diag-value" style="font-size:0.85rem">${info.upnp ? 'Yes' : 'No'}</span></div>
</div>
<div style="text-align:center;margin-bottom:0.75rem">
<span style="color:#888;font-size:0.75rem">HTTP</span>
<span style="color:#ccc;font-size:0.8rem;margin-left:0.5rem">${httpVal}</span>
</div>`;
html += '<div style="font-size:0.8rem">';
for (const a of info.addresses) {
const color = a.status.includes('Public') || a.status.includes('punchable') || a.status.includes('Server') ? '#7fdbca' :
a.status.includes('UPnP') || a.status.includes('External') ? '#5b8def' : '#888';
html += `<div style="padding:0.3rem 0;border-bottom:1px solid #1a1a2e">
<div style="color:#ccc;font-family:monospace;font-size:0.7rem;word-break:break-all">${a.addr}</div>
<div style="color:${color};font-size:0.7rem;margin-top:0.1rem">${a.family} · ${a.status}</div>
</div>`;
}
html += '</div>';
html += `<p style="color:#555;font-size:0.7rem;text-align:center;margin-top:0.5rem;word-break:break-all">Node: ${info.nodeId.substring(0, 16)}…</p>`;
$('#ourinfo-content').innerHTML = html;
} catch (e) {
$('#ourinfo-content').innerHTML = `<p class="empty-hint">Failed to load: ${e}</p>`;
}
} else {
section.classList.add('hidden');
btn.textContent = 'Our Info';
}
});
// Wire connections toggle
$('#show-connections-btn').addEventListener('click', () => {
const section = $('#connections-section');
@ -3570,10 +3614,29 @@ async function init() {
}
}, 2000);
// Ready button — click to go to feed
const readyBtn = document.getElementById('welcome-ready-btn');
const readyBar = document.getElementById('welcome-ready-bar');
if (readyBtn) {
readyBtn.addEventListener('click', () => {
if (readyBtn.disabled) return;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const feedTab = document.querySelector('.tab[data-tab="feed"]');
if (feedTab) feedTab.classList.add('active');
document.getElementById('view-feed').classList.add('active');
currentTab = 'feed';
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
});
}
// Wait for backend in the background, then load node info
(async () => {
for (let attempt = 0; attempt < 30; attempt++) {
try {
// Animate progress bar toward 90% during readiness checks
if (readyBar) readyBar.style.width = Math.min(90, (attempt + 1) * 3) + '%';
await invoke('get_node_info');
break;
} catch (e) {
@ -3586,9 +3649,19 @@ async function init() {
setupOverlay.classList.remove('hidden');
setupName.focus();
}
// Reload feed now that backend is ready
loadFeed(true).catch(() => {});
// Pre-load feed + messages from local DB (instant — no network needed)
await loadFeed(true).catch(() => {});
loadMessages(true).catch(() => {});
// Mark ready button as clickable
if (readyBar) readyBar.style.width = '100%';
if (readyBtn) {
readyBtn.disabled = false;
readyBtn.textContent = 'Ready — Go to Feed';
readyBtn.style.opacity = '1';
readyBtn.style.color = '#7fdbca';
readyBtn.style.borderColor = '#7fdbca';
readyBtn.style.cursor = 'pointer';
}
})();
// Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam)

View file

@ -49,6 +49,12 @@
<div><span id="welcome-reacts" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Reacts</div>
<div><span id="welcome-comments" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Comments</div>
</div>
<div style="margin-top:2rem;max-width:280px;margin-left:auto;margin-right:auto">
<div style="background:#1a1a2e;border-radius:6px;height:6px;overflow:hidden;margin-bottom:0.75rem">
<div id="welcome-ready-bar" style="height:100%;background:#7fdbca;width:0%;transition:width 0.5s ease;border-radius:6px"></div>
</div>
<button id="welcome-ready-btn" disabled style="width:100%;padding:0.75rem 1.5rem;border:1px solid #333;border-radius:8px;background:#1a1a2e;color:#666;font-size:0.95rem;cursor:not-allowed;opacity:0.5;transition:all 0.3s ease">Loading...</button>
</div>
</div>
</section>