← room2

Friends — the contact layer, and the four rootsphases 1–5 shipped · 2026-08-09

WeChat's request-and-accept, studied and ported onto our invite-gated pool — plus the IA move it forces: the app's roots become a bottom bar of four (Chats · Friends · Discover · Me), which is where every proven messenger has put them. This page is gap 13 of Proven · Better · New, taken from a card to a build. Opened 2026-08-08.

the word · owner call, 2026-08-09 We no longer call these things rooms. The product word is「chat」 — a chat, a group chat, a private chat, Shared chats, chat-mates (the people you share one with). 「Room」 survives only as the internal name: the table, the routes, the folder on disk, the project's own nickname. That split is deliberate and this page keeps it — every room you still see below is a real identifier quoted accurately, never a word a user reads.

The debt this exposes: the live app still says 「room」 in about 25 user-facing strings"leave this room", "This room is full", "A line about you — shown to people you share rooms with", "Shared rooms". That is a separate sweep, and not a small one: in this codebase the English string is the translation key, so renaming them edits the dictionary too. Not folded into the friend layer; flagged here so it doesn't get lost.
1 · the one open door 2 · WeChat, studied 3 · three models 4 · ours 5 · the data shape 6 · personas 7 · the four roots 7b · the fold layout 8 · mockups 9 · what flips 10 · build order

1 · The one open door

The app already has a philosophy about reach, and it is written down in two places. db.shares_room(): "no global user directory — you can only message people you already share a room with." db.contacts(): "an invite-gated app keeps discovery social." Both are enforced. The DM gate, the profile page and the people picker all read them.

Then there is one query that doesn't. db.users_not_in_room() (lib/db.py:502) returns the whole users table, and GET /api/rooms/{id}/invitable serves it to any member of any chat. Its own docstring admits it: "Friend-scoping will narrow this later; for now it's the whole user table."

That one door is enough to void the other two, because being in a chat together is what grants the rest:

any member of any one chat /invitable users_not_in_room() = THE WHOLE USER TABLE add them by username shares_room() is now true — forever DM key PROFILE CONTACTS The rest of the gate is sound — the app-level _gate already refuses any /api/rooms/<id>/… path to a non-member, and /api/user/<uid>/profile is chat-mate-scoped. The leak is not the walls. It is that anyone can open a door in one.
fig 1 · the chain — one un-narrowed query is the whole reach model
So the friend layer is not a new philosophy. It is the missing half of one the code already believes: my people persist beyond any chat, and a stranger cannot pull me into one. Everything else here follows from closing that query.

2 · WeChat, studied

Sources at the foot of the page. The mechanics that matter, in the order a relationship passes through them.

MechanismWeChat's nameWhat it actually does
The pool微信号Closed. There is no browsable directory — a person is reachable only through a handle you already hold, a code you scanned, or a chat you're both in.
Findability switches添加我的方式Every add-path is its own on/off toggle — WeChat ID, phone number, group chat, QR code, name card. Not one privacy level: six independent doors, each closable.
The verification message验证申请A short free-text note that rides the request, defaulted to your name and meant to be edited. This is the whole ceremony — one sentence of "who I am and why".
The approval toggle加我为好友时需要验证On by default. Off means anyone who finds you is added with no ceremony at all — the setting exists, and almost nobody turns it off.
Accept接受 / 忽略One tap. Ignore is the other button, and it is silent — the sender is never told no; the request simply never resolves.
The private rename备注At accept (or any time after) you set a remark name that only you see, plus tags, a description, a phone. The most under-copied feature in the whole app: it makes a contact list survive 500 people.
Tiered friendship仅聊天A friend can be marked chat-only — messages, calls and transfers, but no Moments, no status. A second citizenship inside "friend", set per person and changeable later.
Group ≠ friends群聊Law. Sharing a group never makes you contacts. You can talk in the chat; adding each other from the member list is still a request.
Removing删除Unilateral and silent. The famous consequence: WeChat stores two directed edges, so the other side still thinks you're friends until a message bounces.
Blocking加入黑名单Also silent. Denies messages and Moments in both directions without ever saying so.
The second citizen公众号Official Accounts sit in the same Contacts tab under their own header — but the verb is follow, not friend: no approval, one-way, unfollow is silent. A whole parallel relationship type, filed beside people.
the detail everyone misses WeChat's request ceremony is one sentence long. There is no form, no reason-code, no category. The note is optional-in-practice and pre-filled. What makes the ceremony work is not its weight — it's that it is asymmetric in time: the asker spends three seconds, the answerer spends one tap, and the pool stays closed the whole time. Any design that makes the request heavier is copying the ritual and missing the mechanism.

3 · Three models, one axis

The axis is where the friction sits: before reach (WeChat), after reach (WhatsApp), or nowhere at all but reach is gated on reciprocation (Snapchat).

