docs/adr/search/plan.md · decisions settled 2026-08-01 · PingboUI/PingboOrb.swift for the orb.vanta: Screened 591 · Matter 23 · Reading 17, so Screened is ~95% of the hits, about 25:1. That ratio decides the mail ordering.PersonAvatar(size: 44, cornerRadius: 13), the roster's own geometry.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.⋯, 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.PersonRosterRow. Four ways a person can match, and each one explains itself in the row:Nina 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..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".Screened · N divider, no kept-then-screened split, and no zone prior on the ranking.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.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 —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.↳ 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.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.ConversationReaderScreen / ConversationView); what differs is the presentation and the chrome around it.book · Screened: More › Screened's tray.full · unassigned: nothing.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.ThreadReaderPresenter → openThreadReader(matchedMessageID) with the default .threadCards.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 exists — ConversationMessageBodyState 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.ConversationView.swift:512-517 and ScreenedView.swift:117-119 both still describe the removed image gate. They are why this was drawn wrong twice.proxy.scrollTo(thread.focusMessageId, anchor: .top). Search implements nothing — it hands over the message that won the ranking rather than the thread's newest.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.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.focusMessageId lands you on the right message, which is what the promise was actually about.
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.
alignment: .center, spacing: 10PingboAvatar(size: PingboLayout.avatar) = 36, corner radius size × 13/46 ≈ 10.2. Carries personId so the counterparty's photo resolves.PingboType.headline (17 semibold) · ink · lineLimit(1) · .tailPingboType.mailTimestamp · monospacedDigit() · textTertiary, at the trailing edge of the title linePingboType.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.OwedPill(singleLine: true) — the one exception to the two-line rule. Keep it: it marks the step the agent cannot do for you.zoneBadge; adding a pill here would be a fourth vocabulary for the same fact.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.
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.
spacing: 12 · row padding .horizontal 10 / .vertical 12PersonAvatar(size: 44, cornerRadius: 13, initialsFontSize: 13) — the Google contact photo when there is one, else sage-on-panel initials..system(size: 14.5, weight: .semibold) · ink · lineLimit(1).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..system(size: 12) · textSecondary · lineLimit(1) · .truncationMode(.middle) — an address truncated at the tail loses its domain, which is the half that identifies.ZoneGlyph(size: .roster) + count (monoFixed(11, .medium)) per non-zero zone. .fixedSize + .layoutPriority(1) so the strip never compresses and the address yields first.Nina Patel · nina.patel@vanta.com shows why a query for a company surfaced a person.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.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.
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.spacing: 3 · row padding .vertical 10, no separators.system(size: 12, weight: .semibold) · textSecondary — exactly the Reading shelf's source labelPingboType.mailTimestamp · monospacedDigit() · textTertiaryPingboType.subhead.weight(.semibold) (15 semibold) · ink · lineLimit(1)PingboType.footnote (13) · textSecondary · lineLimit(2). In search this is the ts_headline span — see below.↳ 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..fixedSize + .layoutPriority(1) so the span yields first — the same trick PersonRosterRow uses for its zone strip, rather than a new one.📎 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.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.