← Commit history
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&#10;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()}),