DimensionWhatsAppWeChatSnapchatOurs — proposed
The poolevery phone number on earth, openclosed; per-path findability switchesclosed-ish; username + Snapcodeinvite-gated accounts (unchanged)
Becoming friendsaddress-book sync — no ceremony, one-way is finerequest + note → accept; symmetricone-way add; "Added Me" list; no decline buttonrequest + note → accept, on the notifications rail
Cold DMallowed; block is downstreamforbidden until acceptedgated on reciprocationfriends ∪ chat-mates — nothing else
Add pathsknowing the number is the pathhandle · QR · group member · name cardusername · Snapcode · Quick Addchat-mate's profile · personal link/QR · handle search behind a findable toggle
Group ≠ friendsn/alawn/akept as law
Private renamefrom your address book备注 — first-classdisplay-name overrideyes — a remark per friend, phase 4
Tiered friendno (privacy tiers instead)仅聊天nonot v1 — recorded, see §4
Removingdelete from address book; silentunilateral, silent, the bounceremove; silentunilateral, silent, no bounce
Decliningn/aIgnore — sender never toldno such button by designIgnore — sender never told
snap's third way, kept on file Snapchat's variant deletes the accept ceremony entirely: you add one-way, they see you in Added Me, and messaging unlocks only on reciprocation. Nobody ever has to reject anyone — "decline" isn't a button, it's the absence of an action. We take WeChat's note-carrying request instead, because in an invite-gated pool the note does real work (it carries which chat we met in). But if the ceremony reads heavy in testing, Snap's variant is one deletion from this design, not a redesign: drop the Accept button, rename the section "Added me", and reciprocation becomes the accept. Its Quick Add — mutual-friend suggestions — is the cautionary half: it drove Snap's growth and earned the minor-safety retrofits that followed. We don't build it. Ever.

4 · Ours — the blend, and one correction

WeChat's ceremony on our invite-gated pool — request + one-line note → one-tap accept, symmetric while it lasts, unilateral silent removal — with WhatsApp's lightness where it is safe: inside a chat you already share, the request arrives pre-loaded with its context and there is no interrogation wall.

The correction gap 13 needs

The gap-13 card records two rules that cannot both hold: "one symmetric friendship row per pair" and "unilateral remove, silent (the proven mercy)." With a single shared state, my removal rewrites your list — that is not unilateral, and the mercy it names is an artifact of WeChat storing two directed edges, not of symmetry. Three ways out:

OptionWhat you getWhat it costs
a · two directed edges
WeChat exactly
Removal touches only my edge; the other side keeps theirs. True unilateral.You must then build the bounce — a dead-letter path, a rejected-message bubble, and an error state a person discovers by being humiliated in it. WeChat's single most disliked behaviour.
b · one shared state
the card as written
Simplest possible table.Not unilateral. My housekeeping edits your list, and the same cell is written by two people — the exact shape room_members.status was split apart to avoid (v515).
c · one row, two per-side states
the pick
Each side owns its own cell. Removal writes only mine. The pair stops being live for both — so no bounce path can exist — and nobody is ever notified. Block folds into the same cell.Every read branches on lo/hi. Exactly what dm_pairs already does, and one helper hides it.
what we deliberately don't reproduce In WeChat, a removed friend doesn't know until a message bounces — they keep you in their list for months. We drop that. The other side's row simply stops being there, with no notice, no event and no explanation. It is the same mercy (nobody is ever told "X removed you") without the one machine that makes WeChat's version cruel.

Chat-only (仅聊天) — recorded, not built

WeChat's second tier is genuinely good and we have the surface it would govern (Vibes, the profile page, the persona feed). It is not v1: a tier is only meaningful once there's something behind it worth withholding, and until the "My friends" privacy tier of gap 11 exists there is nothing to withhold. The per-side state column has room for it — ok becomes ok | ok_chat — so adding it later is a value, not a migration.

5 · The data shape

One row per pair, keyed the way dm_pairs is keyed. Each side owns its own state cell — that single decision is what makes removal unilateral, decline silent, and block free.

