fix(web5): bound connected-nodes tab panes to sibling-matched height (UIFIX-02)
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m58s

The three tab panes carried max-h-72 xl:max-h-none, so at the xl breakpoint
(where the Web5 row becomes two grid columns) the cap lifted with nothing to
replace it: the visible pane grew to fit every row, stretched the grid row,
and the scrollbar the user expects never appeared.

Give each pane xl:flex-1 xl:basis-0 xl:max-h-none instead — zero flex-basis
means the pane contributes no intrinsic height, so the grid row is sized by
the Web5NodeVisibility sibling alone, grid's default align-items: stretch
gives the card that height, and flex-1 hands the leftover height back to the
pane, which scrolls inside it via the existing overflow-y-auto. The card root
gets min-h-0 (so the flex column can shrink below content height) plus an
xl:min-h-[20rem] floor so a short sibling still leaves a usable list area
instead of collapsing to the header+tabs strip.

Below the row breakpoint nothing changes: the stacked cap (max-h-72) and
scroll are untouched, and dropping flex-auto (replaced by nothing, i.e. the
default 0 1 auto) has no visible effect since single-column stacked cards
have no extra flex space to distribute anyway.

Adds Web5ConnectedNodesScroll.test.ts to pin the contract across all three
tab panes and the card root so a future cleanup cannot reintroduce the
grow-to-fit regression a third time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-31 21:38:34 -04:00
parent bf9cfc446a
commit ceafbcb596
2 changed files with 104 additions and 4 deletions

View File

@ -1,6 +1,6 @@
<template>
<!-- Connected Nodes (P2P over Tor) -->
<div data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
<div data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col min-h-0 xl:min-h-[20rem]">
<!-- Desktop: side-by-side layout -->
<div class="hidden md:flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
@ -54,7 +54,7 @@
</div>
<!-- Trusted tab -->
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none">
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@ -87,7 +87,7 @@
</div>
<!-- Observers tab -->
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none">
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@ -117,7 +117,7 @@
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none">
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>

View File

@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// Structural pin for UIFIX-02: the connected-nodes card's height must track
// its row sibling (via grid `align-items: stretch` + a zero-basis flex
// child), and the tab panes must scroll inside that height rather than
// growing to fit every row. See:
// .planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
listPeers: vi.fn(() => new Promise(() => {})),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
call: vi.fn(),
},
}))
vi.mock('@/composables/useMessageToast', () => ({
useMessageToast: () => ({
loadReceivedMessages: vi.fn(),
}),
}))
vi.mock('@/stores/web5Badge', () => ({
useWeb5BadgeStore: () => ({ pendingRequestCount: 0 }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ peerHealth: {} }),
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('Web5ConnectedNodes scroll contract (UIFIX-02)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('gives all three tab panes the bounded, sibling-matched scroll contract', () => {
const wrapper = mount(Web5ConnectedNodes)
// The three v-show panes (trusted, observers, requests) are always in the
// DOM regardless of which tab is active. The Send Message modal is
// v-if="false" by default, so this selector matches exactly the panes.
const panes = wrapper.findAll('.overflow-y-auto')
expect(panes).toHaveLength(3)
for (const pane of panes) {
const classes = pane.classes()
// Below the row breakpoint: unchanged stacked cap, still scrollable.
expect(classes).toContain('min-h-0')
expect(classes).toContain('overflow-y-auto')
expect(classes).toContain('max-h-72')
// At the row breakpoint: zero-basis growing flex child with no cap —
// this is what lets the sibling drive the card's height while the
// pane itself contributes nothing to intrinsic sizing.
expect(classes).toContain('xl:flex-1')
expect(classes).toContain('xl:basis-0')
expect(classes).toContain('xl:max-h-none')
// The old grow-to-fit-content class must be gone.
expect(classes).not.toContain('flex-auto')
}
})
it('gives the card root a min-h-0 flex column with a row-breakpoint height floor', () => {
const wrapper = mount(Web5ConnectedNodes)
const root = wrapper.find('[data-controller-container]')
const classes = root.classes()
expect(classes).toContain('flex')
expect(classes).toContain('flex-col')
expect(classes).toContain('min-h-0')
expect(classes).toContain('xl:min-h-[20rem]')
})
it('still renders the empty-state row for each pane when the node list is empty', () => {
const wrapper = mount(Web5ConnectedNodes)
// Default state: no cached peers, observers, or connection requests.
expect(wrapper.text()).toContain('web5.noPeers')
expect(wrapper.text()).toContain('web5.noObservers')
expect(wrapper.text()).toContain('web5.noRequests')
// The panes themselves are still present in the tree (not collapsed away).
expect(wrapper.findAll('.overflow-y-auto')).toHaveLength(3)
})
})