← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.25.1"+version = "0.25.2" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.25.1"+version = "0.25.2" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.25.1+0.25.2
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+10
@@ -381,3 +381,13 @@ Hydrogen·self). Kept INTERNAL source keys (ad-window/abe/hd) so existing shots still filter; only labels changed. The read-hook path match also updated: screenshots/adom-desktop/ -> adom-bridge/. "Preferences" dialog -> "Settings" (ah calls it that). Env var SHOTLOG_AD_TARGET kept (internal, not user-facing).++## Settings steppers + on/off toggles (0.25.2, John)+Number inputs got −/+ steppers (.num-step buttons, data-target + data-dir;+delegated click adjusts by the input's step, clamps min/max, dispatches a+change event to reuse wireNum's commit). Steps: width 5, keep 25, prune 5.+Keep-per-channel and prune-idle got on/off switches: OFF stores 0, ON restores+the remembered last value (lastKeep/lastPrune in JS). max_shots=0 now means+UNLIMITED - the janitor guards `cap > 0` at BOTH enforcement points (recovery+trim + add_entry trim) and the prefs handler accepts 0 or 10-5000. retention=0+already meant "never". The .pref-control.off class greys + disables the field.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.25.1",+  "version": "0.25.2",   "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.25.1",+  "version": "0.25.2",   "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+70−11
@@ -448,13 +448,26 @@ header {    the human guessing what they're changing. */ .num-wrap { display: inline-flex; align-items: center; gap: 6px; height: 30px;   background: var(--overlay); border: 1px solid var(--border); border-radius: 7px;-  padding: 0 10px; flex-shrink: 0; }+  padding: 0 4px 0 10px; flex-shrink: 0; } .num-wrap:focus-within { border-color: rgba(0,184,176,.6); } .num-wrap .num-input { -webkit-appearance: none; appearance: textfield; width: 46px;   background: transparent; border: none; outline: none; color: var(--text);   font: 600 13px 'JetBrains Mono', monospace; text-align: right; } .num-wrap .num-input::-webkit-inner-spin-button, .num-wrap .num-input::-webkit-outer-spin-button { -webkit-appearance: none; } .num-wrap .unit { color: var(--text3); font: 500 11px 'Satoshi', sans-serif; }+/* Up/down stepper buttons on number inputs */+.num-wrap .num-step { -webkit-appearance: none; appearance: none; flex-shrink: 0;+  width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center;+  background: var(--elevated); color: var(--text2); border: 1px solid var(--border);+  border-radius: 5px; cursor: pointer; font: 600 15px/1 'Satoshi', sans-serif; padding: 0;+  transition: background .12s, color .12s, border-color .12s; }+.num-wrap .num-step:first-child { margin-right: 2px; }+.num-wrap .num-step:hover { background: rgba(0,184,176,.16); color: var(--accent-bright); border-color: rgba(0,184,176,.5); }+.num-wrap .num-step:active { transform: translateY(.5px); }+/* A setting that pairs an on/off switch with its number field */+.pref-control { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; }+/* When the switch is OFF, grey + disable the number field */+.pref-control.off .num-wrap { opacity: .4; pointer-events: none; } .ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;   letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; } @@ -582,28 +595,40 @@ header {         <div class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</div>       </div>       <span class="num-wrap" data-tooltip="Percent of the workspace width for the skinny shotlog panel (10-50). Seeds a new panel; only re-applied on every open when auto-size is ON.">+        <button class="num-step" data-target="prefWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>         <input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span>+        <button class="num-step" data-target="prefWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button>       </span>     </div>     <div class="pref-row">       <div class="pref-info">         <div class="pref-name">Keep per channel</div>-        <div class="pref-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Auto-capture channels like read-captures never grow unbounded.</div>+        <div class="pref-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Turn the switch OFF to keep every shot (unlimited on disk).</div>         <div class="ai-chip"><span class="lbl">or say to your AI</span>keep only the newest 100 shots per shotlog channel</div>       </div>-      <span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Applies as new shots arrive.">-        <input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="10"><span class="unit">shots</span>-      </span>+      <div class="pref-control">+        <button class="toggle" id="prefKeepToggle" role="switch" aria-checked="true" data-tooltip="On: cap the shots kept per channel. Off: keep everything (unlimited)."></button>+        <span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Turn the switch off to keep unlimited.">+          <button class="num-step" data-target="prefMaxShots" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>+          <input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="25"><span class="unit">shots</span>+          <button class="num-step" data-target="prefMaxShots" data-dir="1" aria-label="increase" tabindex="-1">+</button>+        </span>+      </div>     </div>     <div class="pref-row">       <div class="pref-info">         <div class="pref-name">Prune idle channels after (days)</div>-        <div class="pref-desc">Channels with no new shots for this many days are deleted entirely (checked at startup and every 6 hours). 0 means never prune.</div>+        <div class="pref-desc">Channels with no new shots for this many days are deleted entirely (checked at startup and every 6 hours). Turn the switch OFF to never prune.</div>         <div class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</div>       </div>-      <span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. 0 = never.">-        <input class="num-input" id="prefRetention" type="number" min="0" max="3650" step="1"><span class="unit">days</span>-      </span>+      <div class="pref-control">+        <button class="toggle" id="prefPruneToggle" role="switch" aria-checked="true" data-tooltip="On: prune idle channels after N days. Off: never prune."></button>+        <span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. Turn the switch off to never prune.">+          <button class="num-step" data-target="prefRetention" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>+          <input class="num-input" id="prefRetention" type="number" min="1" max="3650" step="5"><span class="unit">days</span>+          <button class="num-step" data-target="prefRetention" data-dir="1" aria-label="increase" tabindex="-1">+</button>+        </span>+      </div>     </div>   </div> </div>@@ -1403,6 +1428,9 @@ header {     $('prefsClose').addEventListener('click',close);     dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });     document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });+    // Remember the last positive value so toggling a setting OFF (which stores 0)+    // then back ON restores what the human had, not a bare default.+    let lastKeep=200, lastPrune=30;     function renderPrefs(p){       hook.setAttribute('aria-checked', String(!!p.read_hook));       $('prefAlerts').setAttribute('aria-checked', String(p.update_alerts!==false));@@ -1411,8 +1439,16 @@ header {       APREFS=p;       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;+      // Keep per channel: max_shots 0 = OFF (unlimited).+      const mk = p.max_shots ?? 200; if(mk>0) lastKeep=mk;+      $('prefKeepToggle').setAttribute('aria-checked', String(mk>0));+      $('prefMaxShots').closest('.pref-control').classList.toggle('off', mk===0);+      $('prefMaxShots').value = mk>0 ? mk : lastKeep;+      // Prune idle: retention_days 0 = OFF (never).+      const rd = p.retention_days ?? 30; if(rd>0) lastPrune=rd;+      $('prefPruneToggle').setAttribute('aria-checked', String(rd>0));+      $('prefRetention').closest('.pref-control').classList.toggle('off', rd===0);+      $('prefRetention').value = rd>0 ? rd : lastPrune;     }     async function patch(body, okMsg){       try{@@ -1453,6 +1489,29 @@ header {     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.');+    // Up/down steppers: adjust the target input by its step, clamp, commit.+    dlg.addEventListener('click', e=>{+      const b=e.target.closest('.num-step'); if(!b) return;+      const el=$(b.dataset.target); if(!el) return;+      if(el.closest('.pref-control')?.classList.contains('off')) return;   // disabled while switch is off+      const step=parseInt(el.step,10)||1, dir=parseInt(b.dataset.dir,10)||0;+      const min=parseInt(el.min,10), max=parseInt(el.max,10);+      let v=(parseInt(el.value,10)||min)+dir*step;+      v=Math.max(min, Math.min(max, v));+      if(String(v)!==el.value){ el.value=v; el.dispatchEvent(new Event('change')); }+    });+    // Keep-per-channel switch: OFF => max_shots 0 (unlimited); ON => restore last.+    $('prefKeepToggle').addEventListener('click',()=>{+      const on=$('prefKeepToggle').getAttribute('aria-checked')==='true';+      if(on) patch({max_shots:0}, 'keep-per-channel OFF: keeping every shot (unlimited on disk).');+      else patch({max_shots:lastKeep}, 'keep-per-channel ON: newest '+lastKeep+' shots per channel.');+    });+    // Prune-idle switch: OFF => retention_days 0 (never); ON => restore last.+    $('prefPruneToggle').addEventListener('click',()=>{+      const on=$('prefPruneToggle').getAttribute('aria-checked')==='true';+      if(on) patch({retention_days:0}, 'prune OFF: idle channels are never deleted.');+      else patch({retention_days:lastPrune}, 'prune ON: idle channels prune after '+lastPrune+' days.');+    });     $('prefsBtn').addEventListener('click', async ()=>{       try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}       dlg.classList.add('open');
src/server.rs+3−2
@@ -551,8 +551,9 @@ async fn set_prefs(         p.read_hook = h;     }     if let Some(n) = patch.max_shots {-        if !(10..=5000).contains(&n) {-            return Json(serde_json::json!({"ok": false, "error": "max_shots must be 10-5000"}));+        // 0 = UNLIMITED (keep-per-channel toggle off); otherwise 10-5000.+        if n != 0 && !(10..=5000).contains(&n) {+            return Json(serde_json::json!({"ok": false, "error": "max_shots must be 0 (unlimited) or 10-5000"}));         }         p.max_shots = n;     }
src/store.rs+4−2
@@ -322,7 +322,8 @@ impl Store {             // Keep only the newest max_shots() - and DELETE the older shots'             // files, which previously lingered on disk forever.             let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200);-            if entries.len() > cap {+            // cap == 0 means UNLIMITED (the "keep per channel" toggle turned off) - never trim.+            if cap > 0 && entries.len() > cap {                 let trimmed: Vec<Entry> = entries.drain(..entries.len() - cap).collect();                 for e in &trimmed { delete_entry_files(e); }             }@@ -437,7 +438,8 @@ impl Store {             .unwrap_or_else(|| { // prefs read is async; channels lock is held - use try_read                 self.app_prefs.try_read().map(|p| p.max_shots).unwrap_or(200)             });-        while ch.entries.len() > cap {+        // cap == 0 means UNLIMITED (keep-per-channel toggle off) - never trim.+        while cap > 0 && ch.entries.len() > cap {             let old = ch.entries.remove(0);             delete_entry_files(&old);         }