Document a client's WordPress site
In this tutorial you will build a page of a client handbook: two annotated screenshots showing how to start a post and how to add an image to it. The site runs on your own machine, shotlist signs into it once and reuses that session, and the second screenshot reaches inside the block editor's canvas.
It is the first tutorial where the pages are behind a login, so it assumes you have finished Your first screenshot and that recipes, marks and callouts are familiar.
Before you begin
You need Node.js 20 or later, and WordPress.com Studio, which runs WordPress locally with no server to configure.
Written and run against WordPress 7.0.3. The editor's markup moves between releases, so see when WordPress changes below if a query stops matching.
The choice of using WordPress is deliberate: it is a complex application with a JavaScript editor, and the tutorial shows how to reach into that editor's canvas. The same techniques work on any site, so you can follow along on a different application if you prefer.
Step 1: create the site
In Studio, choose Add site and name it shotlist. Studio installs WordPress and starts it. Two things from its panel are what the rest of this needs:
- the site's address, written here as
http://localhost:8883 - the administrator password, which Studio generates and shows you
Do not give it a custom domain. Studio offers names like shotlist.wp.local, and macOS resolves anything ending in .local through multicast DNS — which takes about five seconds per lookup even with the name in /etc/hosts. A browser resolves per connection, so an admin page pulling a hundred assets never finishes loading. The localhost address Studio gives you by default has none of that.
Step 2: start the handbook project
The handbook is its own project. You do not want a node_modules folder inside a client's WordPress install:
mkdir client-handbook
cd client-handbook
npm init -y
npm install --save-dev shotlist playwright Step 3: point at the site and name the session
site:
url: http://localhost:8883
viewport: { width: 1440, height: 900 }
sessions:
editor:
path: .shotlist/editor.json
verify: 'body.wp-admin'
allowEnv:
- WP_ADMIN_PASSWORD
install:
handbook: handbook/images path is where shotlist writes the cookies once you have signed in. verify is a selector only a signed-in page has. body.wp-admin is the class WordPress puts on every admin screen, and a signed-out visitor is redirected to a login page that does not carry it.
Do not reach for #wpadminbar here, which is the obvious choice and the wrong one: the editor opens full-screen and hides the admin bar with CSS, so the element is in the page but never visible, and every recipe fails as though the session had expired.
Set verify on every WordPress session. Without it an expired login does not fail: WordPress redirects to its sign-in form, every screenshot becomes a picture of that form, and --install publishes them into the handbook.
Step 4: sign in once
Signing in is ordinary steps, so it goes in a macro. Create screenshots/macros/sign-in.yaml:
steps:
- goto: http://localhost:8883/wp-login.php
- fill: { css: '#user_login' }
value: admin
- fill: { css: '#user_pass' }
value: ${env.WP_ADMIN_PASSWORD}
- click: { css: '#wp-submit' }
- wait: { css: '#wpadminbar' }
comment: the admin bar only exists once the sign-in has gone through The last wait is not decoration. Clicking the button starts a navigation, and without something to wait for, shotlist would save the session before WordPress had finished signing you in — leaving a file that looks fine and works for nothing.
Put the password in a file rather than in an export, which your shell writes to its history in plaintext and keeps there. Create .env beside the configuration:
WP_ADMIN_PASSWORD='the password Studio shows you' Then load it and run the sign-in. set -a exports every name defined while it is on, so the file arrives as ordinary environment; the allowEnv line in your configuration is what lets the macro read it:
set -a; . ./.env; set +a
npx shotlist --login editor --using sign-in ${env.WP_ADMIN_PASSWORD} resolves only because that name was allowed. The password itself is never in the configuration, never in the macro, and never in anything shotlist writes — only its name is. A name that was allowed but is empty stays unresolved and the run says which one, rather than submitting an empty field and reporting a sign-in that failed for no visible reason.
Loading the file is the shell's job because shotlist never opens a .env — that name is on the list of paths it refuses in every mode, so a configuration cannot aim anything at one either. Capture a page behind a sign-in has the node --env-file form, for a shell without set -a.
Both files are credentials: the .env holds the password, and anyone holding the session file is signed in as that administrator without needing it. Add .shotlist/ and .env to .gitignore before committing anything.
Step 5: show the way in
A handbook starts where the reader is. Create screenshots/recipes/add-post-menu.yaml:
name: add-post-menu
install: handbook
session: editor
url: http://localhost:8883/wp-admin/edit.php
clip: viewport
marks:
posts: { css: '#menu-posts .wp-menu-name' }
add: { css: '#adminmenu a', text: Add Post }
numbered: [posts, add] numbered puts a disc on each mark in the order you listed them, which is what a numbered list in the prose beside it needs.
The url is the Posts screen rather than the dashboard, and that is deliberate. On the dashboard the Posts submenu is a flyout that appears on hover, and WordPress parks a closed flyout at top: -13000px rather than hiding it — so a mark still resolves, the canvas grows thirteen thousand pixels to reach it, and the screenshot is mostly empty. On the Posts screen the submenu is simply open.
Step 6: the editor's controls
name: new-post
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php
setup:
- optional:
- click: { role: button, name: Close }
- wait: { role: button, name: Block Inserter }
clip: { css: '.editor-header', pad: 12 }
marks:
inserter: { role: button, name: Block Inserter }
publish: { within: clip, text: Publish }
callouts:
- { mark: inserter, text: Add a block, place: bottom }
- { mark: publish, text: Publish when it is ready, place: bottom } -
session: editoris what makes this work at all. Without it the browser is a stranger andpost-new.phpis a login form. - The
optionalblock dismisses the welcome dialog WordPress shows the first time somebody opens the editor. It ignores failures, so the recipe works whether or not the dialog is there — and it is there exactly once per account. -
waitholds until the toolbar exists. The editor is a JavaScript application, so the page responding is not the page being ready.
Step 7: choosing the Image block
name: block-search
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php
setup:
- optional:
- click: { role: button, name: Close }
- click: { role: button, name: Block Inserter }
- fill: { placeholder: Search }
value: image
- wait: 800
comment: the list filters on a debounce, and Image is in the unfiltered list too
- wait: { role: option, name: Image, exact: true }
clip: viewport
marks:
search: { css: '.block-editor-inserter__menu .components-input-control__container' }
image: { role: option, name: Image, exact: true }
numbered: [search, image] The wait: 800 is doing something a query cannot. The list filters on a debounce, and Image is in the unfiltered list too — so waiting for the Image option succeeds immediately and photographs every block in WordPress. Waiting for a state that both versions satisfy is not waiting.
The search field is marked by its container rather than the input. The input stops short of the magnifier and the clear button, and a box around two thirds of a control looks like a mistake.
Step 8: the block in the post
name: image-block
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php
setup:
- optional:
- click: { role: button, name: Close }
- click: { role: button, name: Block Inserter }
- fill: { placeholder: Search }
value: image
- wait: 800
comment: the list filters on a debounce, and Image is in the unfiltered list too
- click: { role: option, name: Image, exact: true }
- wait:
frame: { css: 'iframe[name="editor-canvas"]' }
text: Media Library
- wait: 1500
comment: the editor rebuilds the canvas after the first insert; let it settle
clip: viewport
marks:
upload:
frame: { css: 'iframe[name="editor-canvas"]' }
text: Upload
library:
frame: { css: 'iframe[name="editor-canvas"]' }
text: Media Library
callouts:
- mark: upload
text: Upload an image
place: left
inside: true
- mark: library
text: Or choose one already uploaded
place: bottom
inside: true
dy: 100 frame: is the new part. The post being edited is not in the page shotlist opened — the editor renders it inside an iframe, and a query cannot cross into one on its own. frame: names that iframe with a query of its own, and everything beside it resolves in the document inside. The wait uses it too, since a step takes a query like any other key.
The plain wait: 1500 after it is not padding. Inserting the first block makes the editor rebuild its canvas, and the iframe that replaces the old one has not painted yet — capture at that moment and the block is in the page, the callout lands in the right place, and the picture is empty.
inside: true keeps the labels over the screenshot rather than in a margin the canvas grows to make, and dy pushes them clear of the block, so the image stays exactly the viewport size. The two callouts name different sides, so each arrow is short and neither label has to be moved out of the other's way.
Step 9: take them all, and install them
npx shotlist --all --install Both images land in handbook/images/, ready for the page that explains the steps beside them.
When the session expires the run stops and says so, rather than photographing the login form:
recipe "new-post": session "editor" is signed out — run: shotlist --login editor Run npx shotlist --login editor again and the screenshots are current once more.
What to know about frames
A rect measured inside a frame starts at that frame's top-left, and the canvas sits well down the page. shotlist converts it to page coordinates, so a callout lands where the reader sees the element rather than where the frame thinks it is. A frame inside a frame costs nothing extra.
Two things are worth knowing before you rely on it:
- A cross-origin frame works, because resolution goes through the browser rather than the page's own JavaScript — an embedded checkout or a payment field is reachable.
- A
transformorzoomon the iframe itself is not compensated for, so a scaled preview will place its callouts off the mark.
When WordPress changes
The block editor is a moving target, and the queries here are not all equally exposed to that. Two kinds are doing the work:
- What a person can see —
Block Inserter,Image,Publish,Media Library. These are accessible names and visible text, so they change only when WordPress changes the interface, and then the screenshot needed updating anyway. - Gutenberg's own class names —
.editor-headerand.wp-block-image. These are internal, and they are the ones that break quietly. They appear here only where the markup offers no other handle: the editor header carries no role or label, and a placed block has no accessible name.
That second kind has moved before. The inserter button used to be .edit-post-header-toolbar__inserter-toggle, which had already gone by WordPress 6.9.4 — a recipe written against it would fail today with nothing to say beyond "no element matched". It is the first place to look after a WordPress update.
We keep this page current as WordPress moves. If a query here stops matching on a version you are running, open an issue with the version and the query, and it gets fixed here rather than in everybody's private copy.
What you have learned
- run a real WordPress site locally, with no server to configure
- declare a named session, and prove it is live with a
verifyselector - sign in once by hand, without the password reaching shotlist
- drive a JavaScript application before capturing it
- ignore a dialog that may or may not be there
- clip, mark and wait inside an iframe with
frame:
Next steps
- Capture a page behind a sign-in covers signing in with a macro, for a run with nobody at the keyboard.
- Hide data that changes deals with the dates and counts a WordPress admin screen is full of.
- Keeping a screenshot current catches these going stale when the client updates WordPress.