spb/search-box Public
Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.
TypeScript 76.9%
CSS 18.7%
SQL 2.1%
JavaScript 1.8%
Shell 0.5%
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/ResearchGraph.tsx6 * Description: Signature "bloom" — the question expands into objectives and claims as real events arrive.7 */89"use client";1011import type { Claim } from "@search-box/shared";1213const W = 980;14const QX = 84;1516function truncate(s: string, n: number): string {17 return s.length > n ? `${s.slice(0, n - 1)}…` : s;18}1920const STATUS_COLOR: Record<string, string> = {21 supported: "var(--teal)",22 contradicted: "var(--accent)",23 uncertain: "var(--amber)",24 exploring: "var(--ink-faint)"25};2627/**28 * Deterministic layout: objectives fan out from the question node; claims29 * bloom further right, each visually attached to an objective branch.30 * Every element appears because a real backend event arrived.31 */32export function ResearchGraph({33 objectives,34 claims,35 live,36 hasContradiction37}: {38 objectives: string[];39 claims: Claim[];40 live: boolean;41 hasContradiction: boolean;42}) {43 const nObj = objectives.length;44 const rows = Math.max(nObj, 1);45 const rowGap = Math.min(46, 190 / rows);46 const height = Math.max(150, rows * rowGap + 70);47 const qy = height / 2;4849 const objY = (i: number) => qy + (i - (nObj - 1) / 2) * rowGap;50 const objX = 470;51 const claimX = 830;52 const claimY = (i: number) =>53 qy + (i - (claims.length - 1) / 2) * Math.min(52, (height - 60) / Math.max(claims.length, 1));5455 return (56 <div className="bloom-wrap" role="img" aria-label="Research map: question, objectives and claims">57 <svg viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="xMidYMid meet">58 {/* objective branches */}59 {objectives.map((o, i) => {60 const y = objY(i);61 return (62 <g key={`o${i}`}>63 <path64 className="rg-branch"65 style={{ animationDelay: `${i * 90}ms` }}66 d={`M${QX + 14},${qy} C ${QX + 170},${qy} ${objX - 190},${y} ${objX - 10},${y}`}67 />68 <circle className="rg-node" style={{ animationDelay: `${i * 90 + 250}ms` }} cx={objX} cy={y} r={4} fill="var(--ink)" />69 <text className="rg-label rg-sm" x={objX + 12} y={y + 3.5}>70 {truncate(o, 44)}71 </text>72 </g>73 );74 })}7576 {/* claim blooms */}77 {claims.map((c, i) => {78 const anchor = nObj > 0 ? objY(i % nObj) : qy;79 const y = claimY(i);80 const color = STATUS_COLOR[c.status] ?? "var(--ink-faint)";81 return (82 <g key={c.id}>83 <path84 className="rg-twig"85 style={{ animationDelay: "120ms" }}86 d={`M${objX + 4},${anchor} C ${objX + 150},${anchor} ${claimX - 140},${y} ${claimX - 12},${y}`}87 />88 <circle className="rg-node" cx={claimX} cy={y} r={6} fill={color} opacity={0.18} style={{ animationDelay: "300ms" }} />89 <circle className="rg-node" cx={claimX} cy={y} r={Math.max(3, 5.5 * c.confidence)} fill={color} style={{ animationDelay: "300ms" }} />90 <text className="rg-label rg-sm" x={claimX + 12} y={y + 3.5} fill={color}>91 {truncate(c.text, 22)} · {Math.round(c.confidence * 100)}%92 </text>93 </g>94 );95 })}9697 {/* the question node */}98 <g>99 {live && <circle className="rg-qring live" cx={QX} cy={qy} r={16} />}100 <circle className="rg-qring" cx={QX} cy={qy} r={11} />101 <circle className="rg-node" cx={QX} cy={qy} r={7} fill={hasContradiction ? "var(--accent)" : "var(--ink)"} />102 <text className="rg-label strong" x={QX - 8} y={qy + 30}>103 question104 </text>105 </g>106 </svg>107 </div>108 );109}110