{
  "$schema": "https://a11y.world/agents/rules.schema.json",
  "name": "a11y.world agent rules",
  "version": "0.1.0",
  "updated": "2026-10-02",
  "license": "CC0-1.0",
  "about": "Rules an accessibility agent or code-generating assistant can act on directly. Each rule names a test method an agent can run, a severity, and the human note that explains it. Ids are stable; wording may change.",
  "severities": {
    "blocker": "Stops a group of people from completing a task. Fix before shipping.",
    "major": "Makes a task much harder for a group of people.",
    "minor": "Friction or noise. Fix when touching the code."
  },
  "rules": [
    {
      "id": "AW-ALT-001",
      "title": "Every img has an alt attribute",
      "severity": "blocker",
      "applies_to": "img",
      "test": "For each <img>, the alt attribute exists (it may be empty). A missing attribute makes screen readers announce the file name.",
      "fix": "Add alt. Use alt=\"\" only when the image is decorative.",
      "note": "https://a11y.world/notes/alt-text-is-a-decision-not-a-description",
      "lab": "https://a11y.world/labs/alt-text"
    },
    {
      "id": "AW-ALT-002",
      "title": "Alt text does not start with image of, picture of, photo of, or icon of",
      "severity": "minor",
      "applies_to": "img[alt]",
      "test": "alt does not match /^(an?\\s+)?(image|picture|photo|photograph|graphic|icon|illustration)\\s+(of|showing)\\b/i.",
      "fix": "Remove the prefix. Screen readers already announce the element as an image.",
      "note": "https://a11y.world/notes/alt-text-is-a-decision-not-a-description"
    },
    {
      "id": "AW-ALT-003",
      "title": "Alt text is not the file name or a placeholder",
      "severity": "major",
      "applies_to": "img[alt]",
      "test": "alt does not match a file name pattern (/\\.(png|jpe?g|gif|svg|webp)$/i), is not the string image, photo, picture, or alt, and is not identical to the src basename.",
      "fix": "Write what the image contributes on this page, or use alt=\"\" if it contributes nothing.",
      "note": "https://a11y.world/notes/alt-text-is-a-decision-not-a-description"
    },
    {
      "id": "AW-ALT-004",
      "title": "An image inside a link or button has an accessible name that describes the destination or action",
      "severity": "blocker",
      "applies_to": "a img, button img",
      "test": "If the link or button has no other text content, the img alt is non-empty and does not merely describe the picture (for example, logo, arrow, icon).",
      "fix": "Make the alt the link purpose: Home, Next page, Search.",
      "note": "https://a11y.world/notes/how-an-agent-should-decide-on-alt-text"
    },
    {
      "id": "AW-ALT-005",
      "title": "Decorative images are hidden, not described",
      "severity": "minor",
      "applies_to": "img",
      "test": "Images that are purely decorative (backgrounds, spacers, repeated flourishes) have alt=\"\" or role=\"presentation\". Heuristic: an image whose alt repeats adjacent heading or caption text verbatim is likely decorative or redundant.",
      "fix": "Use alt=\"\". Do not write alt text that duplicates visible text next to the image.",
      "note": "https://a11y.world/notes/how-an-agent-should-decide-on-alt-text"
    },
    {
      "id": "AW-FOCUS-001",
      "title": "No interactive element removes its focus indicator without replacing it",
      "severity": "blocker",
      "applies_to": "a, button, input, select, textarea, summary, [tabindex]",
      "test": "In the computed styles for :focus or :focus-visible, if outline is none or 0, then box-shadow, border, or background must change between the unfocused and focused states. Static check: any rule containing outline: none or outline: 0 for a focus selector must, in the same rule, declare box-shadow or a border change.",
      "fix": "Keep the outline, or replace it with a visible ring in the same rule.",
      "note": "https://a11y.world/notes/the-focus-ring-is-the-interface",
      "lab": "https://a11y.world/labs/focus"
    },
    {
      "id": "AW-FOCUS-002",
      "title": "The focus indicator has at least 3:1 contrast against the backgrounds it can land on",
      "severity": "major",
      "applies_to": "a, button, input, select, textarea, summary, [tabindex]",
      "test": "Compute WCAG 2 contrast between the focus ring color and each background the element can appear on (page, cards, dark sections). Every pair must reach 3:1. A two-tone ring passes a background when either tone does.",
      "fix": "Use a two-tone ring (one light, one dark band) so one tone always contrasts.",
      "note": "https://a11y.world/notes/why-your-focus-ring-vanishes-in-dark-mode",
      "lab": "https://a11y.world/labs/focus"
    },
    {
      "id": "AW-FOCUS-003",
      "title": "Focus is visible for keyboard users and not forced on mouse users",
      "severity": "minor",
      "applies_to": "stylesheet",
      "test": "Custom focus styles use :focus-visible rather than :focus alone, unless the element is a text input.",
      "fix": "Replace :focus with :focus-visible in custom ring rules.",
      "note": "https://a11y.world/notes/the-focus-ring-is-the-interface"
    },
    {
      "id": "AW-KEY-001",
      "title": "Every control that responds to a click is reachable and operable by keyboard",
      "severity": "blocker",
      "applies_to": "[onclick], [role=button], [role=link], div, span",
      "test": "A non-interactive element (div, span, li) with a click handler or role=button must have tabindex=\"0\" and respond to Enter and Space. Prefer a real <button> or <a href>.",
      "fix": "Use <button> or <a href>. If you cannot, add tabindex=\"0\" and a keydown handler for Enter and Space.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-KEY-002",
      "title": "No positive tabindex",
      "severity": "major",
      "applies_to": "[tabindex]",
      "test": "tabindex is 0 or -1. Any value above 0 reorders focus unpredictably.",
      "fix": "Remove the positive tabindex and fix the DOM order instead.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-KEY-003",
      "title": "Hover-only content has a keyboard and touch equivalent",
      "severity": "major",
      "applies_to": "stylesheet, [title]",
      "test": "Content shown only on :hover (menus, tooltips, action buttons) is also shown on :focus-within or by an explicit control. title attributes are not the only way to reach information.",
      "fix": "Pair :hover with :focus-within, or add a visible toggle.",
      "note": "https://a11y.world/notes/hover-is-a-state-most-people-do-not-have"
    },
    {
      "id": "AW-NAME-001",
      "title": "Every form control has a programmatic label",
      "severity": "blocker",
      "applies_to": "input, select, textarea",
      "test": "Each control (except type=hidden, submit, button) has a <label for> pointing at its id, is wrapped in a <label>, or has aria-label or aria-labelledby. placeholder alone is not a label.",
      "fix": "Add a visible <label for=\"id\">.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-NAME-002",
      "title": "Every button and link has an accessible name",
      "severity": "blocker",
      "applies_to": "button, a[href]",
      "test": "Text content, aria-label, aria-labelledby, or the alt of a contained img is non-empty after trimming. Icon-only buttons need aria-label.",
      "fix": "Add text or aria-label that names the action.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-NAME-003",
      "title": "Link text makes sense out of context",
      "severity": "minor",
      "applies_to": "a[href]",
      "test": "Link name is not click here, here, read more, more, or link, unless aria-label or aria-describedby adds the destination.",
      "fix": "Name the destination: Read the focus ring note.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-COLOR-001",
      "title": "Text has at least 4.5:1 contrast against its background (3:1 for large text)",
      "severity": "major",
      "applies_to": "text",
      "test": "WCAG 2 contrast ratio between computed color and the effective background. Large text is at least 24px, or 18.66px bold.",
      "fix": "Darken the text or lighten the background. Check every pair, not just body text.",
      "note": "https://a11y.world/notes/terminal-colors-are-a-contrast-problem-too",
      "lab": "https://a11y.world/labs/color"
    },
    {
      "id": "AW-COLOR-002",
      "title": "Color is not the only way a state is shown",
      "severity": "major",
      "applies_to": "text, [aria-invalid], .error, .success, a",
      "test": "Error, success, selected, and link states differ from their neighbors by something other than color: an icon, text, underline, border, or weight.",
      "fix": "Add an icon or text to the state. Underline links inside body text.",
      "note": "https://a11y.world/notes/color-is-never-the-only-signal"
    },
    {
      "id": "AW-STRUCT-001",
      "title": "Headings are in order and there is one h1",
      "severity": "major",
      "applies_to": "h1, h2, h3, h4, h5, h6",
      "test": "Exactly one h1 per page. No heading level skips downward (h2 to h4).",
      "fix": "Restructure the headings to reflect the outline. Style with CSS, not by picking a smaller heading.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-STRUCT-002",
      "title": "The page has a language and a title",
      "severity": "major",
      "applies_to": "html, title",
      "test": "<html lang> is set to a valid tag, and <title> is non-empty and page-specific.",
      "fix": "Add lang=\"en\" (or the page language) and a descriptive title.",
      "note": "https://a11y.world/notes/a-checklist-for-code-generating-assistants"
    },
    {
      "id": "AW-STRUCT-003",
      "title": "Zoom is not disabled",
      "severity": "blocker",
      "applies_to": "meta[name=viewport]",
      "test": "The viewport meta does not contain user-scalable=no or a maximum-scale below 2.",
      "fix": "Remove user-scalable=no and maximum-scale.",
      "note": "https://a11y.world/notes/400-percent-zoom-is-a-layout-test-not-an-edge-case"
    },
    {
      "id": "AW-MOTION-001",
      "title": "Non-essential animation respects prefers-reduced-motion",
      "severity": "major",
      "applies_to": "stylesheet, script",
      "test": "Any CSS animation, transition over 0.3s, or scripted motion is disabled or shortened inside @media (prefers-reduced-motion: reduce) or a matching matchMedia check.",
      "fix": "Wrap motion in the media query, or reduce it to a fade.",
      "note": "https://a11y.world/notes/reduced-motion-is-not-no-motion"
    }
  ]
}
