# Obsrv

[![CI](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml/badge.svg)](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/getobsrv)](https://www.npmjs.com/package/getobsrv)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

See your site the way 1x screens see it.

Designers and developers work on HiDPI (2x–3x) monitors. A large share of users are on
1x screens — 1080p desktops, 1366×768 laptops, cheap TN panels. On those screens, thin
font weights go weak, 0.5px hairlines vanish, low-contrast grey text becomes illegible,
gradients band and P3 colours clip. Browser dev tools emulate the *viewport* of a 1x
screen but still rasterise on your monitor's physical pixels, so you never see any of it.

Obsrv shows any URL (or a dropped 2x design export) two ways, side by side:

- **Native** — rendered at your host DPR, the way you normally see it.
- **Target** — rendered at a true 1x by an offscreen Chromium surface
  (`deviceScaleFactor: 1`), displayed at the physical size it would have on a chosen
  target monitor, with optional cheap-panel simulation (contrast floor, sRGB coverage,
  6-bit + FRC dithering, brightness) applied in a WebGL2 shader.

Both panes stay in lock-step (scroll, navigation) and the 1x pane is fully interactive.

- **Mobile presets** — phone and tablet presets (iPhone 6.1" @3x, budget Android @2x, …)
  rasterise at the device's *true* 2x/3x DPR, wear a mobile user agent, get real mobile
  viewport semantics (a page without a viewport meta lays out at 980px and shrinks to
  fit), and are shown at true physical size — usually a small, dense render on a
  desktop monitor, exactly like the phone in your hand.

> **macOS only, and the app is not yet signed** — macOS will call the DMG
> "damaged" on first launch until you clear its quarantine flag (below).
> [**What Obsrv cannot do**](docs/limitations.md) is the full boundary: what
> the measurement does not see, where the numbers stop being exact, and how it
> behaves on a page that keeps moving. Worth two minutes before you install.

## Quickstart

**The desktop app** — download the DMG for your chip from
[Releases](https://github.com/vibesyemmy/obsrv/releases), drag Obsrv.app to
Applications, then clear the quarantine flag once (the build is not yet
notarised, so macOS falsely reports it as "damaged"):

```bash
xattr -cr /Applications/Obsrv.app
```

Open it and set your monitor's diagonal in Settings — that one number is what
makes the target pane render at true physical size.

**The CLI, and Claude Code / MCP clients:**

```bash
npm i -g getobsrv                                          # or use npx -y getobsrv
obsrv install-skill                                        # teach Claude Code when to use it
claude mcp add --scope user obsrv -- npx -y getobsrv mcp   # give it the tools
```

`install-skill` copies the [obsrv-screens skill](skills/obsrv-screens/SKILL.md)
into `~/.claude/skills/` (`--dest` for elsewhere, `--print` to pipe it into
another agent framework). The skill is what makes an agent reach for Obsrv on
its own when frontend work needs checking; the MCP registration is what gives
it the tools to do so. New sessions pick both up.

**As a Claude Code plugin** — the repo is one: it carries the skill, and its
`.mcp.json` registers the MCP server.

```bash
claude plugin marketplace add vibesyemmy/obsrv
claude plugin install obsrv@obsrv
```

That gives an agent the `obsrv-screens` skill and the seven-plus tools in one
step; `obsrv install-skill` remains for the skill alone. The marketplace entry
points at the release tag over https, so what installs is a release, never main
between releases, and cloning it needs no git credentials. `marketplace add` does not refresh a marketplace you already have,
so to move to a newer release:

```bash
claude plugin marketplace update obsrv
claude plugin update obsrv@obsrv
```

**Both together** — install the app *and* the tools, then flip **Agent control**
on in the app's toolbar: agent testing now drives the window you are watching
instead of rendering invisibly.

## Use

```bash
npm install
npm run dev
```

Type a URL (localhost is fine), pick a target screen preset (1080p 24", 1366×768
laptop, iPhone 6.1" @3x, …), pick a panel profile (Reference, Office IPS, Budget TN, Old laptop) or open
the side panel (the sliders icon) for the rest: text scale, throttle, the **onion skin** — the
page's HiDPI render ghosted over the 1x raster, what the cheap raster moved, in one place
(`docs/onion-skin.md`) — the panel sliders and colour vision. A condition in force is named in the
target pane's footer, and clicking it opens the panel at its control. Enter your own monitor's
diagonal in Settings once so the target pane renders at true physical size. Drop a 2x/3x PNG or JPG export to check a design
before it's built.

The target pane opens in **Fit**, a fit-to-pane view of the whole render. Fit
never enlarges past 1:1, so a render that already fits is shown at true size;
a larger one is smoothly minified to fit, which is not pixel-exact — the footer
says so, and names the magnification. Fit is fully interactive: clicks, drags,
the wheel and the keyboard all reach the page.

Switch the toolbar's `1:1 / Fit` control to **1:1** for true magnification,
which usually overflows the pane: pan with a middle-button drag, Option+drag or
Option+wheel. From fit, **Option+click** jumps straight to 1:1 with the clicked
spot centred (plain clicks belong to the page).

The `Both / Target` control beside it hides the native pane so the target render
takes the whole window — useful for a small mobile preset that would otherwise
sit in half a window, and for agent captures. The native pane stays loaded while
hidden, so the URL bar, back/forward and link clicks keep working exactly as
they do side by side.

### Tabs

The strip above the URL bar holds several independent sessions at once. A tab is
its own URL, its own screen preset and panel profile, its own view mode and pan,
and its own page state — so the same page on a 1366×768 laptop and on a budget
Android sit in two tabs and you flip between them without either one reloading.

`Cmd+T` opens a tab, `Cmd+W` closes one, `Cmd+1`–`Cmd+8` select by position and
`Cmd+9` selects the last. Closing the last tab leaves a fresh blank one rather
than closing the window. Tabs come back on relaunch — their URLs, their screens
and which one was in front.

Every tab is two Chromium renderers (a native pane and an offscreen 1x surface),
so the count is capped: 12 by default, settable from 2 to 32 in Settings. At the
cap the new-tab button dims and says why. Background tabs stay loaded and keep
their scroll position, but stop rasterising until you return to them — the cost
of a background tab is memory, not GPU.

## Agent & CI use

The same rendering pipeline runs headless — no window, JSON on stdout, humans
on stderr — so agents (Claude Code) and CI can ask "how does this URL look on
a 1366×768 laptop / budget Android?" without the GUI. Build first
(`npm run build`; the CLI runs the built `out/`), then:

```bash
# One PNG at a preset's true raster density (+ metadata JSON on stdout):
npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --out shot.png

# A matrix of screens, cheap-panel simulation, full-page capture:
npx -y getobsrv snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out tn.png
npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --full-page --out full.png

# The whole page: captured a screenful at a time at the screen's own viewport and stitched, so a
# 100vh section keeps the height the screen gives it and an inner scroller is followed.
npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --full-page --out full.png

# Machine-readable 1x-vs-2x comparison (ink coverage, row ratios, band deltas):
npx -y getobsrv diff http://localhost:5173 --preset laptop-768 --out-dir diffout

# Tap targets and text measured in millimetres on the chosen screen — a 24 CSS px
# control is 6.6 mm on a 24" 1080p and 4.5 mm on a 6.5" phone, and only one of
# those is a thumb's worth. Findings under 7 mm / 2 mm (provisional, tunable):
npx -y getobsrv audit http://localhost:5173 --preset android-65

# The elements a 1x screen and a cheap panel break, named: edges under a device
# pixel, light text too small for its weight, contrast that fails as stated or
# only once the panel lifts the blacks, images upscaled or oversized. Each finding
# carries a page rect and one sentence with the figures (docs/lint.md).
npx -y getobsrv lint http://localhost:5173 --preset 1080p-24 --profile budget-tn

# All of the above on one self-contained HTML page — two laptops/desktops and two
# phones by default: each render, the audit in millimetres, the lint grouped by
# cause, the 1x-vs-2x diff, and the worst findings pinned and cropped on the page.
# The thing to attach to a PR.
npx -y getobsrv report http://localhost:5173 --out obsrv-report.html

# The page as a user at 150% sees it: browser zoom as reflow, on any command.
# The PNG stays the screen's size; the page lays out in two thirds of it at
# 1.5x the density, and the audit's millimetres grow to match.
npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --text-scale 1.5 --out zoomed.png

# The screen turned a quarter turn (0.61.0+): a phone held landscape, or a monitor stood on end.
# --orientation is deprecated and keeps its meaning, relative to how the preset is stored: portrait is
# the preset as stored, landscape is it turned, so landscape on a desktop preset (stored landscape) is a
# portrait screen. A --rotate and --orientation that disagree are refused.
npx -y getobsrv snap http://localhost:5173 --preset iphone-61 --rotate --out landscape.png

# How the page feels there: Chrome DevTools' network and CPU presets, and the time
# to paint-quiet in the JSON (settledMs). Compare against --throttle none.
npx -y getobsrv snap http://localhost:5173 --preset android-65 --throttle budget-phone --out slow.png

# One element, measured: font in millimetres on that screen, colours, and contrast
# as stated and on the panel, against the WCAG threshold for text that size.
npx -y getobsrv inspect http://localhost:5173 --preset android-65 --profile budget-tn --selector '#cta'
```

`npx -y getobsrv --help` (or `node bin/obsrv.js --help` in a checkout) lists every preset, profile and flag;
`npx -y getobsrv --version` prints the installed version, and needs neither a build nor Electron. Diff findings
are informational (exit 0); CI thresholds are the caller's job. A ready-made
Claude Code skill that wraps the loop (snap matrix → read the PNGs → diff →
fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md);
`obsrv install-skill` copies it into `~/.claude/skills/` so agents find it.

### MCP server

The same CLI is also wrapped as an MCP server (stdio, stateless) so MCP
clients get the tools natively: `obsrv_snap` (render a URL at a preset's true
raster density — the PNG comes back as an inline image up to 1.5 MiB; `inlined` says whether it did, and past the cap a warning names the path),
`obsrv_diff` (the 1x-vs-2x metrics as structured output), `obsrv_audit` and
`obsrv_inspect` (millimetres and contrast on a chosen screen), `obsrv_lint`
(the elements a 1x screen and a cheap panel break, named), `obsrv_report`
(a whole matrix of screens as one HTML page) and `obsrv_presets` (every
preset and panel profile, no render).

The MCP tools launch the app when it is not running; if it is open with agent
control off, the app asks — Allow for this session, or Not now. Once it is
live, `obsrv_snap` drives the *visible* window instead: you watch the URL load
and the preset flip, and the agent gets back a capture of the app exactly as
you see it (plus `obsrv_drive` to flip URL/preset/profile/panes directly —
`panes: 'target'` gives the target render the whole window, which is usually
what you want before a capture). Agents can
also scroll, click, pan and highlight while you watch — a drive session works
as a guided demo. A `scroll` reports the offset it actually reached
(`scrolled` / `scroller`), finds the inner scroll container on pages whose
root cannot scroll, and takes a `scrollSelector` when you need to name the
container yourself. `obsrv_inspect`, `obsrv_audit` and `obsrv_lint` follow the
app the same way: they measure the page in front, on the screen, text scale
and panel in force, after whatever the drive did to it; `obsrv_audit` and `obsrv_lint` walk the
page to the end and back first, so you see it looked at — and so do the headless audit, lint and
report, so lazy images are judged by the file that arrived rather than its placeholder (`--no-walk`
or `walk: false` measures the page as it first shows); `obsrv_report` is the exception to the live
part and stays headless, being a batch over a matrix of screens. With no app running, `mode: 'auto'` (the
default) launches one; `mode: 'headless'` never touches it.

To photograph a scrolled or panned state, pass `capture: 'window' | 'pane'` to
`obsrv_drive`: it captures after its commands run, and nothing in that tool
navigates unless you pass `url`, so the scroll survives the shutter. A live
`obsrv_snap` only navigates when the app is showing a *different* URL — its
`navigated` field says which happened — and navigating is a fresh load, which
starts at the top of the page.

**Tabs and the agent.** `obsrv_drive` and a live `obsrv_snap` act on the tab
that is in front, resolved per command rather than fixed when the drive starts —
a command that quietly succeeded on a tab you could not see would not surface
until the drive ended. Both report `tabId` and `tabIndex`, so an agent that
cares can compare them across calls and notice you switched tabs under it. While
Agent control is on, the driven tab carries a neutral rule on its leading edge
that brightens for ~3 s on each command, so it is visible which session is being
driven. An agent opens, fronts, switches and closes tabs itself, through
`obsrv_drive`'s `tab` and `closeTab`.

A headless `snap` returns `settled: true` when the page went paint-quiet and
every pixel painted. `settled: false` is still a usable capture, not a
failure — a page that kept animating, or one whose repaint never completed,
comes back as-is (exit code 0) with a warning saying what was missing and an
`unsettledReason`: `animating` (captured after ~2 s of steady painting;
waiting longer would not help), `timeout` (still painting at the budget),
`uncovered` (part of the frame never painted) or `blank` (the frame went
quiet one colour end to end and stayed that way for 3 s: the page's
background with nothing on it yet, or a page that really is empty — pass
`--wait` for a page that paints late). Only a render that painted nothing
at all is an error.

Build first, then register:

```bash
claude mcp add --scope user obsrv -- npx -y getobsrv mcp
```

In a checkout of this repository, test the local build as `obsrv-dev`, through
the dev lane (see [Develop](#develop)), and leave `obsrv` to the plugin or the
published package.

`npx getobsrv` run inside the repository is the checkout, not the release: npm
links a project into the npx cache when the requested name is the project's
own, so an `obsrv` registered as `npx -y getobsrv mcp` there ran whatever `out/`
held. The manifest (`.mcp.json`, the plugin's and the project's) therefore pins
`npx -y --prefix / getobsrv@<version> mcp`: with its project at the filesystem
root npm has none, and fetches the pinned release wherever the session runs.
A result from `obsrv-dev` is the local build; one from `obsrv` is the release.

## Develop

```bash
npm run typecheck     # tsc, both processes
npm test              # unit (Vitest, node)
npm run test:browser  # shader parity vs the TS reference (Vitest browser mode)
npm run test:e2e      # Playwright driving the real Electron app
npm run dist          # build a macOS DMG (unsigned without a Developer ID identity)
```

### The dev lane

Testing a change through the MCP tools used to mean a release: publish, install
the DMG, update the plugin, restart the session. The dev lane runs a checkout's
own build instead, headless and live, and follows every rebuild.

```bash
npm run lane                 # point the lane at this checkout, build it, (re)launch its app
npm run lane -- --no-build   # point and relaunch without building
npm run lane -- --no-app     # point and build; the next live call relaunches the app
npm run lane -- --status     # where the lane points, and how fresh its builds and app are
```

- **One lane per machine**, at `~/.obsrv-dev` (`OBSRV_DEV_HOME` overrides): a
  pointer to the checkout it runs, the dev app's own profile, and the proxy.
  `npm run lane` in any checkout or worktree points it there.
- **The dev app runs beside an installed Obsrv**, never instead of it: its own
  profile, so its own single-instance lock and `control.json`, and a title bar
  that says `Obsrv — dev lane (<branch> @ <commit>)`.
- **`obsrv-dev` is a proxy** (`scripts/dev-mcp.js`, installed into
  `~/.obsrv-dev/bin`). It runs the lane's own MCP server in dev mode and
  starts it again when `out/mcp/server.js` changes or the lane moves, so the
  call after a build runs the build on the same session. A call in flight
  finishes on the build it started on.
- **Every result says which build answered.** The lane is one pointer shared by
  every session, so `npm run lane` in one session moves every other session's
  `obsrv-dev`. Each tool result's `notes` (or `warnings`, for a tool without
  notes) end with a line naming the branch, commit, uncommitted changes, build
  time and checkout; `obsrv_presets`, which has neither, reads the preset table
  alone. The proxy also appends that line as a text block, and after a move says
  the lane moved, for clients that show text blocks.
- **Every call names the checkout it means to test.** The stamp still leaves the
  comparison to whoever reads it, and the proxy cannot make it: it starts in the
  project the session opened, whichever worktree the session works in after
  that. So every `obsrv-dev` tool takes a required `tree`, the top of the
  caller's working tree. A call naming a checkout other than the lane's is not
  run, and says which checkout the lane serves and how to point it there.
  `tree: "any"` runs on whatever the lane serves, and that answer's stamp says
  it was not compared with your checkout.
- **In dev mode the server drives the lane's app**: it discovers the lane
  profile's `control.json`, launches the checkout's `out/main/index.js`
  rather than `/Applications/Obsrv.app`, and relaunches a dev app that started
  before the latest build, saying so in the result.

Register it once, then restart the session once; after that nothing about a
rebuild needs a restart:

```bash
claude mcp add --scope local --transport stdio obsrv-dev -- node ~/.obsrv-dev/bin/dev-mcp.js
```

A change to the proxy itself is the exception: it reaches a session once
`npm run lane` has copied it and that session's MCP connection has restarted.

The loop is then: work on a branch in a worktree, `npm run lane` there, test
through the `obsrv-dev` tools, adjust, `npm run build`, test again. `obsrv`
stays the pinned release throughout. A release is still proven by the packed
tarball's own CLI, since the lane by definition runs local code. Before
removing a worktree the lane points at, point it elsewhere (`npm run lane` in
the shared checkout); a lane that points at nothing answers every call with
that sentence rather than failing to start.

Architecture, decisions and the full spec live in
[docs/superpowers/specs/2026-08-22-obsrv-design.md](docs/superpowers/specs/2026-08-22-obsrv-design.md);
the UI style rationale (why the chrome is strictly neutral) is in
[docs/superpowers/specs/2026-08-23-obsrv-ui-style.md](docs/superpowers/specs/2026-08-23-obsrv-ui-style.md).

## Install (desktop app)

Grab the DMG for your chip from [Releases](https://github.com/vibesyemmy/obsrv/releases),
drag Obsrv.app to Applications, then clear the quarantine flag once (the build is not
yet notarised, so macOS falsely reports it as "damaged"):

```bash
xattr -cr /Applications/Obsrv.app
```

Obsrv checks GitHub for a newer release once a day and, when there is one, shows
it in the toolbar; clicking opens the release page. It is a single
unauthenticated request carrying no identifiers, and Settings → Updates turns it
off.

## Distribution

Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
package.json before lifecycle hooks run, which silently skips the prepack
electron dev→prod dependency swap (this shipped a broken 0.4.0). The flow is:

```bash
npm run release:pack
npm publish ./getobsrv-<version>.tgz
```

`npm version` also rewrites the plugin manifests: their version, the
marketplace entry's source, which is pinned to the tag `plugin-v<version>` that
`npm run plugin:branch` creates, and the MCP registration, pinned to
`getobsrv@<version>`. Push the tags after `main` is green, and push `plugin-v<version>`
only once npm has that version: the marketplace catalog points at the plugin
tag, and a plugin fetched before the publish starts an MCP that npm cannot
install. So the order is: main green, `npm publish`, then `v<version>`, `plugin`
and `plugin-v<version>` together.

Obsrv publishes to npm as **`getobsrv`** (the installed commands remain `obsrv`
and `obsrv-mcp`; the app's display name remains Obsrv). The bare `obsrv` npm name
belongs to an unrelated package.

## Privacy and files

**Nothing is uploaded.** The app makes exactly one outbound request of its own:
a version check to `api.github.com` once a day, which asks for the latest
release and sends nothing about you (`src/main/updateCheck.ts`). The npm
package makes one more, once: when its Electron binary is missing, it downloads
it — about 120 MB, from Electron's GitHub releases unless `ELECTRON_MIRROR`
points elsewhere — as the MCP server starts, or on a CLI run's first use
(`bin/electronPath.js`). Everything else that touches the network is the page
you asked it to render, fetched by Chromium the way any browser would.

**Everything it produces is a local file.** PNGs and report HTML go where you
point `--out` / `--out-dir`. The MCP tools don't take an output path: every
capture, diff and report they make is written into a fresh `obsrv-mcp-*`
directory under `os.tmpdir()` and handed back as a path — and the MCP server
prunes those directories, older than a day, at startup
(`src/shared/pruneTemp.ts`). **Copy anything you want to keep**, a report
included. Headless CLI runs use a throwaway Electron profile under
`os.tmpdir()` and remove it on exit.

**The app keeps its own state** in Electron's application-support directory
for Obsrv: `settings.json`, `history.json` (the addresses you have visited in
the app), `tabs.json` (the session it restores), and — only while agent
control is on — `control.json`, mode `0600`, holding the loopback port and
token.

**And it keeps Chromium's caches for every page it has rendered**, in the same
directory. Obsrv renders arbitrary third-party pages by design, so this grows
with use in a way an ordinary app's does not — and faster for the people who
use it most. A working profile was measured at **1.3 GB, 915 MB of it
`Cache`**, with nothing pruning it. The disk cache is now capped at 256 MiB;
`Code Cache` is Chromium's own and is not, so the directory can still grow,
just not without limit at the part that dominated it. Headless CLI runs are
not part of this at all: each takes a throwaway profile and deletes it.

A warm cache is worth about 14 ms of load time on a small page and changes
nothing Obsrv measures — five cold/warm pairs moved zero result fields — so
the cap costs latency and not correctness.

**Removing Obsrv does not remove any of that.** Deleting `Obsrv.app` — dragging
it to the Trash — removes the app and nothing else; `npm rm -g getobsrv`
removes the CLI and nothing else. Measured on a fresh install: one page
visited, then the app deleted, and every file below was still there.

**`obsrv uninstall` will tell you what is actually on your machine** — which of
these paths exist, how big each one is, what is in it, and what is deliberately
left alone (Electron's shared cache is not Obsrv's to delete). **It lists only:
it removes nothing**, and prints the commands below with your own paths filled
in. On macOS, to remove it by hand:

```bash
rm -rf ~/Library/Application\ Support/Obsrv   # settings, history, tabs, the Chromium profile
rm -rf ~/Library/Logs/Obsrv                    # obsrv.log
rm -rf ~/Library/Caches/electron               # the downloaded Electron runtimes
```

The first line is the one that matters for privacy: `history.json` lives there
and holds the addresses you visited in the app. The third is the one that
matters for disk — it is Electron itself, one copy per version Obsrv has used,
kept outside `node_modules` and left behind by `npm rm`; it reached 477 MB on
the machine this was measured on. Quit Obsrv before removing these, and check
no other Electron app of yours relies on that cache.

**If you used the MCP tools from the npm package before 0.61.0, with no
`Obsrv.app` installed, one more place holds Obsrv's data.** The app those tools
launched named itself "Electron", not "Obsrv". Its files went into
`~/Library/Application Support/Electron`, and its log into
`~/Library/Logs/Electron`. **Every other unnamed Electron app writes there
too**, and most of what is there can't be told apart by name:

- **Obsrv's own files** are `history.json` (the addresses you opened in
  Obsrv), `tabs.json` (the tabs it restored), `settings.json` (with
  `hostDiagonalInches` in it), `control.json` (a `port` and `token`, or
  `"enabled": false`, with a `pid`), and the log's `obsrv.log`. Check each one
  is Obsrv's, then remove it:

  ```bash
  cd ~/Library/Application\ Support/Electron && rm -i history.json settings.json tabs.json control.json
  rm -i ~/Library/Logs/Electron/obsrv.log
  ```

- **The Chromium profile is there too:** `Cache`, `Code Cache`, cookies,
  Local Storage and IndexedDB for every page that app rendered. It's usually
  most of the disk use, and it carries the sites' cookies. It uses the same
  names every Electron app uses. **If nothing else on this Mac ever ran as
  "Electron"** (you don't build or run unpackaged Electron apps), the whole
  directory is Obsrv's and `rm -rf ~/Library/Application\ Support/Electron
  ~/Library/Logs/Electron` removes all of it. **If you aren't sure, leave the
  directory.** Removing another app's profile would take its data with it.

**The log records what breaks, not where you went.** `obsrv.log`, in
Electron's logs directory, is a few lines an hour about GPU processes dying,
crashed targets and lost WebGL contexts, so a bug report arrives with evidence
(`src/shared/logFile.ts`). It is not a record of the URLs you visit: an address
appears in only two warnings, both written when something breaks — the page a
crashed target was showing (`src/main/targetSource.ts`), and the address at
which a navigation loop between the two panes was stopped
(`src/main/syncBus.ts`).

**The version a bug report needs** is `obsrv --version` for the CLI (no build
or Electron required), Settings → Updates in the app, and `version` in any
live `status` reply.

See [**what an agent can do to your machine**](docs/agent-control.md) for the
agent-control surface in full, [**what Obsrv cannot
do**](docs/limitations.md) for the measurement's limits, [**the
thresholds**](docs/thresholds.md) for where every judged number comes from, and
[**breaking changes**](docs/breaking-changes.md) for anything that has moved or
been renamed.

**Want to help?** [**The board**](https://vibesyemmy.github.io/obsrv/) is the
open work, each card carrying the criterion it closes and the file or commit
that defines done. The cards themselves live in [`board/`](board) — one
markdown file each, and the only source of truth.
Cards in *Next* are the ones worth starting.

The board itself is [`board/`](board) — one markdown file per card. **Claim one
by editing its file**: set `owner:` and `column: doing`, run `npm run board`,
and open a pull request with both changes. There is no separate tracker to
update and nobody to ask for access.

[**CONTRIBUTING.md**](CONTRIBUTING.md) is worth ten minutes first. It is not a
style guide — it is the list of traps that have actually cost this project
time, including the two that produce *false test failures* and the one rule
that matters most here: a check nobody has watched refuse is a claim, not a
check. `npm run board` regenerates two views from
those cards, and CI fails if either has drifted from them:

| | |
|---|---|
| [**vibesyemmy.github.io/obsrv**](https://vibesyemmy.github.io/obsrv/) | the Kanban view — columns, owners, click a card for its full evidence. Rebuilt from `board/` on every push to `main`, so it is never a stale copy of anything |
| [`board/`](board) | the cards themselves, one markdown file each — the source the views are built from, and the only thing committed |

## Known v1 limits

- Rendering truth is the host OS's 1x rasteriser (macOS today). Windows ClearType at 1x
  looks different again; a Windows build would show Windows truth natively.
- Panel simulation is an approximation, not colourimetric.
- Non-ASCII text input does not type into the target pane (Electron `sendInputEvent`
  limitation).
- Inner-scroller *reporting* is one-way. An agent `scroll` finds the page's real scroll
  host — the app-shell pattern (`html, body { overflow: hidden }` with an inner
  `overflow-y: auto` container) is handled, and the result reports the offset actually
  reached — but scrolling a nested container **by hand** in the native pane is not
  mirrored to the target: element scroll events don't bubble to `window`, so the report
  side never sees them. Dragging the page itself still syncs both ways.
- **A stuck side rail still repeats in a full-page capture.** `--full-page` captures the
  page a screenful at a time at the screen's own viewport and stitches the bands, so
  anything stuck to the viewport is painted into every one. Full-bleed chrome — a header,
  a cookie bar — is now hidden for the bands after the first, so it appears once and the
  page rows behind it are not lost (measured: tailwindcss.com/docs at laptop-768 stuck its
  57 px header with `position: fixed`, developer.mozilla.org sticks its 98 px header and
  both side rails with `position: sticky`, so detection is by measurement — an element
  whose viewport rect is unchanged between two scroll offsets — rather than by reading
  `position`). An **app shell** gets the same treatment against its own scroller: the
  chrome around the scroller is sliced out of those bands already, so what repeats there
  is a sticky toolbar or table header *inside* it, and a bar spanning a scroller inset
  from the window is not full-bleed against the viewport at all — the frame for the whole
  measurement is the element the capture scrolls. A *rail* is left alone in both, page or
  panel: it covers no page content, so hiding it would only leave a blank column down
  every band but the first. `--keep-stuck-chrome` leaves all of it, as the capture used
  to; `--single-surface` avoids bands entirely, at the price of a page sized against the
  viewport laying out differently and an inner scroller not being followed at all.
- Scroll targeting stops at the top-level document. The automatic detection finds a scroller
  inside an open shadow root, but not one inside an iframe or a closed root, and
  `scrollSelector` can't name a scroller inside any root or iframe
  (`document.querySelector` doesn't cross either boundary).
- Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
  with dirty rects it has not been needed.
- Tabs are a first cut. They cannot be reordered, dragged out into another window, or
  reopened after closing (no `Cmd+Shift+T`), and there is no tab overflow menu — a strip
  longer than the window scrolls. An agent opens, fronts and closes tabs through
  `obsrv_drive` (`tab`, `closeTab`) and reads them from `tabs`. The URL bar's
  visited-URL suggestions are one window-wide list rather than one per tab (back/forward
  *are* per tab), and a restored tab comes back at the top of its page — the scroll
  position is not persisted.
