PINGBO · SEARCH
Authority: docs/adr/search/plan.md · decisions settled 2026-08-01 · PingboUI/PingboOrb.swift for the orb.
Grounded in dogfood — 105 matters / 72,043 threads / 88,003 messages. For vanta: Screened 591 · Matter 23 · Reading 17, so Screened is ~95% of the hits, about 25:1. That ratio decides the mail ordering.
9:41􀫧 􀙇 􀛨
Search
Frequent people
RJ
Raj
JW
Jordan
NP
Nina
EP
Elena
DR
Dana
Recent
soc2
term sheet
northwind
1 · Before typing
Non-browsable. Frequent people and recent searches, nothing else — never a list of everything. A directory turns a push-not-pull product into something you browse.

No chips yet: there is nothing to divide.

On first run this screen is empty — no frequent people, no recent searches, nothing in their place. Settled 2026-08-01. The rule above forbids the obvious filler: any list that would populate it is a directory, and a directory is what this screen is defined against. Just the field and the caret.

Exactly five faces, 44pt, and the strip never scrolls — a facepile you can swipe through is a browsable directory of people. The sixth person is what the field is for. Tiles are PersonAvatar(size: 44, cornerRadius: 13), the roster's own geometry.

Frequent people ranks by correspondence, not message volume: by volume the top four are Medium (3,801), Google Cloud (3,362) and HubSpot (2,011), which makes the shortcut useless.
9:41􀫧 􀙇 􀛨
va
All Matter3 People2 Mail
Ask
People
NP
Nina Patel
nina.patel@vanta.com | 2
Matter
VA
Vanta SOC2 renewal6d
Quiet 6d · Nina owes the updated policy set.
AC
Acme security questionnaire2d
Q7 asks for the Vanta report; unconfirmed.
2 · One or two characters
Matter and People answer from the first character; Mail joins at the third.

Not a compromise — it is the architecture Gmail uses, made explicit. Type one letter into Gmail and what drops down is suggestions: contacts, recent queries, label names. The message search waits for Enter. The cheap, bounded sets answer immediately; the expensive one waits for a gate. Gmail's gate is a keypress; ours is the third character.

Why the third: pg_trgm has no trigram to look up below three characters, so a shorter query cannot use messages_search_text_trgm_idx and falls back to a sequential scan. That path is measured in the migration's own comment — 91,552ms cold, 3,904ms warm, reading ~1.8 GB every run. The gate is not tuning; it is staying out of a path we have already timed.

Matter (105 rows) and People (a roster of thousands of short rows) are prefix lookups. They cost nothing and they are usually what you were reaching for.

The Mail chip stays put and shows , not 0. Zero is a finding — "I looked there and found nothing". Nothing has looked yet, and a chip that vanished and came back would re-lay-out the row under your finger, which is the same reason the zero chips stay.

⚠️ ~300ms p95 on the dogfood corpus is a budget, not an escape hatch. If Mail cannot answer inside it, that is a defect in the search path and the search path is what changes — not this screen. Writing the fallback into the design would pre-authorise the implementation to decide the product, which is the wrong way round. Measure it against real queries; if it misses, someone decides with the number in hand.
9:41􀫧 􀙇 􀛨
vanta
All Matter3 People2 Mail40
Ask
People
NP
Nina Patel
nina.patel@vanta.com | 2
Matter
VA
Vanta SOC2 renewal6d
Quiet 6d · Nina owes the updated policy set.
AC
Acme security questionnaire2d
Q7 asks for the Vanta report; unconfirmed.
Mail
NP
Nina Patel09:12
Re: policy set for renewal
…uploaded everything to Vanta this morning, so the evidence should sync…
3 · All (default)
All is a stack of capped sections, not a merged ranked list. Cross-entity ranking is deferred in the ADR, so a merged list would have no defensible order.

The three rows are the board row, the roster row and the screened row, unchanged. They already look different — 36 / 44 / 40 tiles, and a second line that is a decision, an address, and a subject. Nothing had to be invented to tell them apart.

All was removed and then put back. It was removed because the three types rendered identically and read as mush — a rendering defect, not a reason to drop the mode. Without All the default lands on one type and shows an empty screen whenever that type is 0; auto-selecting a non-empty chip instead makes the selection jump around as you type, which is worse.
9:41􀫧 􀙇 􀛨
vanta
All Matter3 People4 Mail40
Ask
NP
Nina Patel
nina.patel@vanta.com | 1 2
VS
Vanta Support
support@vanta.com
MR
Marco Reyes
marco.reyes@vanta.com | 1
BI
billing@vanta.com
no name on any message
4 · People
The roster row, unchanged — PersonRosterRow. Four ways a person can match, and each one explains itself in the row:

