···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+ className="h-full"
144144+ glowIntensity={score?.intensity ?? 0}
145145+ tileIndex={index}
146146+ score={score}
147147+ >
148148+ {/* Card content: speakers, title, room chip, duration chip.
149149+ Copy the existing JSX from talks/page.tsx (the <div className="p-5">
150150+ block with speakers, h2 title, and metadata chips). */}
151151+ </LumeCard>
152152+ </Link>
153153+ ))}
154154+ </div>
155155+ );
156156+}
157157+```
158158+159159+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.
160160+161161+---
162162+163163+## 4. Modified Files
164164+165165+### 4.1 `src/app/talks/page.tsx` — use `ScoredTalksGrid`
166166+167167+Replace the inline `<div className="grid ...">` that maps over talks with:
168168+169169+```tsx
170170+<ScoredTalksGrid talks={talks} />
171171+```
172172+173173+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.
174174+175175+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.
176176+177177+### 4.2 `src/components/ui/lume-card.tsx` — add hover/tap score detail
178178+179179+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.
180180+181181+**Detail strip content by state:**
182182+183183+| `score.state` | Text | Color |
184184+|---|---|---|
185185+| `"missed"` | `"Your network missed this"` | `text-primary-fixed` (mint) |
186186+| `"engaged"` | `"{X}% of your network missed this"` | `text-on-surface-variant` (muted) |
187187+| `"unknown"` | (no detail strip) | — |
188188+| `null` | (no detail strip) | — |
189189+190190+Where:
191191+- `X` = `Math.round(score.layer1.attentionInverse * 100)` — e.g., 83
192192+193193+**Why no percentage for "missed":** The `missed` state means `uniqueFollows === 0`, so `attentionInverse` is always exactly `1.0` — the percentage would always read "100%". A static message is clearer and avoids pointless math. For `engaged` talks, the percentage IS meaningful: "83% of your network missed this" conveys the gradient between "almost nobody talked about it" and "most of your follows discussed it."
194194+195195+**Interaction:**
196196+- Desktop (`sm:` and above): detail is hidden by default, revealed on `:hover` via CSS transition (`max-height` + `opacity`). Uses Tailwind `group`/`group-hover:` — no JavaScript state needed.
197197+- Mobile (below `sm:`): detail is always visible when `score` is present (since there's no hover). Acceptable because mobile cards are full-width and the detail text is small.
198198+199199+**Implementation note:** The detail strip uses flow-based expansion (`max-h-0`/`max-h-12` + `overflow-hidden`) rather than absolute positioning. This avoids layout complexity and works naturally with the card's existing padding. The `transition-all duration-300` on `sm:` breakpoint smoothly animates the reveal on desktop hover.
200200+201201+---
202202+203203+## 5. Data Flow
204204+205205+```
206206+Server: talks/page.tsx
207207+ → reads data/talks.json
208208+ → filters + sorts by startsAt
209209+ → renders <Nav user={authUser} />
210210+ → renders <ScoredTalksGrid talks={talks} />
211211+212212+Client: ScoredTalksGrid mounts
213213+ → useCrawlData() fires fetch("/api/crawl")
214214+ → 401 (not auth) → mentions=null → grid renders with no glow
215215+ → 200 (auth) → { talkMentions, followCount } → rankTalks() → TalkScore[]
216216+ → grid re-renders with glow intensities + re-sorted by score
217217+ → each LumeCard receives intensity + score for hover detail
218218+```
219219+220220+---
221221+222222+## 6. Loading Behavior
223223+224224+**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.
225225+226226+**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.
227227+228228+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.
229229+230230+**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.
231231+232232+---
233233+234234+## 7. Sort Behavior
235235+236236+| State | Sort Order |
237237+|---|---|
238238+| Not authenticated | Original: by `startsAt` date (server-side) |
239239+| Authenticated, crawl loading | Original: by `startsAt` date |
240240+| Authenticated, crawl loaded | By score: `missed` first (intensity desc) → `engaged` (intensity desc) → `unknown` (rkey asc) |
241241+242242+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.
243243+244244+---
245245+246246+## 8. Edge Cases
247247+248248+- **Not authenticated:** Grid renders without scores. The hook fires `fetch("/api/crawl")` which returns 401; the hook treats this as "no data" and sets `mentions=null`. Same visual as today.
249249+- **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.
250250+- **Crawl timeout (30s):** `/api/crawl` returns 504. Hook receives error. Grid stays in Phase 1.
251251+- **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.
252252+- **Empty talks array:** Grid renders empty (same as today).
253253+254254+---
255255+256256+## 9. Non-goals
257257+258258+- **Sliders** (#20) — scoring weights are fixed at `DEFAULT_WEIGHTS` for this issue
259259+- **Coverage map** (#25, #26) — separate visualization, different layout
260260+- **Talk page scoring** — deferred to a follow-up (option C: "who discussed this" with profile resolution)
261261+- **Interest match badges** — Layer 2 not live
262262+- **Friend recommendation badges** — Layer 3 not live
263263+- **Grid re-sort animation** — polish follow-up if the abrupt re-sort feels jarring
264264+- **Error UI for crawl failure** — the page works without scores; no user-visible error needed
265265+266266+---
267267+268268+## 10. Acceptance Criteria
269269+270270+- [ ] `src/hooks/useCrawlData.ts` exists and exports `useCrawlData`
271271+- [ ] `src/components/scored-talks-grid.tsx` exists as a `"use client"` component
272272+- [ ] `/talks` page uses `ScoredTalksGrid` instead of inline grid
273273+- [ ] Authenticated users see glow on missed talks after crawl data loads
274274+- [ ] Hover (desktop) or tap (mobile) shows score detail: "X% of your network missed this" or "Discussed by N of T follows"
275275+- [ ] Unauthenticated users see the grid without glow (same as today)
276276+- [ ] Grid re-sorts by score when authenticated + crawl loaded
277277+- [ ] `LumeCard` accepts `score: TalkScore | null` prop for the detail strip
278278+- [ ] `npx tsc --noEmit` clean
279279+- [ ] `npx eslint src/` clean
280280+- [ ] `npm test` passes (existing 40 scoring tests)
281281+- [ ] `npm run build` succeeds
282282+- [ ] Manual test on staging: log in, see glow appear, hover for detail
+2-26
src/app/talks/page.tsx
···11import * as fs from "fs";
22import * as path from "path";
33-import Link from "next/link";
43import { Nav } from "@/components/ui/nav";
55-import { Chip } from "@/components/ui/chip";
66-import { LumeCard } from "@/components/ui/lume-card";
77-import { formatDuration } from "@/lib/format";
44+import { ScoredTalksGrid } from "@/components/scored-talks-grid";
85import { getAuthUser } from "@/lib/auth/user";
96import type { TalkEntry } from "@/lib/types";
107···4340 </p>
4441 </header>
45424646- <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
4747- {talks.map((talk) => (
4848- <Link key={talk.rkey} href={`/talk/${talk.rkey}`}>
4949- <LumeCard className="h-full">
5050- <div className="p-5">
5151- {talk.speakers.length > 0 && (
5252- <p className="text-label-md text-primary-fixed-dim mb-2">
5353- {talk.speakers.map((s) => s.name).join(", ")}
5454- </p>
5555- )}
5656- <h2 className="text-headline-sm text-on-surface mb-3">
5757- {talk.title}
5858- </h2>
5959- <div className="flex flex-wrap gap-2">
6060- {talk.room && <Chip>{talk.room}</Chip>}
6161- <Chip>{formatDuration(talk.durationMs)}</Chip>
6262- </div>
6363- </div>
6464- </LumeCard>
6565- </Link>
6666- ))}
6767- </div>
4343+ <ScoredTalksGrid talks={talks} />
6844 </main>
6945 </>
7046 );