-- lib/db.py · SCHEMA CREATE TABLE IF NOT EXISTS friendships ( user_lo INTEGER NOT NULL, -- smaller user id of the pair (dm_pairs' convention) user_hi INTEGER NOT NULL, lo_state TEXT NOT NULL, -- ok | pending | gone | blocked — LO's own cell, written only by LO hi_state TEXT NOT NULL, -- …and HI's lo_remark TEXT, -- 备注: LO's private name for HI (phase 4) hi_remark TEXT, asked_by INTEGER, -- who sent the live request note TEXT, -- the verification message, ~140 chars via TEXT, -- context: 'room:<id>' | 'link' | 'search' — renders as "via Flat-hunt crew" asked TEXT, -- ISO ts of the current request (drives the re-ask cooldown) since TEXT, -- stamped the first time both cells read ok PRIMARY KEY (user_lo, user_hi) ); CREATE INDEX IF NOT EXISTS idx_friend_lo ON friendships(user_lo, lo_state); CREATE INDEX IF NOT EXISTS idx_friend_hi ON friendships(user_hi, hi_state);

The predicate is one line, and it is the only thing the rest of the app ever asks:

friends(a, b) ⟺ lo_state == 'ok' AND hi_state == 'ok' may_reach(a, b) ⟺ friends(a, b) OR shares_room(a, b) — and NEITHER cell is 'blocked'

The state machine

a fresh ask flips the other cell back to pending — once per 7 days strangers NO ROW A asks + NOTE + VIA requested A: ok · B: pending B accepts friends A: ok · B: ok either removes parted one cell = gone B ignores → B's cell becomes gone. A is never told; A's screen keeps reading REQUESTED. blocked my cell = blocked Reachable from any state and never announced — the one state a re-ask cannot leave. It is not a fifth box on the line; it is a value in the same cell as the other four.
fig 2 · four states, and every transition writes exactly one cell
why the row survives a parting Keeping the row after gone buys three things a DELETE would throw away: the re-ask cooldown (a declined request cannot be re-sent for 7 days — the one anti-nag rule this design needs), the block that must outlive the friendship, and the fact that re-friending doesn't lose the remark you'd already written. A row per pair that ever interacted is a rounding error in an invite-gated pool.

The API

RouteDoes
GET /api/friendsThe Friends root in one read: {requests[], friends[], personas[]} — requests carry note + via + the requester's card.
POST /api/friends/ask{user_id, note, via}. Refuses if blocked, if the cooldown is live, or if the target isn't reachable by the path claimed.
POST /api/friends/acceptOne tap. Writes my cell to ok, stamps since, notifies the asker only.
POST /api/friends/ignoreWrites my cell to gone. Notifies nobody. Returns 200 either way — a decline must be indistinguishable from a slow answer.
DELETE /api/friends/{uid}Unilateral remove. Writes my cell. Silent.
POST /api/friends/{uid}/blockWrites my cell to blocked. Silent. Drops the DM key in both directions and denies future asks.
POST /api/personas/{slug}/followFriend a persona — no approval. DELETE to unfriend. ⚠ v892 gave this route a UI rather than a second endpoint: 「follow」in the code and「friend」in the UI are one act. See §6.

On the rail. A request is a friend_request notice with an Accept verb — the noticeSpec pattern the rail already has, ckey=friend:<uid> so a re-ask collapses onto the same row instead of stacking. It is also our second organic push payload (gap 1 gives it the lock screen): the FarmVille neighbour bar, round two — the request is the come-back reminder.

6 · Personas are the second citizen class

The owner's ask — "show friends list and persona list in Friends" — lands on a slot WeChat already built and named. Official Accounts live in the Contacts tab, under their own header, with a different verb. Personas are ours:

A human friendA personaWeChat's precedent
The verbAdd friend → requestFriend — instant, no approval (v892)关注 (follow)
Symmetrysymmetric while liveone-way, alwaysone-way
Removingunilateral, silentunilateral, silent — there is no one to hurt取消关注
Reach it grantsDM beyond chatsa private chat, seating that personathe account's messages
Where it's filedthe A–Z listthe same A–Z list (v723)公众号, own header
Where the rest liveAll persona, the root's one sub-page (v892)公众号 › 更多
v892 · one axis, and「met」stops meaning「kept」 Two changes land together and each one needs the other. ① Favourites are cancelled (owner). A contact list turned out to need one axis, not two — you friend someone or you don't — and a star on top of that was a second ranking nobody was maintaining. The favourites table is retired (rows left in place; nothing reads them) and the Fav pill goes with it. ② The follow table becomes the ONLY source of the persona half. v724 read follows ∪ everyone you had met, and that was right at the time for exactly one reason: there was no friend button anywhere in the app, so the follow half was empty by construction and「met」was the only signal available. All persona is that button. So the union is retired — a contact list you cannot curate is a log, and being seated beside someone in one chat is not a decision to keep them.

The thing the union was hiding: unfriending could not work. Under v724, unfollowing a persona you shared a room with did nothing at all — the next read handed it straight back. The smoke-test check for it had to exempt that case to stay green, which is the tell. The exemption is gone with the union.
v893b · nothing is friended by MEETING — the migration is retired v892 shipped a one-time「met becomes friended」conversion so no existing list would visibly empty on the ship. The owner retired it three days later:「if the users have met a persona in a group chat, don't add that persona as friend automatically. The friend action should not be automatic, unless the persona is created by the user in the studio.

Meeting someone is not deciding to keep them — and a contact list that fills itself is the log v892 set out to stop being. users.friends_backfilled stays as an unread column (dropping it would rewrite every existing DB to save nothing), and rows the conversion already wrote are left alone: they are ordinary follows now, and unfriending them works.

The check that matters is the one that runs after an unfriend. A list that refills itself from the rooms you are in is indistinguishable, from the outside, from an unfriend that does not work — which is exactly how a re-running conversion would present. So the smoke test empties the list, confirms the seated personas do not come back, and reads twice.

The one automatic friending left is the Studio's, and it is not automatic in the sense the rule rejects: meeting a persona happens to you; building one is the decision, and there is no second act left to ask for. It is written in _notify_build_end, where the build's owner is known for certain, and it follows-never-fails — a contact row is not worth losing a persona over. Driven end-to-end in the smoke test through the real /api/build/start route with the admin fake-build fixture.
the storage trap Personas have no DB rows by design — disk is the registry (prototype/personas/personas-built/). So the follow table is slug-keyed with no foreign key, the way vibes_likes and hidden_msgs already are, and the read path resolves each slug through run_room.persona_dir() and filters it through discoverable(slug, uid). A follow whose persona was deleted or hidden renders as nothing — never as a broken row, never as a 404.
CREATE TABLE IF NOT EXISTS persona_follows ( user_id INTEGER NOT NULL, slug TEXT NOT NULL, -- NO FK: disk is the registry added TEXT NOT NULL, PRIMARY KEY (user_id, slug) );

Seeding it costs nothing: the Me sheet already computes "Personas you've met" (meVoices). On first open of the Friends root, every met persona is offered as a one-tap add — the list is populated before the user has done any work, which is the only way a contact list ever survives its first screen. v892 made this literal: the seeding is the backfill above, and it writes rather than offers.

§6b · All persona — the root's one sub-page (v892)

Friends holds the personas you friended; All persona holds every one you could. It is a canonical sub-page — a .drw with the shared ‹, a plain .drw-body so the column default reaches it, registered with the back stack — and its body is 「Add to panel」's body with the one thing that made that page a picker taken out: no tick, because nothing is being selected, and no footer. Same search row, same A–Z / Popularity / Recent / Studio pills (sharing the picker's sort state), same persona rows the Friends list builds.

