1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
<!doctype html>
<html lang="en" style="background:#0d1117">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
<link rel="shortcut icon" href="favicon.ico">
<link rel="apple-touch-icon" href="favicon-192.png">
<title>adom-shotlog</title>
<style>
@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }
@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }
@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;
  --border: #30363d; --border-muted: #21262d;
  --text: #e6edf3; --text2: #8b949e; --text3: #484f58;
  --accent: #00b8b0; --accent-bright: #00e6dc; --purple: #8C6BF7; --danger: #f85149;
}
*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* The OUTERMOST html/body are NEVER scrollers — no bars, either axis, ever.
   The .feed is the one designated scroller (modal overlays scroll themselves).
   overflow:hidden also kills the phantom 1px outer bar that fractional-DPR
   (e.g. 1.5) rounding of 100dvh otherwise creates. */
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);
  font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif;
  overflow: hidden; max-width: 100%; }
body { display: flex; flex-direction: column; height: 100dvh; width: 100%; }
img { max-width: 100%; }

/* Pretty scrollbars everywhere (thin, rounded, themed; only where needed). */
* { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(139,148,158,.32); border-radius: 999px;
  border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,184,176,.55); background-clip: padding-box; }
::-webkit-scrollbar-corner { background: transparent; }

/* Global tooltip — body-appended fixed (human-ui-patterns §1d) */
#global-tooltip {
  position: fixed; top: 0; left: 0; transform: translate(0,0);
  background: rgba(22,27,34,.97); backdrop-filter: blur(12px);
  border: 1px solid rgba(0,184,176,.25); border-radius: 7px; padding: 6px 10px;
  font: 12px/1.45 'Satoshi', sans-serif; color: var(--text);
  max-width: min(320px, calc(100vw - 16px)); /* fit a skinny ~360px panel, never overflow */
  max-height: calc(100dvh - 16px); overflow: hidden; /* never taller than the viewport */
  white-space: pre-wrap; box-shadow: 0 8px 28px rgba(0,0,0,.55);
  z-index: 99999; pointer-events: none; opacity: 0; display: none; transition: opacity .18s;
}
#global-tooltip.visible { display: block; } #global-tooltip.shown { opacity: 1; }
/* Structured (rich) tooltip content — title / body / requirement / prompt chip. */
#global-tooltip .tt-title { font: 600 12.5px 'Familjen Grotesk', sans-serif; color: var(--text); }
#global-tooltip .tt-p { margin-top: 6px; color: var(--text2); }
#global-tooltip .tt-req { margin-top: 7px; color: var(--text3); font-size: 11px; }
#global-tooltip .tt-count { margin-top: 8px; color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
#global-tooltip .tt-prompt { margin-top: 9px; padding: 7px 9px; border-radius: 7px;
  background: rgba(0,184,176,.10); border: 1px solid rgba(0,184,176,.28);
  color: var(--accent-bright); font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.45; }
#global-tooltip .tt-prompt .lbl { display: block; color: var(--text3); font-family: 'Satoshi', sans-serif;
  font-size: 9.5px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 3px; }

/* Header (adom-app-header, 44px) */
header {
  display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 44px;
  flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);
  position: relative;
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--accent);
  font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;
  cursor: default; flex-shrink: 0; outline: none; }
.brand:hover .app-name { text-decoration: underline; text-underline-offset: 3px; }
.brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; color: var(--text); } /* monochrome per brand */
.brand .sep { color: var(--text3); font-weight: 400; margin: 0 1px; }
.brand .app-subtitle { color: var(--text2); font-weight: 500; font-size: 12px; }
.vsep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
/* baseline (not center) so the smaller mono meta sits ON the name's baseline */
.subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; }
.subject-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600;
  color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.subject-meta { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px;
  line-height: 1; white-space: nowrap; flex-shrink: 0; }
.subject-meta:not(:empty)::before { content: '·'; color: var(--text3); margin-right: 7px; }
/* adom-app-header, REVISED for skinny (John, 2026-07-18): wide stays ONE 44px
   row, but a skinny ~360px panel goes to EXACTLY TWO rows — shedding zones on
   one row crushed the channel name to 2-3 chars once the action cluster grew.
   Row 1: logo + full channel name + count. Row 2: all actions, right-aligned
   (with room back for the canvas button's label). Never more than two rows. */
@media (max-width: 520px) {
  header { gap: 4px 8px; padding: 5px 10px 6px; height: auto; min-height: 44px; flex-wrap: wrap; }
  .brand .app-name, .brand .sep, .brand .app-subtitle { display: none; } /* logo carries identity */
  .vsep { display: none; }
  .subject { flex: 1; min-width: 0; }   /* the name gets the whole rest of row 1 */
  .actions { flex-basis: 100%; justify-content: flex-end; gap: 5px; }
}
/* Header action buttons + LED (adom-app-header). These were the raw-<button>
   ugliness: clean pills, subtle until hover, icon+label capable. */
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-shrink: 0; }
.hdr-btn { -webkit-appearance: none; appearance: none;
  display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px;
  background: var(--overlay); color: var(--text2); border: 1px solid var(--border);
  border-radius: 7px; font: 500 12px/1 'Satoshi', sans-serif; cursor: pointer;
  white-space: nowrap; flex-shrink: 0; transition: background .15s, color .15s, border-color .15s; }
.hdr-btn:hover { background: var(--elevated); color: var(--text); border-color: rgba(0,184,176,.45); }
.hdr-btn:active { transform: translateY(.5px); }
.hdr-btn:disabled { opacity: .55; cursor: default; transform: none; }
.hdr-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
.hdr-btn.icon-only { padding: 0; width: 26px; justify-content: center; }
/* Busy spinner for in-flight actions (canvas switch). */
@keyframes hdr-spin { to { transform: rotate(360deg); } }
.hdr-btn.busy svg.spin { animation: hdr-spin .8s linear infinite; }
.hdr-btn.busy { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }
/* Canvas toggle: the primary affordance, tinted with the accent. */
.hdr-btn.accent { color: var(--accent); border-color: rgba(0,184,176,.35); }
.hdr-btn.accent:hover { background: rgba(0,184,176,.12); color: var(--accent-bright); border-color: rgba(0,184,176,.6); }
/* Destructive: quiet until hover, then a two-step "arm" state (no browser modal). */
.hdr-btn.danger { color: var(--text2); }
.hdr-btn.danger:hover { color: var(--danger); background: rgba(248,81,73,.09); border-color: rgba(248,81,73,.45); }
.hdr-btn.danger.arm { color: #fff; background: var(--danger); border-color: var(--danger); }
.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; margin-left: 2px;
  background: var(--text3); transition: background .4s, box-shadow .4s; }
.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }

/* App-info popover */
/* left:12 + right:12 with a max-width caps it inside a skinny ~360px panel —
   never off the right edge (tooltips/popovers must never render off-screen). */
.hover-popover { position: absolute; top: calc(100% + 6px); left: 12px;
  background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px;
  min-width: 0; max-width: min(400px, calc(100vw - 24px)); width: max-content;
  font: 12px 'Satoshi', sans-serif; color: var(--text); z-index: 99999;
  opacity: 0; transform: translateY(-2px); pointer-events: none; transition: opacity 120ms, transform 120ms; }
.hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }
.hover-popover .pop-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.hover-popover .pop-row { display: flex; gap: 10px; padding: 2px 0; align-items: baseline; }
.hover-popover .pop-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; width: 56px; flex-shrink: 0; }
.hover-popover .pop-row .v { color: var(--text); font-size: 11px; flex: 1; }
.hover-popover .pop-actions { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-muted); }
.hover-popover .pop-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; }

/* In-app CHANNEL TAB STRIP: shotlog owns its tabs (one window, many channels).
   read-captures pinned first; orange flash + unseen badge on updates. */
.chtabs { display: flex; align-items: center; gap: 2px; padding: 0 8px; height: 32px;
  flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);
  overflow-x: auto; overflow-y: hidden; }
.chtab { -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center;
  gap: 6px; padding: 0 10px; height: 31px; border: none; background: transparent;
  color: var(--text2); font: 500 12px 'Familjen Grotesk', sans-serif; cursor: pointer;
  white-space: nowrap; border-bottom: 2px solid transparent; flex-shrink: 0; }
.chtab:hover { color: var(--text); }
.chtab.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.chtab .badge { min-width: 16px; height: 15px; padding: 0 4px; border-radius: 999px;
  background: var(--overlay); color: var(--text2); display: none;
  font: 600 9.5px 'JetBrains Mono', monospace; align-items: center; justify-content: center; }
.chtab.unseen .badge { display: inline-flex; background: rgba(255,140,0,.16);
  color: #ffa657; border: 1px solid rgba(255,140,0,.4); }
@keyframes tabflash { 0%,100% { background: transparent; } 50% { background: rgba(255,140,0,.16); } }
.chtab.flash { animation: tabflash 1s ease-in-out 4; }

/* Gallery */
.feed { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; -webkit-overflow-scrolling: touch; }
.empty { text-align: center; color: var(--text3); padding: 48px 20px; font-size: 13px; line-height: 1.6; }
.empty code { color: var(--accent); font-family: 'JetBrains Mono', monospace; }

.shot-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
  overflow: hidden; transition: border-color .15s; cursor: zoom-in;
  display: flex; flex-direction: column; flex-shrink: 0; } /* never compress in the flex-column feed */
.shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); }
/* WHY-headline for auto-captures: the prompt IS the title (same gutter as a
   description), the filename demoted to a small mono sub-line under it. */
.shot-card-why { padding: 10px 13px 0; font: 500 13.5px/1.45 'Satoshi', sans-serif; color: var(--text); overflow-wrap: anywhere; }
.shot-card-sub { padding: 2px 13px 0; font-family: 'JetBrains Mono', monospace; font-size: 10px;
  color: var(--text3); overflow-wrap: anywhere; }
.shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;
  font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; }
.shot-card-meta .thr { color: var(--purple); font-style: italic; }
.chip { display: inline-block; padding: 1px 6px; border-radius: 5px; background: var(--overlay);
  border: 1px solid var(--border-muted); font-family: 'JetBrains Mono', monospace;
  font-size: 9.5px; color: var(--text2); cursor: help; }
