screenmap shows you every screen in an Expo / React Native or NativeScript app, and shows your reviewers exactly which screens a pull request changed, without anyone writing a test.
Web pull requests get a preview URL. Mobile pull requests get a QR code, a build to install, and a screen to find on your own, so most reviewers skip the visual half of the change. screenmap closes that gap: it runs the app in CI, screenshots the screens your diff can reach, and posts them into the review.
It is MIT licensed, runs in your own GitHub Actions, and keeps every screenshot on a branch in your own repository.
| Use | What you run | Section |
|---|---|---|
| Review what a pull request changes on screen | the aleqsio/screenmap@v1 GitHub Action |
Review pull requests in CI |
| Learn an app you have never seen, or map yours locally | the /screenmap Claude Code plugin |
Map an app on your machine |
| Look at a map, replay a flow, share it with a designer | the map viewer at app.screenmap.dev | The map viewer |
All three read and write the same .scrmap bundle, so a map recorded locally works in CI and the other way round. If you want the pipeline behind them before the setup steps, jump to How it works under the hood.
Want to see it first? Open app.screenmap.dev/?template=bluesky for a full map of the Bluesky client: 70 screens, 126 recorded flows, captured in one run.
- Map (
.scrmap) is one bundle holding every screen of the app: screenshots, routes, the edges between screens, and the recorded flows. It is a zip, and the viewer opens it. - Changes (
.diff.scrmap) is a second, smaller bundle describing what one pull request changed against a map. It is meaningless on its own and is always loaded on top of a map. - Flow is a recorded tap path to a screen, written as argent YAML with a
.meta.jsonsidecar. Committed to your repo, it replays headlessly withargent flow run, no LLM involved. - Baseline is the map of your default branch, cached on a
screenmapsbranch in your own repo. Pull request runs diff against it instead of re-mapping the app. - Suspects are the screens a pull request's diff can reach, worked out from the router and the changed files. Only suspects get re-captured.
- Deterministic lane covers everything that runs without a model: deep links, committed flow replays, diffing, packing. The agent lane is the headless coding agent, and it only handles screens with no flow yet.
The aleqsio/screenmap@v1 Action reviews the screens a pull request touches without re-mapping the whole app. When a PR opens, it works out which routes the diff can reach, captures them on a simulator, and posts a sticky comment with the changed region of each affected screen and a link into the viewer for the rest.
It reports, it does not gate. There is no pass/fail check by default, the agent can mark a suspected change as unaffected, and a reviewer looking at two images decides for themselves.
- Before and after, in place. Changed screens flip between the base and head revision, so a moved button reads instantly instead of forcing a thumbnail comparison.
- The changed region, boxed. Hovering a screen renders a region-aware visual diff: changed areas get a box, moved areas get an arrow from where they used to be, and the rest of the screen stays quiet.
- A note per screen. The agent writes a short line on each changed screen describing what visibly moved, and can mark a suspect screen as unaffected so it drops out of the way.
- The whole map behind it. The link opens the viewer preloaded with the baseline map plus the changes overlay, so a reviewer can also see where the changed screen sits in the app.
- Committed flows are the source of truth. The flows in
.screenmap/flows/*.yaml(plus their.meta.jsonsidecars) live in the app repo and get reviewed like code. A screen with a committed flow is captured by a headlessargent flow run, which is deterministic, needs no LLM, and takes seconds. - The baseline map is a cache. The full
.scrmapofmainis built once and refreshed incrementally on every push. A PR run captures only the head side of its suspect screens, because the base-side screenshots already exist in the baseline. - The agent fills gaps, then retires. A headless coding agent (Claude Code by default, see AI providers) runs only for screens that have no flow, guided by the repo's
.screenmap/SKILL.mdand capped by a per-run budget. On a PR its output is ephemeral: captures go into the bundle, flows go into the artifact. After the feature merges, the baseline job records flows for the new screens and opens a flows PR againstmain. Once that PR is merged, those screens replay for free.
PR opened ──▶ restore baseline (screenmaps branch) ──▶ static parse + suspects ──▶ per suspect:
committed flow? ──yes──▶ argent replay (teal)
──no───▶ agent explores (violet, budgeted)
base-side screenshots reused ──────────────────────────────────▶ head captures + notes ──▶ pack .diff.scrmap
──▶ publish (screenmaps branch + artifact)
──▶ sticky PR comment → ?map=…&changes=…
You need all four of these:
- An Expo or React Native app using expo-router or a react-navigation route map, with
expo-dev-clientinstalled and deep-linkable routes. Or a NativeScript app (Angular Router, or Core XML pages): no dev client and no EAS lane, so you pass a prebuilt.app/.apkasapp_pathand name the routes your URL handler opens underroutes.links— see NativeScript apps. - An EAS build profile that produces a simulator build (iOS) or an APK (Android — set
"android": { "buildType": "apk" }, since an.aabcannot be installed on an emulator), or a build of your own. - A GitHub repo you can add secrets to.
- Runner minutes. iOS needs macOS, which bills at ten times the Linux rate; Android runs on
ubuntu-latest. A JavaScript-only pull request takes about 12 minutes end to end.
-
Copy the two workflow files from
action/templates/into your app repo's.github/workflows/:screenmap-pr.ymlruns on every pull request and posts the comment.screenmap-baseline.ymlkeeps the map ofmainfresh, and opens the flows PR after a merge.
-
Decide where the dev client comes from. The Action owns no build pipeline, so pick one:
-
EAS (the default). Link the project with
npx eas init, add a simulator profile toeas.json, and pass anEXPO_TOKENsecret as theexpo_tokeninput. The profile looks like this:"development-simulator": { "developmentClient": true, "distribution": "internal", "ios": { "simulator": true } }
The Action reuses the newest finished EAS build whose
@expo/fingerprintmatches the checkout, so a JS-only PR never waits on a rebuild. When nothing matches, it runseas buildon Expo's infrastructure and waits.Get your
EXPO_TOKENfrom expo.dev under Account settings → Access tokens, and add it in your repo under Settings → Secrets and variables → Actions. -
Bring your own build. Pass
app_pathpointing at a simulator.appan earlier step produced, from your own pipeline, a shared artifact, or anywhere else. EAS is skipped entirely, and noEXPO_TOKENis needed.
-
-
Optionally add an agent key and pass it as
agent_api_key. Without a key the Action stays deterministic: deep links and committed flows only, with no exploration and no per-screen notes. Claude Code is the default, and the alternatives are under AI providers.The key is also what makes a bad capture recoverable. Nothing verifies that a deep link landed on the screen you meant, so a route that starts needing a param will quietly capture its own not-found state; the agent is told to check the link arrived and to find real params when it did not. How many screens it re-checks is the
effortinput:effortThe agent sees Cost fastonly screens no committed flow can reach cheapest, quickest balanced(default)also routes whose deep link guesses a param a few screens more thoroughevery screen in the run most accurate, most tokens effortalso sets how far suspect marking follows imports (1, 2 and 3 hops). Anything set explicitly in.screenmap/config.json—agent.scan,agent.maxScreens,suspects.depth— overrides the preset. -
Optionally commit two files in the app repo.
.screenmap/config.jsonholds the mechanical knobs. Every key is optional; start fromaction/templates/config.json.{ "effort": "balanced", "scheme": "myapp", "device": "iPhone 17 Pro", "params": { "id": "v60" }, "suspects": { "depth": 2, "broadCap": 8 }, "waits": { "transition": 2500, "network": 6000, "boot": 15000, "relaunch": 4000 }, "agent": { "enabled": true, "provider": "claude", "scan": "params", "maxScreens": 8 } }Key Default What it sets effortbalancedThe preset from step 3. The effortinput andSCREENMAP_EFFORTset the same thingschemefrom the parsed app config URL scheme the deep links use platforms["ios"]Which platforms a run captures — ["ios"],["android"], or both. In the Action each platform is its own job (theplatforminput), folded together afterwards byscreenmap-ci mergeios.deviceiPhone 16 ProSimulator to boot. In the Action, the simulatorinput boots the deviceios.appPathdiscovered under ios/buildA prebuilt simulator .app. Theapp_pathinput wins over itios.appIdread from the .appBundle id. Override when discovery picks the wrong one android.deviceany attached device, else the first AVD AVD name, or the model of an attached device. In the Action, the avdinputandroid.appPathdiscovered under android/app/build/outputs/apkA prebuilt .apk. Theapp_pathinput wins over itandroid.appIdread from the APK with aapt2Package name. Also accepted as android.packageNameappNamethe project directory name Name recorded in the bundle device,bundleId,appPath— Pre-multi-platform spellings of the ios.*keys above; still honouredmetroPort8081Port Metro starts on (Expo only) runtimefrom the route provider expoornativescript:nativescriptwhen thenativescriptprovider read the app, elseexpo. Set it when acustomprovider reads a NativeScript app. A NativeScript session launches the installed app instead of steering a dev client onto Metroroutes.providerautoPin the route provider ( expo-router,react-navigation,nativescript,custom) when detection is wrong. See docs/route-providers.mdroutes.links{}Which routes a deep link opens, needed for NativeScript and usable with any provider: route id → path after the scheme ( truefor the route's own URL,falsefor none,"*"as the default). Without it every NativeScript screen is reached by tappingparams{}Real values for route parameters, see below suspects.depthfrom effortImport hops followed out from a changed file suspects.broadCap8Cap on screens marked by a change to a widely imported file waits2500/6000/15000/4000mstransition,network,bootandrelaunchsettle timesagent.enabledtrueSet falsefor a deterministic-only runagent.providerclaudeA preset CLI, or agent.commandplusagent.keyEnvfor any otheragent.scanfrom effortunflowed,paramsorallagent.maxScreensfrom effortScreen budget for one run flowsDir.screenmap/flowsWhere committed flows live skillFile.screenmap/SKILL.mdWhere the project guidance lives effortonly fills inagent.scan,agent.maxScreensandsuspects.depth, so any of the three set here wins over the preset.paramssupplies real values for route parameters. Without it a route takes a made-up one, so/brew/[id]opens as/brew/1, and if1is not a real id the not-found screen is captured and reported as that screen."id": "v60"covers every route with anid, and prefixing the route id scopes it to one route:"brew/[id].id"under expo-router,"Profile.name"for a react-navigation route map. With an agent key,balancedandthoroughalso send those routes back to the agent to find values that work..screenmap/SKILL.mdis the app-specific guidance: how to log in, which ids are real, which controls to leave alone, how long screens take to settle. The agent reads it before it explores. Start fromaction/templates/SCREENMAP_SKILL.md. -
Open a pull request. Pull requests are diffed against a baseline map of the default branch, published as
main/<sha>.scrmapandmain/latest.scrmapon an orphanscreenmapsbranch. You do not have to build that first map: when a PR run finds none, it starts the baseline workflow itself and re-runs the PR once that finishes.The Action dispatches rather than mapping inline, because a full map takes roughly twenty minutes and waiting for it inside the PR job would bill that at the macOS rate for nothing. This needs
actions: writeon both workflows and aworkflow_dispatchtrigger on the baseline one, which the templates have. Setauto_baseline: "false"to have a PR report the missing baseline instead, and run it yourself throughworkflow_dispatch.
In a monorepo, point the Action at the app with project: apps/mobile. Dependencies are installed from the nearest directory, up to the repo root, that has a lockfile: pnpm-lock.yaml, yarn.lock, package-lock.json, or bun.lock / bun.lockb. For bun, the Action uses a bun already on PATH, and otherwise sets one up with oven-sh/setup-bun at the version packageManager or engines.bun pins in that directory's package.json (the latest when neither does). On a private repo, set publish: "false", because raw GitHub URLs are not anonymously readable there. The comment then links the workflow artifact, which you download and drop into the viewer yourself.
| Step | Lane | Notes |
|---|---|---|
Restore the baseline for the PR base SHA (or latest) |
deterministic | read from the screenmaps branch |
parse-routes on head, then diff-map suspects against the baseline graph |
deterministic | the same suspect logic as /screenmap pr |
| Copy base-side screenshots of the suspects out of the baseline | deterministic | nothing is captured twice |
Replay committed flows for the suspects (argent flow run, fragments around capture points) |
deterministic | landing-checked, see below |
| Deep-link the remaining screens | deterministic | xcrun simctl openurl plus a screenshot |
| Explore screens that have no flow | agent (budgeted) | writes captures, flows and notes.json with per-screen "what changed" and unaffected verdicts |
diff-map pack into a .diff.scrmap |
deterministic | per-state statuses and dismissed suspects |
| Publish and post the sticky comment | deterministic | the link is ?map=<baseline-url>&changes=<diff-url>, which opens the viewer preloaded |
The landing check. Coordinate taps drift silently, because argent reports success wherever a tap lands. So every replay is verified: the end screen is OCR'd with Apple Vision, any system alert is dismissed, and the text is checked against the landmarks the recorder saved in the flow's sidecar (two to five words that identify the arrival screen). A flow with no landmarks falls back to comparing OCR text against a fresh deep-link capture of the same route. When the check fails, the flow is marked drifted, the deep-link capture is used instead, the app is relaunched, and the flow is queued for re-recording in the next baseline run.
| Input | Default | What it does |
|---|---|---|
mode |
required | pr or baseline |
project |
. |
Path to the app project (Expo or NativeScript), relative to the repo root |
agent_provider |
claude |
claude, codex, gemini or opencode. See AI providers |
agent_cli_version |
empty | Version of the agent_provider CLI to install. Empty installs the version this release pins |
agent_api_key |
empty | Key for the chosen provider. Leave empty for deterministic-only runs |
effort |
balanced |
fast, balanced or thorough — tokens and wall-clock against accuracy. See Install step 3 |
agent_max_screens |
empty | How many screens the agent may explore in one run. Empty uses the effort preset (6 / 8 / 24) |
auto_baseline |
"true" |
(pr) Start the baseline workflow when no map exists yet, instead of asking for it. Needs actions: write |
baseline_workflow |
screenmap-baseline.yml |
(pr) Which workflow to start when no baseline exists |
screenmaps_branch |
screenmaps |
Orphan branch holding baseline maps and per-PR change bundles |
publish |
"true" |
Publish bundles to the screenmaps branch so the comment can deep-link the viewer. Needs contents: write |
viewer_url |
https://app.screenmap.dev |
Viewer origin used in comment links |
simulator |
iPhone 17 Pro |
Device to boot, falling back to any available iPhone |
app_path |
empty | A prebuilt simulator .app (or .apk). When set, EAS is skipped. The only option for a NativeScript app |
expo_token |
empty | EXPO_TOKEN for the EAS lane. Required unless app_path is set |
eas_profile |
development-simulator |
The eas.json profile used for the dev client |
flows_pr |
"true" |
Baseline runs open a PR with the flows the agent recorded |
github_token |
${{ github.token }} |
Used for comments, screenmaps pushes and flows PRs |
Outputs: bundle (path of the produced .scrmap or .diff.scrmap), summary (path of the run summary JSON), and viewer_link (the preloaded viewer link, when the run published).
The agent lane is provider-agnostic. Its contract is file-based: the agent is told which screens to handle and exactly where to write captures, flows, notes.json and summary.json, and the CLI validates those files afterwards. Any headless agentic CLI that can run shell commands fits the slot.
agent_provider |
CLI invoked | Key env var |
|---|---|---|
claude (default) |
claude -p … --dangerously-skip-permissions |
ANTHROPIC_API_KEY |
codex |
codex exec --dangerously-bypass-approvals-and-sandbox |
OPENAI_API_KEY |
gemini |
gemini --yolo -p … |
GEMINI_API_KEY |
opencode |
opencode run … |
whichever its configured provider needs, named in agent.keyEnv |
Pass the key as the agent_api_key input and the CLI maps it onto the env var the provider expects (an env var you set explicitly wins). The Action installs the chosen CLI on demand, at the version it pins unless agent_cli_version says otherwise. Locally, AGENT_PROVIDER plus the provider's own env var work the same way.
For any other CLI, set this in .screenmap/config.json:
"agent": { "command": "myagent --prompt-file {promptFile}", "keyEnv": "MYAGENT_API_KEY" }{promptFile} (also available as $SCREENMAP_PROMPT_FILE) is a markdown file holding the full task. The command runs through bash in the project directory, and your workflow has to install it beforehand. Setting agent.provider in the same file picks a preset without touching the workflow YAML.
The prompt is identical across providers, so quality depends on the model driving it. Claude Code is the only one dogfooded end to end.
The Action is a thin wrapper around action/cli/screenmap-ci.mjs, and that CLI runs against your own simulator:
cd action/cli && npm install
# full baseline of the current checkout (the agent runs if its provider's key is set)
node screenmap-ci.mjs baseline --project ~/app --out /tmp/main.scrmap
# a PR diff: check out the head, point at the baseline, name the base commit
node screenmap-ci.mjs pr --project ~/app --baseline /tmp/main.scrmap --base <sha> --pr 42 --title "…" --out /tmp/pr42.diff.scrmap
# preview the comment
node screenmap-ci.mjs comment --summary ~/app/.screenmap/out/ci/pr/summary.json --map-url … --changes-url …While iterating, --only id,id, --limit N, --no-agent and --no-sim (static only) save a lot of waiting.
A local run writes flows to .screenmap/out/flows, which is generated output and gitignored. CI replays from .screenmap/flows, which is committed and reviewed. flows-adopt moves them across:
node screenmap-ci.mjs flows-adopt --project ~/appIt copies the YAML and its .meta.json sidecar, and refuses to overwrite a committed flow whose contents differ — pass --force when you mean to replace one. Review the result and commit it; from then on those screens replay deterministically, with no agent and no tokens.
This is also the repair path when a flow drifts. A drifted flow is captured by deep link for that run and reported in the comment, but nothing re-records it unless an agent key is set, so re-record locally with /screenmap, adopt, and commit.
| Path | Who uses it | What it holds |
|---|---|---|
.screenmap/SKILL.md |
agent | App-specific guidance, templated in action/templates/SCREENMAP_SKILL.md |
.screenmap/config.json |
deterministic lane | Scheme, device, waits, suspect depth, agent budget, sample params |
.screenmap/flows/ |
both lanes | Committed argent flows. The flows PR adds to this directory |
screenmaps branch |
the Action | main/<sha>.scrmap, main/latest.scrmap, pr-<n>/<sha>.diff.scrmap, all on SHA-pinned raw URLs |
eas.json |
EAS lane | The simulator dev-client profile named by eas_profile |
| EAS build history | EAS lane | The dev-client cache, keyed by @expo/fingerprint, so no actions/cache is involved |
| workflow artifact | the Action | The bundle plus summary.json, kept for 90 days |
- The Action never builds the app on the runner. EAS, or the
app_pathyou pass, supplies the dev client. An afternoon of CI archaeology (CocoaPods sync, Swift-tools minimums, clang strictness per Xcode point release) is Expo's problem now rather than this Action's. - Flows PRs are opened by the baseline job against
mainafter a merge, which leaves feature branches alone and keeps the flows PR reviewable on its own. - The baseline refreshes on every push to
mainand on a weekday cron. Refreshes are incremental: only suspect screens, and screens with no usable previous capture, get re-captured. - The agent budget defaults to 8 screens per run, and the comment says what got skipped.
- The status bar is frozen at 9:41 with
simctl status_bar overridebefore every screenshot, so base and head pixels differ only where the app differs. Simulator privacy is pre-granted withsimctl privacy grant all, so a mis-tap cannot raise a system dialog over later captures.
The agent reads untrusted text: the PR's source, and whatever the app puts on screen. Treat it as something that can be talked into running a command, and keep what it can reach small.
- Run on
pull_request, neverpull_request_target.pull_request_targethands a fork's PR the write token and your secrets while running its code: the install scripts, the app, and the agent. - Fork PRs get a read-only token and no secrets. With no
expo_tokenthe run stops at its first step. Withapp_pathit captures and uploads the artifact, then fails at publishing and commenting, both of which need write access. - Set
publish: "false"on a private repo, or when the app's screens show data you would not put on a public URL. - The agent's environment carries only its own API key. Its process gets
PATH,HOME, locale and terminal variables, the Android and Xcode tooling paths, and the one key variable for its provider (oragent.keyEnv).GH_TOKEN,GITHUB_TOKEN,EXPO_TOKEN, the Actions runtime tokens andAGENT_API_KEYitself are never passed. An opencode or custom agent that reads a different variable needs it named inagent.keyEnv. This is not a sandbox: the agent runs as the runner user, which can still read the job tokenactions/checkoutpersists in.git/config, the one publishing pushes with. - The agent CLIs,
eas-cliandargentare installed at pinned versions, so a new release cannot change what runs on your runner without a change to the Action.
The same pipeline runs as a Claude Code plugin, which is the fastest way to answer "what is even in this app" on your first day in an unfamiliar codebase, and the way to try screenmap before wiring up CI.
claude plugin marketplace add aleqsio/screenmap
claude plugin install screenmap@screenmapThen, in any Expo, React Native or NativeScript project, run one of these in Claude Code:
/screenmap # full run: parse + simulator exploration + pack
/screenmap --static # parse + render only, no simulator
/screenmap replay <flow-name> # replay a recorded flow on the simulator
/screenmap pr <number> # PR diff locally: which screens, states and edges changed
What you get:
- Real screenshots, not previews. Every card is the app actually running, with real data, including the empty states and error boundaries you forgot about.
- Runtime states as first-class screens. Bottom-sheet snap points, modals and drawers are captured as variants of the screen they belong to.
- The path to every screen, saved. Each screen carries the exact tap sequence that reaches it, ready to replay headlessly. Commit those flows and CI replays them instead of paying an agent to rediscover them.
Output lands in <project>/.screenmap/out/, so add that to your .gitignore. You need a macOS host with the iOS simulator, or an Android emulator (--platform android); --platform both captures each screen on both and puts them in one map behind a platform switcher.
The same pipeline reads a NativeScript app, with three differences that follow from how NativeScript apps are built.
-
Routes come from whichever framework drives the views. For Angular, the
nativescriptprovider followsprovideNativeScriptRouter(routes)(orNativeScriptRouterModule.forRoot) into everyloadChildrenmodule, reads named outlets,redirectToaliases, enum-valued paths and barrel re-exports, and takes edges fromnavigate([...]),navigateByUrl()andnsRouterLink. A route withchildrenis a layout, read as tabs when its children sit in several named outlets. Modals and material bottom sheets opened from a screen become state hints naming the component. Core apps (no framework) are read from their<Page>XML files andFrame.navigate({ moduleName })calls. For Octane, React NativeScript, Vue, Svelte and Solid, a screen is a component the framework mounts as a root (renderNativeScriptApp,ReactNativeScript.start,createApp,svelteNative,startSolidApp), pushes ($navigateTo,navigate({ page }), asolid-navigationnavigate('Name')against its<Route>table), presents ($showModal,showModal({ page }), a second root rendered into a view handed toshowModal) or hosts in a<frame>tab; the entry's mount is the app root, a<Route>table'sinitialRouteNameis the root behind a router shell, and a<drawer>or a nativemenu=prop becomes a state hint. -
Deep links are declared, not inferred. The app registers its URL scheme in
App_Resources(read automatically,${BUNDLE_IDENTIFIER}-style variables included), but which screen a link opens is the app's own code. So every route starts navigation-only, and.screenmap/config.jsonsays which ones a link reaches:{ "routes": { "links": { "talk/today": "today", "chatbot/chat": "ask-mae", "settings": true, "*": false } } }Keys are route ids, values the path after the scheme,
truefor the route's own URL,"*"as the default. Everything not listed goes to the agent lane to be reached by tapping. An app that registers no URL scheme at all still maps: the root screen is captured by launching the app, and the rest through committed flows or the agent. -
There is no Metro and no dev client. The JS bundle ships inside the app, so a session installs and launches the build and that is the whole boot. Locally,
ns build iosputs the simulator app underplatforms/ios/build/Debug-iphonesimulator/andns build androidthe APK underplatforms/android/app/build/outputs/apk/debug/; both the plugin andscreenmap-cifind them there. It has to bens build: under@nativescript/vite,ns debugandns runproduce a stub that imports every module from the Vite dev server, which is not there in a headless run, andscreenmap-cirefuses such a build by name rather than capturing the home screen. Debug and release builds can sit side by side; the newest one is used. In CI the Action never builds, so a build step produces the.appor.apkand passes it asapp_path; the EAS lane andexpo_tokendo not apply.
Angular, Octane, Vue and Solid are validated against real apps (a 54-screen NativeScript Angular app with lazy route modules and named-outlet tabs, an Octane chat app with a drawer and a native settings sheet, and three Apple Music clones in Angular, Vue and Solid with tab-hosted screens and shared-transition modals); Core, React and Svelte against fixtures. Flow replay, PR diffs and the agent lane run unchanged, with one caveat: a PR that changes native code needs a rebuild per side, since there is no Metro to restart.
The hosted viewer lives at app.screenmap.dev. Drop a .scrmap file (the Map) and, if you have one, a .diff.scrmap file (the Changes overlay for a pull request). Parsing happens in your browser, so nothing is uploaded. Opening the site with no parameters gives you the drop screen.
In the Map view you get code-declared edges as solid lines and agent-observed edges as dashed ones, flow playback with a follow camera and tap/swipe markers drawn on the exact screen state they happened on, per-screen state pickers, a one-action neighbours mode, minimap click-to-jump, and a copy-paste replay command for every flow.
In the Changes view, added, changed and removed screens and edges light up over the dimmed map, changed screens flip between base and head in place, and hovering renders the region-aware visual diff. The format behind it is specified in docs/diff-scrmap-format.md.
URL parameters:
?map=<url>&changes=<url>loads both bundles from any CORS-readable URL (a raw GitHub URL works) and opens on Changes. This is what the PR comment links to.?template=blueskyloads the bundled demo map and diff, opening on the Map with Changes behind the toggle./diffsloads the same demo pair and opens on Changes.
To run the viewer yourself:
cd apps/visualiser
npm install
npm run devDrop a .scrmap bundle on the landing page. The demo bundle ships in public/demo.scrmap, and the hosted instance loads the same file behind the "load the demo bundle" button.
┌─────────────────┐ ┌───────────────────────┐ ┌────────────────┐ ┌──────────────┐
│ 1. static parse │ ──▶ │ 2. agent exploration │ ──▶ │ 3. pack │ ──▶ │ 4. visualise │
│ parse-routes.mjs│ │ (simulator + flows) │ │ pack-map.mjs │ │ (web app) │
└─────────────────┘ └───────────────────────┘ └────────────────┘ └──────────────┘
routes, edges, screenshots per screen <app>.scrmap zip graph map with
state hints + state variants (manifest, map.json, flow playback,
+ nav/interaction flows screens/*.png) tap overlays
- Static parse (no dependencies). Reads expo-router file conventions, react-navigation route maps (the kind Bluesky keeps in
src/routes.ts), and NativeScript apps — Angular RouterRoutesarrays or Core XML pages — so the screen list is complete rather than whatever a crawler happened to find. It produces the route list, navigation edges fromLink,navigate()andnsRouterLinkcalls, and state hints saying which screens use a bottom-sheet or dialog system. - Agent exploration in the iOS simulator or Android emulator. A deep-link sweep captures every screen and classifies each capture (real, empty state, not found, error boundary, auth wall). For the screens a deep link cannot reach, an agent drives the app and records the tap path as an argent flow in YAML, replayable later with
argent flow run. Runtime states get captured too: open drawers, bottom-sheet snap points, dialogs. If a sticky error boundary blocks the app, the agent recovers and carries on. - Pack. Everything merges into a producer-agnostic
.scrmapzip. The format contract is in docs/scrmap-format.md, which is what you need if you want to write your own producer. - Visualise. The viewer draws a top-down graph with the root screen at the top-center and phone-framed screenshots. Load a second bundle, a
.diff.scrmap, and it overlays what a pull request changed.
The expensive part is step 2, and you only pay it once. Recorded flows get committed to your repo, and every later run replays them headlessly instead of exploring again. The agent only wakes up for screens that have no flow yet.
plugins/screenmap/skills/screenmap/SKILL.mdis the agent orchestration: phases, safety rails, and the flow-recording contract.plugins/screenmap/skills/screenmap/scripts/holdsparse-routes.mjs,pack-map.mjs,diff-map.mjs(PR diff: suspects and pack) andrender-map.mjs(static HTML fallback). All plain Node, no dependencies.plugins/screenmap/skills/screenmap/scripts/routes/is the route-provider layer:parse-routes.mjsonly drives it, and each framework (expo-router, react-navigation, nativescript, or a command you supply) is one module underproviders/. See docs/route-providers.md to add one.apps/visualiser/is the Map / Changes viewer, built with Vite, React, Tailwind v4, shadcn/ui, React Flow and elkjs, plus pixelmatch and OpenCV.js for the visual diff.action.ymlis the composite GitHub Action. The metadata sits at the repo root so the repo is publishable to the Marketplace. Itsscreenmap-ciCLI lives inaction/cli, and the workflow and.scrmaptemplates live inaction/templates.docs/scrmap-format.mdis the versioned bundle format contract, for writing your own producer.docs/diff-scrmap-format.mdis the format of the PR diff bundle behind the Changes view.docs/ci.mdis a stub pointing at the CI section above, which is where that guide lives now.fixtures/demo-app/is a minimal expo-router app that exercises the parser;fixtures/rn-demo-app/,fixtures/ns-angular-demo-app/andfixtures/ns-core-demo-app/do the same for the other providers, andfixtures/run-tests.mjspins every graph.
- One platform per CI job. iOS needs a macOS runner and Android is only worth running on Linux, so capturing both means two jobs and a
screenmap-ci mergestep; the workflow templates show the shape. A local run does both in one pass. - Android's dev-menu muting is best-effort. iOS writes the preference through
simctl spawn defaults; Android has to reach the app's SharedPreferences throughrun-as, which only works for a debuggable build. When it fails, the dev-menu floating button stays in the captures — cosmetic, and the run continues. - OCR on Linux is tesseract, not Vision. The landing checks, deep-link verification and system-alert dismissal all read the screen, and tesseract recovers noticeably fewer words than Apple Vision. Screen-to-screen comparisons hold up (same-text scores are unchanged; different-screen scores only move further apart), but a landmark check is likelier to miss, so a drift warning from a Linux run is less certain than one from macOS. The run summary and the PR comment name the backend when it is not Vision.
- Flows are per platform. Coordinates are normalized, but layouts and system chrome are not, so a flow recorded on iOS is not guaranteed to replay on Android. Record and commit them per platform.
- Your app needs a router screenmap can read. expo-router file conventions, a react-navigation route map, or a NativeScript Angular Router / Core page tree. Screens registered without URLs are invisible to the static parse, and only show up through agent exploration. A NativeScript app's deep links are whatever its own URL handler does, so its screens are navigation-only until
routes.linksnames them; Octane, React, Vue and Svelte apps are read from their mount, push and modal calls, so a screen wired any other way is invisible until an agent finds it. - Edge extraction is regex-based, so dynamic hrefs resolve to their route pattern.
- It reports, it does not gate. There is no pass/fail check, by design. A reviewer decides what the screenshots mean.
MIT © Aleksander Mikucki