/* ── storage bridge ── */
window.storage={
  async get(key){
    try{const r=await fetch("/api/state",{headers:{Accept:"application/json"}});
      if(r.ok){const j=await r.json();
        if(j&&j.value){localStorage.setItem(key,j.value);return {value:j.value};}}}catch(e){}
    const v=localStorage.getItem(key);return v?{value:v}:null;},
  async set(key,value){
    localStorage.setItem(key,value);
    const r=await fetch("/api/state",{method:"PUT",
      headers:{"Content-Type":"application/json"},body:value});
    if(!r.ok) throw new Error("remote save failed");
    return {ok:true};},
};

const { useState, useEffect, useMemo, useCallback, useRef } = React;

const { useState, useEffect, useMemo, useCallback, useRef } = React;

/* ═══════════════════════════════════════════════════════════
   HARMATTAN VERIFY
   Automated claim detection · human signed verdicts · share cards
   ═══════════════════════════════════════════════════════════ */

const STORE = "harmattan-verify-v1";

const C = {
  night:"#120C22", night2:"#1F1636", ink:"#120C22", ink2:"#463C63", soft:"#877CA6",
  canvas:"#FAF7FF", panel:"#FFFFFF", line:"#E9E2F6", line2:"#F4F0FC",
  violet:"#7C3AED", pink:"#EC4899", cyan:"#06B6D4", lime:"#84CC16",
  amber:"#F59E0B", green:"#10B981", red:"#E11D48", slate:"#94A3B8",
};

/* verdict system */
const V = {
  FALSE:      {label:"False",         color:"#E11D48", ink:"#fff", icon:"✕", rank:1},
  MISLEADING: {label:"Misleading",    color:"#F59E0B", ink:"#3B2400", icon:"!", rank:2},
  CONTEXT:    {label:"Needs context", color:"#06B6D4", ink:"#04303B", icon:"?", rank:3},
  TRUE:       {label:"True",          color:"#10B981", ink:"#03291D", icon:"✓", rank:4},
  UNVERIFIED: {label:"Unverified",    color:"#94A3B8", ink:"#12212E", icon:"·", rank:5},
};
const VK = Object.keys(V);

const PARTIES=["NDC","APC","ADC","PDP","NNPP","LP","Other"];
const CHANNELS=["X","Facebook","Instagram","TikTok","Radio","Television","Rally","Print","WhatsApp"];
const STATES=["National","Abia","Adamawa","Akwa Ibom","Anambra","Bauchi","Bayelsa","Benue","Borno","Cross River","Delta","Ebonyi","Edo","Ekiti","Enugu","FCT","Gombe","Imo","Jigawa","Kaduna","Kano","Katsina","Kebbi","Kogi","Kwara","Lagos","Nasarawa","Niger","Ogun","Ondo","Osun","Oyo","Plateau","Rivers","Sokoto","Taraba","Yobe","Zamfara"];

const num=n=>(n||0).toLocaleString();
const today=()=>new Date().toISOString().slice(0,10);
const dOff=n=>{const x=new Date();x.setDate(x.getDate()-n);return x.toISOString().slice(0,10);};

/* ── demo corpus. Sample claims for demonstration, speakers anonymised ── */
const K=(id,date,speaker,party,channel,state,claim,verdict,ruling,evidence,reach,status,viz,why)=>
 ({id:"C-"+String(id).padStart(4,"0"),date,speaker,party,channel,state,claim,verdict,ruling,evidence,
   reach,status,viz:viz||null,why:why||"",
   checkedBy:verdict?"Verify desk":"",minutes:verdict?[38,52,71,95,126,144][id%6]:null});

const SEED={
 version:1, updated:new Date().toISOString(),
 claims:[
  K(12,dOff(0),"Party spokesperson","APC","X","National",
   "Nigeria's unemployment rate has fallen to the lowest level in a decade.",null,null,[],48200,"pending"),
  K(11,dOff(0),"Campaign surrogate","NDC","Radio","Kano",
   "No presidential candidate has visited Kano in the last six months.",null,null,[],12400,"pending"),
  K(10,dOff(1),"State official","APC","Television","Rivers",
   "The federal government has completed every road project awarded in the South South since 2023.",
   "FALSE","No published project register supports a completion rate of 100 per cent. The Bureau of Public Procurement portal and the ministry's own quarterly reports both list active and uncompleted contracts in the zone during the period.",
   ["Bureau of Public Procurement contract register","Ministry quarterly implementation report","Budget Office capital releases"],
   96500,"published"),
  K(9,dOff(1),"Party spokesperson","ADC","X","National",
   "Voter turnout in 2023 was the highest since 1999.",
   "FALSE","Turnout in 2023 was approximately 27 per cent, the lowest of the Fourth Republic. Roughly 25.3 million people were accredited from a register of about 93 million.",
   ["INEC declared results, 2023 presidential","INEC voter register summary"],
   142000,"published",
   {title:"Presidential turnout, per cent of registered voters",unit:"%",
    bars:[{label:"1999",value:52.3},{label:"2003",value:69.1},{label:"2011",value:53.7},
          {label:"2019",value:34.8},{label:"2023",value:26.7,tone:"bad"}],
    claimedNote:"Claimed: highest since 1999",actualNote:"Actual: the lowest of the Fourth Republic"},
   "If turnout is misremembered as high, the real story is missed: about 68 million registered Nigerians did not vote in 2023."),
  K(8,dOff(2),"Campaign surrogate","NDC","Rally","Enugu",
   "Our ticket has held more public engagements this month than any other campaign.",
   "CONTEXT","Correct on the record we can verify, but the comparison rests on what each campaign chose to publish. Two campaigns publish no schedule at all, so the gap may reflect disclosure rather than activity.",
   ["Harmattan Reach Desk weekly record","Published campaign schedules"],
   31000,"published"),
  K(7,dOff(2),"Party official","APC","Print","National",
   "Electricity generation has doubled in the past two years.",
   "MISLEADING","Installed capacity and actual generation are different measures. Average daily generation rose within a far narrower band over the period, and the doubling figure appears to describe peak capacity rather than delivered output.",
   ["System operator daily generation data","NERC quarterly report"],
   58000,"published",
   {title:"Capacity against delivered generation",unit:"MW",
    bars:[{label:"Installed capacity",value:13000},{label:"Available capacity",value:6000},
          {label:"Average delivered",value:4200,tone:"bad"}],
    claimedNote:"Claimed: generation doubled",actualNote:"Delivered output moved within a far narrower band"},
   "Capacity is what exists on paper. Generation is what reaches a home. Quoting one and calling it the other is the oldest trick in energy politics."),
  K(6,dOff(3),"Campaign surrogate","ADC","X","National",
   "INEC charged one and a half billion naira for access to the voter register.",
   "TRUE","INEC approved a request for certified true copies of the register and polling unit records priced at ₦1,505,901,750, which triggered public criticism in October 2025.",
   ["INEC correspondence reported nationally","Contemporaneous news coverage"],
   77000,"published",
   {title:"Cost of the certified voter register request",unit:"₦m",
    bars:[{label:"Quoted by INEC",value:1506,tone:"bad"},{label:"Cost to publish openly",value:0}],
    claimedNote:"Claimed: ₦1.5bn charged",actualNote:"Confirmed: ₦1,505,901,750"},
   "The same data was later published free by two volunteers. The price was a choice, not a cost."),
  K(5,dOff(3),"Party spokesperson","NNPP","Television","Kano",
   "More than eighty per cent of northern youth are unemployed.",
   "MISLEADING","The figure conflates unemployment with underemployment and informal work, and uses a definition the national statistics office does not apply. The published rate for the age band is substantially lower.",
   ["National Bureau of Statistics labour force survey"],
   43000,"published",
   {title:"What the eighty per cent figure actually contains",unit:"%",
    bars:[{label:"Unemployed",value:19},{label:"Underemployed",value:26},
          {label:"Informal work",value:36},{label:"Claimed as unemployed",value:81,tone:"bad"}],
    claimedNote:"Claimed: over 80% unemployed",actualNote:"Three different categories added together"},
   "Underemployment and informal work are real problems. Calling them unemployment makes the number louder and the argument weaker."),
  K(4,dOff(4),"State official","PDP","Facebook","Oyo",
   "This administration built more classrooms than the previous two combined.",
   "UNVERIFIED","No disaggregated classroom construction data has been published for the comparison years. We have requested the figures and will update this ruling when they arrive.",
   ["Request filed with the state ministry of education"],
   19000,"published"),
  K(3,dOff(5),"Campaign surrogate","NDC","WhatsApp","National",
   "A viral message claims a candidate has withdrawn from the race.",
   "FALSE","No withdrawal has been filed with INEC and the campaign has publicly denied it. The message originated from an account created four days earlier.",
   ["INEC candidate list","Campaign statement","Account creation metadata"],
   210000,"published",
   {title:"How far the message travelled before it was checked",unit:"shares",
    bars:[{label:"Hour 1",value:4000},{label:"Hour 6",value:38000},{label:"Hour 12",value:120000},
          {label:"Hour 24",value:210000,tone:"bad"},{label:"After our check",value:9000,tone:"good"}],
    claimedNote:"Claimed: candidate withdrew",actualNote:"No withdrawal filed with INEC"},
   "Twenty four hours of unchecked reach is the whole problem. Speed of response is the product."),
  K(2,dOff(6),"Party official","APC","Rally","Lagos",
   "Lagos has the highest number of registered voters in the country.",
   "TRUE","Lagos has consistently recorded the largest state register in recent cycles, ahead of Kano.",
   ["INEC state register summary"],
   26000,"published"),
  K(1,dOff(6),"Campaign surrogate","LP","X","National",
   "Fuel prices have tripled since the subsidy was removed.",
   "CONTEXT","Directionally correct but sensitive to the start date chosen. Measured from the month before removal the multiple is lower than three, and prices have varied by region since.",
   ["NBS retail price watch","Petroleum pricing bulletins"],
   88000,"published"),
 ],
 settings:{org:"Harmattan Verify",keeper:"Harmattan Research",
   handle:"@HarmattanIntel",site:"verify.harmattanintelligence.com",
   autoDetect:true, threshold:60,
   template:{
     brand:"HARMATTAN VERIFY",
     dataEyebrow:"THE DATA",
     evidenceEyebrow:"WHAT WE CHECKED IT AGAINST",
     whyEyebrow:"WHY IT MATTERS",
     signoffLine1:"Do not take",
     signoffLine2:"our word for it.",
     signoffBody1:"Every ruling on our site carries its full evidence,",
     signoffBody2:"the editor who signed it, and the date it was checked.",
     signoffFootnote:"Detection is automated. Every verdict is signed by a human editor.",
     threadCloser:"Do not take our word for it. Full ruling, sources and the editor who signed it:"
   }},
};

