diff --git a/docs/superpowers/plans/2026-06-01-profile-social-logo-links.md b/docs/superpowers/plans/2026-06-01-profile-social-logo-links.md new file mode 100644 index 0000000..87bbc80 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-profile-social-logo-links.md @@ -0,0 +1,624 @@ +# Profile Social Logo Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a row of monochrome logo links (Bluesky, Tangled, sifa.id) on atmo.quest profiles, auto-detected from the user's PDS records. + +**Architecture:** A shared `internal/profile.SocialLinks` function probes the user's PDS via `com.atproto.repo.getRecord` and returns the links to show. Both the own-profile handler and the connection-profile handler call it and pass the result into their views. A single shared templ component (`layouts.SocialLogos`) renders the row of inline monochrome SVG glyphs, styled in `terminal.css`. + +**Tech Stack:** Go, templ (`go tool templ generate`), Catppuccin-style CSS variables, ATProto (indigo atclient). + +--- + +## File Structure + +- `internal/profile/social.go` (new) — `SocialLink` type, `RecordExists`, `SocialLinks`. +- `internal/profile/social_test.go` (new) — unit tests for both functions. +- `features/common/layouts/social.templ` (new) — `layouts.SocialLink` view type + `SocialLogos` component with the inline SVG glyphs. +- `features/profile/pages/profile.templ` (modify) — add `SocialLinks []layouts.SocialLink` to `ProfileView`, render the row near the top. +- `features/connections/pages/profile.templ` (modify) — add `SocialLinks []layouts.SocialLink` to `ProfileView`, render the row near the top. +- `features/profile/handlers.go` (modify) — resolve handle, call `SocialLinks`, attach to view. +- `features/connections/handlers.go` (modify) — call `SocialLinks`, attach to view. +- `web/resources/static/css/terminal.css` (modify) — `.social-logos` / `.social-logo` / `.social-glyph` rules. + +Notes for the implementer: +- Run tests with `go test ./internal/profile/ -run -v`. +- Regenerate templ with `go tool templ generate` (required after editing any `.templ`). +- The PDS getRecord plumbing lives in `internal/profile/profile.go`: `fetchRecord`, `isRecordMissing`, `ErrNotFound`. The test helper `newPDS` in `internal/profile/profile_test.go` spins up an httptest server serving `/xrpc/com.atproto.repo.getRecord`. + +--- + +### Task 1: `RecordExists` helper + +**Files:** +- Create: `internal/profile/social.go` +- Test: `internal/profile/social_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/profile/social_test.go`: + +```go +package profile + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestRecordExists_Present(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("collection"); got != "sh.tangled.actor.profile" { + t.Errorf("collection = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "uri": "at://" + testDID + "/sh.tangled.actor.profile/self", + "cid": "bafyreitangled", + "value": map[string]any{"$type": "sh.tangled.actor.profile"}, + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + ok, err := RecordExists(context.Background(), srv.URL, did, "sh.tangled.actor.profile", "self") + if err != nil { + t.Fatalf("RecordExists: %v", err) + } + if !ok { + t.Fatal("ok = false, want true") + } +} + +func TestRecordExists_Absent(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "RecordNotFound", + "message": "Could not locate record", + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + ok, err := RecordExists(context.Background(), srv.URL, did, "id.sifa.profile.self", "self") + if err != nil { + t.Fatalf("RecordExists: %v", err) + } + if ok { + t.Fatal("ok = true, want false") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/profile/ -run TestRecordExists -v` +Expected: FAIL — `undefined: RecordExists`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/profile/social.go`: + +```go +package profile + +import ( + "context" + "errors" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// RecordExists reports whether did has a record at collection/rkey on pdsHost. +// A missing record (ErrNotFound) returns (false, nil); any other error bubbles +// up so callers can decide whether to soft-fail. +func RecordExists(ctx context.Context, pdsHost string, did syntax.DID, collection, rkey string) (bool, error) { + var discard any + err := fetchRecord(ctx, pdsHost, did, collection, rkey, &discard) + if err == nil { + return true, nil + } + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/profile/ -run TestRecordExists -v` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add internal/profile/social.go internal/profile/social_test.go +git commit -m "feat(profile): add RecordExists helper" +``` + +--- + +### Task 2: `SocialLinks` assembly + +**Files:** +- Modify: `internal/profile/social.go` +- Test: `internal/profile/social_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/profile/social_test.go`: + +```go +func TestSocialLinks_AllPresent(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + // All probed records exist. + coll := r.URL.Query().Get("collection") + _ = json.NewEncoder(w).Encode(map[string]any{ + "uri": "at://" + testDID + "/" + coll + "/self", + "cid": "bafyreiexists", + "value": map[string]any{"$type": coll}, + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", true) + + want := map[string]string{ + "bluesky": "https://bsky.app/profile/brittanyellich.com", + "tangled": "https://tangled.org/brittanyellich.com", + "sifa": "https://sifa.id/p/brittanyellich.com", + } + if len(links) != len(want) { + t.Fatalf("got %d links, want %d: %+v", len(links), len(want), links) + } + for _, l := range links { + if want[l.Service] != l.URL { + t.Errorf("service %q: URL = %q, want %q", l.Service, l.URL, want[l.Service]) + } + } +} + +func TestSocialLinks_NoBsky(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "RecordNotFound"}) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + // hasBsky=false and tangled/sifa records absent -> no links. + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", false) + if len(links) != 0 { + t.Fatalf("got %+v, want none", links) + } +} + +func TestSocialLinks_NoHandle_SkipsTangledAndSifa(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + coll := r.URL.Query().Get("collection") + _ = json.NewEncoder(w).Encode(map[string]any{ + "uri": "at://" + testDID + "/" + coll + "/self", + "cid": "bafyreiexists", + "value": map[string]any{"$type": coll}, + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + // Empty handle: tangled & sifa require a handle, bluesky falls back to DID. + links := SocialLinks(context.Background(), srv.URL, did, "", true) + if len(links) != 1 { + t.Fatalf("got %d links, want 1 (bluesky only): %+v", len(links), links) + } + if links[0].Service != "bluesky" || links[0].URL != "https://bsky.app/profile/"+testDID { + t.Errorf("link = %+v, want bluesky -> DID URL", links[0]) + } +} + +func TestSocialLinks_SoftFailsOnError(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "InternalServerError"}) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + // 500s on the tangled/sifa probes must not panic or surface — they are + // treated as absent. Bluesky still shows (driven by hasBsky, not a probe). + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", true) + if len(links) != 1 || links[0].Service != "bluesky" { + t.Fatalf("got %+v, want bluesky only", links) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/profile/ -run TestSocialLinks -v` +Expected: FAIL — `undefined: SocialLinks` / `undefined: SocialLink`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `internal/profile/social.go` (and add `"strings"` to the import block): + +```go +// SocialLink is one external-service logo link shown on a profile. +type SocialLink struct { + Service string // "bluesky" | "tangled" | "sifa" — selects the glyph + Label string // "Bluesky" | "Tangled" | "sifa.id" — for title/aria-label + URL string +} + +// SocialLinks probes the user's PDS and assembles the external-service links to +// show on their profile. Detection is soft-failing per service: a getRecord +// error (other than "not found") is logged-by-omission — the service is simply +// treated as absent so a slow or erroring PDS never blocks the page. +// +// - Bluesky: shown when hasBsky is true (the caller has already fetched the +// app.bsky.actor.profile record). Uses the handle, falling back to the DID +// when handle is empty (bsky.app resolves both). +// - Tangled: shown when sh.tangled.actor.profile/self exists AND handle is +// non-empty (tangled.org URLs are handle-based). +// - sifa.id: shown when id.sifa.profile.self/self exists AND handle is +// non-empty (sifa.id URLs are handle-based). +func SocialLinks(ctx context.Context, pdsHost string, did syntax.DID, handle string, hasBsky bool) []SocialLink { + handle = strings.TrimSpace(handle) + var links []SocialLink + + if hasBsky { + ref := handle + if ref == "" { + ref = did.String() + } + links = append(links, SocialLink{ + Service: "bluesky", + Label: "Bluesky", + URL: "https://bsky.app/profile/" + ref, + }) + } + + if handle != "" { + if ok, err := RecordExists(ctx, pdsHost, did, "sh.tangled.actor.profile", "self"); err == nil && ok { + links = append(links, SocialLink{ + Service: "tangled", + Label: "Tangled", + URL: "https://tangled.org/" + handle, + }) + } + if ok, err := RecordExists(ctx, pdsHost, did, "id.sifa.profile.self", "self"); err == nil && ok { + links = append(links, SocialLink{ + Service: "sifa", + Label: "sifa.id", + URL: "https://sifa.id/p/" + handle, + }) + } + } + + return links +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/profile/ -run TestSocialLinks -v` +Expected: PASS (all four cases). + +- [ ] **Step 5: Commit** + +```bash +git add internal/profile/social.go internal/profile/social_test.go +git commit -m "feat(profile): assemble external social links from PDS records" +``` + +--- + +### Task 3: Shared `SocialLogos` templ component + +**Files:** +- Create: `features/common/layouts/social.templ` + +- [ ] **Step 1: Write the component** + +Create `features/common/layouts/social.templ`: + +```go +package layouts + +// SocialLink mirrors profile.SocialLink for the view layer. Service selects the +// glyph rendered by SocialLogos; Label is used for the link's accessible name. +type SocialLink struct { + Service string + Label string + URL string +} + +// SocialLogos renders a horizontal row of monochrome logo links to a user's +// external profiles (Bluesky, Tangled, sifa.id). Renders nothing when empty. +templ SocialLogos(links []SocialLink) { + if len(links) > 0 { +
+ for _, l := range links { + + } +
+ } +} + +// socialGlyph renders the inline monochrome SVG for a given service. All glyphs +// use fill="currentColor" so the .social-logo CSS controls their color. Bluesky +// uses the official butterfly mark; Tangled and sifa.id use monospace +// lettermarks (see Task 8 for swapping in official marks). +templ socialGlyph(service string) { + switch service { + case "bluesky": + + case "tangled": + + case "sifa": + + } +} +``` + +- [ ] **Step 2: Generate templ and build** + +Run: `go tool templ generate && go build ./features/common/layouts/` +Expected: no errors; `features/common/layouts/social_templ.go` is created. + +- [ ] **Step 3: Commit** + +```bash +git add features/common/layouts/social.templ features/common/layouts/social_templ.go +git commit -m "feat(layouts): add SocialLogos component" +``` + +--- + +### Task 4: Own profile — view field, render, handler wiring + +**Files:** +- Modify: `features/profile/pages/profile.templ` +- Modify: `features/profile/handlers.go` + +- [ ] **Step 1: Add the view field** + +In `features/profile/pages/profile.templ`, add an import for layouts is already present (`"atmoquest/features/common/layouts"`). Add this field to the `ProfileView` struct (right after the `Links []ProfileLink` field): + +```go + // SocialLinks are auto-detected external-service logo links (Bluesky, + // Tangled, sifa.id). Empty for local accounts. + SocialLinks []layouts.SocialLink +``` + +- [ ] **Step 2: Render the row near the top** + +In the same file, locate the status-pills block that ends just before the `if v.Bio != "" || v.WorksAt != "" ...` details block. Immediately after the closing `}` of the `if v.Hiring || v.Looking { ... }` block and before the details block, insert: + +```go + @layouts.SocialLogos(v.SocialLinks) +``` + +- [ ] **Step 3: Wire the handler** + +In `features/profile/handlers.go`, add `"atmoquest/internal/users"` to the import block. In `Handlers.Profile`, inside the ATProto branch, after the `quest, questErr := profile.FetchQuest(...)` block and before `view := buildProfileView(...)`, the view is built then mutated. After `view.ConnectedDID = ...` (around the QR/connected lines), add: + +```go + _, handle := users.NameAndHandle(r.Context(), h.DB, did.String()) + view.SocialLinks = toLayoutSocialLinks(profile.SocialLinks(r.Context(), pds, did, handle, bsky != nil)) +``` + +- [ ] **Step 4: Add the mapping helper** + +In `features/profile/view.go`, add (and ensure `"atmoquest/features/common/layouts"` is imported): + +```go +// toLayoutSocialLinks maps internal profile.SocialLink values to the layouts +// view type rendered by layouts.SocialLogos. +func toLayoutSocialLinks(in []profile.SocialLink) []layouts.SocialLink { + if len(in) == 0 { + return nil + } + out := make([]layouts.SocialLink, 0, len(in)) + for _, l := range in { + out = append(out, layouts.SocialLink{Service: l.Service, Label: l.Label, URL: l.URL}) + } + return out +} +``` + +- [ ] **Step 5: Generate templ and build** + +Run: `go tool templ generate && go build ./...` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add features/profile/pages/profile.templ features/profile/pages/profile_templ.go features/profile/handlers.go features/profile/view.go +git commit -m "feat(profile): show social logo links on own profile" +``` + +--- + +### Task 5: Connection profile — view field, render, handler wiring + +**Files:** +- Modify: `features/connections/pages/profile.templ` +- Modify: `features/connections/handlers.go` + +- [ ] **Step 1: Add the view field** + +In `features/connections/pages/profile.templ`, the `layouts` package is already imported. Add this field to the `ProfileView` struct (after `Interests []string`): + +```go + // SocialLinks are auto-detected external-service logo links. + SocialLinks []layouts.SocialLink +``` + +- [ ] **Step 2: Render the row near the top** + +In the same file, locate the `// ── Badges row ──` block (the `
...
`). Immediately after that closing `` and before the `// ── Profile details ──` block, insert: + +```go + @layouts.SocialLogos(v.SocialLinks) +``` + +- [ ] **Step 3: Wire the handler** + +In `features/connections/handlers.go`, within `Handlers.View`, after the handle-resolution block that sets `view.Handle` (the block ending around the directory fallback `if view.Handle == "" && h.Directory != nil { ... }`), add: + +```go + view.SocialLinks = toLayoutSocialLinks(profile.SocialLinks(r.Context(), targetPDS, target, view.Handle, bsky != nil)) +``` + +- [ ] **Step 4: Add the mapping helper** + +In `features/connections/handlers.go`, ensure `"atmoquest/features/common/layouts"` is imported, then add this unexported helper at the bottom of the file: + +```go +// toLayoutSocialLinks maps internal profile.SocialLink values to the layouts +// view type rendered by layouts.SocialLogos. +func toLayoutSocialLinks(in []profile.SocialLink) []layouts.SocialLink { + if len(in) == 0 { + return nil + } + out := make([]layouts.SocialLink, 0, len(in)) + for _, l := range in { + out = append(out, layouts.SocialLink{Service: l.Service, Label: l.Label, URL: l.URL}) + } + return out +} +``` + +- [ ] **Step 5: Generate templ and build** + +Run: `go tool templ generate && go build ./...` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add features/connections/pages/profile.templ features/connections/pages/profile_templ.go features/connections/handlers.go +git commit -m "feat(connections): show social logo links on connection profiles" +``` + +--- + +### Task 6: Terminal-themed CSS + +**Files:** +- Modify: `web/resources/static/css/terminal.css` + +- [ ] **Step 1: Add the styles** + +Append to `web/resources/static/css/terminal.css` (after the `.pill-link-arrow` block, near the other profile styles): + +```css +/* ── social logo links (auto-detected external profiles) ── */ +.social-logos { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} +.social-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + color: var(--subtext); + background: var(--surface); + border: 1px solid var(--overlay); + transition: color 120ms, background 120ms, border-color 120ms; +} +.social-logo:hover { + color: var(--lavender); + background: rgba(180, 190, 254, 0.12); + border-color: rgba(180, 190, 254, 0.5); +} +.social-glyph { + width: 18px; + height: 18px; + display: block; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/resources/static/css/terminal.css +git commit -m "style(profile): terminal-themed social logo styling" +``` + +--- + +### Task 7: Full build + unit test sweep + +**Files:** none (verification). + +- [ ] **Step 1: Run the full profile test suite** + +Run: `go test ./internal/profile/ -v` +Expected: PASS (existing tests + RecordExists + SocialLinks). + +- [ ] **Step 2: Regenerate templ and build everything** + +Run: `go tool templ generate && go build ./...` +Expected: no errors. + +- [ ] **Step 3: Vet** + +Run: `go vet ./...` +Expected: no errors. + +- [ ] **Step 4: Commit any regenerated artifacts** + +```bash +git add -A +git commit -m "chore: regenerate templ + verify build" --allow-empty +``` + +--- + +### Task 8: Manual verification + official marks + +**Files:** possibly `features/common/layouts/social.templ` (if swapping marks). + +- [ ] **Step 1: Run the app and inspect a profile** + +Run: `go tool task live` (or `go tool task build && ./bin/main`), then open `http://localhost:8080/profile` signed in as an account that has a Bluesky profile record (and, ideally, Tangled and/or sifa.id records). +Expected: a row of monochrome logo buttons appears under the name/status pills; each opens the correct external URL in a new tab. Verify the connection view at `/connections/{did}` for another such account. + +- [ ] **Step 2 (optional polish): swap in official Tangled / sifa.id marks** + +If a clean monochrome SVG mark is available for Tangled and/or sifa.id (from their site or repo), replace the corresponding `` lettermark in `socialGlyph` (`features/common/layouts/social.templ`) with the official ``. Keep `fill="currentColor"` so theming still works. Then `go tool templ generate && go build ./...` and re-verify visually. + +- [ ] **Step 3: Commit (if changed)** + +```bash +git add features/common/layouts/social.templ features/common/layouts/social_templ.go +git commit -m "style(layouts): use official Tangled/sifa.id marks" +``` + +--- + +## Notes + +- Local (non-ATProto) accounts have no PDS, so the own-profile handler's local branch and `ProfileLocalView` are intentionally not wired — `SocialLinks` stays empty and the row renders nothing. +- The two `toLayoutSocialLinks` helpers are intentionally per-package (mirroring the existing per-package `ProfileLink` pattern); they are tiny and keep each feature package self-contained.