Queries
A query says which element to act on or measure. Keys combine: sources choose the candidates, filters narrow them, traversal moves from them, and selection picks one.
Key a query on what a person can see. text, heading, role+name and label survive a restyle. A generated class name does not survive the next build.
Sources
| Key | Matches |
|---|---|
css | A CSS selector |
role+name | ARIA role and accessible name |
label | Form control by its label |
placeholder | Input by placeholder text |
testid | data-testid |
text | Element whose trimmed text equals the value |
startsWith | Element whose trimmed text begins with it |
heading | h1–h6 with this exact text |
text and startsWith also narrow a source written alongside them. exact: true makes role+name, label and placeholder match the whole string, case-sensitively.
role, label, placeholder and testid cannot be used inside span: or within:. The browser resolves them before the page is searched, so there is nothing for a nested one to narrow. Use css, text, startsWith or contains there.
Filters
| Key | Keeps elements that… |
|---|---|
contains | contain this text |
containingAll | contain all of these strings |
matching | have text matching this regular expression |
maxChildren / minChildren | have at most / at least this many children |
minWidth maxWidth | are within these widths |
minHeight maxHeight | are within these heights |
narrowerThan / widerThan | are narrower / wider than this |
visible | have a non-zero size |
within | sit inside an already-resolved mark |
Sizes take pixels (400) or viewport units (95vw, 50vh). matching is a regular expression run inside the page, so one that backtracks is bounded by site.timeout rather than hanging the run.
within takes a name, or a query written out. Two kinds of name resolve: clip, which is the region being captured, and any mark declared above this one — marks resolve in the order they are written, so an earlier one can narrow a later one. A name that has not resolved yet is an error rather than a miss.
It belongs to marks, mask and check.ignore, which run after the clip is known. A step cannot use it: setup runs before anything has been measured.
Traversal
| Key | Moves to |
|---|---|
ancestor | The first ancestor matching the filters given |
parent | The parent element |
child: n | The nth child; -1 is the last one |
children | All children |
ancestor climbs from the element's parent — an element is never its own ancestor, so a box that already fits the filters is not the answer. It takes pick: nearest (default) or pick: outermost; outermost keeps climbing while the parent also matches, which is how you reach a modal's card rather than stopping at the heading inside it.
Selection and shape
| Key | Effect |
|---|---|
pick: first | last | smallest | largest | Which candidate to use |
nth: n | The candidate at this position; -1 is the last |
pad: n | Grow the resulting box on all sides |
grow: { top, right, bottom, left } | Grow it on specific sides |
span: [query, query] | The bounding box of several queries |
rect: [x, y, width, height] | A literal box, for recipes with no page |
nth and child count from the end when the number is negative, the way Array.at does: -1 is the last, -2 the one before it. pad and grow add room around a box and cannot be negative.
Two patterns worth knowing
pick: smallest is what makes "the row containing X" work. Every ancestor of the row also contains the text, right up to the body — without it you clip the whole page.
# The smallest box holding both a name and an amount.
clip:
css: 'li, div'
contains: Acme Corp
matching: '\$\d'
maxChildren: 12
pick: smallest ancestor with pick: outermost reaches a card inside an overlay. The width filter is doing real work: it is what separates the dialog's card from the full-screen backdrop behind it, which also contains the heading.
# The card owning a heading, climbed out of its full-screen overlay.
marks:
dialog:
heading: Edit order
ancestor: { narrowerThan: 95vw, pick: outermost } Finders
To reuse a query across recipes, define it under finders in the config and call it by name. $1 and $2 stand for the arguments it is called with. It is worth doing the moment the same shape appears in a third recipe — a project's recipes should read like the product, not like CSS.
finders:
listRow:
css: 'li, div'
contains: $1
matching: '\$\d'
maxChildren: 12
pick: smallest
panel:
heading: $1
ancestor: { narrowerThan: 95vw, pick: outermost } clip: { listRow: Acme Corp }
marks:
dialog: { panel: Edit order }