/* ── hooks and atoms ── */
function useNarrow(px=920){
  const[n,setN]=useState(typeof window!=="undefined"?window.innerWidth<px:false);
  useEffect(()=>{const h=()=>setN(window.innerWidth<px);
    window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[px]);
  return n;
}
function Count({to,format=num,dur=1000}){
  const[v,setV]=useState(0);const raf=useRef();
  useEffect(()=>{
    if(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches){setV(to);return;}
    const t0=performance.now();
    const step=n=>{const p=Math.min(1,(n-t0)/dur);
      setV(Math.round(to*(1-Math.pow(1-p,3))));if(p<1)raf.current=requestAnimationFrame(step);};
    raf.current=requestAnimationFrame(step);return()=>cancelAnimationFrame(raf.current);
  },[to,dur]);
  return <>{format(v)}</>;
}
const Pill=({children,bg=C.violet,fg="#fff"})=>(
  <span style={{display:"inline-block",background:bg,color:fg,padding:"5px 12px",borderRadius:99,
    fontFamily:"'Outfit',sans-serif",fontSize:11.5,fontWeight:700,letterSpacing:".05em",
    textTransform:"uppercase",whiteSpace:"nowrap"}}>{children}</span>);
const Card=({title,action,children,pad=20,accent})=>(
  <section style={{background:C.panel,border:`1px solid ${C.line}`,borderRadius:22,overflow:"hidden",
    boxShadow:"0 12px 30px -24px rgba(18,12,34,.55)"}}>
    {accent&&<div style={{height:5,background:accent}}/>}
    {title&&<header style={{display:"flex",alignItems:"center",gap:12,padding:"16px 20px",
      borderBottom:`1px solid ${C.line2}`,flexWrap:"wrap"}}>
      <h3 style={{margin:0,fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:17.5}}>{title}</h3>
      <div style={{marginLeft:"auto"}}>{action}</div></header>}
    <div style={{padding:pad}}>{children}</div></section>);
const Btn=({children,onClick,kind="ghost",size,title})=>{
  const b={fontFamily:"'Outfit',sans-serif",fontSize:size==="sm"?12:13.5,fontWeight:700,cursor:"pointer",
    borderRadius:99,padding:size==="sm"?"7px 14px":"11px 20px",transition:"transform .15s,filter .15s"};
  const k={grad:{background:`linear-gradient(120deg,${C.violet},${C.pink})`,color:"#fff",border:"none"},
    dark:{background:C.night,color:"#fff",border:"none"},
    lime:{background:C.lime,color:C.ink,border:"none"},
    ghost:{background:"#fff",color:C.ink2,border:`1.5px solid ${C.line}`},
    danger:{background:"#fff",color:C.red,border:`1.5px solid ${C.red}`}};
  return <button className="lift" title={title} onClick={onClick} style={{...b,...k[kind]}}>{children}</button>;};
const Stat=({label,value,sub,tone})=>(
  <div className="lift" style={{background:C.panel,border:`1px solid ${C.line}`,borderRadius:20,
    padding:"18px 20px",transition:"transform .15s,box-shadow .15s"}}>
    <div style={{fontFamily:"'Outfit',sans-serif",fontSize:11.5,fontWeight:700,letterSpacing:".1em",
      textTransform:"uppercase",color:C.soft}}>{label}</div>
    <div style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:32,letterSpacing:"-.03em",
      marginTop:8,color:tone||C.ink,lineHeight:1.02}}>{value}</div>
    {sub&&<div style={{fontSize:12.5,color:C.ink2,marginTop:5}}>{sub}</div>}</div>);
const Empty=({children})=><div style={{padding:"30px 4px",color:C.soft,fontSize:14,textAlign:"center"}}>{children}</div>;

const Verdict=({v,size})=>{
  if(!v) return <Pill bg={C.slate}>Awaiting review</Pill>;
  const d=V[v];
  return <span style={{display:"inline-flex",alignItems:"center",gap:7,background:d.color,color:d.ink,
    padding:size==="lg"?"9px 18px":"5px 13px",borderRadius:99,fontFamily:"'Outfit',sans-serif",
    fontWeight:800,fontSize:size==="lg"?16:12,letterSpacing:".03em",textTransform:"uppercase"}}>
    <span style={{fontWeight:900}}>{d.icon}</span>{d.label}</span>;
};

/* ── charts ── */
function Donut({slices,size=190,centreLabel}){
  const[hi,setHi]=useState(null);
  const total=slices.reduce((a,s)=>a+s.value,0)||1;let acc=0;const R=15.9155;
  return <div style={{display:"flex",gap:20,alignItems:"center",flexWrap:"wrap"}}>
    <div style={{position:"relative",width:size,height:size,flex:"0 0 auto"}}>
      <svg viewBox="0 0 42 42" style={{width:size,height:size,transform:"rotate(-90deg)"}}>
        <circle cx="21" cy="21" r={R} fill="none" stroke={C.line2} strokeWidth="7"/>
        {slices.map((s,i)=>{const pct=(s.value/total)*100;
          const el=<circle key={s.name} cx="21" cy="21" r={R} fill="none" stroke={s.color}
            strokeWidth={hi===i?9:7} strokeDasharray={`${pct} ${100-pct}`} strokeDashoffset={-acc}
            strokeLinecap="round" style={{transition:"stroke-width .15s",cursor:"pointer"}}
            onMouseEnter={()=>setHi(i)} onMouseLeave={()=>setHi(null)}/>;acc+=pct;return el;})}
      </svg>
      <div style={{position:"absolute",inset:0,display:"grid",placeItems:"center",pointerEvents:"none"}}>
        <div style={{textAlign:"center"}}>
          <div style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:27}}>
            {hi==null?total:slices[hi].value}</div>
          <div style={{fontSize:11.5,color:C.soft,maxWidth:104}}>
            {hi==null?(centreLabel||"claims"):slices[hi].name}</div></div></div>
    </div>
    <div style={{flex:1,minWidth:170}}>
      {slices.map((s,i)=>(<div key={s.name} onMouseEnter={()=>setHi(i)} onMouseLeave={()=>setHi(null)}
        style={{display:"flex",alignItems:"center",gap:10,padding:"7px 10px",borderRadius:10,
          background:hi===i?C.line2:"transparent",cursor:"pointer",fontSize:14}}>
        <span style={{width:11,height:11,borderRadius:3,background:s.color}}/><span>{s.name}</span>
        <b style={{marginLeft:"auto",fontFamily:"'Outfit',sans-serif"}}>{s.value}</b>
        <span style={{width:44,textAlign:"right",color:C.soft,fontSize:12}}>
          {Math.round((s.value/total)*100)}%</span></div>))}
    </div></div>;
}
function Bars({rows,gradient,format=num}){
  const[hi,setHi]=useState(null);const max=Math.max(1,...rows.map(r=>r.value));
  return <div>{rows.map((r,i)=>(
    <div key={r.name} onMouseEnter={()=>setHi(i)} onMouseLeave={()=>setHi(null)}
      style={{display:"flex",alignItems:"center",gap:12,padding:"8px 10px",borderRadius:10,
        background:hi===i?C.line2:"transparent"}}>
      <span style={{width:104,fontSize:14}}>{r.name}</span>
      <span style={{flex:1,height:12,background:C.line2,borderRadius:99,overflow:"hidden"}}>
        <span style={{display:"block",height:"100%",width:`${(r.value/max)*100}%`,borderRadius:99,
          background:r.color||gradient||C.violet,transition:"width .7s cubic-bezier(.2,.8,.2,1)"}}/></span>
      <b style={{fontFamily:"'Outfit',sans-serif",fontSize:13.5,width:70,textAlign:"right"}}>{format(r.value)}</b>
    </div>))}{rows.length===0&&<Empty>Nothing to chart.</Empty>}</div>;
}

/* ═══ CARD SEQUENCE ENGINE ═════════════════════════════════
   One ruling becomes a five card sequence:
     1 HOOK      the claim and the verdict stamp
     2 DATA      what was said against what the record shows
     3 EVIDENCE  the sources, named
     4 WHY       why the distortion matters
     5 SIGN OFF  where to check it yourself
   Cards are SVG, rasterised through canvas, so there is no
   library to install and the templates are editable text.
   ═══════════════════════════════════════════════════════ */
const FORMATS={
  x:      {w:1200,h:675, name:"X / Twitter", note:"16:9"},
  square: {w:1080,h:1080,name:"Instagram", note:"1:1"},
  story:  {w:1080,h:1920,name:"Story / Reel", note:"9:16"},
};
const STEPS=[
  {key:"hook",     label:"1 · The claim"},
  {key:"data",     label:"2 · The data"},
  {key:"evidence", label:"3 · The evidence"},
  {key:"why",      label:"4 · Why it matters"},
  {key:"signoff",  label:"5 · Check it yourself"},
];

function wrap(text,max){
  const words=String(text||"").split(/\s+/);const lines=[];let cur="";
  words.forEach(w=>{ if((cur+" "+w).trim().length<=max){cur=(cur+" "+w).trim();} else {if(cur)lines.push(cur);cur=w;} });
  if(cur)lines.push(cur);return lines;
}
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
const fmtN=n=>Number(n).toLocaleString();

