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.18.0
12 files changed
+101−78
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.17.1"+version = "0.18.0" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.17.1"+version = "0.18.0" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
SKILL.md+7−3@@ -87,9 +87,13 @@ curl -s -X POST localhost:8820/api/prefs -H 'Content-Type: application/json' \ ``` Canvas rules the broker enforces (do not fight them):-- Every `open`/switch RECORDS the channel's canvas choice; auto-open honors it.-- The user CLOSING a pup window reverts that channel to webview (a deliberate- close is a choice, not a blip - the broker probes to tell them apart).+- Every channel follows the GLOBAL default_canvas. The only exception is an+ IN-MEMORY note from the human's explicit click in the viewer (canvas button)+ or their deliberate pup-window close - forgotten on broker restart.+- YOUR opens (`shotlog open ...`) do NOT record any preference. Never try to+ pin a channel's canvas; if the human wants that, they say so.+- The skinny panel width comes from the webview_width pref (default 20%);+ do not pass --ratio unless the human asked for a specific width. - `shotlog open -c <ch> --pup --swap` / `--skinny --swap` is the switch verb. ## Inject - one command
VERSION+1−1@@ -1 +1 @@-0.17.1+0.18.0
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+17−2@@ -64,8 +64,8 @@ it does, the layout isn't fluid. Rules that keep it fluid: used fixed `168px` cells + `overflow-x:auto`, which overflowed the ~360px panel and made an ugly scrollbar. Fix: `flex-wrap: wrap` with cells `flex: 1 1 130px; min-width: 0; max-width: 220px` so they share the row and wrap, never scroll.-- **Wrap the header at narrow widths** (`@media (max-width: 520px)`): stack it into- rows (brand / channel + count / actions) so nothing truncates or overflows.+- **The header NEVER wraps** - it stays ONE 44px row at every width and SHEDS+ zones at narrow widths (see the header section below + adom-app-header spec). - Long values wrap: `word-break: break-word` / `pre-wrap`, never a fixed min-width that forces overflow. @@ -186,6 +186,21 @@ The `.hdr-btn` system to preserve: .hdr-btn.danger.arm { color:#fff; background:var(--danger); border-color:var(--danger); } /* armed clear */ ``` +## Canvas doctrine: GLOBAL default rules; channel notes are in-memory only++Hard-won (the user called it out twice): per-channel canvas preferences must+NEVER persist. Everything follows prefs.default_canvas except an in-memory+note from the human's OWN actions (viewer canvas-button click; deliberate pup+close), forgotten on restart. CLI opens and auto-opens must NOT record notes -+we once had every AI `open --skinny` and even the broker's own force-opens+writing "choices" to disk, permanently overriding the user's global setting.+Panel width comes from prefs.webview_width (never hardcode 0.2 again).++Logging: the broker's stdout IS the audit trail. Deploys APPEND to+prod-srv.log (never truncate: `>> log`, echo a deploy marker) and every+decision path prints a timestamped `DECISION ...` line (force-open canvas ++why, canvas notes, close-reverts, pup retries).+ ## Build + deploy cycle ```bash
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.17.1",+ "version": "0.18.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.17.1",+ "version": "0.18.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+11−29@@ -386,18 +386,6 @@ header { <div class="helper-card"> <button class="h-close" id="prefsClose">✕</button> <div class="h-title">Preferences</div>- <div class="pref-row">- <div class="pref-info">- <div class="pref-name">This channel: <span id="prefChName"></span></div>- <div class="pref-desc">Which canvas THIS channel uses when shotlog auto-opens it. Opening or switching a canvas records a choice here; closing a pup window flips it to webview. "Follow default" forgets the channel's own choice and uses the global default below.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>let this shotlog channel follow the default again</div>- </div>- <div class="seg" id="prefChCanvas">- <button data-v="webview">Webview</button>- <button data-v="pup">Pup</button>- <button data-v="default">Follow default</button>- </div>- </div> <div class="pref-row"> <div class="pref-info"> <div class="pref-name">Auto-capture what AIs read</div>@@ -425,6 +413,15 @@ header { <button data-v="pup">Pup</button> </div> </div>+ <div class="pref-row">+ <div class="pref-info">+ <div class="pref-name">Webview panel width</div>+ <div class="pref-desc">How wide the slim webview panel is when shotlog opens it, as a percent of your workspace (10-50).</div>+ <div class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</div>+ </div>+ <input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"+ data-tooltip="Percent of the workspace width for the skinny shotlog panel (10-50). Applies to newly opened/auto-opened panels.">+ </div> <div class="pref-row"> <div class="pref-info"> <div class="pref-name">Keep per channel</div>@@ -901,6 +898,7 @@ header { hook.setAttribute('aria-checked', String(!!p.read_hook)); $('prefAlerts').setAttribute('aria-checked', String(p.update_alerts!==false)); seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));+ $('prefWidth').value = p.webview_width ?? 20; $('prefMaxShots').value = p.max_shots ?? 200; $('prefRetention').value = p.retention_days ?? 30; }@@ -930,27 +928,11 @@ header { el.addEventListener('change', commit); el.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); commit(); el.blur(); } }); }+ wireNum('prefWidth','webview_width', v=>'webview panel width is now '+v+'% for new opens.'); wireNum('prefMaxShots','max_shots', v=>'keeping the newest '+v+' shots per channel.'); wireNum('prefRetention','retention_days', v=> v===0?'idle channels are never pruned.':'idle channels prune after '+v+' days.');- const chSeg=$('prefChCanvas');- $('prefChName').textContent=channel;- function renderChPref(v){ // v = "webview" | "pup" | null (follow default)- chSeg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===(v||'default')));- }- chSeg.addEventListener('click', async e=>{- const b=e.target.closest('button[data-v]'); if(!b) return;- try{- const r=await fetch('api/canvas-pref',{method:'POST',headers:{'Content-Type':'application/json'},- body:JSON.stringify({channel, canvas:b.dataset.v})}).then(x=>x.json());- if(r.ok){ renderChPref(b.dataset.v==='default'?null:b.dataset.v);- showToast(b.dataset.v==='default'?'this channel now follows the global default.':'this channel is now a '+b.dataset.v+' channel.', 4000); }- else showToast('could not save: '+(r.error||'unknown'), 6000);- }catch(err){ showToast('could not save: '+err.message, 6000); }- }); $('prefsBtn').addEventListener('click', async ()=>{ try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}- try{ const st=await fetch('api/viewer-status?channel='+encodeURIComponent(channel)).then(r=>r.json());- renderChPref(st.canvas_pref||null); }catch{} dlg.classList.add('open'); }); })();
src/main.rs+16−7@@ -185,9 +185,10 @@ enum Commands { /// their tabs in the same panel (they auto-switch/blink on update). #[arg(long)] skinny: bool,- /// Width of the skinny panel as a fraction of the workspace (default 0.2).- #[arg(long, default_value_t = 0.2)]- ratio: f64,+ /// Width of the skinny panel as a fraction of the workspace. Default+ /// comes from the broker's webview_width preference (20% unless changed).+ #[arg(long)]+ ratio: Option<f64>, /// Open in the FULL-BROWSER pup canvas instead: one shared pup window /// ('shotlog') with a tab per channel. Real zoom at near-full screen, /// and the broker flashes the window's taskbar orange on every update.@@ -412,13 +413,21 @@ async fn main() { Commands::Open { channel, name, icon, port, skinny, ratio, pup, swap, } => {- // Opening a canvas IS choosing it - tell the broker so force-open- // (hook injects during presence blips) lands on the right canvas.- let _ = http_post(port, "/api/canvas-pref",- &serde_json::json!({"channel": channel, "canvas": if pup {"pup"} else {"webview"}}));+ // NOTE: a CLI open does NOT record a canvas choice. Only the human's+ // explicit clicks in the viewer (canvas buttons) set the in-memory+ // note; everything else follows the global default. AI threads+ // opening channels must never masquerade as user preference. if pup { open::open_pup(&channel, port, swap); } else {+ // Resolve the panel width: explicit --ratio wins, else the+ // broker's webview_width pref, else 20%.+ let ratio = ratio.unwrap_or_else(|| {+ http_get(port, "/api/prefs").ok()+ .and_then(|v| v.get("webview_width").and_then(|w| w.as_u64()))+ .map(|w| (w as f64 / 100.0).clamp(0.10, 0.50))+ .unwrap_or(0.2)+ }); open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, skinny, ratio); if swap { if open::close_pup_tab(&channel) {
src/server.rs+29−11@@ -270,7 +270,10 @@ async fn set_canvas_pref( return Json(serde_json::json!({"ok": false, "error": "channel required"})); } match req.canvas.as_str() {- "pup" | "webview" => state.store.set_canvas_pref(&req.channel, &req.canvas).await,+ "pup" | "webview" => {+ println!("{} DECISION canvas note '{}'={} (explicit, in-memory)", ts(), req.channel, req.canvas);+ state.store.set_canvas_pref(&req.channel, &req.canvas).await+ }, // "default" = forget the channel's own choice; follow the global default. "default" => state.store.clear_canvas_pref(&req.channel).await, _ => return Json(serde_json::json!({"ok": false, "error": "canvas must be pup|webview|default"})),@@ -289,6 +292,7 @@ struct PrefsPatch { max_shots: Option<usize>, retention_days: Option<u64>, update_alerts: Option<bool>,+ webview_width: Option<u8>, } /// Patch global preferences from the viewer's Preferences dialog.@@ -315,6 +319,12 @@ async fn set_prefs( if let Some(a) = patch.update_alerts { p.update_alerts = a; }+ if let Some(w) = patch.webview_width {+ if !(10..=50).contains(&w) {+ return Json(serde_json::json!({"ok": false, "error": "webview_width must be 10-50 (percent)"}));+ }+ p.webview_width = w;+ } if let Some(d) = patch.retention_days { if d > 3650 { return Json(serde_json::json!({"ok": false, "error": "retention_days must be 0-3650 (0 = never)"}));@@ -510,6 +520,12 @@ The description becomes the filename -- say what you are looking at and why. ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body) } +/// Timestamp prefix for decision-log lines (the broker's stdout is the audit+/// trail - deploys APPEND to it, so keep every line greppable + timestamped).+fn ts() -> String {+ chrono::Local::now().format("%m-%d %H:%M:%S").to_string()+}+ /// Progressive-disclosure gate: a teaching hint is shown at most `max` times per /// broker lifetime, then goes quiet. The broker is the long-lived stateful side /// (CLI processes are one-shot), so the counters live here.@@ -553,7 +569,7 @@ fn debounce_ok(key: &str, secs: u64) -> bool { /// hinted to flash the pup taskbar. /// - **nobody** watching → force-open a slim ~20% webview, unless /// `SHOTLOG_NO_FORCE_OPEN` is set.-fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool) {+fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool, ratio: f64) { // Pup viewers get an automatic ORANGE TASKBAR FLASH (FlashWindowEx) on their // hosting window — a gentle "update" cue, never a foreground steal. Works when // the viewer URL carried ?session=<pup sessionId> (reported in the WS hello).@@ -576,10 +592,11 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], if infos.is_empty() && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() { // Honor the channel's chosen canvas (falling back to the global default): // a pup channel whose viewer blipped must NOT get a webview shoved at it.+ println!("{} DECISION force-open '{channel}' canvas={pref} (nobody watching)", ts()); if pref == "pup" {- force_open_pup(channel);+ force_open_pup(channel, ratio); } else {- force_open_viewer(channel, port);+ force_open_viewer(channel, port, ratio); } } }@@ -587,7 +604,7 @@ fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], /// Force-open the PUP canvas for a channel (its chosen canvas) when a shot /// lands with nobody watching. Runs via a self-subprocess (open_pup can exit). /// If pup genuinely cannot open, falls back to a webview - never invisible.-fn force_open_pup(channel: &str) {+fn force_open_pup(channel: &str, fallback_ratio: f64) { // Debounce longer than the whole retry window so attempts never overlap. if !debounce_ok(&format!("openpup:{channel}"), 240) { return;@@ -617,8 +634,8 @@ fn force_open_pup(channel: &str) { std::thread::sleep(std::time::Duration::from_secs(wait_s)); } }- println!("WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (channel pref unchanged).");- crate::open::open_webview(&channel, None, None, 8820, true, 0.2);+ println!("{} WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (no choice recorded).", ts());+ crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio); }); } @@ -688,7 +705,7 @@ fn bring_webview_forward(channel: &str, port: u16, alerts: bool) { /// ~20%-wide right-side panel so cross-thread shots auto-appear without taking /// over the workspace — and CRUCIALLY passes a --panel-id (via open_webview) so /// the tab is actually CREATED, not silently skipped.-fn force_open_viewer(channel: &str, port: u16) {+fn force_open_viewer(channel: &str, port: u16, ratio: f64) { if std::env::var("VSCODE_PROXY_URI").is_err() { return; // not in an Adom container; nothing to open }@@ -698,7 +715,7 @@ fn force_open_viewer(channel: &str, port: u16) { let channel = channel.to_string(); // Best-effort; don't block the inject response on the adom-cli round-trip. std::thread::spawn(move || {- crate::open::open_webview(&channel, None, None, port, true, 0.2);+ crate::open::open_webview(&channel, None, None, port, true, ratio); }); } @@ -853,7 +870,8 @@ async fn post_shots( "layout": if canvas_pref=="pup" {"tab in the shared pup window"} else {"slim 20% right-side webview"}})); } let surfacing = Some(serde_json::json!({"actions": actions}));- notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on);+ let ratio = (app_prefs.webview_width as f64 / 100.0).clamp(0.10, 0.50);+ notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on, ratio); // Progressive mini-skill hints — taught at the moment they're relevant, then // quiet (hint_gate caps how many times each lesson repeats).@@ -1220,7 +1238,7 @@ async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppSta .unwrap_or(None); if verdict == Some(true) && !store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") { store.set_canvas_pref(&ch, "webview").await;- println!("OK: pup tab for '{ch}' was closed by the user - canvas preference reverted to webview (next auto-open uses the slim panel).");+ println!("{} DECISION pup tab for '{ch}' closed by the user - in-memory note set to webview until restart.", ts()); } }); }
src/store.rs+16−21@@ -101,15 +101,19 @@ pub struct AppPrefs { /// the pup taskbar flash. Off = still pulls the tab forward, just no flash. #[serde(default = "default_true")] pub update_alerts: bool,+ /// Width of the skinny webview panel as a percent of the workspace (10-50).+ #[serde(default = "default_webview_width")]+ pub webview_width: u8, } fn default_canvas() -> String { "webview".into() } fn default_true() -> bool { true } fn default_max_shots() -> usize { 200 } fn default_retention_days() -> u64 { 30 }+fn default_webview_width() -> u8 { 20 } impl Default for AppPrefs { fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true, max_shots: default_max_shots(), retention_days: default_retention_days(),- update_alerts: true } }+ update_alerts: true, webview_width: default_webview_width() } } } /// The flag file the read-hook script checks; present = hook disabled.@@ -149,10 +153,11 @@ impl Store { fs::create_dir_all(&base_dir).ok(); // Canonicalize so all paths are absolute let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);- let prefs: HashMap<String, String> = fs::read_to_string(base_dir.join(".canvas-prefs.json"))- .ok()- .and_then(|s| serde_json::from_str(&s).ok())- .unwrap_or_default();+ // Canvas notes are IN-MEMORY ONLY (user's explicit clicks this session);+ // every channel otherwise follows the global default. Remove the legacy+ // persisted file so old recorded "choices" can never resurrect.+ let _ = fs::remove_file(base_dir.join(".canvas-prefs.json"));+ let prefs: HashMap<String, String> = HashMap::new(); let app_prefs: AppPrefs = fs::read_to_string(base_dir.join(".prefs.json")) .ok() .and_then(|s| serde_json::from_str(&s).ok())@@ -529,26 +534,16 @@ impl Store { &self.base_dir } - /// Record the user's chosen canvas for a channel; persisted across restarts.+ /// Note the user's explicit canvas CLICK for a channel - IN MEMORY ONLY.+ /// Never persisted: a broker restart (or user reboot) forgets all notes and+ /// every channel returns to the global default. pub async fn set_canvas_pref(&self, channel: &str, canvas: &str) {- let mut m = self.canvas_prefs.write().await;- if m.get(channel).map(|c| c == canvas).unwrap_or(false) {- return;- }- m.insert(channel.to_string(), canvas.to_string());- if let Ok(j) = serde_json::to_string_pretty(&*m) {- let _ = fs::write(self.base_dir.join(".canvas-prefs.json"), j);- }+ self.canvas_prefs.write().await.insert(channel.to_string(), canvas.to_string()); } - /// Clear a channel's canvas choice so it follows the global default again.+ /// Forget a channel's note so it follows the global default again. pub async fn clear_canvas_pref(&self, channel: &str) {- let mut m = self.canvas_prefs.write().await;- if m.remove(channel).is_some() {- if let Ok(j) = serde_json::to_string_pretty(&*m) {- let _ = fs::write(self.base_dir.join(".canvas-prefs.json"), j);- }- }+ self.canvas_prefs.write().await.remove(channel); } pub async fn canvas_pref(&self, channel: &str) -> Option<String> {