@whyotter/seeotter
v0.1.0
Published
see-otter — visual harness for AI agents developing components, built on Vitest browser mode + Playwright
Readme
Overview
AI agents working against UI are blind: they reason through changes based on assumptions and run tests that assert DOM structure, but they never see the result. SeeOtter is designed to close that loop; it mounts components in isolation, sweeps breakpoints, and returns image artifacts optimized for both agent and human review.
Agents consume SeeOtter's output manifest for first-pass validation prior to ever spending tokens on image recognition.
$ seeotter capture src/components/Card.vue --props '{"title":"Hello"}'
Card › default
contact sheet .seeotter/card/default/contact-sheet.webp 1566×134
still mobile .seeotter/card/default/mobile.webp 390×127
still tablet .seeotter/card/default/tablet.webp 768×127
still desktop .seeotter/card/default/desktop.webp 1440×127
recording .seeotter/card/default/recording.webp
Human review only. A model reading this animation sees only its first frame.
manifest .seeotter/card/default/manifest.json
Breakpoint Viewport Content Subject Overflow
mobile 390×720 565px 390×111 ⚠ yes
tablet 768×720 768px 768×111 — no
desktop 1440×720 1440px 1440×111 — no
Read .seeotter/card/default/contact-sheet.webp to see the result.In this example, Content is the full scrollable width of the page and
Subject is the box the component occupies. Overflow compares the content
against the viewport, so it flags the mobile-first bug in the preceding run
without anyone looking at a pixel.
- The contact sheet is one composite image sized to what a model actually ingests; the full-resolution stills are the back-up artifacts for when a sheet tile renders too small to judge.
- The recording is animated, for human review only; agents cannot watch video, so it is never the primary artifact.
- The manifest and the CLI report price every readable artifact in pixel dimensions, so an agent decides whether looking is worth the tokens before spending them.
Features
SeeOtter is a thin layer over Vitest browser mode + Playwright.
- Ordinary Vitest browser tests. Scene files have no proprietary format — they are plain Vitest browser tests. Mocks just work.
- Breakpoint sweep. One run mounts the component at every configured breakpoint and captures each viewport in a single deterministic pass.
- Bounded artifacts. Every capture is cropped to the measured subject plus padding. No tokens are spent on parsing empty content.
- Contact-sheet economics. The sheet's width is capped at the long edge past which standard-tier models downscale an image. A wider cap for high-resolution-tier models is optional.
- Determinism freeze. Repeated runs are byte-identical: animations are
killed, fonts awaited,
Date.now()frozen,Math.random()seeded, and the rasteriser pinned. - Agent-shaped output. The CLI prints an overflow table an agent reads
before any pixels, and
--jsonswaps the report for a machine envelope.
| SeeOtter ships | SeeOtter does not ship |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| Breakpoint sweep | Dev server, bundler, module graph |
| Artifact pipeline (contact sheet, stills, animated WebP) | Mock system |
| Determinism freeze | Browser lifecycle |
| Agent-shaped CLI output | Visual regression — Vitest's toMatchScreenshot already does it |
Installation
From npm
The package publishes to public npm as @whyotter/seeotter. In the consuming
project, install it alongside the browser stack it drives:
bun add -d @whyotter/seeotter vitest @vitest/browser-playwright vitest-browser-vue playwright
bunx playwright install chromiumFrom a checkout
From a clone of this repo:
make pinpin refuses a dirty worktree, bundles the CLI, verifies that the bundle's
stamped identity names HEAD, installs it as ~/.local/bin/seeotter (which
must be on your PATH), copies the agent skill to
~/.claude/skills/see-otter, and registers the package for bun link. Both
destinations are variables, so a trial run can target a throwaway directory
instead of the pinned copy your agents run:
make install BINDIR="$(mktemp -d)"
make install-skill SKILLDIR="$(mktemp -d)/skill"The installed CLI reports the commit it was built from:
$ seeotter --version
seeotter 0.1.0 (build 011afd2b26a4)Requirements
SeeOtter depends upon Bun for the CLI runtime and Vitest for the test runner:
seeotter capture spawns the consuming project's own
node_modules/.bin/vitest, and the scene it generates imports
@whyotter/seeotter. A consuming project therefore needs both halves:
- vitest and the browser stack, installed locally per "From npm".
- The
@whyotter/seeotterpackage, resolvable — installed from npm, or, for developing against a checkout of this repo, runbun linkonce here andbun link @whyotter/seeotterin the consuming project.
Linking has two side effects worth knowing. The linked package half is a live
symlink into the checkout you linked from, so it tracks whatever is checked
out there rather than the commit you pinned. And the package's bin entry
points at dist/cli/main.js, which is gitignored build output: it does not
exist on a fresh clone until bun run build:dist produces it, and it is a
snapshot of the source it was built from, not the live source. A bare
seeotter in your shell resolves through PATH and finds the pinned bundle,
while bun run seeotter, bunx seeotter, and package.json scripts prefer
node_modules/.bin and find the linked build — after changing src/, run
bun run build:dist in this repo to keep that copy honest.
make pin therefore guarantees the installed CLI only.
Quickstart
Configure the harness.
seeotter.config.tsdeclares the breakpoints and the app-level providers, andvitest.config.tswires the harness into Vitest:// seeotter.config.ts — import from '@whyotter/seeotter/config', never '@whyotter/seeotter' import { defineConfig } from "@whyotter/seeotter/config"; export default defineConfig({ breakpoints: { mobile: 390, tablet: 768, desktop: 1440 }, wrap: [withRouter, withPinia], // app-level providers, applied to every scene });// vitest.config.ts import vue from "@vitejs/plugin-vue"; import { defineSeeOtterConfig } from "@whyotter/seeotter/vitest"; import seeotterConfig from "./seeotter.config"; export default defineSeeOtterConfig(seeotterConfig, { plugins: [vue()] });Capture one component. Quick capture is zero authoring, good for leaf components:
seeotter capture src/components/Button.vue --props '{"label":"Save"}'The command generates a scene around the component, runs it through the project's own Vitest, and prints the report shown in the overview. Read the contact sheet it names to see the result.
Save the states worth keeping. A scene file is colocated with its component and reviewable in the PR:
// Button.scene.ts import { scenes } from "@whyotter/seeotter"; import Button from "./Button.vue"; export default scenes(Button, { default: { label: "Save" }, loading: { label: "Save", busy: true }, overflow: { label: "A".repeat(80) }, });Because a scene file is a Vitest browser test,
vi.mockworks normally:import { vi } from "vitest"; vi.mock("@/api/user", () => ({ getUser: async () => fixtures.user }));Run the batch:
seeotter capture-all # every file the config's include covers seeotter capture-all src/Button.scene.ts # a single file is a narrow globcapture-allruns the matched files through the project's own Vitest in one spawn and prints one batch report: a compact row per captured scene, and a named failure block for everything the run cannot vouch for. Failure reporting is not CI gating:capture-allnames what failed and exits nonzero, and wiring that into a merge gate is deliberately out of scope.captureandcapture-allsplit on generated versus saved, so neither command guesses what it was handed:capturerefuses a positional the config'sincludepatterns cover and points atcapture-all, andcapture-allrefuses the quick-capture flags, which describe scene generation.
Concepts
The following table defines the core concepts:
| Concept | Definition |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scene | One named state of one component: the component plus props, saved in a .scene.ts file or generated by quick capture. A scene file is an ordinary Vitest browser test. |
| Breakpoint | A named viewport width from seeotter.config.ts. One run sweeps every configured breakpoint. |
| Contact sheet | The one composite image the agent reads: every breakpoint as a labelled tile, width-capped to what a model ingests without downscaling. |
| Still | The full-resolution capture of one breakpoint. The escape hatch when a sheet tile renders too small to judge. |
| Recording | An animated WebP of the sweep, for human review only: it glides between breakpoints by default and holds at each. A model reading it sees only its first frame. |
| Manifest | The JSON record of a capture: dimensions, capture bounds, overflow and clamp flags, console errors. The cheap half of the loop, readable as text. |
| Subject | The measured box the mounted component occupies in the browser. Crops derive from it, and the CLI reports it per breakpoint. |
| Freeze | The determinism pass before every capture, in src/runtime/freeze.ts. Any new capture step joins it. |
Output contract
Every run writes one directory per scene:
.seeotter/<component>/<scene>/
contact-sheet.webp ← the one image the agent should read
mobile.webp ← full-resolution stills, when detail matters
tablet.webp
desktop.webp
recording.webp ← animated, for human review only
manifest.json ← dimensions, capture bounds, overflow flags, console errorsEvery artifact is bounded to the component. SeeOtter measures the mounted
subject in the browser and crops each capture to that box plus padding, so a
111px-tall card at a 1440px breakpoint returns a 127px-tall image instead of
720px of empty page. The Subject column of the CLI output reports the measured
box the crop is derived from. The padding is what protects a box-shadow or an
outline, which getBoundingClientRect() excludes from its measurement.
Both settings live under one config key:
// seeotter.config.ts
export default defineConfig({
breakpoints: { mobile: 390, tablet: 768, desktop: 1440 },
crop: { padding: 16 }, // the default
// crop: false, // full-frame captures instead
});A capture SeeOtter declines to crop records the reason in the manifest rather
than dropping the field, because an absent value cannot distinguish a healthy
run from a broken one. The reasons are no-subject when nothing measurable
mounted, clamped when the browser window clipped the capture, full-bleed
when the subject already fills the frame, and disabled under crop: false.
The artifact asymmetry matters. Stills are for the agent; the recording is
for the human. Agents cannot watch video, so the recording is never the primary
artifact. And the manifest is the cheap half of the loop — an agent can read
overflowedAt as text and decide whether looking at pixels is worth the tokens.
recording.webp is an animated WebP, and models do not ingest animations: only
the first frame is used, per the Anthropic vision
documentation
fetched 2026-08-08. Nothing reports an error. A model that reads the recording
sees the narrowest breakpoint alone and reports the sweep as reviewed, which is
why seeotter capture prints that caveat on the line under every recording path
it emits.
manifest.json also reports clampedAt: breakpoints whose capture was clipped
by the browser window. A clipped screenshot looks perfectly valid while showing
a viewport that never rendered, so it is flagged rather than silently trusted.
The recording glides between breakpoints
By default the recording is a transition sweep: it holds at each breakpoint, then glides to the next through tween frames captured along a width ramp, so a human reviewing responsive behavior watches the layout reflow instead of jump-cutting between three states. The glide always plays from the narrowest breakpoint to the widest — a resize only reads as one in one direction — while the contact sheet and the stills keep declaration order, so a desktop-first config orders the two artifacts differently on purpose.
Tween frames feed the recording and nothing else. They never appear in the stills, the contact sheet, or the manifest — the recording is the human artifact, and the glide must not move what an agent reads. Each tween frame is captured through the same settle path as a breakpoint and cropped by its own measured subject rect, and a tween frame the browser window clips is dropped from the recording (the clamp warning on stderr says so) rather than encoded truncated.
Everything lives under the recording key:
// seeotter.config.ts
export default defineConfig({
breakpoints: { mobile: 390, tablet: 768, desktop: 1440 },
recording: {
delay: 1500, // ms held at each breakpoint (the default)
transition: {
steps: 8, // tween frames between adjacent breakpoints (the default)
delay: 80, // ms per tween frame (the default)
},
// transition: false, // restore the discrete per-breakpoint recording
},
});Under fidelity: 'device' the transition key is inert: every breakpoint
lives in its own browser context there, so no single viewport exists to glide,
and the recording stays discrete.
What an image costs to read
manifest.json records the pixel dimensions of the contact sheet and of every
still under artifacts.dimensions, keyed to match artifacts.stills, and
seeotter capture prints them beside each path. The recording has no entry,
because it is the human artifact and pricing it invites a model to read it.
Dimensions are what an image costs. As dated reference material: Claude ingests
an image as 28×28-px patches, so an image costs about ⌈width / 28⌉ × ⌈height /
28⌉ visual tokens, per the Anthropic vision
documentation
fetched 2026-08-08. The 1566×134 contact sheet of the run in the overview
therefore costs about 280 visual tokens, against about 470 for reading its
three stills separately. That arithmetic is why one composite image is the
default read and the stills are the backup.
Those numbers are vendor-specific and generation-specific, which is why SeeOtter records dimensions and never token estimates. Recompute them from the source when the estimate has to be right.
Sizing the contact sheet
contactSheet.maxWidth caps the width of the sheet in px, and tiles scale down
uniformly to fit it. Set it alongside your breakpoints:
// seeotter.config.ts
export default defineConfig({
breakpoints: { mobile: 390, tablet: 768, desktop: 1440 },
contactSheet: { maxWidth: 1568 }, // the default
// contactSheet: { maxWidth: 2576 }, // high-resolution-tier models only
});The default of 1568 is the long edge past which standard-tier models downscale an image, per the Anthropic vision documentation fetched 2026-08-08. A wider sheet buys the reader nothing: the model resamples it before reading, and resampling degrades the 14px labels that name each breakpoint. The default is safe on every model tier.
High-resolution-tier models, Claude 4.7 and later, move that threshold to 2576.
Raise maxWidth to 2576 only when both of the following hold:
- The agent that reads the sheet runs a high-resolution-tier model.
- The run needs the extra per-tile fidelity, such as dense UI or small type that the default renders too small to judge.
Widening to 2576 gives each tile roughly 64% to 67% more width and costs about 2.3 times as many visual tokens to read. On a standard-tier model it is strictly worse than the default, because the model scales the sheet back under 1568 and resamples the labels on the way.
One case exceeds the cap deliberately. Every tile reserves at least enough width
for its own label, and that room is reserved after scaling, because text does
not scale. A sheet of many tiles each narrower than desktop · 1440px therefore
comes out wider than maxWidth. A clipped label no longer names the breakpoint
its tile came from, and that identification is worth more than the cap.
Commands
The following table summarizes the commands:
| Command | Description |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| seeotter capture <component> | Generates a scene around one component and captures it. --props <json> sets the props, --scene <name> names the artifact path, --breakpoints <list> sweeps a subset, and --keep retains the generated scene file for debugging |
| seeotter capture-all [glob …] | Runs saved scene files through the project's own Vitest in one spawn and prints one batch report. The default globs are the config's include patterns |
| seeotter <command> --read-config <path> | Points the CLI at the project config it reads (default: ./seeotter.config.ts). Read-only: the capture's config comes from vitest.config.ts |
| seeotter <command> --json | Swaps the report for machine output: the manifest (capture), or the { manifests, failures, unattributed? } envelope (capture-all) |
| seeotter --version | Prints the version and the build stamp |
Scope
Vue 3 today. src/adapters/types.ts is the mount seam — official
vitest-browser-react and vitest-browser-svelte packages plug in there, so a
second framework is a config swap rather than a rewrite.
Not in scope, deliberately:
- Visual regression. Vitest 4 ships
toMatchScreenshotwith baselines and diff images. SeeOtter is exploratory feedback. - A component-explorer web UI. That is Storybook.
- CI gating.
capture-allnames failures and exits nonzero; wiring that into a merge gate is out of scope.
Determinism
Verified byte-identical across repeated runs. Before every capture SeeOtter
kills animations and transitions, awaits document.fonts.ready, hides
scrollbars, freezes Date.now(), and seeds Math.random().
prefers-reduced-motion: reduce is set at the Playwright context level, since a
page cannot force a media feature on itself. Chromium launches with
--disable-partial-raster for the same reason: tile reuse can rasterise
identical content in more than one stable way, and no page can pin its own
rasteriser. Vite's HMR error overlay is disabled at the dev-server level: a
neighbouring file's failed transform would otherwise paint the overlay over
every connected page — the orchestrator included — and straight into the next
capture.
Documentation
Start with docs/workflow/project-config.md "Execution boundary — get this right". The most common way to break this codebase is putting code on the wrong side of the browser/Node boundary, and that section is the authoritative split.
| Doc | Contents | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | docs/workflow/project-config.md | The engineering doctrine: the execution boundary, the risk map, the CI mirror, the merge policy | | docs/publishing.md | The npm packaging story: the dist build, the export map's design, the accidental-publish guard, the manual release pipeline | | docs/ci-conventions.md | The script-name contract CI invokes quality checks through | | docs/writing-standard.md | The prose standard the vendored vale style enforces mechanically |
Development
To run the CI mirror that every pushed head must pass, in order:
bun install
bun run typecheck # tsc --noEmit
bun test # unit tests (pure logic: layout, config, manifest)
bun run test:browser # scene files through real Vitest browser mode
bun run lint
bun run format:check # oxfmt --checkbun run test:browser needs Playwright browsers installed. It is the only
step that touches a real browser, and it is the step that proves a capture
change kept repeated runs byte-identical.
The following table splits the execution boundary:
| Runs in the browser | Runs in Node |
| ------------------- | ------------------------------------------- |
| src/sweep.ts | src/commands/** (Vitest BrowserCommand) |
| src/scenes.ts | src/cli/** |
| src/adapters/** | src/vitest.ts |
| src/runtime/** | sharp, node:fs |
Browser code must never import sharp, node:fs, or anything Node-only. Node
code reaches raw Playwright through the command context — the escape hatch for
anything the Vitest browser API does not expose. src/config.ts and
src/types.ts are isomorphic; keep them dependency-free.
One packaging consequence to know: an in-repo seeotter capture generates a
scene importing @whyotter/seeotter, which resolves through this package's
own exports map to dist/ — build output, not live source. Run
bun run build:dist first (and again after changing src/); the checked-in
scene fixtures are unaffected, since they import ../../src directly.
Dogfooding. The browser suite captures this repo's own scene files through
the full pipeline, and a device pass re-captures them under
vitest.config.device.ts and audits the emitted artifacts. A capture change
that breaks determinism fails here, not in a consumer.
License
Released under the MIT license. SeeOtter is plumbing around Vitest browser mode and Playwright, and MIT matches the ecosystem it wraps. The stack it drives keeps its own licenses (Vitest and Vue are MIT, Playwright and sharp are Apache-2.0), none of which SeeOtter redistributes.
