simocracy-v2 · profile-page

Profile pages — Bluesky-interoperable user profiles

@daviddao

A new /profile/<did> page reads + writes app.bsky.actor.profile on the user's PDS so it stays interoperable with Bluesky and any other AT-Protocol-aware client. End-to-end run hit two real PDS profiles, the 404 path, and a mobile breakpoint; the layout works and one visual bug was spotted and fixed mid-run.

Date: 2026-04-30 · pass · Target: http://127.0.0.1:3007/profile/<did> (local npm run dev)


Headline result

2/2
PDS profiles rendered
8/8
Behaviours verified
1→0
Visual bugs (found → fixed)
0
Blockers

Pass. The new profile page hydrates cleanly from public.api.bsky.app/xrpc/app.bsky.actor.getProfile, lists the user's org.simocracy.sim records and any org.simocracy.gathering they own or sit on the council of, and serves correct 404s for non-DID paths. The owner-only Edit dialog routes its PUT through the existing /api/records path with the new app.bsky.actor.profile allow-list entry, so writes are wired the same way every other simocracy record is. One visual bug — a long bio pushing the H1 up into a dark banner photo — was caught against @daviddao.org and fixed in the same run; the second commit is the screenshot “FIXED” set you see below.

What was tested

Both DIDs are real Bluesky accounts that have records on a Simocracy PDS, so this hits the indexer + PDS fallback paths the page is meant to use — not a mocked fixture.

  1. Profile with banner + long bio + sims + events@daviddao.org: 4 sims, 2 events (1 admin, 1 council seat).
  2. Profile with no banner, short bio, sims + events@simocracy.climateai.org (the facilitator): 5 sims, 3 events (2 admin, 1 council seat).
  3. Invalid actor/profile/foobar: must 404, not crash, not show a half-rendered shell.
  4. Mobile (390×844) — layout reflows cleanly, hero stacks, sim/event cards become single column.
  5. Card navigation — clicking a sim card navigates to /sims/<did>/<rkey>; clicking an event card navigates to its gathering page.
  6. Lexicon contract — the same record we read via com.atproto.repo.getRecord on the facilitator's PDS is the one the Edit dialog writes back via /api/records.

The visual bug we caught and fixed

David Dao profile, first pass: heading overlaps the banner photo
Before. Long bio pushed the H1 column up; with items-end, “David Dao” ended up inside the dark banner photo and was barely legible.
David Dao profile after fix: heading sits below the banner
After. Twitter-style stack: the avatar still floats up over the banner via a negative margin, but the H1 + bio + stats are their own block underneath, so dark banner photos can’t swallow the text.

The fix is one commit: fbecbbccomponents/profile/profile-client.tsx, swap flex flex-row items-end for an avatar-row + text-block stack. Verified against both the banner-having and banner-less profiles after the change.

Walkthrough

Profile hero with banner, avatar, displayName, handle, bio, follower stats
01 — Hero, banner caseBanner from cdn.bsky.app, avatar floats over it, name/handle/bio sit below. Followers/following/posts come straight from the public AppView.
Simocracy facilitator profile with no banner, fallback diagonal pattern shows
02 — No-banner fallbackWhen the user has no banner blob, a soft diagonal foil pattern is shown instead. Avatar still uses the cdn.bsky.app avatar URL.
Sims and events sections side by side
03 — Sims + eventsSims grid pulls org.simocracy.sim + their shortDescription from the agents record. Events show pixel-sprite council previews, gathering type/status pills, and an ADMIN/COUNCIL chip describing the user’s relationship to the event.
Full simocracy facilitator profile, top to bottom
04 — Full page (facilitator)Hero → stat tiles → sims grid → events grid. Five sims, three events. Note “The Senate” and “Funding the Commons SF” both show ADMIN because the facilitator owns those gathering records.
Mobile layout (390x844) showing single-column reflow of all sections
05 — Mobile (390×844)Hero stacks, stat tiles drop to 2-up, sims and events go single column. Description and stats stay readable.
404 page rendered when DID is invalid
06 — Invalid actor → 404/profile/foobar hits notFound() in the server component (only did:plc: and did:web: are allowed). Renders the standard simocracy 404, not a half-broken page.

