Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
+438
View 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>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
const saving = ref(false)
const status = computed(() => mesh.assistantStatus)
const enabled = ref(false)
const model = ref('') // '' = use the backend's default model
const policy = ref<'trusted' | 'anyone'>('trusted')
const backend = ref<'claude' | 'ollama'>('claude')
const allowedContacts = ref<string[]>([])
// Preset Claude models offered in the dropdown ('' = backend default = Haiku).
const CLAUDE_MODELS: { value: string; label: string }[] = [
{ value: '', label: 'Default (Claude Haiku 4.5)' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — fast & cheap' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8 — most capable' },
]
// Include any non-preset value the node already has so it isn't silently lost.
const claudeModelOptions = computed(() => {
const opts = [...CLAUDE_MODELS]
if (model.value && !opts.some((o) => o.value === model.value)) {
opts.push({ value: model.value, label: `${model.value} (custom)` })
}
return opts
})
// Sync local controls from the fetched status.
watch(
status,
(s) => {
if (!s) return
enabled.value = s.enabled
model.value = s.model ?? ''
policy.value = s.trusted_only ? 'trusted' : 'anyone'
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
allowedContacts.value = [...(s.allowed_contacts ?? [])]
},
{ immediate: true },
)
// Addressable contacts (have an archipelago/radio pubkey) for the allowlist.
const contactOptions = computed(() =>
mesh.peers
.filter((p) => !!p.pubkey_hex)
.map((p) => ({ pubkey: p.pubkey_hex as string, name: p.advert_name || (p.pubkey_hex as string).slice(0, 10) })),
)
function isAllowed(pubkey: string) {
return allowedContacts.value.some((k) => k.toLowerCase() === pubkey.toLowerCase())
}
function toggleAllowed(pubkey: string) {
if (isAllowed(pubkey)) {
allowedContacts.value = allowedContacts.value.filter((k) => k.toLowerCase() !== pubkey.toLowerCase())
} else {
allowedContacts.value = [...allowedContacts.value, pubkey]
}
apply({ allowed_contacts: allowedContacts.value })
}
// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that
// isn't in the contact list yet (e.g. a phone/meshcore device).
const newPubkey = ref('')
const pubkeyError = ref('')
// Allowlisted keys that aren't one of our known contacts (manually added).
const extraAllowed = computed(() =>
allowedContacts.value.filter(
(k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()),
),
)
function addPubkey() {
const pk = newPubkey.value.trim().toLowerCase()
pubkeyError.value = ''
if (!/^[0-9a-f]{64}$/.test(pk)) {
pubkeyError.value = 'Enter a 64-character hex ed25519 public key.'
return
}
if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) {
newPubkey.value = ''
return
}
allowedContacts.value = [...allowedContacts.value, pk]
newPubkey.value = ''
apply({ allowed_contacts: allowedContacts.value })
}
// Radio/peers who recently tried `!ai` and were turned away by the policy.
// Surfaced so the operator can one-click allow them instead of digging through
// the journal for the firmware key. Hide any we've since allowed.
const deniedAskers = computed(() =>
(status.value?.denied_askers ?? []).filter(
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
),
)
function allowDenied(pubkey: string | null) {
if (!pubkey) return
if (!isAllowed(pubkey)) {
allowedContacts.value = [...allowedContacts.value, pubkey]
apply({ allowed_contacts: allowedContacts.value })
}
}
onMounted(() => {
mesh.fetchAssistantStatus()
})
const claudeReady = computed(() => status.value?.claude_available ?? false)
const ollamaReady = computed(() => status.value?.ollama_detected ?? false)
const availableModels = computed(() => status.value?.models ?? [])
const defaultModel = computed(() =>
backend.value === 'claude' ? 'Claude Haiku 4.5' : status.value?.default_model ?? 'qwen2.5-coder',
)
// The selected backend is usable when its provider is available.
const backendReady = computed(() =>
backend.value === 'claude' ? claudeReady.value : ollamaReady.value,
)
async function apply(partial: {
enabled?: boolean
model?: string | null
trusted_only?: boolean
backend?: string
allowed_contacts?: string[]
}) {
saving.value = true
try {
await mesh.configureAssistant(partial)
} finally {
saving.value = false
}
}
function onToggle(val: boolean) {
enabled.value = val
apply({ enabled: val })
}
function onBackend() {
// Reset the model override when switching backend (models differ).
model.value = ''
apply({ backend: backend.value, model: null })
}
function onModel() {
apply({ model: model.value === '' ? null : model.value })
}
function onPolicy() {
apply({ trusted_only: policy.value === 'trusted' })
}
</script>
<template>
<div class="glass-card mesh-assistant-panel">
<h3 class="mesh-panel-title">AI Assistant</h3>
<!-- Backend chooser -->
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">AI backend</label>
<select v-model="backend" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onBackend">
<option value="claude">Claude (shared token no GPU needed)</option>
<option value="ollama">Local model (Ollama on this node)</option>
</select>
</div>
<!-- Provider missing -> guidance / deep-link -->
<div v-if="status && backend === 'ollama' && !ollamaReady" class="mesh-assistant-install">
<p class="text-sm text-white/70 mb-3">
Local mode needs the <strong>Ollama</strong> app installed and running.
</p>
<RouterLink to="/dashboard/marketplace/ollama" class="glass-button mesh-assistant-install-btn">
Install AI (Ollama)
</RouterLink>
</div>
<div v-else-if="status && backend === 'claude' && !claudeReady" class="mesh-assistant-install">
<p class="text-sm text-white/70">
No Claude API token is configured on this node yet. Add one in Settings to use the shared
Claude backend, or switch to a local model.
</p>
</div>
<!-- Enable toggle -->
<button
type="button"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="enabled ? 'bg-white/10 border-orange-500/40' : 'bg-black/20 border-white/10 hover:border-white/20'"
:style="!backendReady ? 'opacity:0.5;cursor:not-allowed' : ''"
@click="backendReady && onToggle(!enabled)"
>
<svg class="w-5 h-5 shrink-0" :class="enabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="enabled ? 'text-white/95' : 'text-white/70'">
{{ enabled ? 'Answering mesh AI queries' : 'Answer mesh AI queries' }}
</p>
<p class="text-xs text-white/50 mt-0.5">Peers can ask this node's AI over the radio</p>
</div>
<ToggleSwitch :model-value="enabled" @click.stop @update:model-value="backendReady && onToggle($event)" />
</button>
<template v-if="enabled">
<div v-if="backend === 'ollama'" class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Model</label>
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
<option value="">Default ({{ defaultModel }})</option>
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<div v-else class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Model</label>
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
<option v-for="m in claudeModelOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Who can ask</label>
<select v-model="policy" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onPolicy">
<option value="trusted">Trusted nodes only</option>
<option value="anyone">Anyone on the mesh</option>
</select>
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
Any peer can spend this node's AI budget + airtime.
</p>
</div>
<!-- Per-contact allowlist: let specific contacts use !ai even when the
policy is "trusted only" and they aren't federation-trusted. -->
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Always allow these contacts</label>
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
No contacts yet — they appear here once you have mesh/federation contacts.
</div>
<div v-else class="mesh-assistant-allowlist">
<label
v-for="c in contactOptions"
:key="c.pubkey"
class="mesh-assistant-allow-row"
>
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
</label>
<!-- Manually-added pubkeys not in the contact list -->
<label
v-for="pk in extraAllowed"
:key="pk"
class="mesh-assistant-allow-row"
>
<input type="checkbox" checked @change="toggleAllowed(pk)" />
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }}… (added)</span>
</label>
</div>
<!-- Add an arbitrary pubkey directly -->
<div class="mesh-assistant-addkey">
<input
v-model="newPubkey"
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
@keyup.enter="addPubkey"
/>
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
</div>
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
</div>
<!-- Recently denied askers: someone tried !ai but the policy turned them
away. Show who, and offer a one-click Allow when we know their key. -->
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Recently denied</label>
<p class="text-xs text-white/40 mb-2">
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
</p>
<div class="mesh-assistant-allowlist">
<div
v-for="d in deniedAskers"
:key="d.contact_id + (d.pubkey_hex || '')"
class="mesh-assistant-allow-row"
>
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
{{ d.name || ('#' + d.contact_id) }}
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}…</span>
</span>
<button
v-if="d.pubkey_hex"
type="button"
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
@click="allowDenied(d.pubkey_hex)"
>
Allow
</button>
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
no key
</span>
</div>
</div>
</div>
<p class="text-xs text-white/50 mt-2">
Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel.
</p>
</template>
</div>
</template>
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
// Bitcoin-headers-over-mesh send/receive toggles (issue #28). Initialized from
// mesh.status (which now carries the persisted prefs) and saved via mesh.configure.
const announceHeaders = ref(false)
const receiveHeaders = ref(true)
const headersSaving = ref(false)
watch(
() => mesh.status,
(s) => {
if (!s) return
if (typeof s.announce_block_headers === 'boolean') announceHeaders.value = s.announce_block_headers
if (typeof s.receive_block_headers === 'boolean') receiveHeaders.value = s.receive_block_headers
},
{ immediate: true, deep: true }
)
async function setAnnounceHeaders(v: boolean) {
announceHeaders.value = v
headersSaving.value = true
try { await mesh.configure({ announce_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
}
async function setReceiveHeaders(v: boolean) {
receiveHeaders.value = v
headersSaving.value = true
try { await mesh.configure({ receive_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
}
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
const relayingTx = ref(false)
const relayingLn = ref(false)
const relayResult = ref('')
const meshSendAddr = ref('')
const meshSendAmount = ref('')
const relayMode = ref<'archy' | 'broadcast'>('archy')
const sendTab = ref<'onchain' | 'lightning'>('onchain')
function pollRelayStatus(requestId: number) {
let attempts = 0
const maxAttempts = 30
const interval = setInterval(async () => {
attempts++
try {
const res = await mesh.relayStatus(requestId)
if (res.status === 'confirmed' && res.txid) {
relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}`
clearInterval(interval)
} else if (res.status === 'failed') {
const code = res.error_code ? ` [${res.error_code}]` : ''
relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}`
clearInterval(interval)
} else if (attempts >= maxAttempts) {
relayResult.value += ' (timed out waiting for confirmation)'
clearInterval(interval)
}
} catch {
if (attempts >= maxAttempts) clearInterval(interval)
}
}, 3000)
}
async function handleMeshSendBitcoin() {
if (!meshSendAddr.value.trim() || !meshSendAmount.value) return
relayingTx.value = true
relayResult.value = ''
try {
relayResult.value = 'Creating signed transaction...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
})
relayResult.value = relayMode.value === 'broadcast'
? 'Broadcasting via mesh network...'
: 'Sending to Archy peers (encrypted)...'
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value)
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...`
meshSendAddr.value = ''
meshSendAmount.value = ''
pollRelayStatus(relayRes.request_id)
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Send failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayTx() {
if (!txHexInput.value.trim()) return
relayingTx.value = true
relayResult.value = ''
try {
const res = await mesh.relayTransaction(txHexInput.value.trim())
relayResult.value = `TX queued (request #${res.request_id})`
txHexInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayLightning() {
if (!bolt11Input.value.trim() || !bolt11AmountInput.value) return
relayingLn.value = true
relayResult.value = ''
try {
const res = await mesh.relayLightning(bolt11Input.value.trim(), parseInt(bolt11AmountInput.value))
relayResult.value = `Lightning relay queued (request #${res.request_id})`
bolt11Input.value = ''
bolt11AmountInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingLn.value = false
}
}
</script>
<template>
<div class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Relay status notification -->
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
<!-- Header send/receive toggles (issue #28) -->
<div class="flex items-center justify-between gap-3 pt-3 mt-2 border-t border-white/10">
<div class="min-w-0">
<span class="mesh-bitcoin-label">Send headers</span>
<small class="mesh-bitcoin-hint">Broadcast new block headers to mesh peers (needs internet)</small>
</div>
<ToggleSwitch :model-value="announceHeaders" :disabled="headersSaving" @update:model-value="setAnnounceHeaders" />
</div>
<div class="flex items-center justify-between gap-3 pt-3">
<div class="min-w-0">
<span class="mesh-bitcoin-label">Receive headers</span>
<small class="mesh-bitcoin-hint">Accept block headers relayed by peers</small>
</div>
<ToggleSwitch :model-value="receiveHeaders" :disabled="headersSaving" @update:model-value="setReceiveHeaders" />
</div>
</div>
<!-- On-Chain / Lightning tabs -->
<div class="mesh-send-tabs">
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
</div>
<!-- On-Chain tab -->
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
</button>
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
<div style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
</div>
<!-- Lightning tab -->
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<div class="mesh-relay-mode">
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
<input type="radio" v-model="relayMode" value="archy" />
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
</label>
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
<input type="radio" v-model="relayMode" value="broadcast" />
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
</label>
</div>
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
</div>
</template>
<style>
.mesh-bitcoin-panel { padding: 18px; display: flex; flex-direction: column; gap: 14px; flex: 1; min-height: 0; overflow-y: auto; }
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
.mesh-panel-sub { font-size: 0.78rem; color: rgba(255,255,255,0.45); margin: -6px 0 0; }
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 10px; }
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
.mesh-bitcoin-label { font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-bitcoin-height { font-size: 0.85rem; font-weight: 700; color: #fb923c; font-family: monospace; }
.mesh-bitcoin-height.mesh-muted { color: rgba(255,255,255,0.3); font-weight: 400; }
.mesh-bitcoin-hint { font-size: 0.78rem; color: rgba(255,255,255,0.4); margin: 0; }
.mesh-bitcoin-input { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: rgba(255,255,255,0.9); padding: 10px 12px; font-size: 0.85rem; font-family: inherit; outline: none; box-sizing: border-box; }
.mesh-bitcoin-input:focus { border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.25); }
.mesh-bitcoin-input-sm { padding: 8px 12px; font-size: 0.8rem; }
textarea.mesh-bitcoin-input { resize: vertical; min-height: 60px; }
select.mesh-bitcoin-input { cursor: pointer; }
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
.mesh-block-row { display: flex; align-items: center; gap: 10px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 6px; font-size: 0.78rem; }
.mesh-block-height { color: #fb923c; font-weight: 600; font-family: monospace; }
.mesh-block-hash { color: rgba(255,255,255,0.4); font-family: monospace; font-size: 0.72rem; }
.mesh-send-tabs { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 3px; }
.mesh-send-tab { flex: 1; padding: 7px 10px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.8rem; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.mesh-send-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.mesh-send-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
.mesh-relay-mode { display: flex; gap: 6px; flex-wrap: wrap; }
.mesh-relay-mode-option { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; font-size: 0.8rem; color: rgba(255,255,255,0.7); transition: all 0.2s; flex: 1; }
.mesh-relay-mode-option:hover { border-color: rgba(255,255,255,0.2); }
.mesh-relay-mode-option.active { border-color: rgba(251,146,60,0.4); background: rgba(251,146,60,0.08); color: rgba(255,255,255,0.9); }
.mesh-relay-mode-option small { color: rgba(255,255,255,0.4); }
.mesh-relay-mode-option input[type="radio"] { accent-color: #fb923c; }
.mesh-relay-result { padding: 10px 14px; border-radius: 8px; font-size: 0.8rem; }
.mesh-relay-result.success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
.mesh-relay-result.error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
.mesh-bitcoin-advanced { margin-top: 4px; }
.mesh-bitcoin-advanced summary { cursor: pointer; color: rgba(255,255,255,0.5); font-size: 0.8rem; }
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
const deadmanConfiguring = ref(false)
const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Sync from store on creation
if (mesh.deadmanStatus) {
deadmanEnabled.value = mesh.deadmanStatus.dead_man_enabled
deadmanInterval.value = String(mesh.deadmanStatus.dead_man_interval_secs)
}
function formatTimeRemaining(secs: number): string {
if (secs >= 86400) return `${Math.floor(secs / 3600)}h`
if (secs >= 3600) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`
if (secs >= 60) return `${Math.floor(secs / 60)}m ${secs % 60}s`
return `${secs}s`
}
async function handleDeadmanToggle() {
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({ enabled: deadmanEnabled.value })
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanConfigure() {
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({
enabled: deadmanEnabled.value,
interval_secs: parseInt(deadmanInterval.value) || 21600,
custom_message: deadmanCustomMsg.value || undefined,
})
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanCheckin() {
await mesh.deadmanCheckin()
await mesh.fetchDeadmanStatus()
}
</script>
<template>
<div class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<button
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
:class="deadmanEnabled
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
</div>
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
</button>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
</template>
<style>
.mesh-deadman-panel { padding: 18px; display: flex; flex-direction: column; gap: 14px; flex: 1; min-height: 0; overflow-y: auto; }
.mesh-deadman-status { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 16px; background: rgba(0,0,0,0.2); border-radius: 10px; }
.mesh-deadman-indicator { font-size: 0.75rem; font-weight: 700; letter-spacing: 1px; padding: 4px 14px; border-radius: 6px; text-transform: uppercase; }
.mesh-deadman-indicator.armed { background: rgba(251,146,60,0.15); color: #fb923c; border: 1px solid rgba(251,146,60,0.3); }
.mesh-deadman-indicator.disabled { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.1); }
.mesh-deadman-indicator.triggered { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); animation: pulse-alert 1.5s infinite; }
@keyframes pulse-alert { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.mesh-deadman-timer { font-size: 1.6rem; font-weight: 700; color: #fb923c; font-family: monospace; }
.mesh-deadman-message { font-size: 0.78rem; color: rgba(255,255,255,0.4); text-align: center; }
.mesh-deadman-checkin-btn { margin-top: 4px; }
.mesh-deadman-config { display: flex; flex-direction: column; gap: 10px; }
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.05); padding: 3px 10px; border-radius: 4px; }
</style>
+525
View File
@@ -0,0 +1,525 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS, RNODE_REGION_PLANS } from '@/utils/loraRegions'
const mesh = useMeshStore()
const rebooting = ref(false)
const rebootError = ref<string | null>(null)
const rebootMessage = ref<string | null>(null)
async function handleReboot() {
rebooting.value = true
rebootError.value = null
rebootMessage.value = null
// Same as apply: the radio goes away on purpose for ~15-20s.
mesh.suppressDeviceDetect()
try {
const res = await mesh.rebootRadio()
// The backend now waits for the device's acknowledgement and says what
// actually happened — show it instead of silently going idle again.
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
} finally {
rebooting.value = false
}
}
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
const rnodeForm = ref({
enabled: true,
port: '',
frequency: '',
bandwidth: '125000',
spreading_factor: '8',
coding_rate: '5',
txpower: '17',
airtime_limit_short: '',
airtime_limit_long: '',
})
const rnodeLive = ref<Record<string, unknown> | null>(null)
const rnodeLiveError = ref<string | null>(null)
const rnodeLoading = ref(false)
const rnodeApplying = ref(false)
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
let rnodeSeeded = false
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
function setRnodeRecommendedForRegion() {
const plan = rnodeRegionPlan.value
if (!plan) return
rnodeForm.value.frequency = String(plan.frequency)
rnodeForm.value.bandwidth = String(plan.bandwidth)
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
rnodeForm.value.coding_rate = String(plan.coding_rate)
rnodeForm.value.txpower = String(plan.txpower)
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
}
async function loadRnodeConfig() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
const s = res.settings as Record<string, unknown>
if (!rnodeSeeded && s) {
rnodeSeeded = true
rnodeForm.value.enabled = s.enabled !== false
rnodeForm.value.port = (s.port as string) ?? ''
rnodeForm.value.frequency = String(s.frequency ?? '')
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
rnodeForm.value.txpower = String(s.txpower ?? '17')
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
}
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
} finally {
rnodeLoading.value = false
}
}
async function applyRnodeSettings() {
rnodeApplying.value = true
rnodeResult.value = null
// Applying deliberately restarts the radio daemon; without this the
// "new device detected" modal interrupts the flow mid-apply.
mesh.suppressDeviceDetect()
try {
const res = await mesh.applyRnodeConfig({
enabled: rnodeForm.value.enabled,
port: rnodeForm.value.port.trim() || null,
frequency: Number(rnodeForm.value.frequency),
bandwidth: Number(rnodeForm.value.bandwidth),
spreading_factor: Number(rnodeForm.value.spreading_factor),
coding_rate: Number(rnodeForm.value.coding_rate),
txpower: Number(rnodeForm.value.txpower),
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
})
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
if (res.live) rnodeLive.value = res.live
} catch (e) {
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
} finally {
rnodeApplying.value = false
}
}
async function refreshRnodeLive() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
} finally {
rnodeLoading.value = false
}
}
function fmtMhz(v: unknown): string {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
}
// ── Editable settings (persisted via mesh.configure) ──
const form = ref({
region: '',
deviceKind: 'auto',
channel: 'archipelago',
name: '',
broadcastIdentity: true,
// MeshCore LoRa PHY params (human units; converted to firmware units on
// save). All four empty = leave the radio's flashed settings untouched.
rfFreqMhz: '',
rfBwKhz: '',
rfSf: '',
rfCr: '',
})
const saving = ref(false)
const saveError = ref<string | null>(null)
const saveDone = ref(false)
let seeded = false
// Seed the form once from status (don't clobber in-progress edits on poll)
watch(
() => mesh.status,
(s) => {
if (!s || seeded) return
seeded = true
form.value.region = s.lora_region ?? ''
form.value.deviceKind = s.device_kind ?? 'auto'
form.value.channel = s.channel_name || 'archipelago'
form.value.name = s.self_advert_name ?? ''
const rp = (s as Record<string, unknown>).lora_radio_params as
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
| null
| undefined
if (rp) {
applyingPreset = true
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
form.value.rfBwKhz = String(rp.bw_hz / 1000)
form.value.rfSf = String(rp.sf)
form.value.rfCr = String(rp.cr)
// Show the matching named preset if the stored values are one; else Custom.
const match = MESHCORE_RF_PRESETS.find(
(p) =>
Math.round(p.freqMhz * 1000) === rp.freq_khz &&
Math.round(p.bwKhz * 1000) === rp.bw_hz &&
p.sf === rp.sf &&
p.cr === rp.cr,
)
rfPreset.value = match ? match.id : 'custom'
applyingPreset = false
}
},
{ immediate: true },
)
// RF preset dropdown: picking a named plan fills the four fields; hand-editing
// any field flips the dropdown to Custom so it never misrepresents the values.
const rfPreset = ref('')
let applyingPreset = false
function selectRfPreset(id: string) {
rfPreset.value = id
if (id === 'custom' || id === '') return
const p = MESHCORE_RF_PRESETS.find((x) => x.id === id)
if (!p) return
applyingPreset = true
form.value.rfFreqMhz = String(p.freqMhz)
form.value.rfBwKhz = String(p.bwKhz)
form.value.rfSf = String(p.sf)
form.value.rfCr = String(p.cr)
applyingPreset = false
}
watch(
() => [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr],
() => {
if (!applyingPreset && rfPreset.value && rfPreset.value !== 'custom') rfPreset.value = 'custom'
},
)
const selectedRegion = computed(() => regionByCode(form.value.region))
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
// Firmware whose options apply: explicit pin wins, else the connected type.
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
const t = deviceType.value.toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
async function saveSettings() {
saving.value = true
saveError.value = null
saveDone.value = false
try {
// MeshCore RF params: a named preset sends its plan; Custom sends the
// four fields (all required — partial input is an error); "Keep the
// radio's current settings" omits the key so the config is untouched.
let rfParams: { freq_khz: number; bw_hz: number; sf: number; cr: number } | undefined
if (rfPreset.value && rfPreset.value !== 'custom') {
const p = MESHCORE_RF_PRESETS.find((x) => x.id === rfPreset.value)
if (p) {
rfParams = {
freq_khz: Math.round(p.freqMhz * 1000),
bw_hz: Math.round(p.bwKhz * 1000),
sf: p.sf,
cr: p.cr,
}
}
} else if (rfPreset.value === 'custom') {
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
if (!rf.every((v) => String(v).trim() !== '')) {
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR)')
}
rfParams = {
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
sf: parseInt(form.value.rfSf, 10),
cr: parseInt(form.value.rfCr, 10),
}
}
await mesh.configure({
lora_region: form.value.region,
device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago',
// Always sent: an empty string CLEARS the custom mesh name (backend
// maps "" -> None -> fall back to the server name). The old omit-when-
// empty made clearing impossible once a name was ever set.
advert_name: form.value.name.trim(),
broadcast_identity: form.value.broadcastIdentity,
...(rfParams ? { lora_radio_params: rfParams } : {}),
})
saveDone.value = true
setTimeout(() => { saveDone.value = false }, 5000)
} catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
} finally {
saving.value = false
}
}
// Load the RNode settings + live state as soon as the panel knows a
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
// `immediate: true` the source getter runs at setup, and `effectiveKind`
// must already exist (the SendBitcoinModal TDZ-crash lesson).
watch(
() => effectiveKind.value,
(kind) => {
if (kind === 'reticulum') void loadRnodeConfig()
},
{ immediate: true },
)
</script>
<template>
<div class="glass-card mesh-device-panel">
<h3 class="mesh-panel-title">Device</h3>
<p class="mesh-panel-sub">Firmware, identity, and radio controls for the connected mesh device</p>
<div v-if="mesh.status" class="mesh-device-panel-grid">
<div class="mesh-stat">
<span class="mesh-stat-label">Firmware</span>
<span class="mesh-stat-value">{{ mesh.status.firmware_version ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Node ID</span>
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Type</span>
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
</div>
</div>
<!-- Radio settings -->
<div class="mt-5 pt-4 border-t border-white/10">
<h4 class="text-sm font-semibold text-white mb-3">Radio Settings</h4>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-white/60 mb-1">LoRa region / frequency plan</label>
<select v-model="form.region" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Keep the radio's current region</option>
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
{{ selectedRegion.code }}: {{ selectedRegion.dutyCyclePct }}% duty-cycle limit, {{ selectedRegion.band }} MHz, max {{ selectedRegion.maxPowerDbm }} dBm
</p>
<p v-if="effectiveKind === 'meshtastic' || effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-1">
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
</p>
<p v-else-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} — set the RF fields below to program the radio.
</p>
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
Program the radio's RF settings with the fields below every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
Pick your region, then use "Set recommended for region" in the RNode section below.
</p>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Radio firmware</label>
<select v-model="form.deviceKind" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="auto">Auto-detect (Meshcore Meshtastic RNode)</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum / RNode</option>
</select>
<p class="text-[11px] text-white/40 mt-1">
Pin the flashed firmware so no other protocol's probe bytes touch the port.
</p>
</div>
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-xs text-white/60 mb-1">Channel</label>
<input v-model="form.channel" maxlength="11" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="node name" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<!-- MeshCore LoRa PHY params — every radio on the local mesh must match
or it hears RF energy but decodes nothing. Empty = leave the radio's
flashed settings untouched. -->
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
<div class="mb-3">
<label class="block text-xs text-white/60 mb-1">Frequency plan</label>
<select :value="rfPreset" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" @change="selectRfPreset(($event.target as HTMLSelectElement).value)">
<option value="">Keep the radio's current settings</option>
<option v-for="p in MESHCORE_RF_PRESETS" :key="p.id" :value="p.id">{{ p.label }}</option>
<option value="custom">Custom…</option>
</select>
</div>
<div v-if="rfPreset === 'custom'" class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (kHz)</label>
<select v-model="form.rfBwKhz" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">—</option>
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="form.rfSf" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">—</option>
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="form.rfCr" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">—</option>
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</div>
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
first (radio-confirmed read-back), then every parameter editable,
with apply → device confirmation. Actions stack in a column. -->
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
{{ rnodeLoading ? 'Reading' : 'Refresh' }}
</button>
</div>
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '' }}</span></div>
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '' }} Hz</span></div>
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '' }}</span></div>
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '' }}</span></div>
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '' }} dBm</span></div>
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '' }}%</span></div>
</div>
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
<select v-model="rnodeForm.bandwidth" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="rnodeForm.spreading_factor" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="rnodeForm.coding_rate" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Serial port</label>
<input v-model="rnodeForm.port" placeholder="auto-detect" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
RNode interface enabled
</label>
<!-- Actions: stacked in a column on purpose (operator layout request) -->
<div class="flex flex-col gap-2 mt-4 max-w-sm">
<button
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!rnodeRegionPlan || rnodeApplying"
@click="setRnodeRecommendedForRegion"
>
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
</button>
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="rnodeApplying"
@click="applyRnodeSettings"
>
{{ rnodeApplying ? 'Applying waiting for the radio to confirm' : 'Apply & Confirm on Device' }}
</button>
</div>
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
</label>
<p v-if="effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-3">
Options adapt to the detected firmware: region + channel program Meshtastic radios;
MeshCore and RNode own their RF parameters in firmware/daemon config; name and identity apply to all.
</p>
<div class="flex items-center gap-3 mt-4">
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="saveDone" class="text-xs text-green-400">Saved applying to the radio now</span>
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
</div>
</div>
<div class="mesh-device-panel-actions">
<button
class="glass-button mesh-device-reboot-btn"
:disabled="rebooting || !mesh.status?.device_connected"
@click="handleReboot"
>
<span v-if="rebooting" class="mesh-spinner" aria-hidden="true"></span>
<template v-else>Reboot Radio</template>
</button>
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div>
</div>
</template>
+615
View File
@@ -0,0 +1,615 @@
/* Mesh view styles — extracted from Mesh.vue
* Unscoped — mesh-* classes must reach child components (MeshBitcoinPanel, MeshDeadmanPanel)
*/
.mesh-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
@media (min-width: 921px) {
.mesh-dashboard-panel.mobile-scroll-pad {
padding-bottom: 0;
}
}
@media (min-width: 921px) and (max-width: 1279px) {
.mesh-dashboard-panel {
overflow-y: auto;
}
}
.mesh-header { justify-content: space-between; align-items: center; gap: 16px; flex-shrink: 0; }
.mesh-header-left { flex: 1; }
.mesh-title-row { display: flex; align-items: center; gap: 8px; }
.mesh-title { font-size: 1.5rem; font-weight: 700; color: rgba(255, 255, 255, 0.95); margin: 0; }
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; }
.mesh-tools-wrapper { display: contents; }
.mesh-tools-tab-bar { display: none; }
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
.mesh-columns-wide .mesh-left { grid-column: 1; width: auto; }
.mesh-columns-wide .mesh-right { display: contents; }
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
/* A very wide screen gets a roomier third column — but the SAME tabbed
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
AI, the map and Device on top of each other in fixed grid rows, which on
a real 2560px display clipped the first three headings to a few pixels,
letterboxed the map, and pushed Radio Settings into a scroll — more
screen producing a worse view (reported with a screenshot 2026-08-05).
One tab at a time, filling the column, is what makes the map edge to
edge and every control reachable without scrolling. */
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
}
/* The map is the one panel with nothing to scroll: let it consume the
column edge to edge rather than sitting in a letterbox. */
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
.mesh-status-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; cursor: pointer; }
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
.mesh-status-indicator { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
/* Collapse chevron — the Device panel collapses/expands on every breakpoint. */
.mesh-status-chevron { display: block; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-stat-value { font-size: 0.8rem; color: rgba(255, 255, 255, 0.85); font-weight: 500; }
.text-green { color: #4ade80; }
.text-orange { color: #fb923c; }
.text-muted { color: rgba(255, 255, 255, 0.4); }
.mesh-loading, .mesh-empty { color: rgba(255, 255, 255, 0.4); font-size: 0.85rem; text-align: center; padding: 16px 0; }
.mesh-detected-devices { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.06); }
.mesh-device-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255, 255, 255, 0.04); border-radius: 6px; }
.mesh-device-indicator { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); flex-shrink: 0; }
.mesh-device-path { font-family: monospace; font-size: 0.8rem; color: rgba(255, 255, 255, 0.7); flex: 1; }
.mesh-connect-btn { padding: 3px 12px; font-size: 0.75rem; flex-shrink: 0; }
.mesh-offgrid-banner { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.3); border-radius: 8px; flex-shrink: 0; }
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
.mesh-refresh-spinner {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 9999px;
border: 2px solid rgba(251, 146, 60, 0.7);
border-top-color: transparent;
animation: mesh-refresh-spin 0.8s linear infinite;
vertical-align: -1px;
}
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 36px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; touch-action: manipulation; }
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
.mesh-peer-reach.is-reachable { background: #34d399; }
.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); }
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
.mesh-peer-channel-badge { font-size: 0.6rem; font-weight: 700; color: #3b82f6; background: rgba(59, 130, 246, 0.12); padding: 1px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-peer-count { font-size: 0.75rem; font-weight: 600; color: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.08); padding: 2px 8px; border-radius: 10px; margin-left: 6px; vertical-align: middle; }
.mesh-peer-row.is-channel { border-bottom: 1px solid rgba(255, 255, 255, 0.04); padding-bottom: 12px; margin-bottom: 4px; }
.mesh-peer-info { flex: 1; min-width: 0; }
.mesh-peer-name { font-weight: 600; font-size: 0.85rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
.mesh-peer-archy-badge { font-size: 0.6rem; font-weight: 700; color: #fb923c; background: rgba(251, 146, 60, 0.12); padding: 1px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-peer-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-peer-signal { flex-shrink: 0; }
.mesh-signal-bars { display: flex; align-items: flex-end; gap: 2px; height: 14px; }
.mesh-signal-bar { width: 3px; border-radius: 1px; background: rgba(255, 255, 255, 0.12); }
.mesh-signal-bar:nth-child(1) { height: 3px; }
.mesh-signal-bar:nth-child(2) { height: 6px; }
.mesh-signal-bar:nth-child(3) { height: 10px; }
.mesh-signal-bar:nth-child(4) { height: 14px; }
.mesh-signal-bar.active { background: #4ade80; }
.mesh-unread-badge { background: #fb923c; color: #000; font-size: 0.65rem; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 5px; flex-shrink: 0; }
.mesh-chat-card { padding: 0; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.mesh-chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.14); gap: 8px; padding: 40px; }
.mesh-chat-empty-icon { font-size: 3rem; opacity: 0.18; }
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.1); }
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
/* Floating mobile back button (Teleported to body). Hidden by default; only
shown in the single-column mobile mesh layout (see media query below). */
.mesh-chat-mobile-back { display: none; }
.mesh-chat-header-info { flex: 1; min-width: 0; }
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
.mesh-chat-header-rename:hover { color: rgba(255, 255, 255, 0.9); }
.mesh-chat-header-rename-input { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 6px; color: rgba(255, 255, 255, 0.95); font-size: 0.95rem; font-weight: 600; padding: 4px 8px; outline: none; min-width: 0; max-width: 220px; }
.mesh-chat-header-rename-input:focus { border-color: rgba(255, 255, 255, 0.4); }
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
.mesh-chat-header-status { flex-shrink: 0; }
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
.mesh-chat-messages { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.mesh-chat-no-messages { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.25); font-size: 0.85rem; }
.mesh-chat-bubble-wrapper { display: flex; }
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
.mesh-chat-bubble-wrapper.received { justify-content: flex-start; }
.mesh-chat-bubble { max-width: 75%; padding: 10px 14px; border-radius: 16px; word-break: break-word; }
.mesh-chat-bubble.sent { background: rgba(251, 146, 60, 0.15); border: 1px solid rgba(251, 146, 60, 0.2); border-bottom-right-radius: 4px; }
.mesh-chat-bubble.received { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); border-bottom-left-radius: 4px; }
.mesh-chat-bubble-sender { font-size: 0.7rem; font-weight: 600; color: rgba(251, 146, 60, 0.85); margin-bottom: 3px; }
.mesh-chat-bubble-text { color: rgba(255, 255, 255, 0.9); font-size: 0.9rem; line-height: 1.4; }
.mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; }
.mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); }
.mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; }
/* Per-message transport pill (Meshtastic / Meshcore / Reticulum / FIPS / Tor), styled like the E2E badge. */
.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; }
.mesh-chat-transport.transport-meshtastic { color: #3eb489; } /* Meshtastic — mint */
.mesh-chat-transport.transport-meshcore { color: #fb923c; } /* Meshcore — orange */
.mesh-chat-transport.transport-reticulum { color: #60a5fa; } /* Reticulum — blue */
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* legacy generic Mesh/LoRa (pre-split) — amber */
.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */
.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */
.mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; }
.mesh-chat-compose { padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
.mesh-chat-send-error { color: #ef4444; font-size: 0.75rem; margin-bottom: 6px; }
.mesh-chat-compose-row { display: flex; gap: 8px; }
.mesh-chat-input { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 20px; color: rgba(255, 255, 255, 0.9); padding: 10px 16px; font-size: 0.9rem; font-family: inherit; outline: none; }
.mesh-chat-input:focus { border-color: rgba(251, 146, 60, 0.4); }
.mesh-chat-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.mesh-chat-send-btn { padding: 10px 20px; border-radius: 20px; font-size: 0.85rem; background: rgba(251, 146, 60, 0.15); border-color: rgba(251, 146, 60, 0.25); min-width: 72px; display: inline-flex; align-items: center; justify-content: center; }
.mesh-chat-send-btn:hover:not(:disabled) { background: rgba(251, 146, 60, 0.25); }
.mesh-send-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgba(251, 146, 60, 0.9); border-radius: 50%; animation: mesh-send-spin 0.7s linear infinite; }
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
.mesh-mobile-back-btn { display: none; }
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
≤1279px block flips it to flex and the placement mirrors the mobile back
button (pinned above the global tab bar + audio player). */
.mesh-mobile-tabbar {
display: none;
position: fixed;
left: 12px;
right: 12px;
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
z-index: 40;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.mesh-mtab {
flex: 1 1 0;
min-width: 0;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
@media (max-width: 1279px) {
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
.mesh-columns { flex-direction: column; overflow: visible; }
.mesh-left { width: 100%; overflow: visible; }
.mesh-right { min-height: auto; overflow: visible; }
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
/* ── Single-column mobile mesh: one fixed, internally-scrolling pane that
fills the space between the top tab strip and the floating mesh tab bar.
The page itself never scrolls; each pane scrolls inside its own bounds.
Fixed positioning is relative to the full-height perspective container, so
the offsets line up with the body-teleported tab bar / back button. ──── */
.mesh-left,
.mesh-mobile-tools,
.mesh-chat-card.mesh-chat-card-active {
position: fixed;
left: 12px;
right: 12px;
/* width:auto so left+right govern the box — .mesh-left otherwise carries a
fixed 380px width that ignores `right` and overflows the screen. */
width: auto;
box-sizing: border-box;
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
height: auto;
min-height: 0;
max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
z-index: 30;
}
/* Active conversation: the floating tabs are hidden here and the back button
takes the standard spot above the tab bar, so the chat window fills down to
just above the back pill (back pill ≈44px at +8, plus a 16px gap). When the
keyboard is up it covers the tab bar, so anchor to whichever is taller — the
bottom controls or the keyboard — so the window sits right above both. */
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
overflow: hidden; /* the messages list inside does the scrolling */
}
.mesh-tools-wrapper { display: none !important; }
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
bottom margin); the panel itself scrolls if its content is taller. */
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
.mesh-mobile-tools .mesh-bitcoin-panel,
.mesh-mobile-tools .mesh-assistant-panel,
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
/* In a conversation the tabs are hidden, so the back pill sits just above the
tab bar — or above the keyboard when it's up, whichever is taller. */
.mesh-chat-mobile-back { display: flex; }
.mesh-chat-mobile-back.mobile-back-btn {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
}
/* Floating mesh tab strip — same placement logic as the mobile back button. */
.mesh-mobile-tabbar { display: flex; }
.mobile-hidden { display: none !important; }
:deep(.mesh-bitcoin-panel),
:deep(.mesh-assistant-panel),
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
.mesh-mobile-back-btn:hover { color: rgba(255, 255, 255, 0.9); }
}
@media (min-width: 921px) and (max-width: 1279px) {
.mesh-view {
padding: 24px;
}
/* In this range the desktop sidebar (256px) is still shown. The in-pane
fixed elements are positioned relative to the main content area (their
perspective containing block), so they already clear the sidebar — but the
body-teleported floating bars are viewport-relative, so nudge them right. */
.mesh-mobile-tabbar,
.mesh-chat-mobile-back.mobile-back-btn {
left: 268px;
}
/* The ≤1279px bottom offsets above reserve `--mobile-tab-bar-height` for the
OS-style bottom nav bar — but that bar only ever renders below 768px
(DashboardMobileNav's `md:hidden`); here the desktop sidebar is the primary
nav instead, so the var is always unset and its 72px fallback becomes a
phantom reservation with nothing under it, leaving a huge dead gap below
the panel. Drop that term so bottom clearance matches the top/left/right
margins, keeping only the space actually needed for the in-page floating
tab bar / back button. */
.mesh-left,
.mesh-mobile-tools,
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(var(--audio-player-height, 0px) + 72px);
}
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
}
.mesh-mobile-tabbar {
bottom: calc(var(--audio-player-height, 0px) + 12px);
}
.mesh-chat-mobile-back.mobile-back-btn {
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
}
}
@media (max-width: 920px) {
.mesh-view {
padding-left: 0;
padding-right: 0;
}
}
.mesh-session-badge { font-size: 0.75rem; margin-right: 6px; opacity: 0.7; }
.mesh-session-rotate { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); font-size: 0.75rem; line-height: 1; padding: 2px 6px; margin-right: 8px; border-radius: 10px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.mesh-session-rotate:hover:not(:disabled) { background: rgba(251,146,60,0.2); color: #fff; border-color: rgba(251,146,60,0.4); }
.mesh-session-rotate:disabled { opacity: 0.5; cursor: wait; }
.mesh-outbox-badge { font-size: 0.7rem; padding: 2px 7px; margin-right: 8px; border-radius: 10px; background: rgba(251,146,60,0.2); border: 1px solid rgba(251,146,60,0.4); color: #fff; }
.session-ratchet { color: #4ade80; opacity: 1; }
.session-static { color: #fbbf24; }
.session-none { color: rgba(255,255,255,0.3); }
.mesh-typed-icon { margin-right: 4px; }
.mesh-typed-label { font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.typed-invoice { border-left: 3px solid #fb923c; }
.mesh-typed-invoice { padding: 4px 0; }
.mesh-typed-invoice-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #fb923c; font-size: 0.75rem; }
.mesh-typed-invoice-amount { font-size: 1.1rem; font-weight: 700; color: #fb923c; }
.mesh-typed-invoice-memo { font-size: 0.8rem; color: rgba(255,255,255,0.7); margin-top: 2px; }
.mesh-typed-invoice-bolt11 { font-size: 0.65rem; color: rgba(255,255,255,0.3); font-family: monospace; margin-top: 4px; word-break: break-all; }
.mesh-typed-paid { background: rgba(74,222,128,0.2); color: #4ade80; font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; margin-left: auto; }
.typed-alert { border-left: 3px solid #ef4444; }
.typed-alert.alert-status { border-left-color: #3b82f6; }
.mesh-typed-alert { padding: 4px 0; }
.mesh-typed-alert-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 0.75rem; }
.alert-emergency .mesh-typed-alert-header { color: #ef4444; }
.alert-dead_man .mesh-typed-alert-header { color: #ef4444; }
.alert-status .mesh-typed-alert-header { color: #3b82f6; }
.mesh-typed-alert-message { font-size: 0.85rem; color: rgba(255,255,255,0.9); }
.mesh-typed-alert-location { display: block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
.mesh-typed-signed { font-size: 0.6rem; color: #4ade80; border: 1px solid rgba(74,222,128,0.3); padding: 0 4px; border-radius: 3px; margin-left: auto; }
.typed-coordinate { border-left: 3px solid #3b82f6; }
.mesh-typed-coordinate { padding: 4px 0; }
.mesh-typed-coordinate-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #3b82f6; font-size: 0.75rem; }
.mesh-typed-coordinate-value { font-size: 0.9rem; font-family: monospace; color: rgba(255,255,255,0.8); }
.mesh-typed-coordinate-label { font-size: 0.8rem; color: rgba(255,255,255,0.6); margin-top: 2px; }
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
.typed-block_header { border-left: 3px solid #a855f7; }
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; cursor: pointer; }
.mesh-typed-content-thumb { opacity: 0.85; filter: blur(0.5px); margin-bottom: 6px; }
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
.mesh-typed-content-download-btn {
position: absolute; bottom: 8px; right: 8px;
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
display: flex; align-items: center; justify-content: center; cursor: pointer;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
transition: background 0.15s ease, transform 0.15s ease;
}
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
replaced collapsed to its text width inside the narrow mobile bubble and
looked squashed — this is a full-width glass pill in the house style. */
.mesh-typed-content-fetch-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
transition: background 0.15s ease, border-color 0.15s ease;
}
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
}
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.mesh-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
.mesh-tab-badge { font-size: 0.65rem; background: rgba(251,146,60,0.2); color: #fb923c; padding: 1px 5px; border-radius: 4px; font-weight: 600; }
.mesh-tab-badge-alert { background: rgba(239,68,68,0.3); color: #ef4444; animation: pulse-alert 1.5s infinite; }
@keyframes pulse-alert { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.mesh-map-panel { flex: 1; min-height: 400px; padding: 0 !important; overflow: hidden; border-radius: 12px; position: relative; }
/* Bitcoin & Deadman panels (child components) */
.mesh-bitcoin-panel,
.mesh-deadman-panel,
.mesh-assistant-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
.mesh-assistant-allowlist { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 6px; background: rgba(0,0,0,0.2); }
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; }
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
.mesh-device-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; }
.mesh-device-panel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.mesh-device-panel-actions { display: flex; flex-direction: column; gap: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }
.mesh-device-reboot-btn { align-self: flex-start; padding: 8px 16px; font-size: 0.85rem; }
.mesh-device-reboot-hint { font-size: 0.75rem; color: rgba(255,255,255,0.4); margin: 0; }
.mesh-device-reboot-error { font-size: 0.8rem; color: #ef4444; margin: 0; }
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
.mesh-bitcoin-label { font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-bitcoin-height { font-size: 0.85rem; font-weight: 700; color: #fb923c; font-family: monospace; }
.mesh-bitcoin-height.mesh-muted { color: rgba(255,255,255,0.3); font-weight: 400; }
.mesh-bitcoin-hint { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: 0; }
.mesh-bitcoin-input { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: rgba(255,255,255,0.9); padding: 10px 12px; font-size: 0.85rem; font-family: inherit; outline: none; box-sizing: border-box; }
.mesh-bitcoin-input:focus { border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.25); }
.mesh-bitcoin-input-sm { padding: 8px 12px; font-size: 0.8rem; }
textarea.mesh-bitcoin-input { resize: vertical; min-height: 60px; }
select.mesh-bitcoin-input { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='rgba(255,255,255,0.4)' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,0.9); }
.mesh-bitcoin-advanced { margin-top: 4px; }
.mesh-bitcoin-advanced summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; }
.mesh-bitcoin-advanced summary::before { content: '\25B6'; font-size: 0.6rem; color: rgba(255,255,255,0.4); transition: transform 0.2s; }
.mesh-bitcoin-advanced[open] summary::before { transform: rotate(90deg); }
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
.mesh-block-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 6px; }
.mesh-block-height { font-size: 0.8rem; font-weight: 600; color: #a855f7; font-family: monospace; }
.mesh-block-hash { font-size: 0.7rem; color: rgba(255,255,255,0.35); font-family: monospace; }
.mesh-send-tabs { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 2px; }
.mesh-send-tab { flex: 1; padding: 6px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.8rem; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.mesh-send-tab:hover { color: rgba(255,255,255,0.8); }
.mesh-send-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
.mesh-relay-mode { display: flex; gap: 4px; flex-wrap: wrap; }
.mesh-relay-mode-option { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; color: rgba(255,255,255,0.6); transition: all 0.15s; }
.mesh-relay-mode-option.active { color: rgba(255,255,255,0.9); }
.mesh-relay-mode-option small { color: rgba(255,255,255,0.35); font-size: 0.7rem; }
.mesh-relay-mode-option input[type="radio"] { accent-color: #fb923c; }
.mesh-relay-result { padding: 8px 12px; border-radius: 8px; font-size: 0.8rem; }
.mesh-relay-result.success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
.mesh-relay-result.error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
/* Deadman panel specifics */
.mesh-deadman-status { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: rgba(0,0,0,0.2); border-radius: 10px; }
.mesh-deadman-indicator { display: inline-flex; align-items: center; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 4px 10px; border-radius: 6px; width: fit-content; }
.mesh-deadman-indicator.armed { background: rgba(251,146,60,0.15); color: #fb923c; border: 1px solid rgba(251,146,60,0.3); }
.mesh-deadman-indicator.disabled { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.08); }
.mesh-deadman-indicator.triggered { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); animation: pulse-alert 1.5s infinite; }
.mesh-deadman-timer { font-size: 1.8rem; font-weight: 700; color: #fb923c; font-family: monospace; }
.mesh-deadman-message { font-size: 0.8rem; color: rgba(255,255,255,0.5); font-style: italic; }
.mesh-deadman-checkin-btn { margin-top: 4px; }
.mesh-deadman-config { display: flex; flex-direction: column; gap: 10px; }
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
/* Reaction chips and action menu (Phase 2a) */
.mesh-chat-reactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.mesh-chat-reaction-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); font-size: 0.85rem; line-height: 1.1; }
.mesh-chat-reaction-chip.by-self { background: rgba(251,146,60,0.15); border-color: rgba(251,146,60,0.4); }
.mesh-chat-reaction-count { font-size: 0.7rem; color: rgba(255,255,255,0.55); font-weight: 600; }
.mesh-chat-action-menu { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 8px; padding: 8px 10px; border-radius: 10px; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.1); }
.mesh-chat-action-btn { background: transparent; border: none; color: rgba(255,255,255,0.75); font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.mesh-chat-action-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
.mesh-chat-reaction-btn { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 1.15rem; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; transition: transform 0.1s ease, background 0.15s ease; }
.mesh-chat-reaction-btn:hover { background: rgba(251,146,60,0.2); transform: scale(1.1); }
.mesh-chat-action-danger { color: rgba(248, 113, 113, 0.9) !important; }
.mesh-chat-action-danger:hover { background: rgba(239,68,68,0.2) !important; color: #fff !important; }
.mesh-chat-forward-header { font-size: 0.75rem; color: rgba(251,146,60,0.85); font-style: italic; margin-bottom: 3px; }
.mesh-chat-forward-body { }
.mesh-chat-deleted { font-style: italic; opacity: 0.55; }
.mesh-chat-edited { font-size: 0.7rem; opacity: 0.55; font-style: italic; }
/* Telegram-style ⋯ action trigger: tiny, ghosted in the meta row, expands on hover or when menu is open */
.mesh-chat-action-trigger {
background: transparent;
border: none;
color: rgba(255,255,255,0.45);
font-size: 1rem;
line-height: 1;
padding: 2px 6px;
margin-left: 2px;
border-radius: 10px;
cursor: pointer;
opacity: 0;
transform: scale(0.85);
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.mesh-chat-bubble:hover .mesh-chat-action-trigger,
.mesh-chat-bubble.menu-open .mesh-chat-action-trigger,
.mesh-chat-action-trigger.active,
.mesh-chat-action-trigger:focus-visible {
opacity: 1;
transform: scale(1);
}
.mesh-chat-action-trigger:hover,
.mesh-chat-action-trigger.active {
background: rgba(255,255,255,0.1);
color: #fff;
}
@media (hover: none) {
.mesh-chat-action-trigger { opacity: 0.7; transform: scale(1); }
}
/* Generic inline spinner for busy buttons */
.mesh-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fb923c; border-radius: 50%; animation: mesh-spin 0.7s linear infinite; vertical-align: middle; }
@keyframes mesh-spin { to { transform: rotate(360deg); } }
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
.mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; }
@keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } }
/* "+" attach menu — replaces individually-visible attach/record buttons
(was overflowing the compose row) with one toggle + an animated stack. */
.mesh-attach-menu-anchor { position: relative; flex-shrink: 0; }
.mesh-chat-plus-btn { font-size: 1.3rem; line-height: 1; font-weight: 300; transition: transform 0.2s ease; }
.mesh-chat-plus-btn.is-open { transform: rotate(45deg); background: rgba(255,255,255,0.14); }
.mesh-attach-stack {
position: absolute; bottom: calc(100% + 8px); left: 0;
display: flex; flex-direction: column; gap: 8px;
}
.mesh-attach-stack-enter-active, .mesh-attach-stack-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.mesh-attach-stack-enter-from, .mesh-attach-stack-leave-to {
opacity: 0; transform: translateY(8px) scale(0.9);
}
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }
/* Reply / attachment pending banner */
.mesh-chat-pending-reply,
.mesh-chat-pending-attachment { display: flex; align-items: flex-start; gap: 8px; padding: 8px 12px; margin: 6px 0; border-radius: 10px; background: rgba(251,146,60,0.1); border: 1px solid rgba(251,146,60,0.25); font-size: 0.85rem; }
.mesh-chat-pending-reply .mesh-typed-icon,
.mesh-chat-pending-attachment .mesh-typed-icon { color: #fb923c; font-size: 1rem; line-height: 1.4; flex: 0 0 auto; }
.mesh-chat-pending-name { flex: 1 1 auto; min-width: 0; color: rgba(255,255,255,0.85); overflow-wrap: anywhere; word-break: break-word; line-height: 1.35; }
.mesh-chat-pending-size { flex: 0 0 auto; color: rgba(255,255,255,0.45); font-size: 0.75rem; margin-left: 4px; }
.mesh-chat-pending-clear { flex: 0 0 auto; align-self: center; margin-left: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.85); width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.95rem; line-height: 1; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
.mesh-chat-pending-clear:hover { background: rgba(239,68,68,0.3); color: #fff; border-color: rgba(239,68,68,0.6); transform: scale(1.08); }
.mesh-chat-pending-clear:active { transform: scale(0.92); }
/* Transport chooser modal (attachment size router) */
.mesh-transport-modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.mesh-transport-modal { max-width: 420px; width: 92%; padding: 24px; display: flex; flex-direction: column; gap: 14px; }
.mesh-transport-title { margin: 0; font-size: 1.1rem; color: #fff; }
.mesh-transport-sub { margin: 0; color: rgba(255,255,255,0.6); font-size: 0.85rem; overflow-wrap: anywhere; }
.mesh-transport-options { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
.mesh-transport-option { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); color: #fff; cursor: pointer; text-align: left; transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
.mesh-transport-option:hover:not(:disabled) { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
.mesh-transport-option:disabled { opacity: 0.4; cursor: not-allowed; }
.mesh-transport-icon { font-size: 1.5rem; flex: 0 0 auto; }
.mesh-transport-label { flex: 1 1 auto; font-weight: 600; }
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
.mesh-transport-cancel:hover { color: #fff; }
/* Transport pills at the bottom of the image quality modal — pick LoRa vs
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.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; }
/* 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); }
/* Per-message "more" button — opens the route/hops modal (same target as the
transport pill, but always visible and discoverable). */
.mesh-chat-more-btn { background: none; border: none; color: rgba(255,255,255,0.45); font-size: 0.9rem; line-height: 1; padding: 0 4px; cursor: pointer; border-radius: 6px; }
.mesh-chat-more-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
/* Reaction dropdown inside the message action menu */
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }