app
Shotlog - Screenshot Log
Public Made by Adomby adom
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 Bridge child-window arrays via --meta.
← Commit history
Publish 0.11.3
10 files changed
+160−33
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. While green the server knows you're watching and 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(); }+ _ => {} } } }