.chip.ad { color: var(--accent); border-color: rgba(0,184,176,.3); }
.chip.auto { color: var(--text3); }
.fchip { -webkit-appearance: none; appearance: none; padding: 5px 11px; border-radius: 999px;
  border: 1px solid var(--border); background: var(--overlay); color: var(--text2);
  cursor: pointer; font: 600 11px 'Familjen Grotesk', sans-serif; }
.fchip.on { color: var(--accent-bright); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.12); }
.frow { display: flex; flex-wrap: wrap; gap: 7px; padding: 8px 0 2px; }
.hdr-btn.filters-on { color: var(--accent-bright); border-color: rgba(0,184,176,.55); }
.m-full-toggle { color: var(--accent); cursor: pointer; font-weight: 600; }
.m-full-toggle:hover { text-decoration: underline; }
.m-why { padding: 0 16px 8px; font: italic 12px/1.5 'Satoshi', sans-serif; color: var(--text2); }
.m-why .k { color: var(--text3); font-style: normal; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; margin-right: 6px; }
.shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }
.shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;
  font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }
.shot-card-meta .ts { color: var(--text2); }
.shot-card-meta .sep { color: var(--border); }
.shot-card-meta .dim-main { color: var(--text2); cursor: help; }
.shot-card-meta .dim-orig { cursor: help; }
.shot-card-meta .dim-missing { color: var(--danger); font-weight: 600; cursor: help; }
.shot-card-meta .src { font-style: italic; }
.shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111;
  object-fit: contain; object-position: center top; transition: opacity .15s; }
.shot-card:active .shot-card-img { opacity: .85; }
/* Child-window-array badge + nerd info chip on a card */
.shot-card-meta .badge-pop { background: rgba(0,184,176,.16); color: var(--accent); border: 1px solid rgba(0,184,176,.4);
  border-radius: 999px; padding: 0 7px; font-weight: 600; cursor: help; }
.shot-card-meta .badge-nerd { color: var(--text3); cursor: pointer; border: 1px solid var(--border);
  border-radius: 4px; padding: 0 5px; user-select: none; }
.shot-card-meta .badge-nerd:hover { color: var(--accent); border-color: rgba(0,184,176,.4); }
.shot-card-meta .badge-nerd.active { color: var(--accent); border-color: rgba(0,184,176,.5); background: rgba(0,184,176,.10); }
/* Child-window row, inline on the card. FLUID: cells share the available width
   and wrap when the card is skinny — NO horizontal scrollbar, ever. */
.card-popups { display: flex; flex-wrap: wrap; gap: 8px; padding: 9px 13px 6px; flex-shrink: 0; }
.card-popups .ph { flex: 0 0 100%; font: 600 10px 'Familjen Grotesk', sans-serif;
  color: var(--text2); margin-bottom: -2px; }
.card-popups .pc { flex: 1 1 130px; min-width: 0; max-width: 220px; background: #000; border: 1px solid var(--border);
  border-radius: 8px; overflow: hidden; cursor: zoom-in; transition: border-color .12s; }
.card-popups .pc:hover { border-color: rgba(0,184,176,.5); }
.card-popups .pc img { width: 100%; height: 96px; object-fit: contain; background: #111; display: block; }
.card-popups .pc .cap { padding: 3px 7px; font-family: 'JetBrains Mono', monospace; font-size: 9px;
  color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Nerd metadata revealed INLINE on the card (not just the zoom modal) */
.card-nerd { display: none; margin: 6px 13px 12px; border: 1px solid var(--border); border-radius: 8px;
  background: var(--elevated); flex-shrink: 0; }
.card-nerd.open { display: block; }
.card-nerd .nhead { display: flex; align-items: center; gap: 8px; padding: 7px 11px; font: 600 10px 'JetBrains Mono', monospace; color: var(--text2); }
.card-nerd .nhead .copy { margin-left: auto; font-size: 9px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);
  border-radius: 4px; padding: 1px 7px; cursor: pointer; }
.card-nerd .nbody { padding: 0 11px 9px; }
.card-nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
  padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }
.card-nerd .row .k { color: var(--text3); min-width: 120px; }
.card-nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }
.card-nerd .nbody { max-height: 340px; overflow-y: auto; }
.card-nerd pre { margin: 8px 0 0; padding: 8px; background: #0c0f12; border-radius: 6px; max-height: 220px;
  overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; }

/* Zoom modal — full card */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none;
  align-items: flex-start; justify-content: center; z-index: 100; overflow-y: auto;
  padding: 16px; padding-top: max(16px, env(safe-area-inset-top)); }
.modal.open { display: flex; }
/* Zoom uses ALL the real estate the surface has: near-full viewport in a big
   pup/browser window, and still fits a skinny panel. Never a fixed cap. */
.modal-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
  overflow: hidden; width: 100%; max-width: min(96vw, 2200px); flex-shrink: 0; }
.modal-card .m-desc { padding: 14px 16px 6px; font: 500 15px/1.4 'Satoshi', sans-serif; color: var(--text); }
.modal-card .m-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 16px 10px;
  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text3); }
.modal-card .m-meta .dim-main { color: var(--text2); } .modal-card .m-meta .dim-missing { color: var(--danger); font-weight: 600; }
.modal-card .m-meta .sep { color: var(--border); }
.modal-card img { display: block; width: 100%; max-height: 84vh; object-fit: contain; background: #000; }
/* Popup array inside the modal */
.modal-card .m-popups { padding: 10px 16px 14px; }
.modal-card .m-popups .pop-h { font: 600 12px 'Familjen Grotesk', sans-serif; color: var(--text2);
  margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.modal-card .m-popups .pop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.modal-card .m-popups .pop-cell { background: #000; border: 1px solid var(--border); border-radius: 8px;
  overflow: hidden; cursor: zoom-in; }
.modal-card .m-popups .pop-cell img { width: 100%; height: 110px; object-fit: contain; background: #111; }
.modal-card .m-popups .pop-cap { padding: 4px 6px; font-family: 'JetBrains Mono', monospace; font-size: 9px;
  color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Nerd metadata panel — readable + copyable */
.modal-card .nerd { margin: 0 16px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--elevated); }
.modal-card .nerd summary { cursor: pointer; padding: 8px 12px; font: 600 11px 'JetBrains Mono', monospace;
  color: var(--text2); list-style: none; display: flex; align-items: center; gap: 8px; }
.modal-card .nerd summary::-webkit-details-marker { display: none; }
.modal-card .nerd summary .copy { margin-left: auto; font-size: 10px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);
  border-radius: 4px; padding: 1px 7px; }
.modal-card .nerd .body { padding: 0 12px 10px; }
.modal-card .nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
  padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }
.modal-card .nerd .row .k { color: var(--text3); min-width: 130px; } .modal-card .nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }
.modal-card .nerd pre { margin: 8px 0 0; padding: 8px; background: #0c0f12; border-radius: 6px; max-height: 220px;
  overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; }

/* Canvas-move countdown: floating bottom-center card, NON-modal (the page
   stays fully usable behind it), clamped to the viewport, always cancelable. */
.cdown { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 210;
  background: var(--elevated); border: 1px solid rgba(0,184,176,.5); border-radius: 12px;
  padding: 12px 16px; display: flex; align-items: center; gap: 12px;
  width: max-content; max-width: min(460px, calc(100vw - 24px));
  box-shadow: 0 8px 30px rgba(0,0,0,.5);
  font: 13px/1.45 'Satoshi', sans-serif; color: var(--text); }
/* The message must NEVER collapse to min-content (one word per line) when the
   no-shrink cancel button competes for width. */
.cdown .cd-msg { flex: 1 1 auto; min-width: 0; }
/* Skinny: stack message over the button instead of squeezing side-by-side */
@media (max-width: 520px) {
  .cdown { flex-direction: column; align-items: stretch; gap: 10px;
    width: auto; left: 12px; right: 12px; transform: none; max-width: none; }
  .cdown .cd-cancel { align-self: flex-end; }
}
.cdown .cd-n { display: inline-block; min-width: 1.2em; text-align: center; color: var(--accent);
  font: 700 18px/1 'JetBrains Mono', monospace; }
.cdown .cd-cancel { flex-shrink: 0; background: none; border: 1px solid var(--border); border-radius: 8px;
  color: var(--text2); padding: 6px 10px; cursor: pointer; font: 600 11px 'Satoshi', sans-serif; }
.cdown .cd-cancel:hover { color: var(--text); border-color: var(--accent); }

/* Toast */
/* Toast: centered, CLAMPED to the viewport so long messages wrap instead of
   spilling off both edges (the old white-space:nowrap bug). */
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
  background: var(--elevated); color: var(--text); border: 1px solid var(--border);
  padding: 9px 14px; border-radius: 12px; font: 12px/1.45 'Satoshi', sans-serif;
  opacity: 0; transition: opacity .18s; pointer-events: none; z-index: 200;
  max-width: min(360px, calc(100vw - 24px)); width: max-content; text-align: center;
  box-shadow: 0 8px 28px rgba(0,0,0,.5); }
/* When the countdown card holds bottom-center, the toast rides ABOVE it
   instead of stacking into the same pixels (JS adds .lifted). */
.toast.lifted { bottom: 96px; }
/* Skinny panel: tighter toast, never edge-to-edge slab */
@media (max-width: 520px) {
  .toast { font-size: 11px; padding: 7px 11px; border-radius: 10px;
    max-width: calc(100vw - 32px); }
}
.toast.show { opacity: 1; }

/* In-app helper dialog (e.g. "pup needs Adom Desktop") - NEVER a native
   alert/confirm. Backdrop + card, dismiss via X / backdrop / Esc. */
.helper { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: none;
  align-items: center; justify-content: center; z-index: 400; padding: 14px; }
.helper.open { display: flex; }
.helper-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
  padding: 16px 18px 15px; width: 100%; max-width: 440px; max-height: calc(100dvh - 28px);
  overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,.6); position: relative; }
.helper-card .h-close { position: absolute; top: 10px; right: 10px; width: 26px; height: 26px;
  display: flex; align-items: center; justify-content: center; border-radius: 6px;
  color: var(--text3); cursor: pointer; border: none; background: transparent; font-size: 15px; }
.helper-card .h-close:hover { background: var(--overlay); color: var(--text); }
.helper-card .h-title { font: 700 15px 'Familjen Grotesk', sans-serif; color: var(--text);
  margin-bottom: 8px; padding-right: 28px; }
