← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.11.0"+version = "0.11.3" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.11.1"+version = "0.11.3" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+1−1
@@ -55,7 +55,7 @@ The layout adapts to the narrow screen, and while the tab is backgrounded the pa  ### Pup and plain browser -Pup is the **full-browser canvas**: zooming a card uses nearly the whole screen, so fine text is readable (the skinny webview can never zoom past its panel width). Or just click the **`pup ⧉` button in the viewer header**: every webview shows it, and it switches that channel to a full pup window (the pup view shows `webview ◱` to switch back). The button checks whether pup is available first; if Adom Desktop isn't installed it tells you how to get it (pup is a default bridge that auto-installs with Adom Desktop). Your AI can do the same switch with one command: `shotlog open -c <ch> --pup --swap` (opens a tab in the shared shotlog pup window and closes the webview tab). shotlog leaves your foreground alone; the gentle cue is an automatic **orange taskbar flash** on updates. In any plain browser tab, the same live gallery and unseen-shots title badge apply.+Pup is the **full-browser canvas**: zooming a card uses nearly the whole screen, so fine text is readable (the skinny webview can never zoom past its panel width). Or just click the **`Pup` button in the viewer header**: every webview shows it, and it switches that channel to a full pup window (the pup view shows a `Webview` button to switch back). The button checks whether pup is available first; if Adom Desktop isn't installed it tells you how to get it (pup is a default bridge that auto-installs with Adom Desktop). Your AI can do the same switch with one command: `shotlog open -c <ch> --pup --swap` (opens a tab in the shared shotlog pup window and closes the webview tab). shotlog leaves your foreground alone; the gentle cue is an automatic **orange taskbar flash** on updates. In any plain browser tab, the same live gallery and unseen-shots title badge apply.  ## Is a human actually seeing it? 
VERSION+1−1
@@ -1 +1 @@-0.11.1+0.11.3
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+64
@@ -83,6 +83,70 @@ teal on hover. Never ship a default OS scrollbar. ::-webkit-scrollbar-thumb:hover { background: rgba(0,184,176,.55); background-clip: padding-box; } ``` +## HARD RULE: read the human-ui-patterns skillpack BEFORE touching the viewer++The viewer is a real UI, held to `~/.claude/skills/adom/ui-implementation-reference.md`+(+ its SKILL.md). Read it before any UI change. Non-negotiables learned the hard way:++- **NEVER use native `alert()` / `confirm()` / `prompt()`.** They are modal to the+  WHOLE browser and render "An embedded page at … says" - disgusting in a webview.+  For a destructive action use a **two-step arm** on the button itself (click ->+  "sure? clears all" for 3s -> click again), not a dialog. In-app only.+- **Toasts must be clamped**: `max-width: min(360px, calc(100vw - 24px))` and WRAP.+  A `white-space:nowrap` toast + a long message spills off both screen edges. Bit us.+- **Tooltips: body-appended `#global-tooltip`, cursor-relative, HARD-CLAMPED both+  axes** (skillpack §1c/§1d). The bug we shipped: the fallback clamped only the+  left/top min (`Math.max(4,x)`), so a 300px tooltip spilled off the right in the+  skinny ~360px panel. Fix = `Math.min(Math.max(M,x), vw-tw-M)` on BOTH axes ++  tooltip `max-width: min(300px, calc(100vw - 16px))`. Never a CSS `::after`.+- **Buttons are styled pills, never raw `<button>`.** The `.hdr-btn` system (below)+  is the only header-button style. A raw `<button>` = the browser's grey default =+  instant "hideous". Icon buttons use inline SVG (Lucide-style stroke), never unicode+  glyphs like `⧉ ◱ ↑`.+- **The zoom modal closes on: backdrop click, a click on the IMAGE itself+  (`cursor: zoom-out`), AND Escape.** All three. Not just the backdrop.+- **Zoom uses the real estate**: `max-width: min(96vw, 2200px)`, image `max-height: 84vh` -+  never a fixed px cap (useless in both a skinny panel and a big pup window).++## HARD RULE: the header CSS is a SET - never clobber siblings++We have rebuilt this header repeatedly. The recurring self-inflicted wound: editing+ONE header rule (e.g. the skinny `@media` block) with a block-replace that drops the+sibling rules that follow it. When `.hdr-btn` / base `.actions` / `.ws-led` vanished,+every button silently fell back to raw browser chrome. **These selectors MUST all+exist in `src/html/index.html` at all times:**++```+header  .brand  .subject/.subject-name/.subject-meta  .vsep+.actions (base: display:flex)   .hdr-btn   .hdr-btn:hover   .hdr-btn.accent+.hdr-btn.danger   .hdr-btn.danger.arm   .hdr-btn.icon-only   .ws-led   .ws-led.live+@media (max-width:520px){…}  (the skinny 2-row grid: brand+actions row, subject row)+```++Guard after EVERY index.html edit - if any line prints 0, you clobbered a rule:++```bash+for sel in '\.actions {' '\.hdr-btn {' '\.hdr-btn\.danger' '\.hdr-btn\.accent' \+           '\.ws-led {' '\.ws-led\.live' 'max-width: 520px'; do+  printf '%-24s %s\n' "$sel" "$(grep -c "$sel" src/html/index.html)"+done+```++Then ALSO ralph-screenshot the header at skinny width (it's the case that breaks).+The `.hdr-btn` system to preserve:++```css+.actions { display:flex; align-items:center; gap:6px; margin-left:auto; flex-shrink:0; }+.hdr-btn { -webkit-appearance:none; display:inline-flex; align-items:center; gap:5px;+  height:26px; padding:0 9px; background:var(--overlay); color:var(--text2);+  border:1px solid var(--border); border-radius:7px; font:500 12px/1 'Satoshi';+  cursor:pointer; white-space:nowrap; transition:background .15s,color .15s,border-color .15s; }+.hdr-btn:hover { background:var(--elevated); color:var(--text); border-color:rgba(0,184,176,.45); }+.hdr-btn svg { width:13px; height:13px; }         .hdr-btn.icon-only { padding:0; width:26px; justify-content:center; }+.hdr-btn.accent { color:var(--accent); border-color:rgba(0,184,176,.35); }  /* the canvas toggle */+.hdr-btn.danger.arm { color:#fff; background:var(--danger); border-color:var(--danger); }  /* armed clear */+```+ ## Build + deploy cycle  ```bash
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.11.1",+  "version": "0.11.3",   "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.11.1",+  "version": "0.11.3",   "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+84−25
@@ -45,7 +45,8 @@ img { max-width: 100%; }   position: fixed; top: 0; left: 0; transform: translate(0,0);   background: rgba(22,27,34,.97); backdrop-filter: blur(12px);   border: 1px solid rgba(0,184,176,.25); border-radius: 7px; padding: 6px 10px;-  font: 12px/1.45 'Satoshi', sans-serif; color: var(--text); max-width: 300px;+  font: 12px/1.45 'Satoshi', sans-serif; color: var(--text);+  max-width: min(300px, calc(100vw - 16px)); /* fit a skinny ~360px panel, never overflow */   white-space: pre-wrap; box-shadow: 0 8px 28px rgba(0,0,0,.55);   z-index: 99999; pointer-events: none; opacity: 0; display: none; transition: opacity .18s; }@@ -77,6 +78,7 @@ header {   header { display: grid; grid-template-columns: minmax(0,1fr) auto;     align-items: center; height: auto; padding: 7px 12px 8px; row-gap: 3px; column-gap: 8px; }   .brand { grid-row: 1; grid-column: 1; min-width: 0; overflow: hidden; }+  .brand .app-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }   .brand .sep, .brand .app-subtitle { display: none; } /* declutter at this width */   .vsep { display: none; }   .actions { grid-row: 1; grid-column: 2; margin-left: 0; justify-self: end; }@@ -85,6 +87,30 @@ header {     word-break: break-word; line-height: 1.25; }   .subject-meta { display: inline; } }+/* Header action buttons + LED (adom-app-header). These were the raw-<button>+   ugliness: clean pills, subtle until hover, icon+label capable. */+.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-shrink: 0; }+.hdr-btn { -webkit-appearance: none; appearance: none;+  display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px;+  background: var(--overlay); color: var(--text2); border: 1px solid var(--border);+  border-radius: 7px; font: 500 12px/1 'Satoshi', sans-serif; cursor: pointer;+  white-space: nowrap; flex-shrink: 0; transition: background .15s, color .15s, border-color .15s; }+.hdr-btn:hover { background: var(--elevated); color: var(--text); border-color: rgba(0,184,176,.45); }+.hdr-btn:active { transform: translateY(.5px); }+.hdr-btn:disabled { opacity: .55; cursor: default; transform: none; }+.hdr-btn svg { width: 13px; height: 13px; flex-shrink: 0; }+.hdr-btn.icon-only { padding: 0; width: 26px; justify-content: center; }+/* Canvas toggle: the primary affordance, tinted with the accent. */+.hdr-btn.accent { color: var(--accent); border-color: rgba(0,184,176,.35); }+.hdr-btn.accent:hover { background: rgba(0,184,176,.12); color: var(--accent-bright); border-color: rgba(0,184,176,.6); }+/* Destructive: quiet until hover, then a two-step "arm" state (no browser modal). */+.hdr-btn.danger { color: var(--text2); }+.hdr-btn.danger:hover { color: var(--danger); background: rgba(248,81,73,.09); border-color: rgba(248,81,73,.45); }+.hdr-btn.danger.arm { color: #fff; background: var(--danger); border-color: var(--danger); }+.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; margin-left: 2px;+  background: var(--text3); transition: background .4s, box-shadow .4s; }+.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }+ /* App-info popover */ .hover-popover { position: absolute; top: calc(100% + 6px); left: 12px;   background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;@@ -193,10 +219,14 @@ header {   overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; }  /* Toast */-.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);+/* Toast: centered, CLAMPED to the viewport so long messages wrap instead of+   spilling off both edges (the old white-space:nowrap bug). */+.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);   background: var(--elevated); color: var(--text); border: 1px solid var(--border);-  padding: 7px 13px; border-radius: 999px; font-size: 12px; opacity: 0; transition: opacity .18s;-  pointer-events: none; z-index: 200; white-space: nowrap; }+  padding: 9px 14px; border-radius: 12px; font: 12px/1.45 'Satoshi', sans-serif;+  opacity: 0; transition: opacity .18s; pointer-events: none; z-index: 200;+  max-width: min(360px, calc(100vw - 24px)); width: max-content; text-align: center;+  box-shadow: 0 8px 28px rgba(0,0,0,.5); } .toast.show { opacity: 1; }  /* Paste progress overlay */@@ -233,7 +263,7 @@ header {   </div>   <div class="actions">     <button class="hdr-btn" id="canvasBtn" style="display:none"></button>-    <button class="hdr-btn" id="toTopBtn" data-tooltip="Scroll to newest (top)">↑</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 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>   </div>@@ -280,10 +310,15 @@ header {     function show(el) {       const t = el.getAttribute('data-tooltip'); if (!t) return;       tip.textContent = t; tip.classList.add('visible');-      const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14;+      const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14, M=6;       const c=[{x:cx+O,y:cy+O},{x:cx-tw-O,y:cy+O},{x:cx+O,y:cy-th-O},{x:cx-tw-O,y:cy-th-O}]-        .find(p=>p.x>=4&&p.y>=4&&p.x+tw<=vw-4&&p.y+th<=vh-4)||{x:cx+O,y:cy+O};-      tip.style.transform=`translate(${Math.max(4,c.x)}px,${Math.max(4,c.y)}px)`;+        .find(p=>p.x>=M&&p.y>=M&&p.x+tw<=vw-M&&p.y+th<=vh-M)||{x:cx+O,y:cy+O};+      // skillpack §1d: ALWAYS hard-clamp BOTH axes as the last step. In a skinny+      // ~360px panel no quadrant fits a wide tooltip, so the fallback must be+      // pulled back inside the right/bottom edges too — not just the left/top min.+      const x=Math.min(Math.max(M,c.x), Math.max(M, vw-tw-M));+      const y=Math.min(Math.max(M,c.y), Math.max(M, vh-th-M));+      tip.style.transform=`translate(${x}px,${y}px)`;       requestAnimationFrame(()=>tip.classList.add('shown'));     }     function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown');@@ -487,7 +522,13 @@ header {      modal.classList.add('open'); modal.scrollTop=0;   }-  modal.addEventListener('click', e => { if(e.target===modal) modal.classList.remove('open'); });+  function closeModal(){ modal.classList.remove('open'); }+  // Close on: backdrop click, clicking the zoomed image again, or Escape.+  // (Metadata/desc clicks are preserved — only the backdrop and image close.)+  modal.addEventListener('click', e => { if(e.target===modal) closeModal(); });+  mImg.style.cursor='zoom-out';+  mImg.addEventListener('click', e => { e.stopPropagation(); closeModal(); });+  document.addEventListener('keydown', e => { if(e.key==='Escape' && modal.classList.contains('open')) closeModal(); });    function renderAll() {     feed.innerHTML='';@@ -513,29 +554,39 @@ header {     const surf=detectSurface();     if(surf!=='webview' && surf!=='pup') return;   // phone/plain browser: no toggle     const toPup = surf==='webview';-    const label = toPup ? 'pup ⧉' : 'webview ◱';+    // Clean inline-SVG icons (Lucide-style): external-link for →pup, panel-right for →webview.+    const ICON = toPup+      ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'+      : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>';+    const label = toPup ? 'Pup' : 'Webview';+    btn.classList.add('accent');     btn.style.display='';-    btn.textContent=label;+    const render=()=>{ btn.innerHTML=ICON+'<span>'+label+'</span>'; };+    render();+    // Tooltip: the surface term (pup/webview), what it does, and the plain-English+    // prompt the human can just say to their AI to do the same thing.     btn.setAttribute('data-tooltip', toPup-      ? 'Open this channel in a full pup browser window (zoom uses the whole screen) and close this webview tab.'-      : 'Move this channel back to the slim webview panel in Hydrogen and close this pup tab.');+      ? 'Show in pup — a full browser window. Real zoom, whole-screen images. Closes this slim webview tab.\nOr just tell your AI: "show me shotlog in pup"'+      : 'Show in webview — the slim panel docked in Hydrogen. Closes this pup tab.\nOr just tell your AI: "show me shotlog in webview"');+    const busy=(t)=>{ btn.disabled=true; btn.innerHTML='<span>'+t+'</span>'; };     btn.addEventListener('click', async ()=>{-      btn.disabled=true; btn.textContent='checking…';+      busy('checking…');       try{         if(toPup){           const chk=await fetch('api/canvas-check').then(r=>r.json());           if(!chk.available){-            showToast(chk.hint||'pup is not available on this machine yet.', 12000);-            btn.textContent=label; btn.disabled=false; return;+            showToast(chk.hint||'pup is not available on this machine yet.', 9000);+            render(); btn.disabled=false; return;           }         }-        showToast(toPup ? 'Opening in pup… this webview tab will close.' : 'Moving back to webview… this pup tab will close.', 6000);+        busy(toPup?'popping out…':'docking…');+        showToast(toPup ? 'Opening in pup — this tab will close.' : 'Docking to webview — this tab will close.', 5000);         const r=await fetch('api/canvas-switch',{method:'POST',           headers:{'Content-Type':'application/json'},           body:JSON.stringify({channel, to: toPup?'pup':'webview'})}).then(x=>x.json());-        if(!r.ok){ showToast('switch failed: '+(r.error||r.detail||'unknown'), 8000); btn.textContent=label; btn.disabled=false; }+        if(!r.ok){ showToast('switch failed: '+(r.error||r.detail||'unknown'), 7000); render(); btn.disabled=false; }         // on success this surface gets closed by the swap - nothing more to do here-      }catch(e){ showToast('switch failed: '+e.message, 8000); btn.textContent=label; btn.disabled=false; }+      }catch(e){ showToast('switch failed: '+e.message, 7000); render(); btn.disabled=false; }     });   })(); @@ -587,12 +638,20 @@ header {    // ── Wiring ───────────────────────────────────────────────────────────────   $('toTopBtn').addEventListener('click',()=>{ feed.scrollTop=0; });-  $('clearBtn').addEventListener('click', async ()=>{-    if(!confirm(`Delete ALL screenshots in "${channel}"? This cannot be undone.`)) return;-    entries=[]; renderAll();-    await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});-    showToast('cleared');-  });+  // Two-step arm instead of a browser-modal confirm(): first click arms the+  // button ("sure? clears all") for 3s; a second click within that window does it.+  (function initClear(){+    const btn=$('clearBtn'); let armed=false, t=null;+    const reset=()=>{ armed=false; btn.classList.remove('arm'); btn.textContent='clear'; clearTimeout(t); };+    btn.addEventListener('click', async ()=>{+      if(!armed){ armed=true; btn.classList.add('arm'); btn.textContent='sure? clears all';+        t=setTimeout(reset,3000); return; }+      reset();+      entries=[]; renderAll();+      await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});+      showToast('cleared');+    });+  })();    // ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────   const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');
src/store.rs+6−2
@@ -426,8 +426,12 @@ impl Store {             if let Ok(entries) = fs::read_dir(&channel_dir) {                 for entry in entries.flatten() {                     let p = entry.path();-                    if p.extension().and_then(|e| e.to_str()) == Some("png") {-                        fs::remove_file(&p).ok();+                    // Remove EVERY per-shot file, not just the PNGs: the .json+                    // sidecars and __popup child-window PNGs must go too, or the+                    // folder fills with orphaned metadata.+                    match p.extension().and_then(|e| e.to_str()) {+                        Some("png") | Some("json") => { fs::remove_file(&p).ok(); }+                        _ => {}                     }                 }             }