The Studio door beside it (v1038 → v1046 → v1061). The Persona studio row moved into Friends at v1038 and took All persona's geometry at v1046 — the 40px squircle lead, one label column, one 60px height — while keeping its gold tile and white pen. v1061 (owner:「make it the similar circular icon like that of All persona, but depict a persona being built」) finishes the pairing: the door sits on the same soft ground as All persona, and its glyph is All persona's own figure in teal with the far shoulder left open and a gold pencil at the open end — gold, the colour that has always said Studio, now inside the drawing as the tool doing the making. Six glyphs were mocked (half-drawn · pencil · corner brackets · three-quarter ring · sparkles · dotted body); the two dotted ones lost their dots at row size, the pencil read at every size. Same change: on wide, the row now wears the coral current-mark while the Studio page is open on the right, exactly as the All persona door and a profile row do. Two lessons rode along: a door that sweeps on open (openDrawer, the v868 left-pane click) rewrites class on pages that are already shut, and a mutation observer that cleared the mark on「profile not open」wiped a fresh mark a microtask later — a close is an open→shut transition, not a rewrite; and the row's click is wired far from the list, so the mark is one extra listener on the static node, which travels with it between its parking slot and the list.

SurfaceWhat the row's TRAILING SLOT holds
Add to panel (picking)✓ the tick
All persona (browsing)★ if already a friend — gold, see below
Friends (your contacts)nothing
A persona's profile (deciding)★ at the hero's right edge — the one pressable star (v893)
v893 · gold, not coral — and one place a star may be pressed Coral is this app's ACTION colour — a live pill, a commit button, a selected tick all wear it — so a coral ★ read as a control the reader had switched on rather than a fact about the persona. Gold is the marking colour, and it is what a star is everywhere else.