.helper-card .h-body { font: 13px/1.55 'Satoshi', sans-serif; color: var(--text2); margin-bottom: 12px; }
.helper-card .h-lbl { font: 600 9.5px 'Satoshi', sans-serif; letter-spacing: .05em;
  text-transform: uppercase; color: var(--text3); margin: 0 0 5px; }
.helper-card .h-prompt { background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.3);
  border-radius: 8px; padding: 10px 12px; font-family: 'JetBrains Mono', monospace;
  font-size: 11.5px; line-height: 1.5; color: var(--accent-bright); user-select: all;
  word-break: break-word; }
.helper-card .h-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; flex-wrap: wrap; }
.helper-card .h-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 13px;
  border-radius: 7px; font: 600 12px 'Familjen Grotesk', sans-serif; cursor: pointer;
  border: 1px solid rgba(0,184,176,.4); background: rgba(0,184,176,.12); color: var(--accent-bright); }
.helper-card .h-btn:hover { background: rgba(0,184,176,.2); }
.helper-card .h-btn.secondary { border-color: var(--border); background: var(--overlay); color: var(--text2); }
.helper-card .h-btn.secondary:hover { color: var(--text); }
.helper-card .h-link { color: var(--accent); font: 600 12px 'Familjen Grotesk', sans-serif;
  text-decoration: none; margin-left: auto; }
.helper-card .h-link:hover { text-decoration: underline; }

/* Preferences dialog widgets (inside the .helper shell) */
.pref-row { display: flex; align-items: center; gap: 14px; padding: 11px 0;
  border-top: 1px solid var(--border-muted); }
.pref-row:first-of-type { border-top: none; }
.pref-info { flex: 1; min-width: 0; }
.pref-name { font: 600 13px 'Familjen Grotesk', sans-serif; color: var(--text); }
.pref-desc { font: 12px/1.45 'Satoshi', sans-serif; color: var(--text2); margin-top: 2px; }
.toggle { -webkit-appearance: none; appearance: none; width: 40px; height: 22px; border-radius: 999px;
  background: var(--overlay); border: 1px solid var(--border); cursor: pointer; position: relative;
  flex-shrink: 0; transition: background .15s, border-color .15s; }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
  border-radius: 50%; background: var(--text2); transition: transform .18s, background .18s; }
.toggle[aria-checked="true"] { background: rgba(0,184,176,.25); border-color: rgba(0,184,176,.6); }
.toggle[aria-checked="true"]::after { transform: translateX(18px); background: var(--accent-bright); }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; }
.seg button { -webkit-appearance: none; appearance: none; border: none; background: var(--overlay);
  color: var(--text2); font: 600 12px 'Familjen Grotesk', sans-serif; padding: 6px 12px; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--border); }
.seg button.on { background: rgba(0,184,176,.18); color: var(--accent-bright); }
/* Skinny panel: STACK each preference (text full-width, control below) - the
   side-by-side row crushes the description to one word per line at ~360px. */
@media (max-width: 520px) {
  .pref-row { flex-direction: column; align-items: stretch; gap: 9px; }
  .pref-row .toggle, .pref-row .seg { align-self: flex-start; }
}
.ai-chip { margin-top: 7px; padding: 6px 9px; border-radius: 6px; display: inline-block;
  background: rgba(0,184,176,.08); border: 1px solid rgba(0,184,176,.25);
  font-family: 'JetBrains Mono', monospace; font-size: 10.5px; line-height: 1.4;
  color: var(--accent-bright); }
/* Number input with an ALWAYS-VISIBLE unit suffix - a bare number box leaves
   the human guessing what they're changing. */
.num-wrap { display: inline-flex; align-items: center; gap: 6px; height: 30px;
  background: var(--overlay); border: 1px solid var(--border); border-radius: 7px;
  padding: 0 10px; flex-shrink: 0; }
.num-wrap:focus-within { border-color: rgba(0,184,176,.6); }
.num-wrap .num-input { -webkit-appearance: none; appearance: textfield; width: 46px;
  background: transparent; border: none; outline: none; color: var(--text);
  font: 600 13px 'JetBrains Mono', monospace; text-align: right; }
.num-wrap .num-input::-webkit-inner-spin-button, .num-wrap .num-input::-webkit-outer-spin-button { -webkit-appearance: none; }
.num-wrap .unit { color: var(--text3); font: 500 11px 'Satoshi', sans-serif; }
.ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px;
  letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; }

/* Paste progress overlay */
.paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none;
  align-items: center; justify-content: center; z-index: 150; }
.paste-overlay.active { display: flex; }
.paste-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
  padding: 20px 24px; min-width: 260px; max-width: 420px; }
.paste-box .title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; margin-bottom: 8px; }
.paste-box .detail { font-size: 12px; color: var(--text2); margin: 3px 0; }
.paste-box .val { color: var(--text); } .paste-box .filepath-result { font-family: 'JetBrains Mono', monospace;
  font-size: 10px; color: var(--text3); margin-top: 8px; word-break: break-all; }
</style>
</head>
<body tabindex="0">

<div id="global-tooltip"></div>

<header>
  <!-- The brand opens the app-info POPOVER on hover — NOT also a data-tooltip.
       Two poppers on one trigger fight each other and land off-screen. -->
  <div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0">
    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/>
    </svg>
    <span class="app-name">adom-shotlog</span>
    <span class="sep">·</span>
    <span class="app-subtitle">screenshot log</span>
  </div>
  <div class="vsep"></div>
  <div class="subject">
    <span class="subject-name" id="channelName"
          data-tooltip="The channel — a screenshot log for one project or task.&#10;AI agents inject shots here; you watch them arrive live.">…</span>
    <span class="subject-meta" id="shotCount"></span>
  </div>
  <div class="actions">
    <button class="hdr-btn" id="canvasBtn" style="display:none"></button>
    <button class="hdr-btn icon-only" id="toTopBtn" data-tooltip="Scroll to newest (top)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg></button>
    <button class="hdr-btn icon-only" id="filterBtn" data-tooltip="Filter shots: child-window arrays, capture endpoint, safe/full pairs, direct vs auto-captured."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg></button>
    <button class="hdr-btn icon-only" id="prefsBtn" data-tooltip="Preferences: the auto-capture read-hook, and whether channels default to webview or pup."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
    <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button>
    <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live.&#10;While green the server knows you're watching and&#10;won't force-open a duplicate tab on new shots."></span>
  </div>
  <div class="hover-popover" id="app-info" role="dialog">
    <div class="pop-title">adom-shotlog</div>
    <div class="pop-row"><span class="k">what</span><span class="v">Chronological screenshot log. AI agents inject shots as they work; you view them here, newest first, with descriptions and source resolution.</span></div>
    <div class="pop-row"><span class="k">inject</span><span class="v">adom-shotlog inject -c CH -d "why" FILE — auto-resizes &gt;1500px and records the original size</span></div>
    <div class="pop-row"><span class="k">paste</span><span class="v">Ctrl+V anywhere to paste a screenshot from the clipboard</span></div>
    <div class="pop-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>
    <div class="pop-actions"><a class="pop-link" href="https://wiki.adom.inc/adom/adom-shotlog" target="_blank" rel="noopener">Wiki ↗</a></div>
  </div>
</header>

<div class="chtabs" id="chtabs" role="tablist" aria-label="Channels"></div>

<div class="feed" id="feed"><div class="empty">loading…</div></div>

<div class="helper" id="pupHelp" role="dialog" aria-modal="true" aria-label="Pup setup help">
  <div class="helper-card">
    <button class="h-close" id="pupHelpClose" data-tooltip="Close">✕</button>
    <div class="h-title" id="pupHelpTitle">Pup needs Adom Desktop</div>
    <div class="h-body" id="pupHelpBody"></div>
    <div class="h-lbl">copy this prompt into any AI thread and it will fix it for you</div>
    <div class="h-prompt" id="pupHelpPrompt"></div>
    <div class="h-actions">
      <button class="h-btn" id="pupHelpCopy">⧉ Copy prompt</button>
      <button class="h-btn secondary" id="pupHelpDismiss">Close</button>
      <a class="h-link" href="https://wiki.adom.inc/adom/adom-desktop" target="_blank" rel="noopener">Adom Desktop wiki ↗</a>
    </div>
  </div>
</div>
<div class="helper" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Preferences">
  <div class="helper-card">
    <button class="h-close" id="prefsClose">✕</button>
    <div class="h-title">Preferences</div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Auto-capture what AIs read</div>
        <div class="pref-desc">The full always-see guarantee: every image any AI thread reads is copied into read-captures, and if no shotlog is showing anywhere, one auto-opens on your default canvas, brings the read-captures tab to the front, and (if update alerts are on) flashes orange. Turn off to stop auto-capturing (deliberate injects still work).</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>turn the shotlog read-hook off</div>
      </div>
      <button class="toggle" id="prefHook" role="switch" aria-checked="true" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."></button>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Update alerts (orange flashes)</div>
        <div class="pref-desc">When new shots arrive: the webview tab highlights orange and the pup window flashes its taskbar. Turn off for calmer eyeballs - shotlog still brings the tab forward, just without the flashing.</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>turn off the shotlog orange flashes</div>
      </div>
      <button class="toggle" id="prefAlerts" role="switch" aria-checked="true" data-tooltip="Toggle the orange update cues (webview tab highlight + pup taskbar flash) when new shots arrive."></button>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Auto-switch to updated channel</div>
        <div class="pref-desc">When a shot arrives for another channel, jump that channel's tab to the front. Turn off to sit where you are (the tab just flashes orange and shows an unseen badge instead).</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog auto-switching tabs</div>
      </div>
      <button class="toggle" id="prefAutoswitch" role="switch" aria-checked="true" data-tooltip="Auto-switch the in-app channel tab when a new shot arrives for it."></button>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Default canvas</div>
        <div class="pref-desc">Where shotlog auto-opens a channel when nobody is watching. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Closing a pup window reverts that channel to webview. Pup needs Adom Desktop. Pick the other canvas while you're watching and closing this dialog moves your shotlog there too, after a 3-2-1 countdown you can cancel.</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>make shotlog default to pup</div>
      </div>
      <div class="seg" id="prefCanvas">
        <button data-v="webview">Webview</button>
        <button data-v="pup">Pup</button>
      </div>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Webview panel width</div>
        <div class="pref-desc">How wide the slim webview panel is when shotlog opens it, as a percent of your workspace (10-50).</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</div>
      </div>
      <span class="num-wrap" data-tooltip="Percent of the workspace width for the skinny shotlog panel (10-50). Applies to newly opened/auto-opened panels.">
        <input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span>
      </span>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Keep per channel</div>
        <div class="pref-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Auto-capture channels like read-captures never grow unbounded.</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>keep only the newest 100 shots per shotlog channel</div>
      </div>
      <span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Applies as new shots arrive.">
        <input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="10"><span class="unit">shots</span>
      </span>
    </div>
    <div class="pref-row">
      <div class="pref-info">
        <div class="pref-name">Prune idle channels after (days)</div>
        <div class="pref-desc">Channels with no new shots for this many days are deleted entirely (checked at startup and every 6 hours). 0 means never prune.</div>
        <div class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</div>
      </div>
      <span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. 0 = never.">
        <input class="num-input" id="prefRetention" type="number" min="0" max="3650" step="1"><span class="unit">days</span>
      </span>
    </div>
  </div>
