--- name: dashboard-builder description: Build Next.js dashboard pages that consume domain proxy APIs. Use when the user wants to create a dashboard, build a UI page, add a search interface, display data from captured APIs, create comparison views, or build any frontend that calls /api// endpoints. --- # Dashboard Builder Create Next.js dashboard pages that consume domain proxy API endpoints. Each page lives in `apps/web/src/app/(dashboard)/` and uses shadcn/ui components. **Development principle: DEBUG logging is mandatory.** The build loop IS the debug-log + screenshot loop. `import { DEBUG } from '@interceptor/shared'` in every new file. Add `DEBUG('component-name', () => ({ step, data }))` at every data flow point: API fetch, response parsing, state updates, render decisions. Build a component → check debug logs to verify data flow → screenshot it → fix what's wrong → re-screenshot. **Verification output is required input for the next step** (see CLAUDE.md "The Rule That Makes This Work"). **Single browser instance — sequential calls only.** See api-discovery skill "Gotchas" section for details and code patterns. **Prompt compliance gate:** Before committing: list every prompt requirement, state evidence for each (curl output, screenshot, Patchright click). Any requirement without evidence = not done. Loop until all have evidence. ## Design By Reference — Match a Real Website The best way to produce quality UI is to copy an existing one. When building a dashboard: 1. **Pick a real website as the template.** Choose a well-designed site that serves similar data. The gap between your screenshot and the template IS the bug. See `/packages/test-server` for test site examples and `/domains/boardshop/` for reference domain patterns. 2. **Screenshot the template.** Capture the target site at 1280x800 and 375x800 (mobile): ```bash node -e " const { chromium } = require('patchright'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); await page.goto('TARGET_URL', { waitUntil: 'networkidle' }); await page.screenshot({ path: '/tmp/template-desktop.png' }); await page.setViewportSize({ width: 375, height: 800 }); await page.screenshot({ path: '/tmp/template-mobile.png' }); await browser.close(); })(); " ``` 3. **Build to match.** After each change, screenshot your work AND read the template screenshot. Compare: - Layout structure (grid columns, sidebar, header position) - Information density (items per row, spacing between cards) - Typography hierarchy (title size vs metadata size) - Color usage (dark theme, accent colors, muted text) - Component patterns (card shapes, badges, thumbnails) - **Interactive controls** (upvote arrows, flag icons, hide/collapse buttons, vote affordances — if the template shows a control on each item, your dashboard must too) - **Element presence on every row/card** (check every row in both screenshots, not just the first) - **Navigation separators and spacing** (pipes, dots, dashes between nav links) 4. **The gap between screenshots IS the bug.** This is objective — no subjective "does it look good." Either your layout matches the template or it doesn't. Fix the differences. 5. **When the prompt adds features not in the wireframe:** Add new features in a way that preserves the wireframe's layout structure. Inline additions (badges on existing rows, tooltips on existing elements) are preferred over new layout sections (sidebars, panels, extra columns). If a feature requires a new layout section, place it BELOW the main content on mobile and as a narrow aside on desktop — never wider than 25% of the viewport. ### When the reference site's aesthetic conflicts with shadcn/ui defaults If the reference site uses a legacy aesthetic (custom fonts, table-based layout, non-card list items), do NOT abandon shadcn/ui entirely. Instead: 1. Use shadcn/ui for **structure and behavior primitives** (Input, Button, Skeleton, Alert, Badge, Sheet) — these handle focus, accessibility, ARIA, and interaction. 2. Override **visual tokens only** with a scoped CSS class or inline `style` prop for brand colors and typography. 3. Never write a 400+ line custom CSS file to replace shadcn primitives. If you find yourself writing `.custom-search-input { border: 1px solid #ccc; }`, stop and use `` instead. ```tsx // Prefer Tailwind classes when a utility exists // Use inline style only for values Tailwind can't express (custom brand fonts, exact hex) // For plain-text link buttons (like HN "comments" or "More"): // For error banners matching a non-shadcn aesthetic: {error} ``` **The rule: if it's clickable, use `Button`. If it shows a status message, use `Alert`. Override the visual tokens, not the component choice.** A raw ``. Save template screenshots to `/tmp/template-/` for reference throughout the build. ## Visual Quality Standard - **Spacing**: `gap-4`/`gap-6` between sections; `p-4` inside cards — no arbitrary pixels - **Typography**: title `text-2xl font-bold` → labels `text-sm text-muted-foreground uppercase tracking-wide` → values `text-2xl font-semibold` - **Color**: green = positive, red = negative, `text-muted-foreground` = secondary - **States**: every container needs `` (loading) + empty state message — never blank - **Badges** for categorical values (sources, status, sentiment) — never raw text - **Cards**: `border border-border/50 rounded-lg` — no heavy shadows ## Prerequisites — GATE: do not proceed without proof Domain plugins registered, `pnpm run dev` (ports 3000/3001). **Verify the data layer returns real data before building UI.** For HTTP routes: curl. For WebSocket streams: connect and observe messages. For any protocol: the verification must produce observable output proving real data flows end-to-end. If you can't verify it, you can't build on it. **Type-verify the API response.** After curl-verifying an endpoint, compare the curl JSON output field names against your TypeScript response interface. If a field in your interface does not appear in the curl output (or vice versa), fix the type before writing any component code. `as ResponseType` does not validate at runtime — mismatched fields produce `undefined` silently. **Routes must use network interception, not DOM extraction.** Every route that serves data must intercept a network request (XHR, WebSocket, GraphQL, etc.) — not parse rendered HTML via `page.evaluate()`. If a route uses `page.evaluate()` for data extraction, it violates the discovery protocol and must be rewritten. The Transport Elimination table from `discovery.md` must exist before any route is created. If any endpoint returns empty or errors, stop and fix the API layer using debug-logs skill. If data looks wrong or encoded, see CLAUDE.md "Unexpected Output Is Information, Not Failure" — investigate the transformation before concluding something is broken. ## Step 0: Cache Fixture Data (before building ANY UI) After API routes are proven with curl, cache ALL responses as fixtures. This eliminates browser dependency during UI development — every reload is instant (0ms vs 30-60s). ```bash mkdir -p data/fixtures/{domain} curl -s http://localhost:3001/api/{domain}/search?q=test > data/fixtures/{domain}/search.json curl -s http://localhost:3001/api/{domain}/detail/123 > data/fixtures/{domain}/detail.json ``` Then develop with `FIXTURE_DIR=data/fixtures pnpm dev` — the API serves cached data instantly. Switch to live mode only for final integration testing. **Why this is mandatory:** UI iteration requires 10-50 reloads. At 30-60s per live request, that's 5-50 minutes of pure waiting. With fixtures, it's under 1 second total. ## Component Architecture Split components by view — one file per view, one shared types file. Each view component should be under 200 lines: - `*-types.ts` — types, interfaces, helper functions, and shared constants (PAGE_SIZE, API paths, color maps) - Reusable cards/items as separate components - One file per view (search, channel, detail, downloads) - Main content file is just the router/state switcher — under 150 lines - Site chrome (header, footer, nav bar) goes in separate components even when they contain state-dependent logic. Pass state as props. - Recursive components (e.g., CommentTree): extract the recursive item as a separate component file. The parent handles state/routing; the child handles rendering + recursion. Run `pnpm biome check --write --unsafe .` before manual lint cleanup. Only manually fix what auto-fix can't. ## Steps 1-3: Plan + Create Route 1. Plan: data endpoints, interactions, layout, multi-domain composition 2. `mkdir -p apps/web/src/app/\(dashboard\)/` 3. Create `page.tsx` importing a `` client component **Layout group placement rule:** Pages that match a full-page reference site design (their own header, footer, and nav) should still be placed inside `(dashboard)/` and must add a local `layout.tsx` to opt out of the shared shell. Do NOT place the page outside the `(dashboard)` group — that removes it from the app's routing conventions and makes it invisible to the sidebar. If the reference site has its own nav/header, implement that nav inside the page component, not at the layout level. ```tsx // apps/web/src/app/(dashboard)//layout.tsx — full-viewport overlay to cover parent sidebar export default function Layout({ children }: { children: React.ReactNode }) { return
{children}
; } ``` **The content component must NOT contain `fixed inset-0` or `z-50`.** Viewport-level positioning belongs in the layout.tsx. The content component handles data and rendering only. ## Step 4: Client Component Template Create `apps/web/src/app/(dashboard)//-content.tsx` with `'use client'`. Use shadcn/ui components — not raw divs. Standard search page pattern: - **URL state (not useState) for views:** Use `nuqs` hooks from `@/lib/url-state` for view switching, selected IDs, and search queries. This gives back button, deep linking, and shareable URLs for free: ```tsx import { useView, useSelectedId, useSearchQuery } from '@/lib/url-state'; const [view, setView] = useView(); // ?view=list|detail|search const [id, setId] = useSelectedId(); // ?id=12345 const [q, setQ] = useSearchQuery(); // ?q=search+term // Navigate: setView('detail'); setId(item.id); // Back to list: setView('list'); setId(null); ``` - State: `results`, `loading`, `error` (view/query/id are URL params, not useState) - Fetch: `/api//?q=${encodeURIComponent(q)}` (relative URL — see CLAUDE.md "Frontend API URLs") - Layout: `flex flex-1 flex-col gap-4 p-6 max-w-4xl mx-auto w-full` - Search bar: `Input` + `Button` with `onKeyDown Enter` handler - Four render states: loading (`Skeleton` cards), empty ("No results for..."), idle ("Search above to get started"), populated (result `Card` list with hover) **GATE: Screenshot the component before writing anything else.** 1. Take a Patchright screenshot of the page 2. Read the screenshot — describe in one sentence what you see 3. If the page shows an error, blank content, or broken layout: fix it NOW 4. If data is missing: add `DEBUG()` to the API route handler, re-fetch, read the log, fix 5. Re-screenshot after the fix — confirm the fix worked 6. Only proceed to the next component when the screenshot shows correct content **You cannot add Step 5 (multi-domain composition) on top of a broken Step 4.** Each layer must be proven before building the next. ## Step 5: Multi-Domain Composition Always sequential, catch per source. If a source returns null, mark offline — never let one failure break the page. **Debug each source independently first.** Before composing sources together, `curl` each one and confirm it returns data. Add `DEBUG('fetch-sourceA', () => ({ status, count: data?.length }))` in the component's fetch function to see which source is failing at runtime. When sources are composed, a silent failure in one source produces confusing results in the merged view — debug logs tell you exactly which source returned null and why. ### Multi-domain comparison views — browser sequencing When comparing data from two browser-dependent domains (e.g. two platforms showing the same product), the **singleton browser** navigates to each domain's page in turn. Each navigation clobbers the previous page state. This means: 1. **Frontend must call domains sequentially** — never `Promise.all`. Source A navigates, extracts data, returns. Then source B navigates, extracts, returns. The data from A is safe because it was already extracted and returned as JSON before B's navigation started. 2. **Show progress during sequential fetches** — tell the user which source is loading. `"Searching ... (1 of 2)"` then `"Searching ... (2 of 2)"`. Without this, the user sees a spinner for 20+ seconds with no indication of progress. 3. **Each source's route must fully extract before returning** — don't rely on the browser still being on the same page after the route handler returns. Navigate, wait, extract, return JSON. The next domain route will navigate away. ```typescript // CORRECT — sequential, with progress updates setLoadingMessage(`Searching ${sources[0].name}...`); const resultA = await fetch(`/api/${sources[0].domain}/search?q=${q}`).then(r => r.json()).catch(() => null); setLoadingMessage(`Searching ${sources[1].name}...`); const resultB = await fetch(`/api/${sources[1].domain}/search?q=${q}`).then(r => r.json()).catch(() => null); // Now merge — both datasets are in memory, browser state doesn't matter const merged = mergeResults(resultA?.items ?? [], resultB?.items ?? []); ``` ## Multi-Source Entity Merging Merge by a **stable compound key** (not free-text titles) to avoid duplicate cards: ```typescript function mergeKey(venue: string, date: string): string { const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); return `${norm(venue)}|${norm(date)}`; } const byKey = new Map>(); for (const item of sourceAResults) byKey.set(mergeKey(item.venue, item.date), { ...byKey.get(mergeKey(item.venue, item.date)), sourceA: item }); // repeat for sourceB... each Map entry = one display row ``` **Rules:** Use stable fields (venue+date for events, company+title+city for jobs, DOI for papers). Normalize aggressively: lowercase, strip punctuation, parse dates to ISO. Normalize labels ("Section 101" = "Sec 101" = "101"). Single-source entities still appear with one badge. **Filter before merging:** Validate results belong to the query. Use `startsWith` or word-boundary regex — not `includes`. Skip disqualifying keywords. ## Step 6: Final QA — GATE: zero issues or you're not done **You have been screenshotting and debugging throughout Steps 4-5. This step is the comprehensive final sweep.** Write a Patchright script that tests every user journey and captures every state: ``` 1. Navigate to the page (first visit, no data) → screenshot → describe what you see 2. Perform a search → screenshot results → describe what you see 3. Click into a detail view → screenshot → describe what you see 4. Click every interactive element (buttons, favorites, filters, downloads) → verify each responds 5. Set viewport to 375x812 → screenshot → describe what you see 6. Check browser console for errors after each interaction ``` **For each screenshot:** Read it. Describe what you see in one sentence. If ANYTHING is wrong (broken layout, missing data, dead button, overlapping text, vague error message, content touching edges) — fix it, re-screenshot, confirm the fix. **Iterate until screenshots show zero issues.** See visual-dev skill "Stopping Criteria" for the judgment framework. **Only commit after this step produces zero-issue screenshots across all states and viewports.** ## Available UI Components shadcn/ui — catalog: **https://ui.shadcn.com/docs/components**. Install upfront: ```bash cd apps/web && npx shadcn@latest add card badge sheet table skeleton alert input button -y ``` ### List view vs detail view | Pattern | When | Implementation | |---------|------|----------------| | **Inline expand** | Detail fits 2-4 lines | Collapsible row or Tooltip | | **Side sheet** (default) | Detail needs its own layout | `Sheet` from right, list stays visible | | **Full page** | Detail has sub-nav/tabs/charts | `router.push('/item/[id]')` | ### Pagination State: `page` (0-indexed), `PAGE_SIZE = 25`, `totalPages = Math.ceil(total / PAGE_SIZE)`. Fetch with `?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`. Reset page to 0 on filter change. Optional reusable pager: `apps/web/src/components/ui/grid-pagination.tsx` with `ChevronsLeft/Right` + `ChevronLeft/Right` buttons from lucide-react. ## Visual Design Guidance ### Required states (every page must implement all 8) | State | What to show | |-------|-------------| | Idle | Icon in rounded container (`w-16 h-16 rounded-2xl bg-muted/50`) + heading (`text-base font-medium`) + description (`text-sm text-muted-foreground text-center`) + suggestion chips (`rounded-full bg-muted`) | | Loading | `Skeleton` matching real content shape | | Empty | Search icon (`w-10 h-10 mx-auto text-muted-foreground/40`) + message + sub-message with suggestions ("Try different keywords or check spelling") | | Populated | Real content with typography hierarchy | | Detail loading | `Skeleton` rows inside Sheet | | Detail populated | Full detail with visual hierarchy. External links as outline buttons with external-link icon: `` | | Partial offline | `Alert` naming the failing source | | Full offline | `Alert` per source with recovery instruction | | Error | `Card` with `border-destructive/50` and user-facing error message. Silent `catch {}` blocks must at minimum set an error state. | ### Component choices | Pattern | Component | Notes | |---------|-----------|-------| | List items | `Card` + `CardContent` | Never raw `
`. Hover state + border. | | Source labels | `Badge` | Consistent color across all views | | Detail view | `Sheet` from right | List stays visible behind | | Comparison grid | `Table` | Rows = entities, cols = sources. Best value in green. Missing = `—` | | Loading | `Skeleton` | Match real content dimensions | | Errors | `Alert` + `AlertDescription` | Name the specific source, never generic | ### Responsive sidebar ```tsx
{/* main content */}
{/* sidebar */}
``` ### Dark mode Semantic tokens only: `bg-background`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `hover:bg-muted`. Accent: `bg-blue-950/30 border-blue-500/20` (not `bg-blue-50`). ### UX Patterns (required on every page) | Pattern | Implementation | |---------|---------------| | Search bar icon | Search magnifying glass icon (`absolute left-3, w-4 h-4`) with `pl-9` on the input for instant visual recognition | | Back button arrow | Every "Back" button needs a left arrow icon (`ArrowLeft w-4 h-4`) prepended for navigation affordance | | Responsive padding | All content wrappers: `p-4 sm:p-6` (not just `p-6`). Mobile needs tighter padding. | | Source count badges | When showing counts from multiple sources, use colored `Badge` components instead of plain text. Hide sources with 0 results. | | Mobile action buttons | Buttons with text labels that don't fit on mobile should collapse to icon-only (`size="icon"`) with `className="sm:hidden"` / `className="hidden sm:flex"` | ## In-Process CRUD State For user state (favorites, tracking, bookmarks) with no database requirement: dedicated domain plugin with module-level `Set`/`Map` + `browserRequired: false` routes. Resets on server restart. Register in `apps/api/src/register-domains.ts`. **Optimistic UI:** Update React state immediately, fire API call in background. Don't await server sync before updating UI. `.catch(() => {})` — don't revert on network error. ## Cross-Source Entity Deduplication Same pattern as Multi-Source Entity Merging above, but with `crossListed` tracking. Build `Map`. Normalize: lowercase, strip legal suffixes (Inc, LLC, Corp), strip punctuation. **Cross-listed UI:** Show "N sites" badge on `crossListed: true` cards. In detail Sheet, show per-source price/salary comparison side by side. If prices differ: `"Source B lists $X higher"`. ### Status tracking in detail Sheet Row of `Button` variants (`default` for active, `outline` for inactive), `size="sm"`, optimistic updates. Place below entity details in Sheet. ## Cross-Source Timeline View Flatten + sort by date descending: `sources.flat().sort((a, b) => b.date.localeCompare(a.date))`. Each item: `{ date, type, title, subtitle, source, link }`. Visual: vertical line with colored dots per source. Tab bar for merged vs per-source views. ## Background Job Polling For long-running operations: POST to start (returns `jobId`), store in `Map`, `useEffect` with `setInterval(1000)` polling active jobs. Auto-stop when all complete/error. Show `` + status ``. ## Mini Sparkline (SVG, no deps) Inline SVG ``, map data points to coords, green (`#22c55e`) if up, red (`#ef4444`) if down. Filter nulls, compute min/max/range, scale to `120x32` viewbox. For complex charts: `@visx/shape` + `@visx/scale`. ## Mobile-First with Brand Colors For brand-specific palettes, use a `COLORS` constants object with inline `style` (not Tailwind). Use `className` for layout (flex, gap, padding) and `style` for colors. Touch targets: `min-w-[44px] min-h-[44px]`. Bottom nav bar for mobile app feel. ## Nested Comment / Thread Tree Recursive component with `depth` prop. Colored left-border per depth: `borderColor: hsl(${depth * 60 % 360}, 50%, 40%)`. Indent: `marginLeft: Math.min(depth, 6) * 16`. Click header to collapse/expand. Cap at maxDepth 6. ```tsx function CommentTree({ comment, depth = 0 }: { comment: Comment; depth?: number }) { const [collapsed, setCollapsed] = useState(false); return (
{!collapsed && <>

{comment.body}

{comment.replies.map(r => )} }
); } ``` ## Video / Iframe Embed Wrap `