/* shared chrome for every card in the sequence */
function frame(F,d,step,total,inner,settings,tone){
  const T=tpl(settings);
  const {w:W,h:H}=F; const story=H/W>1.5, sq=Math.abs(H/W-1)<0.1;
  const PAD=story?86:66;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="#160D2C"/><stop offset="52%" stop-color="#2C1560"/>
      <stop offset="100%" stop-color="#4A1D93"/></linearGradient>
    <linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
      <stop offset="0%" stop-color="${tone||d.color}"/><stop offset="100%" stop-color="#EC4899"/></linearGradient>
    <filter id="blur"><feGaussianBlur stdDeviation="${story?90:66}"/></filter>
  </defs>
  <rect width="${W}" height="${H}" fill="url(#bg)"/>
  <circle cx="${W*0.94}" cy="${H*0.06}" r="${W*0.24}" fill="${tone||d.color}" opacity="0.34" filter="url(#blur)"/>
  <circle cx="${W*0.06}" cy="${H*0.97}" r="${W*0.2}" fill="#06B6D4" opacity="0.22" filter="url(#blur)"/>

  <text x="${PAD}" y="${story?128:86}" font-family="Outfit,Arial,sans-serif" font-size="${story?36:28}"
    font-weight="800" fill="#84CC16" letter-spacing="4">${esc(T.brand)}</text>
  <text x="${W-PAD}" y="${story?128:86}" text-anchor="end" font-family="Outfit,Arial,sans-serif"
    font-size="${story?32:25}" font-weight="700" fill="#C4B5FD">${step} / ${total}</text>
  <rect x="${PAD}" y="${story?150:104}" width="${W-PAD*2}" height="3" fill="url(#rule)" opacity=".65"/>

  ${inner}

  <rect x="0" y="${H-(story?128:96)}" width="${W}" height="${story?128:96}" fill="rgba(0,0,0,0.30)"/>
  <rect x="0" y="${H-(story?128:96)}" width="${W}" height="5" fill="url(#rule)"/>
  <text x="${PAD}" y="${H-(story?56:40)}" font-family="Arial,sans-serif" font-size="${story?30:24}"
    fill="#FFFFFF">${esc(settings.site)}</text>
  <text x="${W-PAD}" y="${H-(story?56:40)}" text-anchor="end" font-family="Arial,sans-serif"
    font-size="${story?27:22}" fill="#A895D8">${esc(settings.handle)}</text>
</svg>`;
}

const TPL_DEFAULT={brand:"HARMATTAN VERIFY",dataEyebrow:"THE DATA",
  evidenceEyebrow:"WHAT WE CHECKED IT AGAINST",whyEyebrow:"WHY IT MATTERS",
  signoffLine1:"Do not take",signoffLine2:"our word for it.",
  signoffBody1:"Every ruling on our site carries its full evidence,",
  signoffBody2:"the editor who signed it, and the date it was checked.",
  signoffFootnote:"Detection is automated. Every verdict is signed by a human editor.",
  threadCloser:"Do not take our word for it. Full ruling, sources and the editor who signed it:"};
const tpl=settings=>({...TPL_DEFAULT,...((settings&&settings.template)||{})});

function cardSVG(claim,fmt,settings,stepKey){
  const T=tpl(settings);
  const F=FORMATS[fmt]; const {w:W,h:H}=F;
  const story=H/W>1.5, sq=Math.abs(H/W-1)<0.1;
  const PAD=story?86:66;
  const d=V[claim.verdict]||V.UNVERIFIED;
  const top=story?300:(sq?230:180);
  const idx=STEPS.findIndex(s=>s.key===stepKey)+1;

  /* ── 1 HOOK ── */
  if(stepKey==="hook"){
    const size=story?62:(sq?54:46);
    const lines=wrap(claim.claim,story?24:(sq?26:32)).slice(0,story?7:(sq?6:5));
    const bandH=story?132:96;
    const inner=`
      <rect x="${PAD}" y="${top-bandH-24}" rx="${bandH/2}" width="${d.label.length*(story?32:24)+150}"
        height="${bandH}" fill="${d.color}"/>
      <text x="${PAD+54}" y="${top-bandH/2-24+(story?20:14)}" font-family="Outfit,Arial,sans-serif"
        font-size="${story?58:44}" font-weight="900" fill="${d.ink}">${d.icon}  ${d.label.toUpperCase()}</text>
      ${lines.map((l,i)=>`<text x="${PAD}" y="${top+80+i*(size*1.26)}" font-family="Outfit,Arial,sans-serif"
        font-size="${size}" font-weight="800" fill="#FFFFFF">${esc(l)}</text>`).join("")}
      <text x="${PAD}" y="${top+80+lines.length*(size*1.26)+(story?66:48)}" font-family="Arial,sans-serif"
        font-size="${story?32:26}" fill="#C4B5FD">${esc(claim.speaker)} · ${esc(claim.party)} · ${esc(claim.channel)} · ${esc(claim.date)}</text>`;
    return frame(F,d,idx,STEPS.length,inner,settings);
  }

  /* ── 2 DATA ── */
  if(stepKey==="data"){
    const viz=claim.viz;
    if(!viz){
      const lines=wrap(claim.ruling,story?36:44).slice(0,story?9:7);
      const inner=`<text x="${PAD}" y="${top}" font-family="Outfit,Arial,sans-serif" font-size="${story?46:38}"
        font-weight="900" fill="#84CC16">${esc(T.dataEyebrow)}</text>
        ${lines.map((l,i)=>`<text x="${PAD}" y="${top+80+i*(story?54:44)}" font-family="Arial,sans-serif"
        font-size="${story?36:30}" fill="#FFFFFF">${esc(l)}</text>`).join("")}`;
      return frame(F,d,idx,STEPS.length,inner,settings,"#06B6D4");
    }
    const max=Math.max(...viz.bars.map(b=>b.value))||1;
    const chartTop=top+ (story?150:110);
    const chartH=story?720:(sq?430:260);
    const bw=(W-PAD*2)/viz.bars.length;
    const barW=bw*0.56;
    const bars=viz.bars.map((b,i)=>{
      const h=Math.max(6,(b.value/max)*chartH);
      const x=PAD+i*bw+(bw-barW)/2;
      const y=chartTop+chartH-h;
      const col=b.tone==="bad"?d.color:(b.tone==="good"?"#84CC16":"#8B5CF6");
      return `<rect x="${x}" y="${y}" width="${barW}" height="${h}" rx="${story?14:10}" fill="${col}"/>
      <text x="${x+barW/2}" y="${y-(story?22:16)}" text-anchor="middle" font-family="Outfit,Arial,sans-serif"
        font-size="${story?38:29}" font-weight="900" fill="#FFFFFF">${esc(fmtN(b.value))}</text>
      <text x="${x+barW/2}" y="${chartTop+chartH+(story?54:40)}" text-anchor="middle"
        font-family="Arial,sans-serif" font-size="${story?28:21}" fill="#C4B5FD">${esc(b.label)}</text>`;
    }).join("");
    const tLines=wrap(viz.title,story?32:40).slice(0,2);
    const inner=`
      <text x="${PAD}" y="${top-10}" font-family="Outfit,Arial,sans-serif" font-size="${story?44:34}"
        font-weight="900" fill="#84CC16">${esc(T.dataEyebrow)}</text>
      ${tLines.map((l,i)=>`<text x="${PAD}" y="${top+(story?60:46)+i*(story?46:36)}"
        font-family="Outfit,Arial,sans-serif" font-size="${story?38:29}" font-weight="700"
        fill="#FFFFFF">${esc(l)}</text>`).join("")}
      <line x1="${PAD}" y1="${chartTop+chartH}" x2="${W-PAD}" y2="${chartTop+chartH}"
        stroke="rgba(255,255,255,.25)" stroke-width="2"/>
      ${bars}
      <text x="${PAD}" y="${chartTop+chartH+(story?130:96)}" font-family="Arial,sans-serif"
        font-size="${story?32:25}" fill="${d.color}">✕ ${esc(viz.claimedNote)}</text>
      <text x="${PAD}" y="${chartTop+chartH+(story?180:132)}" font-family="Arial,sans-serif"
        font-size="${story?32:25}" fill="#84CC16">✓ ${esc(viz.actualNote)}</text>`;
    return frame(F,d,idx,STEPS.length,inner,settings,"#06B6D4");
  }

  /* ── 3 EVIDENCE ── */
  if(stepKey==="evidence"){
    const ev=(claim.evidence||[]).slice(0,story?7:5);
    const rows=ev.map((e,i)=>{
      const y=top+(story?90:70)+i*(story?110:82);
      const lines=wrap(e,story?32:38).slice(0,2);
      return `<rect x="${PAD}" y="${y-(story?52:40)}" width="${W-PAD*2}" height="${story?92:68}" rx="${story?20:14}"
        fill="rgba(255,255,255,0.08)" stroke="rgba(255,255,255,0.18)"/>
      <circle cx="${PAD+(story?46:34)}" cy="${y-(story?6:6)}" r="${story?14:10}" fill="#06B6D4"/>
      ${lines.map((l,j)=>`<text x="${PAD+(story?86:62)}" y="${y+(j*(story?36:28))-(story?2:2)}"
        font-family="Arial,sans-serif" font-size="${story?32:25}" fill="#FFFFFF">${esc(l)}</text>`).join("")}`;
    }).join("");
    const inner=`
      <text x="${PAD}" y="${top-10}" font-family="Outfit,Arial,sans-serif" font-size="${story?44:34}"
        font-weight="900" fill="#84CC16">${esc(T.evidenceEyebrow)}</text>
      ${rows}`;
    return frame(F,d,idx,STEPS.length,inner,settings,"#06B6D4");
  }

  /* ── 4 WHY ── */
  if(stepKey==="why"){
    const txt=claim.why||claim.ruling;
    const size=story?46:(sq?42:36);
    const lines=wrap(txt,story?30:(sq?32:38)).slice(0,story?9:7);
    const inner=`
      <text x="${PAD}" y="${top-10}" font-family="Outfit,Arial,sans-serif" font-size="${story?44:34}"
        font-weight="900" fill="#F59E0B">${esc(T.whyEyebrow)}</text>
      ${lines.map((l,i)=>`<text x="${PAD}" y="${top+(story?90:70)+i*(size*1.3)}"
        font-family="Outfit,Arial,sans-serif" font-size="${size}" font-weight="700"
        fill="#FFFFFF">${esc(l)}</text>`).join("")}`;
    return frame(F,d,idx,STEPS.length,inner,settings,"#F59E0B");
  }

  /* ── 5 SIGN OFF ── */
  const inner=`
    <text x="${PAD}" y="${top+(story?40:20)}" font-family="Outfit,Arial,sans-serif"
      font-size="${story?66:52}" font-weight="900" fill="#FFFFFF">${esc(T.signoffLine1)}</text>
    <text x="${PAD}" y="${top+(story?130:90)}" font-family="Outfit,Arial,sans-serif"
      font-size="${story?66:52}" font-weight="900" fill="#FFFFFF">${esc(T.signoffLine2)}</text>
    <text x="${PAD}" y="${top+(story?230:160)}" font-family="Arial,sans-serif"
      font-size="${story?36:29}" fill="#C4B5FD">${esc(T.signoffBody1)}</text>
    <text x="${PAD}" y="${top+(story?282:198)}" font-family="Arial,sans-serif"
      font-size="${story?36:29}" fill="#C4B5FD">${esc(T.signoffBody2)}</text>
    <rect x="${PAD}" y="${top+(story?340:240)}" rx="${story?46:36}" width="${story?620:520}"
      height="${story?92:72}" fill="#84CC16"/>
    <text x="${PAD+(story?54:42)}" y="${top+(story?400:288)}" font-family="Outfit,Arial,sans-serif"
      font-size="${story?40:32}" font-weight="900" fill="#1A2E05">${esc(settings.site)}</text>
    <text x="${PAD}" y="${top+(story?500:360)}" font-family="Arial,sans-serif"
      font-size="${story?30:24}" fill="#A895D8">${esc(T.signoffFootnote)}</text>`;
  return frame(F,d,idx,STEPS.length,inner,settings,"#84CC16");
}

function CardImg({claim,fmt,settings,step}){
  const svg=cardSVG(claim,fmt,settings,step);
  return <img src={"data:image/svg+xml;charset=utf-8,"+encodeURIComponent(svg)} alt={step}
    style={{width:"100%",height:"auto",display:"block",borderRadius:14,
      boxShadow:"0 18px 44px -22px rgba(18,12,34,.8)"}}/>;
}

function rasterise(claim,fmt,settings,step){
  return new Promise((res,rej)=>{
    const F=FORMATS[fmt];
    const img=new Image();
    img.onload=()=>{
      const cv=document.createElement("canvas");cv.width=F.w;cv.height=F.h;
      cv.getContext("2d").drawImage(img,0,0,F.w,F.h);
      cv.toBlob(b=>res(b),"image/png");
    };
    img.onerror=rej;
    img.src="data:image/svg+xml;charset=utf-8,"+encodeURIComponent(cardSVG(claim,fmt,settings,step));
  });
}
async function downloadOne(claim,fmt,settings,step,n){
  const b=await rasterise(claim,fmt,settings,step);
  const a=document.createElement("a");
  a.href=URL.createObjectURL(b);
  a.download=`${claim.id}-${fmt}-${n}-${step}.png`;a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),2000);
}
async function downloadSequence(claim,fmt,settings){
  for(let i=0;i<STEPS.length;i++){
    await downloadOne(claim,fmt,settings,STEPS[i].key,i+1);
    await new Promise(r=>setTimeout(r,420));
  }
}

/* thread text that matches the card order */
function threadText(claim,settings){
  const d=V[claim.verdict]||V.UNVERIFIED;
  const t=[];
  t.push(`${d.icon} ${d.label.toUpperCase()}\n\n“${claim.claim}”\n\n— ${claim.speaker}, ${claim.party}, ${claim.channel}, ${claim.date}`);
  if(claim.viz){
    t.push(`THE DATA\n\n${claim.viz.title}\n\n`+
      claim.viz.bars.map(b=>`${b.label}: ${fmtN(b.value)}${claim.viz.unit?" "+claim.viz.unit:""}`).join("\n")+
      `\n\n${claim.viz.actualNote}`);
  } else {
    t.push(`THE RECORD\n\n${claim.ruling}`);
  }
  t.push(`${tpl(settings).evidenceEyebrow}\n\n`+(claim.evidence||[]).map(e=>`· ${e}`).join("\n"));
  t.push(`${tpl(settings).whyEyebrow}\n\n${claim.why||claim.ruling}`);
  const T=tpl(settings);
  t.push(`${T.threadCloser}\n${settings.site}\n\n${T.signoffFootnote}`);
  return t;
}

/* ═══ APP ═══ */
function Verify(){
  const[page,setPage]=useState("feed");
  const[db,setDb]=useState(null);
  const[log,setLog]=useState([]);
  const[toast,setToast]=useState(null);
  const[busy,setBusy]=useState(false);
  const[desk,setDesk]=useState(false);
  const[open,setOpen]=useState(null);
  const[menu,setMenu]=useState(false);
  const narrow=useNarrow(920);
  const push=(m,p,s,ms)=>setLog(L=>[{id:Math.random(),time:new Date().toLocaleTimeString(),m,p,s,ms},...L].slice(0,40));

  useEffect(()=>{(async()=>{const s=Date.now();
    try{const r=await window.storage.get(STORE);
      setDb(r&&r.value?JSON.parse(r.value):SEED);push("GET","/api/claims",200,Date.now()-s);}
    catch{setDb(SEED);push("GET","/api/claims",404,Date.now()-s);}})();},[]);

  const commit=useCallback(async(next,note)=>{
    const p={...next,version:(next.version||0)+1,updated:new Date().toISOString()};
    setDb(p);setBusy(true);const s=Date.now();
    try{await window.storage.set(STORE,JSON.stringify(p));
      push("PUT","/api/claims",200,Date.now()-s);setToast({ok:true,msg:note||"Saved"});}
    catch{push("PUT","/api/claims",503,Date.now()-s);
      setToast({ok:false,msg:"Storage unreachable. Held for this session."});}
    setBusy(false);setTimeout(()=>setToast(null),2500);},[]);

  const all=db?db.claims:[];
  const pub=useMemo(()=>all.filter(c=>c.status==="published"),[all]);
  const pending=useMemo(()=>all.filter(c=>c.status==="pending"),[all]);

  if(!db) return <div style={{minHeight:"100vh",background:C.night,color:C.lime,display:"grid",
    placeItems:"center",fontFamily:"'Outfit',sans-serif",letterSpacing:".2em",fontSize:13}}>STARTING VERIFY…</div>;

  const NAV=[["feed","Live feed"],["cards","Card studio"],["analytics","Analytics"],["method","Method"]];
  const DESKNAV=[["queue","Detection queue",pending.length],["editor","Sequence editor",0],
    ["new","Add claim",0],["api","Backend",0],["settings","Settings",0]];
  const go=k=>{setPage(k);setMenu(false);setOpen(null);window.scrollTo({top:0,behavior:"smooth"});};

  return <div style={{minHeight:"100vh",background:C.canvas,color:C.ink,
    fontFamily:"'Plus Jakarta Sans',system-ui,sans-serif",fontSize:15,lineHeight:1.6}}>
    <style>{`
      @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@600;700;800;900&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
      *{box-sizing:border-box}
      input,select,textarea{font-family:'Plus Jakarta Sans',sans-serif;font-size:14.5px;padding:11px 14px;
        border:1.5px solid ${C.line};border-radius:12px;background:#fff;color:${C.ink};width:100%}
      input:focus,select:focus,textarea:focus{outline:2px solid ${C.violet};outline-offset:-2px}
      .lift:hover{transform:translateY(-2px);box-shadow:0 16px 30px -18px rgba(18,12,34,.5)}
      .navl{font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:700;color:rgba(255,255,255,.74);
        background:transparent;border:none;cursor:pointer;padding:9px 15px;border-radius:99px;white-space:nowrap}
      .navl:hover{color:#fff;background:rgba(255,255,255,.13)}
      .navl.on{color:${C.ink};background:${C.lime}}
      .lab{font-family:'Outfit',sans-serif;font-size:11px;font-weight:700;letter-spacing:.1em;
        text-transform:uppercase;color:${C.soft};display:block;margin-bottom:6px}
      .seg{font-family:'Outfit',sans-serif;font-size:12px;font-weight:700;padding:7px 14px;
        border:1.5px solid ${C.line};background:#fff;color:${C.ink2};cursor:pointer;white-space:nowrap}
      .seg:first-child{border-radius:99px 0 0 99px}.seg:last-child{border-radius:0 99px 99px 0}
      .seg+.seg{border-left:none}
      .seg.on{background:${C.violet};color:#fff;border-color:${C.violet}}
      @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
      .dot{animation:pulse 1.8s ease-in-out infinite}
      @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
    `}</style>

    <header style={{background:C.night,position:"sticky",top:0,zIndex:50,
      boxShadow:"0 2px 18px -6px rgba(18,12,34,.6)"}}>
      <div style={{maxWidth:1280,margin:"0 auto",padding:"12px 20px",display:"flex",alignItems:"center",gap:14,flexWrap:"wrap"}}>
        <button onClick={()=>go("feed")} style={{background:"none",border:"none",cursor:"pointer",
          display:"flex",alignItems:"center",gap:11,padding:0}}>
          <span style={{width:40,height:40,borderRadius:13,background:`linear-gradient(135deg,${C.violet},${C.pink})`,
            display:"grid",placeItems:"center",fontFamily:"'Outfit',sans-serif",fontWeight:900,color:"#fff",fontSize:17}}>✓</span>
          <span style={{textAlign:"left"}}>
            <span style={{display:"block",fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:16.5,color:"#fff"}}>Harmattan Verify</span>
            <span style={{display:"block",fontSize:10.5,letterSpacing:".14em",color:C.lime,fontWeight:700}}>ELECTION CLAIM MONITOR</span></span>
        </button>
        {!narrow&&<nav style={{display:"flex",gap:2,flexWrap:"wrap"}}>
          {NAV.map(([k,l])=><button key={k} className={"navl"+(page===k?" on":"")} onClick={()=>go(k)}>{l}</button>)}</nav>}
        <div style={{marginLeft:"auto",display:"flex",alignItems:"center",gap:9,flexWrap:"wrap"}}>
          <span style={{display:"inline-flex",alignItems:"center",gap:6,fontFamily:"'Outfit',sans-serif",
            fontSize:11.5,fontWeight:700,color:busy?C.amber:C.lime}}>
            <span className="dot" style={{width:8,height:8,borderRadius:99,background:busy?C.amber:C.lime}}/>
            {busy?"SYNCING":"MONITORING"}</span>
          <button onClick={()=>setDesk(!desk)} style={{fontFamily:"'Outfit',sans-serif",fontSize:12,fontWeight:700,
            padding:"8px 15px",borderRadius:99,cursor:"pointer",border:desk?"none":"1.5px solid rgba(255,255,255,.25)",
            background:desk?C.lime:"transparent",color:desk?C.ink:"rgba(255,255,255,.9)"}}>{desk?"Desk":"Public"}</button>
          {narrow&&<button onClick={()=>setMenu(!menu)} style={{background:"transparent",color:"#fff",
            border:"1.5px solid rgba(255,255,255,.25)",borderRadius:10,padding:"7px 12px",cursor:"pointer"}}>☰</button>}
        </div>
      </div>
      {narrow&&menu&&<div style={{padding:"0 20px 14px",display:"flex",flexWrap:"wrap",gap:5}}>
        {NAV.map(([k,l])=><button key={k} className={"navl"+(page===k?" on":"")} onClick={()=>go(k)}>{l}</button>)}</div>}
      {desk&&<div style={{background:C.night2,borderTop:"1px solid rgba(255,255,255,.08)"}}>
        <div style={{maxWidth:1280,margin:"0 auto",padding:"9px 20px",display:"flex",gap:4,alignItems:"center",flexWrap:"wrap"}}>
          <span style={{fontFamily:"'Outfit',sans-serif",fontSize:10.5,fontWeight:700,letterSpacing:".14em",
            color:"rgba(255,255,255,.45)",marginRight:8}}>VERIFY DESK</span>
          {DESKNAV.map(([k,l,b])=><button key={k} className={"navl"+(page===k?" on":"")} onClick={()=>go(k)}>
            {l}{b?<span style={{marginLeft:7,background:C.red,color:"#fff",borderRadius:99,padding:"1px 8px",fontSize:10.5}}>{b}</span>:null}</button>)}
          <span style={{marginLeft:"auto",fontSize:11,color:"rgba(255,255,255,.5)"}}>v{db.version}</span>
        </div></div>}
    </header>

    <main style={{maxWidth:1280,margin:"0 auto",padding:narrow?"16px":"24px 20px 60px"}}>
      {page==="feed"&&<Feed db={db} pub={pub} pending={pending} desk={desk} go={go} setOpen={setOpen}/>}
      {page==="cards"&&<CardStudio db={db} pub={pub} open={open}/>}
      {page==="analytics"&&<Analytics pub={pub} all={all}/>}
      {page==="method"&&<Method db={db}/>}
      {page==="editor"&&<SequenceEditor db={db} commit={commit} go={go}/>}
      {page==="queue"&&<Queue db={db} pending={pending} commit={commit} go={go}/>}
      {page==="new"&&<NewClaim db={db} commit={commit} go={go}/>}
      {page==="api"&&<Api log={log} db={db}/>}
      {page==="settings"&&<Settings db={db} commit={commit}/>}
    </main>

    <footer style={{background:C.night,color:"rgba(255,255,255,.6)",padding:"28px 20px",marginTop:40}}>
      <div style={{maxWidth:1280,margin:"0 auto",display:"flex",justifyContent:"space-between",gap:16,
        flexWrap:"wrap",fontSize:13.5}}>
        <span>{db.settings.org} · detection is automated, every verdict is signed by a human editor</span>
        <span>{db.settings.site}</span></div></footer>

    {toast&&<div style={{position:"fixed",right:18,bottom:18,zIndex:90,background:toast.ok?C.night:C.red,
      color:"#fff",padding:"14px 20px",borderRadius:14,fontSize:14,maxWidth:330,
      boxShadow:"0 18px 40px -14px rgba(0,0,0,.6)"}}>{toast.msg}</div>}
  </div>;
}

/* ── FEED ── */
function Feed({db,pub,pending,desk,go,setOpen}){
  const[filter,setFilter]=useState("ALL");
  const[q,setQ]=useState("");
  const rows=pub
    .filter(c=>filter==="ALL"?true:c.verdict===filter)
    .filter(c=>q?(c.claim+c.speaker+c.party+c.state).toLowerCase().includes(q.toLowerCase()):true);
  const counts={};pub.forEach(c=>{counts[c.verdict]=(counts[c.verdict]||0)+1;});
  const falseish=(counts.FALSE||0)+(counts.MISLEADING||0);
  const reach=pub.reduce((a,c)=>a+(c.reach||0),0);
  const med=(()=>{const m=pub.map(c=>c.minutes).filter(Boolean).sort((a,b)=>a-b);
    return m.length?m[Math.floor(m.length/2)]:0;})();

  return <div style={{display:"grid",gap:18}}>
    {/* hero */}
    <div style={{background:C.night,borderRadius:24,padding:"30px 28px",color:"#fff",position:"relative",overflow:"hidden"}}>
      <div style={{position:"absolute",width:280,height:280,borderRadius:"50%",background:C.red,
        opacity:.32,filter:"blur(60px)",right:-50,top:-70}}/>
      <div style={{position:"absolute",width:200,height:200,borderRadius:"50%",background:C.violet,
        opacity:.4,filter:"blur(60px)",right:150,top:50}}/>
      <div style={{position:"relative"}}>
        <Pill bg={C.lime} fg={C.ink}>Live claim monitor</Pill>
        <h1 style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:"clamp(30px,4.8vw,48px)",
          lineHeight:1.03,letterSpacing:"-.035em",margin:"14px 0 0"}}>
          {falseish} false or misleading claims<br/>
          <span style={{background:`linear-gradient(100deg,${C.lime},${C.cyan})`,
            WebkitBackgroundClip:"text",backgroundClip:"text",color:"transparent"}}>caught this week.</span></h1>
        <p style={{color:"rgba(255,255,255,.75)",maxWidth:"56ch",marginTop:12}}>
          Every public claim we can source, checked against the record and published with the evidence.
          Detection runs continuously. Nothing is published until an editor signs it.</p>
      </div>
    </div>

    {desk&&pending.length>0&&(
      <div style={{background:"#FFF1F2",border:`1.5px solid ${C.red}`,borderRadius:18,padding:"14px 18px",
        display:"flex",gap:13,alignItems:"center",flexWrap:"wrap"}}>
        <Pill bg={C.red}>{pending.length} detected, unreviewed</Pill>
        <span style={{fontSize:14,color:"#881337"}}>Detection flagged these. No verdict has been assigned.</span>
        <span style={{marginLeft:"auto"}}><Btn kind="grad" size="sm" onClick={()=>go("queue")}>Open queue</Btn></span>
      </div>)}

    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(165px,1fr))",gap:14}}>
      <Stat label="Claims checked" value={<Count to={pub.length}/>} sub="published rulings"/>
      <Stat label="False" value={counts.FALSE||0} tone={V.FALSE.color}/>
      <Stat label="Misleading" value={counts.MISLEADING||0} tone={V.MISLEADING.color}/>
      <Stat label="True" value={counts.TRUE||0} tone={V.TRUE.color}/>
      <Stat label="Combined reach" value={<Count to={reach}/>} sub="of the claims we checked"/>
      <Stat label="Median time to verdict" value={med+" min"} tone={C.cyan}/>
    </div>

    <Card title="The feed" pad={0} action={
      <div style={{display:"flex",gap:9,flexWrap:"wrap",alignItems:"center"}}>
        <input placeholder="Search claims" value={q} onChange={e=>setQ(e.target.value)} style={{width:190}}/>
        <div style={{display:"flex"}}>
          <button className={"seg"+(filter==="ALL"?" on":"")} onClick={()=>setFilter("ALL")}>All</button>
          {VK.map(k=><button key={k} className={"seg"+(filter===k?" on":"")}
            onClick={()=>setFilter(k)}>{V[k].label}</button>)}
        </div></div>}>
      {rows.map(c=>(
        <article key={c.id} style={{padding:"20px",borderBottom:`1px solid ${C.line2}`}}>
          <div style={{display:"flex",gap:10,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
            <Verdict v={c.verdict}/>
            <Pill bg={C.night}>{c.party}</Pill>
            <span style={{fontSize:12.5,color:C.soft}}>{c.speaker} · {c.channel} · {c.state} · {c.date}</span>
            <span style={{marginLeft:"auto",display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
              <Pill bg={C.slate}>{num(c.reach)} reach</Pill>
              <Btn size="sm" onClick={()=>{setOpen(c.id);go("cards");}}>Make card</Btn></span>
          </div>
          <div style={{fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:"clamp(17px,2vw,21px)",
            lineHeight:1.3,marginBottom:8}}>“{c.claim}”</div>
          <p style={{margin:"0 0 10px",fontSize:14,color:C.ink2}}>{c.ruling}</p>
          {c.evidence&&c.evidence.length>0&&(
            <div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
              {c.evidence.map(e=>(
                <span key={e} style={{fontSize:12,color:C.cyan,background:"#ECFEFF",
                  border:`1px solid #A5F3FC`,borderRadius:99,padding:"4px 11px",fontWeight:600}}>{e}</span>))}
            </div>)}
        </article>))}
      {rows.length===0&&<Empty>Nothing matches that filter.</Empty>}
    </Card>
  </div>;
}