</div>
<div class="helper" id="filterDlg" role="dialog" aria-modal="true" aria-label="Filters">
  <div class="helper-card">
    <button class="h-close" id="filterClose">✕</button>
    <div class="h-title">Filters</div>
    <div class="pref-info"><div class="pref-name">What kind of shot</div></div>
    <div class="frow">
      <button class="fchip" data-f="arrays" data-tooltip="Only shots carrying an Adom Desktop CHILD-WINDOW ARRAY (parent hwnd + its owned dropdowns/dialogs).">child-window arrays</button>
      <button class="fchip" data-f="full" data-tooltip="Only shots stored as a SAFE/FULL pair (a full-resolution original is viewable).">safe/full pairs</button>
      <button class="fchip" data-f="direct" data-tooltip="Only shots an AI deliberately injected (not auto-captured by the read-hook).">direct injects</button>
      <button class="fchip" data-f="auto" data-tooltip="Only auto-captures from the read-hook (images AIs read).">auto-captured</button>
    </div>
    <div class="pref-info" style="margin-top:8px"><div class="pref-name">Capture endpoint</div></div>
    <div class="frow" id="provRow">
      <button class="fchip" data-p="ad-window" data-tooltip="Adom Desktop window screenshots (desktop_screenshot_window).">AD·window</button>
      <button class="fchip" data-p="ad-screen" data-tooltip="Adom Desktop full-screen screenshots.">AD·screen</button>
      <button class="fchip" data-p="pup" data-tooltip="pup (Puppeteer browser bridge) page captures.">pup</button>
      <button class="fchip" data-p="hydrogen" data-tooltip="Hydrogen workspace screenshots.">hydrogen</button>
      <button class="fchip" data-p="abe" data-tooltip="Adom Browser Extension captures (the user's real browser).">ABE</button>
      <button class="fchip" data-p="hd" data-tooltip="Hydrogen Desktop's built-in screenshot feature.">HD</button>
      <button class="fchip" data-p="crop" data-tooltip="Crops from larger originals.">crop</button>
      <button class="fchip" data-p="clipboard" data-tooltip="Human clipboard pastes (Ctrl+V).">paste</button>
    </div>
    <div class="h-actions">
      <button class="h-btn secondary" id="filterClear">Clear all</button>
      <span style="flex:1"></span>
      <span id="filterCount" style="color:var(--text3);font:11px 'JetBrains Mono',monospace"></span>
    </div>
  </div>
</div>
<div class="toast" id="toast"></div>
<div class="modal" id="modal"><div class="modal-card" id="modalCard">
  <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">
  <div class="m-popups" id="mPopups" style="display:none"></div>
  <div id="mNerd"></div>
</div></div>
<div class="paste-overlay" id="pasteOverlay"><div class="paste-box" id="pasteMsg"></div></div>