Lexicon — the one Bluesky reads is the one we write

The contract that makes this interoperable, not just “a profile-shaped thing”: the page edits the exact app.bsky.actor.profile/self record that bsky.app, deer.social, tangled, etc. all read. We confirmed by curl-ing the facilitator’s own PDS:

$ curl -s 'https://simocracy.climateai.org/xrpc/com.atproto.repo.getRecord?\
    repo=did:plc:awtx57rxuisy4sf4kzzr3uhf&\
    collection=app.bsky.actor.profile&\
    rkey=self' | jq '.value | {displayName, description, avatar: .avatar.ref."$link"}'
{
  "displayName": "Simocracy",
  "description": null,
  "avatar": "bafkreibxzvcztohjohprogt4pmgutocxafcyzwct3qt7ddpo6rk3eynpbq"
}

The avatar CID matches what public.api.bsky.app/xrpc/app.bsky.actor.getProfile returns, which is what the page renders. Save flow:

  1. Owner picks a new image → POST /api/upload-blob on their own PDS, gets back a blob ref.
  2. Server reads the existing app.bsky.actor.profile/self record so unknown fields (pronouns, website, labels, joinedViaStarterPack, pinnedPost) survive the round-trip.
  3. PUT /api/records with collection: "app.bsky.actor.profile", rkey: "self", and the merged record. The new allow-list entry on app/api/records/route.ts is what permits the write.

Findings

Profile reads work via the same indexer + PDS fallback the rest of the app uses

Sims come from fetchSimsForDid (Simocracy indexer first, falls back to com.atproto.repo.listRecords). Events come from fetchGatherings, then are filtered client-side to those owned by the DID OR with one of the user's sim URIs in councilSims[]. Both signals showed up correctly on @daviddao.org (admin + council) and on the facilitator (admin x2 + council x1).

Bluesky-interoperable writes

The Edit dialog targets app.bsky.actor.profile/self. Same NSID + rkey Bluesky uses. We don't store any simocracy-specific shape for the user’s identity — displayName, description, avatar, and banner all round-trip through the standard Bluesky lexicon, so changes here show up on bsky.app immediately, and changes there show up here on next page render.

Navbar dropdown now has a 'View profile' entry

components/navbar.tsx shows the new item when userDid is set, linking to /profile/<userDid>. Confirmed by reading the rendered tree (the dropdown only mounts when signed in, so visual capture requires a real OAuth session — covered in the navbar source diff in commit 11ae869).

404 path is clean

The server component validates did.startsWith("did:plc:") || did.startsWith("did:web:") before any fetch, so /profile/foobar short-circuits to notFound(). No server error, no half-rendered shell, no wasted indexer hit.

Minor — metadata title leaks the DID for 404 paths

generateMetadata() runs before the page component’s notFound(), so /profile/foobar ends up with browser-tab title “foobar · Profile · Simocracy” even though the body renders the 404. Cosmetic only — the page itself 404s correctly. Worth tightening if it ever shows up in shared social-card previews.

Visual bug found and fixed mid-run

Original layout used flex-row items-end on a row that contained the avatar and a long-text column; when the bio overflowed the avatar height the column expanded upward and the H1 ended up inside the banner image. Re-shot against both profiles after the fix — see the figpair above. The fixed shape is the same Twitter/Bluesky layout users already expect.

Reproducing

Routes/profile/did:plc:qc42fmqqlsmdq7jiypiiigww · /profile/did:plc:awtx57rxuisy4sf4kzzr3uhf · /profile/foobar Test profiles@daviddao.org (banner, long bio, 4 sims) · @simocracy.climateai.org (no banner, 5 sims, 3 events) Lexicon readcurl https://simocracy.climateai.org/xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.actor.profile&rkey=self AppView readcurl https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=<did> Mobile viewportagent-browser set viewport 390 844 Checksnpm run lint (no new errors), npm run build (✓ compiled, route ƒ /profile/[did] emitted) Commits11ae869 add /profile/<did> · fbecbbc fix banner overlap