← back to Crazy News Channel

daily-cartoons/review.html

286 lines

<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>P24 Cartoon Queue</title>
<link rel="stylesheet" href="../assets/style.css"></head><body class="cartoon-queue-page" data-mode="static">
<header><div><h1>🖋️ P24 Daily Cartoon Queue</h1>
<p class="note">5 queued · total spend $0.515 · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</p>
<p class="rq-mode-note rq-static-note">Read-only snapshot — open via review-server to act (<code>node daily-cartoons/review-server.mjs</code>, see README).</p></div>
<div class="ctrl"><label>Sort <select id="sort"><option value="new">Newest</option><option value="old">Oldest</option><option value="title">Title A→Z</option><option value="cost">Cost ↓</option></select></label>
<label>Size <input id="dens" type="range" min="220" max="640" step="20" value="340"></label></div></header>
<main id="grid"><article class="card rq-card" data-date="2026-09-25" data-slug="infinite-scroll" data-status="queued" data-created="2026-09-25T14:29:23+00:00" data-title="Infinite Scroll" data-cost="0.103">
  <label class="rq-select"><input type="checkbox" class="rq-check" disabled aria-label="Select &ldquo;Infinite Scroll&rdquo;"> <span>Select</span></label>
  <video controls preload="none" poster="queue/2026-09-25/infinite-scroll/poster.jpg" src="queue/2026-09-25/infinite-scroll/clip.mp4"></video>
  <div class="body">
    <h2>Infinite Scroll</h2>
    <p class="cap">&ldquo;Scrolling: where time goes to die.&rdquo;</p>
    <p class="src no-story">no linked article — pre-TK-12237 item, pass --story-id to approve</p>
    <div class="chips"><span class="chip when" title="2026-09-25T14:29:23+00:00" data-iso="2026-09-25T14:29:23+00:00">🕓 2026-09-25T14:29:23+00:00</span>
      <span class="chip">$0.103</span><span class="chip rq-status st-queued">queued</span></div>
    <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, scratchboard-like white highlights cut out of heavy solid black. A disheveled figure hunched over a laptop, endlessly scrolling through an infinite scroll of repetitive cat memes. The endless repetition is illustrated with identical cat faces and &#x27;lol&#x27; captions filling the screen. Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, no text, no letters, no caption, no speech bubbles, no watermark, no signature.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The cat images rapidly scroll past in a blur, each with the same exaggerated &#x27;lol&#x27; caption, making a monotonous typing sound. Slow, dry, deadpan comedy timing.</p></details>
    <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py infinite-scroll --date 2026-09-25 --story-id &lt;story-id&gt;</code>
    <p class="rq-err" role="alert" hidden></p>
  </div>
  <div class="rq-actions">
    <button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-label="Approve &ldquo;Infinite Scroll&rdquo;">Approve</button>
    <button type="button" class="rq-btn rq-delete" data-act="delete" disabled aria-label="Delete &ldquo;Infinite Scroll&rdquo;">Delete</button>
  </div>
</article><article class="card rq-card" data-date="2026-09-25" data-slug="tech-tyrant" data-status="queued" data-created="2026-09-25T14:27:36+00:00" data-title="Tech Tyrant" data-cost="0.103">
  <label class="rq-select"><input type="checkbox" class="rq-check" disabled aria-label="Select &ldquo;Tech Tyrant&rdquo;"> <span>Select</span></label>
  <video controls preload="none" poster="queue/2026-09-25/tech-tyrant/poster.jpg" src="queue/2026-09-25/tech-tyrant/clip.mp4"></video>
  <div class="body">
    <h2>Tech Tyrant</h2>
    <p class="cap">&ldquo;The algorithm always knows best.&rdquo;</p>
    <p class="src no-story">no linked article — pre-TK-12237 item, pass --story-id to approve</p>
    <div class="chips"><span class="chip when" title="2026-09-25T14:27:36+00:00" data-iso="2026-09-25T14:27:36+00:00">🕓 2026-09-25T14:27:36+00:00</span>
      <span class="chip">$0.103</span><span class="chip rq-status st-queued">queued</span></div>
    <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, scratchboard-like white highlights cut out of heavy solid black. A stern-faced figure sits on a high-tech throne surrounded by screens. A glowing blue hand projects from one of the screens, gesturing towards an abstract image on another screen, which represents &#x27;the people&#x27; being manipulated and guided like puppets. Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, no text, no letters, no caption, no speech bubbles, no watermark, no signature.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The blue hand swipes across the screens rapidly, causing the abstract representation of the people to nod in unison as if controlled by a puppet master. Slow, dry, deadpan comedy timing.</p></details>
    <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py tech-tyrant --date 2026-09-25 --story-id &lt;story-id&gt;</code>
    <p class="rq-err" role="alert" hidden></p>
  </div>
  <div class="rq-actions">
    <button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-label="Approve &ldquo;Tech Tyrant&rdquo;">Approve</button>
    <button type="button" class="rq-btn rq-delete" data-act="delete" disabled aria-label="Delete &ldquo;Tech Tyrant&rdquo;">Delete</button>
  </div>