The domainNina Patel · nina.patel@vanta.com. Searching a company surfaces its people, and the highlight on the address is the only thing that makes that legible.
The name itself — no explanation needed.
No name at all — the address becomes the title and the tile goes neutral. Do not invent a display name out of the local part.

The zone strip is live entanglement, not history: one glyph + count per non-zero zone, and it is .fixedSize with .layoutPriority(1) so the address truncates before it does. A person with nothing open shows no strip, which is itself the answer to "is anything happening with them".

⚠️ What a tap does is the one thing still undecided — the person card, or narrowing the search to them. Drawn as the card, because that is what this row does everywhere else today.
9:41􀫧 􀙇 􀛨
vanta
All Matter3 People2 Mail632
Ask
NP
Nina Patel09:12
Re: policy set for renewal
…uploaded everything to Vanta this morning…
RM
Raj MehtaMon
Vanta report — Q7 follow-up
Attaching the attestation you asked for on the questionnaire…
TW
This Week in ComplianceTue
Continuous monitoring, explained
Vanta ships continuous monitoring for SOC2 evidence…
DR
Dana Ruiz14 Jul
Intro — Vanta AE for EMEA
Happy to make the intro whenever you want it.
JW
Jordan Whitfield12 Jun
Re: Series D data room — countersigned
…the Vanta report is in the folder with the signed copy…
VA
VantaYesterday
Vanta — your weekly digest
3 controls drifted, 1 policy expires this week…
5 · Mail — ranked, not partitioned
One list, ordered by relevance. No Screened · N divider, no kept-then-screened split, and no zone prior on the ranking.

An earlier draft did all three, on the strength of the 25:1 measurement — Screened is 591 of the 631 hits for vanta. All three were wrong, for one reason. A divider announces "these 591 are lesser" before you have looked, and one of them may be the thread you came for. And a zone is not a relevance signal: it is a triage verdict made earlier, answering a different question. Screening a newsletter says it does not need a place in your working flow — it says nothing about whether it is the thing you are hunting for now, and search is often exactly the tool you reach for when you want the thing you put away. Ranking on the triage verdict is the soft version of hiding mail you cannot find again.

⚠️ The real risk behind the 25:1 is a different one: bulk mail repeats brand names, so a digest titled Vanta — your weekly digest can out-score a matter thread that mentions it once. That is a ranking-quality problem, not an argument for a thumb on the scale. Fix it where it belongs —
· field weight: a subject match outranks a body match
· your relationship to the thread: did you open it, did you reply — about you and this thread, not about how it was filed
· recency as a tie-break

Falsifiable, and the corpus is right there: run a handful of real queries against dogfood and read the top ten. If bulk mail still dominates, the ranking function is what changes.

A thread from a CLOSED matter still ranks — its badge is the Done posture in textSecondary, so it reads as settled rather than urgent. This is not a corner case: search is the only route back to a closed matter, because the board no longer carries it.

The trailing slot already says what the divider was saying↳ matter, Reading, Screened, or nothing when the thread is unassigned. Screened rows stay at 72% opacity: visibly lesser, never fenced. The chip counts all 631, because nothing is excluded any more.

Step 2, not now: an Email ▾ dropdown following Gmail's chip + bottom sheet, not RedNote's five-dimension panel — we have one dimension. Trigger: someone wants screened out entirely and better ranking was not enough.
9:41􀫧 􀙇 􀛨
what's open with vanta
All Matter0 People0 Mail0
Ask
No matching results
6 · Zero results = the way in to Ask
The chips stay, all reading 0, and they are not tappable. They stay because results go 0 → some → 0 as you type, and a row that disappears re-lays-out the screen under your finger on every keystroke. Three zeros also say what a blank screen cannot: "I looked in all three."

Dimmed is that sentence. No press feedback; VoiceOver reads "People, no results" and the chip is marked disabled. The row scrolls sideways and never wraps — its height has to be a constant.

Ask is the same box in both states. Amplifying adds a forest fill and nothing else; the width must not change, or the chip row re-lays-out the moment results hit zero. No second Ask card below: amplify means more weight on the same control, not a duplicate introducing itself again.
9:41􀫧 􀙇 􀛨
Re: policy set for renewal
NP
Nina Patel
Sending the updated set today — the old one…
Mon
ME
You
No rush. Renewal window closes on the 14th.
Tue
NP
Nina Patel
All done — I uploaded everything to Vanta this morning, so the evidence should sync before the auditor's window. Shout if anything looks off on your side.
09:12
ME
You
Perfect, thank you.
09:40
7 · Tapping a result
The result opens whatever that thing already opens — search routes, it does not render. Every path below ends in the same screen (ConversationReaderScreen / ConversationView); what differs is the presentation and the chrome around it.

