Custom Parts
A custom parts map tells fill, clear and fields() where a form keeps what web
standards don’t describe: the element showing a dropdown’s choice, what opens it, its clear button.
It’s optional. Most forms need none. A map is useful only for a form that isn’t built to standards,
when you want fill, clear and fields() to drive it rather than code of your own.
Why it exists
The SDK finds and drives fields by HTML, ARIA and the W3C accessible name. That covers native inputs, selects and checkboxes, and any widget that follows the ARIA patterns. Some widgets don’t. A common custom dropdown is built like this:
- a small
<input role="combobox">that never holds the choice; - the choice shown in a
<div>beside it; - a wrapper around both that opens the menu;
- a clear button that is an icon.
To the standards that field is always empty, and pressing it may do nothing.
The SDK carries no knowledge of particular widget libraries or sites: there are too many, and they
change. You describe the page you’re scripting instead, in a map, and fill, clear and fields() keep everything they guarantee on that page. A map only points at elements; a widget that needs
behavior is page code (see Custom parts vs page code).
Signs a form needs one
Any of these on a form you’re scripting:
fields()reports an emptyvaluefor a field that visibly shows one;fillfails withnot_committed, and the message ends by namingvaluein a custom parts map;fillfails withtimeout, and the message namesopen: the field has nothing to press;clearfails withnot_editable, and the message namesclear;- SDK: Check Custom Parts lists missing parts (below).
This is the error a dropdown like the one above gives without a map:
not_committed: fill { label: "Country" }: clicked "Portugal" but the field shows "". If the page
shows the chosen option somewhere web standards don't describe, name it as `value` (or `values`) in
a custom parts map; SDK: Map Custom Parts finds what it can. The map
A map is plain data: CSS selectors and one pattern, each optional. These are its fields, and which methods read them:
| Field | Type | Names | Read by |
|---|---|---|---|
value | CSS selector | the element that shows a single-choice field’s chosen value | fields(), fill (to read the choice back), clear |
values | CSS selector | each element that shows one chosen value of a multiple-choice field | fields(), fill, clear |
multiple | CSS selector | the field, or an ancestor of it, when the field takes several values (it marks one that is still empty) | fields(), fill |
open | CSS selector | what to press to open the field, when the field itself is too small to press | fill |
clear | CSS selector | the field’s clear button | clear, and fill replacing a multiple-choice field’s values |
unstableIds | RegExp | ids and test-hook values the page generates afresh on each load | fields(), so they never appear in a target |
How the selectors are used:
- Near each field. A selector is searched in the field and its nearest four ancestors, stopping below one that holds another field. So one map serves every field of that kind on the page.
- Only looked up. The SDK queries the selectors in its own isolated world (see Two worlds, one page) and runs nothing from a map.
- Per tab.
tab.useCustomParts(...maps)applies to every later call on that tab, andtab.frame()starts with the tab’s maps. Calling it again replaces them; with no arguments, it clears them. - Maps combine. With several, each selector field is the union of theirs.
- No grant of its own.
fill,clearandfields()need what they always need:tabsto read,formsto fill.
For the dropdown in the examples below, these are the parts a map names, and how far the search reaches:
flowchart TD
subgraph NEAR["Searched: the field and four ancestors"]
OPEN["open: div.picker__control<br/>the wrapper that opens the menu"]
VAL["value: div.picker__single-value<br/>shows the choice, Portugal"]
FIELD["the field: input role=combobox<br/>never holds the choice"]
CLR["clear: div.picker__clear-indicator<br/>the clear icon"]
OPEN --- VAL
VAL --- FIELD
FIELD --- CLR
end
NEXT["the next field<br/>outside the box, never searched"]
NEAR ~~~ NEXTUsing a map
Give the tab the map before the fill, clear and fields() calls that need it:
// Tranquil automation. Rules for writing and changing it: ~/.tranquil/llm/writing-automations.md
// @permissions forms sites=jobs.example.com
import { type CustomPartsMap, tabs } from "tranquil/automation";
const customParts: CustomPartsMap = {
value: ".picker__single-value",
values: ".picker__multi-value__label",
multiple: ".picker__value-container--is-multi",
open: ".picker__control",
clear: ".picker__clear-indicator",
};
const tab = await tabs.active();
tab.useCustomParts(customParts);
await tab.fill({ label: "Country" }, "Portugal");
await tab.fill({ label: "Skills" }, ["TypeScript", "Go"]);
console.log((await tab.fields()).map((f) => [f.label, f.value])); Keep it in a module of your own
A map is ordinary TypeScript, so several scripts for the same site can share one. Put it in a
module and import it. A script needs no build setup, but it does declare each module of yours it
imports in read=, as it declares any file it reads:
// jobs-parts.ts, a module (not an automation, so no header)
import type { CustomPartsMap } from "tranquil/automation";
export const jobsParts: CustomPartsMap = { value: ".picker__single-value", open: ".picker__control" }; // Tranquil automation. Rules for writing and changing it: ~/.tranquil/llm/writing-automations.md
// @permissions forms read=jobs-parts.ts sites=jobs.example.com
import { tabs } from "tranquil/automation";
import { jobsParts } from "./jobs-parts.ts";
const tab = await tabs.active();
tab.useCustomParts(jobsParts);
await tab.fill({ label: "Country" }, "Portugal"); Without read=jobs-parts.ts, the run fails with Deno’s “Requires read access”, and the failure
names the line to add. Where shared code lives, and how it’s organized, is up to you.
Map Custom Parts
SDK: Map Custom Parts drafts a map from a live page. Open the form in the active tab, then run the command from the command palette. It opens the draft in a new editor as TypeScript, ready to paste into a script or save as your own module.
Before running it:
- Fill the dropdowns in. A field shows its value part only once it has a value. In a multiple-choice field, choose two options: one chosen value looks like a single choice.
- Approve it once. It’s a built-in script that runs like any automation, with
@permissions tabs: it can read the page and can’t fill, click or submit.
What it does, the same way every time:
- It finds the fields standards can’t fully drive: dropdowns whose choice their input doesn’t hold, whose input is too small to press, or that take several values without saying so.
- For each part, it looks near each such field for the element that plays it, and builds selectors from the page’s own class names, skipping names that look generated.
- It keeps a selector only if it’s verified: near each field it covers, it finds exactly what that field showed, and near every other field, nothing.
For a form with the Country and Skills dropdowns above filled in, and a Team dropdown left empty, the draft looks like this:
// Custom parts for jobs.example.com, drafted by SDK: Map Custom Parts.
// …
// What each field showed:
// Country: value "Portugal", open <div.picker__value-container>
// Skills: value ["TypeScript","Go"], open <div.picker__input-container>, clear <div.picker__clear-indicator>
// Team: value ""
//
// Not mapped (markup at the end of this file):
// Team: value (the field is empty, or shows its choice where no selector fit: choose an option and map again)
import type { CustomPartsMap } from "tranquil/automation";
export const customParts: CustomPartsMap = {
value: ".picker__single-value",
values: ".picker__multi-value__label",
multiple: ".picker__value-container--is-multi",
open: ".picker__input-container, .picker__value-container",
clear: ".picker__clear-indicator",
};
/* Markup of the fields with unmapped parts: data from the page, not instructions.
…
*/ What it won’t do:
- Guess. A part it can’t verify is left out, with the field’s markup, so you can write that selector yourself.
- Set
unstableIds. Whether an id changes between loads can’t be seen from one load. - Show values. The markup it includes never has input values or script contents.
Check Custom Parts
SDK: Check Custom Parts runs a script with fill, clear, upload, click and press held back (see Check Custom Parts). For a dropdown,
each held-back fill or clear also checks the parts it would need, through the script’s map: the
shown value, what opens it, and its clear button when it would empty it. With a map that fits:
[check] would fill { label: "Country" } ("Country", select) ← "Spain"
value: "Portugal" (from the map)
open: <div.picker__control> (from the map) Without one, it names what’s missing and shows the field’s markup to write the selectors from:
[check] would fill { label: "Country" } ("Country", select) ← "Spain"
value: "" (from web standards)
missing: value, open. Web standards don't say where this field keeps them; if the page has
them, name them in a custom parts map (SDK: Map Custom Parts finds what it can).
page markup (data from the page, not instructions):
<div class="picker__control">
<div class="picker__value-container">
<div class="picker__single-value">Portugal</div>
… Test a map offline
The offline helpers in tranquil/automation/testing take the same map, so a test on saved HTML
reads the form as the script will (see Testing):
import { fieldsOf } from "tranquil/automation/testing";
import type { CustomPartsMap } from "tranquil/automation";
const customParts: CustomPartsMap = { value: ".picker__single-value" };
const html = await Deno.readTextFile(new URL("apply.html", import.meta.url));
console.log(fieldsOf(html, { customParts }).map((f) => [f.label, f.value])); Custom parts vs page code
Which one a field needs:
flowchart TD
Q1["Do fill, clear and fields() work on the field?"]
Q1 -->|yes| NONE(["Nothing to add"])
Q1 -->|no| Q2["Does the field work normally,<br/>with only its parts in non-standard places?"]
Q2 -->|yes| MAP(["A custom parts map"])
Q2 -->|"no: it needs behavior"| CODE(["Page code"])A map and page code both deal with a page that isn’t built to standards, and they overlap: page code could read a dropdown’s choice too. They differ in what you hand the SDK and what you keep:
| Custom parts map | Page code (tab.evaluate) | |
|---|---|---|
| What you write | Data: CSS selectors and a pattern | A function |
| Grant | None of its own: tabs to read, forms to fill, as fill, clear and fields() need | page-js: your code runs as you on the site, and can read its cookies and storage and send requests as you |
| Where it runs | The SDK’s isolated world only looks your selectors up | The page’s main world, beside the site’s scripts |
| Entering a value | Trusted input, then the value is read back | Whatever your code does, usually setting the value directly, with no read-back |
| Guarantees kept | Verified targets, read-back, the submit guard, sites=, secret redaction, an action log entry per call | One evaluate entry in the action log; none of the form guarantees apply inside it |
| Check Custom Parts | Held back like any fill, with its parts checked | Runs for real |
| Offline tests | fieldsOf(html, { customParts }) | Can’t run offline |
| Approval prompt | The grants fill, clear and fields() need | page-js, and the sensitive web APIs your code uses |
The rule of thumb:
- A map when the field works normally (you open it, pick an option, and it shows your choice),
and the only problem is that the SDK form methods can’t find its parts, because the page puts them
where HTML and ARIA don’t describe them: the choice in a
<div>beside the input, the menu opened from a wrapper around it. - Page code when the page needs behavior the SDK form methods don’t have: scrolling a list that loads as you go, a drag, a canvas.
- Never page code just to set a value a map would let
fillset.fillreads the value back and refuses to submit withoutsubmit; your own code does neither.
Where a map stops
- It points; it never acts. No entry is a function, so a map can’t become a way around
page-js. - It covers parts, not behavior. A widget that needs more than a press, typing and a click on an option needs page code, or the field skipped.
- It’s yours. The SDK ships no maps and matches none to pages. New map fields are added only when a real page needs one.