···11+# Network Attention Display Spec
22+33+**Date:** 2026-04-10
44+**Issue:** Chainlink #10
55+**Status:** Approved (pending review)
66+**Depends on:** PR #9 (scoring algorithm — merged), PR #11 (Railway deploy — merged)
77+**Unblocks:** #25/#26 (coverage map), #20 (slider UI), talk page scoring (future follow-up)
88+99+---
1010+1111+## 1. Goal
1212+1313+Wire the scoring engine into the `/talks` grid so authenticated users see bioluminescent glow on talks their network missed. Hover/tap reveals score details ("97% of your network missed this"). Unauthenticated users see the grid without glow, unchanged from today.
1414+1515+---
1616+1717+## 2. Background
1818+1919+The scoring engine (`src/lib/scoring/`) is fully implemented with 40 unit tests. It takes `TalkMentions` from the crawler and produces `TalkScore[]` with a 0–1 `intensity` value, a three-state classifier (`engaged | missed | unknown`), and a `layer1` object with the raw counts.
2020+2121+The `/talks` page currently renders a grid of `LumeCard` components with `glowIntensity={0}` (hardcoded). `LumeCard` already supports `glowIntensity` (maps to CSS glow classes and breathing animation), `tileIndex` (staggers breathing), and `interestMatch` (blue dot for Layer 2, not used yet).
2222+2323+The missing piece: a client-side hook that fetches `/api/crawl`, pipes the result through `rankTalks`, and passes scores to the cards.
2424+2525+---
2626+2727+## 3. New Files
2828+2929+### 3.1 `src/hooks/useCrawlData.ts` — crawl data fetcher
3030+3131+A React hook that:
3232+1. Calls `GET /api/crawl` on mount
3333+2. Returns `{ mentions: TalkMentions | null, followCount: number, loading: boolean, error: string | null }`
3434+3. Only fetches if the user is authenticated (check is implicit — `/api/crawl` returns 401 if not, which the hook treats as "no data")
3535+4. Caches the result for the component lifecycle (no re-fetch on re-render)
3636+3737+```ts
3838+import { useState, useEffect } from "react";
3939+import type { TalkMentions } from "@/lib/scoring";
4040+4141+interface CrawlData {
4242+ mentions: TalkMentions | null;
4343+ followCount: number;
4444+ loading: boolean;
4545+ error: string | null;
4646+}
4747+4848+export function useCrawlData(): CrawlData {
4949+ const [data, setData] = useState<CrawlData>({
5050+ mentions: null,
5151+ followCount: 0,
5252+ loading: true,
5353+ error: null,
5454+ });
5555+5656+ useEffect(() => {
5757+ let cancelled = false;
5858+5959+ async function fetchCrawl() {
6060+ try {
6161+ const res = await fetch("/api/crawl");
6262+ if (!res.ok) {
6363+ // 401 = not authenticated — not an error, just no data
6464+ setData({ mentions: null, followCount: 0, loading: false, error: null });
6565+ return;
6666+ }
6767+ const json = await res.json();
6868+ if (!cancelled) {
6969+ setData({
7070+ mentions: json.talkMentions,
7171+ followCount: json.followCount,
7272+ loading: false,
7373+ error: null,
7474+ });
7575+ }
7676+ } catch (err) {
7777+ if (!cancelled) {
7878+ setData({
7979+ mentions: null,
8080+ followCount: 0,
8181+ loading: false,
8282+ error: err instanceof Error ? err.message : "Crawl failed",
8383+ });
8484+ }
8585+ }
8686+ }
8787+8888+ fetchCrawl();
8989+ return () => { cancelled = true; };
9090+ }, []);
9191+9292+ return data;
9393+}
9494+```
9595+9696+The `cancelled` flag prevents state updates after unmount. The hook doesn't retry on failure — the crawl endpoint already has its own caching and timeout logic.
9797+9898+### 3.2 `src/components/scored-talks-grid.tsx` — scored grid wrapper
9999+100100+A `"use client"` component that:
101101+1. Receives `talks: TalkEntry[]` from the server component
102102+2. Calls `useCrawlData()` to get crawl results
103103+3. When data arrives, calls `rankTalks({ talks, mentions, followCount })` to produce scored + sorted talks
104104+4. Renders a `LumeCard` for each talk, passing `glowIntensity={score.intensity}`, `tileIndex`, and the `score` prop for the hover detail
105105+5. When no crawl data: renders talks in original order with `glowIntensity={0}`
106106+107107+```tsx
108108+"use client";
109109+110110+import { useMemo } from "react";
111111+import Link from "next/link";
112112+import { useCrawlData } from "@/hooks/useCrawlData";
113113+import { rankTalks, type TalkScore } from "@/lib/scoring";
114114+import { LumeCard } from "@/components/ui/lume-card";
115115+import { formatDuration } from "@/lib/format";
116116+import type { TalkEntry } from "@/lib/types";
117117+118118+interface ScoredTalksGridProps {
119119+ talks: TalkEntry[];
120120+}
121121+122122+export function ScoredTalksGrid({ talks }: ScoredTalksGridProps) {
123123+ const { mentions, followCount, loading } = useCrawlData();
124124+125125+ const scoredTalks: { talk: TalkEntry; score: TalkScore | null }[] = useMemo(() => {
126126+ if (!mentions) {
127127+ // Not authenticated or crawl not loaded — render unsorted, no scores
128128+ return talks.map((talk) => ({ talk, score: null }));
129129+ }
130130+ const scores = rankTalks({ talks, mentions, followCount });
131131+ // Match scores back to talks by rkey
132132+ return scores.map((score) => ({
133133+ talk: talks.find((t) => t.rkey === score.rkey)!,
134134+ score,
135135+ }));
136136+ }, [talks, mentions, followCount]);
137137+138138+ return (
139139+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
140140+ {scoredTalks.map(({ talk, score }, index) => (
141141+ <Link key={talk.rkey} href={`/talk/${talk.rkey}`}>
142142+ <LumeCard
143143+ glowIntensity={score?.intensity ?? 0}
144144+ tileIndex={index}
145145+ score={score}
146146+ >
147147+ {/* Card content: speakers, title, chips — same as current */}
148148+ </LumeCard>
149149+ </Link>
150150+ ))}
151151+ </div>
152152+ );
153153+}
154154+```
155155+156156+The `useMemo` ensures `rankTalks` only re-runs when the inputs change (not on every render). The `loading` state is available but not used for a spinner — the grid renders immediately and glow appears when data arrives.
157157+158158+---
159159+160160+## 4. Modified Files
161161+162162+### 4.1 `src/app/talks/page.tsx` — use `ScoredTalksGrid`
163163+164164+Replace the inline `<div className="grid ...">` that maps over talks with:
165165+166166+```tsx
167167+<ScoredTalksGrid talks={talks} />
168168+```
169169+170170+The server component still loads `data/talks.json`, filters by `transcriptFile`, and sorts by `startsAt`. It passes the full talks array to the client component, which re-sorts by score when crawl data arrives.
171171+172172+The `getAuthUser()` call at the top of the page (for the Nav) is unchanged — it's a server-side call that doesn't affect the scoring flow.
173173+174174+### 4.2 `src/components/ui/lume-card.tsx` — add hover/tap score detail
175175+176176+Add an optional `score: TalkScore | null` prop. When present and the card is hovered (desktop) or tapped (mobile), render a detail strip at the bottom of the card.
177177+178178+**Detail strip content by state:**
179179+180180+| `score.state` | Text | Color |
181181+|---|---|---|
182182+| `"missed"` | `"{X}% of your network missed this"` | `text-primary-fixed` (mint) |
183183+| `"engaged"` | `"Discussed by {N} of {T} follows"` | `text-on-surface-variant` (muted) |
184184+| `"unknown"` | (no detail strip) | — |
185185+| `null` | (no detail strip) | — |
186186+187187+Where:
188188+- `X` = `Math.round(score.layer1.attentionInverse * 100)` — e.g., 97
189189+- `N` = `score.layer1.uniqueFollows` — e.g., 3
190190+- `T` = `score.layer1.totalFollows` — e.g., 423
191191+192192+**Interaction:**
193193+- Desktop: detail appears on `:hover` via CSS transition (opacity 0 → 1, translateY(4px) → 0). No JavaScript state needed.
194194+- Mobile: detail is always visible when `score` is present (since there's no hover). This is acceptable because mobile cards are larger (single column) and the detail text is small.
195195+196196+**Implementation note:** The detail strip is inside the card but below the existing content. It uses `absolute` positioning at the bottom, with a subtle gradient fade from transparent to `surface-container-low` so it doesn't clip the card content.
197197+198198+---
199199+200200+## 5. Data Flow
201201+202202+```
203203+Server: talks/page.tsx
204204+ → reads data/talks.json
205205+ → filters + sorts by startsAt
206206+ → renders <Nav user={authUser} />
207207+ → renders <ScoredTalksGrid talks={talks} />
208208+209209+Client: ScoredTalksGrid mounts
210210+ → useCrawlData() fires fetch("/api/crawl")
211211+ → 401 (not auth) → mentions=null → grid renders with no glow
212212+ → 200 (auth) → { talkMentions, followCount } → rankTalks() → TalkScore[]
213213+ → grid re-renders with glow intensities + re-sorted by score
214214+ → each LumeCard receives intensity + score for hover detail
215215+```
216216+217217+---
218218+219219+## 6. Loading Behavior
220220+221221+**Phase 1 (immediate, server-rendered):** Talk grid appears with all cards at `glowIntensity=0`. Page is fully interactive. If not authenticated, this is the final state.
222222+223223+**Phase 2 (~2-5s after mount, client-side):** Crawl data arrives. Cards animate to their scored glow intensities. Grid re-sorts to put missed talks first. The visual effect is the "forest waking up" — cards that your network missed light up with bioluminescent glow.
224224+225225+No loading spinners, no skeleton screens, no loading text. The transition IS the feedback. The existing `LumeCard` glow CSS already uses transitions, so the intensity change animates smoothly.
226226+227227+**If crawl fails:** Grid stays in Phase 1 (no glow, original sort). The error is logged to console but not shown to the user — the page is still fully functional for browsing talks.
228228+229229+---
230230+231231+## 7. Sort Behavior
232232+233233+| State | Sort Order |
234234+|---|---|
235235+| Not authenticated | Original: by `startsAt` date (server-side) |
236236+| Authenticated, crawl loading | Original: by `startsAt` date |
237237+| Authenticated, crawl loaded | By score: `missed` first (intensity desc) → `engaged` (intensity desc) → `unknown` (rkey asc) |
238238+239239+The re-sort happens when crawl data arrives. Cards shift positions as glow appears. If this transition feels jarring, a CSS `transition` on grid item `order` can smooth it — but this is a polish item, not a blocker.
240240+241241+---
242242+243243+## 8. Edge Cases
244244+245245+- **Not authenticated:** Grid renders without scores. No `/api/crawl` call is made (the hook fetches, gets 401, sets `mentions=null`). Same as today.
246246+- **Zero follows:** `/api/crawl` returns `followCount: 0`. `rankTalks` produces all `unknown` states. Grid renders without glow. Acceptable — the user needs follows for the scoring to be meaningful.
247247+- **Crawl timeout (30s):** `/api/crawl` returns 504. Hook receives error. Grid stays in Phase 1.
248248+- **Partial crawl data:** Some talks have mentions, some don't (out-of-scope talks). `rankTalks` classifies absent talks as `unknown`. They sort to the bottom.
249249+- **Empty talks array:** Grid renders empty (same as today).
250250+251251+---
252252+253253+## 9. Non-goals
254254+255255+- **Sliders** (#20) — scoring weights are fixed at `DEFAULT_WEIGHTS` for this issue
256256+- **Coverage map** (#25, #26) — separate visualization, different layout
257257+- **Talk page scoring** — deferred to a follow-up (option C: "who discussed this" with profile resolution)
258258+- **Interest match badges** — Layer 2 not live
259259+- **Friend recommendation badges** — Layer 3 not live
260260+- **Grid re-sort animation** — polish follow-up if the abrupt re-sort feels jarring
261261+- **Error UI for crawl failure** — the page works without scores; no user-visible error needed
262262+263263+---
264264+265265+## 10. Acceptance Criteria
266266+267267+- [ ] `src/hooks/useCrawlData.ts` exists and exports `useCrawlData`
268268+- [ ] `src/components/scored-talks-grid.tsx` exists as a `"use client"` component
269269+- [ ] `/talks` page uses `ScoredTalksGrid` instead of inline grid
270270+- [ ] Authenticated users see glow on missed talks after crawl data loads
271271+- [ ] Hover (desktop) or tap (mobile) shows score detail: "X% of your network missed this" or "Discussed by N of T follows"
272272+- [ ] Unauthenticated users see the grid without glow (same as today)
273273+- [ ] Grid re-sorts by score when authenticated + crawl loaded
274274+- [ ] `LumeCard` accepts `score: TalkScore | null` prop for the detail strip
275275+- [ ] `npx tsc --noEmit` clean
276276+- [ ] `npx eslint src/` clean
277277+- [ ] `npm test` passes (existing 40 scoring tests)
278278+- [ ] `npm run build` succeeds
279279+- [ ] Manual test on staging: log in, see glow appear, hover for detail