Two rules, settled 2026-08-01. Between them they cover all four cases:

The mark — matter: the Board tab's own three-bar glyph, plus the matter's name · Reading: the Reading tab's book · Screened: More › Screened's tray.full · unassigned: nothing.

The reader
· a Reading thread opens ReadingConversationScreen with .readingArticle(title, source) — the real Reading reader, which also carries isSaved / onToggleSaved, openSource and next-unread. Route it through the plain presenter and you get the article body without save, without its source, without next-unread — recognisably not Reading. So the result row has to carry the item's title and source with it.
· everything else — matter, screened, unassigned — opens ThreadReaderPresenteropenThreadReader(matchedMessageID) with the default .threadCards.

The rule binds the layout to what the thing is, not to which surface happened to open it. A Reading item is a publication and has a title and a source; a screened marketing email is a letter someone sent you, and dressing it as a publication invents a masthead it does not have.

⚠️ This changes Screened, not only search. ScreenedView.swift:120 passes .readingArticle today, and its comment gives the reason: the correspondence layout's image gate blanked every remote image. That gate no longer existsConversationMessageBodyState defaults loadImages: true, no call site passes false, and the relay plus a CSP that only permits pingbo-image: made it redundant. One call site to change; if it is not changed, the same email renders one way from Screened and another from search, which is the inconsistency this whole sheet exists to avoid.

Two stale comments to clear while in thereConversationView.swift:512-517 and ScreenedView.swift:117-119 both still describe the removed image gate. They are why this was drawn wrong twice.

Scrolling to the matched message is already built: proxy.scrollTo(thread.focusMessageId, anchor: .top). Search implements nothing — it hands over the message that won the ranking rather than the thread's newest.

Note: MatterDetailView still holds its own reader rather than using ThreadReaderPresenter — it is one of the three copies that modifier was extracted to replace, and it was never migrated. Search should use the presenter, not copy the copy.

Ask opens the Door (DoorSheet) with the typed query handed in as the user's input — the query is already the question, so it does not get asked twice. Nothing new is built for the zero-result state beyond passing the string.

No in-thread highlight — settled 2026-08-01. The thread opens as whatever it already is, and search adds nothing to it. That kills the only piece of new UI this flow would otherwise have needed: an earlier draft asked for the row's highlight to survive the tap, which would have meant building a highlight layer no surface has.

The row's own highlight still does its job — it explains why the row matched, which you have already read by the time you tap. And focusMessageId lands you on the right message, which is what the promise was actually about.
If dogfood shows people landing on a long message and hunting for the word, add it then.

Navigation is a bare ‹ — no breadcrumb, no "scrolled to the match" banner. The matched message already looks different, and a line of text explaining that is Pingbo explaining itself.

Matter result — the board's row

▸ PingboUI/MatterRowView.swift · unchanged

Search shows a matter with the row the board already shows it with. Do not reach for MatterCardView — the card was replaced precisely because at dogfood volume it cost ~180pt and fitted ~2.5 rows a screen. And do not add the board's swipe or advance affordances: those live on the board's List, not in this row, and a search result is a reference to a thing, not a place to pass verdict on it.

VA
Vanta SOC2 renewal6d
Quiet 6d · Nina owes the updated policy set before the auditor window.
HStack
alignment: .center, spacing: 10
avatar
PingboAvatar(size: PingboLayout.avatar) = 36, corner radius size × 13/46 ≈ 10.2. Carries personId so the counterparty's photo resolves.
title
PingboType.headline (17 semibold) · ink · lineLimit(1) · .tail
age
PingboType.mailTimestamp · monospacedDigit() · textTertiary, at the trailing edge of the title line
decision
PingboType.subhead (15) · textSecondary · lineLimit(2). The lead (Quiet 6d ·) is part of the same Text, not a token beside it, so it wraps with the prose it qualifies.
owed pill
OwedPill(singleLine: true) — the one exception to the two-line rule. Keep it: it marks the step the agent cannot do for you.

What search adds

+The highlight, on whichever of the two lines matched — title, decision, or both.
+When a matter fact caused the match, the fact's text takes the decision line, because nothing else on screen would explain why the row is here. Facts are a matching signal; a fact is never a row of its own — nothing in the app renders one, so a row for it would have to invent a destination.
No lane badge. The board conveys zone through the three tiles above the list and the avatar's zoneBadge; adding a pill here would be a fourth vocabulary for the same fact.
?Undecided: does the avatar keep its zoneBadge in search? On the board the zone is grouped, so the badge is reinforcement. In a mixed result list it would be the only zone signal — useful, but it makes matters louder than the other two types. Decide before implementing.

