Adom Shotlog
Public Made by Adomby adom
See the shots your AI never showed you, and know it actually SAW them. adom-shotlog streams every screenshot the AI gathers into a live gallery (webview/pup/browser/phone), reports whether a human is
name: adom-shotlog-dev description: "DEV skill for building, debugging, and laying out adom-shotlog (the Rust CLI + server + embedded HTML viewer). Read before editing the viewer, the server, or the CLI. Covers the ralph-test-yourself loop, the hard fluid-layout rules (no outer-html scrollbars, ever; pretty scrollbars always), the build/deploy cycle, and the central-broker architecture. Trigger words: shotlog dev, build shotlog, shotlog layout, ralph test shotlog, viewer css, skinny webview layout, horizontal scrollbar, fluid layout, shotlog architecture, shotlog server."
adom-shotlog - DEV (build, layout, architecture)
The app is one Rust binary: src/main.rs (CLI), src/server.rs (HTTP + WS +
webview brokering), src/store.rs (channels + presence), src/open.rs (Hydrogen
tabs), and src/html/index.html (the viewer, embedded via include_str!).
ALWAYS ralph-test yourself after a layout change
Never trust a viewer change by eye at desktop width. Load it at the skinny ~20% webview width (≈360px) - that's where the layout actually has to hold - screenshot it, measure it, fix, repeat. The skinny view is the one that matters.
Headless ralph check (a real 360px VIEWPORT, so the CSS media queries fire - unlike CSS-narrowing an element, which does not):
// ralph-check.cjs - node ralph-check.cjs <live-viewer-url>
const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');
(async()=>{
const b = await puppeteer.launch({executablePath: process.env.CHROME, headless:'new',
args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage']});
const p = await b.newPage();
await p.setViewport({width:360, height:820, deviceScaleFactor:2});
await p.goto(process.argv[2], {waitUntil:'networkidle2', timeout:25000}).catch(()=>{});
await new Promise(r=>setTimeout(r,1800));
console.log(JSON.stringify(await p.evaluate(()=>{
const de=document.documentElement, vw=de.clientWidth;
const off=[...document.querySelectorAll('*')].map(el=>{const r=el.getBoundingClientRect();
return {t:el.tagName+'.'+((''+el.className).split(' ')[0]||''), right:Math.round(r.right)};})
.filter(x=>x.right>vw+1).sort((a,b)=>b.right-a.right).slice(0,10);
return {vw, htmlScrollW:de.scrollWidth, outerHScroll: de.scrollWidth>vw, off};
})));
await b.close();
})();
CHROME=$(ls -d ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/chrome|head -1) node ralph-check.cjs <url>
Pass = outerHScroll:false AND off:[]. If anything is in off, that element
is wider than the panel - make it fluid; don't paper over it with a scrollbar.
HARD RULE: ONE designated scroller - the outer html NEVER shows a scrollbar
The outermost html/body never scroll, either axis: html, body { overflow: hidden }.
The .feed is the one designated scroller; overlays (modal) scroll themselves.
Nested outer+inner scrollbars are an instant audit failure.
Hard-won: overflow-x: hidden alone is NOT enough. At fractional devicePixelRatio
(a pup window at DPR 1.5), 100dvh computes fractionally, scrollHeight rounds
1px past clientHeight, and a phantom OUTER vertical scrollbar appears beside the
feed's real one - even though a DPR-1 headless audit says everything fits. Root
overflow: hidden makes the phantom impossible. Audit BOTH axes in the ralph
check: outerVScroll and outerHScroll must both be false.
A skinny webview must never show a horizontal scrollbar on the outermost html. If it does, the layout isn't fluid. Rules that keep it fluid:
html, body { overflow: hidden; max-width: 100%; }(the guarantee), plusimg { max-width: 100%; }.- No fixed-width children. The classic offender was the child-window row: it
used fixed
168pxcells +overflow-x:auto, which overflowed the ~360px panel and made an ugly scrollbar. Fix:flex-wrap: wrapwith cellsflex: 1 1 130px; min-width: 0; max-width: 220pxso they share the row and wrap, never scroll. - The header NEVER wraps - it stays ONE 44px row at every width and SHEDS zones at narrow widths (see the header section below + adom-app-header spec).
- Long values wrap:
word-break: break-word/pre-wrap, never a fixed min-width that forces overflow.
HARD RULE: scrollbars are ALWAYS pretty
Every scrollable area gets styled - thin, rounded, themed, transparent track, teal on hover. Never ship a default OS scrollbar.
* { 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; }
HARD RULE: read the human-ui-patterns skillpack BEFORE touching the viewer
The viewer is a real UI, held to ~/.claude/skills/adom/ui-implementation-reference.md
(+ its SKILL.md). Read it before any UI change. Non-negotiables learned the hard way:
- NEVER use native
alert()/confirm()/prompt(). They are modal to the WHOLE browser and render "An embedded page at … says" - disgusting in a webview. For a destructive action use a two-step arm on the button itself (click -> "sure? clears all" for 3s -> click again), not a dialog. In-app only. - Toasts must be clamped:
max-width: min(360px, calc(100vw - 24px))and WRAP. Awhite-space:nowraptoast + a long message spills off both screen edges. Bit us. - Tooltips: body-appended
#global-tooltip, cursor-relative, HARD-CLAMPED both axes (skillpack §1c/§1d). The bug we shipped: the fallback clamped only the left/top min (Math.max(4,x)), so a 300px tooltip spilled off the right in the skinny ~360px panel. Fix =Math.min(Math.max(M,x), vw-tw-M)on BOTH axes + tooltipmax-width: min(300px, calc(100vw - 16px)). Never a CSS::after. - Number inputs ALWAYS show their unit (%, shots, days) as an inline suffix
inside the box (
.num-wrap+.unit). A bare number box leaves the human guessing what they're changing. User called it. - Buttons are styled pills, never raw
<button>. The.hdr-btnsystem (below) is the only header-button style. A raw<button>= the browser's grey default = instant "hideous". Icon buttons use inline SVG (Lucide-style stroke), never unicode glyphs like⧉ ◱ ↑. - The zoom modal closes on: backdrop click, a click on the IMAGE itself
(
cursor: zoom-out), AND Escape. All three. Not just the backdrop. - Zoom uses the real estate:
max-width: min(96vw, 2200px), imagemax-height: 84vh- never a fixed px cap (useless in both a skinny panel and a big pup window).
HARD RULE: canvas switches are STAGED and TRACKED - never close blind
The webview<->pup switch (the header button) is a state machine, both directions:
checking- pup availability (canvas-check) before anything moves.opening <dest>- POST /api/canvas-open opens the DESTINATION only.waiting for <dest>… Ns- poll /api/viewer-status until the destination viewer's WebSocket CONNECTS (surface count rises above the pre-switch baseline). That connect is the ONLY acceptable "it's really open" signal - a tab existing is NOT enough (the page could be blank/dead).- Only after connect:
switched ✓-> POST /api/canvas-close for the SOURCE.
Failure handling (all verified paths):
- open fails -> source stays, error toast with the broker's detail.
- destination never connects (30s) -> ROLLBACK: close only what we opened, source stays, precise toast (laptop asleep? Hydrogen closed?).
- broker unreachable / fetch throws -> nothing moves, restore button.
- destination already connected (dual-surface) -> instant confirm, then the source closes: the switch doubles as the dual-surface resolver.
- double-clicks: button disabled for the whole flow.
NEVER regress to fire-and-forget (open + close in parallel): a failed open then closes the user's ONLY surface. Bit us; the user called it.
Why switches take seconds anyway: every pup verb is a cloud->relay->laptop round-trip (~1s each) and a fresh Chrome window costs ~3-5s. Keep pup verbs to the MINIMUM number of round-trips (branch on one list_tabs result; never try-and-fallback chains, which serialize 3 trips). webview direction is local Hydrogen = fast (~1-2s).
HARD RULE: the header CSS is a SET - never clobber siblings
We have rebuilt this header repeatedly. The recurring self-inflicted wound: editing
ONE header rule (e.g. the skinny @media block) with a block-replace that drops the
sibling rules that follow it. When .hdr-btn / base .actions / .ws-led vanished,
every button silently fell back to raw browser chrome. These selectors MUST all
exist in src/html/index.html at all times:
header .brand .subject/.subject-name/.subject-meta .vsep
.actions (base: display:flex) .hdr-btn .hdr-btn:hover .hdr-btn.accent
.hdr-btn.danger .hdr-btn.danger.arm .hdr-btn.icon-only .ws-led .ws-led.live
@media (max-width:520px){…} (skinny: stays ONE 44px row; SHEDS zones, never wraps)
Header = ONE 44px row, always (the canonical adom-app-header spec;
~/.claude/skills/adom/guides/adom-app-header.md). Multi-row / wrapping is its
anti-pattern #1. Read that spec before ANY header change. For the skinny ~360px
panel, do NOT wrap to a 2nd row — SHED the least-important zones in order: brand
subtitle -> brand app-name (keep the logo) -> .vsep -> .subject-meta (its
value moves into the .subject-name tooltip) -> actions go icon-only. The
.subject-name stays and ellipsis-truncates. I shipped a 2-row grid here once
and it was a spec breach - never again.
Guard after EVERY index.html edit - if any line prints 0, you clobbered a rule:
for sel in '\.actions {' '\.hdr-btn {' '\.hdr-btn\.danger' '\.hdr-btn\.accent' \
'\.ws-led {' '\.ws-led\.live' 'max-width: 520px'; do
printf '%-24s %s\n' "$sel" "$(grep -c "$sel" src/html/index.html)"
done
Then ALSO ralph-screenshot the header at skinny width (it's the case that breaks).
The .hdr-btn system to preserve:
.actions { display:flex; align-items:center; gap:6px; margin-left:auto; flex-shrink:0; }
.hdr-btn { -webkit-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';
cursor:pointer; white-space:nowrap; 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 svg { width:13px; height:13px; } .hdr-btn.icon-only { padding:0; width:26px; justify-content:center; }
.hdr-btn.accent { color:var(--accent); border-color:rgba(0,184,176,.35); } /* the canvas toggle */
.hdr-btn.danger.arm { color:#fff; background:var(--danger); border-color:var(--danger); } /* armed clear */
Canvas doctrine: GLOBAL default rules; channel notes are in-memory only
Hard-won (the user called it out twice): per-channel canvas preferences must
NEVER persist. Everything follows prefs.default_canvas except an in-memory
note from the human's OWN actions (viewer canvas-button click; deliberate pup
close), forgotten on restart. CLI opens and auto-opens must NOT record notes -
we once had every AI open --skinny and even the broker's own force-opens
writing "choices" to disk, permanently overriding the user's global setting.
Panel width comes from prefs.webview_width (never hardcode 0.2 again).
Logging: the broker's stdout IS the audit trail. Deploys APPEND to
prod-srv.log (never truncate: >> log, echo a deploy marker) and every
decision path prints a timestamped DECISION ... line (force-open canvas +
why, canvas notes, close-reverts, pup retries).
Build + deploy cycle
cargo build --release
# targeted restart of the ONE server (never a broad pkill):
kill "$(pgrep -f '[a]dom-shotlog serve' | head -1)"; sleep 1
sudo cp target/release/adom-shotlog /usr/local/bin/adom-shotlog
adom-shotlog serve & # data dir: ./screenshots/shotlog
SKILL.md (and the viewer HTML) are embedded via include_str! - you must
rebuild after editing them. Bump Cargo.toml + VERSION together.
Architecture: one server = the central broker (per container)
- The same binary is both the server and the CLI.
adom-shotlog servespawns ONE broker on port 8820;inject/open/viewers/channelsare CLI verbs that HTTP-POST/GET to it. - Every AI thread in the container runs
adom-shotlog inject, which posts to that one local server. So the server is the central broker: it owns all channels, tracks every viewer's surface + on-screen state (presence over the WebSocket), and manages all the Hydrogen webviews (auto-open a slim 20% panel, bring the right channel's tab forward - by its uniqueShots: <channel>name so parallel shotlogs are never cross-touched). - Webview activate =
workspace active-tab- NEVER move-tab. To surface a channel's tab:adom-cli hydrogen workspace active-tab --name "Shots: <ch>"(brings it to the front WITHIN its own pane), thenalert set --namefor the orange indicator. HARD-WON DON'T: an earlier version "bounced" the tab through another pane with twomove-tabcalls (move-tab auto-activates in its target pane). That re-parents iframes and re-renders the ENTIRE Hydrogen workspace, interrupting the user mid-keystroke in their VS Code tab. If a verb seems missing, search--helpoutput for hyphenated forms (active-tabdoes not match a grep foractivate) before building a workaround. - It's per-container: threads in a different container get their own server.
shotlog channelsprints the live overview - how every thread is using it.
Metadata display rule (John, 2026-07-17)
When the human asks to see metadata, show ALL of it: every entry field plus the entire sidecar flattened (meta.coordMap., popups[n]., png_meta.*) plus the raw JSON, inline. Never a curated subset. "Almost nothing" summaries got this exact complaint: "when i ask you to show me metadata i mean all of it".
Broker process naming trap (2026-07-17)
The read-hook's self-heal starts the broker as shotlog serve (argv[0]=shotlog),
NOT adom-shotlog serve. Managing the broker means checking BOTH names:
pgrep -x adom-shotlog; pgrep -x shotlog. And pgrep -f "adom-shotlog serve"
MATCHES YOUR OWN SHELL WRAPPER (the shell-snapshot eval embeds the pattern) —
killing head -1 of that killed my own shell (exit 144). Kill by exact name or
explicit PID from ps, never by -f pattern. Also: any deploy-restart window can
be resurrected mid-window by a hook self-heal running the OLD recovery state —
after migrating on-disk data, verify the SERVING process started AFTER the
migration (check prod-srv.log recovery line), not just that the port answers.
Header rule REVISED (John, 2026-07-18)
Supersedes the "always ONE 44px row" skinny rule. Wide: still ONE 44px row. Skinny (<=520px): EXACTLY TWO rows - row 1 = logo + full channel name + count, row 2 = all actions right-aligned. Reason: zone-shedding on one row crushed the channel name to 2-3 chars once the action cluster grew (canvas + top + filter + prefs + clear + LED). John: "in skinny view you probably need to go to two lines in header". Never more than two rows, and never shed the channel name.
Ghost-surface detection (2026-07-18)
"Is shotlog showing anywhere?" has THREE sources of truth and they can ALL disagree: (1) broker viewer-status = only LIVE WebSocket pages; (2) pup bridge tab registry = can lose windows that still exist; (3) the OS window list (desktop_list_windows, match title 'adom-shotlog') = ground truth for pup. A ghost = OS window exists but registry has 0 tabs and broker has 0 pup viewers - a stale page silently showing old shots. Detect by cross-checking all three; kill ghosts with desktop_find_control {hwnd, name:"Close"} -> desktop_ui_click (background UIA, no focus steal). Never process_kill chrome.
Deploys MUST refresh surfaces (John, 2026-07-18: "always do that")
NEVER hand-roll the deploy cycle - run tools/deploy.sh. It builds, installs, restarts, verifies the served stamp, and refreshes every live surface. The handshake stamp is version+boot-epoch precisely because same-version redeploys (0.23.5b/c/d) once shipped invisible changes: the human stared at a stale UI. A deploy is NOT done until the human's open surfaces are re-verified live on the new stamp (browser_evaluate the page's APP_VERSION - read it, don't assume).
"Why did it open in webview when default is pup?" playbook (2026-07-19)
The answer is ALWAYS in prod-srv.log - read it before theorizing. Chain seen live: (1) AD truly offline -> 6 patient pup retries fail -> documented webview FALLBACK ("no choice recorded"); (2) fallback webview stays connected, so every later shot routes to it and "nobody watching" never re-fires (fixed 0.23.9: recover_pup_from_fallback - default=pup + webview-only -> retry pup per shot, 5 min cooldown, close fallback only after pup CONNECTS); (3) AD back online but browser_open_window says status:"bridge_starting"/pending:true while Chrome auto-starts - that is NOT a failure, WAIT and retry (fixed 0.23.10). Every step must log a DECISION line; if you can't answer "why this surface" from the log alone, add the missing DECISION line before doing anything else.
---
name: adom-shotlog-dev
description: "DEV skill for building, debugging, and laying out adom-shotlog (the Rust CLI + server + embedded HTML viewer). Read before editing the viewer, the server, or the CLI. Covers the ralph-test-yourself loop, the hard fluid-layout rules (no outer-html scrollbars, ever; pretty scrollbars always), the build/deploy cycle, and the central-broker architecture. Trigger words: shotlog dev, build shotlog, shotlog layout, ralph test shotlog, viewer css, skinny webview layout, horizontal scrollbar, fluid layout, shotlog architecture, shotlog server."
---
# adom-shotlog - DEV (build, layout, architecture)
The app is one Rust binary: `src/main.rs` (CLI), `src/server.rs` (HTTP + WS +
webview brokering), `src/store.rs` (channels + presence), `src/open.rs` (Hydrogen
tabs), and `src/html/index.html` (the viewer, embedded via `include_str!`).
## ALWAYS ralph-test yourself after a layout change
Never trust a viewer change by eye at desktop width. Load it at the **skinny ~20%
webview width (≈360px)** - that's where the layout actually has to hold - screenshot
it, measure it, fix, repeat. The skinny view is the one that matters.
Headless ralph check (a real 360px VIEWPORT, so the CSS media queries fire - unlike
CSS-narrowing an element, which does not):
```js
// ralph-check.cjs - node ralph-check.cjs <live-viewer-url>
const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');
(async()=>{
const b = await puppeteer.launch({executablePath: process.env.CHROME, headless:'new',
args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage']});
const p = await b.newPage();
await p.setViewport({width:360, height:820, deviceScaleFactor:2});
await p.goto(process.argv[2], {waitUntil:'networkidle2', timeout:25000}).catch(()=>{});
await new Promise(r=>setTimeout(r,1800));
console.log(JSON.stringify(await p.evaluate(()=>{
const de=document.documentElement, vw=de.clientWidth;
const off=[...document.querySelectorAll('*')].map(el=>{const r=el.getBoundingClientRect();
return {t:el.tagName+'.'+((''+el.className).split(' ')[0]||''), right:Math.round(r.right)};})
.filter(x=>x.right>vw+1).sort((a,b)=>b.right-a.right).slice(0,10);
return {vw, htmlScrollW:de.scrollWidth, outerHScroll: de.scrollWidth>vw, off};
})));
await b.close();
})();
```
`CHROME=$(ls -d ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/chrome|head -1) node ralph-check.cjs <url>`
**Pass = `outerHScroll:false` AND `off:[]`.** If anything is in `off`, that element
is wider than the panel - make it fluid; don't paper over it with a scrollbar.
## HARD RULE: ONE designated scroller - the outer html NEVER shows a scrollbar
The outermost html/body never scroll, either axis: `html, body { overflow: hidden }`.
The `.feed` is the one designated scroller; overlays (modal) scroll themselves.
Nested outer+inner scrollbars are an instant audit failure.
Hard-won: `overflow-x: hidden` alone is NOT enough. At fractional devicePixelRatio
(a pup window at DPR 1.5), `100dvh` computes fractionally, `scrollHeight` rounds
1px past `clientHeight`, and a phantom OUTER vertical scrollbar appears beside the
feed's real one - even though a DPR-1 headless audit says everything fits. Root
`overflow: hidden` makes the phantom impossible. Audit BOTH axes in the ralph
check: `outerVScroll` and `outerHScroll` must both be false.
A skinny webview must never show a horizontal scrollbar on the outermost html. If
it does, the layout isn't fluid. Rules that keep it fluid:
- `html, body { overflow: hidden; max-width: 100%; }` (the guarantee), plus
`img { max-width: 100%; }`.
- **No fixed-width children.** The classic offender was the child-window row: it
used fixed `168px` cells + `overflow-x:auto`, which overflowed the ~360px panel
and made an ugly scrollbar. Fix: `flex-wrap: wrap` with cells `flex: 1 1 130px;
min-width: 0; max-width: 220px` so they share the row and wrap, never scroll.
- **The header NEVER wraps** - it stays ONE 44px row at every width and SHEDS
zones at narrow widths (see the header section below + adom-app-header spec).
- Long values wrap: `word-break: break-word` / `pre-wrap`, never a fixed min-width
that forces overflow.
## HARD RULE: scrollbars are ALWAYS pretty
Every scrollable area gets styled - thin, rounded, themed, transparent track,
teal on hover. Never ship a default OS scrollbar.
```css
* { 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; }
```
## HARD RULE: read the human-ui-patterns skillpack BEFORE touching the viewer
The viewer is a real UI, held to `~/.claude/skills/adom/ui-implementation-reference.md`
(+ its SKILL.md). Read it before any UI change. Non-negotiables learned the hard way:
- **NEVER use native `alert()` / `confirm()` / `prompt()`.** They are modal to the
WHOLE browser and render "An embedded page at … says" - disgusting in a webview.
For a destructive action use a **two-step arm** on the button itself (click ->
"sure? clears all" for 3s -> click again), not a dialog. In-app only.
- **Toasts must be clamped**: `max-width: min(360px, calc(100vw - 24px))` and WRAP.
A `white-space:nowrap` toast + a long message spills off both screen edges. Bit us.
- **Tooltips: body-appended `#global-tooltip`, cursor-relative, HARD-CLAMPED both
axes** (skillpack §1c/§1d). The bug we shipped: the fallback clamped only the
left/top min (`Math.max(4,x)`), so a 300px tooltip spilled off the right in the
skinny ~360px panel. Fix = `Math.min(Math.max(M,x), vw-tw-M)` on BOTH axes +
tooltip `max-width: min(300px, calc(100vw - 16px))`. Never a CSS `::after`.
- **Number inputs ALWAYS show their unit** (%, shots, days) as an inline suffix
inside the box (`.num-wrap` + `.unit`). A bare number box leaves the human
guessing what they're changing. User called it.
- **Buttons are styled pills, never raw `<button>`.** The `.hdr-btn` system (below)
is the only header-button style. A raw `<button>` = the browser's grey default =
instant "hideous". Icon buttons use inline SVG (Lucide-style stroke), never unicode
glyphs like `⧉ ◱ ↑`.
- **The zoom modal closes on: backdrop click, a click on the IMAGE itself
(`cursor: zoom-out`), AND Escape.** All three. Not just the backdrop.
- **Zoom uses the real estate**: `max-width: min(96vw, 2200px)`, image `max-height: 84vh` -
never a fixed px cap (useless in both a skinny panel and a big pup window).
## HARD RULE: canvas switches are STAGED and TRACKED - never close blind
The webview<->pup switch (the header button) is a state machine, both directions:
1. `checking` - pup availability (canvas-check) before anything moves.
2. `opening <dest>` - POST /api/canvas-open opens the DESTINATION only.
3. `waiting for <dest>… Ns` - poll /api/viewer-status until the destination
viewer's WebSocket CONNECTS (surface count rises above the pre-switch
baseline). That connect is the ONLY acceptable "it's really open" signal -
a tab existing is NOT enough (the page could be blank/dead).
4. Only after connect: `switched ✓` -> POST /api/canvas-close for the SOURCE.
Failure handling (all verified paths):
- open fails -> source stays, error toast with the broker's detail.
- destination never connects (30s) -> ROLLBACK: close only what we opened,
source stays, precise toast (laptop asleep? Hydrogen closed?).
- broker unreachable / fetch throws -> nothing moves, restore button.
- destination already connected (dual-surface) -> instant confirm, then the
source closes: the switch doubles as the dual-surface resolver.
- double-clicks: button disabled for the whole flow.
NEVER regress to fire-and-forget (open + close in parallel): a failed open
then closes the user's ONLY surface. Bit us; the user called it.
Why switches take seconds anyway: every pup verb is a cloud->relay->laptop
round-trip (~1s each) and a fresh Chrome window costs ~3-5s. Keep pup verbs
to the MINIMUM number of round-trips (branch on one list_tabs result; never
try-and-fallback chains, which serialize 3 trips). webview direction is local
Hydrogen = fast (~1-2s).
## HARD RULE: the header CSS is a SET - never clobber siblings
We have rebuilt this header repeatedly. The recurring self-inflicted wound: editing
ONE header rule (e.g. the skinny `@media` block) with a block-replace that drops the
sibling rules that follow it. When `.hdr-btn` / base `.actions` / `.ws-led` vanished,
every button silently fell back to raw browser chrome. **These selectors MUST all
exist in `src/html/index.html` at all times:**
```
header .brand .subject/.subject-name/.subject-meta .vsep
.actions (base: display:flex) .hdr-btn .hdr-btn:hover .hdr-btn.accent
.hdr-btn.danger .hdr-btn.danger.arm .hdr-btn.icon-only .ws-led .ws-led.live
@media (max-width:520px){…} (skinny: stays ONE 44px row; SHEDS zones, never wraps)
```
**Header = ONE 44px row, always** (the canonical `adom-app-header` spec;
`~/.claude/skills/adom/guides/adom-app-header.md`). Multi-row / wrapping is its
anti-pattern #1. Read that spec before ANY header change. For the skinny ~360px
panel, do NOT wrap to a 2nd row — SHED the least-important zones in order: brand
subtitle -> brand app-name (keep the logo) -> `.vsep` -> `.subject-meta` (its
value moves into the `.subject-name` tooltip) -> actions go icon-only. The
`.subject-name` stays and ellipsis-truncates. I shipped a 2-row grid here once
and it was a spec breach - never again.
Guard after EVERY index.html edit - if any line prints 0, you clobbered a rule:
```bash
for sel in '\.actions {' '\.hdr-btn {' '\.hdr-btn\.danger' '\.hdr-btn\.accent' \
'\.ws-led {' '\.ws-led\.live' 'max-width: 520px'; do
printf '%-24s %s\n' "$sel" "$(grep -c "$sel" src/html/index.html)"
done
```
Then ALSO ralph-screenshot the header at skinny width (it's the case that breaks).
The `.hdr-btn` system to preserve:
```css
.actions { display:flex; align-items:center; gap:6px; margin-left:auto; flex-shrink:0; }
.hdr-btn { -webkit-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';
cursor:pointer; white-space:nowrap; 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 svg { width:13px; height:13px; } .hdr-btn.icon-only { padding:0; width:26px; justify-content:center; }
.hdr-btn.accent { color:var(--accent); border-color:rgba(0,184,176,.35); } /* the canvas toggle */
.hdr-btn.danger.arm { color:#fff; background:var(--danger); border-color:var(--danger); } /* armed clear */
```
## Canvas doctrine: GLOBAL default rules; channel notes are in-memory only
Hard-won (the user called it out twice): per-channel canvas preferences must
NEVER persist. Everything follows prefs.default_canvas except an in-memory
note from the human's OWN actions (viewer canvas-button click; deliberate pup
close), forgotten on restart. CLI opens and auto-opens must NOT record notes -
we once had every AI `open --skinny` and even the broker's own force-opens
writing "choices" to disk, permanently overriding the user's global setting.
Panel width comes from prefs.webview_width (never hardcode 0.2 again).
Logging: the broker's stdout IS the audit trail. Deploys APPEND to
prod-srv.log (never truncate: `>> log`, echo a deploy marker) and every
decision path prints a timestamped `DECISION ...` line (force-open canvas +
why, canvas notes, close-reverts, pup retries).
## Build + deploy cycle
```bash
cargo build --release
# targeted restart of the ONE server (never a broad pkill):
kill "$(pgrep -f '[a]dom-shotlog serve' | head -1)"; sleep 1
sudo cp target/release/adom-shotlog /usr/local/bin/adom-shotlog
adom-shotlog serve & # data dir: ./screenshots/shotlog
```
`SKILL.md` (and the viewer HTML) are embedded via `include_str!` - **you must
rebuild** after editing them. Bump `Cargo.toml` + `VERSION` together.
## Architecture: one server = the central broker (per container)
- The **same binary** is both the server and the CLI. `adom-shotlog serve` spawns
ONE broker on **port 8820**; `inject`/`open`/`viewers`/`channels` are CLI verbs
that HTTP-POST/GET to it.
- **Every AI thread in the container** runs `adom-shotlog inject`, which posts to
that one local server. So the server is the central broker: it owns all channels,
tracks every viewer's surface + on-screen state (presence over the WebSocket),
and manages all the Hydrogen webviews (auto-open a slim 20% panel, bring the
right channel's tab forward - by its unique `Shots: <channel>` name so parallel
shotlogs are never cross-touched).
- **Webview activate = `workspace active-tab` - NEVER move-tab.** To surface a
channel's tab: `adom-cli hydrogen workspace active-tab --name "Shots: <ch>"`
(brings it to the front WITHIN its own pane), then `alert set --name` for the
orange indicator. HARD-WON DON'T: an earlier version "bounced" the tab through
another pane with two `move-tab` calls (move-tab auto-activates in its target
pane). That re-parents iframes and re-renders the ENTIRE Hydrogen workspace,
interrupting the user mid-keystroke in their VS Code tab. If a verb seems
missing, search `--help` output for hyphenated forms (`active-tab` does not
match a grep for `activate`) before building a workaround.
- It's **per-container**: threads in a different container get their own server.
- `shotlog channels` prints the live overview - how every thread is using it.
## Metadata display rule (John, 2026-07-17)
When the human asks to see metadata, show ALL of it: every entry field plus the
entire sidecar flattened (meta.coordMap.*, popups[n].*, png_meta.*) plus the raw
JSON, inline. Never a curated subset. "Almost nothing" summaries got this exact
complaint: "when i ask you to show me metadata i mean all of it".
## Broker process naming trap (2026-07-17)
The read-hook's self-heal starts the broker as `shotlog serve` (argv[0]=shotlog),
NOT `adom-shotlog serve`. Managing the broker means checking BOTH names:
`pgrep -x adom-shotlog; pgrep -x shotlog`. And `pgrep -f "adom-shotlog serve"`
MATCHES YOUR OWN SHELL WRAPPER (the shell-snapshot eval embeds the pattern) —
killing head -1 of that killed my own shell (exit 144). Kill by exact name or
explicit PID from ps, never by -f pattern. Also: any deploy-restart window can
be resurrected mid-window by a hook self-heal running the OLD recovery state —
after migrating on-disk data, verify the SERVING process started AFTER the
migration (check prod-srv.log recovery line), not just that the port answers.
## Header rule REVISED (John, 2026-07-18)
Supersedes the "always ONE 44px row" skinny rule. Wide: still ONE 44px row.
Skinny (<=520px): EXACTLY TWO rows - row 1 = logo + full channel name + count,
row 2 = all actions right-aligned. Reason: zone-shedding on one row crushed the
channel name to 2-3 chars once the action cluster grew (canvas + top + filter +
prefs + clear + LED). John: "in skinny view you probably need to go to two
lines in header". Never more than two rows, and never shed the channel name.
## Ghost-surface detection (2026-07-18)
"Is shotlog showing anywhere?" has THREE sources of truth and they can ALL
disagree: (1) broker viewer-status = only LIVE WebSocket pages; (2) pup bridge
tab registry = can lose windows that still exist; (3) the OS window list
(desktop_list_windows, match title 'adom-shotlog') = ground truth for pup.
A ghost = OS window exists but registry has 0 tabs and broker has 0 pup
viewers - a stale page silently showing old shots. Detect by cross-checking
all three; kill ghosts with desktop_find_control {hwnd, name:"Close"} ->
desktop_ui_click (background UIA, no focus steal). Never process_kill chrome.
## Deploys MUST refresh surfaces (John, 2026-07-18: "always do that")
NEVER hand-roll the deploy cycle - run tools/deploy.sh. It builds, installs,
restarts, verifies the served stamp, and refreshes every live surface. The
handshake stamp is version+boot-epoch precisely because same-version redeploys
(0.23.5b/c/d) once shipped invisible changes: the human stared at a stale UI.
A deploy is NOT done until the human's open surfaces are re-verified live on
the new stamp (browser_evaluate the page's APP_VERSION - read it, don't assume).
## "Why did it open in webview when default is pup?" playbook (2026-07-19)
The answer is ALWAYS in prod-srv.log - read it before theorizing. Chain seen
live: (1) AD truly offline -> 6 patient pup retries fail -> documented webview
FALLBACK ("no choice recorded"); (2) fallback webview stays connected, so every
later shot routes to it and "nobody watching" never re-fires (fixed 0.23.9:
recover_pup_from_fallback - default=pup + webview-only -> retry pup per shot,
5 min cooldown, close fallback only after pup CONNECTS); (3) AD back online but
browser_open_window says status:"bridge_starting"/pending:true while Chrome
auto-starts - that is NOT a failure, WAIT and retry (fixed 0.23.10). Every step
must log a DECISION line; if you can't answer "why this surface" from the log
alone, add the missing DECISION line before doing anything else.