shutter 0.1.0
shutter: ^0.1.0 copied to clipboard
Renders any Flutter widget to PNG, from a command-line expression or the project's `@Preview` functions, and diffs two runs pixel-wise. A small CLI shaped for AI agents.
shutter #
Shutter renders Flutter widgets to PNG and compares two runs pixel by pixel, so an AI agent can attach a visual change to its PR as before and after images.
Any widget can be shot, from a single button to a whole Scaffold screen: import it into a small preview file under Flutter's @Preview annotation, and shutter renders it in flutter test, without launching the app or Flutter's widget previewer.
| before | after | diff --images |
|---|---|---|
![]() |
![]() |
![]() |
AI agents — start here: run
shutter agentbefore driving the tool. It is the step-by-step playbook: choosing the previews to shoot, shooting before and after, and reading the diff.shutter manualis the reference. Both ship in the binary, sodart install shutteris enough.
What it does #
A visual change is closed by an image, not by "it compiles" or "the tests pass". Shutter gives that image without launching the app and without writing a test:
shutter shot <preview-file>...renders the widgets of the named preview files to PNG. A preview file imports any widget of the app and returns it from a function annotated with Flutter's@Preview; the widget itself needs no annotation.shutter diff <run-a> <run-b>classifies each shot of two runs aschanged,added,removed, orunchanged, and points at its before and after images (--imagesadds an image marking the differing pixels).shutter shot --widget '<expression>'renders one widget without a file, for a quick look.
Each command does one thing and prints paths, so it composes with grep, git, gh, and whatever opens images.
Shutter makes no judgement: it does not decide what to shoot, whether a change is good, or where to post.
It keeps no golden images and knows nothing about git; every comparison is between two runs you made.
Install #
dart install shutter
The target project needs Flutter 3.47+ (Dart 3.13+) and flutter_test in dev_dependencies. It gains no dependency on shutter.
Shutter finds the Flutter SDK through FLUTTER_ROOT, or through flutter on PATH (a version manager's shim included).
Agent skill #
The package ships an agent skill, shutter-visual-check, telling an AI agent to reach for shutter when a change affects how a widget looks. It points at shutter agent, so the playbook always matches the installed binary.
dart run skills@ add koji-1009/shutter # from the repository; the project gains no dependency
A project that lists shutter in dev_dependencies gets it with dart run skills@ get instead.
Quick start #
shot takes any file under lib/ with @Preview functions, so previews the project already has are named as they are.
For a widget without one, write a preview file; the convention is the preview dir (lib/preview/, or lib/src/preview/ in a package):
// lib/preview/notification_tile_preview.dart
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';
import '../widgets/notification_tile.dart';
@Preview(name: 'NotificationTile', size: Size(360, double.infinity))
Widget notificationTile() => const Material(
child: NotificationTile(
icon: Icons.local_shipping_outlined,
title: 'Your order has shipped',
body: 'It arrives tomorrow. Track the delivery or change where to leave it in the app.',
timestamp: '5m',
unread: true,
),
);
Size(360, double.infinity) shoots the tile 360 wide at the height it takes in a list, and the Material paints the surface it sits on.
Shoot before the edit:
$ shutter shot lib/preview/notification_tile_preview.dart
# shutter ai-report v1
run: /path/to/app/.dart_tool/shutter/runs/20260919T143503Z
shell: {path: lib/preview/shell.dart, sha256: 3bd0d8689642b359420876d859d4cfe94a404b7bdeb75ba237da990c310e936b}
summary: {error: 0, ok: 1}
shots:
- id: "03a31f8f5d859fec.0"
status: ok
name: NotificationTile
file: lib/preview/notification_tile_preview.dart:6
size: [360, 68]
png: /path/to/app/.dart_tool/shutter/runs/20260919T143503Z/03a31f8f5d859fec.0.png
Edit the widget, shoot again (here into run 20260919T143514Z), and compare the two runs by their ids:
$ shutter diff 20260919T143503Z 20260919T143514Z --images
# shutter ai-report v1
diff: /path/to/app/.dart_tool/shutter/diffs/20260919T143525Z
before: /path/to/app/.dart_tool/shutter/runs/20260919T143503Z
after: /path/to/app/.dart_tool/shutter/runs/20260919T143514Z
summary: {changed: 1, added: 0, removed: 0, unchanged: 0}
entries:
- id: "03a31f8f5d859fec.0"
status: changed
name: NotificationTile
file: lib/preview/notification_tile_preview.dart:6
size: [360, 106]
before_size: [360, 68]
diff_ratio: 0.4158
before: /path/to/app/.dart_tool/shutter/runs/20260919T143503Z/03a31f8f5d859fec.0.png
after: /path/to/app/.dart_tool/shutter/runs/20260919T143514Z/03a31f8f5d859fec.0.png
diff: /path/to/app/.dart_tool/shutter/diffs/20260919T143525Z/03a31f8f5d859fec.0.png
The three images are the ones at the top of this page.
A run can also be named latest, or latest~N for the run N before it, so shutter diff latest~1 latest compares the last two shots.
diff pairs the shots of two runs by id: the id comes from the preview's file and function, so the same preview has the same id in every run, and renaming or moving the function reports it as removed plus added.
The preview file stays in the project: the same function shows up in Flutter's widget previewer, and the next change is shot against it.
Every shot is wrapped in a shell: the project's shell.dart in the preview dir, with the app's own app widget, theme, router, and providers (shutter init writes one), or a default shell.
That shell is committed so everyone shoots with the same ambient; a shell not meant for the commit can live under .dart_tool/ and be named with --shell.
Shutter itself depends on no design library, so Material, Cupertino, and custom widget sets all work.
A widget can also be shot without a file:
shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart --size 200x56
--import names a file the widget expression needs imported (repeatable; package:flutter/widgets.dart is always imported).
The same --widget and --import give the same shot id, so two such runs line up in diff.
Subcommands #
| Command | Purpose |
|---|---|
agent |
The step-by-step playbook for AI agents. |
manual |
The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. |
doctor |
Check the Flutter SDK version, its font cache, and the project's shell (--shell). |
init |
Write <preview dir>/shell.dart, or the --shell file. |
shot |
Render the named preview files, or one --widget, into a new run (--widget/--import/--size, --settle, --shell). |
diff <a> <b> |
Compare two runs (--images). |
Exit codes #
| Command | 0 | 1 | 2 |
|---|---|---|---|
shot |
every shot ok | — | any shot error |
diff |
no difference | differences | — |
doctor |
no failed check | a failed check | — |
diff exiting 1 means "changed", not "failed". Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter SDK, 70 internal error, 78 project not usable.
Limits #
- One frame, no interaction: taps, hovers, scrolling, and mid-animation states are not shot. State comes from the widget's construction expression.
- HTTP is blocked while rendering, so network images fail to load.
- Text renders with the project's fonts plus Roboto; CJK and emoji fall back to the host's system fonts, and Cupertino text uses SF Pro on macOS (Roboto elsewhere), as a device would. Compare runs made on the same machine.
Experimental #
Behaviour whose specification is not settled; a later version may change what it does:
- The default shell for shots without a project shell: the SDK's
MaterialAppwith aMaterialsurface,material_ui's orcupertino_ui's app widget when the project depends on that package, or a plainWidgetsAppwhen no design library is available. It changes when the SDK's Material and Cupertino, announced for deprecation, go. - A preview's
theme: applied throughPreviewThemeData, an interface Flutter documents as not stable. - google_fonts: fonts are downloaded once into
.dart_tool/shutter/fonts/google_fonts/and served to the shot; a font that cannot be downloaded makes the shot an error instead of rendering in another font. Failures are recognised from the package's own messages, which change between its versions.
Details live in doc/manual.md (shutter manual).


