AI agents that can browse the web are only as capable as their ability to complete forms—reliably, correctly, and safely. Yet real-world forms are messy: dynamic validation, masked inputs, datepickers with timezone quirks, infinite/virtualized dropdowns, and multi-step wizards with conditional pages. Naive “fill fields and click submit” strategies break easily.
This article proposes a constraint-driven approach to form handling for AI browser agents. The core idea: treat the form as a constraint satisfaction problem (CSP). Infer strong types for fields from the DOM, JavaScript, and network observations; build a validation dependency graph; solve constraints rather than “try-and-hope”; handle complex widgets through principled adapters; and execute multi-step submissions with idempotent, rollback-safe semantics.
We’ll cover:
- Typed input inference from DOM, ARIA, JS event wiring, and heuristics
- Validation as a dependency graph, including async validators
- Constraint solving with SMT/CSP techniques and generative propose–check loops
- Practical adapters for datepickers, masked inputs, and virtualized lists
- A plan-and-commit execution model for multi-step flows: idempotency, checkpoints, and recovery
- Testing and observability to benchmark reliability
The intended audience is technical: browser automation engineers, RPA builders, and AI agent developers.
1) Why constraint-driven form handling?
Most automation breaks for two reasons:
- It doesn’t know what the form expects. Types, formats, derived fields, and conditional requirements are implicit.
- It retries the same wrong move. Without a model of constraints, it can’t converge to a solution.
A constraint-driven approach flips the sequence:
- Model: Infer types and constraints from the page and its behavior.
- Solve: Satisfy all constraints in the cheapest possible way before submission.
- Execute: Apply a deterministic, observable plan, and verify server acceptance.
This mirrors how robust systems are built elsewhere (e.g., compilers, theorem provers, distributed transactions). Forms are small CSPs with a UI wrapper. If your agent builds an internal model and solves it, success rates jump from brittle heuristics to principled reliability.
2) Typed input inference from DOM and JavaScript
Type inference is your foundation. You need a schema that’s richer than HTML’s type attribute. Consider combining:
- HTML semantics:
<input type="email|tel|url|number|date|datetime-local|time|checkbox|radio|file|range"><select>,<textarea>,contenteditable- Attributes:
required,min,max,step,maxlength,pattern,autocomplete,list/datalist
- Labeling and accessibility:
<label for=...>andaria-labelledby,aria-describedby- Autocomplete tokens (e.g.,
address-line1,postal-code,cc-number) - Role semantics (e.g., role="combobox", role="grid" for datepickers)
- Heuristics on names/ids/labels/placeholders:
- Regex-based hinting: “zip”, “postal”, “dob”, “VAT”, ISO country codes, currency symbols
- Unit hints in labels (“kg”, “USD”, “%”)
- Behavioral and JS introspection:
- Listeners:
addEventListener('input'|'change'|'blur')capture—identify masked inputs, on-blur validators, conditional fields. - React/Vue/Angular controlled inputs: detect synthetic events and value control.
- Listeners:
- Network observations:
- Intercept
fetch/XHR in a sandbox to observe validation endpoints (e.g., “/api/validate/email”). Map which fields affect server responses.
- Intercept
The result is a FieldSpec: domain type (Email, Phone, Date, Money, Integer, Float, Enum, FreeText, Boolean, File, etc.), constraints, and UI control hints.
Example FieldSpec structure:
tsexport type PrimitiveType = | 'string' | 'email' | 'tel' | 'url' | 'integer' | 'float' | 'date' | 'time' | 'datetime' | 'boolean' | 'money' | 'file' | 'enum' | 'multienum' | 'address' | 'richtext'; export type Constraint = | { kind: 'Required' } | { kind: 'Regex'; pattern: RegExp } | { kind: 'Min'; value: number | Date } | { kind: 'Max'; value: number | Date } | { kind: 'Step'; value: number | string } | { kind: 'LengthRange'; min?: number; max?: number } | { kind: 'OneOf'; values: string[] } | { kind: 'DependsOn'; field: string; predicate: any } | { kind: 'Async'; id: string; description: string }; export interface FieldSpec { name: string; locator: () => HTMLElement; // robust lookup primitive: PrimitiveType; constraints: Constraint[]; uiHints?: { masked?: boolean; datepicker?: 'native' | 'flatpickr' | 'mui' | 'unknown'; virtualizedList?: boolean; }; }
A basic inference function combining DOM and heuristics:
tsfunction inferFieldSpec(el: HTMLElement): FieldSpec | null { if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)) return null; const name = el.getAttribute('name') || el.id || computeStableName(el); const constraints: Constraint[] = []; const addIf = (cond: boolean, c: Constraint) => { if (cond) constraints.push(c); }; // Required addIf((el as HTMLInputElement).required === true, { kind: 'Required' }); // Length and pattern const maxlength = (el as HTMLInputElement).maxLength; addIf(Number.isFinite(maxlength) && maxlength > 0, { kind: 'LengthRange', max: maxlength }); const pattern = (el as HTMLInputElement).pattern; addIf(!!pattern, { kind: 'Regex', pattern: new RegExp(pattern) }); // Numeric/date constraints const min = (el as HTMLInputElement).min; const max = (el as HTMLInputElement).max; const step = (el as HTMLInputElement).step; if (min) constraints.push({ kind: 'Min', value: parseMaybeDateOrNumber(el.type, min) as any }); if (max) constraints.push({ kind: 'Max', value: parseMaybeDateOrNumber(el.type, max) as any }); if (step && step !== 'any') constraints.push({ kind: 'Step', value: parseFloat(step) }); // Primitive type inference const t = (el as HTMLInputElement).type || (el instanceof HTMLSelectElement ? 'select' : 'text'); let primitive: PrimitiveType = 'string'; switch (t) { case 'email': primitive = 'email'; break; case 'tel': primitive = 'tel'; break; case 'url': primitive = 'url'; break; case 'number': primitive = 'float'; break; case 'date': primitive = 'date'; break; case 'time': primitive = 'time'; break; case 'datetime-local': primitive = 'datetime'; break; case 'checkbox': primitive = 'boolean'; break; case 'file': primitive = 'file'; break; default: if (el instanceof HTMLSelectElement) { const isMulti = el.multiple; const values = Array.from(el.options).map(o => o.value); constraints.push({ kind: 'OneOf', values }); primitive = isMulti ? 'multienum' : 'enum'; } else { // Heuristics on labels/placeholders const labelText = inferLabelText(el).toLowerCase(); if (/e-?mail/.test(labelText)) primitive = 'email'; else if (/(phone|tel|mobile)/.test(labelText)) primitive = 'tel'; else if (/(amount|price|usd|eur|\$)/.test(labelText)) primitive = 'money'; else if (/(zip|postal)/.test(labelText)) primitive = 'string'; } } // UI hints: try to detect known libraries const uiHints: FieldSpec['uiHints'] = {}; if (isDatepickerOverlay(el)) uiHints.datepicker = detectDatepickerBrand(el); if (isMaskedInput(el)) uiHints.masked = true; if (isVirtualizedCombobox(el)) uiHints.virtualizedList = true; return { name, locator: () => el, primitive, constraints, uiHints }; }
Two advanced tricks substantially improve inference quality:
- Monkey-patch
EventTarget.prototype.addEventListenerearly to record which fields haveinput/blurhandlers and whether handlers transform values (masks) or call validators. You’re not bypassing anything; you’re observing to model constraints. - Wrap
window.fetchandXMLHttpRequestto map async validators: which fields, when changed, cause calls like/validate/username. Associate response status codes and messages to constraints (e.g., uniqueness).
Caveat: Shadow DOM can obscure internals. With shadowRoot (open), you can traverse and infer; with closed shadow roots, stick to surface-level attributes and ARIA semantics, and rely on the control’s public interaction model.
3) Build a validation dependency graph
Once you’ve got FieldSpecs, construct a dependency graph to represent constraints and their relationships:
- Nodes:
- FieldVar(name, domain): value variables for each input
- DerivedVar(name, expr): computed fields (e.g., total = subtotal + tax)
- Constraint nodes: required, range, regex, cross-field constraints (start_date <= end_date)
- Async validators: references to endpoints or side-effects
- Edges:
- depends(FieldVar A -> Constraint C) if C references A
- triggers(Constraint C -> Async V) if satisfying C requires an async validator call
A simple representation:
tsinterface VarNode { kind: 'var'; name: string; domain: any } interface ConstraintNode { kind: 'constraint'; id: string; expr: any; strength?: 'hard'|'soft' } interface AsyncNode { kind: 'async'; id: string; dependsOn: string[]; endpoint: string } type Node = VarNode | ConstraintNode | AsyncNode; interface Edge { from: string; to: string; kind: 'depends' | 'triggers' } interface ValidationGraph { nodes: Map<string, Node>; edges: Edge[] }
Data sources for the graph:
- HTML constraints become hard constraints.
- Labels/placeholders may imply soft constraints (preferred formats) if HTML lacks precision.
- Conditional UI implies dependency constraints: “If ‘Business’ is selected, then ‘Tax ID’ is required.” You can infer this from visibility toggles, conditional validators, or text such as “required if business account”.
- Async endpoints (e.g., email verification, zip-to-city validation) become AsyncNodes.
This graph does triple duty:
- Ordering: topologically sort constraints to plan an interaction order (e.g., select country before state/province).
- Solving: feed constraints into CSP/SMT solver or propose–check engine.
- Observability: On failure, report which constraint couldn’t be satisfied and why.
4) Solve constraints, don’t guess
Approach the fill-in as a CSP. For many forms, a light SMT solver combined with a generative fallback suffices.
- Numeric/date/time: treat with SMT/interval arithmetic.
min,max,stepare exact. - Regex constraints: solve with string-generation heuristics or libraries; if too complex, apply propose–check with a generator seeded by examples.
- Enumerations: exact by set membership.
- Cross-field constraints: encode relational constraints (<=, !=, etc.).
- Soft constraints: optimize (e.g., pick earliest allowed date) or degrade gracefully.
Example using Z3 (via z3-solver in Node.js) for a partial form:
tsimport { Context } from 'z3-solver'; async function solveNumericAndDateConstraints(specs: FieldSpec[]) { const { Z3 } = await Context('main'); const solver = new Z3.Solver(); const vars: Record<string, any> = {}; for (const f of specs) { if (f.primitive === 'integer' || f.primitive === 'float') { vars[f.name] = Z3.Real.const(f.name); for (const c of f.constraints) { if (c.kind === 'Min') solver.add(vars[f.name].ge(Z3.Real.val(String(c.value)))); if (c.kind === 'Max') solver.add(vars[f.name].le(Z3.Real.val(String(c.value)))); if (c.kind === 'Step' && typeof c.value === 'number') { // Approx: (x - base) % step == 0; choose base=0 for simplicity const k = Z3.Real.const(`${f.name}_k`); solver.add(vars[f.name].eq(Z3.Real.mul(Z3.Real.val(String(c.value)), k))); } } } if (f.primitive === 'date') { // Represent as integer days since epoch vars[f.name] = Z3.Int.const(f.name); for (const c of f.constraints) { if (c.kind === 'Min') solver.add(vars[f.name].ge(Z3.Int.val(dateToDays(c.value as Date)))); if (c.kind === 'Max') solver.add(vars[f.name].le(Z3.Int.val(dateToDays(c.value as Date)))); } } } const status = await solver.check(); if (status !== 'sat') throw new Error('Unsatisfiable constraints'); const model = solver.model(); const solution: Record<string, any> = {}; for (const [name, v] of Object.entries(vars)) { solution[name] = model.get(v)?.toString(); } return solution; }
For strings and regexes, pair SMT with a propose–check loop using property-based testing libraries such as fast-check (JS/TS) or Hypothesis (Python). Example propose–check for a regex and length:
tsimport fc from 'fast-check'; function satisfyString(regex?: RegExp, lenRange?: {min?: number; max?: number}) { const arb = fc.string({ minLength: lenRange?.min ?? 0, maxLength: lenRange?.max ?? 50 }); const filtered = regex ? arb.filter(s => regex.test(s)) : arb; return fc.sample(filtered, 1)[0]; }
Async validators integrate into solving via a plan: propose a candidate, schedule async checks in dependency order, and refine until acceptance or exhaustion. Cache negative results to avoid repeating known-bad candidates (e.g., username taken).
Key strategies:
- Bound domains tightly (e.g., for dates, use [min, max] ∩ [today-100y, today+2y]).
- Prefer canonical examples (e.g., RFC 5322-conformant emails, E.164 phone numbers, Luhn-valid credit cards in safe test modes only, and only with appropriate permissions and compliance).
- Represent soft vs hard: hard constraints must be satisfied; soft constraints guide choices.
- Short-circuit with UI hints: if a masked input for phone enforces “(XXX) XXX-XXXX”, generate to that mask.
5) Taming widgets: datepickers, masked inputs, and virtualized lists
Real-world widgets demand adapter logic—still within the constraint model.
Datepickers
Two interaction modes:
- Programmatic value set on the input element, fired with
input/changeevents matching the library’s expectations. - UI-driven selection (open popover, navigate months/years, click day cell).
Prefer (1) when the library listens to native events. Many do not. Libraries like Material UI, Flatpickr, AirBnB react-dates, or custom ARIA grids often require (2). Detect:
- ARIA roles: a calendar popover often exposes
role="dialog"and arole="grid"of days. - Library-specific attributes (e.g.,
data-mui-test,flatpickr-*classes) when present.
Implementation sketch:
tsasync function setDate(el: HTMLInputElement, target: Date, hints?: FieldSpec['uiHints']) { const str = formatDateForInput(el.type, target); // e.g., 'YYYY-MM-DD' if (!hints?.datepicker || hints.datepicker === 'native') { setNativeValue(el, str); // set + dispatch input/change return; } // UI-driven fallback el.click(); const popover = await waitFor(() => document.querySelector('[role=dialog], .flatpickr-calendar')); await navigateCalendarToMonth(popover, target); const dayCell = findDayCell(popover, target); dayCell?.click(); }
Timezone gotchas:
datetime-localvalues are local; server may interpret as UTC or local. Honor min/max with the same basis as the control.- For ranges (start/end), enforce cross-field constraints in the solver, not by trial.
Masked inputs
Libraries (IMask, Cleave.js) enforce formatting as you type. Strategy:
- If possible, set
.valuevia the library’s setter (some exposesetRawValue). - Else, simulate keystrokes that yield the final formatted value, respecting caret movement. For React-controlled inputs, set the value via the property setter on the element’s prototype and dispatch
input.
tsfunction setNativeValue(el: HTMLInputElement, value: string) { const prototype = Object.getPrototypeOf(el); const desc = Object.getOwnPropertyDescriptor(prototype, 'value'); desc?.set?.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }
Virtualized lists and comboboxes
Problem: Only a subset of options exists in the DOM at once. A naive querySelector("li[role=option]") misses off-screen items.
Strategy:
- Detect combobox pattern: input with
role="combobox"+ popuprole="listbox". - Use the filter input if available: type the desired label and select the first matching option.
- Otherwise, scroll the virtualized list to synthesize more items. Use the list’s scroll container and watch for sentinel elements or changes in option text.
- Prefer the underlying hidden input/state update (some frameworks sync a hidden
<input>with the value). If present, set it and dispatchchangeon the visible control to keep the app state consistent.
Sketch:
tsasync function pickFromVirtualizedList(control: HTMLElement, label: string) { openCombobox(control); const list = await waitFor(() => document.querySelector('[role=listbox]')) as HTMLElement; const input = control.matches('[role=combobox]') ? control as HTMLInputElement : control.querySelector('input'); if (input) { setNativeValue(input, label); await delay(50); } for (let i = 0; i < 50; i++) { // bounded attempts const option = Array.from(list.querySelectorAll('[role=option]')).find(o => norm(o.textContent) === norm(label)); if (option) { (option as HTMLElement).click(); return; } list.scrollTop += 200; await delay(30); } throw new Error(`Option not found: ${label}`); }
Accessibility cues (ARIA) are your friends here—it’s more robust than class names.
6) Plan and execute multi-step submissions safely
Single-page forms are the warm-up. Production flows are wizards: step 1/5, progress bars, and conditional detours. Your agent needs state, idempotency, and rollback.
Model the flow as a state machine
Represent steps and transitions explicitly:
- States: pages/steps identified via headings, breadcrumbs, or network routes.
- Transitions: next/back buttons, conditional branches.
- Guards: constraints that unlock transitions (e.g., “next” only enabled when all required fields satisfied).
tsinterface Step { id: string; fields: FieldSpec[]; next?: (ctx: any) => string | null; // decide next step id } interface Plan { steps: Step[]; transitions: Record<string, string[]>; // adjacency }
Build per-step constraint graphs and solve before moving forward. Persist a checkpoint after each successful transition.
Idempotency: exactly-once semantics for submissions
Client-side automation can’t force server idempotency, but it can approximate it:
- Prefer flows with server-provided idempotency (e.g., Stripe PaymentIntents, checkout sessions). Use one intent per logical transaction, reuse on retries.
- Where no explicit support exists, create a client-side idempotency key (UUID) and store it in localStorage/session storage. If there’s a free-form “reference” or “notes” field, include it (only with permission) to enable dedup detection. Do not disrupt user data—keep keys clearly marked as client identifiers.
- Before final submission, perform a read-check if the system exposes a listing or confirmation endpoint to see whether an identical submission already exists.
- Implement completion detection using network intercepts and DOM verification (e.g., a success route + confirmation number pattern). If confirmation exists, mark the transaction committed and do not retry.
Retry policy:
- If the final step fails with a transient network error and there’s no confirmation, retry using the same idempotency key and the same inputs.
- If you detect a server error indicating a duplicate, treat the prior attempt as successful if there’s sufficient evidence (e.g., a returned resource id).
Rollback and recovery
If a multi-step flow partially commits (e.g., account created at step 3, profile failed at step 4), avoid creating zombies. Techniques:
- Snapshot checkpoints: after each step, persist cookies, localStorage, and the solved field assignments. On crash, restore and resume.
- Compensation: if the site provides a “Cancel” or “Delete draft” action, invoke it on failure (with caution and only when safe/allowed).
- Deterministic replays: store the full action log (element locators, inputs, timestamps). Replaying the exact plan reduces divergence on retries.
Transaction templates
- Intent-based template:
- Create Intent (server allocates id) →
- Fill details referencing intent id →
- Confirm →
- Poll for success.
- Draft-based template:
- Start draft (server saves partial) →
- Update draft across steps →
- Finalize or discard.
Detecting these from the DOM/network helps your agent adapt its idempotency strategy.
7) End-to-end execution pipeline
Putting it together:
- Discovery
- Collect candidate fields and controls.
- Infer FieldSpecs with constraints and UI hints.
- Observe validators (DOM + network).
- Graph build
- Construct validation dependency graph.
- Topologically order variables and constraints.
- Solve per step
- Use SMT for numeric/date; propose–check for strings/regex/masks.
- Integrate async validators, caching negative results.
- Interact
- For each field, choose the least brittle adapter (native set vs UI-driven), emit events as the app expects.
- Respect layout and visibility—scroll into view, handle sticky headers.
- Verify
- Wait for UI to reflect acceptance (no inline errors, next enabled).
- Intercept network to ensure the right calls occurred.
- Commit and checkpoint
- After each step, persist plan state, cookies, storage, and observed ids.
- Finalize with idempotency
- Attach/record idempotency key where possible.
- On success, capture confirmation artifacts (ids, receipts).
8) Practical code skeleton (TypeScript/Playwright)
Below is a simplified skeleton wiring the concepts (omitting error handling for brevity):
tsimport { chromium, Page } from 'playwright'; import { Context } from 'z3-solver'; interface RunContext { page: Page; idempotencyKey: string; checkpoints: any[] } async function runForm(url: string) { const browser = await chromium.launch(); const page = await browser.newPage(); const ctx: RunContext = { page, idempotencyKey: crypto.randomUUID(), checkpoints: [] }; await instrumentPage(ctx.page); // patch addEventListener, fetch/xhr await page.goto(url); for await (const step of detectSteps(page)) { const fields = discoverFields(step.root); const specs = fields.map(inferFieldSpec).filter(Boolean) as FieldSpec[]; const assignments = await solveStep(specs); await applyAssignments(page, specs, assignments); await verifyNoInlineErrors(step.root); await checkpoint(ctx, { step: step.id, specs, assignments }); await goNext(step); } const success = await detectSuccess(page); if (success) await persistReceipt({ idempotencyKey: ctx.idempotencyKey, success }); await browser.close(); } async function solveStep(specs: FieldSpec[]) { // Hybrid solving: SMT for numeric/date; propose–check for strings const numericDates = specs.filter(s => ['integer','float','date','time','datetime'].includes(s.primitive)); const ndSolution = await solveNumericAndDateConstraints(numericDates); const others: Record<string, any> = {}; for (const s of specs) { if (ndSolution[s.name] != null) continue; others[s.name] = proposeCheckString(s); } return { ...ndSolution, ...others }; }
This is intentionally schematic. Production code will include robust selectors, race handling, backoff, retries, and strong telemetry.
9) Observability: measure success, don’t guess
You can’t improve what you don’t measure. Build:
- A corpus of public demo forms and controlled test apps covering edge cases: required/optional, min/max/step, regex masks, nested conditionals, async uniqueness checks, multi-step flows, date/time zones.
- Metrics: field coverage, first-pass solve rate, number of backtracks, async validation calls, time-to-submit, and confirmation rate.
- Artifacts: DOM snapshots pre/post, network logs, and the validation graph (export as DOT/JSON for debugging). Visualize which constraints fail most often.
- Property-based fuzzers feeding your solver varied inputs to stress-test inference and adapters.
Tools and references:
- HTML form validation (WHATWG / MDN)
- ARIA Authoring Practices for comboboxes and grids
- z3-solver (SMT), fast-check (property-based testing)
- Playwright/puppeteer for automation harness
10) Security, ethics, and compliance
- Respect site Terms of Service and robots policies. Do not automate where disallowed.
- Never attempt to bypass security controls (e.g., CAPTCHAs) or rate limits. If a form includes such controls, obtain explicit permission and use approved integrations.
- Protect PII and secrets. Use encrypted storage, data minimization, and test accounts where possible.
- For payments or sensitive operations, use sandbox/test modes and provider-issued test credentials. Prefer intent-based flows with explicit idempotency.
These guardrails are essential to keep automation responsible and sustainable.
11) Performance engineering: fast, not fragile
- Cache inference results per origin (field names, constraints, widget types) with versioning keyed by DOM signatures.
- Incremental graph updates: when a user selection reveals new fields, extend the graph rather than rebuilding everything.
- Debounce async validators during solving by staging updates logically—don’t trigger a server call per keypress if a final value is known.
- Parallelize independent validators where safe; respect server load and backoff headers.
- Use a tiered timeout strategy: fail fast on unsatisfiable branches, be patient when servers are slow.
12) A quick checklist for production readiness
- Field inference
- Parse HTML attributes; resolve labels and ARIA.
- Detect masks, datepickers, and virtualized lists.
- Capture event listeners and network validators.
- Validation graph
- Build nodes/edges; mark hard vs soft constraints.
- Topo-order for planning; export graph for debugging.
- Solver
- SMT for numeric/date; propose–check for strings/regex.
- Async validator integration with caching.
- Cross-field constraints (ranges, dependencies).
- Widget adapters
- Datepicker UI flow; masked input caret handling; virtualized combobox search/scroll.
- Execution plan
- Deterministic order; graceful scrolling; robust event dispatch.
- Multi-step
- Step detection; checkpoints; idempotency keys; confirmation detection.
- Recovery and compensation strategies.
- Observability
- Metrics, logs, replays, and graph snapshots.
- Compliance and safety
- Honor ToS; no security bypassing; protect PII; test creds for sensitive ops.
Conclusion
Constraint-driven form handling turns AI browser agents from hopeful clickers into dependable operators. By inferring strong types, modeling validations as a dependency graph, and solving constraints before touching the DOM, you remove guesswork. Widget-specific adapters tame datepickers, masked inputs, and virtualized lists without special-casing every site. Finally, a plan-and-commit approach with idempotency and rollback ensures multi-step submissions are safe to retry and easy to recover.
This isn’t heavier engineering for its own sake. It’s the shortest path to reliability at scale. When your agent can explain why it chose each value and prove every constraint is satisfied, you’ll see success rates climb and troubleshooting time fall. Treat forms as CSPs, make the solution explicit, and let your AI browser agent nail them—every time.
Selected references
- WHATWG HTML Standard—Form validation: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constraints
- MDN Client-side form validation: https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation
- WAI-ARIA Authoring Practices—Combobox, Grid, Dialog: https://www.w3.org/WAI/ARIA/apg/
- z3-solver (Node.js): https://www.npmjs.com/package/z3-solver
- fast-check (JS/TS property-based testing): https://github.com/dubzzz/fast-check
- Hypothesis (Python property-based testing): https://hypothesis.readthedocs.io/