/* ── CARD STUDIO ── */
function CardStudio({db,pub,open}){
  const[sel,setSel]=useState(open||(pub[0]&&pub[0].id));
  const[fmt,setFmt]=useState("x");
  const[step,setStep]=useState("hook");
  const[busy,setBusy]=useState(false);
  const[copied,setCopied]=useState(false);
  const claim=pub.find(c=>c.id===sel)||pub[0];
  if(!claim) return <Empty>Publish a ruling first, then come back to build a sequence.</Empty>;
  const thread=threadText(claim,db.settings);

  return <div style={{display:"grid",gap:18}}>
    <div style={{background:`linear-gradient(130deg,${C.violet},${C.pink})`,color:"#fff",
      borderRadius:24,padding:"28px 26px"}}>
      <Pill bg={C.lime} fg={C.ink}>Sequence studio</Pill>
      <h2 style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:"clamp(24px,3.6vw,36px)",
        margin:"12px 0 8px",letterSpacing:"-.03em"}}>One lie, five cards, in order</h2>
      <p style={{color:"rgba(255,255,255,.92)",margin:0,maxWidth:"60ch"}}>
        A single card states a verdict and convinces nobody. The sequence shows the claim, then the data that
        breaks it, then the sources, then why it matters. Built as a template, so every future ruling produces
        the same five cards automatically.</p>
    </div>

    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(290px,1fr))",gap:18}}>
      <Card title="Choose a ruling" pad={0}>
        <div style={{maxHeight:430,overflowY:"auto"}}>
          {pub.map(c=>(
            <button key={c.id} onClick={()=>setSel(c.id)}
              style={{display:"block",width:"100%",textAlign:"left",padding:"14px 18px",border:"none",
                borderBottom:`1px solid ${C.line2}`,cursor:"pointer",
                background:sel===c.id?"#F5F0FF":"#fff"}}>
              <div style={{display:"flex",gap:8,alignItems:"center",marginBottom:6,flexWrap:"wrap"}}>
                <Verdict v={c.verdict}/>
                {c.viz&&<Pill bg={C.cyan}>has chart</Pill>}
                <span style={{fontSize:12,color:C.soft}}>{c.party}</span></div>
              <div style={{fontSize:14,fontWeight:600,lineHeight:1.35}}>
                {c.claim.slice(0,92)}{c.claim.length>92?"…":""}</div>
            </button>))}
        </div>
      </Card>

      <Card title="Preview" action={
        <div style={{display:"flex"}}>
          {Object.entries(FORMATS).map(([k,f])=>(
            <button key={k} className={"seg"+(fmt===k?" on":"")} onClick={()=>setFmt(k)}>{f.note}</button>))}
        </div>}>
        <div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:14}}>
          {STEPS.map(st=>(
            <button key={st.key} onClick={()=>setStep(st.key)}
              style={{border:"none",cursor:"pointer",borderRadius:99,padding:"7px 13px",
                fontFamily:"'Outfit',sans-serif",fontWeight:700,fontSize:12,
                background:step===st.key?C.violet:"#fff",color:step===st.key?"#fff":C.ink2,
                outline:step===st.key?"none":`1.5px solid ${C.line}`}}>{st.label}</button>))}
        </div>
        <div style={{background:C.line2,borderRadius:16,padding:14}}>
          <CardImg claim={claim} fmt={fmt} settings={db.settings} step={step}/>
        </div>
        <div style={{display:"flex",gap:10,marginTop:14,flexWrap:"wrap",alignItems:"center"}}>
          <Btn kind="grad" onClick={async()=>{setBusy(true);
            await downloadSequence(claim,fmt,db.settings);setBusy(false);}}>
            {busy?"Exporting…":"Download all five"}</Btn>
          <Btn onClick={()=>downloadOne(claim,fmt,db.settings,step,
            STEPS.findIndex(x=>x.key===step)+1)}>This card only</Btn>
          <span style={{fontSize:12.5,color:C.soft}}>{FORMATS[fmt].w} × {FORMATS[fmt].h} px</span>
        </div>
      </Card>
    </div>

    <Card title="The full sequence" action={<Pill bg={C.cyan}>{FORMATS[fmt].name}</Pill>}>
      <div style={{display:"flex",gap:14,overflowX:"auto",paddingBottom:8}}>
        {STEPS.map((st,i)=>(
          <div key={st.key} style={{minWidth:fmt==="story"?190:270,flex:"0 0 auto"}}>
            <CardImg claim={claim} fmt={fmt} settings={db.settings} step={st.key}/>
            <div style={{marginTop:9,display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
              <b style={{fontFamily:"'Outfit',sans-serif",fontSize:12.5}}>{st.label}</b>
              <Btn size="sm" onClick={()=>downloadOne(claim,fmt,db.settings,st.key,i+1)}>PNG</Btn></div>
          </div>))}
      </div>
      <div style={{marginTop:12,fontSize:12.5,color:C.soft}}>
        Post in this order. On Instagram it is one carousel. On X it is a thread, with the card attached to the
        matching post.</div>
    </Card>

    <Card title="Matching thread text" action={
      <Btn kind="dark" size="sm" onClick={()=>{
        try{navigator.clipboard.writeText(thread.join("\n\n———\n\n"));setCopied(true);
          setTimeout(()=>setCopied(false),2000);}catch{}
      }}>{copied?"Copied":"Copy all five"}</Btn>}>
      <div style={{display:"grid",gap:12}}>
        {thread.map((t,i)=>(
          <div key={i} style={{background:C.line2,borderRadius:14,padding:"14px 16px",
            borderLeft:`4px solid ${i===0?(V[claim.verdict]||V.UNVERIFIED).color:C.violet}`}}>
            <div style={{display:"flex",gap:10,alignItems:"center",marginBottom:6,flexWrap:"wrap"}}>
              <Pill bg={C.night}>{i+1} of {thread.length}</Pill>
              <span style={{fontSize:12,color:C.soft}}>{STEPS[i].label}</span>
              <span style={{marginLeft:"auto",fontSize:12,color:t.length>270?C.red:C.soft}}>
                {t.length} characters</span></div>
            <pre style={{margin:0,fontFamily:"'Plus Jakarta Sans',sans-serif",fontSize:13.5,
              whiteSpace:"pre-wrap",color:C.ink}}>{t}</pre>
          </div>))}
      </div>
    </Card>

    <div style={{background:C.night,color:"#fff",borderRadius:20,padding:"22px 24px"}}>
      <b style={{fontFamily:"'Outfit',sans-serif",fontSize:17,display:"block",marginBottom:8}}>
        This is a template, not a one off</b>
      <p style={{color:"#DED4F5",margin:0,fontSize:14}}>
        The five card structure is defined once in the code as a list of steps. Every ruling that gets a
        verdict, a chart and a source list produces the same sequence with no design work. Add a step, change
        a colour, or add a format and every past and future ruling picks it up.</p>
    </div>
  </div>;
}


/* ── SEQUENCE EDITOR: edit what the cards say, with live preview ── */
function SequenceEditor({db,commit,go}){
  const pub=db.claims.filter(c=>c.status==="published");
  const[sel,setSel]=useState(pub[0]&&pub[0].id);
  const[fmt,setFmt]=useState("x");
  const[step,setStep]=useState("hook");
  const[draft,setDraft]=useState(null);
  const[tplDraft,setTplDraft]=useState(tpl(db.settings));
  const claim=pub.find(c=>c.id===sel);

  useEffect(()=>{ if(claim) setDraft(JSON.parse(JSON.stringify(claim))); },[sel]);
  if(!claim||!draft) return <Empty>Publish a ruling first, then edit its sequence here.</Empty>;

  const preview={...draft};
  const previewSettings={...db.settings,template:tplDraft};
  const set=(k,v)=>setDraft({...draft,[k]:v});
  const setViz=(k,v)=>setDraft({...draft,viz:{...(draft.viz||{bars:[]}),[k]:v}});
  const setBar=(i,k,v)=>{const bars=[...(draft.viz.bars||[])];
    bars[i]={...bars[i],[k]:k==="value"?(+v||0):v};setViz("bars",bars);};
  const addBar=()=>setViz("bars",[...((draft.viz&&draft.viz.bars)||[]),{label:"New",value:0}]);
  const delBar=i=>setViz("bars",draft.viz.bars.filter((_,j)=>j!==i));
  const addViz=()=>setDraft({...draft,viz:{title:"",unit:"",bars:[{label:"Claimed",value:0,tone:"bad"},
    {label:"Actual",value:0,tone:"good"}],claimedNote:"",actualNote:""}});
  const dirty=JSON.stringify(draft)!==JSON.stringify(claim)
    ||JSON.stringify(tplDraft)!==JSON.stringify(tpl(db.settings));

  const save=()=>commit({...db,
    claims:db.claims.map(c=>c.id===draft.id?{...draft,evidence:(draft.evidence||[]).map(x=>x.trim()).filter(Boolean)}:c),
    settings:{...db.settings,template:tplDraft}},"Sequence updated");

  return <div style={{display:"grid",gap:18}}>
    <div style={{background:`linear-gradient(130deg,${C.violet},${C.cyan})`,color:"#fff",
      borderRadius:24,padding:"26px"}}>
      <Pill bg={C.lime} fg={C.ink}>Sequence editor</Pill>
      <h2 style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:"clamp(23px,3.4vw,32px)",
        margin:"12px 0 6px",letterSpacing:"-.03em"}}>Edit what the cards say</h2>
      <p style={{color:"rgba(255,255,255,.92)",margin:0,maxWidth:"58ch"}}>
        Everything on the cards is editable here and saves to the backend. The quoted claim is deliberately
        locked, because editing what somebody said is the one thing a fact checker must never do.</p>
    </div>

    <div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"center"}}>
      <select value={sel} onChange={e=>setSel(e.target.value)} style={{maxWidth:460}}>
        {pub.map(c=><option key={c.id} value={c.id}>{c.id} · {c.claim.slice(0,64)}…</option>)}
      </select>
      <Btn kind={dirty?"grad":"ghost"} onClick={save}>{dirty?"Save changes":"Saved"}</Btn>
      {dirty&&<Btn onClick={()=>{setDraft(JSON.parse(JSON.stringify(claim)));setTplDraft(tpl(db.settings));}}>
        Discard</Btn>}
    </div>

    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(330px,1fr))",gap:18}}>
      {/* ── editing pane ── */}
      <div style={{display:"grid",gap:16}}>
        <Card title="Card 1 · The claim" accent={V[draft.verdict].color}>
          <div style={{display:"grid",gap:12}}>
            <div><span className="lab">Quoted claim, locked</span>
              <textarea rows={3} value={draft.claim} readOnly
                style={{background:"#F6F3FC",color:C.soft,cursor:"not-allowed"}}/>
              <div style={{fontSize:12,color:C.soft,marginTop:5}}>
                To correct a misquote, reject the claim and enter it again.</div></div>
            <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
              <div><span className="lab">Verdict</span>
                <select value={draft.verdict} onChange={e=>set("verdict",e.target.value)}>
                  {VK.map(k=><option key={k} value={k}>{V[k].label}</option>)}</select></div>
              <div><span className="lab">Party</span>
                <select value={draft.party} onChange={e=>set("party",e.target.value)}>
                  {PARTIES.map(p=><option key={p}>{p}</option>)}</select></div>
              <div><span className="lab">Speaker</span>
                <input value={draft.speaker} onChange={e=>set("speaker",e.target.value)}/></div>
              <div><span className="lab">Channel</span>
                <select value={draft.channel} onChange={e=>set("channel",e.target.value)}>
                  {CHANNELS.map(p=><option key={p}>{p}</option>)}</select></div>
            </div>
          </div>
        </Card>

        <Card title="Card 2 · The data" accent={C.cyan}>
          {!draft.viz
            ? <div><p style={{marginTop:0,fontSize:13.5,color:C.ink2}}>
                No chart on this ruling. Card two currently falls back to the written ruling.</p>
                <Btn kind="grad" size="sm" onClick={addViz}>Add a chart</Btn></div>
            : <div style={{display:"grid",gap:12}}>
                <div><span className="lab">Chart title</span>
                  <input value={draft.viz.title||""} onChange={e=>setViz("title",e.target.value)}/></div>
                <div><span className="lab">Unit</span>
                  <input value={draft.viz.unit||""} onChange={e=>setViz("unit",e.target.value)}
                    placeholder="%  ·  ₦m  ·  MW"/></div>
                <div><span className="lab">Bars</span>
                  <div style={{display:"grid",gap:8}}>
                    {(draft.viz.bars||[]).map((b,i)=>(
                      <div key={i} style={{display:"grid",gridTemplateColumns:"1fr 90px 110px 34px",
                        gap:8,alignItems:"center"}}>
                        <input value={b.label} onChange={e=>setBar(i,"label",e.target.value)}/>
                        <input type="number" value={b.value} onChange={e=>setBar(i,"value",e.target.value)}/>
                        <select value={b.tone||""} onChange={e=>setBar(i,"tone",e.target.value||undefined)}>
                          <option value="">Neutral</option><option value="bad">Claimed</option>
                          <option value="good">Actual</option></select>
                        <button onClick={()=>delBar(i)} style={{border:"none",background:"#FFE4E9",
                          color:C.red,borderRadius:10,height:38,cursor:"pointer",fontWeight:800}}>×</button>
                      </div>))}
                  </div>
                  <div style={{marginTop:9}}><Btn size="sm" onClick={addBar}>Add bar</Btn></div></div>
                <div><span className="lab">Claimed note, shown in the verdict colour</span>
                  <input value={draft.viz.claimedNote||""} onChange={e=>setViz("claimedNote",e.target.value)}/></div>
                <div><span className="lab">Actual note, shown in lime</span>
                  <input value={draft.viz.actualNote||""} onChange={e=>setViz("actualNote",e.target.value)}/></div>
                <div><Btn size="sm" kind="danger" onClick={()=>set("viz",null)}>Remove chart</Btn></div>
              </div>}
        </Card>

        <Card title="Card 3 · The evidence" accent={C.violet}>
          <span className="lab">One source per line</span>
          <textarea rows={5} value={(draft.evidence||[]).join("\n")}
            onChange={e=>set("evidence",e.target.value.split("\n"))}/>
          <div style={{fontSize:12,color:C.soft,marginTop:6}}>
            The first five appear on the card. All of them appear on the site and in the thread.</div>
        </Card>

        <Card title="Card 4 · Why it matters" accent={C.amber}>
          <div style={{display:"grid",gap:12}}>
            <div><span className="lab">The ruling, used on the site and in the thread</span>
              <textarea rows={4} value={draft.ruling||""} onChange={e=>set("ruling",e.target.value)}/></div>
            <div><span className="lab">Why it matters, the line on card four</span>
              <textarea rows={3} value={draft.why||""} onChange={e=>set("why",e.target.value)}
                placeholder="The consequence, not the correction."/></div>
          </div>
        </Card>

        <Card title="Card 5 and house copy" accent={C.lime}>
          <p style={{marginTop:0,fontSize:13.5,color:C.ink2}}>
            These apply to every card in every sequence, now and in future.</p>
          <div style={{display:"grid",gap:11}}>
            {[["brand","Brand line, top left"],["dataEyebrow","Card 2 heading"],
              ["evidenceEyebrow","Card 3 heading"],["whyEyebrow","Card 4 heading"],
              ["signoffLine1","Card 5 headline, line one"],["signoffLine2","Card 5 headline, line two"],
              ["signoffBody1","Card 5 body, line one"],["signoffBody2","Card 5 body, line two"],
              ["signoffFootnote","Card 5 footnote"],["threadCloser","Closing line of the thread"]].map(([k,l])=>(
              <div key={k}><span className="lab">{l}</span>
                <input value={tplDraft[k]||""} onChange={e=>setTplDraft({...tplDraft,[k]:e.target.value})}/></div>))}
          </div>
        </Card>
      </div>

      {/* ── live preview ── */}
      <div>
        <div style={{position:"sticky",top:90,display:"grid",gap:14}}>
          <Card title="Live preview" action={
            <div style={{display:"flex"}}>
              {Object.entries(FORMATS).map(([k,f])=>(
                <button key={k} className={"seg"+(fmt===k?" on":"")} onClick={()=>setFmt(k)}>{f.note}</button>))}
            </div>}>
            <div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:12}}>
              {STEPS.map(st=>(
                <button key={st.key} onClick={()=>setStep(st.key)}
                  style={{border:"none",cursor:"pointer",borderRadius:99,padding:"6px 12px",
                    fontFamily:"'Outfit',sans-serif",fontWeight:700,fontSize:11.5,
                    background:step===st.key?C.violet:"#fff",color:step===st.key?"#fff":C.ink2,
                    outline:step===st.key?"none":`1.5px solid ${C.line}`}}>{st.label}</button>))}
            </div>
            <div style={{background:C.line2,borderRadius:14,padding:12}}>
              <CardImg claim={preview} fmt={fmt} settings={previewSettings} step={step}/>
            </div>
            <div style={{marginTop:12,display:"flex",gap:9,flexWrap:"wrap"}}>
              <Btn size="sm" onClick={()=>downloadOne(preview,fmt,previewSettings,step,
                STEPS.findIndex(x=>x.key===step)+1)}>Export this card</Btn>
              <Btn size="sm" kind="dark" onClick={()=>downloadSequence(preview,fmt,previewSettings)}>
                Export all five</Btn>
            </div>
            {dirty&&<div style={{marginTop:12,padding:"10px 13px",background:"#FFF7E6",
              border:`1px solid ${C.amber}`,borderRadius:12,fontSize:13,color:"#78350F"}}>
              Unsaved changes. The preview is live, the public feed is not, until you save.</div>}
          </Card>
        </div>
      </div>
    </div>
  </div>;
}

