app
Shotlog - Screenshot Log
Public Made by Adomby adom
See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.
master
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203
<!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; overflow: hidden; } /* never paint under the actions */
.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: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.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. */
/* ONE ROW, always (John, 2026-09-02: "why are you wasting those top 2 lines").
As the panel narrows we shed TEXT, never rows: the brand words go first,
then the Pup button's label (icon only), and the channel name ellipsizes.
Only under ~260px (narrower than any real column) may the actions wrap. */
@media (max-width: 560px) {
header { gap: 8px; padding: 0 10px; }
.brand .app-name, .brand .sep, .brand .app-subtitle { display: none; } /* logo carries identity */
.vsep { display: none; }
.subject-meta { display: none; } /* the count lives in the tab strip's tooltip */
}
@media (max-width: 420px) {
#canvasBtn span { display: none; } /* Pup / Webview button: icon only */
#canvasBtn { padding: 0; width: 26px; justify-content: center; }
.actions { gap: 4px; }
}
@media (max-width: 260px) {
header { height: auto; min-height: 44px; padding: 4px 8px; flex-wrap: wrap; }
.actions { flex-wrap: wrap; row-gap: 4px; }
}
/* 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); }
/* Version pill: always shows what's RUNNING. Amber when a newer shotlog is
published on the wiki (broker behind). Clicking it forces a fresh reload. */
.settings-head .ver-pill { margin-left: 10px; vertical-align: middle; }
.ver-pill { flex-shrink: 0; font: 600 10px/1 'JetBrains Mono', monospace; color: var(--text3);
border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; cursor: pointer;
white-space: nowrap; transition: color .15s, border-color .15s, background .15s; }
.ver-pill:hover { color: var(--text); border-color: rgba(0,184,176,.5); }
.ver-pill.stale { color: #e3b341; border-color: rgba(227,179,65,.55); background: rgba(227,179,65,.10); }
.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); }
/* The ⓘ reveal chip on the image: only exists in compact mode */
.card-info-hot { display: none; }
/* ── COMPACT MODE (the default): images ARE the gallery ─────────────────────
The .card-info block (headline/desc + meta chips) hides; hovering/tapping
the ⓘ chip on the image overlays it. Images render at natural aspect (no
letterbox) so ~90% of a skinny webview is thumbnails. */
body.compact .feed { gap: 8px; padding: 8px; }
body.compact .shot-card { position: relative; }
body.compact .card-info { display: none; }
/* The revealed overlay is FLEETING (gone on mouse-out), so it NEVER scrolls
internally: it sizes to its full content and may spill past the card's
bounds, floating over whatever is below (like a tooltip would). */
body.compact .card-info.show { display: block; position: absolute; top: 0; left: 0; right: 0;
z-index: 6; background: rgba(13,17,23,.96); backdrop-filter: blur(3px);
border: 1px solid var(--border); border-radius: 10px;
padding-bottom: 9px; box-shadow: 0 10px 28px rgba(0,0,0,.55); }
/* Let the overlay escape the card's overflow:hidden clip while it's shown */
body.compact .shot-card:has(.card-info.show) { overflow: visible; }
/* natural aspect: the browser sizes the box from width x aspect (no fixed
height = no letterbox bars), capped so one giant portrait can't eat the feed */
body.compact .shot-card-img { height: auto; max-height: 420px; object-fit: contain; }
/* WIDE canvas (pup / big browser / full-width webview) - wiki issue #2: a
height cap starved the image of width (a 1400x765 shot sat ~800px wide in a
1500px card, centered in dead margins). On wide viewports the image takes
the CARD'S FULL WIDTH at natural aspect, capped only by one screen height.
Applies to classic mode too (its fixed 220px thumbnail was the original
offender); the skinny <900px panel keeps its compact caps. */
@media (min-width: 900px) {
.shot-card-img, body.compact .shot-card-img { height: auto; max-height: 100vh; object-fit: contain; }
}
/* his most-used control stays visible, not hover-revealed */
/* ⛶ is HOVER-ONLY everywhere (John): a click already fills the window, the
icon just tells you so when you hover the thumbnail. */
body.compact .card-fill-btn { opacity: 0; }
body.compact .shot-card:hover .card-fill-btn, body.compact .card-fill-btn:focus-visible { opacity: 1; }
/* the ⓘ reveal chip, top-left over the image (mirrors the ⛶ top-right) */
body.compact .card-info-hot { display: inline-flex; position: absolute; top: 10px; left: 10px;
width: 32px; height: 32px; align-items: center; justify-content: center;
background: rgba(13,17,23,.72); color: var(--text); border: 1px solid rgba(255,255,255,.18);
border-radius: 8px; cursor: help; backdrop-filter: blur(3px);
font: 600 15px/1 'JetBrains Mono', monospace; padding: 0;
opacity: 0; transition: opacity .15s, background .15s, border-color .15s; }
body.compact .shot-card:hover .card-info-hot, body.compact .card-info-hot:focus-visible { opacity: 1; }
body.compact .card-info-hot:hover { background: rgba(0,184,176,.22); border-color: rgba(0,184,176,.6); color: var(--accent-bright); }
@media (hover: none) { body.compact .card-info-hot { opacity: .9; } }
/* child-window strip stays (it's image content); tighten its caption */
body.compact .card-popups .ph { padding-top: 4px; }
/* 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.ref { font-family: 'JetBrains Mono', monospace; color: var(--text3); }
.chip.tag { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); font-weight: 600; }
/* ref/handle strip ABOVE the thumbnail (not on it, so a screenshot of the
thumbnail stays clean): one slim mono line, the NAME of the shot for
talking to your AI. */
.card-refbar { display: flex; align-items: center; gap: 8px; padding: 3px 10px 2px; min-height: 16px;
font: 600 10.5px/1.3 'JetBrains Mono', monospace; color: var(--text3); }
.card-refbar .rb-ref { letter-spacing: .2px; }
/* copy button (Ray, PR #5): hover-revealed like ⛶, never on the thumbnail */
.card-refbar .rb-copy svg { width: 13px; height: 13px; display: block; }
.card-refbar .rb-copy { margin-left: 2px; padding: 2px 5px; border-radius: 5px; cursor: pointer; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
font: inherit; line-height: 1.5; color: var(--accent-bright); background: rgba(0,184,176,.10);
border: 1px solid rgba(0,184,176,.55); opacity: 1; transition: color .12s, border-color .12s, background .12s; }
/* Always visible (Ray, 2026-10-16: hover-revealed and grey, it was too faint to find). */
.shot-card:hover .card-refbar .rb-copy, .card-refbar .rb-copy:focus-visible { opacity: 1; }
.card-refbar .rb-copy:hover { color: #fff; background: rgba(0,184,176,.30); border-color: var(--accent-bright); }
.card-refbar .rb-copy.done { opacity: 1; color: var(--accent-bright); border-color: rgba(0,184,176,.5); }
@media (hover: none) { .card-refbar .rb-copy { opacity: .8; } }
.card-refbar .rb-tag { color: var(--accent-bright); background: rgba(0,184,176,.12); border: 1px solid rgba(0,184,176,.45);
border-radius: 5px; padding: 0 6px; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* caption pill (gold): an annotated shot carries a longer caption from the
AI that sent it. It starts the caption here, cut to fit the narrow
sidebar, and hovering it reveals the full info overlay, like the ⓘ. */
.card-refbar .rb-cap { color: #e3b341; background: rgba(227,179,65,.10); border: 1px solid rgba(227,179,65,.45);
border-radius: 5px; padding: 0 6px; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; cursor: help; font-family: 'Satoshi', sans-serif; font-weight: 600; }
/* classic mode already shows the caption above the image */
body:not(.compact) .card-refbar .rb-cap { display: none; }
.card-refbar .rb-cap:hover { background: rgba(227,179,65,.18); border-color: rgba(227,179,65,.7); }
.shot-card:not(.has-refbar) .card-refbar { }
.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; }
/* Fullscreen affordance: a ⛶ button on the thumbnail (hover-revealed).
The thumbnail IMAGE also fills the window in one click (see the click
handler on img, "ONE click on a thumbnail goes STRAIGHT to fill-window"),
so this button is the discoverable, keyboard-focusable route to the same
thing rather than a separate shortcut past the modal. (Ray, PR #1) */
.card-img-wrap { position: relative; }
.card-fill-btn { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(13,17,23,.72); color: var(--text); border: 1px solid rgba(255,255,255,.18);
border-radius: 8px; cursor: zoom-in; backdrop-filter: blur(3px);
opacity: 0; transition: opacity .15s, background .15s, border-color .15s; }
.shot-card:hover .card-fill-btn, .card-fill-btn:focus-visible { opacity: 1; }
.card-fill-btn:hover { background: rgba(0,184,176,.22); border-color: rgba(0,184,176,.6); color: var(--accent-bright); }
.card-fill-btn svg { width: 16px; height: 16px; }
/* No hover on touch devices — keep it visible there. */
@media (hover: none) { .card-fill-btn { opacity: .9; } }
/* 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 .m-img-wrap { position: relative; }
.modal-card .m-img-wrap img { display: block; width: 100%; max-height: 84vh; object-fit: contain; background: #000; cursor: zoom-in; }
/* "Fill window" button overlaid on the modal image (top-right). In HD this
grows the webview to a 90%-of-window lightbox; elsewhere it's true
fullscreen. */
.modal-card .m-fill-btn { position: absolute; top: 10px; right: 10px; width: 34px; height: 34px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(13,17,23,.72); color: var(--text); border: 1px solid rgba(255,255,255,.18);
border-radius: 8px; cursor: pointer; backdrop-filter: blur(3px); transition: background .15s, border-color .15s; }
.modal-card .m-fill-btn:hover { background: rgba(0,184,176,.22); border-color: rgba(0,184,176,.6); color: var(--accent-bright); }
.modal-card .m-fill-btn svg { width: 17px; height: 17px; }
/* Modal close (×) — top-right of the whole card; the image no longer closes on
click (it fills the window now), so closing is explicit. */
.modal-card .m-close { position: absolute; top: 8px; right: 8px; width: 30px; height: 30px; z-index: 2;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(13,17,23,.7); color: var(--text2); border: 1px solid var(--border);
border-radius: 8px; cursor: pointer; font-size: 17px; line-height: 1; }
.modal-card .m-close:hover { color: var(--text); border-color: rgba(0,184,176,.5); }
/* Fill-window LIGHTBOX — the element that goes fullscreen via the standard
Fullscreen API. In HD's native webview this becomes a 90%-of-window lightbox
that escapes the skinny panel; in pup/browser it's true fullscreen; on a
phone or where the API is missing, the .open class alone makes it a full
fixed-overlay (graceful CSS fallback, no API needed). */
#lightbox { position: fixed; inset: 0; z-index: 300; display: none; cursor: zoom-out;
align-items: center; justify-content: center; background: rgba(0,0,0,.95);
/* A dismiss MARGIN inside our own rectangle: in Adom Hydrogen the host
grants a 90%-of-window lightbox and the area OUTSIDE it is host chrome
our clicks never reach. So the image sits at ~92% of that box and the
dark border around it is ours - a click there (or on the image) exits. */
padding: 4vh 4vw; box-sizing: border-box; }
#lightbox .lb-hint { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); font: 11px/1.4 'Satoshi', sans-serif;
color: rgba(255,255,255,.5); pointer-events: none; white-space: nowrap; }
#lightbox.open { display: flex; }
/* Fill ALL available space: scale the image UP as well as down until it hits
the edges on its constraining axis (object-fit: contain keeps aspect ratio,
letterboxing the other axis). width/height:auto + max:100% only ever shrinks
and never grows, so a small shot floated tiny in the middle — the bug. */
#lightbox img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* When the host actually grants fullscreen, fill whatever rectangle it gave us
(HD's 90%, or the true screen) — never a fixed cap; let the host size it. */
#lightbox:fullscreen, #lightbox:-webkit-full-screen { width: 100%; height: 100%; background: #000; }
#lightbox .lb-close { position: absolute; top: 14px; right: 16px; width: 40px; height: 40px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(13,17,23,.72); color: #fff; border: 1px solid rgba(255,255,255,.22);
border-radius: 9px; cursor: pointer; font-size: 20px; line-height: 1; backdrop-filter: blur(3px); }
#lightbox .lb-close:hover { background: rgba(0,184,176,.28); border-color: rgba(0,184,176,.7); }
/* 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 Bridge") - 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) */
/* ===== Settings (adom-ui-design > settings-layout) ===== */
/* Backdrop law: dim 25%, NEVER blur (settings often change the app behind). */
.helper.settings-overlay { background: rgba(0,0,0,.25); backdrop-filter: none; align-items: flex-start; padding: 24px 12px; overflow: hidden; }
.settings-dialog { padding: 0; overflow: hidden; border-radius: 12px; width: min(820px, 100%); max-width: none; max-height: calc(100vh - 48px); display: flex; flex-direction: column; outline: none; }
.settings-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.settings-head .h-title { margin: 0; }
.settings-head .h-close { position: static; }
.settings-body { overflow: auto; padding: 16px 18px 18px; min-height: 0; }
.settings-body::-webkit-scrollbar-track { margin: 12px 0; }
.settings-card { background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid rgba(0,184,176,.45);
border-radius: 10px; padding: 12px 16px 14px; margin-bottom: 14px; }
.settings-card .card-title { margin: 0; font: 600 13.5px 'Familjen Grotesk', sans-serif; color: var(--accent); display: flex; align-items: center; gap: 10px; height: 2rem; }
.settings-card .card-status { font: 400 11px 'Satoshi', sans-serif; color: var(--text2); }
.settings-card .card-status.ok { color: #3fb950; }
.settings-card .card-desc { margin: 0 0 4px; font: 12.5px/1.55 'Satoshi', sans-serif; color: var(--text2); max-width: 60ch; }
.settings-card code, .footer-note code { font-family: 'JetBrains Mono', monospace; font-size: 11px; background: rgba(110,118,129,.2); padding: 1px 4px; border-radius: 4px; }
.set-row { padding: 10px 0 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 5px; }
.settings-card .card-desc + .set-row { border-top: 1px solid var(--border); }
.set-name { margin: 0; font: 600 14px/1.3 'Familjen Grotesk', sans-serif; min-height: 2rem; display: flex; flex-wrap: wrap; align-items: baseline; color: var(--text); }
.set-name .prefix { white-space: nowrap; }
.set-name .prefix { margin-right: .35em; } /* flex collapses the literal space after the colon */
.set-name .title { color: #8fd3d4; } /* brand light teal: the part that varies */
.set-desc { margin: 0; font: 12.5px/1.55 'Satoshi', sans-serif; color: var(--text2); max-width: 60ch; }
.set-ctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.set-foot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.default-hint { font: 12px 'Satoshi', sans-serif; color: var(--text2); }
.set-foot .ai-chip { margin: 0; }
/* dependent settings: indented under the one they depend on, with a spine; dimmed, never hidden */
.set-children { margin-left: .75rem; padding-left: 1rem; border-left: 2px solid var(--border); }
.set-children .set-row.set-child { border-top: none; }
.set-child-why { font: 11.5px 'Satoshi', sans-serif; color: var(--text3); padding-top: 8px; }
.set-children[data-enabled="false"] { opacity: .55; }
.set-children[data-enabled="false"] .toggle, .set-children[data-enabled="false"] .num-wrap { pointer-events: none; }
/* Webview layout: an explicit TWO-OPTION picker (John: "hard to tell you're
giving me 2 choices"). Each option is a box with a radio header, its real
screenshot, and ONLY the controls that apply to it. The unselected box
stays visible but dimmed, its controls disabled (never hidden). */
.layout-pick { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin: 8px 0 4px; }
.layout-opt { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px 8px; background: var(--surface); transition: border-color .15s, box-shadow .15s; }
.layout-opt.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(0,184,176,.35) inset; }
.layout-opt:not(.selected) .setting-shot, .layout-opt:not(.selected) .opt-desc { opacity: .6; }
.opt-head { -webkit-appearance: none; appearance: none; width: 100%; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; background: none; border: none; padding: 0 0 8px; cursor: pointer; color: var(--text); text-align: left; }
.opt-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.opt-radio { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--text3); flex-shrink: 0; align-self: center; position: relative; transition: border-color .15s; }
.layout-opt.selected .opt-radio { border-color: var(--accent); }
.layout-opt.selected .opt-radio::after { content: ''; position: absolute; inset: 3px; border-radius: 50%; background: var(--accent); }
.opt-head:hover .opt-radio { border-color: var(--accent); }
.opt-title { font: 700 14px 'Familjen Grotesk', sans-serif; color: var(--text); }
.layout-opt.selected .opt-title { color: #8fd3d4; }
.opt-sub { font: 12px 'Satoshi', sans-serif; color: var(--text2); }
.opt-desc { margin: 6px 0 2px; font: 12.5px/1.5 'Satoshi', sans-serif; color: var(--text2); }
.opt-controls { margin-top: 6px; border-top: 1px solid var(--border); }
.opt-controls .set-row.set-child { border-top: none; padding-top: 8px; }
.opt-controls[data-enabled="false"] { opacity: .5; }
.opt-controls[data-enabled="false"] .toggle, .opt-controls[data-enabled="false"] .num-wrap { pointer-events: none; }
.layout-opt .setting-shot { max-width: none; }
/* screenshots of the thing the setting changes: real captures, both states */
.set-shots { display: flex; gap: 12px; flex-wrap: wrap; margin: 4px 0 6px; }
.setting-shot { margin: 0; flex: 1 1 260px; max-width: 520px; }
.setting-shot img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
.setting-shot figcaption { margin-top: 5px; font: 12px 'Satoshi', sans-serif; color: var(--text2); }
/* house toggle: teal-on / neutral-off track, WHITE thumb, press compresses, knob stretches */
.toggle { -webkit-appearance: none; appearance: none; background: none; border: none; padding: 2px; cursor: pointer; flex-shrink: 0; width: auto; height: auto; border-radius: 10px; transition: transform .06s ease; position: static; }
.toggle::after { content: none; }
.toggle .toggle-track { display: block; width: 32px; height: 18px; background: var(--overlay); border: 1px solid var(--border); border-radius: 9px; position: relative; transition: background .15s ease, border-color .15s ease; }
.toggle:hover .toggle-track { border-color: var(--accent); }
.toggle:active { transform: translateY(1px) scale(.97); }
.toggle[aria-checked="true"] .toggle-track { background: var(--accent); border-color: var(--accent); }
.toggle .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: #fff; border-radius: 50%; transition: transform .18s ease, width .12s ease; }
.toggle[aria-checked="true"] .toggle-thumb { transform: translateX(14px); }
.toggle:active .toggle-thumb { width: 16px; }
.toggle[aria-checked="true"]:active .toggle-thumb { transform: translateX(10px); }
.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* footer: two-click Restore defaults, auto-disarming */
.settings-footer { margin-top: 6px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
.footer-note { margin: 0; font: 12px/1.5 'Satoshi', sans-serif; color: var(--text2); max-width: 70ch; }
.restore-btn { width: fit-content; display: inline-flex; align-items: center; gap: 6px; padding: 6px 13px; background: transparent;
border: 1px solid var(--border); border-radius: 6px; color: var(--text); font: 600 13px 'Familjen Grotesk', sans-serif; cursor: pointer; }
.restore-btn:hover { background: var(--elevated); border-color: rgba(0,184,176,.5); }
.restore-btn:active { transform: translateY(1px); }
.restore-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.restore-btn.armed { color: var(--danger); border-color: rgba(248,81,73,.5); background: rgba(248,81,73,.08); }
/* input feedback on header buttons */
.hdr-btn:focus-visible, .h-close:focus-visible, .seg button:focus-visible, .num-step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.hdr-btn.settings-cog svg { width: 15px; height: 15px; }
@media (max-width: 520px) {
.helper.settings-overlay { padding: 8px 6px; }
.settings-dialog { max-height: calc(100vh - 16px); }
.settings-body { padding: 12px 12px 14px; }
.set-shots { flex-direction: column; }
}
.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-illus { display: block; width: 100%; max-width: 520px; margin: 8px 0 6px; border-radius: 8px; border: 1px solid var(--border); }
/* Rows that do not apply in the current mode (shared pane vs own column) */
.pref-row.na { opacity: .45; }
.pref-row.na .toggle, .pref-row.na .num-wrap { pointer-events: none; }
.pref-row.na .pref-name::after { content: ' (not used with Own column)'; font-weight: 400; color: var(--text3); font-size: 11px; }
#rowOwnCol.na .pref-name::after { content: ''; }
.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-legacy-unused { 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; }
border-radius: 50%; background: var(--text2); transition: transform .18s, background .18s; }
.toggle-legacy-unused[aria-checked="true"] { }
.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 4px 0 10px; flex-shrink: 0; }
.num-wrap:focus-within { border-color: rgba(0,184,176,.6); }
.num-wrap .num-input { -webkit-appearance: none; appearance: textfield; width: 46px;
background: transparent; border: none; outline: none; color: var(--text);
font: 600 13px 'JetBrains Mono', monospace; text-align: right; }
.num-wrap .num-input::-webkit-inner-spin-button, .num-wrap .num-input::-webkit-outer-spin-button { -webkit-appearance: none; }
.num-wrap .unit { color: var(--text3); font: 500 11px 'Satoshi', sans-serif; }
/* Up/down stepper buttons on number inputs */
.num-wrap .num-step { -webkit-appearance: none; appearance: none; flex-shrink: 0;
width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center;
background: var(--elevated); color: var(--text2); border: 1px solid var(--border);
border-radius: 5px; cursor: pointer; font: 600 15px/1 'Satoshi', sans-serif; padding: 0;
transition: background .12s, color .12s, border-color .12s; }
.num-wrap .num-step:first-child { margin-right: 2px; }
.num-wrap .num-step:hover { background: rgba(0,184,176,.16); color: var(--accent-bright); border-color: rgba(0,184,176,.5); }
.num-wrap .num-step:active { transform: translateY(.5px); }
/* A setting that pairs an on/off switch with its number field */
.pref-control { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; }
/* When the switch is OFF, grey + disable the number field */
.pref-control.off .num-wrap, .num-wrap.off { opacity: .4; pointer-events: none; }
.ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;
letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; }
/* 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" class="compact">
<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="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 danger" id="clearBtn" data-tooltip="Delete ALL screenshots in EVERY channel and remove all the thread tabs (cannot be undone). Safe for running AIs: a channel auto-recreates the moment any thread injects to it again.">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>
<button class="hdr-btn icon-only settings-cog" id="prefsBtn" aria-label="Settings" data-tooltip="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<div class="hover-popover" id="app-info" role="dialog">
<div class="pop-title">adom-shotlog</div>
<div class="pop-row"><span class="k">version</span><span class="v" id="appInfoVer">…</span></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 Bridge</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-bridge" target="_blank" rel="noopener">Adom Bridge wiki ↗</a>
</div>
</div>
</div>
<div class="helper settings-overlay" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Shotlog Settings">
<div class="helper-card settings-dialog" tabindex="-1" id="prefsCard">
<div class="settings-head">
<div class="h-title">Shotlog Settings <span class="ver-pill" id="verPill" tabindex="0" data-tooltip="Loading version…">v…</span></div>
<button class="h-close" id="prefsClose" aria-label="Close settings" data-tooltip="Close (Esc, or click outside)">✕</button>
</div>
<div class="settings-body" id="prefsBody">
<section class="settings-card" id="cardCapture">
<h3 class="card-title">Capture <span class="card-status" id="stHook"></span></h3>
<p class="card-desc">What gets into shotlog without anyone asking. shotlog installs one Claude Code hook, <code>PostToolUse(Read)</code>, in <code>~/.claude/settings.json</code>. Whenever any AI thread reads an image file, the hook copies that exact file into the shared <code>read-captures</code> channel (a 2-second-capped local HTTP post that can never block the AI). Deliberate <code>adom-shotlog inject</code> calls are unaffected by this switch.</p>
<div class="set-row">
<h4 class="set-name" title="read_hook"><span class="prefix">Capture: </span><span class="title">Auto-capture what AIs read</span></h4>
<p class="set-desc">Every image any AI thread reads is copied into read-captures. If no shotlog is showing anywhere, one auto-opens on your default canvas and brings the read-captures tab to the front. Turn off to stop auto-capturing; deliberate injects still work. Takes effect on the next image an AI reads.</p>
<div class="set-ctl"><button class="toggle" id="prefHook" role="switch" aria-checked="true" aria-label="Auto-capture what AIs read" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn the shotlog read-hook off</span></div>
</div>
</section>
<section class="settings-card" id="cardNotify">
<h3 class="card-title">Notifications <span class="card-status" id="stNotify"></span></h3>
<p class="card-desc">How a new shot gets your attention. shotlog never steals the OS foreground: the strongest cue it will ever use is an orange flash. Unseen badges on channel tabs and the page title work regardless of these switches, and the webview keeps receiving every shot in the background no matter what: switch to it yourself and everything is already there.</p>
<div class="set-row">
<h4 class="set-name" title="update_alerts"><span class="prefix">Notifications: </span><span class="title">Update alerts (orange flashes)</span></h4>
<p class="set-desc">When new shots arrive: the webview tab highlights orange, the in-app channel tab flashes, and the pup window flashes its taskbar. Unseen badges still show either way; this only controls the orange flashing.</p>
<div class="set-ctl"><button class="toggle" id="prefAlerts" role="switch" aria-checked="false" aria-label="Update alerts" data-tooltip="Off (default): no orange flashing when shots arrive. On: webview tab + in-app tab + pup taskbar flash orange."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: Off</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn on the shotlog orange flashes</span></div>
</div>
<div class="set-row">
<h4 class="set-name" title="update_autoswitch"><span class="prefix">Notifications: </span><span class="title">Auto-switch to updated channel</span></h4>
<p class="set-desc">When a shot arrives for another channel, that channel's tab jumps to the front. Turn off to stay where you are; the tab just shows an unseen badge (and flashes, if alerts are on).</p>
<div class="set-ctl"><button class="toggle" id="prefAutoswitch" role="switch" aria-checked="true" aria-label="Auto-switch to updated channel" data-tooltip="Auto-switch the in-app channel tab when a new shot arrives for it."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog auto-switching tabs</span></div>
</div>
<div class="set-row">
<h4 class="set-name" title="webview_bring_forward"><span class="prefix">Notifications: </span><span class="title">Bring the webview tab forward</span></h4>
<p class="set-desc">When a shot lands and the Shotlog webview is open but behind another tab in its pane, pull it to the front (default). Turn it off if you live in webviews and do not want to be pulled away: you stay on whatever tab you were reading, and the webview still receives every shot (including auto-captures from the read-hook) in the background, so it is fully up to date the moment you switch to it. This is about the Hydrogen tab; the switch above is about channel tabs inside shotlog.</p>
<div class="set-ctl"><button class="toggle" id="prefBringFwd" role="switch" aria-checked="true" aria-label="Bring the webview tab forward" data-tooltip="On (default): every new shot pulls the Shotlog tab to the front of its pane. Off: the tab stays where it is; shots keep arriving in the background."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog pulling its webview tab to the front</span></div>
</div>
</section>
<section class="settings-card" id="cardGallery">
<h3 class="card-title">Gallery <span class="card-status" id="stGallery"></span></h3>
<p class="card-desc">How cards look in the list. Either way, one click on a thumbnail fills the window and the next click (or Esc) comes back; the ref (#42) and any handle sit in the strip above each thumbnail.</p>
<div class="set-row">
<h4 class="set-name" title="compact"><span class="prefix">Gallery: </span><span class="title">Compact cards (image-first)</span></h4>
<p class="set-desc">The gallery is mostly thumbnails: the info text (headline, time, sizes, provenance chips) hides behind the ⓘ chip on each image; hover it to reveal. Turn off for the classic text-above-image cards.</p>
<div class="set-ctl"><button class="toggle" id="prefCompact" role="switch" aria-checked="true" aria-label="Compact cards" data-tooltip="On (default): image-first cards, info on hover. Off: classic cards with the text always visible."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn off shotlog compact mode</span></div>
</div>
</section>
<section class="settings-card" id="cardCanvas">
<h3 class="card-title">Canvas <span class="card-status" id="stCanvas"></span></h3>
<p class="card-desc">Which surface shotlog opens on when it has to open one itself. 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 for half an hour. Pup needs Adom Bridge.</p>
<div class="set-row">
<h4 class="set-name" title="default_canvas"><span class="prefix">Canvas: </span><span class="title">Default canvas</span></h4>
<p class="set-desc">Where shotlog auto-opens a channel when nobody is watching. Pick the other canvas while you are watching and closing this dialog moves your shotlog there too, after a 3-2-1 countdown you can cancel.</p>
<div class="set-ctl"><div class="seg" id="prefCanvas"><button data-v="webview">Webview</button><button data-v="pup">Pup</button></div></div>
<div class="set-foot"><span class="default-hint">Default: Webview</span><span class="ai-chip"><span class="lbl">or say to your AI</span>make shotlog default to pup</span></div>
</div>
</section>
<section class="settings-card" id="cardLayout">
<h3 class="card-title">Webview layout <span class="card-status" id="stLayout"></span></h3>
<p class="card-desc">Where the shotlog webview lives inside your Hydrogen workspace. Pick ONE of the two layouts below; each shows a real screenshot of what you get, and the settings that only apply to that layout sit inside its box.</p>
<div class="layout-pick" role="radiogroup" aria-label="Webview layout">
<div class="layout-opt" data-own="false" id="optShared">
<button class="opt-head" role="radio" aria-checked="true" data-own="false"><span class="opt-radio"></span><span class="opt-title">Shared pane</span><span class="opt-sub">a tab inside your webview pane</span></button>
<figure class="setting-shot"><img id="shotOwnOff" src="own-column-off.png?v=3" alt="Shared pane: shotlog is one more tab inside the webview pane, beside your other webviews" loading="lazy"><figcaption>Shotlog sits as a tab beside your other webviews.</figcaption></figure>
<p class="opt-desc">Default. Shotlog joins your existing webview pane as one more tab, so it shares that pane's width with your other webviews.</p>
<div class="opt-controls" id="sharedChildren" data-enabled="true">
<div class="set-row set-child" id="rowWidth">
<h4 class="set-name" title="webview_width"><span class="prefix">Shared pane: </span><span class="title">Pane width</span></h4>
<p class="set-desc">The width shotlog uses when it first CREATES the shared pane, as a percent of your workspace (10-50). It only seeds a brand-new pane; it never resizes one you already have unless auto-size is on.</p>
<div class="set-ctl"><span class="num-wrap" data-tooltip="Percent of the workspace width for the shared webview pane (10-50)."><button class="num-step" data-target="prefWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span><button class="num-step" data-target="prefWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></div>
<div class="set-foot"><span class="default-hint">Default: 20%</span><span class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</span></div>
</div>
<div class="set-row set-child" id="rowAutosize">
<h4 class="set-name" title="webview_autosize"><span class="prefix">Shared pane: </span><span class="title">Auto-size the pane</span></h4>
<p class="set-desc">On: every time shotlog opens, it snaps the shared pane back to the width above (rude if you use webviews a lot, hence off). Off: shotlog leaves the pane at whatever width you last dragged it to.</p>
<div class="set-ctl"><button class="toggle" id="prefAutosize" role="switch" aria-checked="false" aria-label="Auto-size the pane" data-tooltip="On: snap the shared pane to your width on every open. Off (default): respect the width you set manually."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>
<div class="set-foot"><span class="default-hint">Default: Off</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn on shotlog webview auto-sizing</span></div>
</div>
</div>
</div>
<div class="layout-opt" data-own="true" id="optOwn">
<button class="opt-head" role="radio" aria-checked="false" data-own="true"><span class="opt-radio"></span><span class="opt-title">Own column</span><span class="opt-sub">a third column at the right edge</span></button>
<figure class="setting-shot"><img id="shotOwnOn" src="own-column.png?v=3" alt="Own column: your webview pane keeps its tabs and width, and shotlog is a separate narrow column at the far right" loading="lazy"><figcaption>Shotlog is its own narrow column at the far right.</figcaption></figure>
<p class="opt-desc">Shotlog opens as its own narrow third column at the right edge, the width below, so it is always in view while your webviews keep their pane and their width. The editor gives up the space; shotlog never resizes your webview pane.</p>
<div class="opt-controls" id="ownChildren" data-enabled="false">
<div class="set-row set-child" id="rowOwnCol">
<h4 class="set-name" title="own_column_width"><span class="prefix">Own column: </span><span class="title">Column width</span></h4>
<p class="set-desc">Width of shotlog's own column as a percent of the screen (5-30).</p>
<div class="set-ctl"><span class="num-wrap" data-tooltip="Width of shotlog's own column, percent of the screen (5-30)."><button class="num-step" data-target="prefOwnWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefOwnWidth" type="number" min="5" max="30" step="5"><span class="unit">%</span><button class="num-step" data-target="prefOwnWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></div>
<div class="set-foot"><span class="default-hint">Default: 10%</span><span class="ai-chip"><span class="lbl">or say to your AI</span>open shotlog as its own 10% column on the right</span></div>
</div>
</div>
</div>
</div>
<div class="set-foot" style="padding-top:8px"><span class="default-hint">Default: Shared pane</span></div>
</section>
<section class="settings-card" id="cardStorage">
<h3 class="card-title">Storage <span class="card-status" id="stStorage"></span></h3>
<p class="card-desc">How much stays on disk. A janitor runs at startup and every 6 hours; it deletes the oldest files first and never touches a channel that is still receiving shots. The clear button in the header is the manual version: it wipes every channel at once and reports what it freed.</p>
<div class="set-row">
<h4 class="set-name" title="max_shots"><span class="prefix">Storage: </span><span class="title">Keep per channel</span></h4>
<p class="set-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Turn the switch off to keep every shot (unlimited on disk).</p>
<div class="set-ctl"><span class="pref-control"><button class="toggle" id="prefKeepToggle" role="switch" aria-checked="true" aria-label="Keep per channel" data-tooltip="On: cap the shots kept per channel. Off: keep everything (unlimited)."><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Turn the switch off to keep unlimited."><button class="num-step" data-target="prefMaxShots" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="25"><span class="unit">shots</span><button class="num-step" data-target="prefMaxShots" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></span></div>
<div class="set-foot"><span class="default-hint">Default: On, 200 shots</span><span class="ai-chip"><span class="lbl">or say to your AI</span>keep only the newest 100 shots per shotlog channel</span></div>
</div>
<div class="set-row">
<h4 class="set-name" title="retention_days"><span class="prefix">Storage: </span><span class="title">Prune idle channels after</span></h4>
<p class="set-desc">Channels with no new shots for this many days are deleted entirely. Turn the switch off to never prune.</p>
<div class="set-ctl"><span class="pref-control"><button class="toggle" id="prefPruneToggle" role="switch" aria-checked="true" aria-label="Prune idle channels" data-tooltip="On: prune idle channels after N days. Off: never prune."><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. Turn the switch off to never prune."><button class="num-step" data-target="prefRetention" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefRetention" type="number" min="1" max="3650" step="5"><span class="unit">days</span><button class="num-step" data-target="prefRetention" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></span></div>
<div class="set-foot"><span class="default-hint">Default: On, 30 days</span><span class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</span></div>
</div>
</section>
<footer class="settings-footer">
<p class="footer-note">Settings are stored per environment in <code><data-dir>/.prefs.json</code> on this machine and apply to every open shotlog immediately. Restoring returns every option above to its default.</p>
<button class="restore-btn" id="prefsRestore" data-tooltip="First click arms, second click restores every setting to its default. Disarms by itself after 4 seconds."><span class="r-ico">↺</span><span class="r-lbl">Restore defaults</span></button>
</footer>
</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 Bridge 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 Bridge window screenshots (desktop_screenshot_window).">Bridge·window</button>
<button class="fchip" data-p="ad-screen" data-tooltip="Adom Bridge full-screen screenshots.">Bridge·screen</button>
<button class="fchip" data-p="pup" data-tooltip="pup (Puppeteer bridge on Adom Bridge) page captures.">pup</button>
<button class="fchip" data-p="hydrogen" data-tooltip="Adom Hydrogen workspace screenshots.">Hydrogen</button>
<button class="fchip" data-p="abe" data-tooltip="NB (Adom Native Browser) captures of the user's real signed-in browser.">NB</button>
<button class="fchip" data-p="hd" data-tooltip="Adom Hydrogen's built-in self-screenshot feature.">Hydrogen·self</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">
<button class="m-close" id="mClose" data-tooltip="Close (Esc)" aria-label="Close">✕</button>
<div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div>
<div class="m-img-wrap" id="mImgWrap">
<img id="mImg" alt="">
<button class="m-fill-btn" id="mFillBtn" aria-label="Fill window"
data-tooltip="Fill the window — zoom this image to a big lightbox. In Adom Hydrogen it escapes the panel to 90% of the window; in a pup/browser it goes fullscreen. Esc to exit."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg></button>
</div>
<div class="m-popups" id="mPopups" style="display:none"></div>
<div id="mNerd"></div>
</div></div>
<div id="lightbox"><img id="lbImg" alt=""><button class="lb-close" id="lbClose" aria-label="Exit fill window">✕</button><div class="lb-hint">click anywhere or Esc to go back</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 (semver+bootepoch)
const verSemver = s => String(s).split('+')[0]; // "0.24.3+123" -> "0.24.3"
// Cache-busting reload: some hosts (HD's WebView2) serve a CACHED page on a
// plain location.reload(), so we change the URL to force a fresh fetch.
// Guarded once per target stamp so it can never loop.
function hardReload(forStamp){
const key='slReloaded-'+forStamp;
if(sessionStorage.getItem(key)) return;
sessionStorage.setItem(key,'1');
try { const u=new URL(location.href); u.searchParams.set('_v', forStamp); location.replace(u.toString()); }
catch { location.reload(); }
}
const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');
const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');
const mPopups = $('mPopups'), mNerd = $('mNerd');
const lightbox = $('lightbox'), lbImg = $('lbImg');
// 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) {
// A card whose info overlay is REVEALED (compact mode ⓘ) is being read:
// no tooltip may float over it (Ray: "the tooltip obscures the popover").
if (el.closest && el.closest('.shot-card')?.querySelector('.card-info.show')) return;
// ...and nothing incidental may cover ANY surface the human deliberately
// opened: another card's overlay, or the brand hover card (Ray, PR #4).
if (document.querySelector('.hover-popover.show, .card-info.show')) return;
// 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); }
window._hideTip = hide;
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 ──────────────────────────────────────────────────────────────
// execCommand('copy') is deprecated but is the only thing that works in a
// sandboxed frame with no clipboard permission, which is where this viewer
// sometimes lives. Used only when navigator.clipboard is missing or rejects.
// (Ray, PR #5)
// The two-overlapping-squares copy glyph everyone recognises (John: "usually
// there's a nice copy to clipboard button used out there in the wild of the
// double square"). Inline SVG rather than a unicode glyph like ⧉ on purpose:
// the ⛶ in card-fill-btn already renders as tofu in some headless captures,
// and this button has to read at 13px in a slim panel. (Ray, PR #6)
const ICON_COPY = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="9" y="9" width="12" height="12" rx="2"/>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const ICON_DONE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<polyline points="20 6 9 17 4 12"/></svg>';
function legacyCopy(text, done) {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok && done) done();
} catch (e) { /* nothing else to try; leave the label alone */ }
}
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); }
// ── Viewport-ordered image loader ─────────────────────────────────────────
// A log is about the most recent shots, so: (1) images inside the viewport
// load first at high priority, (2) images within ~one screen below load next
// at low priority, (3) anything further down waits until you scroll near it,
// (4) images that have been more than ~3 screens away for 20s are UNLOADED
// (src dropped, data-src kept) and reload when they come back. The first
// few cards at the top are never unloaded.
const lazyImg = (() => {
const KEEP_TOP = 4, UNLOAD_AFTER_MS = 20000;
const timers = new WeakMap();
const load = (img, prio) => { if (!img.src && img.dataset.src) { img.fetchPriority = prio; img.src = img.dataset.src; } };
const unload = (img) => { if (img.src && !img.dataset.pinned) { img.removeAttribute('src'); } };
const isTop = (img) => { const c = img.closest('.shot-card'); if (!c) return false; let n = 0, el = c; while ((el = el.previousElementSibling) && n <= KEEP_TOP) n++; return n < KEEP_TOP; };
if (!('IntersectionObserver' in window)) return { watch(img){ load(img, 'auto'); } };
// The gallery scrolls inside .feed (overflow:auto), NOT the window. An
// observer rooted at the viewport gets clipped by that container, so a card
// just below the feed's bottom edge never counts as "near" (only ONE image
// loaded in a tall pup window). Root the observers at the feed itself.
const rootEl = () => document.querySelector('.feed') || null;
// near: in the viewport or within one screen below/above -> load
const near = new IntersectionObserver(entries => {
entries.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); // top of page first
for (const en of entries) {
if (!en.isIntersecting) continue;
const rb = en.rootBounds || { top: 0, bottom: innerHeight };
const inView = en.boundingClientRect.bottom > rb.top && en.boundingClientRect.top < rb.bottom;
load(en.target, inView ? 'high' : 'low');
}
}, { root: rootEl(), rootMargin: '100% 0px 100% 0px' });
// far: more than ~3 screens away for 20s -> unload (never the top cards)
const far = new IntersectionObserver(entries => {
for (const en of entries) {
const img = en.target;
if (en.isIntersecting) { const t = timers.get(img); if (t) { clearTimeout(t); timers.delete(img); } continue; }
if (isTop(img)) continue;
timers.set(img, setTimeout(() => { timers.delete(img); unload(img); }, UNLOAD_AFTER_MS));
}
}, { root: rootEl(), rootMargin: '300% 0px 300% 0px' });
return {
watch(img) { near.observe(img); far.observe(img); },
// stats for tests/debugging: how many watched images currently hold a src
stats() { const all = [...document.querySelectorAll('img[data-src]')]; return { watched: all.length, loaded: all.filter(i => i.src).length }; }
};
})();
window._lazyImg = lazyImg;
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:'Bridge·window',cls:'ad',tip:'Adom Bridge window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows (dropdowns/dialogs).'},
'desktop_screenshot_window':{label:'Bridge·window',cls:'ad',tip:'Adom Bridge window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows.'},
'ad-window-bridge':{label:'Bridge·window',cls:'ad',tip:'Adom Bridge window screenshot: one app window, captured even when backgrounded; may include child windows.'},
'ad-screen':{label:'Bridge·screen',cls:'ad',tip:'Adom Bridge full-screen screenshot (desktop_screenshot_screen): the whole monitor, popups and all.'},
'desktop_screenshot_screen':{label:'Bridge·screen',cls:'ad',tip:'Adom Bridge full-screen screenshot: the whole monitor.'},
'pup':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer bridge on Adom Bridge): a real rendered browser page, lossless.'},
'pup_screenshot':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer bridge on Adom Bridge): a real rendered browser page, lossless.'},
'hydrogen':{label:'Hydrogen',cls:'',tip:'Captured by Adom Hydrogen\'s internal workspace screenshotter.'},
'abe':{label:'NB',cls:'',tip:'Captured by NB (Adom Native Browser): the user\'s real signed-in browser.'},
'nbrowser':{label:'NB',cls:'',tip:'Captured by NB (Adom Native Browser, nbrowser): the user\'s real signed-in browser.'},
'nb':{label:'NB',cls:'',tip:'Captured by NB (Adom Native Browser): the user\'s real signed-in browser.'},
'hd':{label:'Hydrogen·self',cls:'',tip:'Captured by Adom Hydrogen\'s built-in self-screenshot feature.'},
'hd_shot':{label:'Hydrogen·self',cls:'',tip:'Captured by Adom Hydrogen\'s built-in self-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;
// ALL the info text (headline/desc + the meta chip row) lives in ONE
// .card-info container: normal mode renders it above the image as always;
// COMPACT mode hides it and reveals it as a hover overlay (the ⓘ chip on
// the image), so the gallery is ~90% thumbnails.
const info=document.createElement('div'); info.className='card-info';
// Reveal the compact overlay BELOW the ref bar, never over it: the bar
// holds the copy button and the caption pill, and an overlay covering
// them blocked the copy button (Ray, 2026-10-16).
const showInfo=()=>{
const rb=c.querySelector('.card-refbar');
info.style.top = rb ? (rb.offsetTop + rb.offsetHeight) + 'px' : '';
info.classList.add('show'); window._hideTip&&window._hideTip();
};
// 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 (e.auto_description) {
// Annotated (send-hook / `annotate`): the caption the AI wrote when it
// SENT this image is the headline; the prompt and the file name it was
// auto-captured as become the small sub-lines.
// .shot-card-why styling, not .shot-card-desc: captions run long and
// the desc style clips at 76px, which in the narrow sidebar's ⓘ overlay
// cut the caption to a few words. The overlay sizes to its content.
const desc=document.createElement('div'); desc.className='shot-card-why shot-card-caption';
desc.textContent=e.description;
info.appendChild(desc);
if (e.context) {
const why=document.createElement('div'); why.className='shot-card-sub';
why.textContent='prompt: '+e.context;
info.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-/,'');
info.appendChild(sub);
} else 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.');
info.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-/,'');
info.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"'; }
info.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);
// The REF (#id) and optional HANDLE (--tag): how a human points an AI at
// exactly this shot ("#42 is the one" / "heroG is the one").
{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
const ref=document.createElement('span'); ref.className='chip ref'; ref.textContent='#'+e.id;
ref.setAttribute('data-tooltip','Shot ref #'+e.id+'. Say "#'+e.id+'" to your AI to mean exactly this shot.'+(e.tag?'':' (An AI can also give a shot a handle: shotlog inject --tag heroG.)'));
meta.appendChild(ref);
if (e.tag){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);
const tg=document.createElement('span'); tg.className='chip tag'; tg.textContent=e.tag;
tg.setAttribute('data-tooltip','Handle "'+e.tag+'" given by the AI that injected this shot (--tag). Say "'+e.tag+'" to mean exactly this shot.');
meta.appendChild(tg); } }
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); }
info.appendChild(meta);
c.appendChild(info);
const imgWrap=document.createElement('div'); imgWrap.className='card-img-wrap';
const img=document.createElement('img'); img.className='shot-card-img'; img.alt=e.description||e.filename;
// Viewport-ordered loading (John): the newest shots at the TOP load first,
// the ones below the fold load as you scroll to them, and shots far off
// screen are unloaded again over time so a 200-shot log does not pin
// 200 decoded images in memory. The aspect ratio is reserved up front so
// loading/unloading never shifts the scroll position.
if (e.w && e.h) img.style.aspectRatio = e.w + ' / ' + e.h;
img.dataset.src = imgUrl(e);
lazyImg.watch(img);
// ONE click on a thumbnail goes STRAIGHT to fill-window on every surface
// (John: the old 3-layer stack, modal -> fullscreen -> unmax, is gone).
// The next click anywhere (image or margin), or Esc, comes straight back
// to the normal list. The modal stays only for child-window thumbnails.
img.addEventListener('click', ()=>fillWindow(imgUrl(e), e.description||e.filename));
imgWrap.appendChild(img);
// REF/HANDLE strip ABOVE the thumbnail (never ON it: humans screenshot a
// thumbnail to send it back to the AI, so the image itself stays clean).
// Slim, always visible, even in compact mode where the info is hidden.
{ const rb=document.createElement('div'); rb.className='card-refbar';
const rn=document.createElement('span'); rn.className='rb-ref'; rn.textContent='#'+e.id;
rn.setAttribute('data-tooltip','Shot ref #'+e.id+': say "#'+e.id+'" to your AI to mean exactly this shot.'+(e.tag?'':' (An AI can also give a shot a handle: shotlog inject --tag heroG.)'));
rb.appendChild(rn);
if (e.tag){ const tg=document.createElement('span'); tg.className='rb-tag'; tg.textContent=e.tag;
tg.setAttribute('data-tooltip','Handle "'+e.tag+'" given by the AI that injected this shot (--tag). Say "'+e.tag+'" to mean exactly this shot.');
rb.appendChild(tg); }
// Copy the WHOLE reference, "#158 hero-final", not just one half (Ray,
// PR #5): the ref is globally unambiguous and the handle is what a human
// actually says, so pasting both lets the AI resolve it either way and
// survives a handle being reused later. Untagged shots copy just "#158".
{ const txt = '#' + e.id + (e.tag ? ' ' + e.tag : '');
const cp = document.createElement('button'); cp.className = 'rb-copy';
cp.innerHTML = ICON_COPY; cp.setAttribute('aria-label', 'Copy ' + txt);
cp.setAttribute('data-tooltip', 'Copy "' + txt + '": paste it to your AI to mean exactly this shot.');
cp.addEventListener('click', function (ev) {
ev.stopPropagation(); // never let it open the shot
const done = function () {
cp.innerHTML = ICON_DONE; cp.classList.add('done');
setTimeout(function () { cp.innerHTML = ICON_COPY; cp.classList.remove('done'); }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(done, function () { legacyCopy(txt, done); });
} else { legacyCopy(txt, done); }
});
rb.appendChild(cp); }
// The caption pill comes AFTER the copy button (Ray, 2026-10-16): the
// button copies "#id handle", so it sits beside them, not past the caption.
if (e.auto_description && e.description) {
const cp=document.createElement('span'); cp.className='rb-cap'; cp.textContent=e.description;
cp.setAttribute('aria-label','Caption: '+e.description);
// Same reveal as the ⓘ: hover shows the full info overlay (caption,
// prompt, file, chips); click toggles it for touch.
// Hover INTENT, not a bare mouseenter: the pointer crosses this pill on
// its way to the copy button, and an instant overlay got in the way.
let capTimer=null;
cp.addEventListener('mouseenter', ()=>{ capTimer=setTimeout(showInfo, 350); });
cp.addEventListener('mouseleave', ()=>{ clearTimeout(capTimer); });
cp.addEventListener('click', ev=>{ ev.stopPropagation(); clearTimeout(capTimer);
if (info.classList.contains('show')) info.classList.remove('show'); else showInfo(); });
rb.appendChild(cp); }
c.appendChild(rb); } // info (hidden in compact) -> refbar -> imgWrap (appended below)
// COMPACT mode's reveal chip: hover (or tap, on touch) the ⓘ on the image
// to overlay the full info block; it hides again when the pointer leaves
// the card. Hidden entirely in normal mode (the info is already visible).
const infoHot=document.createElement('button'); infoHot.className='card-info-hot'; infoHot.textContent='ⓘ';
infoHot.setAttribute('aria-label','Show shot info');
// No data-tooltip on the ⓘ: its hover REVEALS the info overlay, and a
// tooltip 600ms later landed right on top of what the human was reading.
infoHot.title='';
infoHot.addEventListener('mouseenter', showInfo);
infoHot.addEventListener('click', ev=>{ ev.stopPropagation();
if (info.classList.contains('show')) info.classList.remove('show'); else showInfo(); });
c.addEventListener('mouseleave', ()=>info.classList.remove('show'));
imgWrap.appendChild(infoHot);
// Additional path: jump STRAIGHT to fullscreen, no modal first. The click
// is the user gesture the Fullscreen API needs.
const fillBtn=document.createElement('button'); fillBtn.className='card-fill-btn'; fillBtn.setAttribute('aria-label','Fullscreen this shot');
fillBtn.setAttribute('data-tooltip','Zoom STRAIGHT to fullscreen (skip the popup).\nIn Adom Hydrogen it fills 90% of the window; in pup/browser it goes fullscreen. Esc / click / × to exit.');
fillBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
fillBtn.addEventListener('click', ev=>{ ev.stopPropagation(); fillWindow(imgUrl(e), e.description||e.filename); });
imgWrap.appendChild(fillBtn);
c.appendChild(imgWrap);
// 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.alt=p.title||p.kind||'popup'; im.dataset.src=popUrl(p.filename); lazyImg.watch(im);
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) {
backFromFill=false; mImg.style.cursor=''; // fresh shot: click 1 = fill
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'); }
// Modal closes on: backdrop click, the × button, or Escape (when NOT filling).
modal.addEventListener('click', e => { if(e.target===modal) closeModal(); });
$('mClose').addEventListener('click', e => { e.stopPropagation(); closeModal(); });
// ── Fill window: zoom the image to fill the surface via the standard
// Fullscreen API. In Adom Hydrogen's native webview this grows the panel
// to a 90%-of-window lightbox (HD implements the standard API specially); in
// a pup/browser it's true fullscreen; where the API is unavailable (some
// phones), the .open class alone makes the lightbox a full fixed overlay.
// ONE code path — the host decides the final rectangle; we just request
// fullscreen on the right element and style it to fill its viewport.
const rfs = lightbox.requestFullscreen || lightbox.webkitRequestFullscreen;
const xfs = document.exitFullscreen || document.webkitExitFullscreen;
function fsElement(){ return document.fullscreenElement || document.webkitFullscreenElement || null; }
// Clicking the maxed image is a TOGGLE STACK: list -> maxed -> fill -> maxed
// -> list. On the way up a click fills; once you've filled and come BACK to
// the maxed modal, the next image click un-maxes (closes the modal). This
// flag records "we just returned from fill", so the 4th click closes instead
// of re-filling. The cursor flips to zoom-out to signal it.
let backFromFill = false;
function landOnMaxed(){ backFromFill = true; mImg.style.cursor = 'zoom-out'; }
function fillWindow(src, alt){
// The click that called this IS the user gesture the API requires.
backFromFill = false; mImg.style.cursor = ''; // going INTO fill
lbImg.src = src; lbImg.alt = alt || '';
lightbox.classList.add('open'); // fallback overlay + backdrop, always
if (rfs) { try { const p = rfs.call(lightbox); if (p && p.catch) p.catch(()=>{}); } catch {} }
}
function exitFill(){
// If the host granted fullscreen, leaving it fires fullscreenchange, which
// clears .open + lands us on maxed. Otherwise (fallback overlay) do it here.
if (fsElement() === lightbox && xfs) { try { xfs.call(document); } catch { lightbox.classList.remove('open'); landOnMaxed(); } }
else { lightbox.classList.remove('open'); landOnMaxed(); }
}
// Keep DOM state in lockstep with the host: if the user exits fullscreen by
// any means (Esc, host chrome), drop the lightbox and arm the un-max click.
const onFsChange = () => {
if (fsElement()) return; // entering/switching: nothing to do
if (lightbox.classList.contains('open')) { lightbox.classList.remove('open'); landOnMaxed(); }
};
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
// Clicking the image: fills the window, OR un-maxes if we just came back from
// fill (the 4th click in the stack). The ⛶ button ALWAYS fills (explicit).
mImg.addEventListener('click', e => { e.stopPropagation();
if (backFromFill) { backFromFill = false; mImg.style.cursor = ''; closeModal(); }
else fillWindow(mImg.src, mImg.alt); });
$('mFillBtn').addEventListener('click', e => { e.stopPropagation(); fillWindow(mImg.src, mImg.alt); });
// ANY click inside the lightbox (image, the dark margin, anything) exits:
// the next click after zooming always takes you back to the list.
lightbox.addEventListener('click', e => { e.stopPropagation(); exitFill(); });
$('lbClose').addEventListener('click', e => { e.stopPropagation(); exitFill(); });
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
// Layered escape: exit fill first (if not already handled natively by the
// host), then close the modal.
if (lightbox.classList.contains('open')) { exitFill(); return; }
if (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{}
// Apply the display mode at boot (the <body> ships class="compact" so the
// default renders without a flash; this corrects it if the pref is off).
document.body.classList.toggle('compact', APREFS.compact!==false);
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 Bridge 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 Bridge). `?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-bridge', 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-bridge';
const C={
'no-adom-bridge':{
title:'Pup needs Adom Bridge',
body:'Pup opens shotlog in a full browser window on YOUR computer, driven by Adom Bridge. Adom Bridge is not installed (or not reachable) yet - pup is a default bridge that installs automatically right after it.',
prompt:'Install Adom Bridge on my computer so shotlog can open pup windows. Start from wiki.adom.inc/adom/adom-bridge, walk me through the install, make sure it connects to my container, verify the pup bridge with `adom-bridge-cli pup_status`, then show my shotlog channel "'+channel+'" in pup.'},
'desktop-not-connected':{
title:'Adom Bridge is not connected',
body:'Adom Bridge is installed but not linked to this container right now. Launch it on your computer and it reconnects automatically.',
prompt:'My Adom Bridge is installed but not connected to this container. Help me get it running and linked, verify with `adom-bridge-cli pup_status`, then show my shotlog channel "'+channel+'" in pup.'},
'pup-not-ready':{
title:'Pup bridge is still warming up',
body:'Adom Bridge 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 Bridge is not ready. Run `adom-bridge-cli pup_status`, verify with `adom-bridge-cli pup_status`, then show my shotlog channel "'+channel+'" in pup.'},
'multiple-desktops':{
title:'More than one Adom Bridge is connected',
body:'The broker cannot tell which computer should host the pup window. It needs a target.',
prompt:'Multiple Adom Bridges 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-bridge'];
$('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(); });
// Remember the last positive value so toggling a setting OFF (which stores 0)
// then back ON restores what the human had, not a bare default.
let lastKeep=200, lastPrune=30;
function renderPrefs(p){
hook.setAttribute('aria-checked', String(!!p.read_hook));
$('prefAlerts').setAttribute('aria-checked', String(p.update_alerts===true));
$('prefAutoswitch').setAttribute('aria-checked', String(p.update_autoswitch!==false));
$('prefAutosize').setAttribute('aria-checked', String(p.webview_autosize===true));
$('prefCompact').setAttribute('aria-checked', String(p.compact!==false));
document.body.classList.toggle('compact', p.compact!==false);
APREFS=p;
seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));
$('prefWidth').value = p.webview_width ?? 20;
// Own column vs shared pane: the two width settings are alternates. The
// shared-pane rows are CHILDREN of this switch: dimmed + inert when it is
// on, never hidden (settings-layout: disable, never hide).
const own = p.webview_own_column===true;
$('prefOwnWidth').value = p.own_column_width ?? 10;
document.querySelectorAll('.layout-opt').forEach(o=>{ const sel=(o.dataset.own==='true')===own; o.classList.toggle('selected', sel);
o.querySelector('.opt-head').setAttribute('aria-checked', String(sel));
const ctl=o.querySelector('.opt-controls'); ctl.dataset.enabled=String(sel);
ctl.querySelectorAll('.toggle,.num-input,.num-step').forEach(el=>{ el.disabled=!sel; }); });
// Live status in every card title (recomputed truth, never a cached claim).
const st=(id,txt,ok)=>{ const el=$(id); if(!el) return; el.textContent=txt; el.classList.toggle('ok', !!ok); };
st('stHook', p.read_hook!==false ? 'active' : 'off', p.read_hook!==false);
$('prefBringFwd').setAttribute('aria-checked', String(p.webview_bring_forward!==false));
st('stNotify', (p.update_alerts===true?'flashes on':'flashes off')+' · '+(p.update_autoswitch!==false?'auto-switch on':'auto-switch off')+' · '+(p.webview_bring_forward!==false?'tab comes forward':'tab stays put'), false);
st('stGallery', p.compact!==false ? 'compact' : 'classic', false);
st('stCanvas', p.default_canvas||'webview', false);
st('stLayout', own ? ('own column, '+(p.own_column_width??10)+'% wide') : ('shared pane'+(p.webview_autosize===true?', auto-sized':'')), false);
st('stStorage', ((p.max_shots??200)>0?('newest '+(p.max_shots??200)+' shots per channel'):'unlimited shots')+' · '+((p.retention_days??30)>0?('prune after '+(p.retention_days??30)+' days'):'never prune'), false);
// Keep per channel: max_shots 0 = OFF (unlimited).
const mk = p.max_shots ?? 200; if(mk>0) lastKeep=mk;
$('prefKeepToggle').setAttribute('aria-checked', String(mk>0));
$('prefMaxShots').closest('.pref-control').classList.toggle('off', mk===0);
$('prefMaxShots').value = mk>0 ? mk : lastKeep;
// Prune idle: retention_days 0 = OFF (never).
const rd = p.retention_days ?? 30; if(rd>0) lastPrune=rd;
$('prefPruneToggle').setAttribute('aria-checked', String(rd>0));
$('prefRetention').closest('.pref-control').classList.toggle('off', rd===0);
$('prefRetention').value = rd>0 ? rd : lastPrune;
}
async function patch(body, okMsg){
try{
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 settings: '+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.');
});
$('prefBringFwd').addEventListener('click',()=>{
const now=$('prefBringFwd').getAttribute('aria-checked')==='true';
patch({webview_bring_forward: !now}, !now?'bring-forward ON: new shots pull the Shotlog tab to the front.':'bring-forward OFF: the Shotlog tab stays where it is; shots keep arriving in the background.');
});
$('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.');
});
$('prefAutosize').addEventListener('click',()=>{
const now=$('prefAutosize').getAttribute('aria-checked')==='true';
patch({webview_autosize: !now}, !now?'auto-size ON: the webview column snaps to your width on every open.':'auto-size OFF: shotlog leaves the webview column at the width you set.');
});
document.querySelectorAll('.layout-opt .opt-head').forEach(h=>h.addEventListener('click',()=>{
const own=h.dataset.own==='true';
patch({webview_own_column: own}, own?'own column: the next time shotlog opens with no Shotlog tab, it takes its own column at the right edge (your webview pane is untouched).':'shared pane: shotlog joins your webview pane as a tab.');
}));
$('prefCompact').addEventListener('click',()=>{
const now=$('prefCompact').getAttribute('aria-checked')==='true';
patch({compact: !now}, !now?'compact ON: image-first cards - hover the ⓘ on a thumbnail for its info.':'compact OFF: classic cards with the info text always visible.');
});
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('prefOwnWidth','own_column_width', v=>'shotlog\'s own column will be '+v+'% of the screen when it is next created.');
wireNum('prefMaxShots','max_shots', v=>'keeping the newest '+v+' shots per channel.');
wireNum('prefRetention','retention_days', v=> v===0?'idle channels are never pruned.':'idle channels prune after '+v+' days.');
// Up/down steppers: adjust the target input by its step, clamp, commit.
dlg.addEventListener('click', e=>{
const b=e.target.closest('.num-step'); if(!b) return;
const el=$(b.dataset.target); if(!el) return;
if(el.closest('.pref-control')?.classList.contains('off') || el.closest('.num-wrap')?.classList.contains('off') || el.disabled) return; // disabled while its switch is off
const step=parseInt(el.step,10)||1, dir=parseInt(b.dataset.dir,10)||0;
const min=parseInt(el.min,10), max=parseInt(el.max,10);
let v=(parseInt(el.value,10)||min)+dir*step;
v=Math.max(min, Math.min(max, v));
if(String(v)!==el.value){ el.value=v; el.dispatchEvent(new Event('change')); }
});
// Keep-per-channel switch: OFF => max_shots 0 (unlimited); ON => restore last.
$('prefKeepToggle').addEventListener('click',()=>{
const on=$('prefKeepToggle').getAttribute('aria-checked')==='true';
if(on) patch({max_shots:0}, 'keep-per-channel OFF: keeping every shot (unlimited on disk).');
else patch({max_shots:lastKeep}, 'keep-per-channel ON: newest '+lastKeep+' shots per channel.');
});
// Prune-idle switch: OFF => retention_days 0 (never); ON => restore last.
$('prefPruneToggle').addEventListener('click',()=>{
const on=$('prefPruneToggle').getAttribute('aria-checked')==='true';
if(on) patch({retention_days:0}, 'prune OFF: idle channels are never deleted.');
else patch({retention_days:lastPrune}, 'prune ON: idle channels prune after '+lastPrune+' days.');
});
$('prefsBtn').addEventListener('click', async ()=>{
try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}
dlg.classList.add('open');
// Open at the TOP, focus the dialog itself (not the first control).
const body=$('prefsBody'), card=$('prefsCard');
body.scrollTop=0; card.focus({preventScroll:true});
requestAnimationFrame(()=>{ body.scrollTop=0; });
});
// Restore defaults: two clicks, the button IS the confirmation, disarms in 4s.
(function(){
const btn=$('prefsRestore'); if(!btn) return; let armed=false, t=0;
const lbl=btn.querySelector('.r-lbl'), ico=btn.querySelector('.r-ico');
const disarm=()=>{ armed=false; btn.classList.remove('armed'); lbl.textContent='Restore defaults'; ico.textContent='↺'; };
btn.addEventListener('click', async ()=>{
if(!armed){ armed=true; btn.classList.add('armed'); lbl.textContent='Click again to confirm'; ico.textContent='!'; clearTimeout(t); t=setTimeout(disarm,4000); return; }
clearTimeout(t); disarm();
try{
const r=await fetch('api/prefs',{method:'DELETE'}).then(x=>x.json());
if(r.ok){ renderPrefs(r.prefs); showToast('every setting is back to its default.', 4000); }
else showToast('could not restore: '+(r.error||'unknown'), 6000);
}catch(e){ showToast('could not restore settings: '+e.message, 6000); }
});
})();
})();
// ── 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) hardReload(m.version); // cache-busting, guarded once/stamp
}
} 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==='prefs' && msg.prefs) {
// Live settings sync from the broker: another surface (or an AI via
// the API) changed a setting - apply it here without a reload.
APREFS=msg.prefs;
document.body.classList.toggle('compact', APREFS.compact!==false);
return;
}
if (msg.type==='cleared-all') {
// Someone wiped everything. If it was THIS page's clear button, it
// already wiped locally and showed the detailed shots/threads/size
// toast - don't stomp it. Other pages reset their tabs here.
if (window._selfCleared) { window._selfCleared=false; return; }
if (window._wipeLocalAll) window._wipeLocalAll();
showToast('all shotlog threads were cleared', 5000);
return;
}
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.
// The orange flash rides the update_alerts pref (off by default);
// the switch/badge behavior is unaffected.
activate(chName).then(()=>{ if(APREFS.update_alerts===true) flashTab(chName); });
} else {
c.unseen++; renderTabs(); if(APREFS.update_alerts===true) 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 ───────────────────────────────────────────────────────────────
// 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); };
const fmtBytes=b=> b>=1024*1024*1024 ? (b/(1024*1024*1024)).toFixed(2)+' GB'
: b>=1024*1024 ? (b/(1024*1024)).toFixed(1)+' MB'
: b>0 ? Math.max(1,Math.round(b/1024))+' KB'
: '0 KB';
// Drop every local tab + entry cache and land on an empty read-captures.
// Used by the button AND by the broker's cleared-all broadcast (so every
// open page resets, not just the one that clicked).
window._wipeLocalAll=function(){
chans.clear(); ensureChan('read-captures');
activate('read-captures');
};
btn.addEventListener('click', async ()=>{
if(!armed){ armed=true; btn.classList.add('arm'); btn.textContent='sure? clears ALL threads';
t=setTimeout(reset,3000); return; }
reset();
try{
// EVERYTHING: all channels, all shots, all thread tabs. Channels
// auto-recreate on the next inject, so this never breaks a live AI.
window._selfCleared=true; // we show the detailed toast; skip the broadcast one
const r=await fetch('api/clear-everything',{method:'DELETE'}).then(x=>x.json());
window._wipeLocalAll();
const n=r.shots_cleared??0, ch=r.channels_deleted??0;
showToast('cleared '+n+' shot'+(n===1?'':'s')+' across '+ch+' thread'+(ch===1?'':'s')+' - freed '+fmtBytes(r.bytes_freed||0)+' of disk', 8000);
}catch{ showToast('clear failed - broker unreachable?'); }
});
})();
// ── Version badge + "am I the latest?" self-heal ─────────────────────────
// The header pill ALWAYS shows what's RUNNING (from the embedded stamp, so it
// works even offline). Then we poll the broker's /api/version, which is the
// process that CALLS HOME to the wiki:
// • broker redeployed since this page loaded -> cache-busting self-reload
// (this is what fixes HD's WebView2 serving a stale cached page)
// • wiki has a newer shotlog than the broker -> pill turns amber (the
// broker binary is behind; a reload can't fix that, an AI must update it)
(function versionWatch(){
const pill=$('verPill'); if(!pill) return;
const mirror=()=>{ const v=$('appInfoVer'); if(v) v.textContent=pill.textContent+(pill.classList.contains('stale')?' (update available)':''); };
new MutationObserver(mirror).observe(pill,{childList:true,characterData:true,subtree:true,attributes:true});
let updCmd='adom-wiki pkg update adom/adom-shotlog';
const paint=(running, latest, upd)=>{
pill.textContent='v'+verSemver(running);
if(upd && latest){ pill.classList.add('stale');
pill.setAttribute('data-tooltip','Running v'+verSemver(running)+' — a newer shotlog (v'+verSemver(latest)+') is published on the wiki.\nThe broker binary is behind; ask an AI to update it:\n'+updCmd+'\n(then restart the broker). Click to force a fresh reload.'); }
else { pill.classList.remove('stale');
pill.setAttribute('data-tooltip','shotlog v'+verSemver(running)+' — running the latest.\nClick to force a fresh reload.'); }
};
if(APP_VERSION.indexOf('__')!==0) paint(APP_VERSION,null,false);
// Clicking always forces a fresh (cache-busting) reload.
pill.addEventListener('click',()=>{ const u=new URL(location.href); u.searchParams.set('_cb', String(Date.now())); location.replace(u.toString()); });
async function poll(){
try{
const r=await fetch('api/version?_='+Date.now(),{cache:'no-store'}).then(x=>x.json());
if(r.update_command) updCmd=r.update_command;
// Self-heal: broker was redeployed since this page loaded -> reload fresh.
if(r.stamp && APP_VERSION.indexOf('__')!==0 && r.stamp!==APP_VERSION){ hardReload(r.stamp); return; }
paint(r.version||APP_VERSION, r.latest, r.update_available);
}catch{}
}
poll(); setInterval(poll, 60000);
})();
// ── 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>