# peer-directory.screen Implementation breakdown of [Brief AE §AE5](./AE-provider-social-network.brief.md) — the opt-in, platform-wide peer-discoverable directory. Mobile-narrow list with a filter bar at the top, searchable by surface, language, region, and endorsement scope-claim text. Rows reveal handle, bio excerpt, surface chips, and endorsement-count badge; tap routes to [`peer-profile.screen.md`](./peer-profile.screen.md). The drawer is reached from chat-home's top-bar overflow ("Peers") and from the coop drawer's peer-roster sub-tab when a coop member opts in to platform-wide discoverability. Voice register: **working** by default (per [`00-system-voice.md`](./00-system-voice.md) §V2b — peers are colleagues, decisions are deliberate); **plain** on empty / blocked / posture-related states (per §V2c — the cost of leaking that someone is incognito is non-trivial). Hearth lexicon stays out — this is a directory, not a hearth. ## Layout (iPhone 17 logical 393×852) ``` ┌─────────────────────────────────────────────────┐ │ ◄ status bar (system) │ 47pt ├─────────────────────────────────────────────────┤ │ [◄] Peers [🎤] [⌥] │ 56pt — top bar │ │ ⌥ = posture + filters sheet ├─────────────────────────────────────────────────┤ │ ╭─────────────────────────────────────────╮ │ 44pt — searchbar │ │ 🔍 Search handle, bio, scope-claim… │ │ voice mic on right of bar │ ╰─────────────────────────────────────────╯ │ ├─────────────────────────────────────────────────┤ │ [ surface ▾ ] [ language ▾ ] [ region ▾ ] │ 36pt — filter chip row │ [ endorses ▾ ] [ reset ] │ horizontal scroll ├─────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────┐ │ result row │ │ @sarah-k ★ 12 │ │ handle · endorsement badge │ │ Berlin · DE/EN · she/her │ │ region + langs line │ │ "Two years on Tryst, three on OF. │ │ bio excerpt (2 lines max) │ │ Pricing-study group regular." │ │ │ │ [tryst] [of] [threads] [ Connect ] │ │ surface chips · primary CTA │ └───────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ @q-berlin ★ 31 │ │ │ │ Berlin · DE/EN/中文 │ │ │ │ "Mandarin-speaking subject handling." │ │ │ │ [tryst] [of] [ Requested ] │ │ pending-state CTA │ └───────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ @mira-pdx ★ 4 │ │ │ │ Portland · EN │ │ │ │ "PPV pricing for content-onlyfans." │ │ │ │ [of] [ Connected ] │ │ post-accept state │ └───────────────────────────────────────────┘ │ │ … │ │ [ load more · 23 results ] │ │ │ ├─────────────────────────────────────────────────┤ │ │ 34pt — home indicator └─────────────────────────────────────────────────┘ ``` ## Components | Component | Brief ref | Notes | |---|---|---| | Top bar | AE §AE5 | Back to chat. `[🎤]` = voice query (routes through `ai-copilot` → directory filter set; see Interactions). `[⌥]` opens posture + advanced-filters sheet (see Interactions). | | Searchbar | AE §AE5 | Full-width, debounced 250ms. Searches `handle`, `bio` substring, and `endorsement.scope_claim` text. Never searches `region` literal beyond chip filter (avoids precise-location leak per K3f-2). | | Filter chip row | AE §AE5 + F §F5 | Four canonical chips: surface (multi-select per O surface_kind), language (ISO 639-1 multi), region (country/metro coarse only — never lat/long or city precision), endorses (free-text scope-claim picker). `[ reset ]` clears all. Active chips show count badge. | | Result row | AE §AE5 + AE §AE7 | Handle line + endorsement-count badge (★N — count of accepted public endorsements per AE7). Region + langs line (coarse only). Bio excerpt (2 lines, ellipsis). Surface chips per O. Right-aligned connection CTA whose label reflects current `peer_connections.state` for this pair (per AE2). | | Connection CTA | AE §AE2 | Polymorphic per state: `none → "Connect"`, `requested-by-me → "Requested"` (disabled), `requested-by-them → "Accept ▾"` (drop = accept/decline), `connected → "Connected"` (tap → row context menu), `muted → "Muted"`, `blocked → row hidden`. | | Posture banner | AE §AE11 | Renders above results when viewer's posture ≠ `open` and they navigated here directly. Plain register: "Your posture is `discoverable`. You can browse, but you won't appear in this directory until you switch to open." Tap → settings posture sheet. | | Voice query mic | A §multimodal + AD §AD3 | Push-to-talk; produces a structured filter set ("show me Berlin peers who endorse Tryst optimization") which surfaces as auto-filled chips + searchbar; never auto-executes a connection request. | | PII-filter receipt | AE §AE5 + K §K3 | Invisible-by-default footer line at very bottom of list: "Directory hides government names + precise location." Plain register. Tap → AE11 posture sheet. | ## States 1. **Browse, populated (default)** — opt-in posture is `discoverable` or `open`; ≥1 result rows matching filters. Default sort: endorsement-count desc, then `last_active_at` desc. 2. **Searching / debouncing** — searchbar non-empty, results re-rendering. Subtle top progress bar (1.5pt) under the searchbar; existing rows dim to 50%. No skeleton — too noisy. 3. **No matches** — filters narrow to zero. Working register: "No peers match these filters. Try widening region or removing a surface chip." Includes a single-tap "reset filters" button. 4. **Empty — incognito posture** — viewer's posture is `incognito` (AE11 default). Plain register full-screen takeover: "You're incognito. The directory is hidden until you opt in." Single CTA "Open posture sheet" → AE11 sheet. No peer rows render; this is the privacy floor — incognito viewers should not even see the directory exists is populated. 5. **Empty — posture `discoverable` but no platform-wide rows opted in to surface to you** — populated but coop-only; banner: "Coop-mediated peers only. Switch to `open` for the platform-wide directory." Result rows that exist via shared-coop opt-in still render. 6. **Voice query active** — `[🎤]` long-pressed. Composer-like mic-pulse replaces filter bar; on release, the recognized intent fills chips + searchbar with a 2s "review filters" toast before applying. Mishears: tap the toast to undo. 7. **Filtered to one endorsement scope-claim** — chip "endorses: `bookings-tryst` listing optimization" pinned. Result rows reorder by endorsements-on-this-claim-desc. Subtle helper line below filter row: "Sorted by endorsements on this claim." 8. **Degraded (M §M2b)** — directory backend unreachable. Top banner (yellow, plain): "Peer directory can't reach the server. Showing cached rows from {time}." Cached rows are read-only; Connect CTAs are disabled with hint "back online to send a request." 9. **Offline (M §M2c)** — no cached data: empty state with grey banner "Offline. Directory needs a connection." If cache exists: behave as state 8. 10. **Rate-limited / posture-rate-cap** (AE §AE10) — viewer has sent too many connection requests in the rolling window (anti-spam per AE10). Connect buttons globally disabled with plain-register tooltip: "You've sent 12 requests today. Cooldown until tomorrow morning." Browsing remains live. 11. **Viewer is shadowbanned from directory** (AE §AE10 sanction) — viewer can browse but their own posture is forcibly demoted; banner: "You can't appear in the directory while a moderation review is open." Browsing is unaffected; appearing is. ## Interactions / gestures - **Tap result row** → routes to [`peer-profile.screen.md`](./peer-profile.screen.md). - **Tap Connect CTA** → opens a one-line "context note" sheet (per AE1b referral pattern but for self-introductions); confirm to write a `peer_connections` row in `requested` state. Haptic on commit. CTA flips to `Requested` (disabled). - **Long-press result row** → row context menu: "View profile", "Mute", "Hide from this directory view" (per-viewer hide; doesn't block), "Report". Block is intentionally NOT here — block lives on the profile screen so the viewer reads the bio first. - **Tap surface chip in row** → adds that surface to the filter set; brief flash on the chip row to show the new filter. - **Pull-to-refresh** → re-pulls directory page. Surfaces newly-opt-in peers at the top with a "new" pip for 2s. - **Swipe-down on searchbar from idle** → dismisses keyboard. - **`[🎤]` long-press** → voice query mode (state 6). - **`[⌥]` tap** → posture + advanced-filters sheet (toggle posture, save filter preset, manage muted-peers list). - **VoiceOver order per row** — handle → endorsement-count → region → langs → bio excerpt → surfaces → connection CTA. Most-identifying first. ## In-the-wild copy - (working, empty-no-matches) "No peers match these filters. Try widening region or removing a surface chip." - (plain, incognito empty) "You're incognito. The directory is hidden until you opt in." - (plain, posture-discoverable) "Your posture is `discoverable`. You can browse, but you won't appear in this directory until you switch to open." - (working, voice-query toast) "Filtering: Berlin · Tryst · endorsing listing optimization. Tap to undo." - (plain, degraded) "Peer directory can't reach the server. Showing cached rows from 14:02." - (plain, rate-limited) "You've sent 12 requests today. Cooldown until tomorrow morning." - (working, PII filter footer) "Directory hides government names + precise location." - (plain, shadowbanned) "You can't appear in the directory while a moderation review is open." - (working, connect-context-sheet placeholder) "One line so they know why you're reaching out. Optional." ## Privacy invariants - **No government name field ever rendered.** `peer_profiles.handle` is the only identity; bio passes K3 PII gate at publish time, never at render. - **No precise location.** Region chip is country / metro at coarsest; the renderer ignores any free-text region beyond a known coarse vocabulary. - **No "viewed by" telemetry.** Tapping a row does not write a visible-to-target event. (Audit row exists per I, but the target peer is never notified of directory views — only of connection requests.) - **Blocked peers are invisible to the blocker and the blocked.** A row that exists for the blocker's view will not render for the blocked viewer, and vice versa. Hash-based row filter at query time, not at render — never round-trip blocked rows to client. - **Incognito peers never appear**, even with shared coops. The directory honors `peer_profiles.posture` strictly. - **Voice query never echoes raw recognized text to other peers.** The structured filter set is what travels; the spoken bytes stay on-device + the audit row. ## Edge cases - **Filter chips combine to a single-peer result who happens to be the viewer** → viewer-self row is suppressed; helper line: "Showing 0 matches (excluding you)." - **Bio contains a flagged phrase post-publish** (K3 list updated after publish) → row renders with bio replaced by plain stub: "Bio under review." Peer is notified separately via chat-home receipt. - **Endorsement count includes withdrawn endorsements?** → No. Badge counts only `public=true AND withdrawn_at IS NULL`. Withdrawal updates the count within ~1 vigil per AE9 aggregator cadence. - **Two peers with the same handle** → schema-enforced unique; this state cannot occur. If it ever does (data corruption), render both with a `(disputed)` chip and block any connection action. - **Searchbar query that K3-PII-trips** (viewer types a govt name as query) → query is dropped client-side with a plain-register inline notice: "We don't search by names like that. Try a handle or scope-claim." - **Reduced motion** — replace top progress bar with a static dim of the result list during debounce. - **Dynamic Type XXL** — result row reflows: surface chips wrap to a second line below bio; CTA stays right-aligned but full-width on row when wrapped. - **VoiceOver + voice query** — voice query goes through VoiceOver's mic path, not the directory mic, to avoid double-narration. - **Right-to-left locale** (per AD-Q4) — searchbar mic-icon flips to left; chip row scroll direction mirrors; CTA stays at the row's trailing edge. - **Peer just-opted-in mid-scroll** → "new" pip on insertion; list does not jump-scroll. - **Peer in shared coop has opt-in to `discoverable` but not `open`** → renders only when viewer is also in that coop and viewer's posture ≥ `discoverable`. Filter chip "in coop ▾" is conditionally available if viewer is in ≥1 shared coop. - **`[open]` Bio length limit on the row excerpt** — currently 2 lines truncated. Open whether tap-to-expand-inline is worth the affordance vs. routing to profile. Lean: route to profile; an excerpt is a teaser. - **`[open]` Default sort tie-breakers** — endorsement-count-desc, then `last_active_at` desc. Open whether mutual-coop count should outrank `last_active_at`. ## Related - [Brief AE §AE5](./AE-provider-social-network.brief.md) — directory parent + opt-in posture. - [Brief AE §AE2](./AE-provider-social-network.brief.md) — connection state machine driving CTA labels. - [Brief AE §AE7](./AE-provider-social-network.brief.md) — endorsement-count badge source. - [Brief AE §AE10](./AE-provider-social-network.brief.md) — rate-limit + shadowban states. - [Brief AE §AE11](./AE-provider-social-network.brief.md) — posture floor + privacy invariants. - [`peer-profile.screen.md`](./peer-profile.screen.md) — destination of row tap. - [`chat-home.screen.md`](./chat-home.screen.md) — top-bar overflow entrypoint. - [`coop-drawer.screen.md`](./coop-drawer.screen.md) — sibling drawer; complementary plane. - [Brief K §K3](./K-safety-blocklist.brief.md) — PII gate on bio + search query. - [Brief AD](./AD-multilingual-opaque.brief.md) — locale-invariant filter labels + voice query. - [Brief X](./X-accessibility.brief.md) — VoiceOver row order + reduced motion. - [Brief V](./V-data-portability-erasure.brief.md) — erasure redacts profile + endorsements without deleting connection-state history. - [Brief O](./O-surface-kinds.brief.md) — `surface_kind` vocabulary for chips. - [Brief F §F5](./00-system-visual-system.md) — surface-chip iconography. - [`00-system-voice.md`](./00-system-voice.md) §V2b working / §V2c plain. ## Out of scope - The posture + advanced-filters sheet interior (its own `.screen.md` if it grows beyond toggles). - Coop-mediated peer-roster sub-tab (lives inside `coop-drawer.screen.md`). - The introduction flow from AE1b referral (separate screen — a referral-introduce sheet). - Cross-org directory rows for org-attributed peer connections (deferred to AE-Q6 P5+). - iPad / web variants. ## Open questions - **Default sort tie-breaker** — endorsement-count-desc → `last_active_at` desc → mutual-coop count? Or mutual-coop count second? `[nice-to-have]` (lean: `last_active_at` second; mutual-coop count is its own filter chip). - **Voice query review-toast window** — 2s feels short for non-native-language voice input. `[exploratory]` (lean: 2s for confident parses, 4s for any chip below 0.75 confidence per AD3 threshold). - **`[open]` Can endorsement-count badge be tapped to surface which scope-claims drive the count?** `[exploratory]` (lean: yes, but on the profile screen, not the directory row — keep rows scannable).