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.12.0
9 files changed
+96−34
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.11.4"+version = "0.12.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.11.4"+version = "0.12.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1@@ -1 +1 @@-0.11.4+0.12.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+10−1@@ -120,9 +120,18 @@ 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)+@media (max-width:520px){…} (skinny: stays ONE 44px row; SHEDS zones, never wraps) ``` +**Header = ONE 44px row, always** (the canonical `adom-app-header` spec;+`~/.claude/skills/adom/guides/adom-app-header.md`). Multi-row / wrapping is its+anti-pattern #1. Read that spec before ANY header change. For the skinny ~360px+panel, do NOT wrap to a 2nd row — SHED the least-important zones in order: brand+subtitle -> brand app-name (keep the logo) -> `.vsep` -> `.subject-meta` (its+value moves into the `.subject-name` tooltip) -> actions go icon-only. The+`.subject-name` stays and ellipsis-truncates. I shipped a 2-row grid here once+and it was a spec breach - never again.+ Guard after EVERY index.html edit - if any line prints 0, you clobbered a rule: ```bash
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.11.4",+ "version": "0.12.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.11.4",+ "version": "0.12.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+37−18@@ -46,7 +46,8 @@ img { max-width: 100%; } 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: min(300px, calc(100vw - 16px)); /* fit a skinny ~360px panel, never overflow */+ max-width: min(320px, calc(100vw - 16px)); /* fit a skinny ~360px panel, never overflow */+ max-height: calc(100dvh - 16px); overflow: hidden; /* never taller than the viewport */ 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; }@@ -71,21 +72,18 @@ header { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .subject-meta { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; white-space: nowrap; flex-shrink: 0; }-/* Skinny view (~20% side panel): a tight 2-row grid.- Row 1: brand (left) + actions (right). Row 2: channel name + count, full width.- Aligned edges, no dead space - not the loose 3-row stack it used to be. */+/* Canonical adom-app-header: the header is ALWAYS ONE 44px row (multi-row is+ anti-pattern #1 in the adom-app-header spec). To fit a skinny ~360px panel we+ SHED the least-important zones per the spec's hierarchy — brand subtitle, then+ brand name (keep the logo), the vsep, and subject-meta — never wrap to a 2nd+ row. subject-name stays and ellipsis-truncates; the count moves to its tooltip. */ @media (max-width: 520px) {- 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 */+ header { gap: 8px; padding: 0 10px; }+ .brand .app-name, .brand .sep, .brand .app-subtitle { display: none; } /* logo carries identity */ .vsep { display: none; }- .actions { grid-row: 1; grid-column: 2; margin-left: 0; justify-self: end; }- .subject { grid-row: 2; grid-column: 1 / -1; min-width: 0; }- .subject-name { white-space: normal; overflow: visible; text-overflow: clip;- word-break: break-word; line-height: 1.25; }- .subject-meta { display: inline; }+ .subject-meta { display: none; }+ .actions { gap: 5px; }+ #canvasBtn span { display: none; } /* actions go icon-only when tight (canonical: icon-buttons) */ } /* Header action buttons + LED (adom-app-header). These were the raw-<button> ugliness: clean pills, subtle until hover, icon+label capable. */@@ -112,9 +110,12 @@ header { .ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); } /* App-info popover */+/* left:12 + right:12 with a max-width caps it inside a skinny ~360px panel —+ never off the right edge (tooltips/popovers must never render off-screen). */ .hover-popover { position: absolute; top: calc(100% + 6px); left: 12px; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;- box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px; min-width: 280px; max-width: 400px;+ box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px;+ min-width: 0; max-width: min(400px, calc(100vw - 24px)); width: max-content; font: 12px 'Satoshi', sans-serif; color: var(--text); z-index: 99999; opacity: 0; transform: translateY(-2px); pointer-events: none; transition: opacity 120ms, transform 120ms; } .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }@@ -246,8 +247,9 @@ header { <div id="global-tooltip"></div> <header>- <div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0"- data-tooltip="adom-shotlog — screenshot log Hover for app info">+ <!-- The brand opens the app-info POPOVER on hover — NOT also a data-tooltip.+ Two poppers on one trigger fight each other and land off-screen. -->+ <div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0"> <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/> </svg>@@ -541,7 +543,24 @@ header { for (let i=entries.length-1;i>=0;i--) feed.appendChild(card(entries[i])); // newest first updateCount(); }- function updateCount(){ $('shotCount').textContent = shotCount? shotCount+(shotCount===1?' shot':' shots'):''; }+ function channelTooltip(countText){+ // read-captures is the SPECIAL auto-capture channel — explain it in full,+ // because even the shots' origin is non-obvious. Multi-paragraph is fine;+ // the renderer clamps it on-screen.+ if (channel === 'read-captures') {+ return 'read-captures — the automatic capture channel.\n\n'++ 'Whenever ANY AI thread in this container READS an image file — a screenshot, a render, a photo — to look at it, a hook instantly copies that exact image here. So this channel shows you everything your AIs have looked at, without them having to remember to send it.\n\n'++ 'That is why shots appear here on their own: they are a side effect of an AI opening an image to analyze it, not something it deliberately posts.\n\n'++ 'Powered by the shotlog read-hook (a Claude Code PostToolUse(Read) hook that runs `shotlog inject -c read-captures`).'++ (countText ? '\n\n'+countText+' captured so far.' : '');+ }+ return 'Channel: '+channel+(countText?' · '+countText:'')+'\nAI agents inject shots here; you watch them arrive live.';+ }+ function updateCount(){ const t = shotCount? shotCount+(shotCount===1?' shot':' shots'):'';+ $('shotCount').textContent = t;+ // Surface the count in the channel-name tooltip too, since .subject-meta is+ // hidden in the skinny panel (canonical adom-app-header: meta -> subject tooltip).+ $('channelName').setAttribute('data-tooltip', channelTooltip(t)); } // ── Load ─────────────────────────────────────────────────────────────── fetch('api/shots?channel='+encodeURIComponent(channel)).then(r=>r.json()).then(data=>{
src/server.rs+44−10@@ -249,24 +249,58 @@ struct CanvasSwitchRequest { to: String, } -/// Switch a channel between canvases from the viewer UI. Runs the same-/// `open --swap` logic as the CLI (one surface at a time).-async fn canvas_switch(Json(req): Json<CanvasSwitchRequest>) -> Json<serde_json::Value> {+/// Switch a channel between canvases from the viewer UI. For SPEED: open the+/// DESTINATION surface (blocking, so the human sees it appear), then close the+/// SOURCE surface in the BACKGROUND. The old code ran the full `--swap`+/// (open + close) sequentially before responding, so the button sat on+/// "docking…" through the slow laptop round-trip of closing the other surface.+async fn canvas_switch(+ State(state): State<Arc<AppState>>,+ Json(req): Json<CanvasSwitchRequest>,+) -> Json<serde_json::Value> { if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() { return Json(serde_json::json!({"ok": false, "error": "invalid channel"})); } if req.to != "pup" && req.to != "webview" { return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"})); }+ let port = state.port;+ let channel = req.channel.clone();+ let to = req.to.clone();++ // Close the SOURCE surface in the background — do NOT make the button wait+ // on it. (Closing the pup tab is a slow laptop round-trip; closing a webview+ // tab is a fast Hydrogen call. Either way it's fire-and-forget.)+ {+ let ch = channel.clone();+ let to_bg = to.clone();+ tokio::spawn(async move {+ let _ = tokio::task::spawn_blocking(move || {+ if to_bg == "pup" {+ // moved to pup → close the webview tab (Hydrogen, fast)+ let _ = std::process::Command::new("adom-cli")+ .args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {ch}")])+ .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())+ .status();+ } else {+ // moved to webview → close the pup tab (laptop round-trip)+ crate::open::close_pup_tab(&ch);+ }+ })+ .await;+ });+ }++ // Open the DESTINATION surface (blocking) via a self-subprocess — isolated+ // so open_pup's process::exit on error can never take down the broker. let v = tokio::task::spawn_blocking(move || { let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());- let mut args: Vec<String> = vec!["open".into(), "-c".into(), req.channel.clone(), "--swap".into()];- if req.to == "pup" {- args.push("--pup".into());- } else {- args.push("--skinny".into());- }- match std::process::Command::new(exe).args(&args).output() {+ let flag = if to == "pup" { "--pup" } else { "--skinny" };+ let _ = port; // (port defaults to 8820 inside the child)+ match std::process::Command::new(exe)+ .args(["open", "-c", &channel, flag])+ .output()+ { Ok(o) => serde_json::json!({"ok": o.status.success(), "detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}), Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),