A row's star is a badge; a profile's star is the verb. On a row the star cannot be pressed (the verb is the held menu) because a row is a list item and a list of 87 tap-targets-inside-tap-targets is a mis-tap machine. A profile page is where you decide about one contact and has room for a control, so there the star IS the toggle — outline → filled, one tap, at a fixed position (the hero's right edge, never trailing the name: a mark that moves with the length of a name cannot be aimed at).

§6c · The friend pill, and「Add time」(v893)

The Studio pill is gone from both All persona and Add to panel; a friend pill takes the right edge of the sort band on each. It cycles three ways — All → ★ Friends → ☆ Others — because those are three answers to one question and only one can be true at a time. It sits right because it is a different KIND of control from the three pills beside it: those order the list, this one shortens it. It resets on every open: a filter you did not set is a filter that makes a full library look empty.

「Add time」on a persona's page is now the last friend time, not the card's created. That was when the persona was made — a fact about the persona — where a contact page's Add time has always been a fact about the reader: when this entered my contacts. A persona you have not friended has no add time and the row says「—」rather than lying with a build date. ⚠ persona_follow had to stop being INSERT OR IGNORE: it keeps the original row, so a persona friended, dropped and friended again would report its first add time forever, and「last friend time」would quietly mean「first」. It upserts the timestamp now, and a smoke-test check holds it.

⚠ the v892 defect this shipped to kill Owner:「the unfriend in All persona page is not taking effect for some persona… but when I unfriend him in the friend list it took effect, and then I can friend/unfriend him properly in the All Persona page.」

All persona derived「is this a friend?」from the FRIENDS LIST'S OWN PAYLOAD. That payload is refetched, re-filtered by the server, narrowed by whatever query the Friends search happened to hold, and empty entirely until that page has been opened once. So the ★ and the row's menu answered from a copy that could be stale, filtered, or absent — and「unfriend」then ran against the wrong belief. Unfriending from the Friends list happened to fix it because it forced a clean unqueried refetch, which is exactly why it looked persona-specific rather than structural.

Deriving one page's state from another page's cache is the whole defect. There is one set nowPFRIEND, in the main scope — and every surface reads it: the badge, the pill, the row menu, the profile toggle. The one write path is setPersonaFriend, which mutates the set synchronously before the request leaves (so the star goes in the same frame — owner: the star should be immediately removed) and rolls back if the server refuses. Optimism is safe here precisely because a persona always says yes: the only real failure is the network. ⚠ Only the unqueried read feeds the set — adopting a search response would announce that every persona which failed to match「darwin」is no longer a friend, which is the same class of bug one layer down.
the ★ — one slot, one meaning per surface The owner asked the two questions that decide this: what about when we need the checkbox? and what about a list mixing personas with humans?

The tick never collides with the star because picking and browsing are different pages, and neither ever shows the other's mark — the slot is a state slot holding exactly one thing per surface, never two.

The mixed list is the sharper one. If ★ means friend, a starred persona beside an un-starred human is a lie — and in Friends every human IS a friend. The resolution is that the star is painted on this page only, which is the one list in the app that mixes friended personas with un-friended ones, i.e. the one list where the mark has a question to answer. The alternative considered and set aside: move the mark to the avatar corner and badge every friend, human included — future-proof against a mixed pickable list, at the cost of an ~11px notch on a coloured disc. Owner's call: keep the scannable column, accept that a future mixed surface re-opens the question.

The star is not a button. Friending is the row's held menu — one verb in one place. A pressable star would be a second friend door onto the same table, and the two would have to agree about a slug forever.

7 · The four roots

The friend layer needs a home, and the app has run out of top-strip. Today's roots are Notes · Chats · Studio · Vibes in a header strip, with Me hidden behind a hamburger. That's five roots in two different places, one of them invisible. The move is the one every proven messenger made: four roots, at the bottom, always visible.

TODAY — 5 roots, 2 places, 1 of them invisible HEADER STRIP + A HAMBURGER Chatsstays a root Vibes→ Discover Studio→ Discover Notes→ Me MeBEHIND ≡ promoted to a root no friend layer at allthe new root PROPOSED — 4 roots, one bar, always visible BOTTOM BAR (FOOT OF THE PANE ON WIDE) 1 · Chats the list, the unread badge, the Call-the-panel pill — unchanged 2 · Friends requests on top · personas · then people, A–Z 3 · Discover everything that isn't yours yet — Vibes · Personas · Studio · Scan 4 · Me everything that is — Notes · Notifications · your code · preferences THE ≡ HAMBURGER IS DELETED — ITS ONLY JOB WAS OPENING ME
fig 3 · the remap — Notes lands in Me because that is where Favourites has always lived
TodayTomorrowWeChat's own slotWhy
Chats (header tab)Chats — root 1微信Unchanged. The list, the unread badge, the "Call the panel" pill.
Friends — root 2通讯录Requests on top, then ONE roster — personas and people filed together, A–Z, with an index rail (v704: full height · the whole alphabet always · drag to scrub). v723 (owner): personas are no longer a block of their own. They are the same object here — someone you hold, whose page you open, whom you can write to — and a「Personas · N」heading made them a category you look up rather than a name you scan for, filing them all under P in effect whatever they are called. Now Ada Lovelace sits between Ada and Alice. The ROW still says which kind it is (a persona wears its card colour, a person wears their face); the chevron went with the split, since a person's row never had one and opens a page just the same. The Friends search follows: one roster by name, not personas-then-people. v724 — and the persona half now has a SOURCE. It read the follow table alone, and nothing in the app has ever written to that table: there is no follow button, so every user's persona half was empty by construction (owner:「I don't see any persona in enovy's friend's list」— enovy followed none, because nobody can). A persona cannot accept a request, so the human rule has no analogue; what does have one is HAVING TALKED. A persona seated in a chat of yours is someone you have met, which is what a contact list is for. Explicit follows are still unioned in, so a follow door stays meaningful if one is built. ⚠ discoverable() still rules — a private persona someone else seated beside you keeps working in that room and stays out of this list: seating survives, discovery does not. v892/v893 (owner) — the layout is now: a frozen SEARCH ROW · the KIND pills (All | People | Persona) · the「All persona」door · the A–Z roster. Fav went because favourites are cancelled and Studio moved to All persona, which is where an un-friended persona can now be found at all; People and Persona came back at v893 as three positions of one question rather than four independent switches — and「All」is a position, which is the difference from v847, where clearing a filter meant tapping the lit pill again and a filter you clear by re-pressing is one you can leave on without noticing. ⚠ The door's lead is the only one in the list that is not a disc: every other lead stands for exactly one contact, this one stands for the whole shelf, so it is a squircle carrying two figures in two colours — a persona wears ONE card colour, so a single-colour figure would read as one more persona. The follow door v724 said「stays meaningful if one is built」was built, and building it retired the union — see §6.
Vibes (header tab)Discover › Vibes发现 › 朋友圈Moments has never been a root. It is the first row of Discover, and always has been.
Studio (header tab)Discover › Studio发现 › 小程序A factory you visit occasionally is not a root. It's the strongest row in Discover.
the new-chat pickerDiscover › Personas发现 › 搜一搜Browsing the cast is discovery. Personas you've added live in Friends; the whole shelf lives here.
Notes (header tab)Me › Notes我 › 收藏Notes is saved lines from every chat — that is Favourites, and Favourites has lived under Me for a decade. Not a demotion; the proven slot.
Me (behind ≡)Me — root 4Promoted out of the hamburger. Hero · Notes · Notifications · Preferences · Admin.
≡ hamburgerdeletedIts only job was opening Me. The notification dot moves to the Me tab.

Three things that break, and how

1 · the pill is retired — owner call, 2026-08-08 The "Call the panel" capsule sat at the bottom of the landing, exactly where the tab bar wants to be. The first draft of this page tried to choreograph the two — pill above the bar at rest, bar sliding out when the composer arms. The owner deleted the pill instead, which is the better answer: it removes the collision rather than timing it, and it removes with it the whole arm-and-collapse dance, the reservation arithmetic, and the v657→v658 trap that arithmetic invites (never size a reservation from a cached measurement of the thing you are making room beside).

Three sites go, not one. The landing capsule; its muted twin that appears on wide with a chat open; and the small echo of it nested inside the hand-pick dialog's empty state. The ✎ in the Chats header becomes the only door to a new chat — one affordance, in the corner every messenger keeps it in — and the dialog it opens is now titled "New chat" (shipped 2026-08-08; the dialog's own redesign is deferred).

Sequencing, deliberately: the pill leaves the app in the same change as the bar, not before. Pulling it early would leave the landing with a 21px icon as its only primary door and nothing at the bottom of the screen at all.
2 · the back stack Roots don't register — that's the v525 owner call, and Chats/Vibes/Studio/Notes already obey it. The one migration: mePage is registered today as a back layer. Promoted to a root it must be de-registered, or Back from Me will return to Chats instead of exiting the app — which is exactly the bug v521 introduced and v525 reverted, arriving a second time through a different door.
3 · wide layout — settled by the reference On wide the bar lives at the foot of the left pane, not the window. That was a guess in the first draft; the owner's fold screenshots confirm it is exactly what WeChat does on a Galaxy Z Fold unfolded — the four buttons sit under the list, and the right pane never carries them. Same markup, same four buttons, one @media rule. No left icon rail: we are not building a desktop app, we are building a phone app that survives a big screen.

The rule the screenshots actually teach is the one below — the right pane belongs to the root, not to the app — and it is a real change to how our wide layout works today. It has its own section: §7b.

§7b · The right pane belongs to the root

Today our wide layout has one right pane and it is always the chat: the left pane's tabs swap the list, the conversation on the right stays put. WeChat's fold layout does something different, and all three reference screenshots show it — tapping a root replaces the right pane too. Contacts puts a person's profile there. Discover puts the section you picked. Me puts Favourites.

RootLeft pane — the listRight pane — the detail
Chatsthe chat listthe open conversation (today's arrangement, unchanged)
Friendsrequests · personas · people A–Zthe selected profile — hero, details, shared chats, Message
Discoverthe four rows: Vibes · Personas · Studio · Scanwhichever row is selected, full height
Meyour hero + the settings rowsNotes · Notifications · Preferences — whichever is open
the one addition it needs Four roots each owning a right pane means the right pane needs four remembered slots, not one: leave Chats mid-conversation, look someone up in Friends, come back — and the conversation must still be there, scrolled where you left it. That is the whole cost of the change, and it is what makes the layout feel like WeChat's rather than like a sidebar that keeps blowing away your chat. Each slot also needs an empty state for "nothing picked yet" — the one screen the phone layout never has to draw.
§7b is NOT in the phase-3 ship · 2026-08-09 The bar landed; the per-root right pane did not, deliberately. On wide today each root already gets the left pane to itself and the chat stays put on the right — a working layout, and the one the app has always had. Converting the right pane to be root-owned means four remembered slots and four empty states, and it rewrites the assumption 21 places in the code still hold (“the right side is the chat”). Bundling that with the navigation change would have put a rewrite of a working layout behind the same revert. It is B2, and nothing in the shipped bar blocks it.

The left pane keeps its resizer. The existing drag handle and double-click-to-reset stay; the fold's near-even split is simply where the default lands when the window is that wide. Nothing about this needs a fold to be worth having — it is the same layout on a laptop, just with a narrower left column.

Badges

Four roots, four counters, all of which already exist somewhere: Chats = the unread count · Friends = pending requests (new) · Discover = the Vibes dot and the Studio cook-dot, folded into one · Me = navNotifDot, moved off the dying hamburger.

8 · Mockups

375px. House palette, house type. The bar is the same four buttons on every screen.

1 · chats — the bar arrives
9:41● ● ●
Chats
F
Flat-hunt crew
老周: 那份对比表我明天发
3
M
Mary
ok — 我看完再说
09:12
Chestnut
Three ways to read that clause…
Tue
B
Book club · 4
Leo: 下周三行吗
Mon
Chats
Friends2
Discover
Me
2 · friends — requests · personas · people
9:41● ● ●
Friends🔍  ⊕
New requests · 2
K
Kenvia Flat-hunt crew
"Mary 说你手里有那份贷款对比表——求一份"
Accept
S
Saravia link
"Leo gave me your link — the Tuesday reading group?"
Accept
Personas · 4
Chestnut
The clause, three ways.
R
Rams
Less, but better.
阿测
测试用,随叫随到。
Friends · 3
M
Mary
Renovating. Slow replies.
老周 (邻居)
看盘中,勿扰
L
Leo
#ACLMS
Chats
Friends
Discover
Me
3 · the ask — from a chat-mate's profile
9:41● ● ●
Ken
K
Ken
@kenz
Two kids, one mortgage, no time.
Shared chats
FFlat-hunt crew
Add Ken to your friends
Mary 说你手里有那份贷款对比表——求一份
VIA · FLAT-HUNT CREW  — they'll see where you met
Send request
Not now
4 · the + — three doors, no directory
9:41● ● ●
Friends🔍  ⊕
New requests · 2
KKen
SSara
Add someone
Scan a code
My code & link
@Search by handle
Let people find me by handle
off — only your link and chats reach you
5 · discover — everything not yours yet
9:41● ● ●
Discover
Vibes
Personas
Studio
Scan
From Vibes
Chestnut
"人人都说要读合同,没人说该读哪一句。"
Join
New in the cast
D
Dan Wang
Industry, seen from the factory floor.
P
Perel
The question under the question.
Chats
Friends
Discover
Me
6 · me — notes comes home
9:41● ● ●
C
CW
@cw · 87 days
Reading contracts so you don't have to.
Notes142 saved
Notifications3 new
My code & link
Preferences
Privacy & friendswho can add me
Last fortnight
Chats
Friends
Discover
Me
reading the mocks Requests sit on top and carry their context. "via Flat-hunt crew" is WeChat's grammar exactly — the note answers why, the via answers where we met, and between them an accept is one tap with nothing to work out. Ignore is the absence of a tap; there is no decline button and the sender is never told.

Personas are filed above people, in their own section with square avatars — the shape rule the app already uses to say "this is not a human". They arrive pre-populated from the personas you've met, so the list is never empty on first open.

The + has three doors and no directory. Scan, my code/link, handle search — and the findable toggle right there under them, so the one path that could be brute-forced is the only one behind a switch at all. The default flipped ON at v680 — see the correction below.

Me is where everything of yours lives — Notes (Favourites), Notifications, your code, preferences, and the new privacy row that owns who can add me to a chat.

No pill. The bottom strip belongs to the bar alone; the ✎ in the header is the only door to a new chat.

Wide — the fold, after WeChat

Three panes-of-two, drawn against the owner's Galaxy Z Fold reference. The constants across all three: the tab bar sits under the left pane only, the right pane carries its own back-chevron header, and each root brings its own right pane.

w1 · friends — the list left, the person right
Friends🔍  +
New requests · 2
K
Ken
via Flat-hunt crew
S
Sara
via link
Personas · 4
Chestnut
RRams
Friends · 3
MMary
老周
LLeo
#ACLMS
Chats
Friends
Discover
Me
···
老周
@laozhou · 邻居
看盘中,勿扰
Friend details
remark name, tags, and what they can see of you
Shared chats · 2
FFlat-hunt crew
BBook club
Message privately
Add to a chat
w2 · discover — four rows left, the section right
Discover🔍  +
Vibes
Personas
Studio
Scan
Chats
Friends
Discover
Me
Personas
Search the cast…
Recently with you
Chestnut
RRams
阿测
PPerel
New in the cast
D
Dan Wang
Industry, seen from the factory floor.
T
Taku
Prep, not advice.
w3 · me — the rows left, notes right
C
CW
@cw
Notes
Notifications
My code & link
Preferences
Privacy & friends
Chats
Friends
Discover
Me
Notes
Search
RecentFrom personasMineLinks
"The clause you want is the one nobody reads aloud — the indemnity, not the price."
CHESTNUT · FLAT-HUNT CREW · JUL 29
"Less, but better" is not a style. It is a subtraction you have to be able to defend.
RAMS · DESIGN TALK · JUN 8
老周: 那份贷款对比表,看第三列就够了
老周 · FLAT-HUNT CREW · MAY 10
what the fold reference settles The bar is the left pane's, not the window's — visible in all three screenshots, and it is what lets the right pane be a full, uninterrupted page. The right pane has its own header: back chevron left, title centred, one action right. And the detail: the left pane's selected row stays highlighted while its detail is open on the right, which is the only thing telling you what you are looking at once the right pane's title bar is empty (WeChat leaves the contact-profile title blank — see w1).

The one thing we don't copy: WeChat's right pane opens empty and stays empty until you tap something. On Chats that would throw away the conversation you were just in, so our Chats root restores its last open chat instead — which is what our wide layout already does today. The other three roots can open empty; only Chats has something worth remembering by default.

9 · What flips when it ships

Every call site that asks "can these two reach each other" today, and what it asks tomorrow. The migration is one predicate swap, plus one query that stops returning the world.

SurfaceTodayAfter
GET /api/rooms/{id}/invitablethe whole user tablefriends ∪ chat-mates, minus anyone blocked either way. The door in fig 1, closed.
POST /api/dmshares_roomfriends ∪ chat-mates, minus blocked. Chat-mates keep the door they have — WeChat's group temp-chat is the proven precedent and it is already shipped behaviour.
GET /api/contactschat-matesfriends first, then chat-mates — same payload shape, so the people picker needs no change.
GET /api/user/{uid}/profilechat-mates onlyfriends ∪ chat-mates. A non-friend chat-mate sees the card; the "My friends" privacy tier of gap 11 becomes expressible for the first time.
POST /api/rooms/{id}/membersany usernamemust pass the same predicate — "who can pull me into a chat" stops being an open door and becomes a setting.
db.shares_room()the reach predicatedemoted to one input of may_reach(). It stays; it stops being the whole answer.
blockdoesn't existbuildable — one cell value. This closes the debt the v515 duo-exit note recorded: "real prevention waits for block."
noticesa friend_request kind with an Accept verb, ckey per requester; the rail's third mirror gives it the lock screen.
the migration is quiet On the day it ships, nothing visibly narrows for anyone: every existing chat-mate keeps every door they had. What changes is that the pool stops being enumerable — you can no longer reach someone you have never met. Which means Phase 1 can ship with no UI at all, and be verified by the smoke test alone.

10 · Build order

PhaseWhatWhy here
1 ✓The table + the predicate. friendships, persona_follows, may_reach(), and invitable narrowed. No UI.Shipped 2026-08-09. Fig 1's door is shut — and it took TWO changes, not one: /invitable now serves the caller's own reach, and POST …/members enforces the same predicate, because that route took any username and would have made the narrowed picker cosmetic. 26 smoke-test checks, the load-bearing ones being refusals.
2 ✓The Friends page — requests carrying their note and context, accept/ignore, the list, personas-as-follows, the rail notice. A page off the Me sheet, as planned.Shipped 2026-08-09. One door came forward from phase 4: Add friend on a chat-mate's profile — phase 2 is untestable without some way to create a request, and it is the one add-path that needed no new machinery. The link, the code, handle-search and the note composer stay in phase 4, so a request sent from a profile today carries its context but no message.
3 ✓The tab bar. Four roots, Notes→Me, Vibes+Studio→Discover, the pill and the ≡ deleted, mePage de-registered.Shipped 2026-08-09, Style Guide synced in the same change. §7b (the per-root right pane) is NOT in it — see below.
4 ✓
minus scan
Add paths — the personal link (rotatable), handle search behind findable (default on since v680), the request composer with its note, and the private remark.Shipped 2026-08-09. The two doors differ on purpose: a link is something you handed out, so holding it IS the permission; handle search is something a stranger can guess, so it is the one path behind a switch — handle-only (a display name would let you trawl for people by first name), two characters or more, and blocked accounts subtracted. Two of those numbers were wrong on the day and were corrected at v680–v681: the switch defaulted off, so nobody was ever in the index and every search returned nothing — over-cautious, and a mis-copy of WeChat, where the ID switch is ON and it is the phone number people close (a phone number is enumerable in a way a handle is not). It defaults on now, with a one-shot backfill for accounts that predate the flip — one-shot, not a boot-time update, or it would silently re-open the switch for anyone who closed it deliberately. ⚠ And「defaults on」was only half-true until v899 (user-caught: a brand-new account could not be found by its own -ish ID). v680 wrote the flip into the CREATE TABLE, but a column default only ever applies to the table it was declared on — and no database that already existed got its column from there. They got it from the ADD-COLUMN migration, whose decl still said DEFAULT 0. So the switch was ON by default on a database created from scratch after v680, and OFF on every real one, the box included. The backfill is what hid it: it flipped everyone alive at that moment to 1 and marked itself done, so the breakage starts at the next account and nowhere else — and the smoke test, which runs on a fresh database, asserted「ON by default」and was green throughout. The birth path names the column now (one constant, FINDABLE_AT_BIRTH), so no table default decides it either way, and a lint refuses any ADD-COLUMN default that contradicts the CREATE TABLE it shadows. Accounts already born off are not mass-flipped — that is the very re-opening the one-shot rule exists to prevent — so they stay as they are until their owner turns the switch on. And the floor was three characters, which hid real handles: @CW is two. Scan is still not built: no QR reader exists in the repo, and the link makes it optional.
5 ✓Block (with an unblock list) · who can add me to a chat · the "My friends" profile tier.Shipped 2026-08-09. The add-policy belongs to the person being added, never the person adding. Below the profile tier a viewer gets the thin card — a name, a handle, a face, and none of the about line, presence or shared chats: not an error, and not an empty page. An unknown policy string reads as open, so a stale client can never silently narrow someone's settings.
6 ✓
v892
The friend/unfriend redesign (owner) — favourites cancelled · the frozen search row · the All persona sub-page · the follow table becomes the persona half's only source · Unfriend in every row's held menu.Shipped 2026-08-16. The change that makes the rest cohere is the smallest-looking one: ONE axis. With favourites gone,「friend」is the only thing a contact list records, and everything else follows — the four filter pills lose their reasons, the un-friended personas need somewhere to live (All persona), and unfriending finally does something, because the「met」union that used to undo it is retired. See §6 for the migration, §6b for the page and the ★.

House obligations

Never

Sources

docs/friends.html · opened 2026-08-08 · design only — nothing here is built yet; this page updates in the same change as each phase