Design & UX No. 355

Form UX Design skill for Claude Code

Fewer fields, clearer errors, more finished forms. Claude designs the form before anyone codes it. Every field must justify itself or get cut, deferred or inferred. It then sets layout, controls, input types and autocomplete tokens, validation timing, error placement and wording, and decides between one page and steps with a check-answers page. A bundled auditor flags missing labels, autofill gaps, number-type misuse and blocked paste in rendered HTML.

  • nameform-ux-design
  • size240 lines · ≈3,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs forms people finish — cutting fields with a question protocol, single-column layout with top-aligned labels, optional-not-required marking, input types, inputmode and autocomplete tokens, inline validation timing, error message placement and an error summary, one-page versus multi-step wizards with progress and a check-answers page, mobile keyboards and touch targets, and WCAG 2.2 form criteria. Ships an HTML form auditor. Use when designing or reviewing a sign-up, checkout, onboarding, application or settings form, when completion or conversion is low, when users hit errors repeatedly, or when deciding between a wizard and a single page.

Try asking

  • Our sign-up form converts badly. Review it and tell me what to cut and change
  • Design the checkout flow: one page or steps, and which fields we actually need
  • Write the field spec for this insurance application form, including all error messages

Use it when

  • Designing or reviewing sign-up, checkout, onboarding, application or settings forms
  • Form completion is low or users hit the same errors repeatedly
  • Deciding between a single page and a multi-step wizard
  • Autofill, mobile keyboards or error messages behave badly

Skip it when

  • You need schemas, server validation and form library wiring — use forms-validation instead
  • You are writing interface copy across the whole product — use ux-microcopy instead

How to check the result

The audit script reports no errors on the rendered form, browser autofill fills every known field in one action, and an empty submit followed by fixing one field at a time shows, links and clears each error without losing input.

The common mistake it prevents

Polishing the layout and error styling of fields that should not be asked at all; run the question protocol before designing anything.

~/.claude/skills/form-ux-design/SKILL.md

--- name: form-ux-design description: Designs forms people finish — cutting fields with a question protocol, single-column layout with top-aligned labels, optional-not-required… license: MIT ---

A good form asks only what is needed, in the order people expect, in a way their browser can fill for them, and tells them exactly how to fix a mistake without losing what they typed. Done well means: every field has a written reason to exist, the form scans in one column, autofill completes the known fields, errors appear at the right moment in the right place, and completion is measured per field.

This skill owns design decisions. Implementation (schemas, server validation, React Hook Form) belongs to forms-validation; product-wide wording belongs to ux-microcopy.

Workflow

- [ ] 1. Inventory fields and run the question protocol; cut, defer or infer
- [ ] 2. Decide structure: single page or steps; order and grouping
- [ ] 3. Lay out: one column, top labels, hints, field widths, one primary action
- [ ] 4. Specify each field: control, type, inputmode, autocomplete, optional marking
- [ ] 5. Specify validation timing and every error message
- [ ] 6. Check mobile and accessibility rules
- [ ] 7. Audit the built HTML (scripts/audit_form.py) and test with real users or analytics

1. Question protocol (cut fields first)

For every field, answer in the field spec:

QuestionIf the answer is weak
Who uses this answer, and for what?Remove the field
What happens if we do not ask?Remove or make optional
Is it needed now, or later?Defer to after sign-up or to the moment it is needed
Can we infer, look up, or default it?Infer (country from locale as a default, card type from number) — never force the inferred value
Is the answer reliable when asked this way?Change the question (free text instead of a guessed list)
  • Ask one full-name field instead of first and last unless a system truly needs them split; many names do not fit a first/last model.
  • Remove confirmation fields (confirm email, confirm password); offer show-password and a summary review instead.
  • Never ask for what you already have (WCAG 2.2 SC 3.3.7 Redundant Entry): prefill, or offer “same as shipping address”.

2. Structure: one page or steps

SituationChoose
One topic, fits roughly one or two screens, no branchingSingle page
Distinct topics (account, address, payment) or many fieldsSteps, one topic per step
Branching questions (answers change later questions)Steps; “one thing per page” for complex or infrequent tasks
Expert users repeating the task dailyDenser single page with sections; research before splitting

