An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

Select the types of activity you want to include in your feed.

docs: implementation plan for profile social logo links

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Brittany Ellich (Jun 1, 2026, 11:04 PM -0700) 8f57555e c7ea2b81

+624
+624
docs/superpowers/plans/2026-06-01-profile-social-logo-links.md
··· 1 + # Profile Social Logo Links Implementation Plan 2 + 3 + > **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. 4 + 5 + **Goal:** Show a row of monochrome logo links (Bluesky, Tangled, sifa.id) on atmo.quest profiles, auto-detected from the user's PDS records. 6 + 7 + **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`. 8 + 9 + **Tech Stack:** Go, templ (`go tool templ generate`), Catppuccin-style CSS variables, ATProto (indigo atclient). 10 + 11 + --- 12 + 13 + ## File Structure 14 + 15 + - `internal/profile/social.go` (new) — `SocialLink` type, `RecordExists`, `SocialLinks`. 16 + - `internal/profile/social_test.go` (new) — unit tests for both functions. 17 + - `features/common/layouts/social.templ` (new) — `layouts.SocialLink` view type + `SocialLogos` component with the inline SVG glyphs. 18 + - `features/profile/pages/profile.templ` (modify) — add `SocialLinks []layouts.SocialLink` to `ProfileView`, render the row near the top. 19 + - `features/connections/pages/profile.templ` (modify) — add `SocialLinks []layouts.SocialLink` to `ProfileView`, render the row near the top. 20 + - `features/profile/handlers.go` (modify) — resolve handle, call `SocialLinks`, attach to view. 21 + - `features/connections/handlers.go` (modify) — call `SocialLinks`, attach to view. 22 + - `web/resources/static/css/terminal.css` (modify) — `.social-logos` / `.social-logo` / `.social-glyph` rules. 23 + 24 + Notes for the implementer: 25 + - Run tests with `go test ./internal/profile/ -run <Name> -v`. 26 + - Regenerate templ with `go tool templ generate` (required after editing any `.templ`). 27 + - 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`. 28 + 29 + --- 30 + 31 + ### Task 1: `RecordExists` helper 32 + 33 + **Files:** 34 + - Create: `internal/profile/social.go` 35 + - Test: `internal/profile/social_test.go` 36 + 37 + - [ ] **Step 1: Write the failing tests** 38 + 39 + Create `internal/profile/social_test.go`: 40 + 41 + ```go 42 + package profile 43 + 44 + import ( 45 + "context" 46 + "encoding/json" 47 + "net/http" 48 + "testing" 49 + 50 + "github.com/bluesky-social/indigo/atproto/syntax" 51 + ) 52 + 53 + func TestRecordExists_Present(t *testing.T) { 54 + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { 55 + if got := r.URL.Query().Get("collection"); got != "sh.tangled.actor.profile" { 56 + t.Errorf("collection = %q", got) 57 + } 58 + _ = json.NewEncoder(w).Encode(map[string]any{ 59 + "uri": "at://" + testDID + "/sh.tangled.actor.profile/self", 60 + "cid": "bafyreitangled", 61 + "value": map[string]any{"$type": "sh.tangled.actor.profile"}, 62 + }) 63 + }) 64 + defer srv.Close() 65 + 66 + did, _ := syntax.ParseDID(testDID) 67 + ok, err := RecordExists(context.Background(), srv.URL, did, "sh.tangled.actor.profile", "self") 68 + if err != nil { 69 + t.Fatalf("RecordExists: %v", err) 70 + } 71 + if !ok { 72 + t.Fatal("ok = false, want true") 73 + } 74 + } 75 + 76 + func TestRecordExists_Absent(t *testing.T) { 77 + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { 78 + w.WriteHeader(http.StatusBadRequest) 79 + _ = json.NewEncoder(w).Encode(map[string]any{ 80 + "error": "RecordNotFound", 81 + "message": "Could not locate record", 82 + }) 83 + }) 84 + defer srv.Close() 85 + 86 + did, _ := syntax.ParseDID(testDID) 87 + ok, err := RecordExists(context.Background(), srv.URL, did, "id.sifa.profile.self", "self") 88 + if err != nil { 89 + t.Fatalf("RecordExists: %v", err) 90 + } 91 + if ok { 92 + t.Fatal("ok = true, want false") 93 + } 94 + } 95 + ``` 96 + 97 + - [ ] **Step 2: Run tests to verify they fail** 98 + 99 + Run: `go test ./internal/profile/ -run TestRecordExists -v` 100 + Expected: FAIL — `undefined: RecordExists`. 101 + 102 + - [ ] **Step 3: Write minimal implementation** 103 + 104 + Create `internal/profile/social.go`: 105 + 106 + ```go 107 + package profile 108 + 109 + import ( 110 + "context" 111 + "errors" 112 + 113 + "github.com/bluesky-social/indigo/atproto/syntax" 114 + ) 115 + 116 + // RecordExists reports whether did has a record at collection/rkey on pdsHost. 117 + // A missing record (ErrNotFound) returns (false, nil); any other error bubbles 118 + // up so callers can decide whether to soft-fail. 119 + func RecordExists(ctx context.Context, pdsHost string, did syntax.DID, collection, rkey string) (bool, error) { 120 + var discard any 121 + err := fetchRecord(ctx, pdsHost, did, collection, rkey, &discard) 122 + if err == nil { 123 + return true, nil 124 + } 125 + if errors.Is(err, ErrNotFound) { 126 + return false, nil 127 + } 128 + return false, err 129 + } 130 + ``` 131 + 132 + - [ ] **Step 4: Run tests to verify they pass** 133 + 134 + Run: `go test ./internal/profile/ -run TestRecordExists -v` 135 + Expected: PASS (both cases). 136 + 137 + - [ ] **Step 5: Commit** 138 + 139 + ```bash 140 + git add internal/profile/social.go internal/profile/social_test.go 141 + git commit -m "feat(profile): add RecordExists helper" 142 + ``` 143 + 144 + --- 145 + 146 + ### Task 2: `SocialLinks` assembly 147 + 148 + **Files:** 149 + - Modify: `internal/profile/social.go` 150 + - Test: `internal/profile/social_test.go` 151 + 152 + - [ ] **Step 1: Write the failing tests** 153 + 154 + Append to `internal/profile/social_test.go`: 155 + 156 + ```go 157 + func TestSocialLinks_AllPresent(t *testing.T) { 158 + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { 159 + // All probed records exist. 160 + coll := r.URL.Query().Get("collection") 161 + _ = json.NewEncoder(w).Encode(map[string]any{ 162 + "uri": "at://" + testDID + "/" + coll + "/self", 163 + "cid": "bafyreiexists", 164 + "value": map[string]any{"$type": coll}, 165 + }) 166 + }) 167 + defer srv.Close() 168 + 169 + did, _ := syntax.ParseDID(testDID) 170 + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", true) 171 + 172 + want := map[string]string{ 173 + "bluesky": "https://bsky.app/profile/brittanyellich.com", 174 + "tangled": "https://tangled.org/brittanyellich.com", 175 + "sifa": "https://sifa.id/p/brittanyellich.com", 176 + } 177 + if len(links) != len(want) { 178 + t.Fatalf("got %d links, want %d: %+v", len(links), len(want), links) 179 + } 180 + for _, l := range links { 181 + if want[l.Service] != l.URL { 182 + t.Errorf("service %q: URL = %q, want %q", l.Service, l.URL, want[l.Service]) 183 + } 184 + } 185 + } 186 + 187 + func TestSocialLinks_NoBsky(t *testing.T) { 188 + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { 189 + w.WriteHeader(http.StatusBadRequest) 190 + _ = json.NewEncoder(w).Encode(map[string]any{"error": "RecordNotFound"}) 191 + }) 192 + defer srv.Close() 193 + 194 + did, _ := syntax.ParseDID(testDID) 195 + // hasBsky=false and tangled/sifa records absent -> no links. 196 + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", false) 197 + if len(links) != 0 { 198 + t.Fatalf("got %+v, want none", links) 199 + } 200 + } 201 + 202 + func TestSocialLinks_NoHandle_SkipsTangledAndSifa(t *testing.T) { 203 + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { 204 + coll := r.URL.Query().Get("collection") 205 + _ = json.NewEncoder(w).Encode(map[string]any{ 206 + "uri": "at://" + testDID + "/" + coll + "/self", 207 + "cid": "bafyreiexists", 208 + "value": map[string]any{"$type": coll}, 209 + }) 210 + }) 211 + defer srv.Close() 212 + 213 + did, _ := syntax.ParseDID(testDID) 214 + // Empty handle: tangled & sifa require a handle, bluesky falls back to DID. 215 + links := SocialLinks(context.Background(), srv.URL, did, "", true) 216 + if len(links) != 1 { 217 + t.Fatalf("got %d links, want 1 (bluesky only): %+v", len(links), links) 218 + } 219 + if links[0].Service != "bluesky" || links[0].URL != "https://bsky.app/profile/"+testDID { 220 + t.Errorf("link = %+v, want bluesky -> DID URL", links[0]) 221 + } 222 + } 223 + 224 + func TestSocialLinks_SoftFailsOnError(t *testing.T) { 225 + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { 226 + w.WriteHeader(http.StatusInternalServerError) 227 + _ = json.NewEncoder(w).Encode(map[string]any{"error": "InternalServerError"}) 228 + }) 229 + defer srv.Close() 230 + 231 + did, _ := syntax.ParseDID(testDID) 232 + // 500s on the tangled/sifa probes must not panic or surface — they are 233 + // treated as absent. Bluesky still shows (driven by hasBsky, not a probe). 234 + links := SocialLinks(context.Background(), srv.URL, did, "brittanyellich.com", true) 235 + if len(links) != 1 || links[0].Service != "bluesky" { 236 + t.Fatalf("got %+v, want bluesky only", links) 237 + } 238 + } 239 + ``` 240 + 241 + - [ ] **Step 2: Run tests to verify they fail** 242 + 243 + Run: `go test ./internal/profile/ -run TestSocialLinks -v` 244 + Expected: FAIL — `undefined: SocialLinks` / `undefined: SocialLink`. 245 + 246 + - [ ] **Step 3: Write minimal implementation** 247 + 248 + Append to `internal/profile/social.go` (and add `"strings"` to the import block): 249 + 250 + ```go 251 + // SocialLink is one external-service logo link shown on a profile. 252 + type SocialLink struct { 253 + Service string // "bluesky" | "tangled" | "sifa" — selects the glyph 254 + Label string // "Bluesky" | "Tangled" | "sifa.id" — for title/aria-label 255 + URL string 256 + } 257 + 258 + // SocialLinks probes the user's PDS and assembles the external-service links to 259 + // show on their profile. Detection is soft-failing per service: a getRecord 260 + // error (other than "not found") is logged-by-omission — the service is simply 261 + // treated as absent so a slow or erroring PDS never blocks the page. 262 + // 263 + // - Bluesky: shown when hasBsky is true (the caller has already fetched the 264 + // app.bsky.actor.profile record). Uses the handle, falling back to the DID 265 + // when handle is empty (bsky.app resolves both). 266 + // - Tangled: shown when sh.tangled.actor.profile/self exists AND handle is 267 + // non-empty (tangled.org URLs are handle-based). 268 + // - sifa.id: shown when id.sifa.profile.self/self exists AND handle is 269 + // non-empty (sifa.id URLs are handle-based). 270 + func SocialLinks(ctx context.Context, pdsHost string, did syntax.DID, handle string, hasBsky bool) []SocialLink { 271 + handle = strings.TrimSpace(handle) 272 + var links []SocialLink 273 + 274 + if hasBsky { 275 + ref := handle 276 + if ref == "" { 277 + ref = did.String() 278 + } 279 + links = append(links, SocialLink{ 280 + Service: "bluesky", 281 + Label: "Bluesky", 282 + URL: "https://bsky.app/profile/" + ref, 283 + }) 284 + } 285 + 286 + if handle != "" { 287 + if ok, err := RecordExists(ctx, pdsHost, did, "sh.tangled.actor.profile", "self"); err == nil && ok { 288 + links = append(links, SocialLink{ 289 + Service: "tangled", 290 + Label: "Tangled", 291 + URL: "https://tangled.org/" + handle, 292 + }) 293 + } 294 + if ok, err := RecordExists(ctx, pdsHost, did, "id.sifa.profile.self", "self"); err == nil && ok { 295 + links = append(links, SocialLink{ 296 + Service: "sifa", 297 + Label: "sifa.id", 298 + URL: "https://sifa.id/p/" + handle, 299 + }) 300 + } 301 + } 302 + 303 + return links 304 + } 305 + ``` 306 + 307 + - [ ] **Step 4: Run tests to verify they pass** 308 + 309 + Run: `go test ./internal/profile/ -run TestSocialLinks -v` 310 + Expected: PASS (all four cases). 311 + 312 + - [ ] **Step 5: Commit** 313 + 314 + ```bash 315 + git add internal/profile/social.go internal/profile/social_test.go 316 + git commit -m "feat(profile): assemble external social links from PDS records" 317 + ``` 318 + 319 + --- 320 + 321 + ### Task 3: Shared `SocialLogos` templ component 322 + 323 + **Files:** 324 + - Create: `features/common/layouts/social.templ` 325 + 326 + - [ ] **Step 1: Write the component** 327 + 328 + Create `features/common/layouts/social.templ`: 329 + 330 + ```go 331 + package layouts 332 + 333 + // SocialLink mirrors profile.SocialLink for the view layer. Service selects the 334 + // glyph rendered by SocialLogos; Label is used for the link's accessible name. 335 + type SocialLink struct { 336 + Service string 337 + Label string 338 + URL string 339 + } 340 + 341 + // SocialLogos renders a horizontal row of monochrome logo links to a user's 342 + // external profiles (Bluesky, Tangled, sifa.id). Renders nothing when empty. 343 + templ SocialLogos(links []SocialLink) { 344 + if len(links) > 0 { 345 + <div class="social-logos"> 346 + for _, l := range links { 347 + <a class="social-logo" href={ templ.SafeURL(l.URL) } target="_blank" rel="noopener noreferrer" title={ l.Label } aria-label={ l.Label }> 348 + @socialGlyph(l.Service) 349 + </a> 350 + } 351 + </div> 352 + } 353 + } 354 + 355 + // socialGlyph renders the inline monochrome SVG for a given service. All glyphs 356 + // use fill="currentColor" so the .social-logo CSS controls their color. Bluesky 357 + // uses the official butterfly mark; Tangled and sifa.id use monospace 358 + // lettermarks (see Task 8 for swapping in official marks). 359 + templ socialGlyph(service string) { 360 + switch service { 361 + case "bluesky": 362 + <svg class="social-glyph" viewBox="0 0 600 530" role="img" aria-hidden="true" focusable="false"> 363 + <path fill="currentColor" d="M135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.38-3.69-10.832-3.708-7.896-.017-2.936-1.193.516-3.707 7.896-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.449-163.25-81.433-5.956-21.281-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 364 + </svg> 365 + case "tangled": 366 + <svg class="social-glyph" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"> 367 + <text x="12" y="17" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="currentColor">t</text> 368 + </svg> 369 + case "sifa": 370 + <svg class="social-glyph" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"> 371 + <text x="12" y="17" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="currentColor">s</text> 372 + </svg> 373 + } 374 + } 375 + ``` 376 + 377 + - [ ] **Step 2: Generate templ and build** 378 + 379 + Run: `go tool templ generate && go build ./features/common/layouts/` 380 + Expected: no errors; `features/common/layouts/social_templ.go` is created. 381 + 382 + - [ ] **Step 3: Commit** 383 + 384 + ```bash 385 + git add features/common/layouts/social.templ features/common/layouts/social_templ.go 386 + git commit -m "feat(layouts): add SocialLogos component" 387 + ``` 388 + 389 + --- 390 + 391 + ### Task 4: Own profile — view field, render, handler wiring 392 + 393 + **Files:** 394 + - Modify: `features/profile/pages/profile.templ` 395 + - Modify: `features/profile/handlers.go` 396 + 397 + - [ ] **Step 1: Add the view field** 398 + 399 + 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): 400 + 401 + ```go 402 + // SocialLinks are auto-detected external-service logo links (Bluesky, 403 + // Tangled, sifa.id). Empty for local accounts. 404 + SocialLinks []layouts.SocialLink 405 + ``` 406 + 407 + - [ ] **Step 2: Render the row near the top** 408 + 409 + 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: 410 + 411 + ```go 412 + @layouts.SocialLogos(v.SocialLinks) 413 + ``` 414 + 415 + - [ ] **Step 3: Wire the handler** 416 + 417 + 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: 418 + 419 + ```go 420 + _, handle := users.NameAndHandle(r.Context(), h.DB, did.String()) 421 + view.SocialLinks = toLayoutSocialLinks(profile.SocialLinks(r.Context(), pds, did, handle, bsky != nil)) 422 + ``` 423 + 424 + - [ ] **Step 4: Add the mapping helper** 425 + 426 + In `features/profile/view.go`, add (and ensure `"atmoquest/features/common/layouts"` is imported): 427 + 428 + ```go 429 + // toLayoutSocialLinks maps internal profile.SocialLink values to the layouts 430 + // view type rendered by layouts.SocialLogos. 431 + func toLayoutSocialLinks(in []profile.SocialLink) []layouts.SocialLink { 432 + if len(in) == 0 { 433 + return nil 434 + } 435 + out := make([]layouts.SocialLink, 0, len(in)) 436 + for _, l := range in { 437 + out = append(out, layouts.SocialLink{Service: l.Service, Label: l.Label, URL: l.URL}) 438 + } 439 + return out 440 + } 441 + ``` 442 + 443 + - [ ] **Step 5: Generate templ and build** 444 + 445 + Run: `go tool templ generate && go build ./...` 446 + Expected: no errors. 447 + 448 + - [ ] **Step 6: Commit** 449 + 450 + ```bash 451 + git add features/profile/pages/profile.templ features/profile/pages/profile_templ.go features/profile/handlers.go features/profile/view.go 452 + git commit -m "feat(profile): show social logo links on own profile" 453 + ``` 454 + 455 + --- 456 + 457 + ### Task 5: Connection profile — view field, render, handler wiring 458 + 459 + **Files:** 460 + - Modify: `features/connections/pages/profile.templ` 461 + - Modify: `features/connections/handlers.go` 462 + 463 + - [ ] **Step 1: Add the view field** 464 + 465 + In `features/connections/pages/profile.templ`, the `layouts` package is already imported. Add this field to the `ProfileView` struct (after `Interests []string`): 466 + 467 + ```go 468 + // SocialLinks are auto-detected external-service logo links. 469 + SocialLinks []layouts.SocialLink 470 + ``` 471 + 472 + - [ ] **Step 2: Render the row near the top** 473 + 474 + In the same file, locate the `// ── Badges row ──` block (the `<div class="pv-badges">...</div>`). Immediately after that closing `</div>` and before the `// ── Profile details ──` block, insert: 475 + 476 + ```go 477 + @layouts.SocialLogos(v.SocialLinks) 478 + ``` 479 + 480 + - [ ] **Step 3: Wire the handler** 481 + 482 + 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: 483 + 484 + ```go 485 + view.SocialLinks = toLayoutSocialLinks(profile.SocialLinks(r.Context(), targetPDS, target, view.Handle, bsky != nil)) 486 + ``` 487 + 488 + - [ ] **Step 4: Add the mapping helper** 489 + 490 + In `features/connections/handlers.go`, ensure `"atmoquest/features/common/layouts"` is imported, then add this unexported helper at the bottom of the file: 491 + 492 + ```go 493 + // toLayoutSocialLinks maps internal profile.SocialLink values to the layouts 494 + // view type rendered by layouts.SocialLogos. 495 + func toLayoutSocialLinks(in []profile.SocialLink) []layouts.SocialLink { 496 + if len(in) == 0 { 497 + return nil 498 + } 499 + out := make([]layouts.SocialLink, 0, len(in)) 500 + for _, l := range in { 501 + out = append(out, layouts.SocialLink{Service: l.Service, Label: l.Label, URL: l.URL}) 502 + } 503 + return out 504 + } 505 + ``` 506 + 507 + - [ ] **Step 5: Generate templ and build** 508 + 509 + Run: `go tool templ generate && go build ./...` 510 + Expected: no errors. 511 + 512 + - [ ] **Step 6: Commit** 513 + 514 + ```bash 515 + git add features/connections/pages/profile.templ features/connections/pages/profile_templ.go features/connections/handlers.go 516 + git commit -m "feat(connections): show social logo links on connection profiles" 517 + ``` 518 + 519 + --- 520 + 521 + ### Task 6: Terminal-themed CSS 522 + 523 + **Files:** 524 + - Modify: `web/resources/static/css/terminal.css` 525 + 526 + - [ ] **Step 1: Add the styles** 527 + 528 + Append to `web/resources/static/css/terminal.css` (after the `.pill-link-arrow` block, near the other profile styles): 529 + 530 + ```css 531 + /* ── social logo links (auto-detected external profiles) ── */ 532 + .social-logos { 533 + display: flex; 534 + flex-wrap: wrap; 535 + gap: 10px; 536 + margin-top: 14px; 537 + } 538 + .social-logo { 539 + display: inline-flex; 540 + align-items: center; 541 + justify-content: center; 542 + width: 34px; 543 + height: 34px; 544 + border-radius: 8px; 545 + color: var(--subtext); 546 + background: var(--surface); 547 + border: 1px solid var(--overlay); 548 + transition: color 120ms, background 120ms, border-color 120ms; 549 + } 550 + .social-logo:hover { 551 + color: var(--lavender); 552 + background: rgba(180, 190, 254, 0.12); 553 + border-color: rgba(180, 190, 254, 0.5); 554 + } 555 + .social-glyph { 556 + width: 18px; 557 + height: 18px; 558 + display: block; 559 + } 560 + ``` 561 + 562 + - [ ] **Step 2: Commit** 563 + 564 + ```bash 565 + git add web/resources/static/css/terminal.css 566 + git commit -m "style(profile): terminal-themed social logo styling" 567 + ``` 568 + 569 + --- 570 + 571 + ### Task 7: Full build + unit test sweep 572 + 573 + **Files:** none (verification). 574 + 575 + - [ ] **Step 1: Run the full profile test suite** 576 + 577 + Run: `go test ./internal/profile/ -v` 578 + Expected: PASS (existing tests + RecordExists + SocialLinks). 579 + 580 + - [ ] **Step 2: Regenerate templ and build everything** 581 + 582 + Run: `go tool templ generate && go build ./...` 583 + Expected: no errors. 584 + 585 + - [ ] **Step 3: Vet** 586 + 587 + Run: `go vet ./...` 588 + Expected: no errors. 589 + 590 + - [ ] **Step 4: Commit any regenerated artifacts** 591 + 592 + ```bash 593 + git add -A 594 + git commit -m "chore: regenerate templ + verify build" --allow-empty 595 + ``` 596 + 597 + --- 598 + 599 + ### Task 8: Manual verification + official marks 600 + 601 + **Files:** possibly `features/common/layouts/social.templ` (if swapping marks). 602 + 603 + - [ ] **Step 1: Run the app and inspect a profile** 604 + 605 + 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). 606 + 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. 607 + 608 + - [ ] **Step 2 (optional polish): swap in official Tangled / sifa.id marks** 609 + 610 + If a clean monochrome SVG mark is available for Tangled and/or sifa.id (from their site or repo), replace the corresponding `<text>` lettermark in `socialGlyph` (`features/common/layouts/social.templ`) with the official `<path fill="currentColor" .../>`. Keep `fill="currentColor"` so theming still works. Then `go tool templ generate && go build ./...` and re-verify visually. 611 + 612 + - [ ] **Step 3: Commit (if changed)** 613 + 614 + ```bash 615 + git add features/common/layouts/social.templ features/common/layouts/social_templ.go 616 + git commit -m "style(layouts): use official Tangled/sifa.id marks" 617 + ``` 618 + 619 + --- 620 + 621 + ## Notes 622 + 623 + - 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. 624 + - 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.