README.md

squint

Visual regression testing and UI review for web applications.

squint review      photograph every screen, for a design review
squint check       photograph a revision and the working tree, and diff

squint drives the Chrome already installed on the machine. It does not download a browser, it does not ship one, and it commits no baseline images.

Why there are no golden images

Stored baselines are what makes visual regression testing miserable. The baseline was rendered on one machine months ago and the candidate on another today, so font hinting, subpixel rounding and the processor architecture all differ — and every one of those reads as "something changed".

squint renders both sides in the same run, on the same machine, with the same browser binary, seconds apart. Those differences cancel exactly. What is left in the difference is the change itself.

Which also means no Git LFS, no binary files in review, no -update ritual, and no argument about which machine may regenerate a baseline. The baseline comes from git on demand — a worktree at HEAD, or any ref.

Configuration

A squint.yaml in the project under test:

app:
  build: go build -o server ./cmd/server
  run:   ./server -http 127.0.0.1:{{.Port}} -data {{.TmpDir}}
  ready: "{{.BaseURL}}/healthz"
  setup: ./hack/seed.sh {{.BaseURL}}      # the most important line in the file

auth:
  path: /login
  form: {user: "{{.User}}", password: password}

viewports:
  desktop: {width: 1280, height: 900}
  phone:   {width: 390, height: 844}

themes:
  light: {}
  dark:  {cookies: {theme: dark}}

mask:
  - ".token-prefix"        # hidden before the shutter, keeps its space

scenarios:
  - {name: home, path: /}
  - {name: settings, path: /settings, login: root}
  - name: drawer
    path: /
    login: root
    viewports: [phone]
    steps:
      - js: "document.getElementById('site-drawer').checked = true"
      - wait: 300ms

Scenarios expand across every viewport and theme automatically. Narrowing is opt-in, which is how the phone and dark renderings stop being the ones nobody checks.

Steps are click, type/into, wait_for, wait and js. The last is the escape hatch: a browser automation vocabulary grows without bound, and capping it is cheaper than inventing a language.

Determinism is the caller's job

A screenshot of undetermined data differs for reasons that are not the code's. squint gives you two tools and the rest is the fixture's problem:

  • setup: runs after the application is ready and before the first shot.
  • mask: hides elements whose content is genuinely new every run — a token minted from real randomness, an id, a clock. Hidden with visibility, so the element keeps its space and the layout is unchanged.

What neither can fix is an application that renders something variable: live error text from a network stack will wrap to a different number of lines and change a page's height. Either pin it in the fixture or do not photograph that screen.

A fixture that reaches the real network is not a fixture.

Output

.squint/
  before/<scenario>.<viewport>.<theme>.png
  after/...
  diff/...            # the candidate faded, changed pixels marked
  report.html         # for a person: side by side, worst first
  result.json         # for a reviewer: paths, numbers, verdicts,
                      #   and overflow / overflow_ink per shot

The one thing squint judges by itself

Everything else here measures and leaves the judging to a person. There is one exception, because it needs no taste at all: a page that renders wider than the viewport it was taken for scrolls sideways, and on a phone that is always a bug.

A full-page screenshot is captured at the page's own content size, so this falls out of the shot for free. Every shot that does not fit is reported:

admin-accounts.phone.light    65px past the 390px viewport, with content out there
admin-settings.phone.light    40px past the 390px viewport, nothing drawn out there

The second half matters as much as the first. The accounts card had pushed an account's status badge off the screen, which is what a person reported. The settings page was forty pixels wider because of a tooltip bubble that is invisible at rest and still counted -- a real bug, but nothing is out of reach, and a report that presented the two identically would be ignored within a week. result.json carries both as overflow and overflow_ink.

Requirements

Go 1.24+ to build, and a Chrome or Chromium on the machine. Set SQUINT_CHROME to point at a specific one.

Status

Early. The compare and review commands work and are dogfooded against Parley. Not yet built: the Claude Code skill and slash command that read result.json, open only the images that moved, and say what a difference means.