Multi-step rules:

  • Show progress as text plus step names (“Step 2 of 4: Delivery”). For branching flows, where the count changes, show section names without a fixed count.
  • Name the button “Continue” on intermediate steps and the real action on the last (“Pay £24.00”, “Submit application”).
  • Back keeps every answer. Refresh and returning later keep progress (server draft or storage) for long forms.
  • End with a check-your-answers page listing each answer with a “Change” link that returns to the field and back to the summary.
  • Put the easiest, least sensitive questions first; ask for payment and personal data once the user is committed.

3. Layout

  • One column. Exceptions: short related fields that are read as one unit (city / state / postcode, card expiry and CVC) may share a row on wide screens; stack them on narrow.
  • Labels above fields: fastest to scan, survive long translations and zoom, work on mobile. Left-aligned labels only for dense expert forms on desktop.
  • Hint text sits under the label and above the input, so it is read before typing and is not covered by autofill dropdowns or the keyboard. Put format examples there (“For example, 27 3 2007”).
  • Field width suggests the expected length (postcode short, street long); do not stretch every input to full width on desktop.
  • Group related fields under a heading or fieldset/legend (“Delivery address”).
  • One primary button, left-aligned under the last field, with a verb label. No “Reset” or “Clear” buttons. Secondary actions (Cancel, Save draft) look secondary.
  • Never use the placeholder as the label; it disappears on input and usually fails contrast. Floating labels shrink to small text — prefer a static label.

4. Field specification

Marking: mark optional fields with “(optional)” in the label (in the legend for radio or checkbox groups). Do not rely on asterisks; if most fields are optional, question why the required ones are not the only ones.

Choose the control:

Answer shapeControl
One of 2 to about 6 optionsRadios, all visible (not a select)
One of many known options (country)Select, or a combobox with type-ahead for very long lists
Several of a few optionsCheckboxes in a fieldset
Yes/no consentSingle checkbox with the full statement as the label
Precise numberText input (not a slider)
Memorable date (birth date)Three text inputs: day, month, year
Near-future date (booking)Date picker, with typed entry still allowed
Long free textTextarea sized to the expected length, with a character count if limited

Types, keyboards, and autofill:

FieldMarkup
Emailtype="email" autocomplete="email" autocapitalize="off" spellcheck="false"
Full nametype="text" autocomplete="name" (or given-name / family-name)
Phonetype="tel" autocomplete="tel"; accept spaces, dashes, +; do not force a format
Postal codetype="text" autocomplete="postal-code"; inputmode="numeric" only where codes are digits
Street addressautocomplete="address-line1" / address-line2; lookup plus “Enter address manually”
City / region / countryaddress-level2 / address-level1 / country or country-name
Card numbertype="text" inputmode="numeric" autocomplete="cc-number"; allow spaces
Expiryautocomplete="cc-exp" as one MM/YY field (or cc-exp-month + cc-exp-year)
CVCtype="text" inputmode="numeric" autocomplete="cc-csc"
Sign-up passwordtype="password" autocomplete="new-password" + show-password toggle + visible rules
Sign-inautocomplete="username" (add webauthn for passkeys) and current-password
One-time codetype="text" inputmode="numeric" autocomplete="one-time-code", one input (or split boxes that accept paste of the full code)
Quantity, agetype="text" inputmode="numeric" with validation; type="number" only for true spinner values
  • Never type="number" for codes, card numbers, phone or postcodes: it drops leading zeros, accepts e, and changes on scroll.
  • Separate sections that repeat (shipping vs billing) with shipping / billing prefixes so autofill fills the right one.
  • Use enterkeyhint="next" on intermediate fields of a step and "done" or "go" on the last for a clearer mobile keyboard.

5. Validation timing and errors

Default (“reward early, punish late”):

MomentBehavior
First time typing in a fieldNo error
Leaving a field with a definite problem (bad email format)Show that field’s error
Editing a field that shows an errorRe-check on each change; remove the error as soon as it is fixed
Submit or ContinueValidate all; show error summary; move focus to it
Rules the user must meet while typing (password rules)Neutral live checklist, not red errors
Server checks (username taken)On blur, with a visible “Checking…” state

