From 8256fde1a6110076b2f0f6b077d9c62c65f3382a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 1 Jul 2026 18:04:31 -0400 Subject: [PATCH] fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 --- .../src/components/InstallVersionModal.vue | 2 +- neode-ui/src/composables/useControllerNav.ts | 13 +++- neode-ui/src/locales/en.json | 3 - neode-ui/src/locales/es.json | 3 - neode-ui/src/style.css | 40 ++++++++++++ neode-ui/src/views/Apps.vue | 59 ++++++++++++++---- neode-ui/src/views/Discover.vue | 6 +- neode-ui/src/views/Mesh.vue | 34 +++++++--- neode-ui/src/views/apps/AppIconGrid.vue | 35 ++++++----- neode-ui/src/views/apps/LightningChannels.vue | 6 +- .../views/apps/__tests__/AppIconGrid.test.ts | 4 ++ .../src/views/federation/NodeDetailModal.vue | 2 + neode-ui/src/views/mesh/mesh-styles.css | 47 +++++++++----- neode-ui/src/views/web5/Web5.vue | 15 +---- .../src/views/web5/Web5ConnectedNodes.vue | 62 ++----------------- neode-ui/src/views/web5/Web5Identities.vue | 33 ++++++++-- neode-ui/src/views/web5/Web5QuickActions.vue | 20 ++++++ .../web5/__tests__/Web5ConnectedNodes.test.ts | 27 -------- 18 files changed, 242 insertions(+), 169 deletions(-) diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue index 47edbc73..36977f87 100644 --- a/neode-ui/src/components/InstallVersionModal.vue +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -35,7 +35,7 @@
+
@@ -290,7 +297,9 @@
+ +
Sideload app

Install a trusted Docker image with a simple web UI.

