···11---
22title: Phase 5 Spec
33-updated: 2026-03-25
33+updated: 2026-03-31
44---
5566## Feature Parity
7788-Three new endpoint integrations to round out UI coverage.
88+Three new endpoint integrations to round out UI coverage, plus two Constellation-powered features.
991010---
1111···18181919| Param | Type | Required | Default | Notes |
2020|----------|--------|----------|---------|-------------------------------|
2121-| `q` | string | yes | — | Lucene-style query |
2121+| `q` | string | yes | - | Lucene-style query |
2222| `limit` | int | no | 25 | 1–100 |
2323-| `cursor` | string | no | — | Pagination cursor |
2323+| `cursor` | string | no | - | Pagination cursor |
24242525**Response:**
2626···6565}
6666```
67676868-No pagination — returns all suggestions in one response.
6868+No pagination - returns all suggestions in one response.
69697070**SDK:** `bluesky.graph.getSuggestedFollowsByActor(actor:)`
7171→ `XRPCResponse<GraphGetSuggestedFollowsByActorOutput>`
···104104Show `canUpload` status and any server `message`. Fetch on screen load; show
105105loading indicator while fetching. If the endpoint returns an error or `canUpload`
106106is false, show the reason.
107107+108108+---
109109+110110+### 4. Profile Context (Constellation)
111111+112112+Social context for any account powered by [Constellation](https://constellation.microcosm.blue/) - a public AT Protocol backlink index. No auth required; only a `User-Agent` header.
113113+114114+**Design philosophy** (carried from lazurite-desktop diagnostics):
115115+116116+- Inform, don't alarm. Present data neutrally.
117117+- No composite risk scores. Show the data; let the user interpret it.
118118+- Context over counts. Prefer showing _what kind_ of lists over _how many_.
119119+- Respect the viewed account. Default to aggregate summaries; expand to specifics on request.
120120+121121+#### Constellation Client
122122+123123+A thin HTTP client targeting a configurable Constellation instance (default: `https://constellation.microcosm.blue`). User-configurable via Settings to support self-hosted instances. Timeout: 10 seconds.
124124+125125+All endpoints use XRPC format at `{base}/xrpc/{endpoint}` with query parameters.
126126+127127+#### 4a. Blocked By (incoming blocks)
128128+129129+**Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinksCount`
130130+**Purpose:** Count of accounts that have blocked this user.
131131+132132+| Param | Type | Required | Notes |
133133+|----------|--------|----------|------------------------------------|
134134+| `subject`| string | yes | Target DID |
135135+| `source` | string | yes | `app.bsky.graph.block:subject` |
136136+137137+**Response:** `{ "total": int }`
138138+139139+**Detail list endpoint:** `GET /xrpc/blue.microcosm.links.getDistinct`
140140+141141+| Param | Type | Required | Default | Notes |
142142+|----------|--------|----------|---------|------------------------------------|
143143+| `subject`| string | yes | - | Target DID |
144144+| `source` | string | yes | - | `app.bsky.graph.block:subject` |
145145+| `limit` | int | no | 16 | Max 100 |
146146+| `cursor` | string | no | - | Pagination cursor |
147147+148148+**Response:** `{ "total": int, "dids": string[], "cursor": string? }`
149149+150150+Returned DIDs are hydrated via `bluesky.actor.getProfiles(actors:)` (batch, max 25 per call) to show profile cards.
151151+152152+#### 4b. Users Blocked (outgoing blocks)
153153+154154+**Endpoint:** `GET /xrpc/com.atproto.repo.listRecords` (AT Protocol, not Constellation)
155155+156156+| Param | Type | Required | Default | Notes |
157157+|-------------|--------|----------|---------|-------------------------------|
158158+| `repo` | string | yes | - | Actor DID |
159159+| `collection`| string | yes | - | `app.bsky.graph.block` |
160160+| `limit` | int | no | 50 | Max 100 |
161161+| `cursor` | string | no | - | Pagination cursor |
162162+163163+**Response:** `{ "records": Record[], "cursor": string? }`
164164+165165+Each record's `value.subject` is the blocked DID. Hydrate via `getProfiles`.
166166+167167+**Note:** Outgoing blocks are only readable from the actor's own repo. For other users' profiles, this tab shows only the count from the actor's public repo listing (if accessible) or is hidden entirely if the repo restricts reads.
168168+169169+#### 4c. Lists On
170170+171171+**Endpoint:** `GET /xrpc/blue.microcosm.links.getBacklinks`
172172+173173+| Param | Type | Required | Default | Notes |
174174+|----------|--------|----------|---------|-----------------------------------------|
175175+| `subject`| string | yes | - | Target DID |
176176+| `source` | string | yes | - | `app.bsky.graph.listitem:subject` |
177177+| `limit` | int | no | 16 | Max 100 |
178178+| `cursor` | string | no | - | Pagination cursor |
179179+180180+**Response:**
181181+182182+```json
183183+{
184184+ "total": "int",
185185+ "linking_records": [{ "did": "string", "collection": "string", "rkey": "string" }],
186186+ "cursor": "string?"
187187+}
188188+```
189189+190190+Each backlink record represents a list item. The owning list AT-URI is derived as `at://{record.did}/app.bsky.graph.list/{rkey-of-list}`. Since backlinks only give us the listitem record, we need to resolve the parent list. Two approaches:
191191+192192+1. **getManyToMany** (preferred): `GET /xrpc/blue.microcosm.links.getManyToMany` with `source=app.bsky.graph.listitem:subject` and `pathToOther=list` returns items grouped by their parent list URI. Each item has `otherSubject` (the list AT-URI).
193193+2. **Fallback**: fetch each listitem record via `com.atproto.repo.getRecord` to read its `list` field, then hydrate lists via `bluesky.graph.getList`.
194194+195195+Hydrate list metadata via `bluesky.graph.getList(list:)` to show name, purpose, owner, member count.
196196+197197+#### Profile Context UI
198198+199199+**Entry point:** New "Context" item in the profile screen's overflow menu (PopupMenuButton / three-dot menu). Navigates to a dedicated full screen - not a tab on the profile, since this data is sought intentionally, not browsed casually.
200200+201201+**Route:** `/profile-context?did={DID}`
202202+203203+**Screen layout:**
204204+205205+- `AppBar` with title "Profile Context" and the user's handle as subtitle
206206+- Three-tab `TabBar`: **Blocked By** | **Blocking** | **Lists**
207207+- Each tab is a paginated list with pull-to-refresh
208208+209209+**Blocked By tab:**
210210+211211+- Header row: total count (from `getBacklinksCount`) displayed prominently
212212+- "Show accounts" expand button - on tap, fetches DIDs via `getDistinct` and hydrates profiles
213213+- Profile tiles: avatar, display name, handle. Tap → navigate to profile
214214+- Pagination via cursor (infinite scroll)
215215+- Contextualizing note: _"Blocks are a normal part of social media. This data is public on the AT Protocol."_
216216+217217+**Blocking tab:**
218218+219219+- Same layout as Blocked By but sourced from `listRecords`
220220+- Only available when viewing own profile or if the repo is publicly readable
221221+- When unavailable: show explanatory text
222222+223223+**Lists tab:**
224224+225225+- List cards: name, owner handle, purpose badge (curate/modlist/reference), member count, description snippet
226226+- Grouped by purpose (curation first, then moderation, then reference)
227227+- Tap → navigate to list detail screen (`/list?uri=`)
228228+- Pagination via cursor
229229+230230+**States:**
231231+232232+- Loading: skeleton shimmer matching card dimensions
233233+- Empty: per-tab contextual empty state (e.g., "Not on any lists" / "No blocks found")
234234+- Error: inline retry button per tab, not full-screen error
+123
docs/specs/phase-6.md
···11+---
22+title: Phase 6 Spec
33+updated: 2026-03-31
44+---
55+66+## Social Graph Visualization
77+88+A force-directed graph visualization showing a user's social connections. Inspired by [Skircle](https://skircle.me) - lets people explore their network as an interactive tension graph.
99+1010+### Data Sources
1111+1212+The graph is built from three relationship sets for a target DID:
1313+1414+**Follows (outgoing):** `bluesky.graph.getFollows(actor:, limit:, cursor:)`
1515+→ Paginated `ProfileView[]`. Fetch up to a configurable cap (default: 200) to keep rendering performant.
1616+1717+**Followers (incoming):** `bluesky.graph.getFollowers(actor:, limit:, cursor:)`
1818+→ Paginated `ProfileView[]`. Same cap.
1919+2020+**Mutual detection:** Intersect the two sets by DID. Mutuals get a distinct edge style (thicker, accent-colored).
2121+2222+**Constellation backlinks for extended context:**
2323+2424+- `getDistinct` with `source=app.bsky.graph.block:subject` to identify blocked/blocking edges (rendered as dashed/muted)
2525+- `getBacklinksCount` for aggregate stats shown in the info card
2626+2727+### Graph Model
2828+2929+```dart
3030+GraphNode {
3131+ did: String
3232+ handle: String
3333+ displayName: String?
3434+ avatarUrl: String?
3535+ bannerUrl: String? // for the detail card
3636+ pdsHost: String // extracted from DID document or handle resolution
3737+ relationship: enum { mutual, following, follower }
3838+}
3939+4040+GraphEdge {
4141+ source: DID
4242+ target: DID
4343+ type: enum { mutual, follows, followedBy }
4444+ weight: double // mutual = 1.0, one-way = 0.5
4545+}
4646+```
4747+4848+### Force-Directed Layout
4949+5050+Use a velocity Verlet integration loop on a `CustomPainter` canvas (no external package dependency):
5151+5252+- **Center node:** the target user, pinned at canvas center
5353+- **Charge repulsion:** all nodes repel (Barnes-Hut approximation for O(n log n))
5454+- **Spring attraction:** connected nodes attract along edges; mutuals have stronger spring constant
5555+- **Edge rendering:** straight lines, color-coded by type (mutual = accent, one-way = muted)
5656+- **Node rendering:** circular avatar with border ring colored by relationship type
5757+- **Tick loop:** driven by `Ticker` (vsync), capped at 60fps, with cooling - simulation freezes after convergence
5858+5959+**Interaction:**
6060+6161+- Pan & pinch-zoom via `InteractiveViewer` wrapping the canvas
6262+- Tap node → show user info card (overlay, not navigation)
6363+- Drag node → pin it in place; release to unpin
6464+6565+**Performance constraints:**
6666+6767+- Cap total nodes at ~250 to stay smooth on mid-range devices
6868+- Avatars loaded lazily as `ImageProvider` textures, with placeholder circle until loaded
6969+- Simulation auto-pauses when graph settles (kinetic energy below threshold)
7070+7171+### User Info Card (overlay)
7272+7373+Shown as a `Material` card overlaying the graph canvas when a node is tapped. Positioned near the tapped node (clamped to viewport).
7474+7575+**Layout:**
7676+7777+- Banner image (cover photo) as card header (120px, fallback: gradient)
7878+- Avatar circle overlapping banner bottom-left (56px)
7979+- Display name (bold) + handle (secondary)
8080+- DID (monospace, truncated with copy button)
8181+- PDS host (e.g., `bsky.network`, derived from DID doc `#atproto_pds` service endpoint)
8282+- "View Profile" button → navigates to `/profile?did={DID}`
8383+8484+**Dismiss:** tap outside card, swipe down, or tap X button.
8585+8686+### PDS Resolution
8787+8888+To show the PDS host in the info card, resolve the DID document:
8989+9090+**For `did:plc`:** `GET https://plc.directory/{did}`
9191+→ Response includes `service` array; find entry with `id: "#atproto_pds"`, extract `serviceEndpoint` host.
9292+9393+**For `did:web`:** `GET https://{handle}/.well-known/did.json`
9494+→ Same structure.
9595+9696+Cache resolved PDS hosts in memory (Map<DID, String>) for the session - DID docs rarely change.
9797+9898+### Screen & Navigation
9999+100100+**Route:** `/social-graph?did={DID}`
101101+102102+**Entry point:** New "Social Graph" item in the profile screen's overflow menu, below "Profile Context". Available for all profiles (own and others).
103103+104104+**Screen layout:**
105105+106106+- `AppBar` with title "Social Graph" and handle subtitle
107107+- Full-bleed canvas below
108108+- Floating legend chip row at bottom: colored dots with labels (Mutual / Following / Follower)
109109+- Loading state: centered spinner with "Building graph..." text while fetching follows/followers
110110+- Error state: retry button
111111+112112+**Progressive loading:** Start rendering as soon as first page of follows arrives. Add nodes incrementally as more pages load - the force simulation naturally incorporates new nodes.
113113+114114+### Package Considerations
115115+116116+No external graph package. Implement with:
117117+118118+- `CustomPainter` for rendering
119119+- `GestureDetector` / `InteractiveViewer` for interaction
120120+- `Ticker` for animation loop
121121+- `dart:ui` `Canvas` for drawing circles, lines, images
122122+123123+This avoids dependency risk and gives full control over the mobile-optimized rendering pipeline.
+66-17
docs/tasks/phase-5.md
···11---
22title: Phase 5 Task Breakdown
33-updated: 2026-03-25
33+updated: 2026-03-31
44---
5566# Phase 5 Milestones
7788-## M20 — Starter Pack Search
88+## M20 - Starter Pack Search
991010### Core
11111212-- [ ] `SearchRepository.searchStarterPacks()` — call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor
1212+- [ ] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor
1313- [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension
14141515### Cubit
16161717-- [ ] `SearchBloc` — handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination
1818-- [ ] `SearchState` — add `starterPacks` list and `starterPacksCursor` fields
1717+- [ ] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination
1818+- [ ] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields
19192020### UI
21212222-- [ ] Search screen UI — add third "Starter Packs" tab pill in `_buildTab` row
2323-- [ ] Starter pack result tile widget — show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab
2222+- [ ] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row
2323+- [ ] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab
2424- [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`)
2525- [ ] Infinite scroll pagination for starter packs tab
2626···2929- [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination
3030- [ ] Widget tests: third tab renders, results display, empty state, tap navigation
31313232-## M21 — Suggested Follows Sheet
3232+## M21 - Suggested Follows Sheet
33333434### Core
35353636-- [ ] `ProfileRepository.getSuggestedFollows()` — call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>`
3636+- [ ] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>`
37373838### Cubit
39394040-- [ ] `SuggestedFollowsCubit` — `load(actor:)` fetches suggestions, exposes loaded/loading/error states
4040+- [ ] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states
41414242### UI
43434444-- [ ] Suggested follows sheet widget — `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons
4545-- [ ] Profile screen overflow menu — add "Suggested Follows" `ListTile` entry; hide when viewing own profile
4444+- [ ] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons
4545+- [ ] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile
4646- [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete`
4747- [ ] Tap profile tile → pop sheet, navigate to profile screen
4848- [ ] Empty state when no suggestions returned
···5252- [ ] Unit tests: repository method, cubit state transitions
5353- [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state
54545555-## M22 — Video Upload Limits
5555+## M22 - Video Upload Limits
56565757### Core
58585959-- [ ] `VideoRepository` (or extend settings repository) — `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result
5959+- [ ] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result
60606161### Cubit
62626363-- [ ] `VideoUploadLimitsCubit` — fetch on init, expose `canUpload`, remaining counts, message/error
6363+- [ ] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error
64646565### UI
66666767-- [ ] Settings screen — new tile in Account section: "Video Upload Limits"
6868-- [ ] Tile UI — show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge
6767+- [ ] Settings screen - new tile in Account section: "Video Upload Limits"
6868+- [ ] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge
6969- [ ] Loading state while fetching, error state if request fails
7070- [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false
7171···73737474- [ ] Unit tests: repository method, cubit state transitions and formatting
7575- [ ] Widget tests: tile renders limits, loading indicator, error state, message display
7676+7777+## M23 - Profile Context (Constellation)
7878+7979+### Core - Constellation Client
8080+8181+- [x] `ConstellationClient` - thin HTTP client (`http` package) targeting configurable base URL (default `https://constellation.microcosm.blue`), 10s timeout, `User-Agent: lazurite`
8282+- [x] `Settings` - add `constellation_url` key with default value; expose in Settings screen under "Advanced"
8383+- [x] `getBacklinksCount(subject, source)` → `int` total
8484+- [x] `getDistinct(subject, source, {limit, cursor})` → `({int total, List<String> dids, String? cursor})`
8585+- [x] `getBacklinks(subject, source, {limit, cursor})` → `({int total, List<ConstellationLinkRecord> records, String? cursor})`
8686+- [x] `getManyToMany(subject, source, pathToOther, {limit, cursor})` → `({List<ManyToManyItem> items, String? cursor})`
8787+- [x] `ConstellationLinkRecord` model - `did`, `collection`, `rkey`
8888+- [x] `ManyToManyItem` model - `linkRecord: ConstellationLinkRecord`, `otherSubject: String`
8989+9090+### Core - Profile Context Repository
9191+9292+- [ ] `ProfileContextRepository` - depends on `ConstellationClient` + `Bluesky`
9393+- [ ] `getBlockedByCount(did)` - calls `getBacklinksCount(did, 'app.bsky.graph.block:subject')`
9494+- [ ] `getBlockedByProfiles(did, {cursor})` - calls `getDistinct`, hydrates DIDs via `bluesky.actor.getProfiles` (batched 25), returns `({List<ProfileView> profiles, String? cursor, int total})`
9595+- [ ] `getBlockingProfiles(did, {cursor})` - calls `com.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block')`, extracts subject DIDs, hydrates via `getProfiles`, returns same shape
9696+- [ ] `getListsOn(did, {cursor})` - calls `getManyToMany(did, 'app.bsky.graph.listitem:subject', 'list')`, derives list AT-URIs from `otherSubject`, hydrates via `bluesky.graph.getList`, returns `({List<ListView> lists, String? cursor, int total})`
9797+9898+### Cubit
9999+100100+- [ ] `ProfileContextCubit` - manages tab state, loads counts on init for all three tabs
101101+- [ ] `ProfileContextState` - fields: `blockedByCount`, `blockingCount`, `listsOnCount`, per-tab `status` (initial/loading/loaded/error), per-tab item list + cursor
102102+- [ ] `loadBlockedBy({cursor})` - fetches page of blocked-by profiles, appends to state
103103+- [ ] `loadBlocking({cursor})` - fetches page of blocking profiles, appends to state
104104+- [ ] `loadListsOn({cursor})` - fetches page of lists, appends to state
105105+- [ ] Handle own-profile vs other-profile: blocking tab only available for own profile
106106+107107+### UI
108108+109109+- [ ] Profile screen overflow menu - add "Profile Context" entry (available for all profiles)
110110+- [ ] Route: `/profile-context?did={DID}` in `app_router.dart`
111111+- [ ] `ProfileContextScreen` - `AppBar` (title + handle subtitle), `TabBar` with 3 tabs, `BlocProvider` creating cubit
112112+- [ ] **Blocked By tab** - count header, "Show accounts" expand, paginated profile tiles (avatar, name, handle), tap → profile navigation, contextualizing note text
113113+- [ ] **Blocking tab** - same layout; hidden or explanatory text when viewing other profiles
114114+- [ ] **Lists tab** - list cards (name, owner, purpose badge, member count, description), grouped by purpose, tap → `/list?uri=`
115115+- [ ] Per-tab states: skeleton shimmer (loading), contextual empty state, inline error with retry
116116+- [ ] Pull-to-refresh per tab
117117+- [ ] Infinite scroll pagination per tab
118118+119119+### Tests
120120+121121+- [ ] Unit tests: `ConstellationClient` - each endpoint method, error handling, timeout, URL construction
122122+- [ ] Unit tests: `ProfileContextRepository` - DID hydration batching, list URI derivation, cursor passthrough
123123+- [ ] Unit tests: `ProfileContextCubit` - state transitions for each tab, own-profile vs other-profile logic, pagination appending
124124+- [ ] Widget tests: screen renders 3 tabs, blocked-by count + expand, profile tiles render and navigate, list cards render and navigate, empty states, error + retry, blocking tab hidden for non-own profiles
+81
docs/tasks/phase-6.md
···11+---
22+title: Phase 6 Task Breakdown
33+updated: 2026-03-31
44+---
55+66+# Phase 6 Milestones
77+88+## M25 - Social Graph Visualization
99+1010+### Core - Graph Data
1111+1212+- [ ] `SocialGraphRepository` - depends on `Bluesky` & `ConstellationClient`
1313+- [ ] `getFollows(did, {limit, cursor})` - wraps `bluesky.graph.getFollows`, returns `({List<ProfileView> profiles, String? cursor})`
1414+- [ ] `getFollowers(did, {limit, cursor})` - wraps `bluesky.graph.getFollowers`, returns same shape
1515+- [ ] `fetchGraphData(did, {maxNodes: 200})` - fetches follows + followers up to cap, computes mutual set by DID intersection, returns `GraphData`
1616+- [ ] `GraphNode` model - `did`, `handle`, `displayName`, `avatarUrl`, `bannerUrl`, `relationship` (enum: mutual/following/follower)
1717+- [ ] `GraphEdge` model - `sourceDid`, `targetDid`, `type` (enum: mutual/follows/followedBy), `weight`
1818+- [ ] `GraphData` model - `centerDid`, `List<GraphNode> nodes`, `List<GraphEdge> edges`
1919+2020+### Core - PDS Resolution
2121+2222+- [ ] `PdsResolver` - resolves DID → PDS host
2323+- [ ] `did:plc` resolution - `GET https://plc.directory/{did}`, parse `service` array for `#atproto_pds` entry, extract host from `serviceEndpoint`
2424+- [ ] `did:web` resolution - `GET https://{identifier}/.well-known/did.json`, same parsing
2525+- [ ] In-memory cache (`Map<String, String>`) for resolved PDS hosts, per session
2626+2727+### Core - Force-Directed Layout Engine
2828+2929+- [ ] `ForceSimulation` class - velocity Verlet integration, configurable parameters
3030+- [ ] Charge repulsion force - Barnes-Hut quadtree approximation for O(n log n)
3131+- [ ] Spring attraction force - edge-based, stronger constant for mutual edges
3232+- [ ] Center gravity - gentle pull toward canvas center to prevent drift
3333+- [ ] Cooling schedule - alpha decay, auto-pause when kinetic energy < threshold
3434+- [ ] `SimulationNode` - position (`Offset`), velocity, pinned flag, reference to `GraphNode`
3535+- [ ] `tick()` method - single simulation step, returns whether simulation is still active
3636+3737+### Cubit
3838+3939+- [ ] `SocialGraphCubit` - `load(did)` fetches graph data, initializes simulation
4040+- [ ] `SocialGraphState` - fields: `status` (loading/loaded/error), `graphData`, `selectedNode`, `simulationActive`
4141+- [ ] `selectNode(did)` - sets selected node for info card display
4242+- [ ] `dismissCard()` - clears selected node
4343+- [ ] `pinNode(did, offset)` / `unpinNode(did)` - for drag interaction
4444+4545+### UI - Graph Canvas
4646+4747+- [ ] `SocialGraphScreen` - route `/social-graph?did={DID}`, `AppBar` + full-bleed canvas
4848+- [ ] `GraphPainter extends CustomPainter` - renders edges (lines, color-coded), nodes (avatar circles with relationship-colored borders)
4949+- [ ] `InteractiveViewer` wrapping `CustomPaint` for pan + pinch-zoom
5050+- [ ] `Ticker`-driven animation loop - calls `ForceSimulation.tick()`, triggers repaint via `notifyListeners`
5151+- [ ] Lazy avatar loading - `ImageProvider` textures, placeholder colored circle until loaded
5252+- [ ] Tap detection - hit-test nodes by distance from touch point, emit `selectNode`
5353+- [ ] Drag detection - `GestureDetector` on nodes for pin/unpin
5454+- [ ] Floating legend row - colored dots: Mutual / Following / Follower
5555+- [ ] Loading state - centered spinner + "Building graph..."
5656+- [ ] Error state - retry button
5757+- [ ] Progressive loading - start rendering when first follows page arrives, add nodes as more pages load
5858+5959+### UI - Info Card Overlay
6060+6161+- [ ] `GraphInfoCard` widget - `Material` card positioned near tapped node (clamped to viewport bounds)
6262+- [ ] Banner image header (120px, gradient fallback)
6363+- [ ] Avatar circle (56px) overlapping banner
6464+- [ ] Display name + handle
6565+- [ ] DID row - monospace, truncated, copy-to-clipboard button
6666+- [ ] PDS host - resolved via `PdsResolver`, loading indicator until resolved
6767+- [ ] "View Profile" `FilledButton` → pop card, navigate to `/profile?did={DID}`
6868+- [ ] Dismiss: tap outside, swipe down, or X button
6969+7070+### UI - Entry Point
7171+7272+- [ ] Profile screen overflow menu - add "Social Graph" entry below "Profile Context"
7373+- [ ] Route: `/social-graph?did={DID}` in `app_router.dart`
7474+7575+### Tests
7676+7777+- [ ] Unit tests: `SocialGraphRepository` - follows/followers fetching, cap enforcement, mutual detection
7878+- [ ] Unit tests: `PdsResolver` - `did:plc` and `did:web` parsing, cache hit, error handling
7979+- [ ] Unit tests: `ForceSimulation` - convergence, node pinning, cooling, edge weight effect
8080+- [ ] Unit tests: `SocialGraphCubit` - state transitions, node selection, progressive loading
8181+- [ ] Widget tests: screen renders graph canvas, legend chips, info card shows on tap with correct fields, "View Profile" navigates, loading/error states
+139
lib/core/network/constellation_client.dart
···11+import 'dart:convert';
22+33+import 'package:http/http.dart' as http;
44+55+const String _defaultBaseUrl = 'https://constellation.microcosm.blue';
66+const Duration _kTimeout = Duration(seconds: 10);
77+88+class ConstellationLinkRecord {
99+ const ConstellationLinkRecord({required this.did, required this.collection, required this.rkey});
1010+1111+ final String did;
1212+ final String collection;
1313+ final String rkey;
1414+1515+ factory ConstellationLinkRecord.fromJson(Map<String, dynamic> json) {
1616+ return ConstellationLinkRecord(
1717+ did: json['did'] as String,
1818+ collection: json['collection'] as String,
1919+ rkey: json['rkey'] as String,
2020+ );
2121+ }
2222+}
2323+2424+class ManyToManyItem {
2525+ const ManyToManyItem({required this.linkRecord, required this.otherSubject});
2626+2727+ final ConstellationLinkRecord linkRecord;
2828+ final String otherSubject;
2929+3030+ factory ManyToManyItem.fromJson(Map<String, dynamic> json) {
3131+ return ManyToManyItem(
3232+ linkRecord: ConstellationLinkRecord.fromJson(json['linkRecord'] as Map<String, dynamic>),
3333+ otherSubject: json['otherSubject'] as String,
3434+ );
3535+ }
3636+}
3737+3838+class ConstellationException implements Exception {
3939+ const ConstellationException(this.message);
4040+4141+ final String message;
4242+4343+ @override
4444+ String toString() => 'ConstellationException: $message';
4545+}
4646+4747+class ConstellationClient {
4848+ ConstellationClient({String? baseUrl, http.Client? httpClient})
4949+ : _baseUrl = baseUrl ?? _defaultBaseUrl,
5050+ _httpClient = httpClient ?? http.Client();
5151+5252+ final String _baseUrl;
5353+ final http.Client _httpClient;
5454+5555+ Uri _xrpcUri(String endpoint, Map<String, String?> params) {
5656+ final filtered = <String, String>{};
5757+ for (final entry in params.entries) {
5858+ if (entry.value != null) filtered[entry.key] = entry.value!;
5959+ }
6060+ final base = Uri.parse('$_baseUrl/xrpc/$endpoint');
6161+ return filtered.isEmpty ? base : base.replace(queryParameters: filtered);
6262+ }
6363+6464+ Future<Map<String, dynamic>> _get(Uri uri) async {
6565+ final response = await _httpClient.get(uri, headers: {'User-Agent': 'lazurite'}).timeout(_kTimeout);
6666+6767+ if (response.statusCode != 200) {
6868+ throw ConstellationException('HTTP ${response.statusCode}: ${response.body}');
6969+ }
7070+7171+ return jsonDecode(response.body) as Map<String, dynamic>;
7272+ }
7373+7474+ Future<int> getBacklinksCount(String subject, String source) async {
7575+ final uri = _xrpcUri('blue.microcosm.links.getBacklinksCount', {'subject': subject, 'source': source});
7676+ final data = await _get(uri);
7777+ return data['total'] as int;
7878+ }
7979+8080+ Future<({int total, List<String> dids, String? cursor})> getDistinct(
8181+ String subject,
8282+ String source, {
8383+ int? limit,
8484+ String? cursor,
8585+ }) async {
8686+ final uri = _xrpcUri('blue.microcosm.links.getDistinct', {
8787+ 'subject': subject,
8888+ 'source': source,
8989+ if (limit != null) 'limit': limit.toString(),
9090+ 'cursor': cursor,
9191+ });
9292+ final data = await _get(uri);
9393+ return (
9494+ total: data['total'] as int,
9595+ dids: (data['dids'] as List<dynamic>).cast<String>(),
9696+ cursor: data['cursor'] as String?,
9797+ );
9898+ }
9999+100100+ Future<({int total, List<ConstellationLinkRecord> records, String? cursor})> getBacklinks(
101101+ String subject,
102102+ String source, {
103103+ int? limit,
104104+ String? cursor,
105105+ }) async {
106106+ final uri = _xrpcUri('blue.microcosm.links.getBacklinks', {
107107+ 'subject': subject,
108108+ 'source': source,
109109+ if (limit != null) 'limit': limit.toString(),
110110+ 'cursor': cursor,
111111+ });
112112+ final data = await _get(uri);
113113+ final records = (data['linking_records'] as List<dynamic>)
114114+ .map((r) => ConstellationLinkRecord.fromJson(r as Map<String, dynamic>))
115115+ .toList();
116116+ return (total: data['total'] as int, records: records, cursor: data['cursor'] as String?);
117117+ }
118118+119119+ Future<({List<ManyToManyItem> items, String? cursor})> getManyToMany(
120120+ String subject,
121121+ String source,
122122+ String pathToOther, {
123123+ int? limit,
124124+ String? cursor,
125125+ }) async {
126126+ final uri = _xrpcUri('blue.microcosm.links.getManyToMany', {
127127+ 'subject': subject,
128128+ 'source': source,
129129+ 'pathToOther': pathToOther,
130130+ if (limit != null) 'limit': limit.toString(),
131131+ 'cursor': cursor,
132132+ });
133133+ final data = await _get(uri);
134134+ final items = (data['items'] as List<dynamic>)
135135+ .map((i) => ManyToManyItem.fromJson(i as Map<String, dynamic>))
136136+ .toList();
137137+ return (items: items, cursor: data['cursor'] as String?);
138138+ }
139139+}