For short public-sector-style forms, validating only on submit is a valid simpler choice (GOV.UK’s pattern). Never validate on every keystroke from the first character.

Placement and presentation:

  • Error message directly above the input, after the label and hint, so it is visible with the keyboard open and read before the field by screen readers. Keep the same position everywhere.
  • Error summary at the top on submit: heading “There is a problem”, one link per error pointing to the field, same text as the inline message. Prefix the page <title> with “Error: ” so screen readers announce it early.
  • Icon plus text plus color; never color alone (SC 1.4.1). Thicker border on the field.
  • Keep every value the user entered, valid or not.
  • Keep the submit button enabled; a disabled button gives no reason.

Message formula: what is wrong plus how to fix it, specific to the cause, in the label’s words. “Enter your date of birth”, “Enter a postcode, like SW1A 1AA”, “Date of birth must be in the past”. Never “Invalid input”, “Error”, or blame. Write one message per failure type per field in the spec. For a product-wide voice and glossary, use ux-microcopy.

6. Mobile and accessibility rules

  • Input font size at least 16 px, or iOS Safari zooms the page on focus.
  • Touch targets: WCAG 2.2 SC 2.5.8 requires at least 24×24 CSS px (or spacing); aim for platform sizes on touch-first products (44 pt iOS, 48 dp Android).
  • Fixed footers and banners must not cover the focused field or the submit button when the keyboard is open (SC 2.4.11 Focus Not Obscured).
  • WCAG criteria to check: 1.3.1 (fieldset/legend, label associations), 1.3.5 Identify Input Purpose (autocomplete tokens), 3.3.1 Error Identification, 3.3.2 Labels or Instructions, 3.3.3 Error Suggestion, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication (allow paste and password managers; no puzzle-only checks), 2.2.1 Timing Adjustable (warn before session timeouts and allow extension).
  • Never block paste. Never disable autofill on fields autofill can fill.

7. Audit and measure

Run the auditor on rendered HTML (save the page, or copy outerHTML from DevTools for client-rendered apps):

python3 scripts/audit_form.py checkout.html
python3 scripts/audit_form.py signup.html --max-fields 6 --strict

It counts questions per form and flags missing labels, placeholder-only labels, missing or disabled autocomplete tokens on recognizable fields, type="number" misuse, blocked paste, asterisk-only marking, radio groups without a fieldset and legend, two-option selects, and disabled submit buttons. It exits 1 on errors (or warnings with --strict). It reads markup only: judge wording, order and validation timing yourself.

Measure after launch: completion rate per step, drop-off per field, error rate per field, and time per field from form analytics. The field with the highest error rate is the next design fix. Before launch, run a small moderated usability round on the spec or prototype to catch questions people misread.

Gotchas

  • Splitting a short form into many steps adds clicks without reducing effort; split by topic, not by field count alone.
  • Address autocomplete with no manual entry fallback blocks users whose address is not in the database.
  • Country-specific formats (postcodes, phone, names) break with US-only patterns; let the country choice drive the rest of the address fields.
  • Inline success ticks on every field add noise; reserve them for checks the user cannot judge (username availability).
  • Hiding requirements until an error (“Password must contain…”) makes users fail first; state rules up front in the hint.
  • Masks that insert characters as you type break paste, autofill, and caret position; format on blur or display formatting without altering the stored value.

Verification

  1. Every field in the spec has a reason, owner and error messages; removed fields are listed with the reason.
  2. scripts/audit_form.py reports no errors on the rendered page.
  3. Browser autofill (Chrome and Safari, saved profile) fills name, email, address, phone and card fields correctly in one action.
  4. On a phone: correct keyboard per field, no zoom on focus, submit reachable with the keyboard open.
  5. Submit empty, then fix one field at a time: errors appear on submit, clear as each field is fixed, the summary links move focus to the field, and input is never lost.
  6. Keyboard and screen reader pass: labels, hints, optional marking and errors are announced for each field.

Output contract