/* ── ANALYTICS ── */
function Analytics({pub,all}){
  const counts={};pub.forEach(c=>{counts[c.verdict]=(counts[c.verdict]||0)+1;});
  const slices=VK.filter(k=>counts[k]).map(k=>({name:V[k].label,value:counts[k],color:V[k].color}));
  const byParty={};pub.forEach(c=>{byParty[c.party]=byParty[c.party]||{n:0,bad:0};
    byParty[c.party].n++; if(c.verdict==="FALSE"||c.verdict==="MISLEADING")byParty[c.party].bad++;});
  const partyRows=Object.entries(byParty).sort((a,b)=>b[1].bad-a[1].bad)
    .map(([name,v])=>({name,value:v.bad,color:C.red}));
  const partyAll=Object.entries(byParty).sort((a,b)=>b[1].n-a[1].n)
    .map(([name,v])=>({name,value:v.n}));
  const byChannel={};pub.forEach(c=>{byChannel[c.channel]=(byChannel[c.channel]||0)+1;});
  const chanRows=Object.entries(byChannel).sort((a,b)=>b[1]-a[1]).map(([name,value])=>({name,value}));
  const reachByVerdict={};pub.forEach(c=>{reachByVerdict[c.verdict]=(reachByVerdict[c.verdict]||0)+(c.reach||0);});
  const reachRows=VK.filter(k=>reachByVerdict[k]).map(k=>({name:V[k].label,value:reachByVerdict[k],color:V[k].color}));

  return <div style={{display:"grid",gap:18}}>
    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(170px,1fr))",gap:14}}>
      <Stat label="Rulings published" value={pub.length}/>
      <Stat label="Awaiting review" value={all.length-pub.length} tone={C.amber}/>
      <Stat label="False or misleading" value={Math.round(((counts.FALSE||0)+(counts.MISLEADING||0))/Math.max(1,pub.length)*100)+"%"} tone={C.red}/>
      <Stat label="Reach neutralised" value={num(Object.entries(reachByVerdict)
        .filter(([k])=>k==="FALSE"||k==="MISLEADING").reduce((a,[,v])=>a+v,0))} sub="of false or misleading claims"/>
    </div>
    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(320px,1fr))",gap:18}}>
      <Card title="Verdict mix" accent={`linear-gradient(90deg,${C.violet},${C.pink})`}>
        <Donut slices={slices} centreLabel="rulings"/></Card>
      <Card title="Reach by verdict">
        <Bars rows={reachRows}/>
        <div style={{marginTop:10,fontSize:12.5,color:C.soft}}>
          This is the number that matters. Not how many lies, but how far they travelled before we caught them.</div>
      </Card>
    </div>
    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(320px,1fr))",gap:18}}>
      <Card title="False or misleading, by party"><Bars rows={partyRows}/>
        <div style={{marginTop:10,fontSize:12.5,color:C.soft}}>
          Published in full, including our own client's party. A scoreboard that only counts one side is
          worthless.</div></Card>
      <Card title="Claims checked, by party"><Bars rows={partyAll}
        gradient={`linear-gradient(90deg,${C.violet},${C.cyan})`}/></Card>
    </div>
    <Card title="Where claims are made"><Bars rows={chanRows}
      gradient={`linear-gradient(90deg,${C.cyan},${C.lime})`}/></Card>
  </div>;
}

