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.23.5
12 files changed
+110−17
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(