← Commit history
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> {