/* ── METHOD ── */
function Method({db}){
  return <div style={{display:"grid",gap:18,maxWidth:980}}>
    <div style={{background:C.night,color:"#fff",borderRadius:24,padding:"28px 26px"}}>
      <Pill bg={C.lime} fg={C.ink}>How this works</Pill>
      <h2 style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:"clamp(24px,3.6vw,34px)",
        margin:"12px 0 8px",letterSpacing:"-.03em"}}>Detection is automated. Judgement is not.</h2>
      <p style={{color:"rgba(255,255,255,.82)",margin:0,maxWidth:"60ch"}}>
        Machines are good at finding checkable claims in a flood of speech. They are not good at deciding
        whether a politician lied, and in Nigeria that decision carries legal weight. So the pipeline is
        automated up to the point of judgement, and stops there.</p>
    </div>

    <div style={{display:"grid",gap:12}}>
      {[["01","Ingest","Monitored feeds across X, Facebook, Instagram, TikTok, radio and television transcripts, rally recordings and print. Speech is transcribed and timestamped.",C.violet],
        ["02","Detect","A model separates checkable factual assertions from opinion, promise and rhetoric. A claim about a number, a date, a completed project or a comparison is checkable. A claim about what is best for Nigeria is not.",C.pink],
        ["03","Score and rank","Each detected claim is scored for reach, novelty and consequence. High reach on a checkable, consequential claim goes to the top of the queue.",C.cyan],
        ["04","Match","The claim is matched against our evidence base: INEC returns, National Bureau of Statistics releases, budget and procurement records, our own Reach Desk data, and previously published rulings.",C.amber],
        ["05","Draft","A first draft of the ruling and its sources is assembled automatically. It is a starting point for the editor, never a publishable output.",C.green],
        ["06","Sign","A named human editor assigns the verdict, checks every source, and signs it. Nothing reaches the public feed without that signature.",C.lime],
        ["07","Publish and card","Publication generates the share cards in three formats automatically, and the ruling enters the public API.",C.slate]].map(([n,t,d,col])=>(
        <div key={n} style={{background:C.panel,border:`1px solid ${C.line}`,borderLeft:`6px solid ${col}`,
          borderRadius:16,padding:"16px 20px"}}>
          <div style={{display:"flex",gap:12,alignItems:"baseline",flexWrap:"wrap"}}>
            <span style={{fontFamily:"'Outfit',sans-serif",fontWeight:900,fontSize:14,color:col}}>{n}</span>
            <b style={{fontFamily:"'Outfit',sans-serif",fontSize:17}}>{t}</b></div>
          <p style={{margin:"6px 0 0",fontSize:14,color:C.ink2}}>{d}</p></div>))}
    </div>

    <Card title="The verdict scale">
      <div style={{display:"grid",gap:10}}>
        {[["FALSE","The claim is contradicted by the best available evidence."],
          ["MISLEADING","Contains something true but framed so it leads to a false conclusion."],
          ["CONTEXT","Not wrong, but incomplete in a way that changes the meaning."],
          ["TRUE","Supported by the evidence we can produce."],
          ["UNVERIFIED","We cannot settle it yet. Says so plainly rather than guessing."]].map(([k,d])=>(
          <div key={k} style={{display:"flex",gap:14,alignItems:"center",flexWrap:"wrap",
            padding:"10px 0",borderBottom:`1px solid ${C.line2}`}}>
            <span style={{minWidth:170}}><Verdict v={k}/></span>
            <span style={{flex:1,minWidth:220,fontSize:14,color:C.ink2}}>{d}</span></div>))}
      </div>
    </Card>

    <div style={{background:"#FFF7E6",border:`1.5px solid ${C.amber}`,borderRadius:20,padding:"22px 24px"}}>
      <b style={{fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:17,display:"block",
        marginBottom:8,color:"#78350F"}}>What we will not do</b>
      <ul style={{margin:0,paddingLeft:20,color:"#78350F",fontSize:14}}>
        <li>Publish a verdict no human editor has signed.</li>
        <li>Rule on opinion, prediction or promise. Those go to the promise tracker, not here.</li>
        <li>Check one party and not the others. Every ruling shows the party, including our own client's.</li>
        <li>Publish without naming the evidence. If we cannot show the source, we mark it unverified.</li>
        <li>Delete a ruling. Corrections are published as corrections, with the original visible.</li>
      </ul>
    </div>
  </div>;
}

