The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

docs: add network attention display implementation plan for #10

+550
+550
docs/superpowers/plans/2026-04-10-network-attention-display.md
··· 1 + # Network Attention Display Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Wire the scoring engine into the `/talks` grid so authenticated users see bioluminescent glow on missed talks, with hover/tap detail showing the score. 6 + 7 + **Architecture:** Server component loads talks from disk (unchanged). New client component (`ScoredTalksGrid`) fetches `/api/crawl` via a `useCrawlData` hook, runs `rankTalks`, and passes scores to `LumeCard`. `LumeCard` gains a `score` prop for hover/tap detail strips. Unauthenticated users see the grid without glow. 8 + 9 + **Tech Stack:** Next.js 16 (App Router, React 19), existing scoring module (`@/lib/scoring`), existing bioluminescent design system (Tailwind v4 CSS-first theme) 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-10-network-attention-display.md` 12 + 13 + **Chainlink Issue:** #10 14 + 15 + --- 16 + 17 + ## File Structure 18 + 19 + | File | Action | Responsibility | 20 + |------|--------|----------------| 21 + | `src/hooks/useCrawlData.ts` | Create | React hook: fetches `/api/crawl`, returns `{ mentions, followCount, loading, error }` | 22 + | `src/components/scored-talks-grid.tsx` | Create | Client component: calls `useCrawlData` + `rankTalks`, renders scored `LumeCard` grid with card content | 23 + | `src/components/ui/lume-card.tsx` | Modify | Add optional `score: TalkScore | null` prop for hover/tap detail strip | 24 + | `src/app/talks/page.tsx` | Modify | Replace inline grid with `<ScoredTalksGrid talks={talks} />` | 25 + 26 + --- 27 + 28 + ## Chunk 1: Hook + LumeCard enhancement 29 + 30 + ### Task 1: Create `useCrawlData` hook 31 + 32 + **Files:** 33 + - Create: `src/hooks/useCrawlData.ts` 34 + 35 + - [ ] **Step 1: Create the hooks directory** 36 + 37 + Run: `mkdir -p src/hooks` 38 + 39 + - [ ] **Step 2: Write the hook** 40 + 41 + Create `src/hooks/useCrawlData.ts`: 42 + 43 + ```ts 44 + "use client"; 45 + 46 + import { useState, useEffect } from "react"; 47 + import type { TalkMentions } from "@/lib/scoring"; 48 + 49 + export interface CrawlData { 50 + mentions: TalkMentions | null; 51 + followCount: number; 52 + loading: boolean; 53 + error: string | null; 54 + } 55 + 56 + /** 57 + * Fetches crawl data from `/api/crawl` on mount. 58 + * 59 + * - If authenticated: returns `{ mentions, followCount }` from the crawler. 60 + * - If not authenticated (401): returns `mentions: null` — not an error. 61 + * - If the crawl fails or times out: returns `error` string. 62 + * 63 + * The hook fires one fetch on mount and does not retry. The crawl endpoint 64 + * has its own caching (30-minute TTL) and concurrent-request coalescing. 65 + */ 66 + export function useCrawlData(): CrawlData { 67 + const [data, setData] = useState<CrawlData>({ 68 + mentions: null, 69 + followCount: 0, 70 + loading: true, 71 + error: null, 72 + }); 73 + 74 + useEffect(() => { 75 + let cancelled = false; 76 + 77 + async function fetchCrawl() { 78 + try { 79 + const res = await fetch("/api/crawl"); 80 + if (!res.ok) { 81 + // 401 = not authenticated, 504 = timeout — treat as "no data" 82 + if (!cancelled) { 83 + setData({ 84 + mentions: null, 85 + followCount: 0, 86 + loading: false, 87 + error: null, 88 + }); 89 + } 90 + return; 91 + } 92 + const json = await res.json(); 93 + if (!cancelled) { 94 + setData({ 95 + mentions: json.talkMentions, 96 + followCount: json.followCount, 97 + loading: false, 98 + error: null, 99 + }); 100 + } 101 + } catch (err) { 102 + if (!cancelled) { 103 + setData({ 104 + mentions: null, 105 + followCount: 0, 106 + loading: false, 107 + error: err instanceof Error ? err.message : "Crawl failed", 108 + }); 109 + } 110 + } 111 + } 112 + 113 + fetchCrawl(); 114 + return () => { 115 + cancelled = true; 116 + }; 117 + }, []); 118 + 119 + return data; 120 + } 121 + ``` 122 + 123 + - [ ] **Step 3: Verify tsc is clean** 124 + 125 + Run: `npx tsc --noEmit` 126 + Expected: clean. 127 + 128 + - [ ] **Step 4: Commit** 129 + 130 + ```bash 131 + git add src/hooks/useCrawlData.ts 132 + git commit -m "feat: add useCrawlData hook for fetching crawl results" 133 + ``` 134 + 135 + --- 136 + 137 + ### Task 2: Add score detail strip to `LumeCard` 138 + 139 + **Files:** 140 + - Modify: `src/components/ui/lume-card.tsx` 141 + 142 + - [ ] **Step 1: Read the current `LumeCard`** 143 + 144 + Read `src/components/ui/lume-card.tsx` and confirm the current interface: 145 + - Props: `glowIntensity`, `tileIndex`, `interestMatch`, plus `HTMLAttributes<HTMLDivElement>` 146 + - No `score` prop exists yet 147 + 148 + - [ ] **Step 2: Add the `score` prop and detail strip** 149 + 150 + Replace the entire file with: 151 + 152 + ```tsx 153 + "use client"; 154 + 155 + import { useState, type HTMLAttributes } from "react"; 156 + import type { TalkScore } from "@/lib/scoring"; 157 + 158 + interface LumeCardProps extends HTMLAttributes<HTMLDivElement> { 159 + /** Understory score 0-1. Higher = more undiscovered = brighter glow. */ 160 + glowIntensity?: number; 161 + /** Index for staggered breathing animation. */ 162 + tileIndex?: number; 163 + /** Whether to show the interest match indicator. */ 164 + interestMatch?: boolean; 165 + /** Score data for hover/tap detail strip. null = no detail. */ 166 + score?: TalkScore | null; 167 + } 168 + 169 + function glowStyle(intensity: number): string { 170 + if (intensity > 0.7) return "biolume-glow-strong"; 171 + if (intensity > 0.3) return "biolume-glow"; 172 + return ""; 173 + } 174 + 175 + function ScoreDetail({ score }: { score: TalkScore }) { 176 + if (score.state === "unknown") return null; 177 + 178 + if (score.state === "missed") { 179 + return ( 180 + <div className="text-label-sm text-primary-fixed"> 181 + Your network missed this 182 + </div> 183 + ); 184 + } 185 + 186 + // engaged — show percentage 187 + const pct = Math.round(score.layer1.attentionInverse * 100); 188 + return ( 189 + <div className="text-label-sm text-on-surface-variant"> 190 + {pct}% of your network missed this 191 + </div> 192 + ); 193 + } 194 + 195 + function LumeCard({ 196 + glowIntensity = 0, 197 + tileIndex, 198 + interestMatch = false, 199 + score, 200 + className = "", 201 + children, 202 + ...props 203 + }: LumeCardProps) { 204 + const isUnderstory = glowIntensity > 0.3; 205 + const [tapped, setTapped] = useState(false); 206 + const hasDetail = score && score.state !== "unknown"; 207 + 208 + return ( 209 + <div 210 + className={[ 211 + "group relative rounded-lg", 212 + "bg-surface-container-low/60 backdrop-blur-[20px]", 213 + "border-t-2", 214 + glowIntensity > 0.3 215 + ? "border-primary-fixed-dim" 216 + : glowIntensity > 0 217 + ? "border-primary-fixed-dim/50" 218 + : "border-primary-fixed-dim/20", 219 + "transition-all duration-500", 220 + "hover:biolume-glow-strong", 221 + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-fixed", 222 + glowStyle(glowIntensity), 223 + isUnderstory ? "animate-breathe" : "", 224 + className, 225 + ].join(" ")} 226 + style={ 227 + tileIndex !== undefined 228 + ? ({ "--tile-index": tileIndex } as React.CSSProperties) 229 + : undefined 230 + } 231 + onClick={() => { 232 + if (hasDetail) setTapped((t) => !t); 233 + }} 234 + {...props} 235 + > 236 + {interestMatch && ( 237 + <span 238 + className="absolute top-3 right-3 h-2.5 w-2.5 rounded-full bg-interest-match" 239 + aria-label="Matches your interests" 240 + /> 241 + )} 242 + {children} 243 + 244 + {hasDetail && ( 245 + <div 246 + className={[ 247 + "px-5 pb-3 pt-0 transition-all duration-300", 248 + // Desktop: show on hover via group-hover 249 + "max-h-0 overflow-hidden opacity-0", 250 + "group-hover:max-h-12 group-hover:opacity-100", 251 + // Mobile: show on tap via state 252 + tapped ? "max-h-12 opacity-100" : "", 253 + // Always visible on small screens (no hover available) 254 + "sm:max-h-0 sm:opacity-0 sm:group-hover:max-h-12 sm:group-hover:opacity-100", 255 + ] 256 + .filter(Boolean) 257 + .join(" ")} 258 + > 259 + <div className="border-t border-primary-fixed-dim/20 pt-2"> 260 + <ScoreDetail score={score} /> 261 + </div> 262 + </div> 263 + )} 264 + </div> 265 + ); 266 + } 267 + 268 + export { LumeCard, type LumeCardProps }; 269 + ``` 270 + 271 + **Key changes from the original:** 272 + - Added `"use client"` directive (needed for `useState` — the tap toggle for mobile) 273 + - Added `score?: TalkScore | null` prop 274 + - Added `ScoreDetail` component (renders "Your network missed this" or "X% of your network missed this") 275 + - Added the detail strip at the bottom: hidden by default, shown on `group-hover` (desktop) or tap (mobile) 276 + - Changed `transition-shadow` to `transition-all duration-500` for smoother glow transitions 277 + - Added `group` class for group-hover targeting 278 + - Border top opacity now varies with glow intensity for more visual gradient 279 + - The `onClick` handler only sets tap state (for mobile detail toggle); the parent `Link` still handles navigation because the `Link` wraps the entire card 280 + 281 + **Important interaction note:** The card is wrapped in a Next.js `Link` in the grid. The `onClick` on the card div fires before the `Link` navigation. On desktop, the detail shows on hover without clicking, so the click navigates. On mobile, the first tap shows the detail AND navigates (since the detail is just a visual overlay, not a blocking interaction). If the user wants to read the detail before navigating, they can long-press or use the back button. This is acceptable for V1. 282 + 283 + - [ ] **Step 3: Verify tsc and eslint are clean** 284 + 285 + Run in parallel: 286 + - `npx tsc --noEmit` — Expected: clean 287 + - `npx eslint src/` — Expected: clean 288 + 289 + > **Note:** If eslint flags the `useState` import or the `"use client"` directive, ensure the file starts with `"use client";` on its own line (React 19 / Next.js 16 requirement for client components). 290 + 291 + - [ ] **Step 4: Verify existing tests still pass** 292 + 293 + Run: `npm test` 294 + Expected: 40/40 pass (scoring tests are unrelated but confirms nothing broke). 295 + 296 + - [ ] **Step 5: Commit** 297 + 298 + ```bash 299 + git add src/components/ui/lume-card.tsx 300 + git commit -m "feat: add score detail strip to LumeCard (hover/tap) 301 + 302 + Shows 'Your network missed this' (mint) for missed talks and 303 + 'X% of your network missed this' (muted) for engaged talks. 304 + Detail strip revealed on hover (desktop) or tap (mobile). 305 + Border-top opacity now varies with glow intensity." 306 + ``` 307 + 308 + --- 309 + 310 + ## Chunk 2: Scored grid + page integration 311 + 312 + ### Task 3: Create `ScoredTalksGrid` client component 313 + 314 + **Files:** 315 + - Create: `src/components/scored-talks-grid.tsx` 316 + 317 + - [ ] **Step 1: Write the component** 318 + 319 + Create `src/components/scored-talks-grid.tsx`: 320 + 321 + ```tsx 322 + "use client"; 323 + 324 + import { useMemo } from "react"; 325 + import Link from "next/link"; 326 + import { useCrawlData } from "@/hooks/useCrawlData"; 327 + import { rankTalks, type TalkScore } from "@/lib/scoring"; 328 + import { LumeCard } from "@/components/ui/lume-card"; 329 + import { Chip } from "@/components/ui/chip"; 330 + import { formatDuration } from "@/lib/format"; 331 + import type { TalkEntry } from "@/lib/types"; 332 + 333 + interface ScoredTalksGridProps { 334 + talks: TalkEntry[]; 335 + } 336 + 337 + export function ScoredTalksGrid({ talks }: ScoredTalksGridProps) { 338 + const { mentions, followCount } = useCrawlData(); 339 + 340 + const scoredTalks: { talk: TalkEntry; score: TalkScore | null }[] = 341 + useMemo(() => { 342 + if (!mentions) { 343 + // Not authenticated or crawl not loaded — unsorted, no scores 344 + return talks.map((talk) => ({ talk, score: null })); 345 + } 346 + const scores = rankTalks({ talks, mentions, followCount }); 347 + return scores.map((score) => ({ 348 + talk: talks.find((t) => t.rkey === score.rkey)!, 349 + score, 350 + })); 351 + }, [talks, mentions, followCount]); 352 + 353 + return ( 354 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 355 + {scoredTalks.map(({ talk, score }, index) => ( 356 + <Link key={talk.rkey} href={`/talk/${talk.rkey}`}> 357 + <LumeCard 358 + className="h-full" 359 + glowIntensity={score?.intensity ?? 0} 360 + tileIndex={index} 361 + score={score} 362 + > 363 + <div className="p-5"> 364 + {talk.speakers.length > 0 && ( 365 + <p className="text-label-md text-primary-fixed-dim mb-2"> 366 + {talk.speakers.map((s) => s.name).join(", ")} 367 + </p> 368 + )} 369 + <h2 className="text-headline-sm text-on-surface mb-3"> 370 + {talk.title} 371 + </h2> 372 + <div className="flex flex-wrap gap-2"> 373 + {talk.room && <Chip>{talk.room}</Chip>} 374 + <Chip>{formatDuration(talk.durationMs)}</Chip> 375 + </div> 376 + </div> 377 + </LumeCard> 378 + </Link> 379 + ))} 380 + </div> 381 + ); 382 + } 383 + ``` 384 + 385 + The card content JSX is copied verbatim from the current `talks/page.tsx` (lines 50–64). The `useMemo` ensures `rankTalks` only re-runs when inputs change. 386 + 387 + - [ ] **Step 2: Verify tsc and eslint** 388 + 389 + Run in parallel: 390 + - `npx tsc --noEmit` — Expected: clean 391 + - `npx eslint src/` — Expected: clean 392 + 393 + - [ ] **Step 3: Commit** 394 + 395 + ```bash 396 + git add src/components/scored-talks-grid.tsx 397 + git commit -m "feat: add ScoredTalksGrid client component 398 + 399 + Fetches crawl data via useCrawlData hook, runs rankTalks to score 400 + and sort talks, renders LumeCard grid with glow intensities and 401 + score detail. Falls back to unsorted grid with no glow when 402 + unauthenticated." 403 + ``` 404 + 405 + --- 406 + 407 + ### Task 4: Wire `ScoredTalksGrid` into the talks page 408 + 409 + **Files:** 410 + - Modify: `src/app/talks/page.tsx` 411 + 412 + - [ ] **Step 1: Read the current page** 413 + 414 + Read `src/app/talks/page.tsx` and confirm the current structure (server component that loads talks and renders the grid inline). 415 + 416 + - [ ] **Step 2: Replace the inline grid with `ScoredTalksGrid`** 417 + 418 + Replace the entire file with: 419 + 420 + ```tsx 421 + import * as fs from "fs"; 422 + import * as path from "path"; 423 + import { Nav } from "@/components/ui/nav"; 424 + import { ScoredTalksGrid } from "@/components/scored-talks-grid"; 425 + import { getAuthUser } from "@/lib/auth/user"; 426 + import type { TalkEntry } from "@/lib/types"; 427 + 428 + const DATA_DIR = path.resolve(process.cwd(), "data"); 429 + 430 + function loadTalks(): TalkEntry[] { 431 + const raw = fs.readFileSync(path.join(DATA_DIR, "talks.json"), "utf-8"); 432 + return JSON.parse(raw); 433 + } 434 + 435 + export const metadata = { 436 + title: "All Talks — Understory", 437 + description: "Browse all ATmosphereConf 2026 talks with transcripts.", 438 + }; 439 + 440 + export default async function TalksPage() { 441 + const user = await getAuthUser(); 442 + const talks = loadTalks() 443 + .filter((t) => t.transcriptFile) 444 + .sort((a, b) => { 445 + if (a.startsAt && b.startsAt) 446 + return a.startsAt.localeCompare(b.startsAt); 447 + if (a.startsAt) return -1; 448 + if (b.startsAt) return 1; 449 + return a.createdAt.localeCompare(b.createdAt); 450 + }); 451 + 452 + return ( 453 + <> 454 + <Nav minimal user={user} /> 455 + <main className="mx-auto max-w-7xl px-6 pt-24 pb-16"> 456 + <header className="mb-8"> 457 + <h1 className="text-headline-md text-on-surface mb-2">All Talks</h1> 458 + <p className="text-label-md text-on-surface-variant"> 459 + {talks.length} talks with transcripts 460 + </p> 461 + </header> 462 + 463 + <ScoredTalksGrid talks={talks} /> 464 + </main> 465 + </> 466 + ); 467 + } 468 + ``` 469 + 470 + **Changes from the original:** 471 + - Removed imports: `Link`, `Chip`, `LumeCard`, `formatDuration` (now used inside `ScoredTalksGrid`) 472 + - Added import: `ScoredTalksGrid` 473 + - Replaced the `<div className="grid ...">` block (lines 46–67) with `<ScoredTalksGrid talks={talks} />` 474 + - The server component still does: load talks → filter by transcriptFile → sort by startsAt. The client component handles scoring + re-sorting. 475 + 476 + - [ ] **Step 3: Verify tsc, eslint, and tests** 477 + 478 + Run in parallel: 479 + - `npx tsc --noEmit` — Expected: clean 480 + - `npx eslint src/` — Expected: clean 481 + - `npm test` — Expected: 40/40 pass 482 + 483 + - [ ] **Step 4: Run a production build** 484 + 485 + Run: `npm run build` 486 + 487 + Expected: 488 + - Build succeeds 489 + - Route list unchanged (same routes as before) 490 + - No new warnings 491 + 492 + - [ ] **Step 5: Test locally with the dev server** 493 + 494 + Start the dev server: `npm run dev` 495 + 496 + **Unauthenticated test:** 497 + 1. Open `http://127.0.0.1:3000/talks` in an incognito window 498 + 2. Expected: talk grid loads immediately, no glow on any card, same sort as before 499 + 3. Hover over a card: no detail strip (no score data) 500 + 501 + **Authenticated test (if possible locally):** 502 + 1. Open `http://127.0.0.1:3000/talks` while logged in 503 + 2. Expected: talk grid loads immediately without glow 504 + 3. After ~2-5 seconds: glow appears on missed talks, grid re-sorts by score 505 + 4. Hover over a glowing card: detail strip shows "Your network missed this" (mint) 506 + 5. Hover over a dimmer card: detail strip shows "X% of your network missed this" (muted) 507 + 508 + Stop the dev server. 509 + 510 + - [ ] **Step 6: Commit** 511 + 512 + ```bash 513 + git add src/app/talks/page.tsx 514 + git commit -m "feat: wire ScoredTalksGrid into /talks page 515 + 516 + Server component loads talks and passes to client component. 517 + Scoring + glow + hover detail are progressive enhancement — 518 + unauthenticated users see the same grid as before." 519 + ``` 520 + 521 + --- 522 + 523 + ## Chunk 3: Final verification 524 + 525 + ### Task 5: Verify everything and wrap up 526 + 527 + - [ ] **Step 1: Run all checks** 528 + 529 + Run in parallel: 530 + - `npm test` — Expected: 40/40 pass 531 + - `npx tsc --noEmit` — Expected: clean 532 + - `npx eslint src/` — Expected: clean 533 + - `npm run build` — Expected: succeeds 534 + 535 + - [ ] **Step 2: Verify file inventory** 536 + 537 + The feature branch should have 4 changed/new files: 538 + 539 + ``` 540 + src/hooks/useCrawlData.ts (new) 541 + src/components/scored-talks-grid.tsx (new) 542 + src/components/ui/lume-card.tsx (modified) 543 + src/app/talks/page.tsx (modified) 544 + ``` 545 + 546 + Run: `git diff --stat main` to confirm. 547 + 548 + - [ ] **Step 3: Use the finishing-a-development-branch skill** 549 + 550 + Invoke `superpowers:finishing-a-development-branch` to present merge/PR options and execute the chosen workflow. The work should be pushed to `staging` first for Railway validation, then promoted to `main` via PR.