← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.28.2"+version = "0.28.3" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.28.2"+version = "0.28.3" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2−1
@@ -59,7 +59,7 @@ Every card shows its **ref** (`#42`) in a slim strip above the thumbnail (never adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG variant-g.png ``` -The handle shows beside the ref (`#42 heroG`), and the inject reply hands the AI its ref back (`Ref: #42, handle "heroG"`), so "heroG is the one to use" is unambiguous on both sides.+The handle shows beside the ref (`#42 heroG`), and the inject reply hands the AI its ref back (`Ref: #42, handle "heroG"`), so "heroG is the one to use" is unambiguous on both sides. Auto-captures get a handle too when the file has a wordy name (`board_v3-front.png` becomes `board_v3-front`); timestamp-named captures keep just their ref, so the gallery is not littered with date chips. An AI reads a channel back with `adom-shotlog shots -c <channel>`; handles survive broker restarts.  ### Phone @@ -131,6 +131,7 @@ The viewer has a **Settings** dialog: the outline cog at the right end of the he |---|---| | `adom-shotlog inject` | Inject a screenshot into a channel (records the original resolution; `--meta` adds child windows; `--tag heroG` names the shot; reports its ref and whether a human is watching) | | `adom-shotlog open` | Open the gallery as a Hydrogen webview tab (`--skinny` for the slim right panel; exit 2 = no pane found, exit 1 = adom-cli or bridge failure, with the reason printed) |+| `adom-shotlog shots` | Read a channel back: ref, handle, age and description of its newest shots (`--tagged` for handles only, `--json` for every field, `--limit N`) | | `adom-shotlog viewers` | Is a human watching a channel? Exit 0 / 4 / 3, plus connected surfaces | | `adom-shotlog channels` | Overview of every channel: shots, last activity, and who is watching | | `adom-shotlog serve` | Start the HTTP server / broker (port 8820) |
SKILL.md+2
@@ -309,6 +309,8 @@ adom-shotlog inject -c hero -d "hero variant G, tighter crop" --tag heroG varian  Then "heroG is the one" or "#42 is the one" is unambiguous. Refer to shots by their ref in your own replies too ("compare #41 and #42"). +Read a channel BACK before you talk about it: `adom-shotlog shots -c <channel>` lists ref, handle, age and description (newest last); `--tagged` shows only handled shots, `--json` gives every field. You cannot quote a ref you have not looked up. Read-hook captures get a handle automatically when the source file has a wordy name; timestamp-named files keep just their ref.+ ## Layout rules the viewer follows (so you do not fight them)  - ONE click on a thumbnail fills the window; the next click, Esc, or the margin comes back. There is no modal step any more.
VERSION+1−1
@@ -1 +1 @@-0.28.2+0.28.3
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+8
@@ -461,3 +461,11 @@ Content is bounded anyway (context truncates at capture), so no cap needed. - Header: `.subject` is `overflow:hidden` and the two-row layout kicks in below 760px (a ~570px ah pane overlapped before); below 300px (a 10% own column is ~170 CSS px) `.actions` wraps. - Images: `lazyImg` (IntersectionObservers ROOTED AT `.feed`, the real scroll container; a viewport-rooted observer is clipped by it and loads one image) loads in-view shots at high priority, within one screen at low, unloads after 20s beyond three screens, never the top 4. `img.style.aspectRatio` is reserved from `e.w/e.h` so unloading never shifts scroll. `window._lazyImg.stats()` for tests. - Ralph-test all of the above in pup via `pup_eval` (see the 2026-09-02 transcript for the exact expression): last toolbar child, backdrop color, four dismissals, card/row/hint counts, restore arm/disarm, image naturalWidth.++## Refs, handles and read-back (2026-09-02, Ray's wiki issues #3/#5 and PRs #1-#4)++- `tag` MUST be written to the shot's JSON sidecar and read back in recovery (store.rs). 0.28.0-0.28.2 kept it only in memory, so every handle vanished on the next broker restart. Test on a throwaway broker: inject `--tag`, kill, serve again, `shots --tagged` still shows it.+- `adom-shotlog shots -c <ch> [--tagged] [--json] [--limit N]` reads `/api/shots` back (Ray's PR #2, adapted: the footer says every shot is addressable by its ref; the `--tag` hint is advice, printed only when a channel of 3+ shots has no handle at all).+- The read-hook derives a handle from the source filename ONLY when the stem is wordy: at least 3 letters AND more letters than digits (Ray's PR #3, adapted). Timestamp names (`2026-09-02T12-59-09.647.png`, `IMG_1234.png`, `screenshot-2026-09-02-at-12.59.png`) get no handle; the ref (#id) already names them, and a date chip on every card is noise.+- Tooltips are suppressed while ANY deliberately opened surface is up: the hovered card's `.card-info.show` AND any `.hover-popover.show` / `.card-info.show` on the page (Ray's PR #4).+- Wiki PRs against this page are whole-file diffs from an older base; apply them locally (adapted where needed), release, then close the PR with a comment crediting the author. Never `pr merge` a whole-file diff over newer files.
hooks/shotlog-on-read.sh+14−1
@@ -38,6 +38,19 @@ shotlog health >/dev/null 2>&1 || { (cd "${SHOTLOG_HOME:-$HOME/project}" && shot  base="$(basename "$f")" +# HANDLE (Ray, wiki PR #3, adapted): derive a short handle from the source+# filename so an auto-captured shot can be pointed at by name ("heroG is the+# one"), but ONLY when the stem is wordy. Bridge captures are named by+# timestamp; a "2026-09-02T12-59" handle would be pure noise on every card, and+# those shots already have their ref (#id). A deliberate `inject --tag` wins.+stem="${base%.*}"+letters="$(printf '%s' "$stem" | tr -cd 'A-Za-z' | wc -c | tr -d ' ')"+digits="$(printf '%s' "$stem" | tr -cd '0-9' | wc -c | tr -d ' ')"+TAG=""+if [ "$letters" -ge 3 ] && [ "$letters" -gt "$digits" ]; then+  TAG="$(printf '%s' "$stem" | tr -c 'A-Za-z0-9._-' '-' | sed 's/^[-._]*//; s/[-._]*$//' | cut -c1-16 | sed 's/[-._]*$//')"+fi+ # Attribute the capture to the AI THREAD that read the image: the hook payload # carries session_id + transcript_path; the transcript's ai-title records hold # the thread's human-readable title.@@ -95,7 +108,7 @@ esac for attempt in 1 2 3; do   if shotlog inject -c read-captures \     -d "Auto-captured screenshot Claude read for analysis: ${base}" \-    -s "$SRC" ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} $META_ARGS $ORIG_ARGS "$f" >/dev/null 2>&1; then+    -s "$SRC" ${TAG:+--tag "$TAG"} ${THREAD:+--thread "$THREAD"} ${CONTEXT:+--context "$CONTEXT"} $META_ARGS $ORIG_ARGS "$f" >/dev/null 2>&1; then     break   fi   sleep 2
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.28.2",+  "version": "0.28.3",   "type": "app",   "title": "Shotlog - Screenshot Log",   "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 Bridge child-window arrays via --meta.",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.28.2",+  "version": "0.28.3",   "type": "app",   "title": "Shotlog - Screenshot Log",   "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 Bridge child-window arrays via --meta.",
src/html/index.html+8−3
@@ -287,9 +287,11 @@ body.compact .card-popups .ph { padding-top: 4px; } .shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111;   object-fit: contain; object-position: center top; transition: opacity .15s; } .shot-card:active .shot-card-img { opacity: .85; }-/* Direct-to-fullscreen shortcut: a ⛶ button on the thumbnail (hover-revealed)-   that skips the modal and fills the window in ONE click. The image itself-   still opens the modal on click — this is purely additional. */+/* Fullscreen affordance: a ⛶ button on the thumbnail (hover-revealed).+   The thumbnail IMAGE also fills the window in one click (see the click+   handler on img, "ONE click on a thumbnail goes STRAIGHT to fill-window"),+   so this button is the discoverable, keyboard-focusable route to the same+   thing rather than a separate shortcut past the modal. (Ray, PR #1) */ .card-img-wrap { position: relative; } .card-fill-btn { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;   display: inline-flex; align-items: center; justify-content: center;@@ -925,6 +927,9 @@ body.compact .card-popups .ph { padding-top: 4px; }       // A card whose info overlay is REVEALED (compact mode ⓘ) is being read:       // no tooltip may float over it (Ray: "the tooltip obscures the popover").       if (el.closest && el.closest('.shot-card')?.querySelector('.card-info.show')) return;+      // ...and nothing incidental may cover ANY surface the human deliberately+      // opened: another card's overlay, or the brand hover card (Ray, PR #4).+      if (document.querySelector('.hover-popover.show, .card-info.show')) return;       // Rich (authored, trusted) HTML via el._ttHtml; else plain text. Dynamic       // values interpolated into _ttHtml are escaped by the builders below.       if (el._ttHtml) { tip.innerHTML = el._ttHtml; }
src/main.rs+72
@@ -281,6 +281,27 @@ enum Commands {         #[arg(long, default_value_t = 25)]         limit: usize,     },+    /// List the shots in a channel: ref, handle, age, description. `channels`+    /// answers "what channels exist"; this answers "what is IN one", so an+    /// agent can read back its own history and quote refs ("compare #41 and+    /// #42"). (Ray, wiki PR #2)+    Shots {+        /// Channel to list+        #[arg(short, long)]+        channel: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Max rows to show, newest last (default 25)+        #[arg(long, default_value_t = 25)]+        limit: usize,+        /// Only shots that carry a --tag handle+        #[arg(long)]+        tagged: bool,+        /// Raw JSON (the newest `limit` entries, every field) instead of a table+        #[arg(long)]+        json: bool,+    },     /// Install everything: skill, bash completions     Install,     /// Generate shell completions (bash, zsh, fish)@@ -531,6 +552,9 @@ async fn main() {         Commands::Channels { port, since_min, limit } => {             run_channels(port, since_min, limit);         }+        Commands::Shots { channel, port, limit, tagged, json } => {+            run_shots(&channel, port, limit, tagged, json);+        }         Commands::Install => {             run_install();         }@@ -944,6 +968,54 @@ fn run_channels(port: u16, since_min: Option<i64>, limit: usize) {     } } +/// `shots`: read a channel back (Ray, wiki PR #2, adapted).+fn run_shots(channel: &str, port: u16, limit: usize, tagged_only: bool, json: bool) {+    let endpoint = format!("/api/shots?channel={}", channel.replace(' ', "%20"));+    let val = match http_get(port, &endpoint) {+        Ok(v) => v,+        Err(e) => { err(&format!("Cannot connect to shotlog on port {port}: {e}")); hint("Start it: adom-shotlog serve &"); std::process::exit(1); }+    };+    let rows = val.as_array().cloned().unwrap_or_default();+    let has_tag = |r: &serde_json::Value| r["tag"].as_str().is_some_and(|t| !t.is_empty());+    let picked: Vec<&serde_json::Value> = rows.iter().filter(|r| !tagged_only || has_tag(r)).rev().take(limit).collect();+    if json {+        let v: Vec<&serde_json::Value> = picked.into_iter().rev().collect();+        println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));+        return;+    }+    if rows.is_empty() {+        println!("No shots in channel {channel}.");+        hint("Channels that DO have shots: adom-shotlog channels");+        return;+    }+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);+    let fmt_ago = |secs: i64| -> String {+        let m = secs / 60;+        if m < 1 { "just now".into() } else if m < 60 { format!("{m}m ago") }+        else if m < 1440 { format!("{}h ago", m / 60) } else { format!("{}d ago", m / 1440) }+    };+    let total = rows.len();+    let tagged_total = rows.iter().filter(|r| has_tag(r)).count();+    println!("{:>6}  {:<16}  {:>9}  {}", "REF", "HANDLE", "AGE", "DESCRIPTION");+    for row in picked.into_iter().rev() {+        let id = row["id"].as_u64().unwrap_or(0);+        let tag = row["tag"].as_str().filter(|t| !t.is_empty()).unwrap_or("-");+        let epoch = row["timestamp"].as_str()+            .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())+            .map(|d| d.timestamp()).unwrap_or(0);+        let ago = if epoch == 0 { "-".to_string() } else { fmt_ago(now - epoch) };+        let desc = row["description"].as_str().unwrap_or("");+        let desc = if desc.chars().count() > 58 { format!("{}...", desc.chars().take(55).collect::<String>()) } else { desc.to_string() };+        let tag = if tag.chars().count() > 16 { format!("{}..", tag.chars().take(14).collect::<String>()) } else { tag.to_string() };+        println!("{:>6}  {:<16}  {ago:>9}  {desc}", format!("#{id}"), tag);+    }+    println!("\n{total} shots, {tagged_total} with a handle. Every shot is addressable by its ref: tell the human \"#{}\" and they see exactly that card.", rows.last().and_then(|r| r["id"].as_u64()).unwrap_or(0));+    if tagged_total == 0 && total > 2 {+        hint("Iterating on variants? A handle reads better than a number: `adom-shotlog inject --tag heroG ...` so the human can say \"heroG is the one\". Read-hook captures get a handle from a wordy filename automatically; timestamp-named files keep just their ref.");+    }+}+ fn run_install() {     ensure_read_hook();     let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());
src/store.rs+8−1
@@ -320,6 +320,10 @@ impl Store {                 };                  let png_meta = read_png_meta(&bytes_raw);+                let tag = fs::read_to_string(&meta_path).ok()+                    .and_then(|j| serde_json::from_str::<serde_json::Value>(&j).ok())+                    .and_then(|v| v.get("tag").and_then(|t| t.as_str()).map(String::from))+                    .filter(|t| !t.is_empty());                 entries.push(Entry {                     id: next_id,                     timestamp,@@ -338,7 +342,7 @@ impl Store {                     popups,                     thread,                     context,-                    tag: None,+                    tag,                     full,                 });                 next_id += 1;@@ -423,6 +427,9 @@ impl Store {         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); }+        // The handle must SURVIVE a broker restart (recovery rebuilds entries+        // from these sidecars; 0.28.0-0.28.2 dropped every --tag on restart).+        if let Some(t) = &tag { meta_json["tag"] = serde_json::json!(t); }         fs::write(&meta_path, meta_json.to_string()).ok();          let mut channels = self.channels.write().await;