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

* { 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:

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