Summary
The macOS app has no view of your own account. ProfileRootView opens on an empty "Enter a username to view their public profile." prompt (ProfileRootView.swift:169) even though currentUserStore.currentUserID is already known — so the one profile every user wants to see is the one that takes the most typing to reach.
What the web shows at /user/{handle}
- Avatar, display name,
@handle, bio
- Five stat tiles: Followers, Following, Posts, Documents, Lists
- A Public Lists column (a tree, with a Watch button per list when you are signed in and it is not your own profile)
- A Public Documents column
- A Follow button, and a Message button once you and the other person follow each other
The native header shows followers/following plus mutuals, and no content columns at all.
Profile URL details worth honouring in any deep-link handling: handles are case-insensitive (/user/adron == /user/Adron), /@username is a shortcut, and usernames are [A-Za-z0-9_.-] (anything else typed at signup becomes _ in the URL while the display name keeps what was typed).
Data available (probed live 2026-09-07)
GET /api/users/{username}/documents → 200, and it returns folders too:
{"documents":[{"id":"026028c0-…","title":"Railroad Apps to Build for Fun",
"folderId":null,"relativePath":"railroad-passenger-seating.md",
"createdAt":"…","updatedAt":"…"}],
"folders":[]}
The public-lists equivalent still needs probing — find the route the web profile page calls for another user's public list tree before designing that column. GET /api/lists/watching (also verified live) is the watched lists surface and is a different thing; it is covered in the Lists issue.
Division of labor
Kit
Domain
App
Tests
Acceptance criteria
- Opening Profile while signed in shows your own account with no typing.
- Public lists and public documents are visible for yourself and for others.
- Full E2E gate green.
Notes
work-consolidation.md tracks this as G32. Size M. Pairs with the sidebar-IA issue — build the page first, then re-home it.
Implementation plan (added 2026-09-15)
The probes the issue asked for
The public-lists route is found. GET /api/users/{username}/lists → {lists[], pagination}, confirmed live 2026-09-15. It is the public browse collection, distinct from GET /api/lists/watching (the caller's own watched surface).
Four of the five stat tiles need no new API at all. GET /api/users/{username} already returns them:
{"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0,
"headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false}
PublicProfileDTO has decoded publicMessageCount and publicListCount since the endpoint shipped — they were dropped at the domain boundary. So this is a carry-through, not an integration. The fifth tile (Documents) has no count on that payload and comes from the documents column.
Kit
Nothing. Every route and field needed is already modelled.
Domain
UserProfile gains publicMessageCount, publicListCount, headerImageURL, carried in init(from: PublicProfileDTO) and in withCounts(_:) — the follow-counts stitch rebuilds the profile, and not carrying them there would blank two tiles every time that follow-up landed.
ListsServicing.watch(listId:) — the route's self-subscribe branch (POST …/watchers with no userId), which is free. Modelled as its own method rather than an optional parameter on addWatcher, because "add this person" and "subscribe me" are different intents that happen to share a URL — and addWatcher's empty-id guard must keep erroring rather than quietly becoming this.
App
ProfileRootView lands on the signed-in user's own profile. loadOwnProfileIfNeeded() is a no-op when a profile is already loaded, so re-entering the tab does not yank the user off whoever they were browsing.
ProfileStatTilesView — the five tiles. A tile whose count is unknown is omitted, not rendered as zero: "0 posts" and "we could not find out how many posts" look identical and mean opposite things.
PublicUserListsView / PublicUserListsViewModel — the lists column, mirroring PublicUserDocumentsView's self-contained shape. Watch is optimistic with rollback and per-row error reporting; it is offered only when the profile is someone else's and a session exists.
PublicUserDocumentsView gains an onCountChange callback so the Documents tile reuses the fetch the column already makes — one request, and the tile and the column can never disagree.
- Handle normalisation (
ProfileViewModel.normalizedHandle): strips a leading @ and lowercases, since handles are case-insensitive and /@username is a documented shortcut. Legal username punctuation passes through untouched rather than being sanitised against a charset the client would get subtly wrong.
- Ownership is compared on
id, not handle — a handle comparison would go wrong exactly where it matters, on a rename.
The profileUnavailable guard
Decision 0002's empty state means "this user has no public messages, so there is nothing to project a profile from" — a statement about other people's public content. Shown for your own account it tells a brand-new user with nothing posted yet that their profile does not exist. It is now suppressed for self and still fires for everyone else, with a test in both directions.
Out of scope
The Message button already exists. The sidebar re-homing is #45, which this unblocks.
Summary
The macOS app has no view of your own account.
ProfileRootViewopens on an empty "Enter a username to view their public profile." prompt (ProfileRootView.swift:169) even thoughcurrentUserStore.currentUserIDis already known — so the one profile every user wants to see is the one that takes the most typing to reach.What the web shows at
/user/{handle}@handle, bioThe native header shows followers/following plus mutuals, and no content columns at all.
Profile URL details worth honouring in any deep-link handling: handles are case-insensitive (
/user/adron==/user/Adron),/@usernameis a shortcut, and usernames are[A-Za-z0-9_.-](anything else typed at signup becomes_in the URL while the display name keeps what was typed).Data available (probed live 2026-09-07)
GET /api/users/{username}/documents→ 200, and it returns folders too:{"documents":[{"id":"026028c0-…","title":"Railroad Apps to Build for Fun", "folderId":null,"relativePath":"railroad-passenger-seating.md", "createdAt":"…","updatedAt":"…"}], "folders":[]}The public-lists equivalent still needs probing — find the route the web profile page calls for another user's public list tree before designing that column.
GET /api/lists/watching(also verified live) is the watched lists surface and is a different thing; it is covered in the Lists issue.Division of labor
Kit
GET /api/users/{username}/documentsrequest builder + DTO (documents and folders).Domain
selfProfilepath that loads from the signed-in account rather than the public-author fallback.profileUnavailableempty state must never fire for your own account.App
ProfileRootView: when no username is entered and a user is signed in, land on your own profile. Keep the lookup field as a way to visit someone else.POST /api/lists/{id}/watchers— shared with the Lists issue; coordinate so it is built once).@handleform.Tests
profileUnavailablestate, and assert it cannot fire for the signed-in user@-prefixed handles resolveAcceptance criteria
Notes
work-consolidation.mdtracks this as G32. Size M. Pairs with the sidebar-IA issue — build the page first, then re-home it.Implementation plan (added 2026-09-15)
The probes the issue asked for
The public-lists route is found.
GET /api/users/{username}/lists→{lists[], pagination}, confirmed live 2026-09-15. It is the public browse collection, distinct fromGET /api/lists/watching(the caller's own watched surface).Four of the five stat tiles need no new API at all.
GET /api/users/{username}already returns them:{"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0, "headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false}PublicProfileDTOhas decodedpublicMessageCountandpublicListCountsince the endpoint shipped — they were dropped at the domain boundary. So this is a carry-through, not an integration. The fifth tile (Documents) has no count on that payload and comes from the documents column.Kit
Nothing. Every route and field needed is already modelled.
Domain
UserProfilegainspublicMessageCount,publicListCount,headerImageURL, carried ininit(from: PublicProfileDTO)and inwithCounts(_:)— the follow-counts stitch rebuilds the profile, and not carrying them there would blank two tiles every time that follow-up landed.ListsServicing.watch(listId:)— the route's self-subscribe branch (POST …/watcherswith nouserId), which is free. Modelled as its own method rather than an optional parameter onaddWatcher, because "add this person" and "subscribe me" are different intents that happen to share a URL — andaddWatcher's empty-id guard must keep erroring rather than quietly becoming this.App
ProfileRootViewlands on the signed-in user's own profile.loadOwnProfileIfNeeded()is a no-op when a profile is already loaded, so re-entering the tab does not yank the user off whoever they were browsing.ProfileStatTilesView— the five tiles. A tile whose count is unknown is omitted, not rendered as zero: "0 posts" and "we could not find out how many posts" look identical and mean opposite things.PublicUserListsView/PublicUserListsViewModel— the lists column, mirroringPublicUserDocumentsView's self-contained shape. Watch is optimistic with rollback and per-row error reporting; it is offered only when the profile is someone else's and a session exists.PublicUserDocumentsViewgains anonCountChangecallback so the Documents tile reuses the fetch the column already makes — one request, and the tile and the column can never disagree.ProfileViewModel.normalizedHandle): strips a leading@and lowercases, since handles are case-insensitive and/@usernameis a documented shortcut. Legal username punctuation passes through untouched rather than being sanitised against a charset the client would get subtly wrong.id, not handle — a handle comparison would go wrong exactly where it matters, on a rename.The
profileUnavailableguardDecision 0002's empty state means "this user has no public messages, so there is nothing to project a profile from" — a statement about other people's public content. Shown for your own account it tells a brand-new user with nothing posted yet that their profile does not exist. It is now suppressed for self and still fires for everyone else, with a test in both directions.
Out of scope
The Message button already exists. The sidebar re-homing is #45, which this unblocks.