/* ── QUEUE ── */
function Queue({db,pending,commit,go}){
  const[edit,setEdit]=useState(null);
  const setVerdict=(id,patch)=>commit({...db,claims:db.claims.map(c=>c.id===id?{...c,...patch}:c)},
    patch.status==="published"?"Ruling published":"Saved");
  return <div style={{display:"grid",gap:18}}>
    <Card title={`${pending.length} detected claims awaiting a verdict`}
      action={<Pill bg={C.red}>not public</Pill>} pad={0}>
      {pending.map(c=>(
        <div key={c.id} style={{padding:20,borderBottom:`1px solid ${C.line2}`}}>
          <div style={{display:"flex",gap:10,alignItems:"center",marginBottom:10,flexWrap:"wrap"}}>
            <span style={{fontSize:12,color:C.soft}}>{c.id}</span>
            <Pill bg={C.night}>{c.party}</Pill>
            <span style={{fontSize:13,color:C.soft}}>{c.speaker} · {c.channel} · {c.date}</span>
            <Pill bg={C.slate}>{num(c.reach)} reach</Pill>
          </div>
          <div style={{fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:19,lineHeight:1.3,
            marginBottom:12}}>“{c.claim}”</div>
          {edit===c.id
            ? <RuleForm claim={c} onSave={p=>{setVerdict(c.id,p);setEdit(null);}} onCancel={()=>setEdit(null)}/>
            : <div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
                <Btn kind="grad" size="sm" onClick={()=>setEdit(c.id)}>Write the ruling</Btn>
                <Btn kind="danger" size="sm"
                  onClick={()=>setVerdict(c.id,{status:"rejected"})}>Not checkable</Btn></div>}
        </div>))}
      {pending.length===0&&<Empty>Queue is clear. Detection will keep adding to it.</Empty>}
    </Card>
    <div style={{background:C.night,color:"#fff",borderRadius:20,padding:"20px 24px",fontSize:14}}>
      <b style={{fontFamily:"'Outfit',sans-serif",fontSize:16,display:"block",marginBottom:6}}>
        This queue is the whole safeguard</b>
      <span style={{color:"#DED4F5"}}>Detection puts claims here. Nothing moves to the public feed until an
      editor writes a ruling, names the evidence and signs it.</span>
    </div>
  </div>;
}
function RuleForm({claim,onSave,onCancel}){
  const[v,setV]=useState("FALSE");
  const[ruling,setRuling]=useState("");
  const[ev,setEv]=useState("");
  const[err,setErr]=useState("");
  return <div style={{background:C.line2,borderRadius:16,padding:16,display:"grid",gap:12}}>
    <div><span className="lab">Verdict</span>
      <div style={{display:"flex",flexWrap:"wrap",gap:8}}>
        {VK.map(k=>(
          <button key={k} onClick={()=>setV(k)} style={{border:"none",cursor:"pointer",borderRadius:99,
            padding:"8px 15px",fontFamily:"'Outfit',sans-serif",fontWeight:800,fontSize:12.5,
            textTransform:"uppercase",letterSpacing:".03em",
            background:v===k?V[k].color:"#fff",color:v===k?V[k].ink:C.ink2,
            outline:v===k?"none":`1.5px solid ${C.line}`}}>{V[k].icon} {V[k].label}</button>))}
      </div></div>
    <div><span className="lab">The ruling, in plain language</span>
      <textarea rows={4} value={ruling} onChange={e=>setRuling(e.target.value)}
        placeholder="State what the evidence shows and why the claim fails or holds."/></div>
    <div><span className="lab">Evidence, one source per line</span>
      <textarea rows={3} value={ev} onChange={e=>setEv(e.target.value)}
        placeholder={"INEC declared results, 2023 presidential\nNBS labour force survey"}/></div>
    {err&&<div style={{color:C.red,fontSize:13.5}}>{err}</div>}
    <div style={{display:"flex",gap:10,flexWrap:"wrap"}}>
      <Btn kind="grad" onClick={()=>{
        if(!ruling.trim()){setErr("A ruling needs an explanation before it can be published.");return;}
        if(!ev.trim()){setErr("Name at least one source. No source, no publication.");return;}
        onSave({verdict:v,ruling:ruling.trim(),
          evidence:ev.split("\n").map(x=>x.trim()).filter(Boolean),
          status:"published",checkedBy:"Verify desk",minutes:Math.round(20+Math.random()*120)});
      }}>Sign and publish</Btn>
      <Btn onClick={onCancel}>Cancel</Btn>
    </div>
  </div>;
}

