---
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.
