← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-shotlog"-version = "0.23.4"+version = "0.23.5" dependencies = [  "axum",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.23.4"+version = "0.23.5" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
VERSION+1−1
@@ -1 +1 @@-0.23.4+0.23.5
bin/adom-shotlog
⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+8
@@ -255,3 +255,11 @@ explicit PID from ps, never by -f pattern. Also: any deploy-restart window can be resurrected mid-window by a hook self-heal running the OLD recovery state — after migrating on-disk data, verify the SERVING process started AFTER the migration (check prod-srv.log recovery line), not just that the port answers.++## Header rule REVISED (John, 2026-07-18)+Supersedes the "always ONE 44px row" skinny rule. Wide: still ONE 44px row.+Skinny (<=520px): EXACTLY TWO rows - row 1 = logo + full channel name + count,+row 2 = all actions right-aligned. Reason: zone-shedding on one row crushed the+channel name to 2-3 chars once the action cluster grew (canvas + top + filter ++prefs + clear + LED). John: "in skinny view you probably need to go to two+lines in header". Never more than two rows, and never shed the channel name.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.23.4",+  "version": "0.23.5",   "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.23.4",+  "version": "0.23.5",   "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+63−12
@@ -6,6 +6,10 @@ <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">+<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">+<link rel="shortcut icon" href="favicon.ico">+<link rel="apple-touch-icon" href="favicon-192.png"> <title>adom-shotlog</title> <style> @font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }@@ -84,19 +88,17 @@ header { .subject-meta { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px;   line-height: 1; white-space: nowrap; flex-shrink: 0; } .subject-meta:not(:empty)::before { content: '·'; color: var(--text3); margin-right: 7px; }-/* 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. */+/* adom-app-header, REVISED for skinny (John, 2026-07-18): wide stays ONE 44px+   row, but a skinny ~360px panel goes to EXACTLY TWO rows — shedding zones on+   one row crushed the channel name to 2-3 chars once the action cluster grew.+   Row 1: logo + full channel name + count. Row 2: all actions, right-aligned+   (with room back for the canvas button's label). Never more than two rows. */ @media (max-width: 520px) {-  header { gap: 8px; padding: 0 10px; }+  header { gap: 4px 8px; padding: 5px 10px 6px; height: auto; min-height: 44px; flex-wrap: wrap; }   .brand .app-name, .brand .sep, .brand .app-subtitle { display: none; } /* logo carries identity */   .vsep { display: none; }-  .subject-meta { display: none; }-  .actions { gap: 5px; }-  #canvasBtn span { display: none; } /* actions go icon-only when tight (canonical: icon-buttons) */-  #canvasBtn.busy span { display: inline; } /* progress text ALWAYS visible while switching */+  .subject { flex: 1; min-width: 0; }   /* the name gets the whole rest of row 1 */+  .actions { flex-basis: 100%; justify-content: flex-end; gap: 5px; } } /* Header action buttons + LED (adom-app-header). These were the raw-<button>    ugliness: clean pills, subtle until hover, icon+label capable. */@@ -171,6 +173,12 @@ header {   overflow: hidden; transition: border-color .15s; cursor: zoom-in;   display: flex; flex-direction: column; flex-shrink: 0; } /* never compress in the flex-column feed */ .shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); }+/* WHY-headline for auto-captures: the prompt IS the title (same gutter as a+   description), the filename demoted to a small mono sub-line under it. */+.shot-card-why { padding: 10px 13px 0; font: 500 13.5px/1.45 'Satoshi', sans-serif; color: var(--text); overflow-wrap: anywhere; }+.shot-card-why .q { color: var(--accent); font-weight: 700; }+.shot-card-sub { padding: 2px 13px 0; font-family: 'JetBrains Mono', monospace; font-size: 10px;+  color: var(--text3); overflow-wrap: anywhere; } .shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;   font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; } .shot-card-meta .thr { color: var(--purple); font-style: italic; }@@ -274,6 +282,19 @@ header { .modal-card .nerd pre { margin: 8px 0 0; padding: 8px; background: #0c0f12; border-radius: 6px; max-height: 220px;   overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; } +/* Canvas-move countdown: floating bottom-center card, NON-modal (the page+   stays fully usable behind it), clamped to the viewport, always cancelable. */+.cdown { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 210;+  background: var(--elevated); border: 1px solid rgba(0,184,176,.5); border-radius: 12px;+  padding: 12px 16px; display: flex; align-items: center; gap: 12px;+  max-width: min(460px, calc(100vw - 24px)); box-shadow: 0 8px 30px rgba(0,0,0,.5);+  font: 13px/1.45 'Satoshi', sans-serif; color: var(--text); }+.cdown .cd-n { display: inline-block; min-width: 1.2em; text-align: center; color: var(--accent);+  font: 700 18px/1 'JetBrains Mono', monospace; }+.cdown .cd-cancel { flex-shrink: 0; background: none; border: 1px solid var(--border); border-radius: 8px;+  color: var(--text2); padding: 6px 10px; cursor: pointer; font: 600 11px 'Satoshi', sans-serif; }+.cdown .cd-cancel:hover { color: var(--text); border-color: var(--accent); }+ /* Toast */ /* Toast: centered, CLAMPED to the viewport so long messages wrap instead of    spilling off both edges (the old white-space:nowrap bug). */@@ -460,7 +481,7 @@ header {     <div class="pref-row">       <div class="pref-info">         <div class="pref-name">Default canvas</div>-        <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Closing a pup window reverts that channel to webview. Pup needs Adom Desktop.</div>+        <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Closing a pup window reverts that channel to webview. Pup needs Adom Desktop. Pick the other canvas while you're watching and closing this dialog moves your shotlog there too, after a 3-2-1 countdown you can cancel.</div>         <div class="ai-chip"><span class="lbl">or say to your AI</span>make shotlog default to pup</div>       </div>       <div class="seg" id="prefCanvas">@@ -1180,7 +1201,35 @@ header {   // ── Preferences dialog: read-hook toggle + default canvas ─────────────────   (function wirePrefs(){     const dlg=$('prefsDlg'), hook=$('prefHook'), seg=$('prefCanvas'); if(!dlg) return;-    const close=()=>dlg.classList.remove('open');+    // If the human changes the default canvas AWAY from the surface they're+    // looking at right now, closing the dialog offers to move this shotlog+    // there: a floating (never modal) 3-2-1 countdown card with a cancel,+    // then the SAME fully-tracked switch the header button runs. Works in+    // both directions (pup -> webview and webview -> pup).+    let pendingMove=null;+    function maybeCountdownMove(){+      if(!pendingMove) return;+      const dest=pendingMove; pendingMove=null;+      const surf=detectSurface();+      if(dest===surf || (surf!=='pup'&&surf!=='webview')) return;+      const btn=$('canvasBtn');+      if(!btn || btn.style.display==='none' || btn.disabled) return;+      fetch('api/log',{method:'POST',headers:{'Content-Type':'application/json'},+        body:JSON.stringify({line:'UI prefs default-canvas countdown: '+surf+' -> '+dest})}).catch(()=>{});+      const card=document.createElement('div'); card.className='cdown';+      const msg=document.createElement('div'); msg.className='cd-msg';+      msg.innerHTML='You switched to <b>'+esc(dest)+'</b> from '+esc(surf)+'. I will now move your shotlog to '+esc(dest)+' in <span class="cd-n">3</span>';+      const cancel=document.createElement('button'); cancel.className='cd-cancel'; cancel.textContent='stay in '+surf;+      card.appendChild(msg); card.appendChild(cancel); document.body.appendChild(card);+      let n=3, done=false, iv=0;+      const stop=(m)=>{ if(done) return; done=true; clearInterval(iv); card.remove(); if(m) showToast(m,5000); };+      cancel.addEventListener('click',()=>stop('staying in '+surf+' - the new default applies to future opens.'));+      iv=setInterval(()=>{ n--;+        if(n>0){ card.querySelector('.cd-n').textContent=String(n); return; }+        stop(null); btn.click();+      },1000);+    }+    const close=()=>{ dlg.classList.remove('open'); maybeCountdownMove(); };     $('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(); });@@ -1216,6 +1265,8 @@ header {     seg.addEventListener('click',e=>{       const b=e.target.closest('button[data-v]'); if(!b) return;       patch({default_canvas: b.dataset.v}, 'channels now default to '+b.dataset.v+'.');+      const surf=detectSurface();+      pendingMove=((surf==='pup'||surf==='webview') && b.dataset.v!==surf) ? b.dataset.v : null;     });     function wireNum(id, key, msg){       const el=$(id);
src/icons/favicon-192.pngadded
⋯ 1 unchanged line ⋯
src/icons/favicon-32.pngadded
⋯ 1 unchanged line ⋯
src/icons/favicon.icoadded
⋯ 1 unchanged line ⋯
src/server.rs+34
@@ -14,6 +14,11 @@ use crate::store::Store;  const INDEX_HTML: &str = include_str!("html/index.html"); const ICON_SVG: &str = include_str!("html/icon.svg");+// Raster favicons: Chrome ignores SVG favicons for app-mode windows, pinned+// tabs, and the OS taskbar (what pup windows show) — those need PNG/ICO.+const ICON_PNG_32: &[u8] = include_bytes!("icons/favicon-32.png");+const ICON_PNG_192: &[u8] = include_bytes!("icons/favicon-192.png");+const ICON_ICO: &[u8] = include_bytes!("icons/favicon.ico");  /// Turn a description into a safe filename: lowercase, hyphens, max 60 chars fn slugify_filename(desc: &str) -> String {@@ -72,7 +77,22 @@ pub fn create_router(state: Arc<AppState>) -> Router {         .route("/log/{channel}/", get(serve_viewer))         .route("/ws/_all", get(ws_all_handler))         .route("/favicon.svg", get(serve_favicon))+        // The single-window app lives at /log/ — without this exact route the+        // page's relative favicon.svg 308s into the channel handler and the+        // browser gets HTML instead of an icon (= no favicon at all).+        .route("/log/favicon.svg", get(serve_favicon))         .route("/log/{channel}/favicon.svg", get(serve_favicon))+        // Raster icons at every page level (hrefs are relative so they work+        // through the auth proxy, which owns the URL root).+        .route("/favicon-32.png", get(serve_favicon_png32))+        .route("/log/favicon-32.png", get(serve_favicon_png32))+        .route("/log/{channel}/favicon-32.png", get(serve_favicon_png32))+        .route("/favicon-192.png", get(serve_favicon_png192))+        .route("/log/favicon-192.png", get(serve_favicon_png192))+        .route("/log/{channel}/favicon-192.png", get(serve_favicon_png192))+        .route("/favicon.ico", get(serve_favicon_ico))+        .route("/log/favicon.ico", get(serve_favicon_ico))+        .route("/log/{channel}/favicon.ico", get(serve_favicon_ico))         .route("/api/shots", get(get_shots))         .route("/api/shots", post(post_shots))         .route("/api/viewer-status", get(viewer_status))@@ -108,6 +128,20 @@ async fn log_redirect(Path(channel): Path<String>) -> Redirect {     Redirect::permanent(&format!("/log/{channel}/")) } +fn icon_response(bytes: &'static [u8], mime: &'static str) -> Response {+    let mut res = bytes.into_response();+    res.headers_mut()+        .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));+    res.headers_mut().insert(+        header::CACHE_CONTROL,+        HeaderValue::from_static("public, max-age=86400"),+    );+    res+}+async fn serve_favicon_png32() -> Response { icon_response(ICON_PNG_32, "image/png") }+async fn serve_favicon_png192() -> Response { icon_response(ICON_PNG_192, "image/png") }+async fn serve_favicon_ico() -> Response { icon_response(ICON_ICO, "image/x-icon") }+ async fn serve_favicon() -> Response {     let mut res = ICON_SVG.to_string().into_response();     res.headers_mut().insert(