A .diff.scrmap file is a plain zip describing how an app's navigation map changed
between two revisions — typically the base and head of a pull request. It follows git
semantics: the artifact is the diff, self-contained and shareable, like a .patch
with screenshots.
It embeds both sides' graphs plus captures for the suspect set only — the screens static analysis says the change could touch — not a full re-capture of the app.
myapp-pr123.diff.scrmap (zip)
├── manifest.json # formatVersion: 1 (2 when multi-platform), kind: "diff", base/head/pr metadata
├── diff.json # the verdict: nodes/edges/states classified A/M/D
├── base/map.json # full graph of the base revision (map.json schema from screenmap v2)
├── head/map.json # full graph of the head revision
├── base/screens/*.png # suspect-set captures at base
└── head/screens/*.png # suspect-set captures at head
Verdicts are static-only: a screen is modified iff the change set touches its
route file or a file in its (bounded) import closure. Screenshots are evidence for the
human reviewing the diff, not input to the classification.
Statuses use git letters: A added · M modified · D removed. Unchanged
nodes/edges are not listed — viewers derive them from head/map.json.
{
"changedFiles": ["src/screens/Settings/Settings.tsx", "…"], // the PR's file list
"broadFiles": [ // changed files excluded from suspect expansion
{ "file": "src/state/queries/preferences/index.ts", "importedByRoutes": 41 }
],
"nodes": [
{ "id": "BetaFeaturesSettings", "status": "A",
"reason": "route-added" },
{ "id": "Settings", "status": "M",
"reason": "file-touched", // or "import-touched"
"via": ["src/screens/Settings/Settings.tsx"], // which changed files implicate it
"depth": 0, // import distance; 0 = route file itself
"note": "New 'Beta features' row between Languages and Help" },
// what visibly changes — authored by
// whoever read the PR diff (notes.json
// in the working dir, keyed by node id)
{ "id": "OldScreen", "status": "D", "reason": "route-removed" }
],
"dismissed": [ // statically-flagged suspects a human/agent
{ "id": "ProfileSearch", // judged visually unaffected — dropped from
"reason": "import-touched", // nodes, kept here so the call is auditable
"note": "Shares Explore code by import; those modules don't render here." }
],
"edges": [
{ "from": "Settings", "to": "BetaFeaturesSettings", "status": "A",
"raw": "navigate('BetaFeaturesSettings')" }
],
"states": [
// Per-capture-state ledger for every (non-dismissed) suspect: the bare
// screen is name "" and each captured variant is listed by name, so a
// change that only shows in one state (a scrolled list, an open sheet)
// points the reviewer at that exact state. Statuses: A | M | D |
// "unchanged" (agent judged that state visually unaffected via notes.json
// per-state verdicts). reason "hint" entries are advisory stateHint
// changes with no capture backing them.
{ "node": "Settings", "name": "", "status": "unchanged", "reason": "capture",
"note": "Top of the list is identical — the new row is below the fold." },
{ "node": "Settings", "name": "bottom", "status": "M", "reason": "capture",
"note": "New 'Beta features' row appears between Languages and Help." },
{ "node": "BetaFeaturesSettings", "name": "bottom-sheet:app-dialog",
"status": "A", "reason": "hint" }
]
}Semantics:
- Node identity is the route id. A route present only in head is
A; only in base isD. A rename (same file, new id) appears asD+A— producers may note the suspected rename inreason. - Edge identity is
(from, to, target)on statically resolved edges. Unresolved edges (to: null) are ignored by the differ. Mis a static claim, not a visual one. Screenshots let the reviewer see whether the touched screen actually looks different; the bundle does not judge.- broadFiles records honesty about scope: a changed file imported by more routes than the expansion cap is excluded from suspect marking (it would flag the whole app) and surfaced here so the reviewer knows the blind spot.
- Captures:
base/screens/andhead/screens/are slug-named like plain bundles.Anodes have head captures only,Dnodes base only,Mnodes ideally both. Missing captures are allowed (e.g. auth-walled) — viewers show a placeholder. - Viewers must ignore unknown fields and reject
formatVersionthey don't support. - Backdrop merge: viewers MAY overlay a diff on a full
.scrmapof the same app (matched byapp.name) — unchanged screens then show their real (dimmed) screenshots from the full bundle while the diff supplies annotations and base/head captures for the suspect set. The reference map viewer does this automatically when both bundles are loaded.
The screenmap skill assembles diffs under <project>/.screenmap/out/diff/<slug>/
(pr-<number> or <baseRef>..<headRef>):
.screenmap/out/diff/pr-11123/
├── pr.json # {number,title,url,baseSha,headSha,baseRef,headRef}
├── changed-files.txt # one path per line (git diff --name-only base...head)
├── base/graph.json # parse-routes.mjs output at base
├── base/screens/*.png
├── head/graph.json
├── head/screens/*.png
├── suspects.json # diff-map.mjs suspects → capture work-list
└── diff.json # diff-map.mjs pack
A diff captured on one platform is the v1 layout above, unchanged. One captured
on several becomes formatVersion: 2, gaining the same platform axis as a v3
.scrmap — under each side rather than instead of it:
├── base/
│ ├── map.json
│ └── screens/{ios,android}/*.png
└── head/
├── map.json
└── screens/{ios,android}/*.png
manifest.app.platforms lists them in capture order, and each node in a side's
map.json carries captures alongside a capture that mirrors the first
platform — so a v1 reader still renders the diff as a single-platform one. Each
side's capture-status.json is keyed by platform first.
The diff verdicts themselves (diff.json: nodes, edges, states) stay
platform-independent: a screen is "changed" because the change set touches its
file or import closure, which is static analysis of one commit and says nothing
about which device it renders on. A state variant captured on either platform
counts as a state of that screen.
{ "formatVersion": 1, "kind": "diff", // discriminates from plain .scrmap bundles "generator": "screenmap/1.0", "app": { "name": "bluesky", "scheme": "bluesky", "platform": "ios-simulator", "device": "iPhone 17 Pro", "mode": "react-navigation" }, "base": { "ref": "main", "commit": "abc1234", "generatedAt": "…" }, "head": { "ref": "beta-features", "commit": "def5678", "generatedAt": "…" }, "pr": { // optional — present when diffing a PR "number": 11123, "title": "Add setting for opting in to beta features", "url": "https://github.com/bluesky-social/social-app/pull/11123" }, "generatedAt": "…" }