fix(01-17): pin the FIPS/Tor pills and let the peer-card badge row wrap (UIFIX-01)

Audited every transport-pill render site in the cloud surfaces at 390x740
and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer
cards, PeerFiles.vue header) and both already appear on a phone; three
file-level sites carry none, by decision recorded in the SUMMARY.

- Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport
  badge. Measured at 320px, a longer trust label squeezed the badge until
  its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a
  second line intact. Inert whenever the row fits, so desktop is unchanged.
- New TransportPills.test.ts: one site-specific assertion per render site,
  so removing a pill fails the build. Dorian asked that these never be
  removed in a future cleanup; nothing in the repo pinned them before.
- Unknown-transport cases assert no pill is fabricated (T-01-78), and the
  labels/colours are asserted against PeerFiles.vue's canonical mapping
  rather than a duplicated table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 10:03:26 -04:00
co-authored by Claude Opus 5
parent d9b3a7d5e0
commit 8255b69af2
2 changed files with 329 additions and 3 deletions
+14 -3
View File
@@ -308,7 +308,14 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-2 text-xs">
<!-- flex-wrap (UIFIX-01): this row has no horizontal give at phone
widths. Without wrapping, a longer trust label squeezes the
transport badge until ITS OWN text breaks mid-label ("TOR ·" /
"120.0s") — measured at 320px, the narrowest width we support.
Wrapping drops the transport badge to a second line intact
instead. It is inert whenever the row already fits, so every
desktop width renders exactly as before. -->
<div class="flex flex-wrap items-center gap-2 text-xs">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
@@ -317,10 +324,14 @@
{{ peer.trust_level }}
</span>
<!-- Live transport badge — which route actually served the last
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
browse (FIPS = direct mesh, fast; Tor = fallback, slow).
shrink-0 keeps the transport WORD whole: this badge is the
security signal a phone user reads, so it wraps to the next
line at full size rather than being compressed into a
two-line stub (UIFIX-01). -->
<span
v-if="peerTransport(peer.onion)"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
class="inline-flex shrink-0 items-center gap-1.5 px-2 py-1 rounded-full"
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
>
@@ -0,0 +1,315 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* THE FIPS / TOR TRANSPORT PILLS ARE A PERMANENT, USER-REQUESTED FEATURE.
* ─────────────────────────────────────────────────────────────────────────────
*
* Dorian asked for these pills explicitly ("really helpful") and asked that no
* future cleanup or refactor ever remove them. They are the one place the app
* tells a user whether their file arrived over the fast encrypted mesh (FIPS)
* or over Tor — a security signal, not decoration. Requirement UIFIX-01.
*
* IF A TEST IN THIS FILE FAILS, THE MOST LIKELY CAUSE IS THAT SOMEONE REMOVED
* OR RENAMED A TRANSPORT PILL — not that the test went stale. Put the pill
* back. If a pill genuinely has to move, move the assertion with it; do not
* delete the assertion.
*
* Each `it()` below is keyed to ONE specific render site so that deleting the
* pill from that site, and only that site, fails. A pill somewhere else in the
* app does not satisfy these assertions.
*
* Render sites pinned here (audited 2026-08-02 at 390×740 and 320×640):
* S1 Cloud.vue — peer card badge row, Folders tab (renders a pill)
* S2 Cloud.vue — Peer Files aggregated rows (no pill, by decision)
* S3 Cloud.vue — Paid Files rows (no pill, by decision)
* S4 PeerFiles.vue — header, desktop copy + mobile copy (renders a pill)
* S5 PeerFiles.vue — per-file card body (no pill, by decision)
*
* The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a
* bug: transport is measured PER PEER PER BROWSE, never per file, so a
* per-file pill would claim a reading the app never took. The reasoning is
* written up in .planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md.
* If you deliberately add a per-file pill, update that decision record and
* this test together.
*/
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import Cloud from '../Cloud.vue'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/composables/useAudioPlayer', () => ({
useAudioPlayer: () => ({ play: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
federationListNodes: vi.fn(),
},
}))
const PEER_ONION = 'peeraaaa1111bbbb2222cccc3333dddd4444eeee.onion'
function makePeer(overrides: Record<string, unknown> = {}) {
return {
did: 'did:key:peerAlpha',
pubkey: 'peer',
onion: PEER_ONION,
name: 'Peer Alpha',
trust_level: 'trusted',
added_at: '2026-06-10T10:00:00Z',
...overrides,
}
}
function makeItem(overrides: Record<string, unknown> = {}) {
return {
id: 'file-1',
filename: 'notes.txt',
mime_type: 'text/plain',
size_bytes: 128,
description: '',
access: 'free',
...overrides,
}
}
/** Mount Cloud.vue with one peer whose last browse resolved over `transport`.
* Pass `transport: null` for the "we have not observed a transport" edge. */
async function mountCloud(transport: string | null, peerOverrides: Record<string, unknown> = {}) {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer(peerOverrides)] } as never)
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') {
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
}
if (req.method === 'content.owned-list') {
return {
items: [{
onion: PEER_ONION,
content_id: 'file-1',
filename: 'paid-track.mp3',
mime_type: 'audio/mpeg',
size_bytes: 4096,
paid_sats: 2500,
purchased_at: '2026-07-30T10:00:00Z',
}],
}
}
return {}
}) as never)
const wrapper = mount(Cloud, {
global: { plugins: [createPinia()], stubs: { Teleport: true } },
})
await flushPromises()
await flushPromises()
return wrapper
}
async function mountPeerFiles(transport: string | null) {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer()] } as never)
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') {
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
}
if (req.method === 'content.owned-list') return { items: [] }
return {}
}) as never)
const wrapper = mount(PeerFiles, {
props: { peerId: PEER_ONION },
global: { plugins: [createPinia()], stubs: { Teleport: true } },
})
await flushPromises()
await flushPromises()
return wrapper
}
/** The Cloud.vue peer-card badge pill renders "<TRANSPORT> · <n>s" — a shape
* no other pill in either view produces, which is what keys these assertions
* to site S1 specifically. */
function cloudPeerCardPill(wrapper: ReturnType<typeof mount>) {
return wrapper.findAll('span').find(s => /^(FIPS|TOR|MESH|LAN)\s·\s[\d.]+s$/.test(s.text().trim()))
}
beforeEach(() => {
vi.clearAllMocks()
// The peer-browse cache is a persist:true key — it snapshots into
// sessionStorage, which outlives a per-test `createPinia()`. Without this,
// the transport from an earlier test leaks into the next one and the
// unknown-transport case would "pass" against a stale FIPS reading.
sessionStorage.clear()
})
// ── S1: Cloud.vue peer card badge row ───────────────────────────────────────
describe('S1 — Cloud.vue peer card transport pill (Folders tab)', () => {
it('renders the FIPS pill on the peer card when the last browse used FIPS', async () => {
const wrapper = await mountCloud('fips')
const pill = cloudPeerCardPill(wrapper)
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
expect(pill!.text()).toMatch(/^FIPS · [\d.]+s$/)
// FIPS reads as the good/fast path — emerald, matching the canonical palette.
expect(pill!.classes().join(' ')).toContain('bg-emerald-500/15')
expect(pill!.attributes('title')).toContain('FIPS')
})
it('renders the TOR pill, in the slow-path colour, when the last browse used Tor', async () => {
const wrapper = await mountCloud('tor')
const pill = cloudPeerCardPill(wrapper)
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
expect(pill!.text()).toMatch(/^TOR · [\d.]+s$/)
// Tor is the slow fallback — amber, visibly different from FIPS.
expect(pill!.classes().join(' ')).toContain('bg-amber-500/15')
expect(pill!.attributes('title')).toContain('TOR')
})
it('fabricates no pill when no transport has been observed, and keeps the not-known treatment', async () => {
const wrapper = await mountCloud(null)
// T-01-78: never claim a transport the app has not actually measured.
expect(cloudPeerCardPill(wrapper)).toBeUndefined()
expect(wrapper.text()).not.toMatch(/\bFIPS\b/)
// The existing "we don't know yet" treatment stays.
expect(wrapper.text()).toContain('Peer Node')
})
// UIFIX-01 mobile half. The badge row has no horizontal give at 320px: with a
// longer trust label and no wrapping, flexbox compresses the transport badge
// until its OWN text breaks mid-label ("TOR ·" / "120.0s") — measured in a
// real browser at 320×640. flex-wrap makes the badge drop to a second line
// intact instead, and shrink-0 stops it being squeezed on the way there.
it('lets the badge row wrap and keeps the transport pill unsqueezed (mobile legibility)', async () => {
const wrapper = await mountCloud('tor')
const pill = cloudPeerCardPill(wrapper)
expect(pill).toBeTruthy()
expect(pill!.classes(), 'transport pill must not be compressible').toContain('shrink-0')
const row = pill!.element.parentElement as HTMLElement
expect(row, 'transport pill has no parent row').toBeTruthy()
expect(
Array.from(row.classList),
'the peer-card badge row must wrap, or the pill text breaks mid-label at 320px',
).toContain('flex-wrap')
})
})
// ── S2/S3: Cloud.vue file lists carry no per-file transport pill (decision) ──
describe('S2/S3 — Cloud.vue file rows carry no per-file transport pill (recorded decision)', () => {
it('shows no transport pill on the aggregated Peer Files rows or the Paid Files rows', async () => {
const wrapper = await mountCloud('fips')
const vm = wrapper.vm as unknown as { activeTab: string }
for (const tab of ['peers', 'paid']) {
vm.activeTab = tab
await flushPromises()
// Transport is a per-peer, per-browse reading. These rows list files —
// from many peers at once in the Peer Files case, and from the local
// purchase cache (no live transport at all) in the Paid Files case.
// A pill here would assert a per-file measurement that was never taken.
expect(
cloudPeerCardPill(wrapper),
`a transport pill appeared on the "${tab}" rows — see the header of this file`,
).toBeUndefined()
expect(wrapper.text(), `"${tab}" rows must not label files with a transport`).not.toMatch(/\bFIPS\b/)
}
})
})
// ── S4: PeerFiles.vue header, desktop copy AND mobile copy ──────────────────
describe('S4 — PeerFiles.vue header transport pill', () => {
it('renders the pill in the desktop title block', async () => {
const wrapper = await mountPeerFiles('fips')
const desktopBlock = wrapper.findAll('div').find(d => {
const c = d.classes()
return c.includes('hidden') && c.includes('md:block')
})
expect(desktopBlock, 'PeerFiles desktop title block is missing').toBeTruthy()
const pill = desktopBlock!.findAll('span').find(s => s.text().trim() === 'FIPS')
expect(pill, 'desktop header transport pill is missing — see the header of this file').toBeTruthy()
// Canonical mapping (PeerFiles.vue transportPill), not a duplicated table.
expect(pill!.classes().join(' ')).toContain('bg-green-500/20')
expect(pill!.attributes('title')).toContain('FIPS')
})
it('renders a separate mobile copy of the pill, because the desktop title block is hidden on a phone', async () => {
const wrapper = await mountPeerFiles('fips')
// This is the UIFIX-01 mobile half for this site: the title block that
// carries the desktop pill is `hidden md:block`, so without this copy a
// phone user would see no transport at all on the peer's file page.
const mobilePill = wrapper.findAll('span').find(s =>
s.classes().includes('md:hidden') && s.text().trim() === 'FIPS',
)
expect(
mobilePill,
'the md:hidden mobile transport pill is missing — a phone would show no transport here',
).toBeTruthy()
expect(mobilePill!.classes().join(' ')).toContain('bg-green-500/20')
})
it.each([
['fips', 'FIPS', 'bg-green-500/20'],
['mesh', 'Mesh', 'bg-green-500/20'],
['lan', 'LAN', 'bg-blue-500/20'],
['tor', 'Tor', 'bg-amber-500/20'],
])('maps transport %s to the canonical label %s and its canonical colour', async (transport, label, colour) => {
const wrapper = await mountPeerFiles(transport)
const pills = wrapper.findAll('span').filter(s => s.text().trim() === label)
// One desktop copy + one mobile copy, both from the same canonical mapping.
expect(pills.length, `expected the ${label} pill in both the desktop and mobile header copies`).toBe(2)
for (const p of pills) expect(p.classes().join(' ')).toContain(colour)
})
it('fabricates no pill for an unobserved transport, in either the desktop or the mobile copy', async () => {
const wrapper = await mountPeerFiles(null)
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
expect(
wrapper.findAll('span').some(s => s.text().trim() === label),
`PeerFiles fabricated a "${label}" pill with no observed transport`,
).toBe(false)
}
// The file list itself is unaffected — only the transport claim is absent.
expect(wrapper.text()).toContain('notes.txt')
})
})
// ── S5: PeerFiles.vue per-file cards carry no transport pill (decision) ─────
describe('S5 — PeerFiles.vue per-file cards carry no transport pill (recorded decision)', () => {
it('labels file cards with access only, leaving transport to the single peer-level pill', async () => {
const wrapper = await mountPeerFiles('tor')
// Every file on this page came from the same peer over the same transport,
// so the header pill already states it once. Repeating it per card would
// add no information and would crowd the row at 320px.
const cardBody = wrapper.findAll('div').find(d => {
const c = d.classes()
return c.includes('p-4') && c.includes('flex') && c.includes('mt-auto')
})
expect(cardBody, 'PeerFiles per-file card body is missing').toBeTruthy()
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
expect(
cardBody!.findAll('span').some(s => s.text().trim() === label),
`a transport pill appeared on a per-file card — see the header of this file`,
).toBe(false)
}
})
})