app
Shotlog - Screenshot Log
Public Made by Adomby adom
See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.
← Commit history
Publish 0.28.1
12 files changed
+310−124
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-shotlog"-version = "0.28.0"+version = "0.28.1" dependencies = [ "axum", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-shotlog"-version = "0.28.0"+version = "0.28.1" edition = "2021" description = "Screenshot log viewer and CLI for Adom" license = "MIT"
README.md+2−1@@ -45,6 +45,7 @@ adom-shotlog open -c my-project --skinny # a ~20% right-side column - It **auto-updates live** over a WebSocket: the moment any thread injects a shot, the card appears at the top of that channel, no refresh, no polling. - **One panel, shotlog's own tabs**: the viewer is a single app that manages a tab per channel itself (read-captures pinned first as the global auto-capture feed). New shots flash their tab orange, show an unseen badge, and (by default) auto-switch to the updated channel - all inside the app, no workspace tab juggling. - If a shot lands in a channel with **nobody watching**, the broker auto-opens the slim 20% webview for it, so a shot is never logged invisibly. (Opt out with `SHOTLOG_NO_FORCE_OPEN=1`.)+- **Newest first, lightly**: the shots at the top load first; shots below the fold load as you scroll to them, and shots that stay several screens away are unloaded again after 20 seconds, so a 200-shot log never holds 200 decoded images. Card heights are reserved up front, so nothing shifts while that happens. - **One click zooms**: click any thumbnail and it fills the window (in Hydrogen that is a 90% lightbox; in pup or a browser it is true fullscreen). The next click anywhere, the dark margin around the image, Esc, or the ✕ takes you straight back to the list. The ⛶ icon appears when you hover a thumbnail as a reminder that a click zooms. - **Own column (Settings, off by default)**: if you live in webviews, shotlog can open as its OWN narrow third column at the right edge (10% of the screen by default) instead of joining your webview pane's tabs. Your webview pane keeps its width; the editor gives up the space. See Settings below. - The **clear** button wipes EVERY channel (all the per-thread tabs that build up) and reports exactly what came back: `cleared N shots across M threads - freed X GB of disk`. Safe for running AIs: a channel auto-recreates the moment any thread injects to it again.@@ -108,7 +109,7 @@ So one `desktop_screenshot_window` call, one `--meta` inject, and you see the wh ## Settings -The viewer has a **Settings** dialog (the gear in the header). Every setting is also **AI-drivable**, ask your AI (e.g. "make shotlog default to pup") or `POST` it to `localhost:8820/api/prefs`. Settings are stored per environment in `<data-dir>/.prefs.json`, so your cloud (hw) and desktop (ah) shotlogs keep independent settings.+The viewer has a **Settings** dialog: the outline cog at the right end of the header opens it as a dismissible modal (Esc, the X, or a click outside closes it; the app behind dims 25% and is never blurred, so you can watch a setting take effect). It follows the Adom settings layout: six cards (Capture, Notifications, Gallery, Canvas, Webview layout, Storage), each with live status in its title, a plain-language explanation, a "Default:" hint on every row, real screenshots of both states where a setting changes something visible, and a two-click "Restore defaults" footer. The version number lives in the dialog title (and in the brand hover card) rather than the header. Every setting is also **AI-drivable**, ask your AI (e.g. "make shotlog default to pup") or `POST` it to `localhost:8820/api/prefs`. Settings are stored per environment in `<data-dir>/.prefs.json`, so your cloud (hw) and desktop (ah) shotlogs keep independent settings. | Setting | Default | What it does | |---|---|---|
VERSION+1−1@@ -1 +1 @@-0.28.0+0.28.1
bin/adom-shotlog⋯ 1 unchanged line ⋯
dev-skills/adom-shotlog-dev/SKILL.md+9@@ -452,3 +452,12 @@ Content is bounded anyway (context truncates at capture), so no cap needed. - Own column: prefs `webview_own_column` (default false) + `own_column_width` (default 10, 5-30). In `open_webview`, when ON and no Shotlog tab exists (`tab_exists` via `workspace find-tab`, which returns exit 0 + an error envelope on a miss), split the RIGHT-MOST leaf with ratio leaf_frac/(leaf_frac+own) so the pane keeps its width, then `grow_path` resizes horizontal ancestors so the LEFT sibling (editor) pays. Verify the math with the fake adom-cli shim (scratchpad `shim/`): expect split 0.615 + root 0.84 -> 0.74 for a 10% column beside a 16% pane. - Settings illustrations are REAL screenshots (John): `src/assets/own-column.png` is a crop of Hydrogen web (Edge, AdomLapper) after the column was created for real; captured with `adom-bridge --target AdomLapper desktop_screenshot_window {hwnd}` (the `.full.png` sibling is the sharp one), served at `/own-column.png` via include_bytes. Never ship a mockup. - ah and Hydrogen web keep SEPARATE workspace layouts: `adom-cli hydrogen workspace get` from this container edits the hydrogen.adom.inc workspace (what Edge shows), not ah's local layout on the laptop.++## Settings surface + header rules (2026-09-02, John: "follow the settings skill")++- The Settings dialog follows `adom-ui-design > settings-layout`: outline cog (`mdi:cog-outline` path, stroke 1.8, currentColor) as the RIGHT-MOST toolbar element (`#prefsBtn` after the LED), tooltip and aria-label "Settings"; `.settings-overlay` dims `rgba(0,0,0,.25)` with NO blur; Esc, X and click-outside all close; opens scrolled to the top with focus on the dialog; six `.settings-card`s with a live `.card-status`; rows are `.set-row` with a breadcrumb name (`.prefix` + teal `.title`), description, control, `Default:` hint and the AI chip; dependent shared-pane rows sit in `.set-children` with a spine, dimmed + `disabled` when Own column is on (never hidden); house toggles are `.toggle > .toggle-track > .toggle-thumb` keyed on `aria-checked`; footer has the two-click `#prefsRestore` (arms 4s) calling `DELETE /api/prefs`.+- Settings screenshots are REAL captures of BOTH states: `src/assets/own-column.png` (on) and `own-column-off.png` (off), cropped identically from `desktop_screenshot_window` `.full.png` captures of Hydrogen web on AdomLapper after driving the layout into each state (`close-panel Shotlog` + root resize 0.84 for off; remove-tab + open for on). Served at `/own-column.png` and `/own-column-off.png`.+- The version pill (`#verPill`) lives in the Settings title, mirrored into the brand hover card (`#appInfoVer`); the header carries no version.+- Header: `.subject` is `overflow:hidden` and the two-row layout kicks in below 760px (a ~570px ah pane overlapped before); below 300px (a 10% own column is ~170 CSS px) `.actions` wraps.+- Images: `lazyImg` (IntersectionObservers ROOTED AT `.feed`, the real scroll container; a viewport-rooted observer is clipped by it and loads one image) loads in-view shots at high priority, within one screen at low, unloads after 20s beyond three screens, never the top 4. `img.style.aspectRatio` is reserved from `e.w/e.h` so unloading never shifts scroll. `window._lazyImg.stats()` for tests.+- Ralph-test all of the above in pup via `pup_eval` (see the 2026-09-02 transcript for the exact expression): last toolbar child, backdrop color, four dismissals, card/row/hint counts, restore arm/disarm, image naturalWidth.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.28.0",+ "version": "0.28.1", "type": "app", "title": "Shotlog - Screenshot Log", "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-shotlog",- "version": "0.28.0",+ "version": "0.28.1", "type": "app", "title": "Shotlog - Screenshot Log", "description": "See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is really watching and on which surface, and ingests Adom Bridge child-window arrays via --meta.",
src/assets/own-column-off.pngadded⋯ 1 unchanged line ⋯
src/assets/own-column.png⋯ 1 unchanged line ⋯
src/html/index.html+272−117@@ -82,24 +82,36 @@ header { .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 { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; overflow: hidden; } /* never paint under the actions */ .subject-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .subject-meta { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px;- line-height: 1; white-space: nowrap; flex-shrink: 0; }+ line-height: 1; white-space: nowrap; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .subject-meta:not(:empty)::before { content: '·'; color: var(--text3); margin-right: 7px; } /* adom-app-header, REVISED for skinny (John, 2026-07-18): wide stays ONE 44px row, but a skinny ~360px panel goes to EXACTLY TWO rows — shedding zones on one row crushed the channel name to 2-3 chars once the action cluster grew. Row 1: logo + full channel name + count. Row 2: all actions, right-aligned (with room back for the canvas button's label). Never more than two rows. */-@media (max-width: 520px) {+/* Breakpoint raised 520 -> 760 (John, 2026-09-02, "ugly overlapping"): a+ ~570px ah webview pane still ran the channel name + count under the action+ cluster on one row. Anything narrower than 760px gets the two-row header. */+@media (max-width: 760px) { 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; } }+/* A 10% own column on a 1707px-wide workspace is ~170 CSS px: the action+ cluster cannot fit one row there, so below 300px it WRAPS (never overflows+ past the edge; John, 2026-09-02, "ugly overlapping"). */+@media (max-width: 300px) {+ header { padding: 4px 8px 5px; }+ .actions { flex-wrap: wrap; row-gap: 4px; gap: 4px; min-width: 0; }+ .hdr-btn { height: 24px; padding: 0 7px; }+ .hdr-btn.icon-only { width: 24px; }+} /* 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; }@@ -126,6 +138,7 @@ header { .hdr-btn.danger.arm { color: #fff; background: var(--danger); border-color: var(--danger); } /* Version pill: always shows what's RUNNING. Amber when a newer shotlog is published on the wiki (broker behind). Clicking it forces a fresh reload. */+.settings-head .ver-pill { margin-left: 10px; vertical-align: middle; } .ver-pill { flex-shrink: 0; font: 600 10px/1 'JetBrains Mono', monospace; color: var(--text3); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; cursor: pointer; white-space: nowrap; transition: color .15s, border-color .15s, background .15s; }@@ -482,6 +495,76 @@ body.compact .card-popups .ph { padding-top: 4px; } .helper-card .h-link:hover { text-decoration: underline; } /* Preferences dialog widgets (inside the .helper shell) */++/* ===== Settings (adom-ui-design > settings-layout) ===== */+/* Backdrop law: dim 25%, NEVER blur (settings often change the app behind). */+.helper.settings-overlay { background: rgba(0,0,0,.25); backdrop-filter: none; align-items: flex-start; padding: 24px 12px; overflow: hidden; }+.settings-dialog { padding: 0; overflow: hidden; border-radius: 12px; width: min(760px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; outline: none; }+.settings-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }+.settings-head .h-title { margin: 0; }+.settings-head .h-close { position: static; }+.settings-body { overflow: auto; padding: 16px 18px 18px; min-height: 0; }+.settings-body::-webkit-scrollbar-track { margin: 12px 0; }+.settings-card { background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid rgba(0,184,176,.45);+ border-radius: 10px; padding: 12px 16px 14px; margin-bottom: 14px; }+.settings-card .card-title { margin: 0; font: 600 13.5px 'Familjen Grotesk', sans-serif; color: var(--accent); display: flex; align-items: center; gap: 10px; height: 2rem; }+.settings-card .card-status { font: 400 11px 'Satoshi', sans-serif; color: var(--text2); }+.settings-card .card-status.ok { color: #3fb950; }+.settings-card .card-desc { margin: 0 0 4px; font: 12.5px/1.55 'Satoshi', sans-serif; color: var(--text2); max-width: 60ch; }+.settings-card code, .footer-note code { font-family: 'JetBrains Mono', monospace; font-size: 11px; background: rgba(110,118,129,.2); padding: 1px 4px; border-radius: 4px; }+.set-row { padding: 10px 0 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 5px; }+.settings-card .card-desc + .set-row { border-top: 1px solid var(--border); }+.set-name { margin: 0; font: 600 14px/1.3 'Familjen Grotesk', sans-serif; min-height: 2rem; display: flex; flex-wrap: wrap; align-items: baseline; color: var(--text); }+.set-name .prefix { white-space: nowrap; }+.set-name .prefix { margin-right: .35em; } /* flex collapses the literal space after the colon */+.set-name .title { color: #8fd3d4; } /* brand light teal: the part that varies */+.set-desc { margin: 0; font: 12.5px/1.55 'Satoshi', sans-serif; color: var(--text2); max-width: 60ch; }+.set-ctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }+.set-foot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }+.default-hint { font: 12px 'Satoshi', sans-serif; color: var(--text2); }+.set-foot .ai-chip { margin: 0; }+/* dependent settings: indented under the one they depend on, with a spine; dimmed, never hidden */+.set-children { margin-left: .75rem; padding-left: 1rem; border-left: 2px solid var(--border); }+.set-children .set-row.set-child { border-top: none; }+.set-child-why { font: 11.5px 'Satoshi', sans-serif; color: var(--text3); padding-top: 8px; }+.set-children[data-enabled="false"] { opacity: .55; }+.set-children[data-enabled="false"] .toggle, .set-children[data-enabled="false"] .num-wrap { pointer-events: none; }+/* screenshots of the thing the setting changes: real captures, both states */+.set-shots { display: flex; gap: 12px; flex-wrap: wrap; margin: 4px 0 6px; }+.setting-shot { margin: 0; flex: 1 1 260px; max-width: 520px; }+.setting-shot img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }+.setting-shot figcaption { margin-top: 5px; font: 12px 'Satoshi', sans-serif; color: var(--text2); }+/* house toggle: teal-on / neutral-off track, WHITE thumb, press compresses, knob stretches */+.toggle { -webkit-appearance: none; appearance: none; background: none; border: none; padding: 2px; cursor: pointer; flex-shrink: 0; width: auto; height: auto; border-radius: 10px; transition: transform .06s ease; position: static; }+.toggle::after { content: none; }+.toggle .toggle-track { display: block; width: 32px; height: 18px; background: var(--overlay); border: 1px solid var(--border); border-radius: 9px; position: relative; transition: background .15s ease, border-color .15s ease; }+.toggle:hover .toggle-track { border-color: var(--accent); }+.toggle:active { transform: translateY(1px) scale(.97); }+.toggle[aria-checked="true"] .toggle-track { background: var(--accent); border-color: var(--accent); }+.toggle .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: #fff; border-radius: 50%; transition: transform .18s ease, width .12s ease; }+.toggle[aria-checked="true"] .toggle-thumb { transform: translateX(14px); }+.toggle:active .toggle-thumb { width: 16px; }+.toggle[aria-checked="true"]:active .toggle-thumb { transform: translateX(10px); }+.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }+/* footer: two-click Restore defaults, auto-disarming */+.settings-footer { margin-top: 6px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }+.footer-note { margin: 0; font: 12px/1.5 'Satoshi', sans-serif; color: var(--text2); max-width: 70ch; }+.restore-btn { width: fit-content; display: inline-flex; align-items: center; gap: 6px; padding: 6px 13px; background: transparent;+ border: 1px solid var(--border); border-radius: 6px; color: var(--text); font: 600 13px 'Familjen Grotesk', sans-serif; cursor: pointer; }+.restore-btn:hover { background: var(--elevated); border-color: rgba(0,184,176,.5); }+.restore-btn:active { transform: translateY(1px); }+.restore-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }+.restore-btn.armed { color: var(--danger); border-color: rgba(248,81,73,.5); background: rgba(248,81,73,.08); }+/* input feedback on header buttons */+.hdr-btn:focus-visible, .h-close:focus-visible, .seg button:focus-visible, .num-step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }+.hdr-btn.settings-cog svg { width: 15px; height: 15px; }+@media (max-width: 520px) {+ .helper.settings-overlay { padding: 8px 6px; }+ .settings-dialog { max-height: calc(100vh - 16px); }+ .settings-body { padding: 12px 12px 14px; }+ .set-shots { flex-direction: column; }+}+ .pref-row { display: flex; align-items: center; gap: 14px; padding: 11px 0; border-top: 1px solid var(--border-muted); } .pref-row:first-of-type { border-top: none; }@@ -494,13 +577,12 @@ body.compact .card-popups .ph { padding-top: 4px; } .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;+.toggle-legacy-unused { width: 40px; height: 22px; border-radius: 999px; background: var(--overlay); border: 1px solid var(--border); cursor: pointer; position: relative; flex-shrink: 0; transition: background .15s, border-color .15s; }-.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); }+.toggle-legacy-unused[aria-checked="true"] { }+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; } .seg button { -webkit-appearance: none; appearance: none; border: none; background: var(--overlay); color: var(--text2); font: 600 12px 'Familjen Grotesk', sans-serif; padding: 6px 12px; cursor: pointer; }@@ -539,7 +621,7 @@ body.compact .card-popups .ph { padding-top: 4px; } /* A setting that pairs an on/off switch with its number field */ .pref-control { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; } /* When the switch is OFF, grey + disable the number field */-.pref-control.off .num-wrap { opacity: .4; pointer-events: none; }+.pref-control.off .num-wrap, .num-wrap.off { opacity: .4; pointer-events: none; } .ai-chip .lbl { color: var(--text3); font-family: 'Satoshi', sans-serif; font-size: 9px; letter-spacing: .04em; text-transform: uppercase; margin-right: 6px; } @@ -580,13 +662,13 @@ body.compact .card-popups .ph { padding-top: 4px; } <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="Settings: 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 EVERY channel and remove all the thread tabs (cannot be undone). Safe for running AIs: a channel auto-recreates the moment any thread injects to it again.">clear</button>- <span class="ver-pill" id="verPill" tabindex="0" data-tooltip="Loading version…">v…</span> <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live. While green the server knows you're watching and won't force-open a duplicate tab on new shots."></span>+ <button class="hdr-btn icon-only settings-cog" id="prefsBtn" aria-label="Settings" data-tooltip="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button> </div> <div class="hover-popover" id="app-info" role="dialog"> <div class="pop-title">adom-shotlog</div>+ <div class="pop-row"><span class="k">version</span><span class="v" id="appInfoVer">…</span></div> <div class="pop-row"><span class="k">what</span><span class="v">Chronological screenshot log. AI agents inject shots as they work; you view them here, newest first, with descriptions and source resolution.</span></div> <div class="pop-row"><span class="k">inject</span><span class="v">adom-shotlog inject -c CH -d "why" FILE — auto-resizes >1500px and records the original size</span></div> <div class="pop-row"><span class="k">paste</span><span class="v">Ctrl+V anywhere to paste a screenshot from the clipboard</span></div>@@ -613,118 +695,107 @@ body.compact .card-popups .ph { padding-top: 4px; } </div> </div> </div>-<div class="helper" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Settings">- <div class="helper-card">- <button class="h-close" id="prefsClose">✕</button>- <div class="h-title">Settings</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">OFF by default. When new shots arrive: the webview tab highlights orange, the in-app channel tab flashes, and the pup window flashes its taskbar. Tabs still come forward and unseen badges still show either way - this only controls the orange flashing.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>turn on the shotlog orange flashes</div>- </div>- <button class="toggle" id="prefAlerts" role="switch" aria-checked="false" data-tooltip="Off (default): no orange flashing when shots arrive. On: webview tab + in-app tab + pup taskbar flash orange."></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 class="helper settings-overlay" id="prefsDlg" role="dialog" aria-modal="true" aria-label="Shotlog Settings">+ <div class="helper-card settings-dialog" tabindex="-1" id="prefsCard">+ <div class="settings-head">+ <div class="h-title">Shotlog Settings <span class="ver-pill" id="verPill" tabindex="0" data-tooltip="Loading version…">v…</span></div>+ <button class="h-close" id="prefsClose" aria-label="Close settings" data-tooltip="Close (Esc, or click outside)">✕</button> </div>- <div class="pref-row">- <div class="pref-info">- <div class="pref-name">Compact cards (image-first)</div>- <div class="pref-desc">The gallery shows mostly image thumbnails: the info text (headline, time, sizes, provenance chips) hides behind the ⓘ chip on each image - hover it to reveal. The ⛶ fullscreen button stays on every thumbnail. Turn off for the classic text-above-image cards.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>turn off shotlog compact mode</div>+ <div class="settings-body" id="prefsBody">+ <section class="settings-card" id="cardCapture">+ <h3 class="card-title">Capture <span class="card-status" id="stHook"></span></h3>+ <p class="card-desc">What gets into shotlog without anyone asking. shotlog installs one Claude Code hook, <code>PostToolUse(Read)</code>, in <code>~/.claude/settings.json</code>. Whenever any AI thread reads an image file, the hook copies that exact file into the shared <code>read-captures</code> channel (a 2-second-capped local HTTP post that can never block the AI). Deliberate <code>adom-shotlog inject</code> calls are unaffected by this switch.</p>+ <div class="set-row">+ <h4 class="set-name" title="read_hook"><span class="prefix">Capture: </span><span class="title">Auto-capture what AIs read</span></h4>+ <p class="set-desc">Every image any AI thread reads is copied into read-captures. If no shotlog is showing anywhere, one auto-opens on your default canvas and brings the read-captures tab to the front. Turn off to stop auto-capturing; deliberate injects still work. Takes effect on the next image an AI reads.</p>+ <div class="set-ctl"><button class="toggle" id="prefHook" role="switch" aria-checked="true" aria-label="Auto-capture what AIs read" data-tooltip="Toggle the read-hook on or off. Takes effect on the next image an AI reads."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn the shotlog read-hook off</span></div> </div>- <button class="toggle" id="prefCompact" role="switch" aria-checked="true" data-tooltip="On (default): image-first cards, info on hover. Off: classic cards with the text always visible."></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 Bridge. 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>+ </section>+ <section class="settings-card" id="cardNotify">+ <h3 class="card-title">Notifications <span class="card-status" id="stNotify"></span></h3>+ <p class="card-desc">How a new shot gets your attention. shotlog never steals the OS foreground: the strongest cue it will ever use is an orange flash. Unseen badges on channel tabs and the page title work regardless of these two switches.</p>+ <div class="set-row">+ <h4 class="set-name" title="update_alerts"><span class="prefix">Notifications: </span><span class="title">Update alerts (orange flashes)</span></h4>+ <p class="set-desc">When new shots arrive: the webview tab highlights orange, the in-app channel tab flashes, and the pup window flashes its taskbar. Tabs still come forward and unseen badges still show either way; this only controls the orange flashing.</p>+ <div class="set-ctl"><button class="toggle" id="prefAlerts" role="switch" aria-checked="false" aria-label="Update alerts" data-tooltip="Off (default): no orange flashing when shots arrive. On: webview tab + in-app tab + pup taskbar flash orange."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: Off</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn on the shotlog orange flashes</span></div> </div>- <div class="seg" id="prefCanvas">- <button data-v="webview">Webview</button>- <button data-v="pup">Pup</button>+ <div class="set-row">+ <h4 class="set-name" title="update_autoswitch"><span class="prefix">Notifications: </span><span class="title">Auto-switch to updated channel</span></h4>+ <p class="set-desc">When a shot arrives for another channel, that channel's tab jumps to the front. Turn off to stay where you are; the tab just shows an unseen badge (and flashes, if alerts are on).</p>+ <div class="set-ctl"><button class="toggle" id="prefAutoswitch" role="switch" aria-checked="true" aria-label="Auto-switch to updated channel" data-tooltip="Auto-switch the in-app channel tab when a new shot arrives for it."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>stop shotlog auto-switching tabs</span></div> </div>- </div>- <div class="pref-row" id="rowOwnCol">- <div class="pref-info">- <div class="pref-name">Own column (third column at the right edge)</div>- <div class="pref-desc">OFF (default): shotlog is a tab inside your existing webview pane, sharing it with your other webviews. ON: when you already have a webview pane, shotlog opens as its OWN narrow third column at the right edge, the width below, so it is always visible while your webviews keep their pane and their width. shotlog never resizes your webview pane in this mode. Turn it on if you live in webviews and still want shotlog in the corner of your eye.</div>- <img class="pref-illus" src="own-column.png" alt="Illustration: editor on the left, your webviews in the middle pane, shotlog as its own narrow column at the right edge" data-tooltip="The layout with Own column ON: your webviews keep their pane; shotlog is the narrow third column at the far right.">- <div class="ai-chip"><span class="lbl">or say to your AI</span>open shotlog as its own 10% column on the right</div>+ </section>+ <section class="settings-card" id="cardGallery">+ <h3 class="card-title">Gallery <span class="card-status" id="stGallery"></span></h3>+ <p class="card-desc">How cards look in the list. Either way, one click on a thumbnail fills the window and the next click (or Esc) comes back; the ref (#42) and any handle sit in the strip above each thumbnail.</p>+ <div class="set-row">+ <h4 class="set-name" title="compact"><span class="prefix">Gallery: </span><span class="title">Compact cards (image-first)</span></h4>+ <p class="set-desc">The gallery is mostly thumbnails: the info text (headline, time, sizes, provenance chips) hides behind the ⓘ chip on each image; hover it to reveal. Turn off for the classic text-above-image cards.</p>+ <div class="set-ctl"><button class="toggle" id="prefCompact" role="switch" aria-checked="true" aria-label="Compact cards" data-tooltip="On (default): image-first cards, info on hover. Off: classic cards with the text always visible."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: On</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn off shotlog compact mode</span></div> </div>- <div class="pref-control">- <button class="toggle" id="prefOwnCol" role="switch" aria-checked="false" data-tooltip="ON: shotlog gets its own narrow column at the right edge (your webview pane is untouched). OFF: shotlog is a tab in your webview pane."></button>- <span class="num-wrap" data-tooltip="Width of shotlog's own column, percent of the screen (5-30).">- <button class="num-step" data-target="prefOwnWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>- <input class="num-input" id="prefOwnWidth" type="number" min="5" max="30" step="5"><span class="unit">%</span>- <button class="num-step" data-target="prefOwnWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button>- </span>+ </section>+ <section class="settings-card" id="cardCanvas">+ <h3 class="card-title">Canvas <span class="card-status" id="stCanvas"></span></h3>+ <p class="card-desc">Which surface shotlog opens on when it has to open one itself. A channel you explicitly opened in pup or webview keeps ITS choice; this covers everything else. Closing a pup window reverts that channel to webview for half an hour. Pup needs Adom Bridge.</p>+ <div class="set-row">+ <h4 class="set-name" title="default_canvas"><span class="prefix">Canvas: </span><span class="title">Default canvas</span></h4>+ <p class="set-desc">Where shotlog auto-opens a channel when nobody is watching. Pick the other canvas while you are watching and closing this dialog moves your shotlog there too, after a 3-2-1 countdown you can cancel.</p>+ <div class="set-ctl"><div class="seg" id="prefCanvas"><button data-v="webview">Webview</button><button data-v="pup">Pup</button></div></div>+ <div class="set-foot"><span class="default-hint">Default: Webview</span><span class="ai-chip"><span class="lbl">or say to your AI</span>make shotlog default to pup</span></div> </div>- </div>- <div class="pref-row" id="rowAutosize">- <div class="pref-info">- <div class="pref-name">Auto-size the webview panel</div>- <div class="pref-desc">When ON, shotlog re-forces the panel to the width below every time it opens the webview. When OFF (default), shotlog leaves the column at whatever width YOU last dragged it to; the width below only seeds a brand-new panel.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>turn on shotlog webview auto-sizing</div>+ </section>+ <section class="settings-card" id="cardLayout">+ <h3 class="card-title">Webview layout <span class="card-status" id="stLayout"></span></h3>+ <p class="card-desc">Where the shotlog webview lives inside your Hydrogen workspace, and how wide it is. Two alternatives: share your existing webview pane as a tab (the default), or take an own narrow column at the right edge. The width settings below belong to one mode or the other and are dimmed when they do not apply.</p>+ <div class="set-row" id="rowOwnCol">+ <h4 class="set-name" title="webview_own_column"><span class="prefix">Webview layout: </span><span class="title">Own column (third column at the right edge)</span></h4>+ <p class="set-desc">Off: shotlog is a tab inside your existing webview pane, sharing it with your other webviews. On: when you already have a webview pane, shotlog opens as its own narrow third column at the right edge, the width below, so it is always visible while your webviews keep their pane and their width. The editor gives up the space; shotlog never resizes your webview pane in this mode.</p>+ <div class="set-shots">+ <figure class="setting-shot"><img id="shotOwnOn" src="own-column.png?v=3" alt="Own column on: your webview pane keeps its tabs and width, and shotlog is a separate narrow column at the far right" loading="lazy"><figcaption>On: shotlog is the narrow third column at the far right.</figcaption></figure>+ <figure class="setting-shot"><img id="shotOwnOff" src="own-column-off.png?v=3" alt="Own column off: shotlog is one more tab inside the webview pane, beside your other webviews" loading="lazy"><figcaption>Off: shotlog is a tab in your webview pane.</figcaption></figure>+ </div>+ <div class="set-ctl"><button class="toggle" id="prefOwnCol" role="switch" aria-checked="false" aria-label="Own column" data-tooltip="On: shotlog gets its own narrow column at the right edge (your webview pane is untouched). Off: shotlog is a tab in your webview pane."><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="num-wrap" data-tooltip="Width of shotlog's own column, percent of the screen (5-30)."><button class="num-step" data-target="prefOwnWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefOwnWidth" type="number" min="5" max="30" step="5"><span class="unit">%</span><button class="num-step" data-target="prefOwnWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></div>+ <div class="set-foot"><span class="default-hint">Default: Off, 10% wide</span><span class="ai-chip"><span class="lbl">or say to your AI</span>open shotlog as its own 10% column on the right</span></div> </div>- <button class="toggle" id="prefAutosize" role="switch" aria-checked="false" data-tooltip="ON: snap the webview column to your preferred width on every open. OFF (default): respect the width you set manually."></button>- </div>- <div class="pref-row" id="rowWidth">- <div class="pref-info">- <div class="pref-name">Webview panel width</div>- <div class="pref-desc">The width shotlog uses when it first CREATES the slim webview panel, as a percent of your workspace (10-50). With auto-size OFF (default) this only seeds a new panel and never resizes an existing one; with it ON, every open snaps back to this.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</div>+ <div class="set-children" id="sharedChildren" title="Used when Own column is off: these size the SHARED webview pane.">+ <div class="set-child-why">Used when Own column is off (the shared pane).</div> <div class="set-row set-child" id="rowAutosize">+ <h4 class="set-name" title="webview_autosize"><span class="prefix">Shared pane: </span><span class="title">Auto-size the webview pane</span></h4>+ <p class="set-desc">On: shotlog re-forces the shared pane to the width below every time it opens the webview (rude if you use webviews a lot, hence off). Off: shotlog leaves the pane at whatever width you last dragged it to; the width below only seeds a brand-new pane.</p>+ <div class="set-ctl"><button class="toggle" id="prefAutosize" role="switch" aria-checked="false" aria-label="Auto-size the webview pane" data-tooltip="On: snap the webview pane to your preferred width on every open. Off (default): respect the width you set manually."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div>+ <div class="set-foot"><span class="default-hint">Default: Off</span><span class="ai-chip"><span class="lbl">or say to your AI</span>turn on shotlog webview auto-sizing</span></div> </div>- <span class="num-wrap" data-tooltip="Percent of the workspace width for the skinny shotlog panel (10-50). Seeds a new panel; only re-applied on every open when auto-size is ON.">- <button class="num-step" data-target="prefWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>- <input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span>- <button class="num-step" data-target="prefWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button>- </span>- </div>- <div class="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. Turn the switch OFF to keep every shot (unlimited on disk).</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 class="set-row set-child" id="rowWidth">+ <h4 class="set-name" title="webview_width"><span class="prefix">Shared pane: </span><span class="title">Webview pane width</span></h4>+ <p class="set-desc">The width shotlog uses when it first CREATES the shared webview pane, as a percent of your workspace (10-50). With auto-size off this only seeds a new pane and never resizes an existing one; with it on, every open snaps back to this.</p>+ <div class="set-ctl"><span class="num-wrap" data-tooltip="Percent of the workspace width for the shared webview pane (10-50). Seeds a new pane; only re-applied on every open when auto-size is on."><button class="num-step" data-target="prefWidth" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefWidth" type="number" min="10" max="50" step="5"><span class="unit">%</span><button class="num-step" data-target="prefWidth" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></div>+ <div class="set-foot"><span class="default-hint">Default: 20%</span><span class="ai-chip"><span class="lbl">or say to your AI</span>make the shotlog webview panel 25% wide</span></div> </div>- <div class="pref-control">- <button class="toggle" id="prefKeepToggle" role="switch" aria-checked="true" data-tooltip="On: cap the shots kept per channel. Off: keep everything (unlimited)."></button>- <span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Turn the switch off to keep unlimited.">- <button class="num-step" data-target="prefMaxShots" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>- <input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="25"><span class="unit">shots</span>- <button class="num-step" data-target="prefMaxShots" data-dir="1" aria-label="increase" tabindex="-1">+</button>- </span> </div>- </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). Turn the switch OFF to never prune.</div>- <div class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</div>+ </section>+ <section class="settings-card" id="cardStorage">+ <h3 class="card-title">Storage <span class="card-status" id="stStorage"></span></h3>+ <p class="card-desc">How much stays on disk. A janitor runs at startup and every 6 hours; it deletes the oldest files first and never touches a channel that is still receiving shots. The clear button in the header is the manual version: it wipes every channel at once and reports what it freed.</p>+ <div class="set-row">+ <h4 class="set-name" title="max_shots"><span class="prefix">Storage: </span><span class="title">Keep per channel</span></h4>+ <p class="set-desc">The janitor keeps only the newest N shots per channel on disk; older files are deleted as new ones arrive. Turn the switch off to keep every shot (unlimited on disk).</p>+ <div class="set-ctl"><span class="pref-control"><button class="toggle" id="prefKeepToggle" role="switch" aria-checked="true" aria-label="Keep per channel" data-tooltip="On: cap the shots kept per channel. Off: keep everything (unlimited)."><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="num-wrap" data-tooltip="Newest shots kept per channel (10-5000). Turn the switch off to keep unlimited."><button class="num-step" data-target="prefMaxShots" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefMaxShots" type="number" min="10" max="5000" step="25"><span class="unit">shots</span><button class="num-step" data-target="prefMaxShots" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></span></div>+ <div class="set-foot"><span class="default-hint">Default: On, 200 shots</span><span class="ai-chip"><span class="lbl">or say to your AI</span>keep only the newest 100 shots per shotlog channel</span></div> </div>- <div class="pref-control">- <button class="toggle" id="prefPruneToggle" role="switch" aria-checked="true" data-tooltip="On: prune idle channels after N days. Off: never prune."></button>- <span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. Turn the switch off to never prune.">- <button class="num-step" data-target="prefRetention" data-dir="-1" aria-label="decrease" tabindex="-1">−</button>- <input class="num-input" id="prefRetention" type="number" min="1" max="3650" step="5"><span class="unit">days</span>- <button class="num-step" data-target="prefRetention" data-dir="1" aria-label="increase" tabindex="-1">+</button>- </span>+ <div class="set-row">+ <h4 class="set-name" title="retention_days"><span class="prefix">Storage: </span><span class="title">Prune idle channels after</span></h4>+ <p class="set-desc">Channels with no new shots for this many days are deleted entirely. Turn the switch off to never prune.</p>+ <div class="set-ctl"><span class="pref-control"><button class="toggle" id="prefPruneToggle" role="switch" aria-checked="true" aria-label="Prune idle channels" data-tooltip="On: prune idle channels after N days. Off: never prune."><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="num-wrap" data-tooltip="Days of inactivity before a whole channel is pruned. Turn the switch off to never prune."><button class="num-step" data-target="prefRetention" data-dir="-1" aria-label="decrease" tabindex="-1">−</button><input class="num-input" id="prefRetention" type="number" min="1" max="3650" step="5"><span class="unit">days</span><button class="num-step" data-target="prefRetention" data-dir="1" aria-label="increase" tabindex="-1">+</button></span></span></div>+ <div class="set-foot"><span class="default-hint">Default: On, 30 days</span><span class="ai-chip"><span class="lbl">or say to your AI</span>never auto-delete idle shotlog channels</span></div> </div>+ </section>+ <footer class="settings-footer">+ <p class="footer-note">Settings are stored per environment in <code><data-dir>/.prefs.json</code> on this machine and apply to every open shotlog immediately. Restoring returns every option above to its default.</p>+ <button class="restore-btn" id="prefsRestore" data-tooltip="First click arms, second click restores every setting to its default. Disarms by itself after 4 seconds."><span class="r-ico">↺</span><span class="r-lbl">Restore defaults</span></button>+ </footer> </div> </div> </div>@@ -876,6 +947,51 @@ body.compact .card-popups .ph { padding-top: 4px; } return 'browser'; } function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }+ // ── Viewport-ordered image loader ─────────────────────────────────────────+ // A log is about the most recent shots, so: (1) images inside the viewport+ // load first at high priority, (2) images within ~one screen below load next+ // at low priority, (3) anything further down waits until you scroll near it,+ // (4) images that have been more than ~3 screens away for 20s are UNLOADED+ // (src dropped, data-src kept) and reload when they come back. The first+ // few cards at the top are never unloaded.+ const lazyImg = (() => {+ const KEEP_TOP = 4, UNLOAD_AFTER_MS = 20000;+ const timers = new WeakMap();+ const load = (img, prio) => { if (!img.src && img.dataset.src) { img.fetchPriority = prio; img.src = img.dataset.src; } };+ const unload = (img) => { if (img.src && !img.dataset.pinned) { img.removeAttribute('src'); } };+ const isTop = (img) => { const c = img.closest('.shot-card'); if (!c) return false; let n = 0, el = c; while ((el = el.previousElementSibling) && n <= KEEP_TOP) n++; return n < KEEP_TOP; };+ if (!('IntersectionObserver' in window)) return { watch(img){ load(img, 'auto'); } };+ // The gallery scrolls inside .feed (overflow:auto), NOT the window. An+ // observer rooted at the viewport gets clipped by that container, so a card+ // just below the feed's bottom edge never counts as "near" (only ONE image+ // loaded in a tall pup window). Root the observers at the feed itself.+ const rootEl = () => document.querySelector('.feed') || null;+ // near: in the viewport or within one screen below/above -> load+ const near = new IntersectionObserver(entries => {+ entries.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); // top of page first+ for (const en of entries) {+ if (!en.isIntersecting) continue;+ const rb = en.rootBounds || { top: 0, bottom: innerHeight };+ const inView = en.boundingClientRect.bottom > rb.top && en.boundingClientRect.top < rb.bottom;+ load(en.target, inView ? 'high' : 'low');+ }+ }, { root: rootEl(), rootMargin: '100% 0px 100% 0px' });+ // far: more than ~3 screens away for 20s -> unload (never the top cards)+ const far = new IntersectionObserver(entries => {+ for (const en of entries) {+ const img = en.target;+ if (en.isIntersecting) { const t = timers.get(img); if (t) { clearTimeout(t); timers.delete(img); } continue; }+ if (isTop(img)) continue;+ timers.set(img, setTimeout(() => { timers.delete(img); unload(img); }, UNLOAD_AFTER_MS));+ }+ }, { root: rootEl(), rootMargin: '300% 0px 300% 0px' });+ return {+ watch(img) { near.observe(img); far.observe(img); },+ // stats for tests/debugging: how many watched images currently hold a src+ stats() { const all = [...document.querySelectorAll('img[data-src]')]; return { watched: all.length, loaded: all.filter(i => i.src).length }; }+ };+ })();+ window._lazyImg = lazyImg; function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); } // Child windows can be stored as a bare array (current) or wrapped — normalize to an array. // Provenance: map source strings (possibly compound "read-hook/ad-window")@@ -1033,7 +1149,15 @@ body.compact .card-popups .ph { padding-top: 4px; } info.appendChild(meta); c.appendChild(info); const imgWrap=document.createElement('div'); imgWrap.className='card-img-wrap';- const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;+ const img=document.createElement('img'); img.className='shot-card-img'; img.alt=e.description||e.filename;+ // Viewport-ordered loading (John): the newest shots at the TOP load first,+ // the ones below the fold load as you scroll to them, and shots far off+ // screen are unloaded again over time so a 200-shot log does not pin+ // 200 decoded images in memory. The aspect ratio is reserved up front so+ // loading/unloading never shifts the scroll position.+ if (e.w && e.h) img.style.aspectRatio = e.w + ' / ' + e.h;+ img.dataset.src = imgUrl(e);+ lazyImg.watch(img); // ONE click on a thumbnail goes STRAIGHT to fill-window on every surface // (John: the old 3-layer stack, modal -> fullscreen -> unmax, is gone). // The next click anywhere (image or margin), or Esc, comes straight back@@ -1077,7 +1201,7 @@ body.compact .card-popups .ph { padding-top: 4px; } 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 im=document.createElement('img'); im.alt=p.title||p.kind||'popup'; im.dataset.src=popUrl(p.filename); lazyImg.watch(im); const cap=document.createElement('div'); cap.className='cap'; cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?' '+p.w+'×'+p.h:''); cell.appendChild(im); cell.appendChild(cap);@@ -1589,13 +1713,23 @@ body.compact .card-popups .ph { padding-top: 4px; } APREFS=p; seg.querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===p.default_canvas)); $('prefWidth').value = p.webview_width ?? 20;- // Own column vs shared pane: the two width settings are alternates.+ // Own column vs shared pane: the two width settings are alternates. The+ // shared-pane rows are CHILDREN of this switch: dimmed + inert when it is+ // on, never hidden (settings-layout: disable, never hide). const own = p.webview_own_column===true; $('prefOwnCol').setAttribute('aria-checked', String(own)); $('prefOwnWidth').value = p.own_column_width ?? 10;- $('prefOwnWidth').closest('.pref-control').classList.toggle('off', !own);- $('rowAutosize').classList.toggle('na', own);- $('rowWidth').classList.toggle('na', own);+ $('prefOwnWidth').closest('.num-wrap').classList.toggle('off', !own);+ $('sharedChildren').dataset.enabled = String(!own);+ $('sharedChildren').querySelectorAll('.toggle,.num-input,.num-step').forEach(el=>{ el.disabled = own; });+ // Live status in every card title (recomputed truth, never a cached claim).+ const st=(id,txt,ok)=>{ const el=$(id); if(!el) return; el.textContent=txt; el.classList.toggle('ok', !!ok); };+ st('stHook', p.read_hook!==false ? 'active' : 'off', p.read_hook!==false);+ st('stNotify', (p.update_alerts===true?'flashes on':'flashes off')+' · '+(p.update_autoswitch!==false?'auto-switch on':'auto-switch off'), false);+ st('stGallery', p.compact!==false ? 'compact' : 'classic', false);+ st('stCanvas', p.default_canvas||'webview', false);+ st('stLayout', own ? ('own column, '+(p.own_column_width??10)+'% wide') : ('shared pane'+(p.webview_autosize===true?', auto-sized':'')), false);+ st('stStorage', ((p.max_shots??200)>0?('newest '+(p.max_shots??200)+' shots per channel'):'unlimited shots')+' · '+((p.retention_days??30)>0?('prune after '+(p.retention_days??30)+' days'):'never prune'), false); // Keep per channel: max_shots 0 = OFF (unlimited). const mk = p.max_shots ?? 200; if(mk>0) lastKeep=mk; $('prefKeepToggle').setAttribute('aria-checked', String(mk>0));@@ -1659,7 +1793,7 @@ body.compact .card-popups .ph { padding-top: 4px; } dlg.addEventListener('click', e=>{ const b=e.target.closest('.num-step'); if(!b) return; const el=$(b.dataset.target); if(!el) return;- if(el.closest('.pref-control')?.classList.contains('off')) return; // disabled while switch is off+ if(el.closest('.pref-control')?.classList.contains('off') || el.closest('.num-wrap')?.classList.contains('off') || el.disabled) return; // disabled while its switch is off const step=parseInt(el.step,10)||1, dir=parseInt(b.dataset.dir,10)||0; const min=parseInt(el.min,10), max=parseInt(el.max,10); let v=(parseInt(el.value,10)||min)+dir*step;@@ -1681,7 +1815,26 @@ body.compact .card-popups .ph { padding-top: 4px; } $('prefsBtn').addEventListener('click', async ()=>{ try{ renderPrefs(await fetch('api/prefs').then(r=>r.json())); }catch{} dlg.classList.add('open');+ // Open at the TOP, focus the dialog itself (not the first control).+ const body=$('prefsBody'), card=$('prefsCard');+ body.scrollTop=0; card.focus({preventScroll:true});+ requestAnimationFrame(()=>{ body.scrollTop=0; }); });+ // Restore defaults: two clicks, the button IS the confirmation, disarms in 4s.+ (function(){+ const btn=$('prefsRestore'); if(!btn) return; let armed=false, t=0;+ const lbl=btn.querySelector('.r-lbl'), ico=btn.querySelector('.r-ico');+ const disarm=()=>{ armed=false; btn.classList.remove('armed'); lbl.textContent='Restore defaults'; ico.textContent='↺'; };+ btn.addEventListener('click', async ()=>{+ if(!armed){ armed=true; btn.classList.add('armed'); lbl.textContent='Click again to confirm'; ico.textContent='!'; clearTimeout(t); t=setTimeout(disarm,4000); return; }+ clearTimeout(t); disarm();+ try{+ const r=await fetch('api/prefs',{method:'DELETE'}).then(x=>x.json());+ if(r.ok){ renderPrefs(r.prefs); showToast('every setting is back to its default.', 4000); }+ else showToast('could not restore: '+(r.error||'unknown'), 6000);+ }catch(e){ showToast('could not restore settings: '+e.message, 6000); }+ });+ })(); })(); // ── WebSocket live updates ({type:'entry', entry}) ───────────────────────@@ -1837,6 +1990,8 @@ body.compact .card-popups .ph { padding-top: 4px; } // broker binary is behind; a reload can't fix that, an AI must update it) (function versionWatch(){ const pill=$('verPill'); if(!pill) return;+ const mirror=()=>{ const v=$('appInfoVer'); if(v) v.textContent=pill.textContent+(pill.classList.contains('stale')?' (update available)':''); };+ new MutationObserver(mirror).observe(pill,{childList:true,characterData:true,subtree:true,attributes:true}); let updCmd='adom-wiki pkg update adom/adom-shotlog'; const paint=(running, latest, upd)=>{ pill.textContent='v'+verSemver(running);
src/server.rs+22−1@@ -155,6 +155,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/ws/_all", get(ws_all_handler)) .route("/favicon.svg", get(serve_favicon)) .route("/own-column.png", get(serve_own_column_png))+ .route("/own-column-off.png", get(serve_own_column_off_png)) // The single-window app lives at /log/ — without this exact route the // page's relative favicon.svg 308s into the channel handler and the // browser gets HTML instead of an icon (= no favicon at all).@@ -182,6 +183,7 @@ pub fn create_router(state: Arc<AppState>) -> Router { .route("/api/canvas-pref", post(set_canvas_pref)) .route("/api/prefs", get(get_prefs)) .route("/api/prefs", post(set_prefs))+ .route("/api/prefs", axum::routing::delete(reset_prefs)) .route("/api/log", post(post_log)) .route("/api/notice", post(post_notice)) .route("/api/show", post(post_show))@@ -532,6 +534,21 @@ struct PrefsPatch { } /// Patch global preferences from the viewer's Preferences dialog.+/// Restore defaults (the Settings footer's two-click button): every option+/// back to its default, persisted and live-synced like any other change.+async fn reset_prefs(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {+ let old = state.store.app_prefs().await;+ let p = crate::store::AppPrefs::default();+ if old.default_canvas != p.default_canvas {+ state.store.clear_all_canvas_prefs().await;+ }+ state.store.set_app_prefs(p.clone()).await;+ println!("{} DECISION settings restored to defaults", ts());+ let frame = serde_json::json!({"type": "prefs", "prefs": p});+ let _ = state.store.ctrl_tx.send(frame.to_string());+ Json(serde_json::json!({"ok": true, "prefs": p}))+}+ async fn set_prefs( State(state): State<Arc<AppState>>, Json(patch): Json<PrefsPatch>,@@ -774,8 +791,12 @@ fn own_column(p: &crate::store::AppPrefs) -> Option<f64> { /// The Settings-dialog illustration of the "own column" layout. const OWN_COL_PNG: &[u8] = include_bytes!("assets/own-column.png");+const OWN_COL_OFF_PNG: &[u8] = include_bytes!("assets/own-column-off.png");+async fn serve_own_column_off_png() -> impl IntoResponse {+ ([(axum::http::header::CONTENT_TYPE, "image/png"), (axum::http::header::CACHE_CONTROL, "no-cache")], OWN_COL_OFF_PNG)+} async fn serve_own_column_png() -> impl IntoResponse {- ([(axum::http::header::CONTENT_TYPE, "image/png"), (axum::http::header::CACHE_CONTROL, "public, max-age=86400")], OWN_COL_PNG)+ ([(axum::http::header::CONTENT_TYPE, "image/png"), (axum::http::header::CACHE_CONTROL, "no-cache")], OWN_COL_PNG) } /// Paste-prompt text served at /prompt — guidance for AI agents on how to inject.