## Form spec — <form name>
**Goal:** <what completing it achieves>  **Structure:** single page | N steps (<names>)
| # | Question (label) | Hint | Control / type / inputmode | autocomplete | Optional | Errors (per failure) | Why we ask |
|---|---|---|---|---|---|---|---|
| 1 | Email address | We'll send your receipt here | text input / email | email | no | Empty: "Enter your email address"; Format: "Enter an email address like [email protected]" | Receipt and account |
**Removed or deferred:** Company (deferred to profile), Confirm email (removed)
**Validation timing:** on blur after first edit; summary on submit
**Measures:** completion per step, error rate per field

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/audit_form.py 288 lines · 12.7 KB
#!/usr/bin/env python3
"""Audit the forms in rendered HTML for common form-UX defects.

Checks each visible field for: a programmatic label, placeholder used as the only
label, a missing or disabled autocomplete token on fields whose purpose is
recognizable (email, name, address, phone, card, password, one-time code), wrong
input types (type="number" for phone, postal code or card data; text for email),
blocked paste, asterisk-only required marking, radio/checkbox groups without a
fieldset and legend, tiny selects that should be radios, and disabled submit buttons.

Usage:
    python3 audit_form.py <file.html> [--max-fields N] [--strict]

--max-fields N  also report an error when a form asks more than N questions
                (a radio or checkbox group counts as one question)
--strict        exit 1 on warnings as well as errors

Save rendered HTML first (View Source of the live page, or
`curl -s URL > page.html`; for client-rendered apps copy document.documentElement.outerHTML).
Exit codes: 0 clean, 1 findings at the failing level, 2 unreadable input.
Standard library only.
"""
from __future__ import annotations

import re
import sys
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path

SKIP_TYPES = {"hidden", "submit", "button", "reset", "image"}
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}

# (pattern on name/id/label, expected autocomplete token, expected type or None)
PURPOSES: list[tuple[str, str, str | None]] = [
    (r"e-?mail", "email", "email"),
    (r"one.?time|otp|verification.?code|\bsms.?code", "one-time-code", None),
    (r"(new|confirm|repeat).?pass", "new-password", "password"),
    (r"pass(word|wd)?\b|\bpwd\b", "current-password", "password"),
    (r"user.?name|\blogin\b", "username", None),
    (r"first.?name|given.?name|\bfname\b|forename", "given-name", None),
    (r"last.?name|family.?name|surname|\blname\b", "family-name", None),
    (r"full.?name|^name$|your.?name", "name", None),
    (r"phone|mobile|\btel\b|telephone", "tel", "tel"),
    (r"zip|postal|post.?code", "postal-code", None),
    (r"address.?(line)?.?2|addr2|apartment|suite", "address-line2", None),
    (r"address.?(line)?.?1|addr1|street", "address-line1", None),
    (r"\bcity\b|\btown\b", "address-level2", None),
    (r"\bstate\b|province|county|region", "address-level1", None),
    (r"country", "country", None),
    (r"card.?num|cc.?num|\bpan\b|credit.?card", "cc-number", None),
    (r"cvc|cvv|csc|security.?code", "cc-csc", None),
    (r"expir|\bexp\b|cc.?exp", "cc-exp", None),
    (r"name.?on.?card|card.?holder|cc.?name", "cc-name", None),
    (r"birth|\bdob\b|bday", "bday", None),
    (r"company|organi[sz]ation|employer", "organization", None),
]
NUMBER_IS_WRONG = {"tel", "postal-code", "cc-number", "cc-csc", "cc-exp", "one-time-code"}


@dataclass
class Field:
    tag: str
    attrs: dict[str, str]
    line: int
    label_text: str = ""
    in_label: bool = False
    in_fieldset_with_legend: bool = False


@dataclass
class Form:
    line: int
    fields: list[Field] = field(default_factory=list)
    submits: list[tuple[dict[str, str], int]] = field(default_factory=list)
    selects: list[tuple[Field, int]] = field(default_factory=list)


