| name | docs-screenshots |
| description | Plan, capture, audit, and improve screenshots in a Mintlify docs repo using the mintlify-shots kit. The repo uses a custom <Screenshot> directive backed by Playwright tests that produce light+dark PNG pairs and rewrite <Frame> blocks via `pnpm shots`. Use this skill any time the user wants to: add a screenshot to a docs page, find places in a section that would benefit from a visual, review/resize/recapture an existing shot, or debug a flaky, empty, or wrongly-sized capture. Trigger even when the user doesn't say 'screenshot' — phrases like 'this needs a visual here', 'the image is too big', 'capture the dialog', 'find missing shots in <section>', 'recapture the playground', 'the dark version is broken', 'the dialog gets clipped before it animates' all qualify. |
Docs Screenshots
You add, audit, and improve screenshots in a Mintlify docs repo that uses mintlify-shots. Each screenshot is a real Playwright test under shots/; the MDX uses a <Screenshot> directive that pnpm shots runs and then rewrites the following <Frame> block with the captured PNG pair (light + dark).
The setup, conventions, and CLI commands live in shots/README.md and docs/how-it-works.md — read those for the bare mechanics. This skill captures the judgment that isn't in the README: when a shot adds value, how to choose between shot types, how to size them on the page, which navigation patterns avoid flakiness, and what to look at when reviewing an existing shot or hunting for gaps.
Orientation
Screenshot directive (in MDX)
└── name="section/stem" ──────► shots/section.spec.ts → test("section/stem", ...)
│
├── Plays in light project → images/section/stem-light.png
└── Plays in dark project → images/section/stem-dark.png
│
▼
`pnpm shots` rewrites the <Frame> block following the directive
Three things to internalise:
- Test title = directive name. A spec at
shots/api.spec.ts with test("api/playground", ...) is matched by <Screenshot name="api/playground" />. The folder structure of specs is loose; the title is what matters.
- Two themes, one test. Each test runs twice (light + dark Playwright projects). You write one spec; you get two PNGs.
- Sync rewrites Frame blocks idempotently. Editing a
<Frame> by hand is a waste — the next sync will overwrite it. Edit attributes on the <Screenshot> directive (caption, alt, width, height, skip) and let sync regenerate the Frame.
When a screenshot adds value (and when it doesn't)
A screenshot is worth the maintenance cost when the visual carries information that prose alone cannot convey efficiently. Run each candidate through this filter:
Add a shot for:
- Hero shots at the top of any major feature page — gives the reader an instant "this is what we're talking about" before the prose starts. One per feature page is the norm.
- Configuration UIs with many fields/sections (settings tabs, schema editors, mapping panels). Words like "the Filters card with field toggles, facet/aggregation icons, and an Add button" are immediately legible from a 30 KB image.
- Modal/dialog flows for major actions — Create dialogs, Connect wizards, mock-data generators. These often have unique layouts that don't appear elsewhere.
- Per-section detail shots when a single page has multiple visually-distinct cards or sub-areas worth referring to from prose. A Query Settings page with one shot per card (Base Query, Filters, Search, Sort, Pagination) works because each is a discrete UI unit the docs talk about.
- State illustrations — what does the empty state look like? What does an error state look like? What does the page look like with one example record vs many? Pick the shape that's most representative.
Don't add a shot for:
- Single buttons, single inputs, or trivial UI elements that are fully described by their label. A button labelled "Save changes" doesn't need a picture.
- Code or terminal output — use a code block.
- Anything behind a feature flag that visually differs from production. Mark it
skip="true" and revisit when it ships.
- Pages whose layout is documented elsewhere with a hero shot that already conveys the same thing.
- Marketing fluff. The docs are reference material; every shot you add is a maintenance commitment when the UI changes.
Workflow: add a new screenshot
-
Pick a stable name. Format is {section}/{stem} matching the doc's URL section. Examples: api/listing-settings, data/value-composer/compose-dialog. Stem is kebab-case, descriptive, durable. Don't encode the current name of a UI element if it's likely to change.
-
Decide the shot type before writing anything. The choice drives the spec, the sizing, and the alt text. See Shot types below.
-
Add the directive in the MDX, right where it belongs in the prose flow:
<Screenshot
name="api/playground"
caption="API Playground — testing a Search Listing with filters and context"
alt="API Playground dialog showing request configuration on the left with parameters, filters, and sorting, and JSON response on the right with status and timing badges"
width="640px"
/>
Captions and alt text aren't fluff — captions are visible to readers, alt text is the only description for screen readers and lazy-loaded images. Write both as if explaining the shot to a colleague who can't see it.
-
Write the test in the matching spec file (or create a new one if the section doesn't have one):
test("api/playground", async ({ page, shot }) => {
await page.goto(`/builder/listings/${PRODUCT_LISTING_ID}/query`);
await waitForReady(page);
await page.getByRole("button", { name: /try it/i }).first().click();
const dialog = page.getByRole("dialog");
await dialog.waitFor();
await page.waitForTimeout(800);
await shot(undefined, { clip: dialog });
});
-
Run the capture, scoped to the file you just edited:
pnpm shots --file <path/to/the.mdx>
Scoping with --file runs only the directives in that MDX (skipping the rest of the suite) and re-syncs only that file's Frame blocks. After the run, review both PNGs in the images/ tree.
-
Tune sizing by setting width (and rarely height) on the directive, then re-run with --file to re-sync. No recapture needed — sizing only affects render. See Sizing on the Frame.
Workflow: audit a page or section for missing shots
When the user says "review this section for missing shots" or "what's missing on the api pages" — work top-down:
- List the directives already present in the target file(s):
grep -n '<Screenshot' <file-or-glob>
And the corresponding tests:
grep -n 'test("section/' shots/<section>*.spec.ts
- Walk the rendered page in the live app, section by section. For each h2/h3, ask: would a reader benefit from seeing this? (apply the value filter).
- Cross-check the directives: is each shot still where the prose discusses it? If a section was rewritten and the prose now refers to "the Sort card" but the shot lives 200 lines up, the directive should move.
- Surface a punch list to the user: "section X has a hero already, missing a shot for the Y dialog and the Z detail card. The legacy JPG at /images/.../w.jpg should be replaced with a managed shot."
Don't quietly add a pile of new directives during an audit — propose the punch list, get confirmation, then capture in a single batch (or per-file batches if scope is large).
Workflow: review and improve an existing shot
When the user reports something off ("the playground looks weird", "the dark mode shot is broken", "this shows nothing in the main area"):
- Open both PNG variants (
*-light.png + *-dark.png) and look at them. Most issues are visible at first glance: empty content area, partially-rendered dialog, wrong tab, animation mid-frame.
- Re-run with
--force scoped to that one shot to see if it's flaky:
pnpm shots --force --name <section>/<stem>
- If the captured area is empty: the SPA suspense raced the screenshot. Switch to direct nav (see Navigation patterns).
- If the dialog is clipped before it finishes animating: wait for inner content (a textbox, a heading) before clipping, not just
dialog.waitFor(). See Common races.
- If the shot looks fine but renders too large in the doc: size it on the Frame via the
width attribute. Don't shrink the capture itself.
- If captions or alt are wrong: fix the directive and re-sync (
pnpm shots --file <path> — no recapture).
- For UI changes (the page layout was redesigned): re-capture with
--force --name, then visually verify both PNGs.
Shot types
Pick the type before writing the spec — the type determines the clip strategy and the sizing.
Page shot
Full viewport, no clip. Use for hero shots and any "here's the whole page" reference.
test("section/page-overview", async ({ page, shot }) => {
await page.goto("/some/route");
await waitForReady(page);
await shot();
});
Sizing: typically no width attribute — let it render full-bleed. If the page is unusually narrow on its native viewport, you can still cap with a width like width="900px" for visual balance against neighbours.
Modal / dialog
Clip to the dialog locator so the shot crops out the dimmed page background.
const dialog = page.getByRole("dialog");
await dialog.waitFor();
await dialog.getByRole("textbox", { name: /name/i }).waitFor();
await shot(undefined, { clip: dialog });
Sizing on the Frame:
- Small create dialogs (one or two fields) —
width="320px" to width="480px".
- Wider playgrounds, debuggers, two-column dialogs —
width="600px" to width="640px". The point is to make these read as modal shots, not as full-page shots.
- For very tall vertical forms, use
height="600px" instead of width.
Detail / card
Clip to a specific card or panel within a larger page. Useful when the prose discusses one card at a time and a full-page shot would dilute the focus.
async function getCard(page, headingText) {
const heading = page.locator(`:text-is("${headingText}")`).first();
await heading.waitFor({ timeout: 15_000 });
return heading.locator(
"xpath=ancestor::*[self::section or self::article or " +
"(self::div and (contains(@class,'rounded') or contains(@class,'card') or contains(@class,'bg-elevated')))][1]",
);
}
test("api/listing-base-query-card", async ({ page, shot }) => {
await openListingQuery(page);
const card = await getCard(page, "Base Query");
await shot(undefined, { clip: card });
});
The :text-is(...) selector is more robust than getByText when card headers are span siblings of icons — getByText can match fuzzy/partial text, while :text-is does an exact match. Walk up to the nearest visual wrapper (section, article, or a div with rounded/card/bg-elevated classes — those tend to be the card containers in modern admin UIs).
Sizing: usually no width needed — the clip already constrains size. If the card is unusually wide (full-width like a stretched table) and dwarfs neighbours, cap with a width.
Cropped section (bounding-box math)
Use when the natural element is too long and the shot only needs the first portion (e.g., a config panel that scrolls below the fold but the only relevant part is at the top).
const panel = page.locator("...").first();
await panel.waitFor();
const trailingMarker = panel.getByText(/last visible thing/i);
const panelBox = await panel.boundingBox();
const trailingBox = await trailingMarker.boundingBox();
if (!panelBox || !trailingBox) throw new Error("missing bounding box");
await shot(undefined, {
clip: {
x: panelBox.x,
y: panelBox.y,
width: panelBox.width,
height: trailingBox.y + trailingBox.height + 16 - panelBox.y,
},
});
This is the escape hatch when a Locator.screenshot() would capture too much. Do this sparingly — element clips are easier to keep correct as the UI evolves.
Navigation patterns
Direct nav over list-then-click
The single biggest source of flakiness is navigating to a list page, clicking into a row, and trying to capture the detail page. The SPA's suspense lifecycle can race the screenshot and capture an empty body even after waitForReady passes.
Always prefer a direct URL when one exists:
await page.goto(`/builder/blocks/${PRODUCT_CARD_BLOCK_ID}/response`);
await waitForReady(page);
await page.goto("/builder/blocks");
await waitForReady(page);
await page.getByText(/Product Card/i).click();
await waitForReady(page);
The pattern: navigate to the target page, perform required actions, wait for everything loaded, then shot. Don't waste time on intermediate pages whose content you don't need.
Stable demo IDs
When a test depends on a specific record (a particular block, listing, storage, integration), hard-code the ID as a top-of-file constant with a descriptive name:
const PRODUCT_CARD_BLOCK_ID = "87c5fb60-5c73-4a18-bc78-7b2cbd6e69ae";
const PRODUCT_LISTING_ID = "04081040-fc90-4b8f-bbed-caead87fb6b6";
Demo records persist across runs; using stable IDs makes the spec deterministic and cheap to debug. If the demo data is cleaned up and an ID disappears, the test will fail loudly — that's the right failure mode.
Tabs
- If a tab has a direct URL (e.g.
/storages/{id}/sync, /builder/listings/{id}/query), nav to it directly. Faster, more reliable.
- If a tab has no URL route, fall back to the
clickTab helper from shots/helpers.ts. It clicks, asserts aria-selected="true", and waits for networkidle.
waitForReady
The fixture's waitForReady(page) waits for: networkidle, fonts ready, no visible spinners or skeletons, and two animation frames. Use it after every nav and after any action that triggers a fetch.
For popovers and other transient UI that races networkidle, pass skipReady: true to shot(...):
await shot(undefined, { skipReady: true });
Sizing on the Frame
The width and height attributes on the <Screenshot> directive cap the rendered image in the docs. They affect display, not the captured PNG.
| Shot type | Recommended width | Notes |
|---|
| Hero / page overview | none (full content width) | Page shots want to read at full bleed |
| Two-column playground / debugger | width="640px" | "In-between" feel — clearly a modal, not a page |
| Standard create/configure dialog | width="480px" | Comfortable for one-column forms |
| Tiny dialog (one field, dropdown menu) | width="320px" | Avoids the dialog dwarfing the prose |
| Tall vertical form | height="600px" (no width) | Constrains height instead of width |
| Full-page configuration screen | none | Same as hero |
| Detail card on a busy page | none (clip already caps it) | Add a width only if it still renders too wide |
Why these work: Mintlify's article body is around 800px wide. Shots wider than that render at body width regardless of width. Caps below body width create visible left/right margin around the image, which signals "this is a modal/detail" instead of "this is the whole page".
Tailwind tokens vs CSS lengths: tokens like md emit a class (max-w-md) and CSS lengths emit inline style={{ maxWidth: ... }}. Either works — prefer the explicit length for non-standard sizes (640px, 560px) since Mintlify doesn't run Tailwind JIT for arbitrary-value classes like max-w-[640px].
When a shot is too small in the doc, you usually want to either remove the width cap entirely or pick a wider one — don't try to upscale the PNG itself.
Common races and how to dodge them
These are the failure modes that have actually bitten real projects. Before debugging from scratch, check whether one of these matches.
Dialog entrance animation captures the top sliver
The dialog wrapper is "visible" before its content is laid out — dialog.waitFor() resolves while the dialog is still expanding, and the screenshot captures only the top edge.
Fix: wait for inner content (a specific input, heading, label) before clipping:
await dialog.waitFor();
await dialog.getByRole("textbox", { name: /storage name/i }).waitFor();
await dialog.getByText(/^Schema Type$/i).waitFor();
await shot(undefined, { clip: dialog });
Alternatively await page.waitForTimeout(400) works as a coarse fallback, but content-based waits are more durable.
Empty body after navigation (SPA suspense race)
You navigated and waitForReady passed, but the captured body is blank. The SPA's content boundary suspense is asynchronous and can race networkidle.
Fix: prefer direct URL navigation (above). If you must navigate via clicks, wait for the specific content you intend to screenshot to appear:
await page.getByText(/\d+\s+Records/i).waitFor();
That's a content-truth wait, not a timing-truth wait, and it's much harder to flake.
Auto-fetched data isn't there yet
Some surfaces (the playground, the value-composer debugger) auto-fetch a sample payload on open. If the screenshot fires before the payload arrives, the dialog renders an empty state.
Fix: poll the dialog's inner text for a marker that proves data arrived, with a generous timeout and a graceful fallback:
async function waitForDebuggerPayload(dialog, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const t = await dialog.innerText().catch(() => "");
if (t.includes('"id"')) return true;
await dialog.page().waitForTimeout(500);
}
return false;
}
Popover lifecycle vs networkidle
The default waitForReady includes networkidle, which can race the popover's mount animation. If you're capturing a popover or floating element, pass skipReady: true:
await shot(undefined, { skipReady: true });
Default 900px viewport too short
Tall forms get clipped at the bottom. Bump the viewport on the spec:
await page.setViewportSize({ width: 1440, height: 1400 });
Do this only when you actually need it — fixed viewport sizes complicate visual diffs.
Quick command reference
The full set is in shots/README.md. The ones used most often during iteration:
pnpm shots --file <path/to/file.mdx>
pnpm shots --force --name section/stem
pnpm shots --force --file <path>
pnpm shots --no-sync
pnpm shots:login
pnpm exec playwright test --ui
Key paths
When in doubt
- Don't edit Frame blocks by hand. Edit the directive, run sync.
- Don't add a shot just because a section feels visually empty. Walk through the value filter first.
- Don't fight a flaky shot with retries. Find the race condition (one of the four above) and fix it deterministically.
- Don't shrink a PNG to make it fit. Cap with
width on the directive.
- Surface a plan before mass changes. Mass-add or mass-recapture only after the user signs off on the punch list.