<script>
(function() {
  const $ = id => document.getElementById(id);
  const APP_VERSION='__SHOTLOG_VERSION__'; // stamped by the server at serve time
  const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');
  const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');
  const mPopups = $('mPopups'), mNerd = $('mNerd');

  // Channel from URL path /log/<channel>/ (matches Rust route + base href)
  // Single-window app: shotlog owns its channel tabs. The path (or ?channel=)
  // only picks the INITIALLY active tab.
  const m = location.pathname.match(/\/log\/([^/]+)\/?/);
  let channel = (m && decodeURIComponent(m[1]))
    || new URLSearchParams(location.search).get('channel')
    || 'read-captures';
  $('channelName').textContent = channel;
  document.title = 'shotlog · ' + channel;

  // channel name -> { entries: null|[], unseen: 0, last: epoch-ms }
  const chans = new Map();
  function ensureChan(name){
    if(!chans.has(name)) chans.set(name, { entries: null, unseen: 0, last: 0 });
    return chans.get(name);
  }
  let APREFS = { update_autoswitch: true };

  let entries = [];     // the ACTIVE channel's list (ascending by id)
  let shotCount = 0;

  // ── Tooltip system (body-appended, 600ms, cursor-quadrant) ───────────────
  (function() {
    const tip = $('global-tooltip'); let cx=0, cy=0, timer=null;
    document.addEventListener('mousemove', e => { cx=e.clientX; cy=e.clientY; }, true);
    function show(el) {
      // Rich (authored, trusted) HTML via el._ttHtml; else plain text. Dynamic
      // values interpolated into _ttHtml are escaped by the builders below.
      if (el._ttHtml) { tip.innerHTML = el._ttHtml; }
      else { const t = el.getAttribute('data-tooltip'); if (!t) return; tip.textContent = t; }
      tip.classList.add('visible');
      const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14, M=6;
      const c=[{x:cx+O,y:cy+O},{x:cx-tw-O,y:cy+O},{x:cx+O,y:cy-th-O},{x:cx-tw-O,y:cy-th-O}]
        .find(p=>p.x>=M&&p.y>=M&&p.x+tw<=vw-M&&p.y+th<=vh-M)||{x:cx+O,y:cy+O};
      // skillpack §1d: ALWAYS hard-clamp BOTH axes as the last step. In a skinny
      // ~360px panel no quadrant fits a wide tooltip, so the fallback must be
      // pulled back inside the right/bottom edges too — not just the left/top min.
      const x=Math.min(Math.max(M,c.x), Math.max(M, vw-tw-M));
      const y=Math.min(Math.max(M,c.y), Math.max(M, vh-th-M));
      tip.style.transform=`translate(${x}px,${y}px)`;
      requestAnimationFrame(()=>tip.classList.add('shown'));
    }
    function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown');
      setTimeout(()=>{ if(!tip.classList.contains('shown')) tip.classList.remove('visible'); },180); }
    document.addEventListener('mouseover', e => { const el=e.target.closest('[data-tooltip]');
      if(el){ if(timer)clearTimeout(timer); timer=setTimeout(()=>show(el),600); } else hide(); }, true);
    document.addEventListener('mouseout', e => { if(!e.relatedTarget?.closest('[data-tooltip]')) hide(); }, true);
  })();

  // ── App-info popover ─────────────────────────────────────────────────────
  (function() {
    const trigger = $('brandZone'), target = $('app-info'); let st=null, ht=null;
    const show=()=>{clearTimeout(ht); if(target.classList.contains('show'))return; clearTimeout(st); st=setTimeout(()=>target.classList.add('show'),600);};
    const hide=()=>{clearTimeout(st); clearTimeout(ht); ht=setTimeout(()=>target.classList.remove('show'),220);};
    trigger.addEventListener('mouseenter',show); trigger.addEventListener('mouseleave',hide);
    target.addEventListener('mouseenter',show); target.addEventListener('mouseleave',hide);
    trigger.addEventListener('click',e=>{clearTimeout(st);target.classList.toggle('show');e.stopPropagation();});
    document.addEventListener('click',e=>{ if(!target.contains(e.target)&&!trigger.contains(e.target)) target.classList.remove('show'); });
  })();

  // ── Helpers ──────────────────────────────────────────────────────────────
  let toastTimer=null;
  function showToast(msg, ms){ toast.textContent=msg; toast.classList.add('show');
    toast.classList.toggle('lifted', !!document.querySelector('.cdown')); // never under the countdown card
    clearTimeout(toastTimer); toastTimer=setTimeout(()=>toast.classList.remove('show'), ms||1800); }
  function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
  function fmtTime(ts){ const d=new Date(ts), h=d.getHours(), mm=String(d.getMinutes()).padStart(2,'0');
    return (h%12||12)+':'+mm+(h<12?'am':'pm'); }
  function fmtDate(ts){ const d=new Date(ts), mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()];
    return mo+' '+d.getDate()+', '+fmtTime(ts); }
  // Which surface is showing this viewer? Explicit ?surface= wins (set by
  // `shotlog open`/force-open = webview, or an opener that knows it's pup);
  // otherwise infer mobile from the UA, else a plain browser.
  function detectSurface(){
    const q=new URLSearchParams(location.search).get('surface');
    if(q) return q;
    if(/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent||'')) return 'mobile';
    return 'browser';
  }
  function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }
  function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }
  // Child windows can be stored as a bare array (current) or wrapped — normalize to an array.
  // Provenance: map source strings (possibly compound "read-hook/ad-window")
  // to labeled chips explaining WHICH capture endpoint produced the image.
  const SRC_MAP={
    'read-hook':{label:'auto',cls:'auto',tip:'Auto-captured by the read-hook: an AI thread read this image and shotlog copied it here.'},
    'ad-window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows (dropdowns/dialogs).'},
    'desktop_screenshot_window':{label:'AD·window',cls:'ad',tip:'Adom Desktop window screenshot (desktop_screenshot_window): one app window, captured even when backgrounded; may include child windows.'},
    'ad-screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot (desktop_screenshot_screen): the whole monitor, popups and all.'},
    'desktop_screenshot_screen':{label:'AD·screen',cls:'ad',tip:'Adom Desktop full-screen screenshot: the whole monitor.'},
    'pup':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},
    'pup_screenshot':{label:'pup',cls:'',tip:'Captured by pup (the Puppeteer browser bridge): a real rendered browser page, lossless.'},
    'hydrogen':{label:'hydrogen',cls:'',tip:'Captured by Hydrogen\'s internal workspace screenshotter.'},
    'abe':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension driving the user\'s real signed-in browser.'},
    'nbrowser':{label:'ABE',cls:'',tip:'Captured by the Adom Browser Extension (nbrowser) in the user\'s real browser.'},
    'hd':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},
    'hd_shot':{label:'HD',cls:'',tip:'Captured by Hydrogen Desktop\'s built-in screenshot feature.'},
    'crop':{label:'crop',cls:'',tip:'Cropped from a larger original image.'},
    'clipboard':{label:'paste',cls:'',tip:'Pasted from the clipboard by the human (Ctrl+V in the viewer).'},
  };
  function srcChips(e){
    // 'cli' and 'api' are deliberate injects with no capture endpoint declared —
    // chip-less on purpose. Only DECLARED provenance gets a chip.
    if(!e.source || e.source==='cli' || e.source==='api') return [];
    return e.source.split('/').map(part=>{
      const m=SRC_MAP[part];
      return m || {label:part, cls:'', tip:'Capture source: '+part};
    });
  }
  function popList(e){ const p=e&&e.popups; return Array.isArray(p)?p:(p&&Array.isArray(p.popups)?p.popups:[]); }
  // EVERY field of the entry, flattened to readable dotted-key rows.
  // "Show me metadata" means ALL of it - nothing curated away. The friendly
  // rows (when / sizes) lead, then every remaining key recurses: meta.coordMap.*,
  // meta.screenshots[n].*, popups[n].*, the lot.
  function metaRows(e){
    const rows=[];
    const push=(k,v)=>{ if(v!==undefined&&v!==null&&v!=='') rows.push([k,typeof v==='object'?JSON.stringify(v):String(v)]); };
    const flat=(prefix,v)=>{
      if (v===null||v===undefined||v==='') return;
      if (Array.isArray(v)){ v.forEach((it,i)=>flat(prefix+'['+i+']',it)); return; }
      if (typeof v==='object'){ Object.keys(v).forEach(k=>flat(prefix?prefix+'.'+k:k, v[k])); return; }
      push(prefix, v);
    };
    push('when', e.timestamp?new Date(e.timestamp).toLocaleString():undefined);
    push('image size', (e.w&&e.h)?e.w+'×'+e.h:undefined);
    if(e.orig_w&&e.orig_h) push('orig capture', e.orig_w+'×'+e.orig_h);
    const handled=['timestamp','w','h','orig_w','orig_h'];
    const lead=['id','filename','description','kb','source','thread','context','full'];
    lead.forEach(k=>flat(k, e[k]));
    Object.keys(e).forEach(k=>{ if(!handled.includes(k)&&!lead.includes(k)) flat(k, e[k]); });
    return rows;
  }

  function dimNodes(e, into) {
    if (!e.w || !e.h) return;
    const dm=document.createElement('span'); dm.className='dim-main'; dm.textContent=e.w+'×'+e.h;
    dm.setAttribute('data-tooltip','Stored size (after any resize) — what the AI sees when it reads the shot.');
    into.appendChild(dm);
    // Show the original only when it genuinely differs from the stored size.
    // (New injects always establish the original; equal/absent → show nothing.)
    if (e.orig_w && e.orig_h && (e.orig_w !== e.w || e.orig_h !== e.h)) {
      const sep=document.createElement('span'); sep.className='sep'; sep.textContent='·'; into.appendChild(sep);
      const o=document.createElement('span'); o.className='dim-orig'; o.textContent='orig '+e.orig_w+'×'+e.orig_h;
      o.setAttribute('data-tooltip','Original capture resolution before resize — confirms what was actually captured.');
      into.appendChild(o);
    }
  }

  // Inline nerd-metadata panel, rendered ON the card (revealed in place — no zoom).
  function buildNerdInline(e) {
    const pops=popList(e);
    const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows
    const panel=document.createElement('div'); panel.className='card-nerd';
    const head=document.createElement('div'); head.className='nhead'; head.textContent='⌗ capture metadata';
    const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';
    cp.addEventListener('click', ev=>{ ev.stopPropagation();
      navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });
    head.appendChild(cp); panel.appendChild(head);
    const body=document.createElement('div'); body.className='nbody';
    metaRows(e).forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';
      const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;
      const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;
      r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });
    const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);
    panel.appendChild(body);
    return panel;
  }

  function card(e) {
    const c=document.createElement('div'); c.className='shot-card'; c.dataset.id=e.id;
    // For auto-captures the WHY is the headline: show the prompt the thread
    // was serving, and demote the generic "auto-captured: file.png" to a
    // small mono sub-line. Deliberate injects keep their description first.
    const isAuto=(e.source||'').startsWith('read-hook');
    if (isAuto && e.context) {
      const why=document.createElement('div'); why.className='shot-card-why';
      why.textContent=e.context;
      why.setAttribute('data-tooltip','The prompt this AI thread was working on when it read the image - the REASON this capture exists.');
      c.appendChild(why);
      const sub=document.createElement('div'); sub.className='shot-card-sub';
      sub.textContent='auto-captured: '+(e.filename||'').replace(/^auto-captured-screenshot-claude-read-for-analysis-/,'');
      c.appendChild(sub);
    } else {
      const desc=document.createElement('div');
      if (e.description) { desc.className='shot-card-desc'; desc.textContent=e.description; }
      else { desc.className='shot-card-desc no-desc'; desc.textContent='⚠ No description — inject with -d "what and why"'; }
      c.appendChild(desc);
    }
    const meta=document.createElement('div'); meta.className='shot-card-meta';
    const ts=document.createElement('span'); ts.className='ts'; ts.textContent=fmtDate(e.timestamp);
    ts.setAttribute('data-tooltip', new Date(e.timestamp).toLocaleString()); meta.appendChild(ts);
    if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }
    if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
      const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }
    if(e.full){ const s2=document.createElement('span'); s2.className='sep'; s2.textContent='·'; meta.appendChild(s2);
      const fc=document.createElement('span'); fc.className='chip ad'; fc.textContent='full';
      fc.setAttribute('data-tooltip','Safe/full pair: the full-resolution original is stored alongside - open the zoom and click "view full".');
      meta.appendChild(fc); }
    srcChips(e).forEach(c=>{ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
      const ch=document.createElement('span'); ch.className='chip '+(c.cls||''); ch.textContent=c.label;
      ch.setAttribute('data-tooltip', c.tip); meta.appendChild(ch); });
    if (e.thread){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
      const th=document.createElement('span'); th.className='thr'; th.textContent=e.thread;
      th.setAttribute('data-tooltip','The AI thread that produced this shot.'+(e.context?'\n\nWorking on: "'+e.context+'"':'')); meta.appendChild(th); }
    const pops=popList(e);
    if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
      const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' child window'+(pops.length>1?'s':'');
      b.setAttribute('data-tooltip','This capture also grabbed '+pops.length+' child window'+(pops.length>1?'s':'')+' of the parent window (dropdowns / dialogs) — shown below.');
      meta.appendChild(b); }
    // ⓘ meta chip toggles the inline metadata panel right here on the card.
    // EVERY card gets it - the full entry record is always worth seeing, not
    // just shots that happen to carry an AD sidecar.
    let nerd=null, chip=null;
    { const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);
      chip=document.createElement('span'); chip.className='badge-nerd'; chip.textContent='ⓘ meta';
      chip.setAttribute('data-tooltip','Tap to reveal EVERYTHING shotlog knows about this shot: the full entry record (thread, context, source, sizes), all capture metadata (coordMap, DPI, screen rect, child windows), and the raw JSON.');
      meta.appendChild(chip); }
    c.appendChild(meta);
    const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;
    img.addEventListener('click', ()=>openModal(e));   // image still zooms if you want it
    c.appendChild(img);
    // All child windows in ONE row, inline — no need to open the shot.
    if (pops.length){
      const row=document.createElement('div'); row.className='card-popups';
      const ph=document.createElement('span'); ph.className='ph'; ph.textContent='child windows ▸'; row.appendChild(ph);
      pops.forEach(p=>{
        const cell=document.createElement('div'); cell.className='pc';
        const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup'; im.loading='lazy';
        const cap=document.createElement('div'); cap.className='cap';
        cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?'  '+p.w+'×'+p.h:'');
        cell.appendChild(im); cell.appendChild(cap);
        cell.addEventListener('click', ev=>{ ev.stopPropagation();
          openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,
            timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });
        row.appendChild(cell);
      });
      c.appendChild(row);
    }
    // Inline nerd panel + wire the chip to toggle it in place.
    nerd=buildNerdInline(e); c.appendChild(nerd);
    chip.addEventListener('click', ev=>{ ev.stopPropagation();
      const open=nerd.classList.toggle('open'); chip.classList.toggle('active', open); });
    return c;
  }

  function openModal(e) {
    mImg.src=imgUrl(e); mImg.alt=e.description||e.filename;
    const isAutoM=(e.source||'').startsWith('read-hook');
    mDesc.textContent=(isAutoM&&e.context)?e.context:(e.description||'');
    mDesc.style.display=(mDesc.textContent)?'':'none';
    let why=document.getElementById('mWhy');
    if(!why){ why=document.createElement('div'); why.id='mWhy'; why.className='m-why'; mDesc.after(why); }
    if(e.context){ why.innerHTML='<span class="k">while working on</span>'+esc(e.context); why.style.display=''; }
    else { why.style.display='none'; }
    mMeta.innerHTML='';
    const add=(cls,txt,tip)=>{ const s=document.createElement('span'); if(cls)s.className=cls; s.textContent=txt; if(tip)s.setAttribute('data-tooltip',tip); mMeta.appendChild(s); };
    const sep=()=>add('sep','·');
    add('ts',fmtDate(e.timestamp));
    if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h);
      if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }
    if(e.kb){ sep(); add('',e.kb+' KB'); }
    if(e.full){ sep();
      const t=document.createElement('span'); t.className='m-full-toggle';
      let showingFull=false;
      const label=()=> t.textContent = showingFull
        ? ('viewing FULL '+(e.orig_w||'?')+'×'+(e.orig_h||'?')+' - click for safe'
           )
        : ('view full '+(e.orig_w||'?')+'×'+(e.orig_h||'?'));
      label();
      t.setAttribute('data-tooltip','This shot is a safe/full pair: the resized (safe) copy is shown by default; click to swap in the stored full-resolution original.');
      t.addEventListener('click', ev=>{ ev.stopPropagation();
        showingFull=!showingFull;
        mImg.src = showingFull ? popUrl(e.full) : imgUrl(e);
        label(); });
      mMeta.appendChild(t);
    }
    if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }
    if(e.thread){ sep(); add('thr',e.thread,'The AI thread that produced this shot.'); }

    // Child-window thumbnails — each opens full-size in its own modal entry.
    const pops=popList(e);
    mPopups.innerHTML='';
    if(pops.length){
      mPopups.style.display='';
      const h=document.createElement('div'); h.className='pop-h';
      h.textContent='Child windows · '+pops.length+' captured';
      mPopups.appendChild(h);
      const grid=document.createElement('div'); grid.className='pop-grid';
      pops.forEach(p=>{
        const cell=document.createElement('div'); cell.className='pop-cell';
        const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup';
        im.loading='lazy';
        const cap=document.createElement('div'); cap.className='pop-cap';
        cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?'  '+p.w+'×'+p.h:'');
        cell.appendChild(im); cell.appendChild(cap);
        cell.addEventListener('click', ev=>{ ev.stopPropagation();
          openModal({filename:p.filename, description:p.title||p.kind||'child window', w:p.w, h:p.h, kb:p.kb,
            timestamp:e.timestamp, source:'child window', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });
        grid.appendChild(cell);
      });
      mPopups.appendChild(grid);
    } else { mPopups.style.display='none'; }

    // Nerd metadata panel — readable rows + copyable raw JSON.
    mNerd.innerHTML='';
    const rows=metaRows(e);
    if(true){ // full metadata is ALWAYS available now
      const raw=JSON.stringify(e, null, 2); // FULL entry metadata - everything shotlog knows
      const d=document.createElement('details'); d.className='nerd';
      const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata';
      const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';
      cp.addEventListener('click', ev=>{ ev.preventDefault(); ev.stopPropagation();
        navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });
      sum.appendChild(cp); d.appendChild(sum);
      const body=document.createElement('div'); body.className='body';
      rows.forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';
        const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;
        const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;
        r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });
      const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);
      d.appendChild(body); mNerd.appendChild(d);
    }

    modal.classList.add('open'); modal.scrollTop=0;
  }
  function closeModal(){ modal.classList.remove('open'); }
  // Close on: backdrop click, clicking the zoomed image again, or Escape.
  // (Metadata/desc clicks are preserved — only the backdrop and image close.)
  modal.addEventListener('click', e => { if(e.target===modal) closeModal(); });
  mImg.style.cursor='zoom-out';
  mImg.addEventListener('click', e => { e.stopPropagation(); closeModal(); });
  document.addEventListener('keydown', e => { if(e.key==='Escape' && modal.classList.contains('open')) closeModal(); });

  function renderAll() {
    feed.innerHTML='';
    if (!entries.length) {
      feed.innerHTML='<div class="empty">No screenshots yet in <b>'+esc(channel)+'</b>.<br><br>'+
        'Inject one:<br><code>shotlog inject -c '+esc(channel)+' -d "what and why" FILE</code></div>';
      shotCount=0; updateCount(); return;
    }
    shotCount=entries.length;
    let shown=0;
    for (let i=entries.length-1;i>=0;i--){ const e=entries[i];
      if(!passFilters(e)) continue; shown++; feed.appendChild(card(e)); } // newest first
    if(!shown){
      feed.innerHTML='<div class="empty">No shots match the active filters ('+entries.length+' hidden).<br><br>Open the funnel in the header to adjust or clear them.</div>';
    }
    updateCount();
  }
  function channelTooltipHtml(countText){
    const cnt = countText ? '<div class="tt-count">'+esc(countText)+' captured</div>' : '';
    // read-captures is the SPECIAL auto-capture channel — explain it in full,
    // because even the shots' origin is non-obvious.
    if (channel === 'read-captures') {
      return '<div class="tt-title">read-captures — automatic capture</div>'+
        '<div class="tt-p">Whenever any AI thread here READS an image file (a screenshot, a render, a photo) to look at it, a hook instantly copies that exact image into this channel. You see everything your AIs looked at — they never have to remember to send it.</div>'+
        '<div class="tt-p">So shots appear on their own: a side effect of an AI opening an image to analyze it, not a deliberate post. Powered by the shotlog read-hook.</div>'+
        '<div class="tt-prompt"><span class="lbl">want a deliberate log? say to your AI</span>ralph test your app and show me all the screenshots in shotlog</div>'+
        cnt;
    }
    return '<div class="tt-title">Channel: '+esc(channel)+'</div>'+
      '<div class="tt-p">AI agents inject shots into this channel; you watch them arrive live, newest first.</div>'+
      '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me your progress in shotlog as you go</div>'+
      cnt;
  }
  function updateCount(){ const t = shotCount? shotCount+(shotCount===1?' shot':' shots'):'';
    $('shotCount').textContent = t;
    // Surface the count in the channel-name tooltip too, since .subject-meta is
    // hidden in the skinny panel (canonical adom-app-header: meta -> subject tooltip).
    $('channelName')._ttHtml = channelTooltipHtml(t); }

  // ── Load ───────────────────────────────────────────────────────────────
  // ── Filters: what kinds of shots to show in the feed ──────────────────────
  const FILTERS={arrays:false, full:false, direct:false, auto:false, prov:new Set()};
  function passFilters(e){
    if(FILTERS.arrays && !popList(e).length) return false;
    if(FILTERS.full && !e.full) return false;
    const isAuto=(e.source||'').startsWith('read-hook');
    if(FILTERS.direct && isAuto) return false;
    if(FILTERS.auto && !isAuto) return false;
    if(FILTERS.prov.size){
      const parts=(e.source||'').split('/');
      if(![...FILTERS.prov].some(x=>parts.includes(x))) return false;
    }
    return true;
  }
  function filtersActive(){ return FILTERS.arrays||FILTERS.full||FILTERS.direct||FILTERS.auto||FILTERS.prov.size>0; }
  (function wireFilters(){
    const dlg=$('filterDlg'), btn=$('filterBtn'); if(!dlg) return;
    const close=()=>dlg.classList.remove('open');
    $('filterClose').addEventListener('click',close);
    dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });
    document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });
    function refresh(){
      dlg.querySelectorAll('.fchip[data-f]').forEach(c=>c.classList.toggle('on', !!FILTERS[c.dataset.f]));
      dlg.querySelectorAll('.fchip[data-p]').forEach(c=>c.classList.toggle('on', FILTERS.prov.has(c.dataset.p)));
      btn.classList.toggle('filters-on', filtersActive());
      renderAll();
      const shown=entries.filter(passFilters).length;
      $('filterCount').textContent = filtersActive()? shown+' of '+entries.length+' shown' : '';
    }
    dlg.addEventListener('click',e=>{
      const c=e.target.closest('.fchip'); if(!c) return;
      if(c.dataset.f){ FILTERS[c.dataset.f]=!FILTERS[c.dataset.f];
        // direct & auto are mutually exclusive
        if(c.dataset.f==='direct'&&FILTERS.direct) FILTERS.auto=false;
        if(c.dataset.f==='auto'&&FILTERS.auto) FILTERS.direct=false; }
      else if(c.dataset.p){ FILTERS.prov.has(c.dataset.p)?FILTERS.prov.delete(c.dataset.p):FILTERS.prov.add(c.dataset.p); }
      refresh();
    });
    $('filterClear').addEventListener('click',()=>{ FILTERS.arrays=FILTERS.full=FILTERS.direct=FILTERS.auto=false; FILTERS.prov.clear(); refresh(); });
    btn.addEventListener('click',()=>{ dlg.classList.add('open'); refresh(); });
  })();

  // ── Tab strip: shotlog-managed channel tabs ───────────────────────────────
  const strip=$('chtabs');
  function tabOrder(){
    const names=[...chans.keys()];
    names.sort((a,b)=>{
      if(a==='read-captures') return -1;
      if(b==='read-captures') return 1;
      return (chans.get(b).last||0)-(chans.get(a).last||0);
    });
    return names;
  }
  function renderTabs(){
    strip.innerHTML='';
    for(const name of tabOrder()){
      const c=chans.get(name);
      const b=document.createElement('button');
      b.className='chtab'+(name===channel?' active':'')+(c.unseen>0?' unseen':'');
      b.setAttribute('role','tab');
      b.setAttribute('data-tooltip', name==='read-captures'
        ? 'The global auto-capture tab: every image any AI thread reads, from all threads intermingled.'
        : 'Channel "'+name+'". Click to view; the badge counts shots you have not seen yet.');
      const nm=document.createElement('span'); nm.textContent=name; b.appendChild(nm);
      const bd=document.createElement('span'); bd.className='badge'; bd.textContent=c.unseen>99?'99+':String(c.unseen); b.appendChild(bd);
      b.addEventListener('click',()=>activate(name));
      strip.appendChild(b);
    }
  }
  function flashTab(name){
    const idx=tabOrder().indexOf(name);
    const el=strip.children[idx];
    if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }
  }
  async function activate(name){
    channel=name;
    const c=ensureChan(name);
    $('channelName').textContent=name;
    document.title='shotlog · '+name;
    if(c.entries===null){
      try{ const data=await fetch('api/shots?channel='+encodeURIComponent(name)).then(r=>r.json());
        c.entries=Array.isArray(data)?data:[]; }catch{ c.entries=[]; }
    }
    entries=c.entries;
    c.unseen=0;
    renderAll(); feed.scrollTop=0;
    renderTabs();
  }
  // Boot: seed tabs from the broker's channel overview, then activate.
  (async function bootApp(){
    try{ APREFS=await fetch('api/prefs').then(r=>r.json()); }catch{}
    try{
      const rows=await fetch('api/channels').then(r=>r.json());
      if(Array.isArray(rows)) for(const r of rows){
        const c=ensureChan(r.channel); c.last=(r.last_epoch||0)*1000;
      }
    }catch{}
    ensureChan('read-captures');
    ensureChan(channel);
    await activate(channel);
  })();

  // ── Canvas toggle: show this channel in pup (full browser) or webview ─────
  (function initCanvasToggle(){
    const btn=$('canvasBtn'); if(!btn) return;
    const surf=detectSurface();
    if(surf!=='webview' && surf!=='pup') return;   // phone/plain browser: no toggle
    const toPup = surf==='webview';
    // Clean inline-SVG icons (Lucide-style): external-link for →pup, panel-right for →webview.
    const ICON = toPup
      ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
      : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>';
    const label = toPup ? 'Pup' : 'Webview';
    btn.classList.add('accent');
    btn.style.display='';
    const render=()=>{ btn.innerHTML=ICON+'<span>'+label+'</span>'; };
    render();
    // Structured tooltip: title, what it does, requirement (pup only), and the
    // plain-English prompt the human can say to an AI to do the same thing.
    // NOTE: also set a plain data-tooltip so the hover handler (which matches
    // `[data-tooltip]`) fires for this button; the renderer prefers _ttHtml.
    btn.setAttribute('data-tooltip', toPup ? 'Show in pup' : 'Show in webview');
    btn._ttHtml = toPup
      ? '<div class="tt-title">Show in pup</div>'+
        '<div class="tt-p">A full browser window: real zoom, whole-screen images. Closes this slim webview tab.</div>'+
        '<div class="tt-req">Requires Adom Desktop installed (pup is a default bridge that auto-installs with it).</div>'+
        '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in pup</div>'
      : '<div class="tt-title">Show in webview</div>'+
        '<div class="tt-p">The slim panel docked in Hydrogen. Closes this pup tab.</div>'+
        '<div class="tt-prompt"><span class="lbl">say to your AI</span>show me shotlog in webview</div>';
    const SPINNER='<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>';
    const busy=(t)=>{ btn.disabled=true; btn.classList.add('busy'); btn.innerHTML=SPINNER+'<span>'+esc(t)+'</span>'; };
    const restore=()=>{ btn.classList.remove('busy'); render(); btn.disabled=false; };
    const dest = toPup ? 'pup' : 'webview';
    const src  = toPup ? 'webview' : 'pup';

    // Count how many viewers of a surface are currently connected.
    async function surfaceCount(){
      try { const st=await fetch('api/viewer-status?channel='+encodeURIComponent(channel)).then(r=>r.json());
        return (st.surfaces||[]).filter(x=>x===dest).length; } catch { return -1; }
    }

    // FULL TRACKED SWITCH. Stages: check -> open destination -> WAIT until the
    // destination viewer's WebSocket actually connects (the full signal) ->
    // only then close this (source) surface. Any failure leaves this surface
    // open and reports exactly what happened. Nothing is ever closed blind.
    const uiLog=(line)=>{ fetch('api/log',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({line:'UI '+line})}).catch(()=>{}); };
    btn.addEventListener('click', async ()=>{
      if(btn.disabled) return;
      btn.disabled=true;
      uiLog('switch click: '+channel+' -> '+dest);
      let opened=false;
      try{
        showToast(toPup?'Opening the pup window…':'Opening the webview panel…', 3500);
        // Stage 0: availability (pup needs Adom Desktop). `?simulate=no-pup` in
        // the viewer URL demos the not-installed path without touching anything.
        if(toPup){
          busy('checking pup…');
          const sim=new URLSearchParams(location.search).get('simulate');
          const chk = sim==='no-pup'
            ? {available:false, reason:'no-adom-desktop', simulated:true}
            : await fetch('api/canvas-check').then(r=>r.json());
          if(!chk.available){ showPupHelp(chk); restore(); return; }
        }
        const baseline=await surfaceCount();
        if(baseline<0){ showToast('cannot reach the shotlog broker - switch aborted, nothing changed.', 7000); restore(); return; }

        // Stage 1: open the destination (nothing gets closed yet).
        busy('opening '+dest+'…');
        const op=await fetch('api/canvas-open',{method:'POST',headers:{'Content-Type':'application/json'},
          body:JSON.stringify({channel, to:dest})}).then(r=>r.json());
        if(!op.ok){
          uiLog('switch FAILED opening '+dest+' for '+channel+': '+String(op.error||op.detail||'').slice(0,160));
          showToast('could not open '+dest+': '+String(op.error||op.detail||'unknown').slice(0,300)+' - this tab stays open.', 10000);
          restore(); return;
        }
        opened=true;

        // Stage 2: wait for the REAL signal - the destination viewer connects
        // its WebSocket to the broker. Poll up to 30s, ticking progress.
        const T0=Date.now(), LIMIT=30000;
        let connected = baseline>0;  // already watching there? instant confirm
        while(!connected && Date.now()-T0<LIMIT){
          busy('waiting for '+dest+'… '+Math.round((Date.now()-T0)/1000)+'s');
          await new Promise(r=>setTimeout(r,800));
          const n=await surfaceCount();
          if(n>baseline) connected=true;
        }
        if(!connected){
          // Rollback: destination never came up. Close ONLY what we just opened
          // (best-effort), keep this surface, tell the human precisely.
          busy('rolling back…');
          try{ await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},
            body:JSON.stringify({channel, surface:dest})}); }catch{}
          uiLog('switch ROLLBACK: '+dest+' never connected for '+channel+' (30s)');
          showToast(dest+' opened but never connected (waited 30s) - rolled back, this tab stays open. '+(toPup?'Is the pup window blocked or the laptop asleep?':'Is Hydrogen open?'), 12000);
          restore(); return;
        }

        // Stage 3: destination is LIVE. Now (and only now) close this surface.
        uiLog('switch OK: '+channel+' now on '+dest);
        busy('switched ✓');
        showToast(dest+' is live - closing this '+src+' tab.', 4000);
        await fetch('api/canvas-close',{method:'POST',headers:{'Content-Type':'application/json'},
          body:JSON.stringify({channel, surface:src})}).catch(()=>{});
        // If this page is that tab, it is about to die - nothing more to do.
        // If the close missed (renamed tab etc.), re-enable so retry is possible.
        setTimeout(restore, 4000);
      }catch(e){
        showToast('switch failed: '+e.message+(opened?' ('+dest+' may have opened - check before retrying)':' - nothing changed.'), 9000);
        restore();
      }
    });
  })();

  // ── Pup helper dialog: reason-specific explanation + a COPYABLE AI prompt ──
  function showPupHelp(chk){
    const reason=chk.reason||'no-adom-desktop';
    const C={
      'no-adom-desktop':{
        title:'Pup needs Adom Desktop',
        body:'Pup opens shotlog in a full browser window on YOUR computer, driven by Adom Desktop. Adom Desktop is not installed (or not reachable) yet - pup is a default bridge that installs automatically right after it.',
        prompt:'Install Adom Desktop on my computer so shotlog can open pup windows. Start from wiki.adom.inc/adom/adom-desktop, walk me through the install, make sure it connects to my container, verify the pup bridge with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
      'desktop-not-connected':{
        title:'Adom Desktop is not connected',
        body:'Adom Desktop is installed but not linked to this container right now. Launch it on your computer and it reconnects automatically.',
        prompt:'My Adom Desktop is installed but not connected to this container. Help me get it running and linked, verify with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
      'pup-not-ready':{
        title:'Pup bridge is still warming up',
        body:'Adom Desktop is connected, but the pup browser bridge is not ready yet. It auto-installs on first use - this usually resolves in under a minute.',
        prompt:'The pup bridge on my Adom Desktop is not ready. Run `adom-desktop browser_prewarm`, verify with `adom-desktop browser_readiness`, then show my shotlog channel "'+channel+'" in pup.'},
      'multiple-desktops':{
        title:'More than one Adom Desktop is connected',
        body:'The broker cannot tell which computer should host the pup window. It needs a target.',
        prompt:'Multiple Adom Desktops are connected to my container. Figure out which one I am actually using, set SHOTLOG_AD_TARGET=<that name> on my shotlog broker, then show my shotlog channel "'+channel+'" in pup.'}
    };
    const c=C[reason]||C['no-adom-desktop'];
    $('pupHelpTitle').textContent=(chk.simulated?'(simulated) ':'')+c.title;
    $('pupHelpBody').textContent=c.body+(chk.hint&&!chk.simulated?' ('+chk.hint+')':'');
    $('pupHelpPrompt').textContent=c.prompt;
    $('pupHelp').classList.add('open');
  }
  (function wirePupHelp(){
    const dlg=$('pupHelp'); if(!dlg) return;
    const close=()=>dlg.classList.remove('open');
    $('pupHelpClose').addEventListener('click',close);
    $('pupHelpDismiss').addEventListener('click',close);
    dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });
    document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });
    $('pupHelpCopy').addEventListener('click',()=>{
      const t=$('pupHelpPrompt').textContent;
      const done=()=>{ $('pupHelpCopy').textContent='copied ✓'; setTimeout(()=>$('pupHelpCopy').innerHTML='⧉ Copy prompt',1400); };
      if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(t).then(done).catch(()=>{fallback();}); }
      else fallback();
      function fallback(){ const r=document.createRange(); r.selectNodeContents($('pupHelpPrompt'));
        const sel=getSelection(); sel.removeAllRanges(); sel.addRange(r);
        try{ document.execCommand('copy'); done(); }catch{ showToast('select the prompt text and copy manually',5000); } }
    });
  })();

  // ── Preferences dialog: read-hook toggle + default canvas ─────────────────
  (function wirePrefs(){
    const dlg=$('prefsDlg'), hook=$('prefHook'), seg=$('prefCanvas'); if(!dlg) return;
    // If the human changes the default canvas AWAY from the surface they're
    // looking at right now, closing the dialog offers to move this shotlog
    // there: a floating (never modal) 3-2-1 countdown card with a cancel,
    // then the SAME fully-tracked switch the header button runs. Works in
    // both directions (pup -> webview and webview -> pup).
    let pendingMove=null;
    function maybeCountdownMove(){
      if(!pendingMove) return;
      const dest=pendingMove; pendingMove=null;
      const surf=detectSurface();
      if(dest===surf || (surf!=='pup'&&surf!=='webview')) return;
      const btn=$('canvasBtn');
      if(!btn || btn.style.display==='none' || btn.disabled) return;
      fetch('api/log',{method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({line:'UI prefs default-canvas countdown: '+surf+' -> '+dest})}).catch(()=>{});
      const card=document.createElement('div'); card.className='cdown';
      const msg=document.createElement('div'); msg.className='cd-msg';
      msg.innerHTML='You switched to <b>'+esc(dest)+'</b> from '+esc(surf)+'. I will now move your shotlog to '+esc(dest)+' in <span class="cd-n">3</span>';
      const cancel=document.createElement('button'); cancel.className='cd-cancel'; cancel.textContent='stay in '+surf;
      card.appendChild(msg); card.appendChild(cancel); document.body.appendChild(card);
      let n=3, done=false, iv=0;
      const stop=(m)=>{ if(done) return; done=true; clearInterval(iv); card.remove(); if(m) showToast(m,5000); };
      cancel.addEventListener('click',()=>stop('staying in '+surf+' - the new default applies to future opens.'));
      iv=setInterval(()=>{ n--;
        if(n>0){ card.querySelector('.cd-n').textContent=String(n); return; }
        stop(null); btn.click();
      },1000);
    }
    const close=()=>{ dlg.classList.remove('open'); maybeCountdownMove(); };
    $('prefsClose').addEventListener('click',close);
    dlg.addEventListener('click',e=>{ if(e.target===dlg) close(); });
    document.addEventListener('keydown',e=>{ if(e.key==='Escape'&&dlg.classList.contains('open')) close(); });
    function renderPrefs(p){
      hook.setAttribute('aria-checked', String(!!p.read_hook));
      $('prefAlerts').setAttribute('aria-checked', String(p.update_alerts!==false));
      $('prefAutoswitch').setAttribute('aria-checked', String(p.update_autoswitch!==false));
      APREFS=p;
      seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas));
      $('prefWidth').value = p.webview_width ?? 20;
      $('prefMaxShots').value = p.max_shots ?? 200;
      $('prefRetention').value = p.retention_days ?? 30;
    }
    async function patch(body, okMsg){
      try{
        const r=await fetch('api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json());
        if(r.ok){ renderPrefs(r.prefs); showToast(okMsg, 3500); }
        else showToast('could not save: '+(r.error||'unknown'), 6000);
      }catch(e){ showToast('could not save preferences: '+e.message, 6000); }
    }
    hook.addEventListener('click',()=>{
      const now=hook.getAttribute('aria-checked')==='true';
      patch({read_hook: !now}, !now?'read-hook ON: images AIs read auto-capture again.':'read-hook OFF: nothing auto-captures until you turn it back on.');
    });
    $('prefAlerts').addEventListener('click',()=>{
      const now=$('prefAlerts').getAttribute('aria-checked')==='true';
      patch({update_alerts: !now}, !now?'orange update flashes ON.':'orange update flashes OFF: tabs still come forward, no flashing.');
    });
    $('prefAutoswitch').addEventListener('click',()=>{
      const now=$('prefAutoswitch').getAttribute('aria-checked')==='true';
      patch({update_autoswitch: !now}, !now?'auto-switch ON: updated channels come to the front.':'auto-switch OFF: tabs flash + badge, you stay put.');
    });
    seg.addEventListener('click',e=>{
      const b=e.target.closest('button[data-v]'); if(!b) return;
      patch({default_canvas: b.dataset.v}, 'channels now default to '+b.dataset.v+'.');
      const surf=detectSurface();
      pendingMove=((surf==='pup'||surf==='webview') && b.dataset.v!==surf) ? b.dataset.v : null;
    });
    function wireNum(id, key, msg){
      const el=$(id);
      const commit=()=>{ const v=parseInt(el.value,10); if(Number.isNaN(v)) return;
        patch({[key]: v}, msg(v)); };
      el.addEventListener('change', commit);
      el.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); commit(); el.blur(); } });
    }
    wireNum('prefWidth','webview_width', v=>'webview panel width is now '+v+'% for new opens.');
    wireNum('prefMaxShots','max_shots', v=>'keeping the newest '+v+' shots per channel.');
    wireNum('prefRetention','retention_days', v=> v===0?'idle channels are never pruned.':'idle channels prune after '+v+' days.');
    $('prefsBtn').addEventListener('click', async ()=>{
      try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{}
      dlg.classList.add('open');
    });
  })();

  // ── WebSocket live updates ({type:'entry', entry}) ───────────────────────
  let activeWs = null;
  // Gentle "you have unseen shots" cue: while this tab is backgrounded, count new
  // shots and show them in the page title (surfaces in the pup/browser tab strip
  // and phone tab). Cleared when the tab is shown again. Never steals focus.
  let pendingUnseen = 0;
  const baseTitle = 'adom-shotlog · ' + channel;
  function refreshTitle(){ document.title = pendingUnseen > 0 ? '● '+pendingUnseen+' · '+channel : baseTitle; }
  refreshTitle();
  // Report on-screen state whenever this tab/window is shown or hidden, so the
  // server can tell "watching" from "open but backgrounded/minimized".
  document.addEventListener('visibilitychange', () => {
    const visible = document.visibilityState === 'visible';
    try { if (activeWs && activeWs.readyState===1)
      activeWs.send(JSON.stringify({type:'vis', visible})); } catch {}
    if (visible) { pendingUnseen = 0; refreshTitle(); }
  });
  function connectWs() {
    const proto = location.protocol==='https:'?'wss:':'ws:';
    const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';
    const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/_all`);
    // Version handshake: the broker announces its version on connect; a page
    // from an older deploy reloads ITSELF (once) so no surface ever goes stale.
    ws.addEventListener('message', ev => {
      try { const m = JSON.parse(ev.data);
        if (m.type === 'welcome') {
          const stale = m.version && APP_VERSION.indexOf('__') !== 0 && m.version !== APP_VERSION
            && !sessionStorage.getItem('slReloaded-' + m.version);
          if (m.notice) {
            // One-shot narration from the broker (pup fallback/recovery, AI
            // notices). If this page is about to reload, hand the notice back
            // so the fresh page shows it instead of it dying with us.
            if (stale) fetch('api/notice',{method:'POST',headers:{'Content-Type':'application/json'},
              body:JSON.stringify({text:m.notice}),keepalive:true}).catch(()=>{});
            else showToast(m.notice, 12000);
          }
          if (stale) {
            sessionStorage.setItem('slReloaded-' + m.version, '1');
            location.reload();
          }
        }
      } catch {}
    });
    ws.onopen = () => { wsLed.classList.add('live'); wsLed.setAttribute('data-tooltip','WebSocket live — the server knows you are watching.');
      activeWs = ws;
      // Tell the server WHICH surface is watching + whether it's on-screen right now,
      // so `inject`/`viewers` can distinguish "watching" from "open but backgrounded".
      try { const sess=new URLSearchParams(location.search).get('session');
        ws.send(JSON.stringify({type:'hello', surface:detectSurface(), visible: document.visibilityState==='visible', session:sess||undefined})); } catch {} };
    ws.onmessage = ev => {
      try {
        const msg = JSON.parse(ev.data);
        if (msg.type==='show' && msg.channel) {
          // Consolidation: an AI asked to "open" a channel while this window is
          // already up - switch OUR tab instead of letting a second surface
          // spawn, and tell the human why.
          ensureChan(msg.channel);
          activate(msg.channel);
          renderTabs();
          showToast(msg.toast || ("Switched to '"+msg.channel+"' (consolidated - one shotlog window, tabs inside)."), 12000);
          return;
        }
        if (msg.type==='entry' && msg.entry) {
          const e = msg.entry, chName = msg.channel || channel;
          const c = ensureChan(chName);
          c.last = Date.now();
          if (c.entries !== null) {
            const existing = c.entries.find(x=>x.id===e.id);
            if (existing) Object.assign(existing, e); else c.entries.push(e);
          }
          if (chName === channel) {
            if(!passFilters(e)){ renderTabs(); return; }
            const atTop = feed.scrollTop < 60;
            const old = feed.querySelector(`[data-id="${e.id}"]`);
            if (old) { old.replaceWith(card(e)); }
            else { const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';
              feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0;
              if (document.hidden) { pendingUnseen++; refreshTitle(); } }
            renderTabs();
          } else if (APREFS.update_autoswitch !== false) {
            // The in-app mirror of pull-to-front: jump to the updated channel.
            activate(chName).then(()=>flashTab(chName));
          } else {
            c.unseen++; renderTabs(); flashTab(chName);
          }
        }
      } catch {}
    };
    ws.onclose = () => { wsLed.classList.remove('live');
      wsLed.setAttribute('data-tooltip','WebSocket disconnected — reconnecting…'); setTimeout(connectWs,3000); };
    ws.onerror = () => { try{ws.close();}catch{} };
  }
  connectWs();

  // ── Wiring ───────────────────────────────────────────────────────────────
  $('toTopBtn').addEventListener('click',()=>{ feed.scrollTop=0; });
  // Two-step arm instead of a browser-modal confirm(): first click arms the
  // button ("sure? clears all") for 3s; a second click within that window does it.
  (function initClear(){
    const btn=$('clearBtn'); let armed=false, t=null;
    const reset=()=>{ armed=false; btn.classList.remove('arm'); btn.textContent='clear'; clearTimeout(t); };
    btn.addEventListener('click', async ()=>{
      if(!armed){ armed=true; btn.classList.add('arm'); btn.textContent='sure? clears all';
        t=setTimeout(reset,3000); return; }
      reset();
      entries=[]; renderAll();
      await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});
      showToast('cleared');
    });
  })();

  // ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────
  const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');
  document.body.addEventListener('click',()=>document.body.focus());
  document.addEventListener('paste', async e => {
    const items=e.clipboardData?.items; if(!items) return;
    for (const it of items) if (it.type.startsWith('image/')) {
      e.preventDefault(); const blob=it.getAsFile(); if(!blob) continue;
      pasteMsg.innerHTML='<div class="title">Processing paste…</div><div class="detail">'+Math.round(blob.size/1024)+' KB</div>';
      pasteOverlay.classList.add('active');
      try {
        const dataUrl = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(blob); });
        const res = await fetch('api/paste',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel,dataUrl,source:'clipboard'})});
        const r = await res.json();
        if (r.ok) {
          let oi = (r.original_w&&r.original_h)?`<div class="detail">Original: <span class="val">${r.original_w}×${r.original_h}</span></div>`:'';
          pasteMsg.innerHTML=`<div class="title">Saved to log</div><div class="detail">File: <span class="val">${esc(r.name)}</span></div>`+
            `<div class="detail">Size: <span class="val">${r.w}×${r.h}</span> · <span class="val">${r.kb} KB</span></div>`+oi;
          setTimeout(()=>pasteOverlay.classList.remove('active'),2500);
        } else { pasteMsg.innerHTML='<div class="title" style="color:#f85149">Paste failed</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }
      } catch(err){ pasteMsg.innerHTML='<div class="title" style="color:#f85149">Error: '+esc(err.message)+'</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }
      break;
    }
  });

  // Refresh relative timestamps periodically (cards use absolute, but keep ts tooltips fresh) — no-op for now
})();
</script>
</body>
</html>