···11+# Profile social logo links — design
22+33+## Goal
44+55+Surface a row of monochrome logo links on atmo.quest profiles for the external
66+services a user is present on:
77+88+- **Bluesky** — when the user has an `app.bsky.actor.profile/self` record.
99+ Links to `https://bsky.app/profile/<handle>`.
1010+- **Tangled** — when the user has an `sh.tangled.actor.profile/self` record in
1111+ their repo. Links to `https://tangled.org/<handle>`.
1212+- **sifa.id** — when the user has an `id.sifa.profile.self/self` record. Links
1313+ to `https://sifa.id/p/<handle>`.
1414+1515+Shown on **both** the signed-in user's own profile (`/profile`, whoami) and on
1616+connection profiles viewed via `/connections`.
1717+1818+## Detection layer (`internal/profile`)
1919+2020+New types and functions:
2121+2222+```go
2323+// SocialLink is one external-service logo link.
2424+type SocialLink struct {
2525+ Service string // "bluesky" | "tangled" | "sifa" — drives which glyph renders
2626+ Label string // "Bluesky" | "Tangled" | "sifa.id" — used for title/aria-label
2727+ URL string
2828+}
2929+3030+// SocialLinks probes the user's PDS and assembles the social links to show.
3131+// Soft-fails per service: a getRecord error is treated as "absent", never
3232+// surfaced to the caller, so a slow/erroring PDS never blocks the page.
3333+func SocialLinks(ctx context.Context, pdsHost string, did syntax.DID, handle string, hasBsky bool) []SocialLink
3434+3535+// RecordExists reports whether did has a record at collection/rkey on pdsHost.
3636+// ErrNotFound -> (false, nil). Other errors bubble up.
3737+func RecordExists(ctx context.Context, pdsHost string, did syntax.DID, collection, rkey string) (bool, error)
3838+```
3939+4040+Detection rules inside `SocialLinks`:
4141+4242+| Service | Condition | URL |
4343+|---------|-----------|-----|
4444+| Bluesky | `hasBsky` true | `https://bsky.app/profile/<handle>`, falling back to `<did>` when handle is empty |
4545+| Tangled | `RecordExists(sh.tangled.actor.profile, self)` AND handle non-empty | `https://tangled.org/<handle>` |
4646+| sifa.id | `RecordExists(id.sifa.profile.self, self)` AND handle non-empty | `https://sifa.id/p/<handle>` |
4747+4848+Tangled and sifa.id URLs are handle-based with no DID form, so both are skipped
4949+when the handle is empty even if the record exists. Bluesky tolerates a missing
5050+handle by falling back to the DID (bsky.app resolves both).
5151+5252+`RecordExists` is a thin wrapper over the existing `getRecord` plumbing in
5353+`internal/profile/profile.go` (`fetchRecord` + `isRecordMissing`). It adds two
5454+extra `getRecord` calls per profile render (tangled + sifa); both are soft-failed.
5555+5656+Rejected alternative: inlining the `getRecord` calls in each handler. A shared
5757+`internal/profile` function is the natural fit — both handlers already call
5858+`FetchBluesky`/`FetchQuest` there — and it is unit-testable in isolation.
5959+6060+## Handler + view wiring
6161+6262+**Own profile** (`features/profile/handlers.go`, `Handlers.Profile`): after
6363+fetching `bsky`/`quest`, resolve the handle via
6464+`users.NameAndHandle(ctx, h.DB, did)` (the users row is touched with the handle
6565+at auth time), then call `profile.SocialLinks(ctx, pds, did, handle, bsky != nil)`
6666+and attach the result to the view. Local users (no PDS, `IsLocal`) are skipped
6767+entirely — no social links.
6868+6969+**Connection profile** (`features/connections/handlers.go`, `Handlers.View`):
7070+the handle is already resolved into `view.Handle` and `bsky` is already fetched,
7171+so call `profile.SocialLinks(ctx, targetPDS, target, view.Handle, bsky != nil)`
7272+after the existing fetches and attach to the view.
7373+7474+Both view structs gain a field backed by a small view-layer mirror type
7575+(mirroring the existing `ProfileLink` pattern):
7676+7777+```go
7878+// in features/profile/pages and features/connections/pages
7979+type SocialLink struct {
8080+ Service string
8181+ Label string
8282+ URL string
8383+}
8484+8585+// added to each ProfileView
8686+SocialLinks []SocialLink
8787+```
8888+8989+## Rendering (the logo row)
9090+9191+A new templ block renders only when `len(SocialLinks) > 0`:
9292+9393+- **Own profile** (`features/profile/pages/profile.templ`): right after the
9494+ `profile-status-pills` block and before `profile-details`.
9595+- **Connection profile** (`features/connections/pages/profile.templ`): right
9696+ after the `pv-badges` row.
9797+9898+Each link:
9999+100100+```html
101101+<a class="social-logo" href="<url>" target="_blank" rel="noopener noreferrer"
102102+ title="<Label>" aria-label="<Label>">
103103+ <!-- inline monochrome SVG glyph, fill="currentColor" -->
104104+</a>
105105+```
106106+107107+Glyphs are inline templ SVG components using `fill="currentColor"` so the
108108+terminal palette controls color via CSS (single accent color, hover brightens).
109109+New `.social-logos` (the row) and `.social-logo` (each icon button) rules go in
110110+`web/resources/static/css/terminal.css`, matching the existing terminal/retro
111111+aesthetic.
112112+113113+**Logo assets:** the official Bluesky butterfly SVG path is used for Bluesky.
114114+The Tangled and sifa.id marks are sourced from their sites/repos during
115115+implementation; if a clean monochrome mark is not readily available for one, a
116116+simple lettermark glyph (stylized "t" / "s") is used as a fallback rather than
117117+blocking. Rendered result is shown for confirmation before finalizing.
118118+119119+## Testing
120120+121121+- Unit tests for `SocialLinks` / `RecordExists` covering: bsky present/absent,
122122+ tangled & sifa record present/absent, and the no-handle case (tangled/sifa
123123+ suppressed, bluesky falls back to DID).
124124+- Regenerate both templ views (`templ generate`) and build the app to confirm
125125+ rendering.
126126+127127+## Out of scope
128128+129129+- No editing UI: these links are auto-detected from PDS records, not entered by
130130+ the user.
131131+- No new services beyond the three named above.
132132+- Local (non-ATProto) accounts show no social links.