/* ── NEW CLAIM ── */
function NewClaim({db,commit,go}){
  const blank={date:today(),speaker:"",party:"APC",channel:"X",state:"National",claim:"",reach:""};
  const[f,setF]=useState(blank);const[err,setErr]=useState("");
  const set=k=>e=>setF({...f,[k]:e.target.value});
  return <div style={{maxWidth:840}}>
    <Card title="Add a claim manually" accent={`linear-gradient(90deg,${C.violet},${C.pink})`}>
      <p style={{marginTop:0,color:C.ink2,fontSize:14}}>
        For claims detection missed, or tip-offs from the public. It lands in the queue like any other.</p>
      <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(170px,1fr))",gap:14}}>
        <div><span className="lab">Date</span><input type="date" value={f.date} onChange={set("date")}/></div>
        <div><span className="lab">Speaker</span><input value={f.speaker} onChange={set("speaker")}
          placeholder="Party spokesperson"/></div>
        <div><span className="lab">Party</span><select value={f.party} onChange={set("party")}>
          {PARTIES.map(p=><option key={p}>{p}</option>)}</select></div>
        <div><span className="lab">Channel</span><select value={f.channel} onChange={set("channel")}>
          {CHANNELS.map(p=><option key={p}>{p}</option>)}</select></div>
        <div><span className="lab">Where</span><select value={f.state} onChange={set("state")}>
          {STATES.map(p=><option key={p}>{p}</option>)}</select></div>
        <div><span className="lab">Estimated reach</span><input type="number" min="0" value={f.reach}
          onChange={set("reach")}/></div>
        <div style={{gridColumn:"1 / -1"}}><span className="lab">The claim, in their words</span>
          <textarea rows={3} value={f.claim} onChange={set("claim")}/></div>
      </div>
      {err&&<div style={{marginTop:13,padding:"11px 15px",background:"#FFE4E9",borderRadius:12,
        color:C.red,fontSize:14}}>{err}</div>}
      <div style={{marginTop:18,display:"flex",gap:10,flexWrap:"wrap"}}>
        <Btn kind="grad" onClick={()=>{
          if(!f.claim.trim()){setErr("Enter the claim first.");return;}
          const rec={...f,id:"C-"+String(db.claims.length+1).padStart(4,"0"),
            reach:+f.reach||0,verdict:null,ruling:null,evidence:[],status:"pending",
            speaker:f.speaker||"Unattributed",checkedBy:"",minutes:null};
          commit({...db,claims:[rec,...db.claims]},"Added to the queue");
          setF(blank);go("queue");
        }}>Send to queue</Btn>
        <Btn onClick={()=>setF(blank)}>Clear</Btn></div>
    </Card>
  </div>;
}

/* ── API ── */
function Api({log,db}){
  return <div style={{display:"grid",gap:18}}>
    <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fit,minmax(168px,1fr))",gap:14}}>
      <Stat label="Store" value="Connected" sub={STORE} tone={C.green}/>
      <Stat label="Version" value={"v"+db.version}/>
      <Stat label="Claims held" value={db.claims.length}/>
      <Stat label="Calls" value={log.length} sub="this session"/></div>
    <Card title="Endpoints">
      {[["GET","/api/claims","Public. Every signed ruling with its evidence.",C.green],
        ["GET","/api/claims/:id","Public. A single ruling.",C.green],
        ["POST","/api/claims","Detection pipeline. Lands in the queue, never public.",C.cyan],
        ["PUT","/api/claims","Desk only. Signs and publishes.",C.amber],
        ["GET","/api/cards/:id.png","Public. The share card, generated on demand.",C.green]].map(([m,p,d,c])=>(
        <div key={m+p} style={{display:"flex",gap:13,alignItems:"center",padding:"12px 0",
          borderBottom:`1px solid ${C.line2}`,flexWrap:"wrap"}}>
          <span style={{fontFamily:"'Outfit',sans-serif",fontSize:11,fontWeight:700,color:"#fff",background:c,
            padding:"4px 11px",borderRadius:99,width:62,textAlign:"center"}}>{m}</span>
          <span style={{fontFamily:"ui-monospace,monospace",fontSize:13.5,width:190}}>{p}</span>
          <span style={{fontSize:13.5,color:C.ink2}}>{d}</span></div>))}</Card>
    <Card title="Live request log" pad={0}>
      {log.map(l=>(<div key={l.id} style={{display:"grid",gridTemplateColumns:"104px 58px 1fr 62px 64px",gap:11,
        padding:"10px 20px",borderBottom:`1px solid ${C.line2}`,fontFamily:"ui-monospace,monospace",fontSize:12.5}}>
        <span style={{color:C.soft}}>{l.time}</span>
        <span style={{fontWeight:700,color:l.m==="PUT"?C.amber:C.green}}>{l.m}</span>
        <span>{l.p}</span>
        <span style={{textAlign:"right",fontWeight:700,color:l.s<300?C.green:C.red}}>{l.s}</span>
        <span style={{textAlign:"right",color:C.soft}}>{l.ms}ms</span></div>))}
      {log.length===0&&<Empty>No calls yet.</Empty>}</Card>
  </div>;
}

/* ── SETTINGS ── */
function Settings({db,commit}){
  const[s,setS]=useState(db.settings);
  const exportJson=()=>{const b=new Blob([JSON.stringify(db,null,2)],{type:"application/json"});
    const a=document.createElement("a");a.href=URL.createObjectURL(b);a.download="verify.json";a.click();
    URL.revokeObjectURL(a.href);};
  return <div style={{maxWidth:760,display:"grid",gap:18}}>
    <Card title="Publication details">
      <div style={{display:"grid",gap:13}}>
        <div><span className="lab">Name</span><input value={s.org} onChange={e=>setS({...s,org:e.target.value})}/></div>
        <div><span className="lab">Handle shown on cards</span><input value={s.handle}
          onChange={e=>setS({...s,handle:e.target.value})}/></div>
        <div><span className="lab">Site shown on cards</span><input value={s.site}
          onChange={e=>setS({...s,site:e.target.value})}/></div>
        <div><Btn kind="grad" onClick={()=>commit({...db,settings:s},"Settings saved")}>Save</Btn></div>
      </div></Card>
    <Card title="Detection">
      <div style={{display:"grid",gap:13}}>
        <label style={{display:"flex",gap:12,alignItems:"center",cursor:"pointer"}}>
          <input type="checkbox" checked={s.autoDetect} style={{width:20,height:20}}
            onChange={e=>setS({...s,autoDetect:e.target.checked})}/>
          <span>Run automated detection continuously</span></label>
        <div><span className="lab">Queue threshold, checkability score out of 100</span>
          <input type="number" min="0" max="100" value={s.threshold}
            onChange={e=>setS({...s,threshold:+e.target.value})}/>
          <div style={{fontSize:12.5,color:C.soft,marginTop:6}}>
            Lower catches more and creates more work. Higher is quieter and misses more.</div></div>
        <div><Btn kind="grad" onClick={()=>commit({...db,settings:s},"Detection settings saved")}>Save</Btn></div>
      </div></Card>
    <Card title="Data">
      <div style={{display:"flex",gap:10,flexWrap:"wrap"}}>
        <Btn onClick={exportJson}>Export JSON</Btn>
        <Btn kind="danger" onClick={()=>{if(confirm("Reload the demo corpus?"))commit(SEED,"Demo data restored");}}>
          Restore demo data</Btn></div></Card>
  </div>;
}


class Boundary extends React.Component{
  constructor(p){super(p);this.state={e:null};}
  static getDerivedStateFromError(e){return {e};}
  render(){ if(this.state.e) return React.createElement("pre",
    {style:{padding:24,whiteSpace:"pre-wrap",fontFamily:"monospace",color:"#E11D48"}},
    "Render error:\n\n"+String(this.state.e));
    return this.props.children; }
}
ReactDOM.createRoot(document.getElementById("root"))
  .render(React.createElement(Boundary,null,React.createElement(Verify)));
