← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.22.1"+version = "0.23.0" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.22.1"+version = "0.23.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.22.1+0.23.0
bin/adom-shotlog
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.22.1",+  "version": "0.23.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.22.1",+  "version": "0.23.0",   "type": "app",   "title": "Adom Shotlog",   "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Desktop child-window arrays via --meta.",
src/html/index.html+109−4
@@ -179,6 +179,14 @@ header {   font-size: 9.5px; color: var(--text2); cursor: help; } .chip.ad { color: var(--accent); border-color: rgba(0,184,176,.3); } .chip.auto { color: var(--text3); }+.fchip { -webkit-appearance: none; appearance: none; padding: 5px 11px; border-radius: 999px;+  border: 1px solid var(--border); background: var(--overlay); color: var(--text2);+  cursor: pointer; font: 600 11px 'Familjen Grotesk', sans-serif; }+.fchip.on { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); }+.frow { display: flex; flex-wrap: wrap; gap: 7px; padding: 8px 0 2px; }+.hdr-btn.filters-on { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }+.m-full-toggle { color: var(--accent); cursor: pointer; font-weight: 600; }+.m-full-toggle:hover { text-decoration: underline; } .m-why { padding: 0 16px 8px; font: italic 12px/1.5 'Satoshi', sans-serif; color: var(--text2); } .m-why .k { color: var(--text3); font-style: normal; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; margin-right: 6px; } .shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }@@ -385,6 +393,7 @@ header {   <div class="actions">     <button class="hdr-btn" id="canvasBtn" style="display:none"></button>     <button class="hdr-btn icon-only" id="toTopBtn" data-tooltip="Scroll to newest (top)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg></button>+    <button class="hdr-btn icon-only" id="filterBtn" data-tooltip="Filter shots: child-window arrays, capture endpoint, safe/full pairs, direct vs auto-captured."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>     <button class="hdr-btn icon-only" id="prefsBtn" data-tooltip="Preferences: the auto-capture read-hook, and whether channels default to webview or pup."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>     <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button>     <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live.&#10;While green the server knows you're watching and&#10;won't force-open a duplicate tab on new shots."></span>@@ -488,6 +497,35 @@ header {     </div>   </div> </div>+<div class="helper" id="filterDlg" role="dialog" aria-modal="true" aria-label="Filters">+  <div class="helper-card">+    <button class="h-close" id="filterClose">✕</button>+    <div class="h-title">Filters</div>+    <div class="pref-info"><div class="pref-name">What kind of shot</div></div>+    <div class="frow">+      <button class="fchip" data-f="arrays" data-tooltip="Only shots carrying an Adom Desktop CHILD-WINDOW ARRAY (parent hwnd + its owned dropdowns/dialogs).">child-window arrays</button>+      <button class="fchip" data-f="full" data-tooltip="Only shots stored as a SAFE/FULL pair (a full-resolution original is viewable).">safe/full pairs</button>+      <button class="fchip" data-f="direct" data-tooltip="Only shots an AI deliberately injected (not auto-captured by the read-hook).">direct injects</button>+      <button class="fchip" data-f="auto" data-tooltip="Only auto-captures from the read-hook (images AIs read).">auto-captured</button>+    </div>+    <div class="pref-info" style="margin-top:8px"><div class="pref-name">Capture endpoint</div></div>+    <div class="frow" id="provRow">+      <button class="fchip" data-p="ad-window" data-tooltip="Adom Desktop window screenshots (desktop_screenshot_window).">AD·window</button>+      <button class="fchip" data-p="ad-screen" data-tooltip="Adom Desktop full-screen screenshots.">AD·screen</button>+      <button class="fchip" data-p="pup" data-tooltip="pup (Puppeteer browser bridge) page captures.">pup</button>+      <button class="fchip" data-p="hydrogen" data-tooltip="Hydrogen workspace screenshots.">hydrogen</button>+      <button class="fchip" data-p="abe" data-tooltip="Adom Browser Extension captures (the user's real browser).">ABE</button>+      <button class="fchip" data-p="hd" data-tooltip="Hydrogen Desktop's built-in screenshot feature.">HD</button>+      <button class="fchip" data-p="crop" data-tooltip="Crops from larger originals.">crop</button>+      <button class="fchip" data-p="clipboard" data-tooltip="Human clipboard pastes (Ctrl+V).">paste</button>+    </div>+    <div class="h-actions">+      <button class="h-btn secondary" id="filterClear">Clear all</button>+      <span style="flex:1"></span>+      <span id="filterCount" style="color:var(--text3);font:11px 'JetBrains Mono',monospace"></span>+    </div>+  </div>+</div> <div class="toast" id="toast"></div> <div class="modal" id="modal"><div class="modal-card" id="modalCard">   <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">@@ -645,7 +683,7 @@ header {   // Inline nerd-metadata panel, rendered ON the card (revealed in place — no zoom).   function buildNerdInline(e) {     const pops=popList(e);-    const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+    const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows     const panel=document.createElement('div'); panel.className='card-nerd';     const head=document.createElement('div'); head.className='nhead'; head.textContent='⌗ capture metadata';     const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';@@ -673,6 +711,10 @@ header {     if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }     if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }+    if(e.full){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);+      const fc=document.createElement('span'); fc.className='chip ad'; fc.textContent='full';+      fc.setAttribute('data-tooltip','Safe/full pair: the full-resolution original is stored alongside - open the zoom and click "view full".');+      meta.appendChild(fc); }     srcChips(e).forEach(c=>{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);       const ch=document.createElement('span'); ch.className='chip '+(c.cls||''); ch.textContent=c.label;       ch.setAttribute('data-tooltip', c.tip); meta.appendChild(ch); });@@ -732,6 +774,21 @@ header {     if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h);       if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }     if(e.kb){ sep(); add('',e.kb+' KB'); }+    if(e.full){ sep();+      const t=document.createElement('span'); t.className='m-full-toggle';+      let showingFull=false;+      const label=()=> t.textContent = showingFull+        ? ('viewing FULL '+(e.orig_w||'?')+'×'+(e.orig_h||'?')+' - click for safe'+           )+        : ('view full '+(e.orig_w||'?')+'×'+(e.orig_h||'?'));+      label();+      t.setAttribute('data-tooltip','This shot is a safe/full pair: the resized (safe) copy is shown by default; click to swap in the stored full-resolution original.');+      t.addEventListener('click', ev=>{ ev.stopPropagation();+        showingFull=!showingFull;+        mImg.src = showingFull ? popUrl(e.full) : imgUrl(e);+        label(); });+      mMeta.appendChild(t);+    }     if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }     if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); } @@ -762,8 +819,8 @@ header {     // Nerd metadata panel — readable rows + copyable raw JSON.     mNerd.innerHTML='';     const rows=metaRows(e);-    if(e.meta || rows.length){-      const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+    if(true){ // full metadata is ALWAYS available now+      const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows       const d=document.createElement('details'); d.className='nerd';       const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata';       const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';@@ -797,7 +854,12 @@ header {       shotCount=0; updateCount(); return;     }     shotCount=entries.length;-    for (let i=entries.length-1;i>=0;i--) feed.appendChild(card(entries[i])); // newest first+    let shown=0;+    for (let i=entries.length-1;i>=0;i--){ const e=entries[i];+      if(!passFilters(e)) continue; shown++; feed.appendChild(card(e)); } // newest first+    if(!shown){+      feed.innerHTML='<div class="empty">No shots match the active filters ('+entries.length+' hidden).<br><br>Open the funnel in the header to adjust or clear them.</div>';+    }     updateCount();   }   function channelTooltipHtml(countText){@@ -823,6 +885,48 @@ header {     $('channelName')._ttHtml = channelTooltipHtml(t); }    // ── Load ───────────────────────────────────────────────────────────────+  // ── Filters: what kinds of shots to show in the feed ──────────────────────+  const FILTERS={arrays:false, full:false, direct:false, auto:false, prov:new Set()};+  function passFilters(e){+    if(FILTERS.arrays && !popList(e).length) return false;+    if(FILTERS.full && !e.full) return false;+    const isAuto=(e.source||'').startsWith('read-hook');+    if(FILTERS.direct && isAuto) return false;+    if(FILTERS.auto && !isAuto) return false;+    if(FILTERS.prov.size){+      const parts=(e.source||'').split('/');+      if(![...FILTERS.prov].some(x=>parts.includes(x))) return false;+    }+    return true;+  }+  function filtersActive(){ return FILTERS.arrays||FILTERS.full||FILTERS.direct||FILTERS.auto||FILTERS.prov.size>0; }+  (function wireFilters(){+    const dlg=$('filterDlg'), btn=$('filterBtn'); if(!dlg) return;+    const close=()=>dlg.classList.remove('open');+    $('filterClose').addEventListener('click',close);+    dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });+    document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });+    function refresh(){+      dlg.querySelectorAll('.fchip[data-f]').forEach(c=>c.classList.toggle('on', !!FILTERS[c.dataset.f]));+      dlg.querySelectorAll('.fchip[data-p]').forEach(c=>c.classList.toggle('on', FILTERS.prov.has(c.dataset.p)));+      btn.classList.toggle('filters-on', filtersActive());+      renderAll();+      const shown=entries.filter(passFilters).length;+      $('filterCount').textContent = filtersActive()? shown+' of '+entries.length+' shown' : '';+    }+    dlg.addEventListener('click',e=>{+      const c=e.target.closest('.fchip'); if(!c) return;+      if(c.dataset.f){ FILTERS[c.dataset.f]=!FILTERS[c.dataset.f];+        // direct & auto are mutually exclusive+        if(c.dataset.f==='direct'&&FILTERS.direct) FILTERS.auto=false;+        if(c.dataset.f==='auto'&&FILTERS.auto) FILTERS.direct=false; }+      else if(c.dataset.p){ FILTERS.prov.has(c.dataset.p)?FILTERS.prov.delete(c.dataset.p):FILTERS.prov.add(c.dataset.p); }+      refresh();+    });+    $('filterClear').addEventListener('click',()=>{ FILTERS.arrays=FILTERS.full=FILTERS.direct=FILTERS.auto=false; FILTERS.prov.clear(); refresh(); });+    btn.addEventListener('click',()=>{ dlg.classList.add('open'); refresh(); });+  })();+   // ── Tab strip: shotlog-managed channel tabs ───────────────────────────────   const strip=$('chtabs');   function tabOrder(){@@ -1149,6 +1253,7 @@ header {             if (existing) Object.assign(existing, e); else c.entries.push(e);           }           if (chName === channel) {+            if(!passFilters(e)){ renderTabs(); return; }             const atTop = feed.scrollTop < 60;             const old = feed.querySelector(`[data-id="${e.id}"]`);             if (old) { old.replaceWith(card(e)); }
src/main.rs+5−2
@@ -365,6 +365,7 @@ async fn main() {              // Resolve the original dimensions. Two ways to supply them for a             // pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.+            let orig_path_keep = orig_file.clone();             let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) {                 (Some(of), _, _) => {                     let bytes = std::fs::read(&of).unwrap_or_else(|e| {@@ -421,7 +422,7 @@ async fn main() {                 }             }; -            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref());+            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref());         }         Commands::Open {             channel, name, icon, port, skinny, ratio, pup, swap, webview,@@ -477,7 +478,7 @@ async fn main() {                     std::process::exit(1);                 }             };-            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None);+            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None);         }         Commands::Resize { file, output, width } => {             run_resize(&file, output.as_deref(), width);@@ -580,6 +581,7 @@ fn run_inject(     popups: Option<Vec<serde_json::Value>>,     thread: Option<&str>,     context: Option<&str>,+    orig_path: Option<&str>, ) {     let bytes = if file == "-" {         use std::io::Read;@@ -612,6 +614,7 @@ fn run_inject(             "source": source,         "thread": thread,         "context": context,+        "orig_path": orig_path.map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())),         });         if let (Some(ow), Some(oh)) = (orig_w, orig_h) {             b["orig_w"] = serde_json::json!(ow);
src/server.rs+5−2
@@ -775,6 +775,9 @@ struct InjectRequest {     /// WHY the shot exists: the user prompt the thread was serving (the     /// read-hook extracts it programmatically from the transcript).     context: Option<String>,+    /// Absolute path of the ORIGINAL full-res image (when the caller passed+    /// --orig-file): the broker stores a copy beside the shot as {stem}__full.png.+    orig_path: Option<String>, }  /// Images larger than this on any edge are auto-resized (Claude crashes >1500px).@@ -871,7 +874,7 @@ async fn post_shots(      let entry = state         .store-        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone())+        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone(), req.orig_path.clone())         .await;      // Presence at inject time — the caller uses this to know if the human is@@ -1011,7 +1014,7 @@ async fn post_paste(             &req.channel, bytes, filename, description, "clipboard".into(), w, h,             if was_resized { Some(orig_w) } else { None },             if was_resized { Some(orig_h) } else { None },-            None, None, None, None,+            None, None, None, None, None,         )         .await; 
src/store.rs+28−5
@@ -44,6 +44,10 @@ pub struct Entry {     /// transcript's last-prompt record - zero AI tokens).     #[serde(skip_serializing_if = "Option::is_none")]     pub context: Option<String>,+    /// Filename of the stored FULL-RESOLUTION copy ({stem}__full.png) when the+    /// injector provided the original (safe/full pairs). Viewable via the zoom.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub full: Option<String>, }  struct Channel {@@ -72,6 +76,9 @@ fn delete_entry_files(entry: &Entry) {     let p = std::path::Path::new(&entry.path);     let _ = fs::remove_file(p);     let _ = fs::remove_file(p.with_extension("json"));+    if let (Some(f), Some(dir)) = (&entry.full, p.parent()) {+        let _ = fs::remove_file(dir.join(f));+    }     if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) {         if let Some(dir) = p.parent() {             for c in arr {@@ -216,7 +223,7 @@ impl Store {                     if p.extension().and_then(|e| e.to_str()) == Some("png") {                         // Skip owned-popup attachment PNGs ({stem}__popupN.png) — they                         // belong to a primary shot's sidecar, not a standalone entry.-                        if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup")).unwrap_or(false) {+                        if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup") || n.contains("__full")).unwrap_or(false) {                             continue;                         }                         if let Ok(meta) = fs::metadata(&p) {@@ -250,7 +257,7 @@ impl Store {                  // Read sidecar metadata if it exists                 let meta_path = path.with_extension("json");-                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context, full) = if let Ok(json_str) = fs::read_to_string(&meta_path) {                     if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {                         let desc = v["description"].as_str().unwrap_or("").to_string();                         let src = v["source"].as_str().unwrap_or("recovered").to_string();@@ -263,12 +270,13 @@ impl Store {                         let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };                         let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);                         let cx = v.get("context").and_then(|t| t.as_str()).map(String::from);-                        (desc, src, ts, ow, oh, cm, pp, th, cx)+                        let fl = v.get("full").and_then(|t| t.as_str()).map(String::from);+                        (desc, src, ts, ow, oh, cm, pp, th, cx, fl)                     } else {-                        (String::new(), "recovered".into(), modified, None, None, None, None, None, None)+                        (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)                     }                 } else {-                    (String::new(), "recovered".into(), modified, None, None, None, None, None, None)+                    (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)                 };                  let png_meta = read_png_meta(&bytes_raw);@@ -290,6 +298,7 @@ impl Store {                     popups,                     thread,                     context,+                    full,                 });                 next_id += 1;             }@@ -331,6 +340,7 @@ impl Store {         popups: Option<serde_json::Value>,         thread: Option<String>,         context: Option<String>,+        orig_path: Option<String>,     ) -> Entry {         let channel_dir = self.base_dir.join(channel_name);         fs::create_dir_all(&channel_dir).ok();@@ -338,6 +348,17 @@ impl Store {         let file_path = channel_dir.join(&filename);         fs::write(&file_path, &image_bytes).ok(); +        // Safe/full pairs: copy the ORIGINAL next to the stored (safe) shot so+        // the full resolution stays viewable even after the source cache is+        // janitored elsewhere. Skipped when the original IS the injected file.+        let full = orig_path.as_deref().and_then(|op| {+            let ob = fs::read(op).ok()?;+            if ob == image_bytes { return None; }+            let fname = format!("{}__full.png", filename.trim_end_matches(".png"));+            fs::write(channel_dir.join(&fname), &ob).ok()?;+            Some(fname)+        });+         let file_bytes = image_bytes.len() as u64;         let png_meta = read_png_meta(&image_bytes); @@ -358,6 +379,7 @@ impl Store {         if let Some(p) = &popups { meta_json["popups"] = p.clone(); }         if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); }         if let Some(c) = &context { meta_json["context"] = serde_json::json!(c); }+        if let Some(f) = &full { meta_json["full"] = serde_json::json!(f); }         fs::write(&meta_path, meta_json.to_string()).ok();          let mut channels = self.channels.write().await;@@ -390,6 +412,7 @@ impl Store {             popups,             thread,             context,+            full,         };          ch.next_id += 1;