npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 --json swaps 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 chromium

From a checkout

From a clone of this repo:

make pin

pin 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:

  1. vitest and the browser stack, installed locally per "From npm".
  2. The @whyotter/seeotter package, resolvable — installed from npm, or, for developing against a checkout of this repo, run bun link once here and bun link @whyotter/seeotter in 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

  1. Configure the harness. seeotter.config.ts declares the breakpoints and the app-level providers, and vitest.config.ts wires 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()] });
  2. 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.

  3. 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.mock works normally:

    import { vi } from "vitest";
    vi.mock("@/api/user", () => ({ getUser: async () => fixtures.user }));
  4. 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 glob

    capture-all runs 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-all names what failed and exits nonzero, and wiring that into a merge gate is deliberately out of scope.

    capture and capture-all split on generated versus saved, so neither command guesses what it was handed: capture refuses a positional the config's include patterns cover and points at capture-all, and capture-all refuses 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 errors

Every 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 toMatchScreenshot with baselines and diff images. SeeOtter is exploratory feedback.
  • A component-explorer web UI. That is Storybook.
  • CI gating. capture-all names 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 --check

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