Skip to content

Queries that survive a redesign

A recipe is only as durable as the queries in it. This page explains why shotlist offers the keys it does, and what makes one query outlive a redesign while another breaks on the next build.

Two kinds of stability

Everything a query can key on falls into one of two groups.

The first is implementation detail: class names, DOM position, element nesting. These change whenever somebody refactors a component, and they change silently — nobody renaming a CSS module thinks of it as an interface.

screenshots/recipes/order-row.yaml
marks:
  amount: { css: '.css-1x7k9d > div:nth-child(3) > span' }

The second is what a person can see: visible text, headings, accessible names, labels. These change too, but they change deliberately, as a product decision, and the change is visible in review. A query keyed on them breaks only when the thing it points at genuinely changed.

screenshots/recipes/order-row.yaml
marks:
  amount: { within: clip, text: $42.00 }

This is the same reasoning behind accessible test selectors, and it applies more strongly here: the screenshot is a picture of what a person sees, so keying the query on what a person sees keeps the recipe and the image describing the same thing.

Why filters exist

Visible text is not selective enough on its own. Every ancestor of a row also contains the row's text, right up to the document body, so a query for "the element containing Acme Corp" matches dozens of elements, the largest of which is the whole page.

That is what the filters are for. They let you describe the element the way a person would describe it — the smallest box holding both a name and an amount — rather than by where it sits in the tree:

screenshots/recipes/order-row.yaml
clip:
  css: 'li, div'
  contains: Acme Corp
  matching: '\$\d'
  maxChildren: 12
  pick: smallest

pick: smallest is doing the essential work. Without it you capture the whole page. Size and child-count filters do the rest: a row has few children and contains a currency figure, and that description survives a restyle because it is about the shape of the content rather than the shape of the markup.

Why traversal is limited

ancestor exists because the element you can name is often not the element you want to photograph. You can name a dialog's heading; you want the card around it. Climbing is the reliable way to get from one to the other.

ancestor takes filters for the same reason queries do. In an overlay, the backdrop also contains the heading, so climbing without a constraint reaches a full-screen element rather than the card. A width filter distinguishes them, and it does so by a property the design actually has, rather than by counting levels of nesting, which no design guarantees.

There is no descendant-by-index traversal beyond child, and that is deliberate: a path of indices is the most fragile thing a query could be keyed on.

Why a failed query stops the run

A query that matches nothing could reasonably be skipped. shotlist stops instead, because a recipe is an assertion: it claims the page contains a row for Acme Corp with an amount in it. If that is no longer true, the screenshot would be of something else, and publishing it silently is worse than publishing nothing.

The practical consequence is that recipes fail loudly when a product changes, which is exactly when you want to hear about a screenshot in your documentation.

When to name a query

A query describing "a row in a list" is not specific to one recipe. Once the same shape appears in a third file, move it into finders and call it by name:

shotlist.config.yaml
finders:
  listRow:
    css: 'li, div'
    contains: $1
    matching: '\$\d'
    maxChildren: 12
    pick: smallest

Beyond removing duplication, this changes what the recipes read like. A file that says { listRow: Acme Corp } describes the product; a file full of CSS describes the markup. When the markup changes, only the finder needs editing.