visual-qa-starter

Next.js template · vision model · deterministic scoring

Compare two screenshots with a vision model. Score them with arithmetic.

Upload a design and the implementation that was built from it. A vision model returns findings only — each one tagged with a rubric category and a severity. The server turns those into a 0–100 score with a weighted formula the model never sees, and writes a ticket you can paste into an issue tracker.

Model: anthropic/claude-sonnet-5 · no API key set, so comparisons are disabled

WebMCP review session · no API key needed

/review →

The same engine, opened up to a visiting agent. Seven tools registered through document.modelContext or navigator.modelContext: the agent sweeps three bundled screen pairs, ranks them worst first and drafts the ticket, and you decide which differences are defects and which were on purpose. Runs on sample data, so it works in any browser with nothing configured.

Not configured

Set OPENAI_COMPATIBLE_API_KEY to run a comparison. Everything else on this page works without it.

Locally: copy .env.example to .env.local, paste a key, restart npm run dev. On Vercel: add it under Project Settings → Environment Variables, then redeploy.

Run a comparison

Two screenshots · about 30 seconds
Drop a screenshot, or click to chooseThe approved frame. This is treated as the source of truth.
Drop a screenshot, or click to chooseWhat was actually built — a simulator, device, or browser capture.

Why the model doesn’t produce the score

Ask a model to rate its own work and it says nine

Vision models are good at seeing that a button moved. They are poor at converting what they saw into a number, and worse at doing it the same way twice. Run the same two screenshots through a “score this out of 100” prompt repeatedly and the answers cluster around 90 whatever the images show, then wander by ten or fifteen points between runs.

So the model only classifies

It returns findings. Each carries a category id from the rubric and one of three severities. The server rejects anything with an unknown category, dedupes, sorts, and then does the arithmetic in src/lib/score.ts. Same findings in, same number out — every time, and defensible to whoever the ticket lands on, because the rubric is a file they can read.

raw(c)       = Σ severity_points[f.severity] × (weight(c) / 10)
deduction(c) = min(raw(c), weight(c))      ← a category never exceeds its weight
score        = clamp(round(100 − Σ deduction(c)), 5, 100)

severity_points = { critical: 10, major: 4, minor: 1.25 }

critical = 10 is chosen so that exactly one critical finding consumes its entire category. Severity therefore scales with how much a category is worth, with no second table to keep in sync. The per-category cap is what the weights are really for: it bounds how much damage one talkative run can do, which is where run-to-run variance comes from.

The starter rubric

5 categories, 15 sub-checks, weights summing to 100. It lives in one file — src/lib/rubric.json — which the prompt, the validator, the scoring and this page all read. Add a category and the prompt grows a section for it automatically. This is the part you are meant to rewrite.

What to change first

  1. 01

    src/lib/rubric.json

    Categories, weights, severity points and bands. Nothing else needs editing when you add a category — the prompt and the UI are generated from it.

  2. 02

    src/lib/prompt.ts

    The engine prose: what to ignore, how to hedge, what makes a finding. The taxonomy underneath it is generated from the rubric.

  3. 03

    src/lib/score.ts

    The arithmetic. Roughly forty lines, no dependencies, and trivially unit-testable — which is the point of keeping it out of the model.