app
Adom Shotlog
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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
<!doctype html>
<html lang="en" style="background:#0d1117">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<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; }
@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }
@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
--bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;
--border: #30363d; --border-muted: #21262d;
--text: #e6edf3; --text2: #8b949e; --text3: #484f58;
--accent: #00b8b0; --accent-bright: #00e6dc; --purple: #8C6BF7; --danger: #f85149;
}
*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* The OUTERMOST html/body are NEVER scrollers — no bars, either axis, ever.
The .feed is the one designated scroller (modal overlays scroll themselves).
overflow:hidden also kills the phantom 1px outer bar that fractional-DPR
(e.g. 1.5) rounding of 100dvh otherwise creates. */
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);
font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif;
overflow: hidden; max-width: 100%; }
body { display: flex; flex-direction: column; height: 100dvh; width: 100%; }
img { max-width: 100%; }
/* Pretty scrollbars everywhere (thin, rounded, themed; only where needed). */
* { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(139,148,158,.32); border-radius: 999px;
border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,184,176,.55); background-clip: padding-box; }
::-webkit-scrollbar-corner { background: transparent; }
/* Global tooltip — body-appended fixed (human-ui-patterns §1d) */
#global-tooltip {
position: fixed; top: 0; left: 0; transform: translate(0,0);
background: rgba(22,27,34,.97); backdrop-filter: blur(12px);
border: 1px solid rgba(0,184,176,.25); border-radius: 7px; padding: 6px 10px;
font: 12px/1.45 'Satoshi', sans-serif; color: var(--text);
max-width: min(320px, calc(100vw - 16px)); /* fit a skinny ~360px panel, never overflow */
max-height: calc(100dvh - 16px); overflow: hidden; /* never taller than the viewport */
white-space: pre-wrap; box-shadow: 0 8px 28px rgba(0,0,0,.55);
z-index: 99999; pointer-events: none; opacity: 0; display: none; transition: opacity .18s;
}
#global-tooltip.visible { display: block; } #global-tooltip.shown { opacity: 1; }
/* Structured (rich) tooltip content — title / body / requirement / prompt chip. */
#global-tooltip .tt-title { font: 600 12.5px 'Familjen Grotesk', sans-serif; color: var(--text); }
#global-tooltip .tt-p { margin-top: 6px; color: var(--text2); }
#global-tooltip .tt-req { margin-top: 7px; color: var(--text3); font-size: 11px; }
#global-tooltip .tt-count { margin-top: 8px; color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
#global-tooltip .tt-prompt { margin-top: 9px; padding: 7px 9px; border-radius: 7px;
background: rgba(0,184,176,.10); border: 1px solid rgba(0,184,176,.28);
color: var(--accent-bright); font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.45; }
#global-tooltip .tt-prompt .lbl { display: block; color: var(--text3); font-family: 'Satoshi', sans-serif;
font-size: 9.5px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 3px; }
/* Header (adom-app-header, 44px) */
header {
display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 44px;
flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);
position: relative;
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--accent);
font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;
cursor: default; flex-shrink: 0; outline: none; }
.brand:hover .app-name { text-decoration: underline; text-underline-offset: 3px; }
.brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; color: var(--text); } /* monochrome per brand */
.brand .sep { color: var(--text3); font-weight: 400; margin: 0 1px; }
.brand .app-subtitle { color: var(--text2); font-weight: 500; font-size: 12px; }
.vsep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
/* baseline (not center) so the smaller mono meta sits ON the name's baseline */
.subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; }
.subject-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600;
color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
/* 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: 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 { 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. */
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-shrink: 0; }
.hdr-btn { -webkit-appearance: none; appearance: none;
display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px;
background: var(--overlay); color: var(--text2); border: 1px solid var(--border);
border-radius: 7px; font: 500 12px/1 'Satoshi', sans-serif; cursor: pointer;
white-space: nowrap; flex-shrink: 0; transition: background .15s, color .15s, border-color .15s; }
.hdr-btn:hover { background: var(--elevated); color: var(--text); border-color: rgba(0,184,176,.45); }
.hdr-btn:active { transform: translateY(.5px); }
.hdr-btn:disabled { opacity: .55; cursor: default; transform: none; }
.hdr-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
.hdr-btn.icon-only { padding: 0; width: 26px; justify-content: center; }
/* Busy spinner for in-flight actions (canvas switch). */
@keyframes hdr-spin { to { transform: rotate(360deg); } }
.hdr-btn.busy svg.spin { animation: hdr-spin .8s linear infinite; }
.hdr-btn.busy { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }
/* Canvas toggle: the primary affordance, tinted with the accent. */
.hdr-btn.accent { color: var(--accent); border-color: rgba(0,184,176,.35); }
.hdr-btn.accent:hover { background: rgba(0,184,176,.12); color: var(--accent-bright); border-color: rgba(0,184,176,.6); }
/* Destructive: quiet until hover, then a two-step "arm" state (no browser modal). */
.hdr-btn.danger { color: var(--text2); }
.hdr-btn.danger:hover { color: var(--danger); background: rgba(248,81,73,.09); border-color: rgba(248,81,73,.45); }
.hdr-btn.danger.arm { color: #fff; background: var(--danger); border-color: var(--danger); }
.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; margin-left: 2px;
background: var(--text3); transition: background .4s, box-shadow .4s; }
.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }
/* App-info popover */
/* left:12 + right:12 with a max-width caps it inside a skinny ~360px panel —
never off the right edge (tooltips/popovers must never render off-screen). */
.hover-popover { position: absolute; top: calc(100% + 6px); left: 12px;
background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px;
min-width: 0; max-width: min(400px, calc(100vw - 24px)); width: max-content;
font: 12px 'Satoshi', sans-serif; color: var(--text); z-index: 99999;
opacity: 0; transform: translateY(-2px); pointer-events: none; transition: opacity 120ms, transform 120ms; }
.hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }
.hover-popover .pop-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.hover-popover .pop-row { display: flex; gap: 10px; padding: 2px 0; align-items: baseline; }
.hover-popover .pop-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; width: 56px; flex-shrink: 0; }
.hover-popover .pop-row .v { color: var(--text); font-size: 11px; flex: 1; }
.hover-popover .pop-actions { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-muted); }
.hover-popover .pop-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; }
/* In-app CHANNEL TAB STRIP: shotlog owns its tabs (one window, many channels).
read-captures pinned first; orange flash + unseen badge on updates. */
.chtabs { display: flex; align-items: center; gap: 2px; padding: 0 8px; height: 32px;
flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);
overflow-x: auto; overflow-y: hidden; }
.chtab { -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center;
gap: 6px; padding: 0 10px; height: 31px; border: none; background: transparent;
color: var(--text2); font: 500 12px 'Familjen Grotesk', sans-serif; cursor: pointer;
white-space: nowrap; border-bottom: 2px solid transparent; flex-shrink: 0; }
.chtab:hover { color: var(--text); }
.chtab.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.chtab .badge { min-width: 16px; height: 15px; padding: 0 4px; border-radius: 999px;
background: var(--overlay); color: var(--text2); display: none;
font: 600 9.5px 'JetBrains Mono', monospace; align-items: center; justify-content: center; }
.chtab.unseen .badge { display: inline-flex; background: rgba(255,140,0,.16);
color: #ffa657; border: 1px solid rgba(255,140,0,.4); }
@keyframes tabflash { 0%,100% { background: transparent; } 50% { background: rgba(255,140,0,.16); } }
.chtab.flash { animation: tabflash 1s ease-in-out 4; }
/* Gallery */
.feed { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; -webkit-overflow-scrolling: touch; }
.empty { text-align: center; color: var(--text3); padding: 48px 20px; font-size: 13px; line-height: 1.6; }
.empty code { color: var(--accent); font-family: 'JetBrains Mono', monospace; }
.shot-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
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-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; }
.chip { display: inline-block; padding: 1px 6px; border-radius: 5px; background: var(--overlay);
border: 1px solid var(--border-muted); font-family: 'JetBrains Mono', monospace;
font-size: 9.5px; color: var(--text2); cursor: help; }
.chip.ad { color: var(--accent); border-color: rgba(0,184,176,.3); }
.chip.auto { color: var(--text3); }
.fchip { -webkit-appearance: none; appearance: none; padding: 5px 11px; border-radius: 999px;
border: 1px solid var(--border); background: var(--overlay); color: var(--text2);
cursor: pointer; font: 600 11px 'Familjen Grotesk', sans-serif; }
.fchip.on { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); }
.frow { display: flex; flex-wrap: wrap; gap: 7px; padding: 8px 0 2px; }
.hdr-btn.filters-on { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }
.m-full-toggle { color: var(--accent); cursor: pointer; font-weight: 600; }
.m-full-toggle:hover { text-decoration: underline; }
.m-why { padding: 0 16px 8px; font: italic 12px/1.5 'Satoshi', sans-serif; color: var(--text2); }
.m-why .k { color: var(--text3); font-style: normal; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; margin-right: 6px; }
.shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }
.shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;
font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }
.shot-card-meta .ts { color: var(--text2); }
.shot-card-meta .sep { color: var(--border); }
.shot-card-meta .dim-main { color: var(--text2); cursor: help; }
.shot-card-meta .dim-orig { cursor: help; }
.shot-card-meta .dim-missing { color: var(--danger); font-weight: 600; cursor: help; }
.shot-card-meta .src { font-style: italic; }
.shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111;
object-fit: contain; object-position: center top; transition: opacity .15s; }
.shot-card:active .shot-card-img { opacity: .85; }
/* Child-window-array badge + nerd info chip on a card */
.shot-card-meta .badge-pop { background: rgba(0,184,176,.16); color: var(--accent); border: 1px solid rgba(0,184,176,.4);
border-radius: 999px; padding: 0 7px; font-weight: 600; cursor: help; }
.shot-card-meta .badge-nerd { color: var(--text3); cursor: pointer; border: 1px solid var(--border);
border-radius: 4px; padding: 0 5px; user-select: none; }
.shot-card-meta .badge-nerd:hover { color: var(--accent); border-color: rgba(0,184,176,.4); }
.shot-card-meta .badge-nerd.active { color: var(--accent); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.10); }
/* Child-window row, inline on the card. FLUID: cells share the available width
and wrap when the card is skinny — NO horizontal scrollbar, ever. */
.card-popups { display: flex; flex-wrap: wrap; gap: 8px; padding: 9px 13px 6px; flex-shrink: 0; }
.card-popups .ph { flex: 0 0 100%; font: 600 10px 'Familjen Grotesk', sans-serif;
color: var(--text2); margin-bottom: -2px; }
.card-popups .pc { flex: 1 1 130px; min-width: 0; max-width: 220px; background: #000; border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; cursor: zoom-in; transition: border-color .12s; }
.card-popups .pc:hover { border-color: rgba(0,184,176,.5); }
.card-popups .pc img { width: 100%; height: 96px; object-fit: contain; background: #111; display: block; }
.card-popups .pc .cap { padding: 3px 7px; font-family: 'JetBrains Mono', monospace; font-size: 9px;
color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Nerd metadata revealed INLINE on the card (not just the zoom modal) */
.card-nerd { display: none; margin: 6px 13px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--elevated); flex-shrink: 0; }
.card-nerd.open { display: block; }
.card-nerd .nhead { display: flex; align-items: center; gap: 8px; padding: 7px 11px; font: 600 10px 'JetBrains Mono', monospace; color: var(--text2); }
.card-nerd .nhead .copy { margin-left: auto; font-size: 9px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);
border-radius: 4px; padding: 1px 7px; cursor: pointer; }
.card-nerd .nbody { padding: 0 11px 9px; }
.card-nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }
.card-nerd .row .k { color: var(--text3); min-width: 120px; }
.card-nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }
.card-nerd .nbody { max-height: 340px; overflow-y: auto; }
.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; }
/* Zoom modal — full card */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none;
align-items: flex-start; justify-content: center; z-index: 100; overflow-y: auto;
padding: 16px; padding-top: max(16px, env(safe-area-inset-top)); }
.modal.open { display: flex; }
/* Zoom uses ALL the real estate the surface has: near-full viewport in a big
pup/browser window, and still fits a skinny panel. Never a fixed cap. */
.modal-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden; width: 100%; max-width: min(96vw, 2200px); flex-shrink: 0; }
.modal-card .m-desc { padding: 14px 16px 6px; font: 500 15px/1.4 'Satoshi', sans-serif; color: var(--text); }
.modal-card .m-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 16px 10px;
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text3); }
.modal-card .m-meta .dim-main { color: var(--text2); } .modal-card .m-meta .dim-missing { color: var(--danger); font-weight: 600; }
.modal-card .m-meta .sep { color: var(--border); }
.modal-card img { display: block; width: 100%; max-height: 84vh; object-fit: contain; background: #000; }
/* Popup array inside the modal */
.modal-card .m-popups { padding: 10px 16px 14px; }
.modal-card .m-popups .pop-h { font: 600 12px 'Familjen Grotesk', sans-serif; color: var(--text2);
margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.modal-card .m-popups .pop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.modal-card .m-popups .pop-cell { background: #000; border: 1px solid var(--border); border-radius: 8px;
overflow: hidden; cursor: zoom-in; }
.modal-card .m-popups .pop-cell img { width: 100%; height: 110px; object-fit: contain; background: #111; }
.modal-card .m-popups .pop-cap { padding: 4px 6px; font-family: 'JetBrains Mono', monospace; font-size: 9px;
color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Nerd metadata panel — readable + copyable */
.modal-card .nerd { margin: 0 16px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--elevated); }
.modal-card .nerd summary { cursor: pointer; padding: 8px 12px; font: 600 11px 'JetBrains Mono', monospace;
color: var(--text2); list-style: none; display: flex; align-items: center; gap: 8px; }
.modal-card .nerd summary::-webkit-details-marker { display: none; }
.modal-card .nerd summary .copy { margin-left: auto; font-size: 10px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);
border-radius: 4px; padding: 1px 7px; }
.modal-card .nerd .body { padding: 0 12px 10px; }
.modal-card .nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }
.modal-card .nerd .row .k { color: var(--text3); min-width: 130px; } .modal-card .nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }
.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;
width: max-content; 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); }
/* The message must NEVER collapse to min-content (one word per line) when the
no-shrink cancel button competes for width. */
.cdown .cd-msg { flex: 1 1 auto; min-width: 0; }
/* Skinny: stack message over the button instead of squeezing side-by-side */
@media (max-width: 520px) {
.cdown { flex-direction: column; align-items: stretch; gap: 10px;
width: auto; left: 12px; right: 12px; transform: none; max-width: none; }
.cdown .cd-cancel { align-self: flex-end; }
}
.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). */
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--elevated); color: var(--text); border: 1px solid var(--border);
padding: 9px 14px; border-radius: 12px; font: 12px/1.45 'Satoshi', sans-serif;
opacity: 0; transition: opacity .18s; pointer-events: none; z-index: 200;
max-width: min(360px, calc(100vw - 24px)); width: max-content; text-align: center;
box-shadow: 0 8px 28px rgba(0,0,0,.5); }
/* When the countdown card holds bottom-center, the toast rides ABOVE it
instead of stacking into the same pixels (JS adds .lifted). */
.toast.lifted { bottom: 96px; }
/* Skinny panel: tighter toast, never edge-to-edge slab */
@media (max-width: 520px) {
.toast { font-size: 11px; padding: 7px 11px; border-radius: 10px;
max-width: calc(100vw - 32px); }
}
.toast.show { opacity: 1; }
/* In-app helper dialog (e.g. "pup needs Adom Desktop") - NEVER a native
alert/confirm. Backdrop + card, dismiss via X / backdrop / Esc. */
.helper { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: none;
align-items: center; justify-content: center; z-index: 400; padding: 14px; }
.helper.open { display: flex; }
.helper-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px 15px; width: 100%; max-width: 440px; max-height: calc(100dvh - 28px);
overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,.6); position: relative; }
.helper-card .h-close { position: absolute; top: 10px; right: 10px; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center; border-radius: 6px;
color: var(--text3); cursor: pointer; border: none; background: transparent; font-size: 15px; }
.helper-card .h-close:hover { background: var(--overlay); color: var(--text); }
.helper-card .h-title { font: 700 15px 'Familjen Grotesk', sans-serif; color: var(--text);
margin-bottom: 8px; padding-right: 28px; }
.helper-card .h-body { font: 13px/1.55 'Satoshi', sans-serif; color: var(--text2); margin-bottom: 12px; }
.helper-card .h-lbl { font: 600 9.5px 'Satoshi', sans-serif; letter-spacing: .05em;
text-transform: uppercase; color: var(--text3); margin: 0 0 5px; }
.helper-card .h-prompt { background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.3);
border-radius: 8px; padding: 10px 12px; font-family: 'JetBrains Mono', monospace;
font-size: 11.5px; line-height: 1.5; color: var(--accent-bright); user-select: all;
word-break: break-word; }
.helper-card .h-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; flex-wrap: wrap; }
.helper-card .h-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 13px;
border-radius: 7px; font: 600 12px 'Familjen Grotesk', sans-serif; cursor: pointer;
border: 1px solid rgba(0,184,176,.4); background: rgba(0,184,176,.12); color: var(--accent-bright); }
.helper-card .h-btn:hover { background: rgba(0,184,176,.2); }
.helper-card .h-btn.secondary { border-color: var(--border); background: var(--overlay); color: var(--text2); }
.helper-card .h-btn.secondary:hover { color: var(--text); }
.helper-card .h-link { color: var(--accent); font: 600 12px 'Familjen Grotesk', sans-serif;
text-decoration: none; margin-left: auto; }
.helper-card .h-link:hover { text-decoration: underline; }
/* Preferences dialog widgets (inside the .helper shell) */
.pref-row { display: flex; align-items: center; gap: 14px; padding: 11px 0;
border-top: 1px solid var(--border-muted); }
.pref-row:first-of-type { border-top: none; }
.pref-info { flex: 1; min-width: 0; }
.pref-name { font: 600 13px 'Familjen Grotesk', sans-serif; color: var(--text); }
.pref-desc { font: 12px/1.45 'Satoshi', sans-serif; color: var(--text2); margin-top: 2px; }
.toggle { -webkit-appearance: none; appearance: none; width: 40px; height: 22px; border-radius: 999px;
background: var(--overlay); border: 1px solid var(--border); cursor: pointer; position: relative;
flex-shrink: 0; transition: background .15s, border-color .15s; }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
border-radius: 50%; background: var(--text2); transition: transform .18s, background .18s; }
.toggle[aria-checked="true"] { background: rgba(0,184,176,.25); border-color: rgba(0,184,176,.6); }
.toggle[aria-checked="true"]::after { transform: translateX(18px); background: var(--accent-bright); }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; }
.seg button { -webkit-appearance: none; appearance: none; border: none; background: var(--overlay);
color: var(--text2); font: 600 12px 'Familjen Grotesk', sans-serif; padding: 6px 12px; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--border); }
.seg button.on { background: rgba(0,184,176,.18); color: var(--accent-bright); }
/* Skinny panel: STACK each preference (text full-width, control below) - the
side-by-side row crushes the description to one word per line at ~360px. */
@media (max-width: 520px) {
.pref-row { flex-direction: column; align-items: stretch; gap: 9px; }
.pref-row .toggle, .pref-row .seg { align-self: flex-start; }
}
.ai-chip { margin-top: 7px; padding: 6px 9px; border-radius: 6px; display: inline-block;
background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.25);
font-family: 'JetBrains Mono', monospace; font-size: 10.5px; line-height: 1.4;
color: var(--accent-bright); }
/* Number input with an ALWAYS-VISIBLE unit suffix - a bare number box leaves
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; }
.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; }
.ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;
letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; }
/* Paste progress overlay */
.paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none;
align-items: center; justify-content: center; z-index: 150; }
.paste-overlay.active { display: flex; }
.paste-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px 24px; min-width: 260px; max-width: 420px; }
.paste-box .title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; margin-bottom: 8px; }
.paste-box .detail { font-size: 12px; color: var(--text2); margin: 3px 0; }
.paste-box .val { color: var(--text); } .paste-box .filepath-result { font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: var(--text3); margin-top: 8px; word-break: break-all; }
</style>
</head>
<body tabindex="0">
<div id="global-tooltip"></div>
<header>
<!-- The brand opens the app-info POPOVER on hover — NOT also a data-tooltip.
Two poppers on one trigger fight each other and land off-screen. -->
<div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0">
<svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/>
</svg>
<span class="app-name">adom-shotlog</span>
<span class="sep">·</span>
<span class="app-subtitle">screenshot log</span>
</div>
<div class="vsep"></div>
<div class="subject">
<span class="subject-name" id="channelName"
data-tooltip="The channel — a screenshot log for one project or task. AI agents inject shots here; you watch them arrive live.">…</span>
<span class="subject-meta" id="shotCount"></span>
</div>
<div class="actions">
<button class="hdr-btn" id="canvasBtn" style="display:none"></button>
<button class="hdr-btn icon-only" id="toTopBtn" data-tooltip="Scroll to newest (top)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg></button>
<button class="hdr-btn icon-only" id="filterBtn" data-tooltip="Filter shots: child-window arrays, capture endpoint, safe/full pairs, direct vs auto-captured."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>
<button class="hdr-btn icon-only" id="prefsBtn" data-tooltip="Preferences: the auto-capture read-hook, and whether channels default to webview or pup."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button>
<span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span>
</div>
<div class="hover-popover" id="app-info" role="dialog">
<div class="pop-title">adom-shotlog</div>
<div class="pop-row"><span class="k">what</span><span class="v">Chronological screenshot log. AI agents inject shots as they work; you view them here, newest first, with descriptions and source resolution.</span></div>
<div class="pop-row"><span class="k">inject</span><span class="v">adom-shotlog inject -c CH -d "why" FILE — auto-resizes >1500px and records the original size</span></div>
<div class="pop-row"><span class="k">paste</span><span class="v">Ctrl+V anywhere to paste a screenshot from the clipboard</span></div>
<div class="pop-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
<div class="pop-actions"><a class="pop-link" href="https://wiki.adom.inc/adom/adom-shotlog" target="_blank" rel="noopener">Wiki ↗</a></div>
</div>
</header>
<div class="chtabs" id="chtabs" role="tablist" aria-label="Channels"></div>
<div class="feed" id="feed"><div class="empty">loading…</div></div>
<div class="helper" id="pupHelp" role="dialog" aria-modal="true" aria-label="Pup setup help">
<div class="helper-card">
<button class="h-close" id="pupHelpClose" data-tooltip="Close">✕</button>
<div class="h-title" id="pupHelpTitle">Pup needs Adom Desktop</div>
<div class="h-body" id="pupHelpBody"></div>
<div class="h-lbl">copy this prompt into any AI thread and it will fix it for you</div>
<div class="h-prompt" id="pupHelpPrompt"></div>
<div class="h-actions">
<button class="h-btn" id="pupHelpCopy">⧉ Copy prompt</button>
<button class="h-btn secondary" id="pupHelpDismiss">Close</button>
<a class="h-link" href="https://wiki.adom.inc/adom/adom-desktop" target="_blank" rel="noopener">Adom Desktop wiki ↗</a>
</div>
</div>
</div>
<div class="helper" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Preferences">
<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">Auto-capture what AIs read</div>
<div class="pref-desc">The full always-see guarantee: every image any AI thread reads is copied into read-captures, and if no shotlog is showing anywhere, one auto-opens on your default canvas, brings the read-captures tab to the front, and (if update alerts are on) flashes orange. Turn off to stop auto-capturing (deliberate injects still work).</div>
<div class="ai-chip"><span class="lbl">or say to your AI</span>turn the shotlog read-hook off</div>
</div>
<button class="toggle" id="prefHook" role="switch" aria-checked="true" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."></button>
</div>
<div class="pref-row">
<div class="pref-info">
<div class="pref-name">Update alerts (orange flashes)</div>
<div class="pref-desc">When new shots arrive: the webview tab highlights orange and the pup window flashes its taskbar. Turn off for calmer eyeballs - shotlog still brings the tab forward, just without the flashing.</div>
<div class="ai-chip"><span class="lbl">or say to your AI</span>turn off the shotlog orange flashes</div>
</div>
<button class="toggle" id="prefAlerts" role="switch" aria-checked="true" data-tooltip="Toggle the orange update cues (webview tab highlight + pup taskbar flash) when new shots arrive."></button>
</div>
<div class="pref-row">
<div class="pref-info">
<div class="pref-name">Auto-switch to updated channel</div>
<div class="pref-desc">When a shot arrives for another channel, jump that channel's tab to the front. Turn off to sit where you are (the tab just flashes orange and shows an unseen badge instead).</div>
<div class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog auto-switching tabs</div>
</div>
<button class="toggle" id="prefAutoswitch" role="switch" aria-checked="true" data-tooltip="Auto-switch the in-app channel tab when a new shot arrives for it."></button>
</div>
<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. 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">
<button data-v="webview">Webview</button>
<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>
<span class="num-wrap" data-tooltip="Percent of the workspace width for the skinny shotlog panel (10-50). Applies to newly opened/auto-opened panels.">
<input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span>
</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="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>
<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="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>
</div>
</div>
<div class="helper" id="filterDlg" role="dialog" aria-modal="true" aria-label="Filters">
<div class="helper-card">
<button class="h-close" id="filterClose">✕</button>
<div class="h-title">Filters</div>
<div class="pref-info"><div class="pref-name">What kind of shot</div></div>
<div class="frow">
<button class="fchip" data-f="arrays" data-tooltip="Only shots carrying an Adom Desktop CHILD-WINDOW ARRAY (parent hwnd + its owned dropdowns/dialogs).">child-window arrays</button>
<button class="fchip" data-f="full" data-tooltip="Only shots stored as a SAFE/FULL pair (a full-resolution original is viewable).">safe/full pairs</button>
<button class="fchip" data-f="direct" data-tooltip="Only shots an AI deliberately injected (not auto-captured by the read-hook).">direct injects</button>
<button class="fchip" data-f="auto" data-tooltip="Only auto-captures from the read-hook (images AIs read).">auto-captured</button>
</div>
<div class="pref-info" style="margin-top:8px"><div class="pref-name">Capture endpoint</div></div>
<div class="frow" id="provRow">
<button class="fchip" data-p="ad-window" data-tooltip="Adom Desktop window screenshots (desktop_screenshot_window).">AD·window</button>
<button class="fchip" data-p="ad-screen" data-tooltip="Adom Desktop full-screen screenshots.">AD·screen</button>
<button class="fchip" data-p="pup" data-tooltip="pup (Puppeteer browser bridge) page captures.">pup</button>
<button class="fchip" data-p="hydrogen" data-tooltip="Hydrogen workspace screenshots.">hydrogen</button>
<button class="fchip" data-p="abe" data-tooltip="Adom Browser Extension captures (the user's real browser).">ABE</button>
<button class="fchip" data-p="hd" data-tooltip="Hydrogen Desktop's built-in screenshot feature.">HD</button>
<button class="fchip" data-p="crop" data-tooltip="Crops from larger originals.">crop</button>
<button class="fchip" data-p="clipboard" data-tooltip="Human clipboard pastes (Ctrl+V).">paste</button>
</div>
<div class="h-actions">
<button class="h-btn secondary" id="filterClear">Clear all</button>
<span style="flex:1"></span>
<span id="filterCount" style="color:var(--text3);font:11px 'JetBrains Mono',monospace"></span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="modal" id="modal"><div class="modal-card" id="modalCard">
<div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">
<div class="m-popups" id="mPopups" style="display:none"></div>
<div id="mNerd"></div>
</div></div>
<div class="paste-overlay" id="pasteOverlay"><div class="paste-box" id="pasteMsg"></div></div>
<script>
(function() {
const $ = id => document.getElementById(id);
const APP_VERSION='__SHOTLOG_VERSION__'; // stamped by the server at serve time
const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');
const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');
const mPopups = $('mPopups'), mNerd = $('mNerd');
// Channel from URL path /log/<channel>/ (matches Rust route + base href)
// Single-window app: shotlog owns its channel tabs. The path (or ?channel=)
// only picks the INITIALLY active tab.
const m = location.pathname.match(/\/log\/([^/]+)\/?/);
let channel = (m && decodeURIComponent(m[1]))
|| new URLSearchParams(location.search).get('channel')
|| 'read-captures';
$('channelName').textContent = channel;
document.title = 'shotlog · ' + channel;
// channel name -> { entries: null|[], unseen: 0, last: epoch-ms }
const chans = new Map();
function ensureChan(name){
if(!chans.has(name)) chans.set(name, { entries: null, unseen: 0, last: 0 });
return chans.get(name);
}
let APREFS = { update_autoswitch: true };
let entries = []; // the ACTIVE channel's list (ascending by id)
let shotCount = 0;
// ── Tooltip system (body-appended, 600ms, cursor-quadrant) ───────────────
(function() {
const tip = $('global-tooltip'); let cx=0, cy=0, timer=null;
document.addEventListener('mousemove', e => { cx=e.clientX; cy=e.clientY; }, true);
function show(el) {
// Rich (authored, trusted) HTML via el._ttHtml; else plain text. Dynamic
// values interpolated into _ttHtml are escaped by the builders below.
if (el._ttHtml) { tip.innerHTML = el._ttHtml; }
else { const t = el.getAttribute('data-tooltip'); if (!t) return; tip.textContent = t; }
tip.classList.add('visible');
const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14, M=6;
const c=[{x:cx+O,y:cy+O},{x:cx-tw-O,y:cy+O},{x:cx+O,y:cy-th-O},{x:cx-tw-O,y:cy-th-O}]
.find(p=>p.x>=M&&p.y>=M&&p.x+tw<=vw-M&&p.y+th<=vh-M)||{x:cx+O,y:cy+O};
// skillpack §1d: ALWAYS hard-clamp BOTH axes as the last step. In a skinny
// ~360px panel no quadrant fits a wide tooltip, so the fallback must be
// pulled back inside the right/bottom edges too — not just the left/top min.
const x=Math.min(Math.max(M,c.x), Math.max(M, vw-tw-M));
const y=Math.min(Math.max(M,c.y), Math.max(M, vh-th-M));
tip.style.transform=`translate(${x}px,${y}px)`;
requestAnimationFrame(()=>tip.classList.add('shown'));
}
function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown');
setTimeout(()=>{ if(!tip.classList.contains('shown')) tip.classList.remove('visible'); },180); }
document.addEventListener('mouseover', e => { const el=e.target.closest('[data-tooltip]');
if(el){ if(timer)clearTimeout(timer); timer=setTimeout(()=>show(el),600); } else hide(); }, true);
document.addEventListener('mouseout', e => { if(!e.relatedTarget?.closest('[data-tooltip]')) hide(); }, true);
})();
// ── App-info popover ─────────────────────────────────────────────────────
(function() {
const trigger = $('brandZone'), target = $('app-info'); let st=null, ht=null;
const show=()=>{clearTimeout(ht); if(target.classList.contains('show'))return; clearTimeout(st); st=setTimeout(()=>target.classList.add('show'),600);};
const hide=()=>{clearTimeout(st); clearTimeout(ht); ht=setTimeout(()=>target.classList.remove('show'),220);};
trigger.addEventListener('mouseenter',show); trigger.addEventListener('mouseleave',hide);
target.addEventListener('mouseenter',show); target.addEventListener('mouseleave',hide);
trigger.addEventListener('click',e=>{clearTimeout(st);target.classList.toggle('show');e.stopPropagation();});
document.addEventListener('click',e=>{ if(!target.contains(e.target)&&!trigger.contains(e.target)) target.classList.remove('show'); });
})();
// ── Helpers ──────────────────────────────────────────────────────────────
let toastTimer=null;
function showToast(msg, ms){ toast.textContent=msg; toast.classList.add('show');
toast.classList.toggle('lifted', !!document.querySelector('.cdown')); // never under the countdown card
clearTimeout(toastTimer); toastTimer=setTimeout(()=>toast.classList.remove('show'), ms||1800); }
function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function fmtTime(ts){ const d=new Date(ts), h=d.getHours(), mm=String(d.getMinutes()).padStart(2,'0');
return (h%12||12)+':'+mm+(h<12?'am':'pm'); }
function fmtDate(ts){ const d=new Date(ts), mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()];
return mo+' '+d.getDate()+', '+fmtTime(ts); }
// Which surface is showing this viewer? Explicit ?surface= wins (set by
// `shotlog open`/force-open = webview, or an opener that knows it's pup);
// otherwise infer mobile from the UA, else a plain browser.
function detectSurface(){
const q=new URLSearchParams(location.search).get('surface');
if(q) return q;
if(/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent||'')) return 'mobile';
return 'browser';
}
function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }
function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }
// Child windows can be stored as a bare array (current) or wrapped — normalize to an array.
// Provenance: map source strings (possibly compound "read-hook/ad-window")
// to labeled chips explaining WHICH capture endpoint produced the image.
const SRC_MAP={
'read-hook':{label:'auto',cls:'auto',tip:'Auto-captured by the read-hook: an AI thread read this image and shotlog copied it here.'},
'ad-window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows (dropdowns/dialogs).'},
'desktop_screenshot_window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows.'},
'ad-screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot (desktop_screenshot_screen): the whole monitor, popups and all.'},
'desktop_screenshot_screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot: the whole monitor.'},
'pup':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},
'pup_screenshot':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},
'hydrogen':{label:'hydrogen',cls:'',tip:'Captured by Hydrogen\'s internal workspace screenshotter.'},
'abe':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension driving the user\'s real signed-in browser.'},
'nbrowser':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension (nbrowser) in the user\'s real browser.'},
'hd':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},
'hd_shot':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},
'crop':{label:'crop',cls:'',tip:'Cropped from a larger original image.'},
'clipboard':{label:'paste',cls:'',tip:'Pasted from the clipboard by the human (Ctrl+V in the viewer).'},
};
function srcChips(e){
// 'cli' and 'api' are deliberate injects with no capture endpoint declared —
// chip-less on purpose. Only DECLARED provenance gets a chip.
if(!e.source || e.source==='cli' || e.source==='api') return [];
return e.source.split('/').map(part=>{
const m=SRC_MAP[part];
return m || {label:part, cls:'', tip:'Capture source: '+part};
});
}
function popList(e){ const p=e&&e.popups; return Array.isArray(p)?p:(p&&Array.isArray(p.popups)?p.popups:[]); }
// EVERY field of the entry, flattened to readable dotted-key rows.
// "Show me metadata" means ALL of it - nothing curated away. The friendly
// rows (when / sizes) lead, then every remaining key recurses: meta.coordMap.*,
// meta.screenshots[n].*, popups[n].*, the lot.
function metaRows(e){
const rows=[];
const push=(k,v)=>{ if(v!==undefined&&v!==null&&v!=='') rows.push([k,typeof v==='object'?JSON.stringify(v):String(v)]); };
const flat=(prefix,v)=>{
if (v===null||v===undefined||v==='') return;
if (Array.isArray(v)){ v.forEach((it,i)=>flat(prefix+'['+i+']',it)); return; }
if (typeof v==='object'){ Object.keys(v).forEach(k=>flat(prefix?prefix+'.'+k:k, v[k])); return; }
push(prefix, v);
};
push('when', e.timestamp?new Date(e.timestamp).toLocaleString():undefined);
push('image size', (e.w&&e.h)?e.w+'×'+e.h:undefined);
if(e.orig_w&&e.orig_h) push('orig capture', e.orig_w+'×'+e.orig_h);
const handled=['timestamp','w','h','orig_w','orig_h'];
const lead=['id','filename','description','kb','source','thread','context','full'];
lead.forEach(k=>flat(k, e[k]));
Object.keys(e).forEach(k=>{ if(!handled.includes(k)&&!lead.includes(k)) flat(k, e[k]); });
return rows;
}
function dimNodes(e, into) {
if (!e.w || !e.h) return;
const dm=document.createElement('span'); dm.className='dim-main'; dm.textContent=e.w+'×'+e.h;
dm.setAttribute('data-tooltip','Stored size (after any resize) — what the AI sees when it reads the shot.');
into.appendChild(dm);
// Show the original only when it genuinely differs from the stored size.
// (New injects always establish the original; equal/absent → show nothing.)
if (e.orig_w && e.orig_h && (e.orig_w !== e.w || e.orig_h !== e.h)) {
const sep=document.createElement('span'); sep.className='sep'; sep.textContent='·'; into.appendChild(sep);
const o=document.createElement('span'); o.className='dim-orig'; o.textContent='orig '+e.orig_w+'×'+e.orig_h;
o.setAttribute('data-tooltip','Original capture resolution before resize — confirms what was actually captured.');
into.appendChild(o);
}
}
// Inline nerd-metadata panel, rendered ON the card (revealed in place — no zoom).
function buildNerdInline(e) {
const pops=popList(e);
const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows
const panel=document.createElement('div'); panel.className='card-nerd';
const head=document.createElement('div'); head.className='nhead'; head.textContent='⌗ capture metadata';
const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';
cp.addEventListener('click', ev=>{ ev.stopPropagation();
navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });
head.appendChild(cp); panel.appendChild(head);
const body=document.createElement('div'); body.className='nbody';
metaRows(e).forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';
const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;
const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;
r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });
const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);
panel.appendChild(body);
return panel;
}
function card(e) {
const c=document.createElement('div'); c.className='shot-card'; c.dataset.id=e.id;
// For auto-captures the WHY is the headline: show the prompt the thread
// was serving, and demote the generic "auto-captured: file.png" to a
// small mono sub-line. Deliberate injects keep their description first.
const isAuto=(e.source||'').startsWith('read-hook');
if (isAuto && e.context) {
const why=document.createElement('div'); why.className='shot-card-why';
why.textContent=e.context;
why.setAttribute('data-tooltip','The prompt this AI thread was working on when it read the image - the REASON this capture exists.');
c.appendChild(why);
const sub=document.createElement('div'); sub.className='shot-card-sub';
sub.textContent='auto-captured: '+(e.filename||'').replace(/^auto-captured-screenshot-claude-read-for-analysis-/,'');
c.appendChild(sub);
} else {
const desc=document.createElement('div');
if (e.description) { desc.className='shot-card-desc'; desc.textContent=e.description; }
else { desc.className='shot-card-desc no-desc'; desc.textContent='⚠ No description — inject with -d "what and why"'; }
c.appendChild(desc);
}
const meta=document.createElement('div'); meta.className='shot-card-meta';
const ts=document.createElement('span'); ts.className='ts'; ts.textContent=fmtDate(e.timestamp);
ts.setAttribute('data-tooltip', new Date(e.timestamp).toLocaleString()); meta.appendChild(ts);
if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }
if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }
if(e.full){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);
const fc=document.createElement('span'); fc.className='chip ad'; fc.textContent='full';
fc.setAttribute('data-tooltip','Safe/full pair: the full-resolution original is stored alongside - open the zoom and click "view full".');
meta.appendChild(fc); }
srcChips(e).forEach(c=>{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
const ch=document.createElement('span'); ch.className='chip '+(c.cls||''); ch.textContent=c.label;
ch.setAttribute('data-tooltip', c.tip); meta.appendChild(ch); });
if (e.thread){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
const th=document.createElement('span'); th.className='thr'; th.textContent=e.thread;
th.setAttribute('data-tooltip','The AI thread that produced this shot.'+(e.context?'\n\nWorking on: "'+e.context+'"':'')); meta.appendChild(th); }
const pops=popList(e);
if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');
b.setAttribute('data-tooltip','This capture also grabbed '+pops.length+' child window'+(pops.length>1?'s':'')+' of the parent window (dropdowns / dialogs) — shown below.');
meta.appendChild(b); }
// ⓘ meta chip toggles the inline metadata panel right here on the card.
// EVERY card gets it - the full entry record is always worth seeing, not
// just shots that happen to carry an AD sidecar.
let nerd=null, chip=null;
{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
chip=document.createElement('span'); chip.className='badge-nerd'; chip.textContent='ⓘ meta';
chip.setAttribute('data-tooltip','Tap to reveal EVERYTHING shotlog knows about this shot: the full entry record (thread, context, source, sizes), all capture metadata (coordMap, DPI, screen rect, child windows), and the raw JSON.');
meta.appendChild(chip); }
c.appendChild(meta);
const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;
img.addEventListener('click', ()=>openModal(e)); // image still zooms if you want it
c.appendChild(img);
// All child windows in ONE row, inline — no need to open the shot.
if (pops.length){
const row=document.createElement('div'); row.className='card-popups';
const ph=document.createElement('span'); ph.className='ph'; ph.textContent='child windows ▸'; row.appendChild(ph);
pops.forEach(p=>{
const cell=document.createElement('div'); cell.className='pc';
const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup'; im.loading='lazy';
const cap=document.createElement('div'); cap.className='cap';
cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:'');
cell.appendChild(im); cell.appendChild(cap);
cell.addEventListener('click', ev=>{ ev.stopPropagation();
openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,
timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });
row.appendChild(cell);
});
c.appendChild(row);
}
// Inline nerd panel + wire the chip to toggle it in place.
nerd=buildNerdInline(e); c.appendChild(nerd);
chip.addEventListener('click', ev=>{ ev.stopPropagation();
const open=nerd.classList.toggle('open'); chip.classList.toggle('active', open); });
return c;
}
function openModal(e) {
mImg.src=imgUrl(e); mImg.alt=e.description||e.filename;
const isAutoM=(e.source||'').startsWith('read-hook');
mDesc.textContent=(isAutoM&&e.context)?e.context:(e.description||'');
mDesc.style.display=(mDesc.textContent)?'':'none';
let why=document.getElementById('mWhy');
if(!why){ why=document.createElement('div'); why.id='mWhy'; why.className='m-why'; mDesc.after(why); }
if(e.context){ why.innerHTML='<span class="k">while working on</span>'+esc(e.context); why.style.display=''; }
else { why.style.display='none'; }
mMeta.innerHTML='';
const add=(cls,txt,tip)=>{ const s=document.createElement('span'); if(cls)s.className=cls; s.textContent=txt; if(tip)s.setAttribute('data-tooltip',tip); mMeta.appendChild(s); };
const sep=()=>add('sep','·');
add('ts',fmtDate(e.timestamp));
if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h);
if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }
if(e.kb){ sep(); add('',e.kb+' KB'); }
if(e.full){ sep();
const t=document.createElement('span'); t.className='m-full-toggle';
let showingFull=false;
const label=()=> t.textContent = showingFull
? ('viewing FULL '+(e.orig_w||'?')+'×'+(e.orig_h||'?')+' - click for safe'
)
: ('view full '+(e.orig_w||'?')+'×'+(e.orig_h||'?'));
label();
t.setAttribute('data-tooltip','This shot is a safe/full pair: the resized (safe) copy is shown by default; click to swap in the stored full-resolution original.');
t.addEventListener('click', ev=>{ ev.stopPropagation();
showingFull=!showingFull;
mImg.src = showingFull ? popUrl(e.full) : imgUrl(e);
label(); });
mMeta.appendChild(t);
}
if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }
if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); }
// Child-window thumbnails — each opens full-size in its own modal entry.
const pops=popList(e);
mPopups.innerHTML='';
if(pops.length){
mPopups.style.display='';
const h=document.createElement('div'); h.className='pop-h';
h.textContent='Child windows · '+pops.length+' captured';
mPopups.appendChild(h);
const grid=document.createElement('div'); grid.className='pop-grid';
pops.forEach(p=>{
const cell=document.createElement('div'); cell.className='pop-cell';
const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup';
im.loading='lazy';
const cap=document.createElement('div'); cap.className='pop-cap';
cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:'');
cell.appendChild(im); cell.appendChild(cap);
cell.addEventListener('click', ev=>{ ev.stopPropagation();
openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,
timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });
grid.appendChild(cell);
});
mPopups.appendChild(grid);
} else { mPopups.style.display='none'; }
// Nerd metadata panel — readable rows + copyable raw JSON.
mNerd.innerHTML='';
const rows=metaRows(e);
if(true){ // full metadata is ALWAYS available now
const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows
const d=document.createElement('details'); d.className='nerd';
const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata';
const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';
cp.addEventListener('click', ev=>{ ev.preventDefault(); ev.stopPropagation();
navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });
sum.appendChild(cp); d.appendChild(sum);
const body=document.createElement('div'); body.className='body';
rows.forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';
const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;
const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;
r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });
const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);
d.appendChild(body); mNerd.appendChild(d);
}
modal.classList.add('open'); modal.scrollTop=0;
}
function closeModal(){ modal.classList.remove('open'); }
// Close on: backdrop click, clicking the zoomed image again, or Escape.
// (Metadata/desc clicks are preserved — only the backdrop and image close.)
modal.addEventListener('click', e => { if(e.target===modal) closeModal(); });
mImg.style.cursor='zoom-out';
mImg.addEventListener('click', e => { e.stopPropagation(); closeModal(); });
document.addEventListener('keydown', e => { if(e.key==='Escape' && modal.classList.contains('open')) closeModal(); });
function renderAll() {
feed.innerHTML='';
if (!entries.length) {
feed.innerHTML='<div class="empty">No screenshots yet in <b>'+esc(channel)+'</b>.<br><br>'+
'Inject one:<br><code>shotlog inject -c '+esc(channel)+' -d "what and why" FILE</code></div>';
shotCount=0; updateCount(); return;
}
shotCount=entries.length;
let shown=0;
for (let i=entries.length-1;i>=0;i--){ const e=entries[i];
if(!passFilters(e)) continue; shown++; feed.appendChild(card(e)); } // newest first
if(!shown){
feed.innerHTML='<div class="empty">No shots match the active filters ('+entries.length+' hidden).<br><br>Open the funnel in the header to adjust or clear them.</div>';
}
updateCount();
}
function channelTooltipHtml(countText){
const cnt = countText ? '<div class="tt-count">'+esc(countText)+' captured</div>' : '';
// read-captures is the SPECIAL auto-capture channel — explain it in full,
// because even the shots' origin is non-obvious.
if (channel === 'read-captures') {
return '<div class="tt-title">read-captures — automatic capture</div>'+
'<div class="tt-p">Whenever any AI thread here READS an image file (a screenshot, a render, a photo) to look at it, a hook instantly copies that exact image into this channel. You see everything your AIs looked at — they never have to remember to send it.</div>'+
'<div class="tt-p">So shots appear on their own: a side effect of an AI opening an image to analyze it, not a deliberate post. Powered by the shotlog read-hook.</div>'+
'<div class="tt-prompt"><span class="lbl">want a deliberate log? say to your AI</span>ralph test your app and show me all the screenshots in shotlog</div>'+
cnt;
}
return '<div class="tt-title">Channel: '+esc(channel)+'</div>'+
'<div class="tt-p">AI agents inject shots into this channel; you watch them arrive live, newest first.</div>'+
'<div class="tt-prompt"><span class="lbl">say to your AI</span>show me your progress in shotlog as you go</div>'+
cnt;
}
function updateCount(){ const t = shotCount? shotCount+(shotCount===1?' shot':' shots'):'';
$('shotCount').textContent = t;
// Surface the count in the channel-name tooltip too, since .subject-meta is
// hidden in the skinny panel (canonical adom-app-header: meta -> subject tooltip).
$('channelName')._ttHtml = channelTooltipHtml(t); }
// ── Load ───────────────────────────────────────────────────────────────
// ── Filters: what kinds of shots to show in the feed ──────────────────────
const FILTERS={arrays:false, full:false, direct:false, auto:false, prov:new Set()};
function passFilters(e){
if(FILTERS.arrays && !popList(e).length) return false;
if(FILTERS.full && !e.full) return false;
const isAuto=(e.source||'').startsWith('read-hook');
if(FILTERS.direct && isAuto) return false;
if(FILTERS.auto && !isAuto) return false;
if(FILTERS.prov.size){
const parts=(e.source||'').split('/');
if(![...FILTERS.prov].some(x=>parts.includes(x))) return false;
}
return true;
}
function filtersActive(){ return FILTERS.arrays||FILTERS.full||FILTERS.direct||FILTERS.auto||FILTERS.prov.size>0; }
(function wireFilters(){
const dlg=$('filterDlg'), btn=$('filterBtn'); if(!dlg) return;
const close=()=>dlg.classList.remove('open');
$('filterClose').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(); });
function refresh(){
dlg.querySelectorAll('.fchip[data-f]').forEach(c=>c.classList.toggle('on', !!FILTERS[c.dataset.f]));
dlg.querySelectorAll('.fchip[data-p]').forEach(c=>c.classList.toggle('on', FILTERS.prov.has(c.dataset.p)));
btn.classList.toggle('filters-on', filtersActive());
renderAll();
const shown=entries.filter(passFilters).length;
$('filterCount').textContent = filtersActive()? shown+' of '+entries.length+' shown' : '';
}
dlg.addEventListener('click',e=>{
const c=e.target.closest('.fchip'); if(!c) return;
if(c.dataset.f){ FILTERS[c.dataset.f]=!FILTERS[c.dataset.f];
// direct & auto are mutually exclusive
if(c.dataset.f==='direct'&&FILTERS.direct) FILTERS.auto=false;
if(c.dataset.f==='auto'&&FILTERS.auto) FILTERS.direct=false; }
else if(c.dataset.p){ FILTERS.prov.has(c.dataset.p)?FILTERS.prov.delete(c.dataset.p):FILTERS.prov.add(c.dataset.p); }
refresh();
});
$('filterClear').addEventListener('click',()=>{ FILTERS.arrays=FILTERS.full=FILTERS.direct=FILTERS.auto=false; FILTERS.prov.clear(); refresh(); });
btn.addEventListener('click',()=>{ dlg.classList.add('open'); refresh(); });
})();
// ── Tab strip: shotlog-managed channel tabs ───────────────────────────────
const strip=$('chtabs');
function tabOrder(){
const names=[...chans.keys()];
names.sort((a,b)=>{
if(a==='read-captures') return -1;
if(b==='read-captures') return 1;
return (chans.get(b).last||0)-(chans.get(a).last||0);
});
return names;
}
function renderTabs(){
strip.innerHTML='';
for(const name of tabOrder()){
const c=chans.get(name);
const b=document.createElement('button');
b.className='chtab'+(name===channel?' active':'')+(c.unseen>0?' unseen':'');
b.setAttribute('role','tab');
b.setAttribute('data-tooltip', name==='read-captures'
? 'The global auto-capture tab: every image any AI thread reads, from all threads intermingled.'
: 'Channel "'+name+'". Click to view; the badge counts shots you have not seen yet.');
const nm=document.createElement('span'); nm.textContent=name; b.appendChild(nm);
const bd=document.createElement('span'); bd.className='badge'; bd.textContent=c.unseen>99?'99+':String(c.unseen); b.appendChild(bd);
b.addEventListener('click',()=>activate(name));
strip.appendChild(b);
}
}
function flashTab(name){
const idx=tabOrder().indexOf(name);
const el=strip.children[idx];
if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }
}
async function activate(name){
channel=name;
const c=ensureChan(name);
$('channelName').textContent=name;
document.title='shotlog · '+name;
if(c.entries===null){
try{ const data=await fetch('api/shots?channel='+encodeURIComponent(name)).then(r=>r.json());
c.entries=Array.isArray(data)?data:[]; }catch{ c.entries=[]; }
}
entries=c.entries;
c.unseen=0;
renderAll(); feed.scrollTop=0;
renderTabs();
}
// Boot: seed tabs from the broker's channel overview, then activate.
(async function bootApp(){
try{ APREFS=await fetch('api/prefs').then(r=>r.json()); }catch{}
try{
const rows=await fetch('api/channels').then(r=>r.json());
if(Array.isArray(rows)) for(const r of rows){
const c=ensureChan(r.channel); c.last=(r.last_epoch||0)*1000;
}
}catch{}
ensureChan('read-captures');
ensureChan(channel);
await activate(channel);
})();
// ── Canvas toggle: show this channel in pup (full browser) or webview ─────
(function initCanvasToggle(){
const btn=$('canvasBtn'); if(!btn) return;
const surf=detectSurface();
if(surf!=='webview' && surf!=='pup') return; // phone/plain browser: no toggle
const toPup = surf==='webview';
// Clean inline-SVG icons (Lucide-style): external-link for →pup, panel-right for →webview.
const ICON = toPup
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>';
const label = toPup ? 'Pup' : 'Webview';
btn.classList.add('accent');
btn.style.display='';
const render=()=>{ btn.innerHTML=ICON+'<span>'+label+'</span>'; };
render();
// Structured tooltip: title, what it does, requirement (pup only), and the
// plain-English prompt the human can say to an AI to do the same thing.
// NOTE: also set a plain data-tooltip so the hover handler (which matches
// `[data-tooltip]`) fires for this button; the renderer prefers _ttHtml.
btn.setAttribute('data-tooltip', toPup ? 'Show in pup' : 'Show in webview');
btn._ttHtml = toPup
? '<div class="tt-title">Show in pup</div>'+
'<div class="tt-p">A full browser window: real zoom, whole-screen images. Closes this slim webview tab.</div>'+
'<div class="tt-req">Requires Adom Desktop installed (pup is a default bridge that auto-installs with it).</div>'+
'<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in pup</div>'
: '<div class="tt-title">Show in webview</div>'+
'<div class="tt-p">The slim panel docked in Hydrogen. Closes this pup tab.</div>'+
'<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in webview</div>';
const SPINNER='<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>';
const busy=(t)=>{ btn.disabled=true; btn.classList.add('busy'); btn.innerHTML=SPINNER+'<span>'+esc(t)+'</span>'; };
const restore=()=>{ btn.classList.remove('busy'); render(); btn.disabled=false; };
const dest = toPup ? 'pup' : 'webview';
const src = toPup ? 'webview' : 'pup';
// Count how many viewers of a surface are currently connected.
async function surfaceCount(){
try { const st=await fetch('api/viewer-status?channel='+encodeURIComponent(channel)).then(r=>r.json());
return (st.surfaces||[]).filter(x=>x===dest).length; } catch { return -1; }
}
// FULL TRACKED SWITCH. Stages: check -> open destination -> WAIT until the
// destination viewer's WebSocket actually connects (the full signal) ->
// only then close this (source) surface. Any failure leaves this surface
// open and reports exactly what happened. Nothing is ever closed blind.
const uiLog=(line)=>{ fetch('api/log',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({line:'UI '+line})}).catch(()=>{}); };
btn.addEventListener('click', async ()=>{
if(btn.disabled) return;
btn.disabled=true;
uiLog('switch click: '+channel+' -> '+dest);
let opened=false;
try{
showToast(toPup?'Opening the pup window…':'Opening the webview panel…', 3500);
// Stage 0: availability (pup needs Adom Desktop). `?simulate=no-pup` in
// the viewer URL demos the not-installed path without touching anything.
if(toPup){
busy('checking pup…');
const sim=new URLSearchParams(location.search).get('simulate');
const chk = sim==='no-pup'
? {available:false, reason:'no-adom-desktop', simulated:true}
: await fetch('api/canvas-check').then(r=>r.json());
if(!chk.available){ showPupHelp(chk); restore(); return; }
}
const baseline=await surfaceCount();
if(baseline<0){ showToast('cannot reach the shotlog broker - switch aborted, nothing changed.', 7000); restore(); return; }
// Stage 1: open the destination (nothing gets closed yet).
busy('opening '+dest+'…');
const op=await fetch('api/canvas-open',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({channel, to:dest})}).then(r=>r.json());
if(!op.ok){
uiLog('switch FAILED opening '+dest+' for '+channel+': '+String(op.error||op.detail||'').slice(0,160));
showToast('could not open '+dest+': '+String(op.error||op.detail||'unknown').slice(0,300)+' - this tab stays open.', 10000);
restore(); return;
}
opened=true;
// Stage 2: wait for the REAL signal - the destination viewer connects
// its WebSocket to the broker. Poll up to 30s, ticking progress.
const T0=Date.now(), LIMIT=30000;
let connected = baseline>0; // already watching there? instant confirm
while(!connected && Date.now()-T0<LIMIT){
busy('waiting for '+dest+'… '+Math.round((Date.now()-T0)/1000)+'s');
await new Promise(r=>setTimeout(r,800));
const n=await surfaceCount();
if(n>baseline) connected=true;
}
if(!connected){
// Rollback: destination never came up. Close ONLY what we just opened
// (best-effort), keep this surface, tell the human precisely.
busy('rolling back…');
try{ await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({channel, surface:dest})}); }catch{}
uiLog('switch ROLLBACK: '+dest+' never connected for '+channel+' (30s)');
showToast(dest+' opened but never connected (waited 30s) - rolled back, this tab stays open. '+(toPup?'Is the pup window blocked or the laptop asleep?':'Is Hydrogen open?'), 12000);
restore(); return;
}
// Stage 3: destination is LIVE. Now (and only now) close this surface.
uiLog('switch OK: '+channel+' now on '+dest);
busy('switched ✓');
showToast(dest+' is live - closing this '+src+' tab.', 4000);
await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({channel, surface:src})}).catch(()=>{});
// If this page is that tab, it is about to die - nothing more to do.
// If the close missed (renamed tab etc.), re-enable so retry is possible.
setTimeout(restore, 4000);
}catch(e){
showToast('switch failed: '+e.message+(opened?' ('+dest+' may have opened - check before retrying)':' - nothing changed.'), 9000);
restore();
}
});
})();
// ── Pup helper dialog: reason-specific explanation + a COPYABLE AI prompt ──
function showPupHelp(chk){
const reason=chk.reason||'no-adom-desktop';
const C={
'no-adom-desktop':{
title:'Pup needs Adom Desktop',
body:'Pup opens shotlog in a full browser window on YOUR computer, driven by Adom Desktop. Adom Desktop is not installed (or not reachable) yet - pup is a default bridge that installs automatically right after it.',
prompt:'Install Adom Desktop on my computer so shotlog can open pup windows. Start from wiki.adom.inc/adom/adom-desktop, walk me through the install, make sure it connects to my container, verify the pup bridge with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
'desktop-not-connected':{
title:'Adom Desktop is not connected',
body:'Adom Desktop is installed but not linked to this container right now. Launch it on your computer and it reconnects automatically.',
prompt:'My Adom Desktop is installed but not connected to this container. Help me get it running and linked, verify with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
'pup-not-ready':{
title:'Pup bridge is still warming up',
body:'Adom Desktop is connected, but the pup browser bridge is not ready yet. It auto-installs on first use - this usually resolves in under a minute.',
prompt:'The pup bridge on my Adom Desktop is not ready. Run `adom-desktop browser_prewarm`, verify with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
'multiple-desktops':{
title:'More than one Adom Desktop is connected',
body:'The broker cannot tell which computer should host the pup window. It needs a target.',
prompt:'Multiple Adom Desktops are connected to my container. Figure out which one I am actually using, set SHOTLOG_AD_TARGET=<that name> on my shotlog broker, then show my shotlog channel "'+channel+'" in pup.'}
};
const c=C[reason]||C['no-adom-desktop'];
$('pupHelpTitle').textContent=(chk.simulated?'(simulated) ':'')+c.title;
$('pupHelpBody').textContent=c.body+(chk.hint&&!chk.simulated?' ('+chk.hint+')':'');
$('pupHelpPrompt').textContent=c.prompt;
$('pupHelp').classList.add('open');
}
(function wirePupHelp(){
const dlg=$('pupHelp'); if(!dlg) return;
const close=()=>dlg.classList.remove('open');
$('pupHelpClose').addEventListener('click',close);
$('pupHelpDismiss').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(); });
$('pupHelpCopy').addEventListener('click',()=>{
const t=$('pupHelpPrompt').textContent;
const done=()=>{ $('pupHelpCopy').textContent='copied ✓'; setTimeout(()=>$('pupHelpCopy').innerHTML='⧉ Copy prompt',1400); };
if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(t).then(done).catch(()=>{fallback();}); }
else fallback();
function fallback(){ const r=document.createRange(); r.selectNodeContents($('pupHelpPrompt'));
const sel=getSelection(); sel.removeAllRanges(); sel.addRange(r);
try{ document.execCommand('copy'); done(); }catch{ showToast('select the prompt text and copy manually',5000); } }
});
})();
// ── Preferences dialog: read-hook toggle + default canvas ─────────────────
(function wirePrefs(){
const dlg=$('prefsDlg'), hook=$('prefHook'), seg=$('prefCanvas'); if(!dlg) return;
// 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(); });
function renderPrefs(p){
hook.setAttribute('aria-checked', String(!!p.read_hook));
$('prefAlerts').setAttribute('aria-checked', String(p.update_alerts!==false));
$('prefAutoswitch').setAttribute('aria-checked', String(p.update_autoswitch!==false));
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;
}
async function patch(body, okMsg){
try{
const r=await fetch('api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json());
if(r.ok){ renderPrefs(r.prefs); showToast(okMsg, 3500); }
else showToast('could not save: '+(r.error||'unknown'), 6000);
}catch(e){ showToast('could not save preferences: '+e.message, 6000); }
}
hook.addEventListener('click',()=>{
const now=hook.getAttribute('aria-checked')==='true';
patch({read_hook: !now}, !now?'read-hook ON: images AIs read auto-capture again.':'read-hook OFF: nothing auto-captures until you turn it back on.');
});
$('prefAlerts').addEventListener('click',()=>{
const now=$('prefAlerts').getAttribute('aria-checked')==='true';
patch({update_alerts: !now}, !now?'orange update flashes ON.':'orange update flashes OFF: tabs still come forward, no flashing.');
});
$('prefAutoswitch').addEventListener('click',()=>{
const now=$('prefAutoswitch').getAttribute('aria-checked')==='true';
patch({update_autoswitch: !now}, !now?'auto-switch ON: updated channels come to the front.':'auto-switch OFF: tabs flash + badge, you stay put.');
});
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);
const commit=()=>{ const v=parseInt(el.value,10); if(Number.isNaN(v)) return;
patch({[key]: v}, msg(v)); };
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.');
$('prefsBtn').addEventListener('click', async ()=>{
try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}
dlg.classList.add('open');
});
})();
// ── WebSocket live updates ({type:'entry', entry}) ───────────────────────
let activeWs = null;
// Gentle "you have unseen shots" cue: while this tab is backgrounded, count new
// shots and show them in the page title (surfaces in the pup/browser tab strip
// and phone tab). Cleared when the tab is shown again. Never steals focus.
let pendingUnseen = 0;
const baseTitle = 'adom-shotlog · ' + channel;
function refreshTitle(){ document.title = pendingUnseen > 0 ? '● '+pendingUnseen+' · '+channel : baseTitle; }
refreshTitle();
// Report on-screen state whenever this tab/window is shown or hidden, so the
// server can tell "watching" from "open but backgrounded/minimized".
document.addEventListener('visibilitychange', () => {
const visible = document.visibilityState === 'visible';
try { if (activeWs && activeWs.readyState===1)
activeWs.send(JSON.stringify({type:'vis', visible})); } catch {}
if (visible) { pendingUnseen = 0; refreshTitle(); }
});
function connectWs() {
const proto = location.protocol==='https:'?'wss:':'ws:';
const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';
const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/_all`);
// Version handshake: the broker announces its version on connect; a page
// from an older deploy reloads ITSELF (once) so no surface ever goes stale.
ws.addEventListener('message', ev => {
try { const m = JSON.parse(ev.data);
if (m.type === 'welcome') {
const stale = m.version && APP_VERSION.indexOf('__') !== 0 && m.version !== APP_VERSION
&& !sessionStorage.getItem('slReloaded-' + m.version);
if (m.notice) {
// One-shot narration from the broker (pup fallback/recovery, AI
// notices). If this page is about to reload, hand the notice back
// so the fresh page shows it instead of it dying with us.
if (stale) fetch('api/notice',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:m.notice}),keepalive:true}).catch(()=>{});
else showToast(m.notice, 12000);
}
if (stale) {
sessionStorage.setItem('slReloaded-' + m.version, '1');
location.reload();
}
}
} catch {}
});
ws.onopen = () => { wsLed.classList.add('live'); wsLed.setAttribute('data-tooltip','WebSocket live — the server knows you are watching.');
activeWs = ws;
// Tell the server WHICH surface is watching + whether it's on-screen right now,
// so `inject`/`viewers` can distinguish "watching" from "open but backgrounded".
try { const sess=new URLSearchParams(location.search).get('session');
ws.send(JSON.stringify({type:'hello', surface:detectSurface(), visible: document.visibilityState==='visible', session:sess||undefined})); } catch {} };
ws.onmessage = ev => {
try {
const msg = JSON.parse(ev.data);
if (msg.type==='show' && msg.channel) {
// Consolidation: an AI asked to "open" a channel while this window is
// already up - switch OUR tab instead of letting a second surface
// spawn, and tell the human why.
ensureChan(msg.channel);
activate(msg.channel);
renderTabs();
showToast(msg.toast || ("Switched to '"+msg.channel+"' (consolidated - one shotlog window, tabs inside)."), 12000);
return;
}
if (msg.type==='entry' && msg.entry) {
const e = msg.entry, chName = msg.channel || channel;
const c = ensureChan(chName);
c.last = Date.now();
if (c.entries !== null) {
const existing = c.entries.find(x=>x.id===e.id);
if (existing) Object.assign(existing, e); else c.entries.push(e);
}
if (chName === channel) {
if(!passFilters(e)){ renderTabs(); return; }
const atTop = feed.scrollTop < 60;
const old = feed.querySelector(`[data-id="${e.id}"]`);
if (old) { old.replaceWith(card(e)); }
else { const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';
feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0;
if (document.hidden) { pendingUnseen++; refreshTitle(); } }
renderTabs();
} else if (APREFS.update_autoswitch !== false) {
// The in-app mirror of pull-to-front: jump to the updated channel.
activate(chName).then(()=>flashTab(chName));
} else {
c.unseen++; renderTabs(); flashTab(chName);
}
}
} catch {}
};
ws.onclose = () => { wsLed.classList.remove('live');
wsLed.setAttribute('data-tooltip','WebSocket disconnected — reconnecting…'); setTimeout(connectWs,3000); };
ws.onerror = () => { try{ws.close();}catch{} };
}
connectWs();
// ── Wiring ───────────────────────────────────────────────────────────────
$('toTopBtn').addEventListener('click',()=>{ feed.scrollTop=0; });
// Two-step arm instead of a browser-modal confirm(): first click arms the
// button ("sure? clears all") for 3s; a second click within that window does it.
(function initClear(){
const btn=$('clearBtn'); let armed=false, t=null;
const reset=()=>{ armed=false; btn.classList.remove('arm'); btn.textContent='clear'; clearTimeout(t); };
btn.addEventListener('click', async ()=>{
if(!armed){ armed=true; btn.classList.add('arm'); btn.textContent='sure? clears all';
t=setTimeout(reset,3000); return; }
reset();
entries=[]; renderAll();
await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});
showToast('cleared');
});
})();
// ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────
const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');
document.body.addEventListener('click',()=>document.body.focus());
document.addEventListener('paste', async e => {
const items=e.clipboardData?.items; if(!items) return;
for (const it of items) if (it.type.startsWith('image/')) {
e.preventDefault(); const blob=it.getAsFile(); if(!blob) continue;
pasteMsg.innerHTML='<div class="title">Processing paste…</div><div class="detail">'+Math.round(blob.size/1024)+' KB</div>';
pasteOverlay.classList.add('active');
try {
const dataUrl = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(blob); });
const res = await fetch('api/paste',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel,dataUrl,source:'clipboard'})});
const r = await res.json();
if (r.ok) {
let oi = (r.original_w&&r.original_h)?`<div class="detail">Original: <span class="val">${r.original_w}×${r.original_h}</span></div>`:'';
pasteMsg.innerHTML=`<div class="title">Saved to log</div><div class="detail">File: <span class="val">${esc(r.name)}</span></div>`+
`<div class="detail">Size: <span class="val">${r.w}×${r.h}</span> · <span class="val">${r.kb} KB</span></div>`+oi;
setTimeout(()=>pasteOverlay.classList.remove('active'),2500);
} else { pasteMsg.innerHTML='<div class="title" style="color:#f85149">Paste failed</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }
} catch(err){ pasteMsg.innerHTML='<div class="title" style="color:#f85149">Error: '+esc(err.message)+'</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }
break;
}
});
// Refresh relative timestamps periodically (cards use absolute, but keep ts tooltips fresh) — no-op for now
})();
</script>
</body>
</html>