Docs

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 empty value for a field that visibly shows one;
  • fill fails with not_committed, and the message ends by naming value in a custom parts map;
  • fill fails with timeout, and the message names open: the field has nothing to press;
  • clear fails with not_editable, and the message names clear;
  • 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:

FieldTypeNamesRead by
valueCSS selectorthe element that shows a single-choice field’s chosen valuefields(), fill (to read the choice back), clear
valuesCSS selectoreach element that shows one chosen value of a multiple-choice fieldfields(), fill, clear
multipleCSS selectorthe field, or an ancestor of it, when the field takes several values (it marks one that is still empty)fields(), fill
openCSS selectorwhat to press to open the field, when the field itself is too small to pressfill
clearCSS selectorthe field’s clear buttonclear, and fill replacing a multiple-choice field’s values
unstableIdsRegExpids and test-hook values the page generates afresh on each loadfields(), 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, and tab.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, clear and fields() need what they always need: tabs to read, forms to fill.

For the dropdown in the examples below, these are the parts a map names, and how far the search reaches:

Searched: the field and four ancestorsthe next fieldoutside the box, never searchedopen: div.picker__controlthe wrapper that opens the menuvalue: div.picker__single-valueshows the choice, Portugalthe field: input role=comboboxnever holds the choiceclear: div.picker__clear-indicatorthe clear icon

Using 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:

  1. 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.
  2. 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.
  3. 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:

yesnoyesno: it needs behaviorDo fill, clear and fields() work on the field?Nothing to addDoes the field work normally,with only its parts in non-standard places?A custom parts mapPage 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 mapPage code (tab.evaluate)
What you writeData: CSS selectors and a patternA function
GrantNone of its own: tabs to read, forms to fill, as fill, clear and fields() needpage-js: your code runs as you on the site, and can read its cookies and storage and send requests as you
Where it runsThe SDK’s isolated world only looks your selectors upThe page’s main world, beside the site’s scripts
Entering a valueTrusted input, then the value is read backWhatever your code does, usually setting the value directly, with no read-back
Guarantees keptVerified targets, read-back, the submit guard, sites=, secret redaction, an action log entry per callOne evaluate entry in the action log; none of the form guarantees apply inside it
Check Custom PartsHeld back like any fill, with its parts checkedRuns for real
Offline testsfieldsOf(html, { customParts })Can’t run offline
Approval promptThe grants fill, clear and fields() needpage-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 fill set. fill reads the value back and refuses to submit without submit; 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.