People result — the roster row

▸ PingboUI/PersonCard.swift › PersonRosterRow · unchanged

The same row the People tab lists, unchanged. It is the only row in the app whose whole job is already "point at a person", which is exactly what a People result is.
Note for whoever implements this: PingboUI/PingboListRow.swift documents itself as the row shared by Reading, Screened and People. It has zero call sites — People draws PersonRosterRow, Screened draws its own. Do not adopt PingboListRow on the strength of its comment.

NP
Nina Patel
nina.patel@vanta.com | 1 2
HStack
spacing: 12 · row padding .horizontal 10 / .vertical 12
avatar
PersonAvatar(size: 44, cornerRadius: 13, initialsFontSize: 13) — the Google contact photo when there is one, else sage-on-panel initials.
name
.system(size: 14.5, weight: .semibold) · ink · lineLimit(1)
role
.system(size: 11) · textTertiary, beside the name. ⚠️ Leave it empty. Its doc says "Role/title — nil until the contact layer learns it", so it is a job title, not a company, and nothing fills it today. An earlier draft put the matched company there; that invents data and squats on a slot with an owner.
entanglement
.system(size: 12) · textSecondary · lineLimit(1) · .truncationMode(.middle) — an address truncated at the tail loses its domain, which is the half that identifies.
zone strip
One ZoneGlyph(size: .roster) + count (monoFixed(11, .medium)) per non-zero zone. .fixedSize + .layoutPriority(1) so the strip never compresses and the address yields first.

What search adds

+The highlight — on the name, or on the address when the match was the domain. The row then explains itself: Nina Patel · nina.patel@vanta.com shows why a query for a company surfaced a person.
Nothing else. No message count, no "last contacted" — the zone strip already says what this person is entangled in, and it is live rather than historical.
A tap opens the person card — settled 2026-08-01. The card already carries matters: ContactContainerFacet, fed by mattersInvolving(contact), so the zone strip on this row is a promise the destination keeps: you see the glyphs, you tap, the matters are there.

Mail result — the screened row

▸ PingboIOS/ScreenedView.swift · one line changed

Screened already lists threads that are not attached to anything, which is the closest thing the app has to a neutral mail row. Search reuses it whole and changes one line: the preview becomes the matched span.

NP
Nina Patel09:12
Re: policy set for renewal
…uploaded everything to Vanta this morning, so the evidence should sync…
avatar
PersonAvatar(size: 40, cornerRadius: 10, initialsFontSize: 11) — the sender's portrait, same as every other surface; with none it falls back to the identical panel tile, so an unknown sender is pixel-unchanged.
VStack
spacing: 3 · row padding .vertical 10, no separators
sender
.system(size: 12, weight: .semibold) · textSecondary — exactly the Reading shelf's source label
time
PingboType.mailTimestamp · monospacedDigit() · textTertiary
subject
PingboType.subhead.weight(.semibold) (15 semibold) · ink · lineLimit(1)
third line
Shipped: the preview, PingboType.footnote (13) · textSecondary · lineLimit(2). In search this is the ts_headline span — see below.

What search adds

+The third line becomes the matched span, not the message preview. A preview is the first N characters; when the match was in the body it will not contain the keyword, which makes the row a lie. Ellipses mark where it was cut.
This also removes the need for a "Matched in body" label — the highlight is in that line, so it says so itself.
+The provenance, ↳ matter name, trailing on the same line. This is the one piece of information no existing surface has to show: in Screened everything is screened, but a search result can come from a matter, from Reading, from Screened, or from nothing. Mail outside a matter shows Reading or Screened instead; unassigned shows nothing at all — a blank says it, and with 17,691 unassigned threads a word on each is only noise.
Pinned with .fixedSize + .layoutPriority(1) so the span yields first — the same trick PersonRosterRow uses for its zone strip, rather than a new one.
+Attachment matches surface the message carrying the file, with the filename as the span (📎 vanta-soc2-report.pdf). Attachments are not a result type. Whether attachment content is ever searched is undecided — distilled_text exists and has been populated zero times.
No unread dot. Search results are not an inbox, and unread has no bearing on whether this is the email you were looking for.
Screened rows drop to 72% opacity but are never hidden — 591 of the 631 hits for vanta. Grain: match at message level, present at thread level, as Gmail does; the matched message id travels with the result so the tap can scroll to it.