- +
@@ -347,6 +360,7 @@
+
@@ -772,11 +786,11 @@ async function submitSideload() { align-items: center; justify-content: center; flex-shrink: 0; - width: 42px; - height: 42px; - border-radius: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.1); + width: 40px; + height: 40px; + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.78); transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; } @@ -787,8 +801,8 @@ async function submitSideload() { color: white; } .sideload-icon-btn-mobile { - width: 48px; - height: 48px; + width: 52px; + height: 52px; } .sideload-modal { width: 100%; @@ -796,18 +810,37 @@ async function submitSideload() { max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px); overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.14); + /* Bottom sheet on mobile (flush with the screen edge, so only the top + corners round); the md: breakpoint below switches to a centered + floating card, matching the wrapper's own items-end -> md:items-center + layout switch, so it gets fully rounded corners like every other modal. */ border-radius: 1.5rem 1.5rem 0 0; background: rgba(8, 10, 18, 0.94); padding: 1.25rem; padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55); } +@media (min-width: 768px) { + .sideload-modal { + border-radius: 1.5rem; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); + } +} .sideload-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; width: 2.25rem; height: 2.25rem; border-radius: 0.75rem; - color: rgba(255, 255, 255, 0.55); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + background: transparent; + transition: background-color 0.15s ease, color 0.15s ease; +} +.sideload-close-btn:hover, +.sideload-close-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: white; } .sideload-label { display: block; diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index 7361d00c..7e5cc663 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -54,7 +54,8 @@ type="text" placeholder="Search apps..." aria-label="Search apps" - class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors" + data-controller-no-submit + class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors" /> @@ -84,7 +85,8 @@ type="text" placeholder="Search apps..." aria-label="Search apps" - class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors" + data-controller-no-submit + class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors" /> diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 14b3b07a..64d910e1 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -48,6 +48,7 @@ const connectingDevice = ref(null) const showOnboardingModal = ref(false) const onboardingDismissed = ref>(new Set()) const chatScrollEl = ref(null) +const messageInputRef = ref(null) const mobileShowChat = ref(false) // Device status panel starts collapsed on mobile (expandable via its header). const deviceExpanded = ref(false) @@ -366,13 +367,21 @@ onMounted(async () => { await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) refreshOutboxCount() // Deep-link from a message toast: open the sender's conversation if we can - // match it; otherwise just land on the mesh page (graceful fallback). + // match a LoRa mesh peer; otherwise open the Archipelago channel, since + // getReceivedMessages() (the toast's only message source) is exclusively + // Archipelago (Tor federation) traffic — a federation pubkey will never + // match an entry in mesh.peers, so without this fallback the deep-link + // silently failed and just landed on the bare mesh page every time. const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : '' if (targetPeer) { const match = mesh.peers.find( (p) => p.pubkey_hex === targetPeer || p.did === targetPeer ) - if (match) openChat(match) + if (match) { + openChat(match) + } else { + openArchChannel() + } } // Start background polling for Archipelago (Tor) messages so unread count works loadArchMessages() @@ -913,7 +922,7 @@ async function handleSendMessage() { if (mesh.sending || sendingArch.value) return if (archChannelActive.value) { await sendArchMessage() - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) return } // Pending reply: Send flushes as mesh.send-reply targeting the stashed @@ -931,7 +940,7 @@ async function handleSendMessage() { ) messageText.value = '' pendingEdit.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Edit failed' } @@ -949,7 +958,7 @@ async function handleSendMessage() { ) messageText.value = '' pendingReply.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Reply failed' } @@ -966,7 +975,7 @@ async function handleSendMessage() { await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption) messageText.value = '' pendingAttachment.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Share failed' } @@ -978,11 +987,11 @@ async function handleSendMessage() { if (activeChatChannel.value) { await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value) messageText.value = '' - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } else if (activeChatPeer.value) { await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value) messageText.value = '' - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Send failed' @@ -995,6 +1004,13 @@ function scrollChatToBottom() { } } +// Keep the compose field focused after a send so the user can keep typing +// without re-clicking it (Enter re-focuses natively, but the Send button +// click otherwise leaves focus on the button). +function refocusMessageInput() { + messageInputRef.value?.focus() +} + // Wheel over the chat must scroll ONLY the chat — never leak to the contacts // list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event // from reaching the global controller-nav wheel handler (which would otherwise @@ -1869,6 +1885,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { class="mesh-peer-search" placeholder="Search contacts…" aria-label="Search contacts" + data-controller-no-submit /> +
@@ -99,7 +100,11 @@

{{ credentialModal.title }}

{{ credentialModal.description }}

- +
@@ -117,6 +122,7 @@
+
@@ -344,27 +350,22 @@ function scrollToPage(index: number) { transform: scale(0.88); } -.sideload-modal { - display: flex; - flex-direction: column; - width: 100%; - max-width: 34rem; - max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px); - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 1.5rem 1.5rem 0 0; - background: rgba(8, 10, 18, 0.94); - padding: 1.25rem; - padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); - box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55); -} .sideload-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; width: 2.25rem; height: 2.25rem; flex-shrink: 0; border-radius: 0.75rem; - color: rgba(255, 255, 255, 0.55); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + background: transparent; + transition: background-color 0.15s ease, color 0.15s ease; +} +.sideload-close-btn:hover, +.sideload-close-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: white; } .credential-modal-body { flex: 1 1 auto; diff --git a/neode-ui/src/views/apps/LightningChannels.vue b/neode-ui/src/views/apps/LightningChannels.vue index 6129a276..2a55d735 100644 --- a/neode-ui/src/views/apps/LightningChannels.vue +++ b/neode-ui/src/views/apps/LightningChannels.vue @@ -129,6 +129,7 @@
+

Open Channel

@@ -166,15 +167,17 @@
+
+

Close Channel?

@@ -194,6 +197,7 @@
+
diff --git a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts index abd9d664..5322944b 100644 --- a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts +++ b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts @@ -78,6 +78,10 @@ describe('AppIconGrid', () => { props: { apps: [['filebrowser', makePkg('filebrowser')]] }, global: { plugins: [pinia], + // The credential modal is 'd (so its full-screen + // backdrop isn't clipped by the dashboard's transformed layout) — + // stub it to render inline so wrapper.text() still sees it. + stubs: { teleport: true }, }, }) diff --git a/neode-ui/src/views/federation/NodeDetailModal.vue b/neode-ui/src/views/federation/NodeDetailModal.vue index 843446ee..9dc07f81 100644 --- a/neode-ui/src/views/federation/NodeDetailModal.vue +++ b/neode-ui/src/views/federation/NodeDetailModal.vue @@ -1,4 +1,5 @@ diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index 834280c2..ac84816c 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -57,15 +57,6 @@ {{ t('web5.observers') }} ({{ observers.length }}) - -