class Collector(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.forms: list[Form] = []
        self.current: Form | None = None
        self.labels_for: dict[str, str] = {}
        self.label_stack: list[dict] = []
        self.ids_text: dict[str, str] = {}
        self.text_capture: list[tuple[str, str, list[str]]] = []
        self.fieldsets: list[dict] = []
        self.select: Field | None = None
        self.option_count = 0
        self.button: tuple[dict[str, str], int] | None = None

    def _form(self) -> Form:
        if self.current is None:  # fields outside any <form> are grouped as a pseudo-form
            self.current = Form(line=self.getpos()[0])
            self.forms.append(self.current)
        return self.current

    def handle_starttag(self, tag, attrs_list):
        a = {k: (v or "") for k, v in attrs_list}
        line = self.getpos()[0]
        if "id" in a and tag not in VOID:
            self.text_capture.append((tag, a["id"], []))
        if tag == "form":
            self.current = Form(line=line)
            self.forms.append(self.current)
        elif tag == "label":
            self.label_stack.append({"for": a.get("for"), "text": [], "fields": []})
        elif tag == "fieldset":
            self.fieldsets.append({"legend": False})
        elif tag == "legend" and self.fieldsets:
            self.fieldsets[-1]["legend"] = True
        elif tag in ("input", "textarea", "select"):
            t = a.get("type", "text").lower() if tag == "input" else tag
            if tag == "input" and t in ("submit", "image"):
                self._form().submits.append((a, line))
                return
            if tag == "input" and t in SKIP_TYPES:
                return
            f = Field(tag=tag, attrs=a, line=line)
            f.in_fieldset_with_legend = any(fs["legend"] for fs in self.fieldsets)
            if self.label_stack:
                f.in_label = True
                self.label_stack[-1]["fields"].append(f)
            self._form().fields.append(f)
            if tag == "select":
                self.select, self.option_count = f, 0
        elif tag == "option" and self.select is not None:
            self.option_count += 1
        elif tag == "button":
            t = a.get("type", "submit").lower()
            if t == "submit":
                self._form().submits.append((a, line))

    def handle_endtag(self, tag):
        if tag == "form":
            self.current = None
        elif tag == "label" and self.label_stack:
            lab = self.label_stack.pop()
            text = " ".join("".join(lab["text"]).split())
            if lab["for"]:
                self.labels_for[lab["for"]] = text
            for f in lab["fields"]:
                f.label_text = text
        elif tag == "fieldset" and self.fieldsets:
            self.fieldsets.pop()
        elif tag == "select" and self.select is not None:
            self._form().selects.append((self.select, self.option_count))
            self.select = None
        if self.text_capture and self.text_capture[-1][0] == tag:
            _, ident, buf = self.text_capture.pop()
            self.ids_text[ident] = " ".join("".join(buf).split())

    def handle_data(self, data):
        for lab in self.label_stack:
            lab["text"].append(data)
        for _, _, buf in self.text_capture:
            buf.append(data)


def purpose_of(f: Field, label: str) -> tuple[str, str | None] | None:
    hay = " ".join([f.attrs.get("name", ""), f.attrs.get("id", ""), label]).lower()
    hay = re.sub(r"[_\-\[\]]", " ", hay)
    for pattern, token, typ in PURPOSES:
        if re.search(pattern, hay):
            return token, typ
    return None


def audit(path: Path, max_fields: int | None) -> tuple[list[str], list[str]]:
    html = path.read_text(encoding="utf-8", errors="replace")
    c = Collector()
    c.feed(html)
    errors: list[str] = []
    warns: list[str] = []
    groups: dict[str, list[Field]] = {}

    for n, form in enumerate(c.forms, 1):
        tag = f"form {n} (line {form.line})"
        visible = form.fields
        grouped = {f.attrs.get("name") or id(f) for f in visible
                   if f.tag == "input" and f.attrs.get("type", "").lower() in ("radio", "checkbox")}
        singles = [f for f in visible
                   if not (f.tag == "input" and f.attrs.get("type", "").lower() in ("radio", "checkbox"))]
        questions = len(singles) + len(grouped)
        print(f"{tag}: {questions} questions ({len(visible)} controls)")
        if max_fields is not None and questions > max_fields:
            errors.append(f"{tag}: {questions} questions exceeds --max-fields {max_fields}; justify or remove fields")
        for f in visible:
            a = f.attrs
            ident = a.get("name") or a.get("id") or f.tag
            where = f"{tag}, field '{ident}' (line {f.line})"
            label = f.label_text or c.labels_for.get(a.get("id", ""), "")
            if not label and a.get("aria-labelledby"):
                label = " ".join(c.ids_text.get(i, "") for i in a["aria-labelledby"].split()).strip()
            if not label:
                label = a.get("aria-label", "")
            itype = a.get("type", "text").lower() if f.tag == "input" else f.tag

            if itype in ("radio", "checkbox"):
                if a.get("name"):
                    groups.setdefault(f"{tag}|{a['name']}", []).append(f)
                if not label:
                    errors.append(f"{where}: {itype} has no label")
                continue

            if not label:
                if a.get("placeholder"):
                    errors.append(f"{where}: placeholder \"{a['placeholder']}\" is the only label; add a visible <label>")
                else:
                    errors.append(f"{where}: no programmatic label (label[for], wrapping label, aria-labelledby)")
            if "*" in label and "required" not in label.lower():
                warns.append(f"{where}: asterisk in label; mark optional fields with \"(optional)\" instead, or explain the asterisk once")
            if re.search(r"return\s+false|preventDefault", a.get("onpaste", "")):
                errors.append(f"{where}: paste is blocked")

            p = purpose_of(f, label)
            ac = a.get("autocomplete", "").strip().lower()
            if p:
                token, want_type = p
                if not ac:
                    warns.append(f"{where}: add autocomplete=\"{token}\" (WCAG 1.3.5, autofill)")
                elif ac in ("off", "nope", "false", "disabled"):
                    warns.append(f"{where}: autocomplete=\"{ac}\" blocks autofill; use \"{token}\"")
                elif token not in ac.split() and not (token.endswith("password") and "password" in ac):
                    warns.append(f"{where}: autocomplete=\"{ac}\" — expected a token like \"{token}\"; check the purpose")
                if itype == "number" and token in NUMBER_IS_WRONG:
                    errors.append(f"{where}: type=\"number\" for {token}; use type=\"text\" inputmode=\"numeric\" (or type=\"tel\" for phone)")
                if want_type == "email" and itype not in ("email",):
                    warns.append(f"{where}: use type=\"email\" for the email keyboard and autofill")
                if want_type == "tel" and itype not in ("tel",):
                    warns.append(f"{where}: use type=\"tel\" for the phone keypad")
                if token == "new-password" and re.search(r"confirm|repeat|again", (ident + label).lower()):
                    warns.append(f"{where}: confirm-password field; a show-password toggle usually replaces it")

        for sel, count in form.selects:
            ident = sel.attrs.get("name") or sel.attrs.get("id") or "select"
            real = count - (1 if count > 0 else 0)  # allow one placeholder option
            if 0 < real <= 2:
                warns.append(f"{tag}, select '{ident}' (line {sel.line}): {real} real options; radios show the choice without a click")

        if not form.submits:
            warns.append(f"{tag}: no submit button found")
        for attrs, line in form.submits:
            if "disabled" in attrs:
                warns.append(f"{tag}, submit (line {line}): disabled submit hides why the form cannot be sent; keep it enabled and show errors")

    for key, fields in groups.items():
        if len(fields) > 1 and not all(f.in_fieldset_with_legend for f in fields):
            tag, name = key.split("|", 1)
            warns.append(f"{tag}: group '{name}' ({len(fields)} options) needs <fieldset> with <legend> holding the question")

    if not c.forms:
        warns.append("no form fields found — is this the rendered HTML?")
    return errors, warns


def main() -> int:
    args = sys.argv[1:]
    if not args or args[0] in ("-h", "--help"):
        print(__doc__)
        return 2
    strict = "--strict" in args
    max_fields = None
    if "--max-fields" in args:
        i = args.index("--max-fields")
        try:
            max_fields = int(args[i + 1])
        except (IndexError, ValueError):
            print("error: --max-fields needs an integer", file=sys.stderr)
            return 2
    path = Path(args[0])
    if not path.is_file():
        print(f"error: cannot read {path}", file=sys.stderr)
        return 2
    errors, warns = audit(path, max_fields)
    for e in errors:
        print(f"ERROR  {e}")
    for w in warns:
        print(f"WARN   {w}")
    print(f"\n{len(errors)} error(s), {len(warns)} warning(s)")
    if errors or (strict and warns):
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc