← back to Crazy News Channel

.claude/worktrees/agent-a08d7d38661546685

911 lines

commit 8691d1057e4a1dd6e2433ef16af3ceebb3717961
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 10:54:09 2026 -0700

    p24 daily-cartoons: review-server with multi-select Approve/Delete (TK-12243)
    
    - daily-cartoons/review-server.mjs: zero-dep node:http server, 127.0.0.1:9947 (walks up if
      taken; PORT env fixes it), Basic Auth. Renders the review page live, serves style.css + queue
      media, POST /api/approve (approve.py via execFile, sequential), /api/delete (move to
      queue/_trash, never rm), /api/restore; strict date/slug validation (traversal -> 400);
      JSON-only + same-origin POSTs; every action appended to logs/review-actions.jsonl.
    - build_review.py: one template for static + live. Per-card Select checkbox and big full-width
      Approve (green) / Delete (red) buttons below each card; sticky bulk bar (Approve N / Delete N /
      Select all / Clear); in-page delete-confirm modal; Undo toast; Approved-check state, no double
      approve. Static review.html renders controls disabled with "open via review-server to act".
    - common.py: P24_SITE_DIR now also relocates queue/review.html (tests never touch the real queue);
      queue_dates() skips queue/_trash (generate.py uses it).
    - assets/style.css: scoped rq-* classes under body.cartoon-queue-page (one-stylesheet rule).
    - Verified with Playwright against a temp COPY: 46/46 checks, 0 console errors.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq

diff --git a/assets/style.css b/assets/style.css
index 25e1934..ee6aa98 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -1084,6 +1084,63 @@ body.cartoon-queue-page .cmd {
   padding: 6px; cursor: pointer; word-break: break-all;
 }
 
+/* TK-12243 review-queue actions (rq-*): select 1..many + big Approve/Delete buttons below
+   each card, a sticky bulk bar, an in-page delete-confirm modal and an Undo toast. Driven by
+   daily-cartoons/review-server.mjs (body[data-mode="live"]); the static review.html renders
+   the same controls disabled (body[data-mode="static"]). */
+body.cartoon-queue-page [hidden] { display: none !important; }
+body.cartoon-queue-page .rq-card { display: flex; flex-direction: column; position: relative; }
+body.cartoon-queue-page .rq-card .body { flex: 1; }
+body.cartoon-queue-page .rq-card.rq-selected { outline: 4px solid var(--focus); outline-offset: -4px; }
+body.cartoon-queue-page .rq-card.rq-approved { border-color: var(--accent); }
+body.cartoon-queue-page .rq-select {
+  position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 6px;
+  background: var(--panel); border: 2px solid var(--ink); padding: 6px 10px; font-weight: 700; cursor: pointer;
+}
+body.cartoon-queue-page .rq-select input { width: 22px; height: 22px; margin: 0; cursor: pointer; accent-color: var(--accent2); }
+body.cartoon-queue-page .rq-select:has(input:disabled) { cursor: not-allowed; opacity: .6; }
+body.cartoon-queue-page .rq-actions { display: flex; flex-direction: column; gap: 8px; padding: 0 12px 12px; }
+body.cartoon-queue-page .rq-btn {
+  display: block; width: 100%; min-height: 52px; font: 700 1.1rem/1.2 inherit; letter-spacing: .02em;
+  border: 3px solid var(--ink); cursor: pointer; color: #fff;
+}
+body.cartoon-queue-page .rq-btn:focus-visible, body.cartoon-queue-page .rq-mini:focus-visible,
+body.cartoon-queue-page .rq-select:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
+body.cartoon-queue-page .rq-approve { background: #13804a; }
+body.cartoon-queue-page .rq-approve:hover:not(:disabled) { background: #0d6639; }
+body.cartoon-queue-page .rq-delete { background: #b3261e; }
+body.cartoon-queue-page .rq-delete:hover:not(:disabled) { background: #8c1d17; }
+body.cartoon-queue-page .rq-cancel { background: var(--panel); color: var(--text); }
+body.cartoon-queue-page .rq-btn:disabled { cursor: not-allowed; opacity: .45; }
+body.cartoon-queue-page .rq-approved .rq-approve:disabled { opacity: 1; background: var(--panel); color: #13804a; border-color: #13804a; }
+body.cartoon-queue-page .rq-err { margin: 8px 0 0; padding: 6px 8px; border: 2px solid #b3261e; color: #b3261e; font-size: .85rem; }
+body.cartoon-queue-page .rq-mode-note { margin: 6px 0 0; font-size: .85rem; font-weight: 700; }
+body.cartoon-queue-page .rq-static-note { color: #b3261e; }
+body.cartoon-queue-page .rq-mini {
+  min-height: 44px; padding: 0 14px; font: 700 .95rem inherit; background: var(--panel); color: var(--text);
+  border: 2px solid var(--ink); cursor: pointer;
+}
+body.cartoon-queue-page.rq-busy { cursor: progress; }
+body.cartoon-queue-page .rq-bulk {
+  position: sticky; bottom: 0; z-index: 30; max-width: 1400px; margin: 16px auto 0; padding: 10px 12px;
+  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
+  background: var(--chrome); border: 3px solid var(--ink); box-shadow: 0 -6px 20px #0003;
+}
+body.cartoon-queue-page .rq-bulk .rq-btn { width: auto; flex: 1 1 180px; }
+body.cartoon-queue-page .rq-count { font-weight: 700; min-width: 7em; }
+body.cartoon-queue-page .rq-modal {
+  position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center;
+  padding: 16px; background: #000a;
+}
+body.cartoon-queue-page .rq-modal-box { width: min(460px, 100%); background: var(--panel); border: 4px solid var(--ink); padding: 18px; }
+body.cartoon-queue-page .rq-modal-box h2 { margin: 0 0 8px; }
+body.cartoon-queue-page .rq-modal-btns { display: flex; gap: 10px; margin-top: 14px; }
+body.cartoon-queue-page .rq-toast {
+  position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%); z-index: 40;
+  display: flex; align-items: center; gap: 12px; padding: 10px 14px; max-width: calc(100% - 32px);
+  background: var(--text); color: var(--bg); border: 2px solid var(--ink); font-weight: 700;
+}
+
 /* ---- body.cartoon-page-simple: the minimal single-image layout emitted
    by daily-cartoons/approve.py for every newly-approved cartoon page (TK-
    12226 — the generator used to emit its own <style> block with a
diff --git a/daily-cartoons/README.md b/daily-cartoons/README.md
index 2b2eee3..8988982 100644
--- a/daily-cartoons/README.md
+++ b/daily-cartoons/README.md
@@ -35,7 +35,8 @@ article; invented anonymous figures only (`DENY` regex is a backstop, not the pr
     daily-cartoons/run-daily.sh [N]          # default N=4, still image only
     python3 daily-cartoons/generate.py 4 --concepts-only   # articles + concepts only, $0, no Replicate calls
     python3 daily-cartoons/generate.py 4 --video           # opt-in: also render a 5s clip per cartoon (~$0.10 ea)
-    open daily-cartoons/review.html          # review page (sort + size slider, created time, cost, linked article)
+    node daily-cartoons/review-server.mjs    # LIVE review UI (select + Approve/Delete) → http://127.0.0.1:9947/  (admin / DW2024!)
+    open daily-cartoons/review.html          # static READ-ONLY snapshot (sort + size slider, created time, cost, linked article)
 
 Outputs: `queue/<date>/<slug>/{poster.jpg, clip.mp4 (only with --video), meta.json}`.
 `meta.json` includes `story_id`, `story_title`, `story_url` (the real article link, or the
@@ -49,6 +50,31 @@ each paid call is re-checked. Every paid call is logged via the cost-tracker ski
 `--concepts-only` makes zero paid calls regardless of the cap.
 Exit codes: `0` ok · `1` error/no article candidates · `2` cap refused (e.g. `P24_CARTOON_CAP_USD=0 run-daily.sh 1` → exit 2, no spend).
 
+## Review server — select 1..many, Approve / Delete (TK-12243)
+    node daily-cartoons/review-server.mjs                 # http://127.0.0.1:9947/ (walks up to 9966 if 9947 is taken)
+    PORT=9960 node daily-cartoons/review-server.mjs       # fixed port (fails loudly if taken)
+Zero-dependency Node (`node:http` only), binds **127.0.0.1 only**, Basic Auth `admin` / `DW2024!`
+(override with `P24_REVIEW_USER` / `P24_REVIEW_PASS`). Not a pm2/launchd service — run it when you
+review, Ctrl-C when done. It does not touch the `p24` site server on :9934.
+
+- Every card has a **Select** checkbox and two big full-width buttons below it: **Approve** (green)
+  and **Delete** (red). Selecting ≥1 card shows a sticky bulk bar: *Approve N · Delete N · Select all · Clear*.
+- **Approve** runs `approve.py <slug> --date <date>` per item (execFile, sequential — no shell), so it
+  needs a `story_id` in meta.json (pre-TK-12237 items show the approve.py error on the card instead).
+  Approved cards show **Approved ✓** and cannot be approved twice (UI + server both refuse).
+- **Delete** asks in an in-page dialog, then **moves** `queue/<date>/<slug>/` →
+  `queue/_trash/<date>/<slug>/` (never `rm`). The toast's **Undo** moves it back (`POST /api/restore`);
+  manual undo = `mv daily-cartoons/queue/_trash/<date>/<slug> daily-cartoons/queue/<date>/`.
+  `generate.py` / `build_review.py` skip `queue/_trash`.
+- API: `POST /api/approve|delete|restore` with `{"items":[{"date":"YYYY-MM-DD","slug":"a-z0-9-"}]}`
+  (JSON content-type required; date/slug strictly validated, traversal → 400). Every action is appended
+  to `daily-cartoons/logs/review-actions.jsonl` (`{ts, action, items, results}`).
+- The page is rendered live per request by `build_review.py --live --stdout` — the SAME template as
+  the static `review.html`, which renders every action control disabled with a note to use the server.
+- Test against a COPY, never the real queue: `P24_SITE_DIR=/tmp/p24-copy node daily-cartoons/review-server.mjs`
+  (with `P24_SITE_DIR` set, `common.py` and the server both read the queue/review.html/logs from that
+  copy's `daily-cartoons/`, and approve.py writes pages/manifest into the copy's `cartoons/`).
+
 ## Approve (local only)
     python3 daily-cartoons/approve.py <slug> --date YYYY-MM-DD [--story-id <id>] [--section politics]
 Copies media into `cartoons/media/`, writes `cartoons/<date>-<slug>.html`, appends to `cartoons/manifest.js`.
diff --git a/daily-cartoons/build_review.py b/daily-cartoons/build_review.py
index 0e4ccff..e4d20b6 100755
--- a/daily-cartoons/build_review.py
+++ b/daily-cartoons/build_review.py
@@ -1,29 +1,36 @@
 #!/usr/bin/env python3
-"""Rebuild daily-cartoons/review.html (static, local, file://-safe) from queue/*/*/meta.json."""
-import html, json, os
+"""Render the P24 cartoon review queue from queue/<date>/<slug>/meta.json.
+
+  python3 daily-cartoons/build_review.py              # write static review.html (read-only fallback)
+  python3 daily-cartoons/build_review.py --live --stdout   # live markup for review-server.mjs
+
+ONE template serves both modes (TK-12243). In the static file (opened via file://) the
+select checkboxes and the Approve/Delete buttons are rendered DISABLED with a note to open
+the page via review-server.mjs; in live mode (rendered per request by the server) they
+call its /api/approve, /api/delete and /api/restore endpoints."""
+import html, json, os, sys
 import common as C
 
 def items():
     out = []
-    if not os.path.isdir(C.QUEUE): return out
-    for d in sorted(os.listdir(C.QUEUE)):
+    for d in C.queue_dates():  # skips queue/_trash
         dp = os.path.join(C.QUEUE, d)
-        if not os.path.isdir(dp): continue
         for s in sorted(os.listdir(dp)):
             mp = os.path.join(dp, s, "meta.json")
             if os.path.exists(mp):
                 m = json.load(open(mp)); m["_rel"] = f"queue/{d}/{s}"
+                m["_date"], m["_slug"] = d, s
                 m["_has_clip"] = os.path.exists(os.path.join(dp, s, "clip.mp4"))
                 m["_has_poster"] = os.path.exists(os.path.join(dp, s, "poster.jpg"))
                 out.append(m)
     return out
 
-def card(m):
+def card(m, live):
     e = lambda x: html.escape(str(x or ""))
     media = (f'<video controls preload="none" poster="{e(m["_rel"])}/poster.jpg" src="{e(m["_rel"])}/clip.mp4"></video>'
              if m["_has_clip"] else (f'<img src="{e(m["_rel"])}/poster.jpg" alt="">' if m["_has_poster"] else '<div class="nomedia">no media</div>'))
     story_id = m.get("story_id")
-    cmd = (f'python3 daily-cartoons/approve.py {m["slug"]} --date {m["date"]}' + (f' --story-id {story_id}' if story_id
+    cmd = (f'python3 daily-cartoons/approve.py {m["_slug"]} --date {m["_date"]}' + (f' --story-id {story_id}' if story_id
            else ' --story-id &lt;story-id&gt;'))
     story_html = (f'<p class="src"><a href="{e(m.get("story_url"))}" target="_blank" rel="noopener noreferrer">📰 {e(m.get("story_title"))}</a>'
                   f' <span class="chip">{e(m.get("story_source"))}</span></p>' if m.get("story_url")
@@ -31,45 +38,185 @@ def card(m):
     prompts = f'<p><b>image:</b> {e(m.get("image_prompt"))}</p>'
     if m.get("motion_prompt"):
         prompts += f'<p><b>motion:</b> {e(m.get("motion_prompt"))}</p>'
-    return f'''<article class="card" data-created="{e(m.get("created_at"))}" data-title="{e(m.get("title"))}" data-cost="{m.get("cost",{}).get("total",0)}">
+    status = m.get("status") or "queued"
+    approved = status == "approved"
+    dis = "" if live else " disabled"
+    title = e(m.get("title"))
+    appr_btn = (f'<button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-disabled="true">Approved ✓</button>'
+                if approved else
+                f'<button type="button" class="rq-btn rq-approve" data-act="approve"{dis} aria-label="Approve &ldquo;{title}&rdquo;">Approve</button>')
+    return f'''<article class="card rq-card{' rq-approved' if approved else ''}" data-date="{e(m["_date"])}" data-slug="{e(m["_slug"])}" data-status="{e(status)}" data-created="{e(m.get("created_at"))}" data-title="{title}" data-cost="{m.get("cost",{}).get("total",0)}">
+  <label class="rq-select"><input type="checkbox" class="rq-check"{dis} aria-label="Select &ldquo;{title}&rdquo;"> <span>Select</span></label>
   {media}
   <div class="body">
-    <h2>{e(m.get("title"))}</h2>
+    <h2>{title}</h2>
     <p class="cap">&ldquo;{e(m.get("caption"))}&rdquo;</p>
     {story_html}
     <div class="chips"><span class="chip when" title="{e(m.get("created_at"))}" data-iso="{e(m.get("created_at"))}">🕓 {e(m.get("created_at"))}</span>
-      <span class="chip">${m.get("cost",{}).get("total",0):.3f}</span><span class="chip st-{e(m.get("status"))}">{e(m.get("status"))}</span></div>
+      <span class="chip">${m.get("cost",{}).get("total",0):.3f}</span><span class="chip rq-status st-{e(status)}">{e(status)}</span></div>
     <details><summary>prompts</summary>{prompts}</details>
     <code class="cmd" title="click to copy">{cmd}</code>
+    <p class="rq-err" role="alert" hidden></p>
+  </div>
+  <div class="rq-actions">
+    {appr_btn}
+    <button type="button" class="rq-btn rq-delete" data-act="delete"{dis} aria-label="Delete &ldquo;{title}&rdquo;">Delete</button>
   </div>
 </article>'''
 
-def build():
+# Plain (non-f) string so the JS needs no brace-doubling. Talks to review-server.mjs only
+# when body[data-mode="live"]; in the static file every action control is disabled.
+SCRIPT = r'''<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 key=c=>({date:c.dataset.date,slug:c.dataset.slug});
+  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 markApproved(c){
+    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){
+    list=list.filter(c=>c.dataset.status!=='approved');if(!list.length||busy)return;
+    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);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'))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());
+  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;
+  refresh();
+}
+</script>'''
+
+def render(live=False):
     its = items()
     its.sort(key=lambda m: m.get("created_at", ""), reverse=True)
     total = sum(m.get("cost", {}).get("total", 0) for m in its)
+    mode = "live" if live else "static"
+    note = ('<p class="rq-mode-note">Live review — Approve copies into <code>cartoons/</code> locally (nothing deploys); '
+            'Delete moves to <code>queue/_trash</code> (undoable).</p>' if live else
+            '<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>')
+    sel_all = '<button type="button" class="rq-mini" id="rq-select-all">Select all</button>' if live else ''
+    live_ui = '''<div class="rq-bulk" id="rq-bulk" role="region" aria-label="Bulk actions" hidden>
+  <span class="rq-count" id="rq-count" aria-live="polite">0 selected</span>
+  <button type="button" class="rq-btn rq-approve" id="rq-bulk-approve">Approve 0</button>
+  <button type="button" class="rq-btn rq-delete" id="rq-bulk-delete">Delete 0</button>
+  <button type="button" class="rq-mini" id="rq-bulk-all">Select all</button>
+  <button type="button" class="rq-mini" id="rq-bulk-clear">Clear</button>
+</div>
+<div class="rq-modal" id="rq-modal" role="dialog" aria-modal="true" aria-labelledby="rq-modal-title" hidden>
+  <div class="rq-modal-box"><h2 id="rq-modal-title">Delete cartoon?</h2><p id="rq-modal-text"></p>
+    <div class="rq-modal-btns"><button type="button" class="rq-btn rq-cancel" id="rq-modal-cancel">Cancel</button>
+    <button type="button" class="rq-btn rq-delete" id="rq-modal-ok">Delete</button></div></div>
+</div>
+<div class="rq-toast" id="rq-toast" role="status" aria-live="polite" hidden><span id="rq-toast-text"></span>
+  <button type="button" class="rq-mini" id="rq-toast-undo" hidden>Undo</button></div>''' if live else ''
     # TK-12226: link ONLY the shared stylesheet (assets/style.css) — no
-    # per-page <style> block, no locally hardcoded palette/font/dark-mode
-    # media query. Layout lives in style.css under body.cartoon-queue-page.
-    page = f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+    # per-page <style> block, no inline style attrs. Layout lives in style.css under
+    # body.cartoon-queue-page (review-queue action classes are rq-*).
+    return f'''<!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">
+<link rel="stylesheet" href="../assets/style.css"></head><body class="cartoon-queue-page" data-mode="{mode}">
 <header><div><h1>🖋️ P24 Daily Cartoon Queue</h1>
-<p class="note">{len(its)} queued · total spend ${total:.3f} · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</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>
+<p class="note">{len(its)} queued · total spend ${total:.3f} · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</p>
+{note}</div>
+<div class="ctrl">{sel_all}<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">{"".join(card(m) for m in its) or "<p>Queue is empty — run daily-cartoons/run-daily.sh</p>"}</main>
-<script>
-const g=document.getElementById('grid'),S=document.getElementById('sort'),D=document.getElementById('dens');
-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));
-</script></body></html>'''
-    open(os.path.join(C.HERE, "review.html"), "w").write(page)
-    return len(its)
+<main id="grid">{"".join(card(m, live) for m in its) or "<p>Queue is empty — run daily-cartoons/run-daily.sh</p>"}</main>
+{live_ui}
+{SCRIPT}</body></html>'''
+
+def build():
+    page = render(live=False)
+    open(os.path.join(C.DC, "review.html"), "w").write(page)
+    return page.count('class="card rq-card')
 
 if __name__ == "__main__":
-    print("review.html:", build(), "items")
+    if "--stdout" in sys.argv:
+        sys.stdout.write(render(live="--live" in sys.argv))
+    else:
+        print("review.html:", build(), "items")
diff --git a/daily-cartoons/common.py b/daily-cartoons/common.py
index 7d8a032..8d1346c 100644
--- a/daily-cartoons/common.py
+++ b/daily-cartoons/common.py
@@ -4,8 +4,12 @@ local date; logging goes through the cost-tracker skill's log.js. Never prints t
 import datetime, json, os, subprocess, sys, time, urllib.request
 
 HERE = os.path.dirname(os.path.abspath(__file__))
-SITE = os.environ.get("P24_SITE_DIR") or os.path.dirname(HERE)  # override only for tests
-QUEUE = os.path.join(HERE, "queue")
+_SITE_OVERRIDE = os.environ.get("P24_SITE_DIR")
+SITE = _SITE_OVERRIDE or os.path.dirname(HERE)  # override only for tests
+# TK-12243: with P24_SITE_DIR set (tests / review-server against a copy), the queue and the
+# generated review.html live inside THAT copy too — so approve.py never touches the real queue.
+DC = os.path.join(_SITE_OVERRIDE, "daily-cartoons") if _SITE_OVERRIDE else HERE
+QUEUE = os.environ.get("P24_CARTOON_QUEUE_DIR") or os.path.join(DC, "queue")
 APP = "p24-daily-cartoons"
 LEDGER = os.path.expanduser("~/.claude/cost-ledger.jsonl")
 LOGJS = os.path.expanduser("~/.claude/skills/cost-tracker/scripts/log.js")
@@ -13,6 +17,14 @@ LOGJS = os.path.expanduser("~/.claude/skills/cost-tracker/scripts/log.js")
 EST = {"flux": 0.003, "wan": 0.10}
 API_KEYS = {"flux": ("replicate_flux_schnell", "image"), "wan": ("replicate_wan22_i2v_fast", "video")}
 
+DATE_RE = r"^\d{4}-\d{2}-\d{2}$"
+
+def queue_dates():
+    """Sorted YYYY-MM-DD day dirs in the queue — skips queue/_trash (review-server deletes, TK-12243)."""
+    import re
+    if not os.path.isdir(QUEUE): return []
+    return sorted(d for d in os.listdir(QUEUE) if re.match(DATE_RE, d) and os.path.isdir(os.path.join(QUEUE, d)))
+
 class CapExceeded(Exception):
     pass
 
diff --git a/daily-cartoons/generate.py b/daily-cartoons/generate.py
index 008c347..674a04d 100755
--- a/daily-cartoons/generate.py
+++ b/daily-cartoons/generate.py
@@ -47,7 +47,7 @@ DENY = re.compile(r"\b(trump|biden|harris|obama|pelosi|mcconnell|schumer|desanti
 def recent_titles(days=14):
     out = []
     if os.path.isdir(C.QUEUE):
-        for d in sorted(os.listdir(C.QUEUE))[-days:]:
+        for d in C.queue_dates()[-days:]:
             dp = os.path.join(C.QUEUE, d)
             if not os.path.isdir(dp): continue
             for s in os.listdir(dp):
@@ -60,7 +60,7 @@ def used_story_ids(days=14):
     already approved into cartoons/manifest.js (full history, no day window)."""
     used = set()
     if os.path.isdir(C.QUEUE):
-        for d in sorted(os.listdir(C.QUEUE))[-days:]:
+        for d in C.queue_dates()[-days:]:
             dp = os.path.join(C.QUEUE, d)
             if not os.path.isdir(dp): continue
             for s in os.listdir(dp):
diff --git a/daily-cartoons/review-server.mjs b/daily-cartoons/review-server.mjs
new file mode 100644
index 0000000..109da2f
--- /dev/null
+++ b/daily-cartoons/review-server.mjs
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+// P24 daily-cartoon review server (TK-12243). Zero dependencies (node:http only).
+//
+//   node daily-cartoons/review-server.mjs            # http://127.0.0.1:9947/  (admin / DW2024!)
+//   PORT=9960 node daily-cartoons/review-server.mjs
+//   P24_SITE_DIR=/tmp/p24-copy node daily-cartoons/review-server.mjs   # act on a COPY (tests)
+//
+// LOCAL ONLY: binds 127.0.0.1, Basic Auth. Renders the review page live from the queue via
+// build_review.py --live --stdout (same template as the static review.html), serves
+// assets/style.css + queue media, and exposes:
+//   POST /api/approve {items:[{date,slug}]} → runs approve.py per item (execFile, no shell)
+//   POST /api/delete  {items:[...]}         → MOVES queue/<date>/<slug> → queue/_trash/<date>/<slug> (never rm)
+//   POST /api/restore {items:[...]}         → moves it back
+// Every action is appended to daily-cartoons/logs/review-actions.jsonl. Nothing deploys.
+import http from 'node:http';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import { execFile } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+// Mirror common.py: with P24_SITE_DIR set, the queue/review/logs live inside THAT copy.
+const SITE_OVERRIDE = process.env.P24_SITE_DIR ? path.resolve(process.env.P24_SITE_DIR) : null;
+const SITE = SITE_OVERRIDE || path.resolve(HERE, '..');
+const DC = SITE_OVERRIDE ? path.join(SITE_OVERRIDE, 'daily-cartoons') : HERE;
+const QUEUE = path.resolve(process.env.P24_CARTOON_QUEUE_DIR || path.join(DC, 'queue'));
+const TRASH = path.join(QUEUE, '_trash');
+const LOG = path.join(DC, 'logs', 'review-actions.jsonl');
+const PY = process.env.PYTHON || 'python3';
+const HOST = '127.0.0.1';
+const DEFAULT_PORT = 9947;
+const USER = process.env.P24_REVIEW_USER || 'admin';
+const PASS = process.env.P24_REVIEW_PASS || 'DW2024!';
+const MAX_ITEMS = 100;
+const MAX_BODY = 64 * 1024;
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+const SLUG_RE = /^[a-z0-9-]{1,120}$/;
+
+class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
+
+// Strict validation + a resolved-path containment check (belt and braces against traversal).
+function itemPath(root, it) {
+  if (!it || typeof it !== 'object') throw new HttpError(400, 'item must be an object {date,slug}');
+  const { date, slug } = it;
+  if (typeof date !== 'string' || !DATE_RE.test(date)) throw new HttpError(400, `invalid date: ${JSON.stringify(date)}`);
+  if (typeof slug !== 'string' || !SLUG_RE.test(slug)) throw new HttpError(400, `invalid slug: ${JSON.stringify(slug)}`);
+  const p = path.resolve(root, date, slug);
+  if (!p.startsWith(root + path.sep)) throw new HttpError(400, 'path escapes queue');
+  return p;
+}
+
+function parseItems(body) {
+  if (!body || !Array.isArray(body.items)) throw new HttpError(400, 'body must be {items:[{date,slug}]}');
+  if (body.items.length === 0) throw new HttpError(400, 'items is empty');
+  if (body.items.length > MAX_ITEMS) throw new HttpError(400, `max ${MAX_ITEMS} items per request`);
+  const seen = new Set(), out = [];
+  for (const it of body.items) {
+    itemPath(QUEUE, it); // throws 400 on anything invalid — whole request is rejected
+    const k = `${it.date}/${it.slug}`;
+    if (!seen.has(k)) { seen.add(k); out.push({ date: it.date, slug: it.slug }); }
+  }
+  return out;
+}
+
+const exists = (p) => fsp.access(p).then(() => true, () => false);
+const readMeta = async (dir) => JSON.parse(await fsp.readFile(path.join(dir, 'meta.json'), 'utf8'));
+
+function run(file, args) {
+  return new Promise((resolve) => {
+    execFile(file, args, { cwd: SITE, env: process.env, timeout: 120_000, maxBuffer: 4 << 20 },
+      (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout, stderr, err }));
+  });
+}
+
+// Actions run SEQUENTIALLY (approve.py rewrites cartoons/manifest.js — no concurrent writers).
+let chain = Promise.resolve();
+const serial = (fn) => { const p = chain.then(fn, fn); chain = p.catch(() => {}); return p; };
+
+async function approveOne(it) {
+  const dir = itemPath(QUEUE, it);
+  if (!(await exists(path.join(dir, 'meta.json')))) return { ...it, ok: false, error: 'not in queue' };
+  const before = await readMeta(dir);
+  if (before.status === 'approved') return { ...it, ok: false, already: true, error: 'already approved' };
+  const r = await run(PY, [path.join(HERE, 'approve.py'), it.slug, '--date', it.date]);
+  const output = (r.stdout + r.stderr).trim().slice(-2000);
+  if (r.code !== 0) return { ...it, ok: false, error: (r.stderr.trim().split('\n').pop() || `approve.py exit ${r.code}`), output };
+  const m = await readMeta(dir);
+  if (m.status !== 'approved') { // approve.py sets it; guarantee it even if a future version doesn't
+    m.status = 'approved'; m.approved_at = m.approved_at || new Date().toISOString();
+    await fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify(m, null, 2));
+  }
+  return { ...it, ok: true, site_file: m.site_file || null, output };
+}
+
+async function moveDir(src, dst) {
+  await fsp.mkdir(path.dirname(dst), { recursive: true });
+  await fsp.rename(src, dst); // same filesystem (both under queue/) → atomic move, never a copy+rm
+}
+
+async function deleteOne(it) {
+  const src = itemPath(QUEUE, it), dst = itemPath(TRASH, it);
+  if (!(await exists(src))) return { ...it, ok: false, error: 'not in queue' };
+  let displaced = null;
+  if (await exists(dst)) { // an older trashed copy with the same slug: keep it, just rename it aside
+    displaced = `${it.slug}--trashed-${Date.now()}`;
+    await fsp.rename(dst, path.join(path.dirname(dst), displaced));
+  }
+  await moveDir(src, dst);
+  return { ...it, ok: true, trashed_to: path.relative(DC, dst), ...(displaced ? { displaced } : {}) };
+}
+
+async function restoreOne(it) {
+  const src = itemPath(TRASH, it), dst = itemPath(QUEUE, it);
+  if (!(await exists(src))) return { ...it, ok: false, error: 'not in trash' };
+  if (await exists(dst)) return { ...it, ok: false, error: 'a queue item with that slug already exists' };
+  await moveDir(src, dst);
+  return { ...it, ok: true, restored_to: path.relative(DC, dst) };
+}
+
+const ACTIONS = { approve: approveOne, delete: deleteOne, restore: restoreOne };
+
+async function logAction(action, items, results) {
+  await fsp.mkdir(path.dirname(LOG), { recursive: true });
+  const slim = results.map(({ output, ...r }) => ({ ...r, ...(output ? { output: output.slice(-500) } : {}) }));
+  await fsp.appendFile(LOG, JSON.stringify({ ts: new Date().toISOString(), action, items, results: slim }) + '\n');
+}
+
+function send(res, code, body, type = 'application/json; charset=utf-8', extra = {}) {
+  const buf = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body));
+  res.writeHead(code, { 'Content-Type': type, 'Content-Length': buf.length, 'Cache-Control': 'no-store',
+    'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', ...extra });
+  res.end(buf);
+}
+
+function authed(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+  return u === USER && rest.join(':') === PASS;
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    let n = 0; const chunks = [];
+    req.on('data', (c) => { n += c.length; if (n > MAX_BODY) { reject(new HttpError(413, 'body too large')); req.destroy(); } else chunks.push(c); });
+    req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null')); } catch { reject(new HttpError(400, 'invalid JSON')); } });
+    req.on('error', reject);
+  });
+}
+
+const MIME = { '.jpg': 'image/jpeg', '.mp4': 'video/mp4', '.css': 'text/css; charset=utf-8' };
+
+async function sendFile(req, res, file) {
+  let st;
+  try { st = await fsp.stat(file); } catch { return send(res, 404, { error: 'not found' }); }
+  if (!st.isFile()) return send(res, 404, { error: 'not found' });
+  const type = MIME[path.extname(file)] || 'application/octet-stream';
+  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+  if (range && (range[1] || range[2])) { // enough Range support for <video> seeking
+    let start = range[1] ? +range[1] : st.size - +range[2], end = range[1] && range[2] ? +range[2] : st.size - 1;
+    if (start < 0 || start >= st.size || end < start) return send(res, 416, '', 'text/plain', { 'Content-Range': `bytes */${st.size}` });
+    end = Math.min(end, st.size - 1);
+    res.writeHead(206, { 'Content-Type': type, 'Content-Length': end - start + 1, 'Accept-Ranges': 'bytes',
+      'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Cache-Control': 'no-store' });
+    return fs.createReadStream(file, { start, end }).pipe(res);
+  }
+  res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' });
+  fs.createReadStream(file).pipe(res);
+}
+
+async function renderLive() {
+  const r = await run(PY, [path.join(HERE, 'build_review.py'), '--live', '--stdout']);
+  if (r.code !== 0) throw new HttpError(500, 'render failed: ' + r.stderr.trim().split('\n').pop());
+  return r.stdout;
+}
+
+const MEDIA_RE = /^\/daily-cartoons\/queue\/(\d{4}-\d{2}-\d{2})\/([a-z0-9-]{1,120})\/(poster\.jpg|clip\.mp4)$/;
+
+async function handle(req, res) {
+  if (!authed(req)) return send(res, 401, { error: 'auth required' }, undefined, { 'WWW-Authenticate': 'Basic realm="p24-review", charset="UTF-8"' });
+  let url;
+  try { url = new URL(req.url, `http://${HOST}`); } catch { return send(res, 400, { error: 'bad url' }); }
+  const p = url.pathname;
+
+  if (req.method === 'POST' && p.startsWith('/api/')) {
+    const action = p.slice(5);
+    if (!ACTIONS[action]) return send(res, 404, { error: 'unknown action' });
+    // CSRF: require a JSON content-type (cross-site forms can't send it without a CORS preflight,
+    // which we never answer) and, when the browser sends Origin, that it is this server.
+    if (!/^application\/json\b/i.test(req.headers['content-type'] || '')) return send(res, 415, { error: 'Content-Type must be application/json' });
+    const origin = req.headers.origin;
+    if (origin && origin !== `http://${req.headers.host}`) return send(res, 403, { error: 'cross-origin request refused' });
+    const items = parseItems(await readBody(req));
+    const results = await serial(async () => {
+      const out = [];
+      for (const it of items) {
+        try { out.push(await ACTIONS[action](it)); } catch (e) { out.push({ ...it, ok: false, error: e.message }); }
+      }
+      await logAction(action, items, out);
+      return out;
+    });
+    return send(res, 200, { ok: results.every((r) => r.ok), action, results });
+  }
+
+  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { error: 'method not allowed' });
+  if (p === '/' || p === '/daily-cartoons' || p === '/daily-cartoons/') return send(res, 302, '', 'text/plain', { Location: '/daily-cartoons/review.html' });
+  if (p === '/daily-cartoons/review.html') return send(res, 200, await renderLive(), 'text/html; charset=utf-8');
+  if (p === '/assets/style.css') return sendFile(req, res, path.join(SITE, 'assets', 'style.css'));
+  if (p === '/favicon.ico') return send(res, 204, '');
+  if (p === '/healthz') return send(res, 200, { ok: true, queue: QUEUE, site: SITE });
+  const m = MEDIA_RE.exec(p);
+  if (m) return sendFile(req, res, path.join(itemPath(QUEUE, { date: m[1], slug: m[2] }), m[3]));
+  return send(res, 404, { error: 'not found' });
+}
+
+const server = http.createServer((req, res) => {
+  handle(req, res).catch((e) => {
+    const code = e instanceof HttpError ? e.code : 500;
+    if (code === 500) console.error('[review-server]', e);
+    if (!res.headersSent) send(res, code, { error: e.message });
+    else res.destroy();
+  });
+});
+
+// PORT env wins (and fails loudly if taken). Otherwise start at 9947 and walk up to the next free port.
+const fixed = process.env.PORT ? +process.env.PORT : null;
+let port = fixed ?? DEFAULT_PORT;
+server.on('error', (e) => {
+  if (e.code === 'EADDRINUSE' && fixed === null && port < DEFAULT_PORT + 20) { port++; server.listen(port, HOST); return; }
+  console.error(`[review-server] cannot listen on ${HOST}:${port}: ${e.message}`); process.exit(1);
+});
+server.on('listening', () => {
+  console.log(`[review-server] http://${HOST}:${server.address().port}/  (user ${USER}) · queue ${QUEUE}${SITE_OVERRIDE ? ' · SITE OVERRIDE ' + SITE : ''}`);
+});
+server.listen(port, HOST);
diff --git a/daily-cartoons/review.html b/daily-cartoons/review.html
index 8c982b6..42ceeab 100644
--- a/daily-cartoons/review.html
+++ b/daily-cartoons/review.html
@@ -1,72 +1,199 @@
 <!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">
+<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></div>
+<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" data-created="2026-09-25T14:29:23+00:00" data-title="Infinite Scroll" data-cost="0.103">
+<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>
   <img src="queue/2026-09-25/infinite-scroll/poster.jpg" alt="">
   <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 st-queued">queued</span></div>
+      <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>
-</article><article class="card" data-created="2026-09-25T14:27:36+00:00" data-title="Tech Tyrant" data-cost="0.103">
+  <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>
   <img src="queue/2026-09-25/tech-tyrant/poster.jpg" alt="">
   <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 st-queued">queued</span></div>
+      <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" data-created="2026-09-25T14:25:59+00:00" data-title="Senator&#x27;s Shadow War" data-cost="0.103">
+</article><article class="card rq-card" data-date="2026-09-25" data-slug="senator-s-shadow-war" data-status="queued" 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>
   <img src="queue/2026-09-25/senator-s-shadow-war/poster.jpg" alt="">
   <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 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: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 st-queued">queued</span></div>
+      <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 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 &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;Senator&#x27;s Shadow War&rdquo;">Approve</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" data-created="2026-09-25T14:23:48+00:00" data-title="Bureaucrats and Bubbles" data-cost="0.103">
+</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>
   <img src="queue/2026-09-25/bureaucrats-and-bubbles/poster.jpg" alt="">
   <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 st-queued">queued</span></div>
+      <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>
-</article><article class="card" data-created="2026-09-24T22:12:18+00:00" data-title="Committee of Shadows" data-cost="0.103">
+  <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>
   <img src="queue/2026-09-24/committee-of-shadows/poster.jpg" alt="">
   <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 st-queued">queued</span></div>
+      <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 key=c=>({date:c.dataset.date,slug:c.dataset.slug});
+  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 markApproved(c){
+    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){
+    list=list.filter(c=>c.dataset.status!=='approved');if(!list.length||busy)return;
+    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);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'))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());
+  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;
+  refresh();
+}
 </script></body></html>
\ No newline at end of file