feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Demo images / Build & push demo images (push) Successful in 3m18s

Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is
vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than
hotlinked on purpose — these nodes run offline/airgapped, and a remote image
would both break there and leak a request to a third-party host on every
render. A missing asset falls back to a neutral bolt glyph so a row can never
render a broken-image box.

Mobile: the choice row keeps icon + name + blurb together and drops the action
to its own full-width line under 26rem, instead of squeezing the description
into a two-word column next to a button.

Funding mode: a node that is running but has no funds / no inbound liquidity
is neither "install one" nor "start it", so the same modal gains a third mode
that explains it and routes to the run-lightning-node goal, where funding and
channel-opening already live — reusing that flow rather than duplicating it.

It fires where the user actually meets the problem: on a failed attempt.
handleLightningFailure() maps a running node's send/receive failure onto the
funding modal, matched on message text because LND surfaces "no route", "no
channels" and "insufficient balance" as plain strings with no distinct code —
and all three mean the same thing to a user: fund me.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 07:43:24 -04:00
co-authored by Claude Opus 5
parent fa26c5fc56
commit 700947ea3c
5 changed files with 199 additions and 9 deletions
@@ -0,0 +1,56 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_670_3179)">
<path d="M255.871 0.125H0.128906V255.868H255.871V0.125Z" fill="url(#paint0_linear_670_3179)"/>
<g opacity="0.2" filter="url(#filter0_f_670_3179)">
<circle cx="128" cy="-68.0615" r="82.7188" fill="#F0D003"/>
</g>
<g opacity="0.2" filter="url(#filter1_f_670_3179)">
<circle cx="128" cy="323.065" r="82.7188" fill="black"/>
</g>
<g filter="url(#filter2_d_670_3179)">
<path d="M158.533 37L88.7227 130.934L168.984 110.063L120.9 110.096L158.533 37Z" fill="#F0D003"/>
</g>
<g filter="url(#filter3_d_670_3179)">
<path d="M98.4517 218.004L168.228 124.07L88 144.941L136.084 144.908L98.4517 218.004Z" fill="#FFFAE6"/>
</g>
</g>
<defs>
<filter id="filter0_f_670_3179" x="-52.7188" y="-248.78" width="361.438" height="361.438" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="49" result="effect1_foregroundBlur_670_3179"/>
</filter>
<filter id="filter1_f_670_3179" x="-52.7188" y="142.347" width="361.438" height="361.438" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="49" result="effect1_foregroundBlur_670_3179"/>
</filter>
<filter id="filter2_d_670_3179" x="69.7227" y="20" width="112.262" height="125.934" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-3" dy="-1"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.941176 0 0 0 0 0.815686 0 0 0 0 0.0117647 0 0 0 0.16 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_670_3179"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_670_3179" result="shape"/>
</filter>
<filter id="filter3_d_670_3179" x="71" y="111.07" width="114.229" height="127.934" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.980392 0 0 0 0 0.901961 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_670_3179"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_670_3179" result="shape"/>
</filter>
<linearGradient id="paint0_linear_670_3179" x1="128" y1="0.125" x2="128" y2="255.868" gradientUnits="userSpaceOnUse">
<stop stop-color="#161616"/>
<stop offset="1"/>
</linearGradient>
<clipPath id="clip0_670_3179">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -5,12 +5,17 @@
same reasoning as ExternalExplorerModal. -->
<BaseModal
:show="lightning.show.value"
:title="lightning.status.value === 'stopped' ? 'Lightning node not running' : 'Lightning node required'"
:title="modalTitle"
max-width="max-w-md"
z-index="z-[3600]"
@close="onClose"
>
<p v-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
Your Lightning node is running, but it has no funds or inbound liquidity
yet — so it can't send or be paid. The Lightning setup walks you through
funding it and opening a channel.
</p>
<p v-else-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
Lightning payments need a Lightning node that's actually running. Yours is
installed but isn't running right now start it from My Apps and try
again.
@@ -21,13 +26,27 @@
it'll be installed for you.
</p>
<div v-if="lightning.status.value !== 'stopped'" class="mt-4 space-y-2">
<div v-if="lightning.status.value === 'absent'" class="mt-4 space-y-2">
<div
v-for="node in nodes"
:key="node.id"
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
>
<div class="flex items-start gap-3">
<div class="ln-node-row flex items-start gap-3">
<div class="ln-node-icon shrink-0">
<img
v-if="!failedIcons.has(node.id)"
:src="node.icon"
:alt="node.name"
class="w-full h-full object-contain"
@error="failedIcons.add(node.id)"
/>
<!-- No packaged icon yet (Core Lightning): a neutral bolt keeps the
row aligned instead of showing a broken-image box. -->
<svg v-else class="w-6 h-6 text-white/40" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">{{ node.name }}</span>
@@ -41,7 +60,7 @@
<button
v-if="node.available"
:disabled="installing !== null"
class="shrink-0 glass-button glass-button-warning px-3.5 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50"
class="ln-node-action glass-button glass-button-warning rounded-lg text-xs font-medium disabled:opacity-50"
@click="install(node.id)"
>
{{ installing === node.id ? 'Installing…' : 'Install' }}
@@ -49,7 +68,7 @@
<button
v-else
disabled
class="shrink-0 glass-button px-3.5 py-1.5 rounded-lg text-xs opacity-40 cursor-not-allowed"
class="ln-node-action glass-button rounded-lg text-xs opacity-40 cursor-not-allowed"
>Install</button>
</div>
</div>
@@ -71,12 +90,17 @@
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openApps"
>Open My Apps</button>
<button
v-else-if="lightning.status.value === 'no-funds'"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="openLightningSetup"
>Set up Lightning</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
@@ -86,6 +110,8 @@ interface NodeChoice {
id: string
name: string
blurb: string
/** Falls back to a neutral bolt when the asset is missing. */
icon: string
/** False renders the row as "Coming soon" with a dead Install button. */
available: boolean
}
@@ -99,21 +125,32 @@ const nodes: NodeChoice[] = [
id: 'lnd',
name: 'LND',
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
icon: '/assets/img/app-icons/lnd.png',
available: true,
},
{
id: 'core-lightning',
name: 'Core Lightning',
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
icon: '/assets/img/app-icons/core-lightning.svg',
available: false,
},
]
const router = useRouter()
const modalTitle = computed(() => {
if (lightningStatusIs('no-funds')) return 'Lightning not funded yet'
if (lightningStatusIs('stopped')) return 'Lightning node not running'
return 'Lightning node required'
})
const appStore = useAppStore()
const lightning = useLightningRequired()
/** App id currently installing, or null. */
/** Icons that 404'd — swapped for the inline bolt. */
const failedIcons = ref(new Set<string>())
const installing = ref<string | null>(null)
const error = ref('')
@@ -133,6 +170,17 @@ async function install(id: string) {
}
}
function lightningStatusIs(s: string) {
return lightning.status.value === s
}
/** The Lightning goal already owns funding + channel-opening, so reuse it
* rather than duplicating that flow inside this modal. */
function openLightningSetup() {
lightning.close()
router.push('/dashboard/goals/run-lightning-node')
}
function openApps() {
lightning.close()
router.push('/dashboard/apps')
@@ -143,3 +191,35 @@ function onClose() {
lightning.close()
}
</script>
<style scoped>
.ln-node-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 0.35rem;
}
.ln-node-action {
flex-shrink: 0;
padding: 0.375rem 0.875rem;
}
/* Narrow phones: the icon + name + blurb keep the top row, and the action
drops to its own full-width line rather than squeezing the blurb into a
two-word column. */
@media (max-width: 26rem) {
.ln-node-row {
flex-wrap: wrap;
}
.ln-node-action {
width: 100%;
margin-top: 0.625rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
</style>
@@ -191,6 +191,9 @@ async function receive() {
emit('received')
}
} catch (err: unknown) {
// A running node with no inbound liquidity is a funding problem, not a
// failure — reuse the Lightning modal in its funding mode instead.
if (receiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
error.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : 'Failed'
@@ -665,6 +665,8 @@ async function send() {
// Success pane (or the token pane for ecash mints) takes over the modal.
confirming.value = false
} catch (err: unknown) {
// Running node with nothing to pay with -> funding modal, not a raw string.
if (effectiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
processing.value = false
@@ -28,8 +28,10 @@ export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
/** `absent` — nothing installed, offer to install one.
* `stopped` — installed but not running, point the user at My Apps.
* `no-funds` — running, but there is nothing to pay with / no inbound
* liquidity to be paid into; send the user to the Lightning setup goal.
* `running` — good to go. */
export type LightningStatus = 'absent' | 'stopped' | 'running'
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
// Module-scope: one source of truth shared by every caller and the single
// global modal mounted in App.vue.
@@ -73,5 +75,52 @@ export function useLightningRequired() {
show.value = false
}
return { show, status, lightningStatus, hasLightningNode, requireLightningNode, close }
/**
* Raise the same modal in its funding mode: the node is installed and
* running, but has no usable balance/liquidity yet. Reuses this modal
* rather than inventing a second one, and routes to the Lightning setup
* goal where funding and channel-opening already live.
*/
function openLightningFunding() {
status.value = 'no-funds'
show.value = true
}
/**
* Map a failed Lightning attempt onto the funding modal when the node is
* running but has nothing to pay with / no inbound liquidity. Returns true
* when it handled the error, so the caller can skip showing a raw string.
*
* Matched on the message because LND surfaces these as plain text: there is
* no distinct error code for "no channels" vs "no route" vs "insufficient
* balance", and all three mean the same thing to the user — fund me.
*/
function handleLightningFailure(err: unknown): boolean {
if (lightningStatus() !== 'running') return false
const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase()
const fundingRelated = [
'no route',
'no routes',
'insufficient',
'no channel',
'not enough',
'balance',
'unable to find a path',
'no path',
].some((needle) => msg.includes(needle))
if (!fundingRelated) return false
openLightningFunding()
return true
}
return {
show,
status,
lightningStatus,
hasLightningNode,
requireLightningNode,
openLightningFunding,
handleLightningFailure,
close,
}
}