</article><article class="card rq-card rq-approved" data-date="2026-09-25" data-slug="senator-s-shadow-war" data-status="approved" data-created="2026-09-25T14:25:59+00:00" data-title="Senator&#x27;s Shadow War" data-cost="0.103">
  <label class="rq-select"><input type="checkbox" class="rq-check" disabled aria-label="Select &ldquo;Senator&#x27;s Shadow War&rdquo;"> <span>Select</span></label>
  <video controls preload="none" poster="queue/2026-09-25/senator-s-shadow-war/poster.jpg" src="queue/2026-09-25/senator-s-shadow-war/clip.mp4"></video>
  <div class="body">
    <h2>Senator&#x27;s Shadow War</h2>
    <p class="cap">&ldquo;In shadowy negotiations, power is real.&rdquo;</p>
    <p class="src"><a href="https://news.google.com/rss/articles/CBMi3wFBVV95cUxPaTVXUElrb1BVMzFkbjF4ajlaZVZISy1rZ1VQSEx3UnU4dUM5RkNZOUc3V2hsb25RQ25GVGtnVjNZMEpiWmZydmlLYmo4cDNMbk5nYXRDN0RBRDI3LTdxS19xbUlpOHBWajg2dDJNb1NhZHVVVGlsX2VaN3poUUc5ZkRyOGxtQVVhSTNxclpENlVPdWJOX09zOGcwR01oY3c3M2ZXWW1icVN5VTJuUlAxVFJVcVF0SzZTZHItZWUxM0xHcWdRQzVvTU91OXdjalRrOWRVWWNPc05YRE9zSmo0?oc=5" target="_blank" rel="noopener noreferrer">📰 &#x27;Survivor 51&#x27; cast: Meet the 20 castaways battling to win after last night&#x27;s elimination</a> <span class="chip">real-news</span></p>
    <div class="chips"><span class="chip when" title="2026-09-25T14:25:59+00:00" data-iso="2026-09-25T14:25:59+00:00">🕓 2026-09-25T14:25:59+00:00</span>
      <span class="chip">$0.103</span><span class="chip rq-status st-approved">approved</span></div>
    <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, scratchboard-like white highlights cut out of heavy solid black. A jowly senator stands on a podium casting an enormous shadow across a dimly lit room. His shadowy figure morphs into menacing figures and abstract shapes, like swords or masks, battling each other in the background. Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, no text, no letters, no caption, no speech bubbles, no watermark, no signature.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The senator&#x27;s silhouette winks and gestures, causing his shadow to morph into different forms that clash violently. Slow, dry, deadpan comedy timing.</p></details>
    <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py senator-s-shadow-war --date 2026-09-25 --story-id real-entertainment-3</code>
    <p class="rq-err" role="alert" hidden></p>
  </div>
  <div class="rq-actions">
    <button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-disabled="true">Approved ✓</button>
    <button type="button" class="rq-btn rq-delete" data-act="delete" disabled aria-label="Delete &ldquo;Senator&#x27;s Shadow War&rdquo;">Delete</button>
  </div>
</article><article class="card rq-card" data-date="2026-09-25" data-slug="bureaucrats-and-bubbles" data-status="queued" data-created="2026-09-25T14:23:48+00:00" data-title="Bureaucrats and Bubbles" data-cost="0.103">
  <label class="rq-select"><input type="checkbox" class="rq-check" disabled aria-label="Select &ldquo;Bureaucrats and Bubbles&rdquo;"> <span>Select</span></label>
  <video controls preload="none" poster="queue/2026-09-25/bureaucrats-and-bubbles/poster.jpg" src="queue/2026-09-25/bureaucrats-and-bubbles/clip.mp4"></video>
  <div class="body">
    <h2>Bureaucrats and Bubbles</h2>
    <p class="cap">&ldquo;When committees debate, they just inflate ideas.&rdquo;</p>
    <p class="src no-story">no linked article — pre-TK-12237 item, pass --story-id to approve</p>
    <div class="chips"><span class="chip when" title="2026-09-25T14:23:48+00:00" data-iso="2026-09-25T14:23:48+00:00">🕓 2026-09-25T14:23:48+00:00</span>
      <span class="chip">$0.103</span><span class="chip rq-status st-queued">queued</span></div>
    <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, scratchboard-like white highlights cut out of heavy solid black. A committee of identical bureaucrats sit at a rectangular table, each surrounded by floating soap bubbles. They gesture excitedly towards the bubbles, which seem to grow larger with their every word, obscuring their faces in an absurd balloon maze. Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, no text, no letters, no caption, no speech bubbles, no watermark, no signature.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The bureaucrats&#x27; hands wave rapidly as the bubbles expand and pop in exaggerated slow motion, making funny popping sounds. Slow, dry, deadpan comedy timing.</p></details>
    <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py bureaucrats-and-bubbles --date 2026-09-25 --story-id &lt;story-id&gt;</code>
    <p class="rq-err" role="alert" hidden></p>
  </div>
  <div class="rq-actions">
    <button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-label="Approve &ldquo;Bureaucrats and Bubbles&rdquo;">Approve</button>
    <button type="button" class="rq-btn rq-delete" data-act="delete" disabled aria-label="Delete &ldquo;Bureaucrats and Bubbles&rdquo;">Delete</button>
  </div>
</article><article class="card rq-card" data-date="2026-09-24" data-slug="committee-of-shadows" data-status="queued" data-created="2026-09-24T22:12:18+00:00" data-title="Committee of Shadows" data-cost="0.103">
  <label class="rq-select"><input type="checkbox" class="rq-check" disabled aria-label="Select &ldquo;Committee of Shadows&rdquo;"> <span>Select</span></label>
  <video controls preload="none" poster="queue/2026-09-24/committee-of-shadows/poster.jpg" src="queue/2026-09-24/committee-of-shadows/clip.mp4"></video>
  <div class="body">
    <h2>Committee of Shadows</h2>
    <p class="cap">&ldquo;In darkness, they find clarity.&rdquo;</p>
    <p class="src no-story">no linked article — pre-TK-12237 item, pass --story-id to approve</p>
    <div class="chips"><span class="chip when" title="2026-09-24T22:12:18+00:00" data-iso="2026-09-24T22:12:18+00:00">🕓 2026-09-24T22:12:18+00:00</span>
      <span class="chip">$0.103</span><span class="chip rq-status st-queued">queued</span></div>
    <details><summary>prompts</summary><p><b>image:</b> Black and white single-panel editorial cartoon, classic 1970s newspaper op-ed page style. Pen-and-ink with loose, confident, slightly jagged brush contour lines and dense directional crosshatching, scratchboard-like white highlights cut out of heavy solid black. A committee of identical bureaucrats sitting around a circular table under dim red light, each with half their face obscured by shadowy masks; behind them is an endless sea of file cabinets with ominous glowing neon labels. Figures are invented anonymous caricatures, not any real person. High contrast, stark, absurd, ink-heavy, hand-drawn newspaper cartoon. No color, no gradients, no 3D render, no photorealism, no vector clip art, no text, no letters, no caption, no speech bubbles, no watermark, no signature.</p><p><b>motion:</b> Animate this black-and-white pen-and-ink editorial cartoon, keep the hand-drawn ink style throughout, static camera. The shadows on the masks shift and stretch as if animated, making the masked faces look like they are whispering to each other. Slow, dry, deadpan comedy timing.</p></details>
    <code class="cmd" title="click to copy">python3 daily-cartoons/approve.py committee-of-shadows --date 2026-09-24 --story-id &lt;story-id&gt;</code>
    <p class="rq-err" role="alert" hidden></p>
  </div>
  <div class="rq-actions">
    <button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-label="Approve &ldquo;Committee of Shadows&rdquo;">Approve</button>
    <button type="button" class="rq-btn rq-delete" data-act="delete" disabled aria-label="Delete &ldquo;Committee of Shadows&rdquo;">Delete</button>
  </div>
</article></main>

<script>
const g=document.getElementById('grid'),S=document.getElementById('sort'),D=document.getElementById('dens');
const LIVE=document.body.dataset.mode==='live';
const ls=(k,v)=>{try{return v===undefined?localStorage.getItem(k):localStorage.setItem(k,v)}catch(e){return null}};
document.querySelectorAll('.when').forEach(el=>{const d=new Date(el.dataset.iso);if(!isNaN(d))el.textContent='🕓 '+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})});
function sort(){const c=[...g.querySelectorAll('.card')],v=S.value;c.sort((a,b)=>v==='title'?a.dataset.title.localeCompare(b.dataset.title):v==='cost'?b.dataset.cost-a.dataset.cost:v==='old'?a.dataset.created.localeCompare(b.dataset.created):b.dataset.created.localeCompare(a.dataset.created));c.forEach(x=>g.appendChild(x));ls('p24q-sort',v)}
function dens(){g.style.setProperty('--min',D.value+'px');ls('p24q-dens',D.value)}
S.value=ls('p24q-sort')||'new';D.value=ls('p24q-dens')||340;S.onchange=sort;D.oninput=dens;sort();dens();
document.querySelectorAll('.cmd').forEach(el=>el.onclick=()=>navigator.clipboard&&navigator.clipboard.writeText(el.textContent));
if(LIVE){
  const bar=document.getElementById('rq-bulk'),cnt=document.getElementById('rq-count');
  const bA=document.getElementById('rq-bulk-approve'),bD=document.getElementById('rq-bulk-delete');
  const modal=document.getElementById('rq-modal'),mText=document.getElementById('rq-modal-text');
  const mOk=document.getElementById('rq-modal-ok'),mNo=document.getElementById('rq-modal-cancel');
  const toast=document.getElementById('rq-toast'),tText=document.getElementById('rq-toast-text'),tUndo=document.getElementById('rq-toast-undo');
  const cards=()=>[...g.querySelectorAll('.rq-card')];
  const selected=()=>cards().filter(c=>c.querySelector('.rq-check').checked);
  const bErr=document.getElementById('rq-bulk-err');
  const key=c=>c.dataset.storyId?{date:c.dataset.date,slug:c.dataset.slug,story_id:c.dataset.storyId}:{date:c.dataset.date,slug:c.dataset.slug};
  const needsStory=c=>c.classList.contains('rq-needs-story')&&!c.dataset.storyId&&c.dataset.status!=='approved';
  function bulkErr(msg){bErr.textContent=msg||'';bErr.hidden=!msg}
  const byKey=it=>g.querySelector(`.rq-card[data-date="${it.date}"][data-slug="${it.slug}"]`);
  let busy=false,toastTimer=null,lastRemoved=[];
  function refresh(){
    const sel=selected(),toApprove=sel.filter(c=>c.dataset.status!=='approved');
    cards().forEach(c=>c.classList.toggle('rq-selected',c.querySelector('.rq-check').checked));
    bar.hidden=sel.length===0;
    cnt.textContent=sel.length+' selected';
    bA.textContent='Approve '+toApprove.length;bA.disabled=busy||toApprove.length===0;
    bD.textContent='Delete '+sel.length;bD.disabled=busy||sel.length===0;
  }
  function setBusy(v){busy=v;document.body.classList.toggle('rq-busy',v);document.querySelectorAll('.rq-actions .rq-btn').forEach(b=>{if(!b.closest('.rq-approved')||b.dataset.act==='delete')b.disabled=v});refresh()}
  function showToast(msg,undoItems){
    tText.textContent=msg;tUndo.hidden=!(undoItems&&undoItems.length);toast.hidden=false;
    clearTimeout(toastTimer);toastTimer=setTimeout(()=>{toast.hidden=true},undoItems&&undoItems.length?20000:8000);
  }
  async function post(path,items){
    const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items})});
    let j={};try{j=await r.json()}catch(e){}
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
    return j.results||[];
  }
  function markErr(c,msg){const p=c.querySelector('.rq-err');p.textContent=msg;p.hidden=!msg}
  function showSource(c,r){
    if(!r||!r.story_url)return;
    const p=document.createElement('p');p.className='src';
    const a=document.createElement('a');a.href=r.story_url;a.target='_blank';a.rel='noopener noreferrer';a.textContent='📰 '+(r.story_title||'linked article');
    const s=document.createElement('span');s.className='chip';s.textContent=r.story_source||'';
    p.append(a,' ',s);
    const old=c.querySelector('.body .src');if(old)old.replaceWith(p);else c.querySelector('.body .cap').after(p);
  }
  function markApproved(c,r){
    const pk=c.querySelector('.rq-picker');if(pk)pk.remove();
    c.classList.remove('rq-needs-story');showSource(c,r);
    c.dataset.status='approved';c.classList.add('rq-approved');
    const st=c.querySelector('.rq-status');st.textContent='approved';st.className='chip rq-status st-approved';
    const b=c.querySelector('.rq-approve');b.textContent='Approved ✓';b.disabled=true;b.setAttribute('aria-disabled','true');b.removeAttribute('aria-label');
    c.querySelector('.rq-check').checked=false;markErr(c,'');
  }
  async function approve(list,bulk){
    list=list.filter(c=>c.dataset.status!=='approved');if(!list.length||busy)return;
    // Every cartoon must link a real article (TK-12158): refuse up front, name the cards, send nothing.
    const missing=list.filter(needsStory);
    if(missing.length){
      missing.forEach(c=>markErr(c,'Choose an article above before approving.'));
      if(bulk)bulkErr(`Not approved — ${missing.length} selected card${missing.length>1?'s have':' has'} no article chosen: `+missing.map(c=>'“'+c.dataset.title+'”').join(', ')+'. Pick an article on each (or unselect it), then Approve again.');
      else{const q=missing[0].querySelector('.rq-pk-q');if(q)q.focus()}
      return;
    }
    bulkErr('');setBusy(true);
    try{
      const res=await post('/api/approve',list.map(key));let ok=0,bad=0;
      res.forEach(r=>{const c=byKey(r);if(!c)return;if(r.ok){markApproved(c,r);ok++}else{markErr(c,'Approve failed: '+(r.error||'unknown error'));bad++}});
      showToast(`Approved ${ok}`+(bad?` · ${bad} failed (see card)`:''));
    }catch(e){showToast('Approve failed: '+e.message)}
    finally{setBusy(false)}
  }
  async function del(list){
    if(!list.length||busy)return;setBusy(true);
    try{
      const res=await post('/api/delete',list.map(key));const removed=[];let bad=0;
      res.forEach(r=>{const c=byKey(r);if(!c)return;if(r.ok){removed.push({item:{date:r.date,slug:r.slug},el:c});c.remove()}else{markErr(c,'Delete failed: '+(r.error||'unknown error'));bad++}});
      lastRemoved=removed;
      showToast(`Deleted ${removed.length} (moved to queue/_trash)`+(bad?` · ${bad} failed`:''),removed.map(x=>x.item));
    }catch(e){showToast('Delete failed: '+e.message)}
    finally{setBusy(false)}
  }
  async function undo(){
    const back=lastRemoved;if(!back.length||busy)return;setBusy(true);
    try{
      const res=await post('/api/restore',back.map(x=>x.item));let ok=0;
      res.forEach(r=>{const x=back.find(b=>b.item.date===r.date&&b.item.slug===r.slug);if(r.ok&&x){x.el.querySelector('.rq-check').checked=false;g.appendChild(x.el);ok++}});
      lastRemoved=[];sort();showToast(`Restored ${ok}`+(ok<back.length?` · ${back.length-ok} failed`:''));
    }catch(e){showToast('Restore failed: '+e.message)}
    finally{setBusy(false)}
  }
  let pending=null,lastFocus=null;
  function confirmDelete(list){
    if(!list.length)return;pending=list;lastFocus=document.activeElement;
    mText.textContent=list.length===1?`Delete “${list[0].dataset.title}”? It moves to queue/_trash and can be undone.`:`Delete ${list.length} cartoons? They move to queue/_trash and can be undone.`;
    modal.hidden=false;mNo.focus();
  }
  function closeModal(){modal.hidden=true;pending=null;if(lastFocus&&document.contains(lastFocus))lastFocus.focus()}
  mNo.onclick=closeModal;
  mOk.onclick=()=>{const l=pending;closeModal();del(l)};
  modal.addEventListener('keydown',ev=>{
    if(ev.key==='Escape'){ev.preventDefault();closeModal()}
    if(ev.key==='Tab'){const f=[mNo,mOk];const i=f.indexOf(document.activeElement);ev.preventDefault();f[(i+(ev.shiftKey?f.length-1:1))%f.length].focus()}
  });
  modal.addEventListener('click',ev=>{if(ev.target===modal)closeModal()});
  g.addEventListener('change',ev=>{if(ev.target.classList.contains('rq-check')){bulkErr('');refresh()}});
  g.addEventListener('click',ev=>{
    const b=ev.target.closest('.rq-actions .rq-btn');if(!b||b.disabled)return;const c=b.closest('.rq-card');
    if(b.dataset.act==='approve')approve([c]);else confirmDelete([c]);
  });
  bA.onclick=()=>approve(selected(),true);
  bD.onclick=()=>confirmDelete(selected());
  const selAll=()=>{cards().forEach(c=>c.querySelector('.rq-check').checked=true);refresh()};
  document.getElementById('rq-bulk-all').onclick=selAll;
  document.getElementById('rq-select-all').onclick=selAll;
  document.getElementById('rq-bulk-clear').onclick=()=>{cards().forEach(c=>c.querySelector('.rq-check').checked=false);refresh()};
  tUndo.onclick=undo;
  // ---- Article picker for cards with no story_id. Suggestions = plain keyword overlap between the
  // cartoon (title+caption weighted 3, image prompt 1) and each story's headline+tags. $0, no LLM.
  const STOP=new Set('the and for with from that this into over under after about are was were has have had its his her their they them than then what when where which who why how not but you your our out all any one two new more most very just also will can may off onto upon while says said via amid near per'.split(' '));
  const STYLE=new Set('black white single panel editorial cartoon classic 1970s newspaper page style pen ink loose confident slightly jagged brush contour line lines dense directional crosshatching scratchboard like highlight highlights cut heavy solid figure figures invented anonymous caricature caricatures real person high contrast stark absurd drawn color colour gradient gradients render photorealism vector clip art text letter letters caption speech bubble bubbles watermark signature hand made'.split(' '));
  const stem=w=>w.length>4&&w.endsWith('s')&&!w.endsWith('ss')?w.slice(0,-1):w;
  const toks=(s,extra)=>new Set((String(s||'').toLowerCase().match(/[a-z0-9]+/g)||[]).filter(w=>w.length>=3&&!STOP.has(w)&&!(extra&&extra.has(w))).map(stem).filter(w=>!(extra&&extra.has(w))));
  const srcLabel=s=>s.source==='real-news'?(s.sourceName||'Real news'):'P24';
  let STORIES=[],SBYID=new Map();
  function score(pk,s){
    const A=pk._a||(pk._a=toks(pk.dataset.kwA)),B=pk._b||(pk._b=toks(pk.dataset.kwB,STYLE));
    // Outlet names ride along in real-news tags ("the new york times" would match a caption's
    // "time"), so drop source-name tokens; a headline hit counts double a tag-only hit.
    const src=toks(s.sourceName),H=toks(s.headline,src),T=toks((s.tags||[]).join(' '),src);
    let n=0;new Set([...H,...T]).forEach(t=>{const w=(A.has(t)?3:B.has(t)?1:0)*(H.has(t)?2:1);n+=w});return n;
  }
  function choose(c,id){
    const s=SBYID.get(id);if(!s)return;const pk=c.querySelector('.rq-picker');if(!pk)return;
    c.dataset.storyId=id;
    const sel=pk.querySelector('.rq-pk-s');if([...sel.options].some(o=>o.value===id))sel.value=id;
    pk.querySelectorAll('.rq-sugg-btn').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.id===id)));
    const ch=pk.querySelector('.rq-pk-chosen');ch.textContent='Linked: '+s.headline+' · '+srcLabel(s);ch.classList.add('rq-pk-ok');
    pk.classList.add('rq-picked');markErr(c,'');
    const cmd=c.querySelector('.cmd');if(cmd)cmd.textContent=`python3 daily-cartoons/approve.py ${c.dataset.slug} --date ${c.dataset.date} --story-id ${id}`;
    if(!bErr.hidden&&!selected().some(needsStory))bulkErr('');
  }
  function fill(pk){
    const c=pk.closest('.rq-card'),sel=pk.querySelector('.rq-pk-s');
    const terms=pk.querySelector('.rq-pk-q').value.toLowerCase().split(/\s+/).filter(Boolean);
    const hit=s=>{const h=(s.headline+' '+(s.tags||[]).join(' ')+' '+(s.sourceName||'')+' '+s.id).toLowerCase();return terms.every(t=>h.includes(t))};
    sel.textContent='';let n=0;
    [['real-news','Real news'],['p24','P24 stories']].forEach(([src,label])=>{
      const list=STORIES.filter(s=>s.source===src&&hit(s));if(!list.length)return;
      const og=document.createElement('optgroup');og.label=`${label} (${list.length})`;
      list.forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.headline+' — '+srcLabel(s);o.title=s.headline+' — '+srcLabel(s)+' ['+s.id+']';og.appendChild(o);n++});
      sel.appendChild(og);
    });
    if(!n){const o=document.createElement('option');o.disabled=true;o.textContent='No articles match “'+terms.join(' ')+'”';sel.appendChild(o)}
    if(c.dataset.storyId&&[...sel.options].some(o=>o.value===c.dataset.storyId))sel.value=c.dataset.storyId;
    return n;
  }
  function initPicker(pk){
    const c=pk.closest('.rq-card'),q=pk.querySelector('.rq-pk-q'),sel=pk.querySelector('.rq-pk-s'),box=pk.querySelector('.rq-sugg');
    const top=STORIES.map(s=>({s,n:score(pk,s)})).filter(x=>x.n>0).sort((a,b)=>b.n-a.n||(b.s.publishedAt||'').localeCompare(a.s.publishedAt||'')).slice(0,3);
    box.textContent='';
    if(!top.length){const p=document.createElement('p');p.className='rq-sugg-none';p.textContent='No keyword matches — search below.';box.appendChild(p)}
    top.forEach(({s})=>{
      const b=document.createElement('button');b.type='button';b.className='rq-sugg-btn';b.dataset.id=s.id;b.setAttribute('aria-pressed','false');
      const h=document.createElement('span');h.className='rq-sugg-t';h.textContent=s.headline;
      const src=document.createElement('span');src.className='chip rq-sugg-src';src.textContent=srcLabel(s);
      b.append(h,src);b.onclick=()=>choose(c,s.id);box.appendChild(b);
    });
    fill(pk);
    q.addEventListener('input',()=>fill(pk));
    q.addEventListener('keydown',ev=>{
      if(ev.key==='ArrowDown'){ev.preventDefault();const o=[...sel.options].find(x=>!x.disabled);if(o&&!sel.value)sel.value=o.value;sel.focus()}
      if(ev.key==='Enter'){ev.preventDefault();const o=[...sel.options].find(x=>!x.disabled);if(o)choose(c,o.value)}
    });
    sel.addEventListener('change',()=>{if(sel.value)choose(c,sel.value)});
    sel.addEventListener('keydown',ev=>{if(ev.key==='Enter'&&sel.value){ev.preventDefault();choose(c,sel.value)}});
  }
  const pickers=[...g.querySelectorAll('.rq-picker')];
  if(pickers.length){
    fetch('/api/stories').then(r=>r.ok?r.json():Promise.reject(new Error('HTTP '+r.status))).then(j=>{
      STORIES=j.stories||[];SBYID=new Map(STORIES.map(s=>[s.id,s]));pickers.forEach(initPicker);
    }).catch(e=>pickers.forEach(pk=>{const p=pk.querySelector('.rq-sugg-none');if(p){p.textContent='Could not load articles: '+e.message;p.classList.add('rq-pk-bad')}}));
  }
  refresh();
}
</script></body></html>