{"playbook":"Website","url":"https://agentwranglers.se/playbook/website","updated":"2026-10-21","license":"https://creativecommons.org/licenses/by/4.0/","credit":"Required by the licence: credit Agent Wranglers with a link to the rule or playbook you used.","how":"Each rule's How text is on its own page: add .md to its url.","rules":[{"chapter":"HTML basics","name":"Start every page with the doctype","level":"Recommended","rule":"Start every HTML page with `<!doctype html>` as its very first bytes, so browsers use standards mode (the modern, predictable way of laying out pages).","check":"`curl -s URL | head -c 15 | grep -qi '^<!doctype html>$'` succeeds for every page, with no byte order mark or blank line before it. Lighthouse's `doctype` audit passes.","measured":"On a reference site, removing it dropped Lighthouse Best Practices from 100 to 96.","url":"https://agentwranglers.se/playbook/website/html-basics/doctype","related":["/playbook/website/html-basics/valid-html"],"sources":[{"label":"HTML Standard – The DOCTYPE","url":"https://html.spec.whatwg.org/multipage/syntax.html#the-doctype"}]},{"chapter":"HTML basics","name":"Say the page's language","level":"Required","risk":"law","rule":"Set the page's language on the `<html>` element of every page, such as `lang=\"en\"`, using a standard language code (BCP 47).","check":"A build gate finds `<html lang=\"…\">` with a valid BCP 47 code on every page. Lighthouse and axe `html-has-lang` and `html-lang-valid` pass.","measured":"On a reference site, removing it dropped Lighthouse Accessibility from 100 to 96.","url":"https://agentwranglers.se/playbook/website/html-basics/html-lang","related":["/playbook/email/message-basics/language-and-direction","/playbook/website/languages-and-regions/localised-metadata"],"sources":[{"label":"BCP 47 – Tags for Identifying Languages","url":"https://www.rfc-editor.org/info/bcp47"},{"label":"WCAG 2.2 – 3.1.1 Language of Page","url":"https://www.w3.org/TR/WCAG22/#language-of-page"}]},{"chapter":"HTML basics","name":"Declare UTF-8 first","level":"Recommended","rule":"Make `<meta charset=\"utf-8\">` the first element in `<head>`, so it falls inside the first 1024 bytes as the HTML standard requires. Send the same character set in the `Content-Type` header.","check":"`curl -s URL | head -c 1024 | grep -qi '<meta charset=\"utf-8\">'` succeeds. `curl -sI URL` shows `Content-Type: text/html; charset=utf-8`.","url":"https://agentwranglers.se/playbook/website/html-basics/meta-charset","related":["/playbook/email/message-basics/utf-8"],"sources":[{"label":"HTML Standard – Specifying the character encoding","url":"https://html.spec.whatwg.org/multipage/semantics.html#charset"},{"label":"WHATWG Encoding Standard","url":"https://encoding.spec.whatwg.org/"}]},{"chapter":"HTML basics","name":"Fit the page to the phone","level":"Recommended","rule":"Add `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">` to every page, and never turn off zooming.","check":"Lighthouse `viewport-insight` passes (named `viewport` before Lighthouse 13). A build gate finds no `maximum-scale` or `user-scalable` in any page.","measured":"On a reference site, removing it dropped Lighthouse Best Practices from 100 to 93 on phones, and from 100 to 96 on desktop. It also raised layout shift on phones from 0 to 0.034.","url":"https://agentwranglers.se/playbook/website/html-basics/meta-viewport","related":["/playbook/website/accessibility/text-resize"],"sources":[{"label":"CSS Viewport Module Level 1","url":"https://www.w3.org/TR/css-viewport-1/"},{"label":"WCAG 2.2 – 1.4.4 Resize Text","url":"https://www.w3.org/TR/WCAG22/#resize-text"}]},{"chapter":"HTML basics","name":"Keep the HTML valid","level":"Recommended","rule":"Make every built page pass an HTML validator with zero errors, so every browser, crawler and screen reader reads the same structure.","check":"The validator reports 0 errors for every HTML file in the build, and the build fails when you add a `<div>` inside a `<p>`.","url":"https://agentwranglers.se/playbook/website/html-basics/valid-html","related":["/playbook/website/html-basics/doctype","/playbook/engineering/rules-as-code/build-gates","/playbook/html-css/html-structure-and-reuse/native-elements-first","/playbook/html-css/checks-in-code/markup-linter"],"sources":[{"label":"WHATWG HTML Living Standard","url":"https://html.spec.whatwg.org/multipage/"}]},{"chapter":"HTML basics","name":"Give every page its own title","level":"Required","risk":"law","rule":"Give every page exactly one unique, non-empty `<title>`, sent by the server. Put the page's name first and the site name last, in 60 characters at most.","check":"A build gate counts exactly one `<title>` per page, at most 60 characters, and no two pages with the same title.","url":"https://agentwranglers.se/playbook/website/html-basics/title","related":["/playbook/website/seo/server-side-rendering"],"sources":[{"label":"HTML Standard – The title element","url":"https://html.spec.whatwg.org/multipage/semantics.html#the-title-element"},{"label":"WCAG 2.2 – 2.4.2 Page Titled","url":"https://www.w3.org/TR/WCAG22/#page-titled"},{"label":"Google Search Central – Title links","url":"https://developers.google.com/search/docs/appearance/title-link"}]},{"chapter":"HTML basics","name":"Write a summary for search results","level":"Recommended","rule":"Write a unique description of 120 to 160 characters for every public page by hand. Tell a searcher what the page offers.","check":"A build gate finds exactly one description of 120 to 160 characters on every public page, and no two pages share one. Lighthouse `meta-description` passes.","measured":"On a reference site, removing it dropped Lighthouse SEO from 92 to 85.","url":"https://agentwranglers.se/playbook/website/html-basics/meta-description","related":["/playbook/website/html-basics/open-graph"],"sources":[{"label":"HTML Standard – The description meta name","url":"https://html.spec.whatwg.org/multipage/semantics.html#meta-description"},{"label":"Google Search Central – Snippets","url":"https://developers.google.com/search/docs/appearance/snippet"}]},{"chapter":"HTML basics","name":"Ship a full set of icons","level":"Recommended","rule":"Ship an SVG favicon, a `/favicon.ico` fallback, a 180 px opaque `apple-touch-icon`, and 192, 512 and maskable icons for installed web apps.","check":"`curl -sI` on each icon answers 200 with `image/svg+xml`, `image/x-icon` (or `image/vnd.microsoft.icon`) or `image/png`. The maskable icon keeps its logo inside the central 80% circle. The full manifest is in [let people install the site](https://agentwranglers.se/playbook/website/when-things-break/pwa-manifest).","url":"https://agentwranglers.se/playbook/website/html-basics/favicons","related":["/playbook/website/when-things-break/pwa-manifest"],"sources":[{"label":"HTML Standard – The icon link type","url":"https://html.spec.whatwg.org/multipage/links.html#rel-icon"},{"label":"Web Application Manifest – Icon masks and safe zone","url":"https://www.w3.org/TR/appmanifest/#icon-masks"}]},{"chapter":"HTML basics","name":"Colour the browser bar to match","level":"Optional","rule":"Set `<meta name=\"theme-color\">` twice, once for light and once for dark, so the browser's own bar matches the page. Also give `body` an explicit background colour.","check":"The head has one tag with `(prefers-color-scheme: light)` and one with `(prefers-color-scheme: dark)`, and their colours equal the page background in each mode.","url":"https://agentwranglers.se/playbook/website/html-basics/theme-color","related":["/playbook/website/interface/colour-as-tokens"],"sources":[{"label":"HTML Standard – The theme-color meta name","url":"https://html.spec.whatwg.org/multipage/semantics.html#meta-theme-color"}]},{"chapter":"HTML basics","name":"Support light and dark from the start","level":"Recommended","rule":"Say which colour schemes the page supports, with `<meta name=\"color-scheme\">` and the CSS `color-scheme` property. Design dark mode on purpose.","check":"The page has the meta tag and `color-scheme` on `:root`. With dark mode emulated in a browser driver and scripts blocked, `body` has a dark computed background and form controls render dark.","url":"https://agentwranglers.se/playbook/website/html-basics/color-scheme","related":["/playbook/website/interface/colour-as-tokens","/playbook/app-ui/styles-and-components/purpose-tokens","/playbook/email/layout-and-accessibility/email-dark-mode"],"sources":[{"label":"HTML Standard – The color-scheme meta name","url":"https://html.spec.whatwg.org/multipage/semantics.html#meta-color-scheme"},{"label":"CSS Color Adjust – The color-scheme property","url":"https://www.w3.org/TR/css-color-adjust-1/#color-scheme-prop"}]},{"chapter":"HTML basics","name":"Control how shared links look","level":"Recommended","rule":"Give every page Open Graph tags (type, title, description, url, and an image with width, height and alt). Shared links then show the page's title, description and image.","check":"A build gate finds the eight `og:` tags and `twitter:card` on every page, with `og:url` equal to the canonical link. Each `og:image` answers 200 with an image type under 300 KB.","url":"https://agentwranglers.se/playbook/website/html-basics/open-graph","related":["/playbook/website/html-basics/meta-description","/playbook/website/redirects-and-canonicals/canonical-signals"],"sources":[]},{"chapter":"HTML basics","name":"Point readers to your feed","level":"Recommended","rule":"Announce every feed in each page's `<head>` with `<link rel=\"alternate\">` and the right type, so readers and agents can find it.","check":"A build gate finds one `<link rel=\"alternate\">` per feed in every page's head, with the feed's type. Each `href` answers 200 with that type.","url":"https://agentwranglers.se/playbook/website/html-basics/feed-discovery","related":["/playbook/website/ready-for-ai-agents/link-headers"],"sources":[{"label":"HTML Standard – The alternate link type","url":"https://html.spec.whatwg.org/multipage/links.html#rel-alternate"},{"label":"RFC 8288 – Web Linking","url":"https://www.rfc-editor.org/rfc/rfc8288"}]},{"chapter":"HTML basics","name":"Keep your feed clean and valid","level":"Recommended","rule":"Publish valid feeds with a self link and the full text. Give each entry a stable id and an honest updated date.","check":"Feed checkers (W3C for Atom, jsonfeed.org for JSON Feed) report no errors.","url":"https://agentwranglers.se/playbook/website/html-basics/feed-hygiene","related":["/playbook/website/html-basics/websub"],"sources":[{"label":"RFC 4287 – The Atom Syndication Format","url":"https://www.rfc-editor.org/rfc/rfc4287"}]},{"chapter":"HTML basics","name":"Push feed updates to subscribers","level":"Optional","rule":"If you publish a feed, name a WebSub hub in it and in a `Link` header. Tell the hub whenever the content changes.","check":"The hub's subscriber diagnostics show the feed and the last publish.","url":"https://agentwranglers.se/playbook/website/html-basics/websub","related":["/playbook/website/html-basics/feed-hygiene","/playbook/website/seo/indexnow"],"sources":[{"label":"W3C WebSub Recommendation","url":"https://www.w3.org/TR/websub/"}]},{"chapter":"HTML basics","name":"Say in DNS if a domain is for sale","level":"Optional","rule":"Only if a domain is for sale, say so with a TXT record at `_for-sale`, as RFC 10023 describes. Otherwise publish nothing.","check":"`dig +short TXT _for-sale.example.com` returns a record starting `v=FORSALE1;` while the domain is for sale, and nothing otherwise.","url":"https://agentwranglers.se/playbook/website/html-basics/for-sale-dns","related":["/playbook/infrastructure/networking-and-dns/dns-as-code"],"sources":[{"label":"RFC 10023 – The for-sale DNS node name","url":"https://www.rfc-editor.org/rfc/rfc10023"}]},{"chapter":"Site architecture","name":"Keep pages as data","level":"Recommended","rule":"Write each page as plain values (path, title, description, date, HTML, Markdown), not as a template full of logic.","check":"Add a page of a kind that already exists. `git diff --stat` shows one new data file and no changed template.","url":"https://agentwranglers.se/playbook/website/site-architecture/pages-as-data","related":["/playbook/website/site-architecture/shared-html-modules-only"],"sources":[]},{"chapter":"Site architecture","name":"Write each page once for every format","level":"Recommended","rule":"Build every format (HTML, Markdown, JSON-LD, sitemap, feeds, llms.txt) from the same page data, so they cannot drift apart.","check":"A test reads the page list. Every public page has a sitemap entry and a Markdown file. Every sitemap and feed entry is a listed page.","url":"https://agentwranglers.se/playbook/website/site-architecture/one-source-many-formats","related":["/playbook/engineering/documentation/one-home-per-value"],"sources":[]},{"chapter":"Site architecture","name":"Render at build time and serve from memory","level":"Recommended","rule":"Do all templating at build time. Render each page once, compress it once, and keep the bytes, ETag and security policy in memory.","check":"With a `Server-Timing` header on page responses, a warm page shows a `dur` under 1 ms. After warming every page, the server's memory stays within the budget the load test sets.","url":"https://agentwranglers.se/playbook/website/site-architecture/render-at-build-time-serve-from-memory","related":["/playbook/architecture/data-shapes/literal-replacement"],"sources":[]},{"chapter":"Site architecture","name":"Build on HTML that already works","level":"Recommended","rule":"Ship working HTML first. Let JavaScript add behaviour to what is already there.","check":"A browser driver with JavaScript disabled opens each page type. The main content is present, and every navigation link answers 200.","measured":"On a reference site, rendering the content in the browser instead took mobile Lighthouse Performance from 99 to 85. Layout shift went from 0 to 0.295.","url":"https://agentwranglers.se/playbook/website/site-architecture/progressive-enhancement","related":["/playbook/website/seo/server-side-rendering","/playbook/website/when-things-break/graceful-degradation","/playbook/website/when-things-break/hide-script-only-controls","/playbook/notifications/push-notifications/works-without-push","/playbook/html-css/checks-in-code/baseline-target"],"sources":[]},{"chapter":"Site architecture","name":"Load one of each from your own site","level":"Recommended","rule":"One HTML request, one inline stylesheet, one script bundle, one image per screen size, self-hosted fonts, and no third-party origins.","check":"A browser driver records every request on each page type. Every request goes to the page's own origin, with one HTML document, no external stylesheet and at most one script bundle.","url":"https://agentwranglers.se/playbook/website/site-architecture/one-of-each-all-first-party","related":["/playbook/adversarial-testing/supply-chain-and-setup/third-party-scripts"],"sources":[]},{"chapter":"Site architecture","name":"Build repeated HTML from shared modules","level":"Recommended","rule":"Build every repeated piece of HTML from one shared module, such as tables, code blocks, cards, breadcrumbs and calls to action. Make the build reject hand-written copies of the ones that break most easily.","check":"Write a raw `<table>` into a page. The build stops and names the page.","url":"https://agentwranglers.se/playbook/website/site-architecture/shared-html-modules-only","related":["/playbook/website/site-architecture/pages-as-data","/playbook/architecture/solid-and-dry/dry","/playbook/website/accessibility/consistent-help","/playbook/website/accessibility/consistent-navigation","/playbook/html-css/html-structure-and-reuse/partials-take-data"],"sources":[]},{"chapter":"Site architecture","name":"Give every module its own class names","level":"Recommended","rule":"Give each module its own class names, so one module's styles can never reach into another.","check":"Reuse another module's class name on a different element. The build stops and names the class and both pages.","measured":"On a reference site, a new menu panel reused the class name of a chapter list. The list became a fixed overlay on one page, while screenshots of the other pages looked fine.","url":"https://agentwranglers.se/playbook/website/site-architecture/module-class-names","related":["/playbook/app-ui/styles-and-components/shared-components","/playbook/app-ui/styles-and-components/component-variants","/playbook/html-css/structure-and-files/five-style-layers","/playbook/html-css/the-cascade/scope-component-styles","/playbook/html-css/naming-and-state/name-by-role","/playbook/html-css/checks-in-code/unused-selectors"],"sources":[]},{"chapter":"Site architecture","name":"Keep the previous build's assets","level":"Required","risk":"outage","rule":"After a deploy, keep the hashed files of the last three deploys and of the last 7 days, whichever keeps more. Pages cached before the deploy still find their scripts.","check":"A deploy test loads a page, deploys a build with new hashes, then requests the old page's script and stylesheet addresses. Each answers 200.","url":"https://agentwranglers.se/playbook/website/site-architecture/keep-the-previous-build-s-assets","related":["/playbook/testing/output-comparison/keep-previous-manifest","/playbook/app-ui/permissions-flags-and-failures/new-release-reload","/playbook/website/performance/cache-control"],"sources":[]},{"chapter":"Site architecture","name":"Audit every page in a browser before release","level":"Recommended","rule":"Run an accessibility checker and a lab tool such as Lighthouse in a browser. Test light and dark mode, at phone and desktop width, and watch the console for policy errors.","check":"No axe violations and no console errors, such as CSP or Trusted Types errors, on any page. Run Lighthouse on the home page and one page of each type, and keep its scores as a record.","url":"https://agentwranglers.se/playbook/website/site-architecture/browser-audits-before-release","related":["/playbook/testing/end-to-end-and-browser-tests/test-the-built-app","/playbook/testing/non-functional-tests/accessibility-in-ci"],"sources":[{"label":"WCAG 2.2","url":"https://www.w3.org/TR/WCAG22/"}]},{"chapter":"Interface","name":"Build menus with popover","level":"Recommended","rule":"Build menus, tooltips and simple overlays with the built-in `popover` attribute, instead of JavaScript modal code.","check":"A browser test focuses the button and presses Enter. The menu matches `:popover-open`. After Escape it no longer matches, and focus is back on the button.","url":"https://agentwranglers.se/playbook/website/interface/popover-api","related":["/playbook/website/interface/anchor-positioning","/playbook/website/accessibility/content-on-hover-focus","/playbook/app-ui/feedback-and-dialogs/dialogs-for-short-tasks"],"sources":[{"label":"HTML Standard – The popover attribute","url":"https://html.spec.whatwg.org/multipage/popover.html"}]},{"chapter":"Interface","name":"Build modal dialogs with dialog","level":"Recommended","rule":"Build modal dialogs with the `<dialog>` element and `showModal()`, never with a styled `div` and your own focus code.","check":"A browser test opens the dialog with the keyboard. Tab never leaves the dialog, Escape closes it, and focus returns to the button that opened it.","url":"https://agentwranglers.se/playbook/website/interface/dialog-element","related":["/playbook/website/interface/invoker-commands","/playbook/app-ui/feedback-and-dialogs/managed-focus","/playbook/app-ui/feedback-and-dialogs/dialogs-for-short-tasks"],"sources":[{"label":"HTML Standard – The dialog element","url":"https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element"}]},{"chapter":"Interface","name":"Pin menus to their button with CSS","level":"Recommended","rule":"Attach popovers and menus to the element they belong to with CSS anchor positioning. Keep a plain fallback where browsers lack it.","check":"A browser test opens the popup, scrolls the page by 200 px and compares bounding boxes. The popup's top edge stays within 1 px of the trigger's bottom edge.","url":"https://agentwranglers.se/playbook/website/interface/anchor-positioning","related":["/playbook/website/interface/popover-api"],"sources":[{"label":"CSS Anchor Positioning Module Level 1","url":"https://www.w3.org/TR/css-anchor-position-1/"}]},{"chapter":"Interface","name":"Wire buttons without click handlers","level":"Optional","rule":"Connect buttons to popovers and dialogs in HTML with `commandfor` and `command`. Keep a fallback for older browsers.","check":"In a browser that supports `command`, a test with JavaScript disabled clicks the button, and the popover or dialog opens.","url":"https://agentwranglers.se/playbook/website/interface/invoker-commands","related":["/playbook/website/interface/dialog-element"],"sources":[{"label":"HTML Standard – The button command attribute","url":"https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-command"}]},{"chapter":"Interface","name":"Let parts fit the space they get","level":"Recommended","rule":"Let components adapt to the space they are given with container queries, not only to the width of the screen.","check":"A browser test renders the component in a 300 px and a 900 px wide container. Neither scrolls sideways, and the layout changes between them.","url":"https://agentwranglers.se/playbook/website/interface/container-queries","related":["/playbook/website/accessibility/no-sideways-scrolling","/playbook/html-css/structure-and-files/queries-with-the-component"],"sources":[{"label":"CSS Conditional 5 – Container queries","url":"https://www.w3.org/TR/css-conditional-5/#container-queries"}]},{"chapter":"Interface","name":"Let the browser balance line breaks","level":"Recommended","rule":"Use `text-wrap: balance` on headings and `text-wrap: pretty` on paragraphs, instead of manual line breaks.","check":"The stylesheet contains both rules, and a build gate finds no `<br>` inside any heading.","url":"https://agentwranglers.se/playbook/website/interface/text-wrap","related":[],"sources":[{"label":"CSS Text 4 – The text-wrap-style property","url":"https://www.w3.org/TR/css-text-4/#text-wrap-style"}]},{"chapter":"Interface","name":"Define every colour once","level":"Optional","rule":"Define every colour once as a `light-dark()` custom property, and switch themes with `color-scheme`, never with duplicate rules.","check":"Search the stylesheet. No colour values appear outside `:root`, apart from black stops in masks, which set transparency rather than colour.","url":"https://agentwranglers.se/playbook/website/interface/colour-as-tokens","related":["/playbook/website/html-basics/theme-color","/playbook/website/html-basics/color-scheme","/playbook/app-ui/styles-and-components/design-tokens","/playbook/app-ui/styles-and-components/purpose-tokens","/playbook/html-css/naming-and-state/component-custom-properties"],"sources":[{"label":"CSS Color 5 – The light-dark function","url":"https://www.w3.org/TR/css-color-5/#light-dark"}]},{"chapter":"Interface","name":"Keep room for the scrollbar","level":"Recommended","rule":"Keep space for the scrollbar with `scrollbar-gutter: stable`, so the page doesn't shift when scrolling is locked or unlocked.","check":"On a desktop browser with classic scrollbars, the `main` element's left edge stays at the same x position before and after a modal opens.","url":"https://agentwranglers.se/playbook/website/interface/scrollbar-gutter","related":["/playbook/website/performance/core-web-vitals"],"sources":[{"label":"CSS Overflow 3 – The scrollbar-gutter property","url":"https://www.w3.org/TR/css-overflow-3/#scrollbar-gutter-property"}]},{"chapter":"Interface","name":"Size full-height panels for phones","level":"Recommended","rule":"Size full-height phone layouts with `dvh`, `svh` or `lvh` instead of `vh`, so they fit when the address bar moves.","check":"A grep of the stylesheet finds no `100vh` without a `dvh`, `svh` or `lvh` line after it.","url":"https://agentwranglers.se/playbook/website/interface/dynamic-viewport-units","related":["/playbook/testing/end-to-end-and-browser-tests/browsers-and-sizes"],"sources":[{"label":"CSS Values 4 – Viewport-percentage lengths","url":"https://www.w3.org/TR/css-values-4/#viewport-variants"}]},{"chapter":"Interface","name":"Animate on scroll with CSS","level":"Optional","rule":"Drive scroll effects with CSS `animation-timeline` instead of scroll event listeners in JavaScript.","check":"A grep of the scripts finds no `scroll` event listener used for visual effects.","url":"https://agentwranglers.se/playbook/website/interface/scroll-driven-animations","related":["/playbook/website/accessibility/reduced-motion","/playbook/html-css/checks-in-code/baseline-target"],"sources":[{"label":"Scroll-driven Animations Module Level 1","url":"https://www.w3.org/TR/scroll-animations-1/"}]},{"chapter":"Interface","name":"Skip page fades unless they help","level":"Optional","rule":"Use View Transitions only where the animation helps people follow a change, and check that it doesn't slow down clicks.","check":"A lab test times click to first paint of the next page, with and without the rule. The result and the decision are written down next to the rule in your stylesheet.","measured":"On a reference site, a 180 ms cross-fade added 33 to 58 ms to every click in Chrome. That is the time to first paint of the next page.","url":"https://agentwranglers.se/playbook/website/interface/view-transitions","related":["/playbook/website/performance/core-web-vitals"],"sources":[{"label":"CSS View Transitions Module Level 2","url":"https://www.w3.org/TR/css-view-transitions-2/"}]},{"chapter":"Performance","name":"Keep load, response and stability fast","level":"Recommended","rule":"Keep the three Core Web Vitals in the good range for at least 75% of visits. LCP (main content shown) stays under 2.5 s, INP (reaction to a tap) under 200 ms and CLS (layout jumps) under 0.1.","check":"The Chrome UX Report shows all three metrics good at the 75th percentile on phones and on desktops. Sites too small for it use their own field data.","url":"https://agentwranglers.se/playbook/website/performance/core-web-vitals","related":["/playbook/website/interface/scrollbar-gutter","/playbook/website/interface/view-transitions","/playbook/website/performance/field-measurement","/playbook/website/performance/font-loading","/playbook/website/performance/long-tasks","/playbook/app-ui/data-views/loading-without-flicker"],"sources":[{"label":"Google Search Central – Core Web Vitals","url":"https://developers.google.com/search/docs/appearance/core-web-vitals"}]},{"chapter":"Performance","name":"Measure speed in visitors' browsers","level":"Recommended","rule":"Collect LCP, INP and CLS from visitors' browsers on every page type, and report the 75th percentile for phones and desktops apart.","check":"A dashboard shows the 75th percentile of each metric per page type for the last 28 days. A test posts a fake metric and the endpoint answers 400.","url":"https://agentwranglers.se/playbook/website/performance/field-measurement","related":["/playbook/website/performance/core-web-vitals","/playbook/website/privacy/analytics-privacy","/playbook/operations/service-levels/service-level-indicators","/playbook/website/performance/preload-prefetch-preconnect"],"sources":[{"label":"W3C Largest Contentful Paint","url":"https://www.w3.org/TR/largest-contentful-paint/"},{"label":"W3C Beacon","url":"https://www.w3.org/TR/beacon/"}]},{"chapter":"Performance","name":"Send small, modern images","level":"Recommended","rule":"Send AVIF or WebP images, each one sized for the screen it lands on with `srcset` and `sizes`, and always give `width` and `height`.","check":"Lighthouse `image-delivery-insight` passes (it replaced the separate image audits in Lighthouse 13). A browser test at 360 and 1280 px downloads one copy of each image. None is wider than twice its displayed width.","measured":"On a reference site, JPEG only (no AVIF, WebP or srcset) cost 0.45 s of mobile LCP and 73 KB. One large image for every screen cost 0.15 s. In an earlier lab, serving phones a 768 px AVIF through a `media` source showed the hero 100 ms sooner on 3× phones.","url":"https://agentwranglers.se/playbook/website/performance/image-optimization","related":["/playbook/website/performance/lazy-loading","/playbook/api-design/files-and-uploads/process-images"],"sources":[{"label":"HTML Standard – Images","url":"https://html.spec.whatwg.org/multipage/images.html"}]},{"chapter":"Performance","name":"Load far-down images later","level":"Recommended","rule":"Lazy-load images and iframes below the fold (load them only when someone scrolls near), and never the main image at the top.","check":"Lighthouse `lcp-discovery-insight` passes (`lcp-lazy-loaded` before Lighthouse 13). A browser test that loads the page without scrolling requests no image from below the first screen.","url":"https://agentwranglers.se/playbook/website/performance/lazy-loading","related":["/playbook/website/performance/image-optimization","/playbook/website/performance/visibility-aware-rendering"],"sources":[{"label":"HTML Standard – Lazy loading attributes","url":"https://html.spec.whatwg.org/multipage/urls-and-fetching.html#lazy-loading-attributes"},{"label":"W3C Intersection Observer","url":"https://www.w3.org/TR/intersection-observer/"}]},{"chapter":"Performance","name":"Hint only what speeds up the first paint","level":"Recommended","rule":"Preload only what the first paint needs, prefetch likely next pages, and preconnect only to other origins you are certain to use.","check":"Compare first paint and LCP with and without each hint in a browser. Every hint left has a before and after measurement behind it.","measured":"On a reference site, two font preloads delayed first paint by 80 ms in Chrome at a 100 ms round trip. Lighthouse's simulation reported the opposite. Removing the hero preload and `fetchpriority` left Lighthouse LCP unchanged at 2.1 s, because the image was early in the HTML.","url":"https://agentwranglers.se/playbook/website/performance/preload-prefetch-preconnect","related":["/playbook/website/performance/field-measurement"],"sources":[{"label":"HTML Standard – The preload link type","url":"https://html.spec.whatwg.org/multipage/links.html#link-type-preload"},{"label":"HTML Standard – The preconnect link type","url":"https://html.spec.whatwg.org/multipage/links.html#link-type-preconnect"}]},{"chapter":"Performance","name":"Send early hints only when they help","level":"Optional","rule":"Send 103 Early Hints (a note sent before the answer) only for critical, safe files. Check that first paint gets faster.","check":"`curl -v` shows the 103. A lab run shows first paint earlier with the hint than without it. If it does not, remove the hint.","measured":"On a reference site (earlier lab), Early Hints could only carry fonts, which delayed paint. A hint for a responsive image couldn't be sent, because the server API rejected the spaces in `imagesrcset`.","url":"https://agentwranglers.se/playbook/website/performance/early-hints","related":["/playbook/website/hosting-and-network/http3"],"sources":[{"label":"RFC 8297 – An HTTP Status Code for Indicating Hints","url":"https://www.rfc-editor.org/rfc/rfc8297"}]},{"chapter":"Performance","name":"Cache files for a year and pages briefly","level":"Recommended","rule":"Cache files with a content hash in their name for a year as `immutable`. Give HTML a `max-age` of at most 600 seconds, or `no-cache`.","check":"`curl -sI` on a hashed asset shows `max-age=31536000` and `immutable`. On an HTML page it shows `no-cache` or a `max-age` of 600 or less, and never `immutable`.","url":"https://agentwranglers.se/playbook/website/performance/cache-control","related":["/playbook/website/site-architecture/keep-the-previous-build-s-assets","/playbook/adversarial-testing/requests-and-files/private-responses-uncached","/playbook/api-design/requests-and-responses/api-cache-headers","/playbook/data/caches-and-copies/cache-freshness"],"sources":[{"label":"RFC 9111 – HTTP Caching","url":"https://www.rfc-editor.org/rfc/rfc9111"},{"label":"RFC 8246 – HTTP Immutable Responses","url":"https://www.rfc-editor.org/rfc/rfc8246"},{"label":"RFC 5861 – Cache-Control Extensions for Stale Content","url":"https://www.rfc-editor.org/rfc/rfc5861"}]},{"chapter":"Performance","name":"Say which request headers change a response","level":"Recommended","rule":"Send `Vary` with every request header that changes a response, such as `Accept-Encoding` and `Accept`.","check":"`curl -sI -H 'Accept-Encoding: br'` on a compressed response shows `Vary` with `Accept-Encoding`. On an address that answers `Accept: text/markdown` with Markdown, both answers show `Vary` with `Accept`.","url":"https://agentwranglers.se/playbook/website/performance/vary","related":["/playbook/website/performance/compression","/playbook/app-ui/client-and-server-state/complete-cache-keys"],"sources":[{"label":"RFC 9110 – The Vary header","url":"https://www.rfc-editor.org/rfc/rfc9110#field.vary"}]},{"chapter":"Performance","name":"Answer repeat requests with 304","level":"Recommended","rule":"Send an `ETag` or `Last-Modified` on responses, and answer matching requests with 304 Not Modified.","check":"`curl -sI -H 'If-None-Match: <etag>'` returns 304.","url":"https://agentwranglers.se/playbook/website/performance/conditional-requests","related":["/playbook/api-design/reliability/optimistic-concurrency"],"sources":[{"label":"RFC 9110 – Conditional requests","url":"https://www.rfc-editor.org/rfc/rfc9110#conditional.requests"},{"label":"RFC 9110 – 304 Not Modified","url":"https://www.rfc-editor.org/rfc/rfc9110#status.304"}]},{"chapter":"Performance","name":"Ignore tracking parameters in caches","level":"Recommended","rule":"Tell caches to ignore tracking parameters with `No-Vary-Search`, so one page isn't stored many times.","check":"`curl -sI` on an HTML page shows the header, and the same page with and without `?utm_source=x` has identical content.","url":"https://agentwranglers.se/playbook/website/performance/no-vary-search","related":["/playbook/website/performance/speculation-rules"],"sources":[{"label":"IETF draft – The No-Vary-Search HTTP Caching Extension","url":"https://datatracker.ietf.org/doc/draft-ietf-httpbis-no-vary-search/"}]},{"chapter":"Performance","name":"Compress every text response","level":"Recommended","rule":"Compress every text response with Brotli (or zstd), with gzip as a fallback, ideally ahead of time at the highest level.","check":"`curl -sI -H 'Accept-Encoding: br'` shows `Content-Encoding: br` for HTML, CSS and JS. With `Accept-Encoding: gzip` the same files come back as `gzip`.","measured":"On a reference site, turning compression off took Lighthouse mobile Performance from 99 to 92 (from 99 to 89 on a second page). It took LCP from 2.1 to 3.3 s (from 2.1 to 3.75 s on the second page). It took page weight from 202 to 403 KB. On those two pages, it was the largest single effect of every feature tested.","url":"https://agentwranglers.se/playbook/website/performance/compression","related":["/playbook/website/performance/vary","/playbook/website/performance/compression-dictionary-transport","/playbook/website/hosting-and-network/tcp-initial-congestion-window"],"sources":[{"label":"RFC 9110 – Accept-Encoding","url":"https://www.rfc-editor.org/rfc/rfc9110#field.accept-encoding"},{"label":"RFC 7932 – Brotli Compressed Data Format","url":"https://www.rfc-editor.org/rfc/rfc7932"},{"label":"RFC 8878 – Zstandard Compression","url":"https://www.rfc-editor.org/rfc/rfc8878"}]},{"chapter":"Performance","name":"Load fonts without moving the page","level":"Recommended","rule":"Self-host fonts as WOFF2, subset them, and add fallback fonts adjusted to the same size. Never let a late font move the page. Use `font-display: optional` on long text, and `swap` only where you measured no shift.","check":"Lighthouse `font-display-insight` passes. Lab CLS stays 0 on your longest pages. With font requests blocked in a browser test, the page still renders all text and first paint is no slower.","measured":"On a reference site, `swap` gave no shift on a short home page. On long pages, text re-wrapped when the fonts arrived, and CLS went from 0 to 0.226 (desktop) and 0.123 (phone). With `optional`, every page measured 0.","url":"https://agentwranglers.se/playbook/website/performance/font-loading","related":["/playbook/website/performance/core-web-vitals"],"sources":[{"label":"W3C WOFF File Format 2.0","url":"https://www.w3.org/TR/WOFF2/"},{"label":"CSS Fonts 4 – The font-display descriptor","url":"https://www.w3.org/TR/css-fonts-4/#font-display-desc"}]},{"chapter":"Performance","name":"Put the styles in the page","level":"Recommended","rule":"Put nothing in the head that blocks the first paint. Inline the key CSS and load the rest without blocking.","check":"Lighthouse `render-blocking-insight` lists no requests (`render-blocking-resources` before Lighthouse 13).","measured":"On a reference site, an 18 KB stylesheet (4.6 KB compressed) was moved from inline to a file. In Chrome at a 100 ms round trip, first paint came 200 ms later, one extra round trip.","url":"https://agentwranglers.se/playbook/website/performance/critical-css","related":["/playbook/website/performance/script-loading"],"sources":[]},{"chapter":"Performance","name":"Never let a script block the page","level":"Recommended","rule":"Load scripts as `type=\"module\"` or with `defer`, keep blocking scripts out of the head, and send as little JavaScript as possible.","check":"Lighthouse mobile Total Blocking Time is under 200 ms. A build gate finds no `<script src>` in the head without `type=\"module\"`, `defer` or `async`.","url":"https://agentwranglers.se/playbook/website/performance/script-loading","related":["/playbook/website/performance/critical-css","/playbook/app-ui/tests-and-budgets/split-heavy-code"],"sources":[{"label":"HTML Standard – Script async and defer","url":"https://html.spec.whatwg.org/multipage/scripting.html#attr-script-defer"}]},{"chapter":"Performance","name":"Break up long tasks","level":"Recommended","rule":"Never run JavaScript for more than 50 ms at a time. Split long work into chunks, and let the browser handle input between them.","check":"A performance trace of a typical click shows no task over 50 ms. Interaction to Next Paint stays under 200 ms in the lab and for visitors.","url":"https://agentwranglers.se/playbook/website/performance/long-tasks","related":["/playbook/website/performance/core-web-vitals"],"sources":[{"label":"W3C Long Tasks API","url":"https://www.w3.org/TR/longtasks-1/"}]},{"chapter":"Performance","name":"Drop old HTTP/1 tricks","level":"Avoid","rule":"Don't use old HTTP/1 tricks such as extra asset domains, image sprites or merging everything into one file. With HTTP/2 they do harm.","check":"A browser test records one origin for all first-party files, and a search of the source finds no sprite sheets.","url":"https://agentwranglers.se/playbook/website/performance/http1-workarounds","related":["/playbook/website/hosting-and-network/http3"],"sources":[{"label":"RFC 9113 – HTTP/2","url":"https://www.rfc-editor.org/rfc/rfc9113"}]},{"chapter":"Performance","name":"Fetch the next page on hover","level":"Recommended","rule":"Fetch or fully load likely next pages ahead of time with Speculation Rules, but skip addresses that change something.","check":"In DevTools › Application › Speculative loads, hover a link and see it prefetched. In the lab, time the first click, or watch CDP `Preload.prefetchStatusUpdated`. The rules exclude every path that signs out, deletes or changes data.","measured":"On a reference site, fetching every link at once (`immediate`) cost 0.6 s of mobile LCP on a short page in Lighthouse. On a page with about 50 links it cost 1.9 s against `moderate`. Data downloaded went from 89 to 403 KB. In Chrome with a warm service worker cache, `immediate` clicks took 63 ms against 65 ms with no rules (phone). In an earlier lab, removing a view transition took a prefetched click from 111 to 59 ms.","url":"https://agentwranglers.se/playbook/website/performance/speculation-rules","related":["/playbook/website/performance/no-vary-search","/playbook/website/performance/bfcache","/playbook/website/standard-addresses/traffic-advice"],"sources":[{"label":"HTML Standard – Speculation rules","url":"https://html.spec.whatwg.org/multipage/speculative-loading.html#speculation-rules"}]},{"chapter":"Performance","name":"Keep the back button instant","level":"Recommended","rule":"Keep every page able to use the back/forward cache, which shows a page instantly on Back. Use no `unload` handlers, and no `Cache-Control: no-store` on ordinary pages.","check":"DevTools › Application › Back/forward cache reports the page as restored. A browser test goes forward and Back, and the `pageshow` event has `persisted` set to true.","url":"https://agentwranglers.se/playbook/website/performance/bfcache","related":["/playbook/website/performance/speculation-rules","/playbook/adversarial-testing/requests-and-files/private-responses-uncached","/playbook/app-ui/forms/unsaved-changes-warning"],"sources":[{"label":"HTML Standard – The PageTransitionEvent interface","url":"https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-pagetransitionevent-interface"}]},{"chapter":"Performance","name":"Skip work for what is off screen","level":"Optional","rule":"Skip drawing content that is off screen with `content-visibility: auto`, and start heavy widgets only when they come into view.","check":"A performance trace of the page load shows less rendering time than without the rule. Lab CLS stays 0 while a browser test scrolls to the bottom.","measured":"On a reference site, removing `content-visibility: auto` from sections far down, together with `contain: content` on cards, cost 0.15 s of mobile LCP in Lighthouse.","url":"https://agentwranglers.se/playbook/website/performance/visibility-aware-rendering","related":["/playbook/website/performance/lazy-loading","/playbook/website/performance/css-containment"],"sources":[{"label":"CSS Containment 2 – The content-visibility property","url":"https://www.w3.org/TR/css-contain-2/#content-visibility"},{"label":"W3C Intersection Observer","url":"https://www.w3.org/TR/intersection-observer/"}]},{"chapter":"Performance","name":"Fence off self-contained parts","level":"Optional","rule":"Use `contain` on self-contained parts of the page, so the browser can limit layout and paint work.","check":"A performance trace shows less layout and paint time than without the rule, and no card clips a menu or focus ring.","url":"https://agentwranglers.se/playbook/website/performance/css-containment","related":["/playbook/website/performance/visibility-aware-rendering"],"sources":[{"label":"CSS Containment 2 – The contain property","url":"https://www.w3.org/TR/css-contain-2/#contain-property"}]},{"chapter":"Performance","name":"Use shared dictionaries for big bundles","level":"Optional","rule":"Use shared compression dictionaries only for bundles over 100 KB compressed that change at least monthly. Skip them otherwise.","check":"In the browser's network panel, the new bundle shows `Content-Encoding: dcb` or `dcz`, and far fewer bytes than with Brotli alone.","url":"https://agentwranglers.se/playbook/website/performance/compression-dictionary-transport","related":["/playbook/website/performance/compression"],"sources":[{"label":"RFC 9842 – Compression Dictionary Transport","url":"https://www.rfc-editor.org/rfc/rfc9842"}]},{"chapter":"Performance","name":"Report how long the server took","level":"Optional","rule":"Report how long the server took in a `Server-Timing` header, without internal names.","check":"`curl -sI URL` shows `Server-Timing` with a `dur` value, and no metric name reveals a database, host or library.","url":"https://agentwranglers.se/playbook/website/performance/server-timing","related":["/playbook/operations/metrics-and-traces/distributed-tracing","/playbook/operations/metrics-and-traces/red-metrics"],"sources":[{"label":"W3C Server Timing","url":"https://www.w3.org/TR/server-timing/"}]},{"chapter":"Performance","name":"Fail the build when pages get heavier","level":"Recommended","rule":"Set a budget for page weight, request count and lab metrics, and make the build fail when a change goes over it.","check":"Add a large image or script on a branch. The build fails and names the budget that was passed.","url":"https://agentwranglers.se/playbook/website/performance/performance-budget","related":["/playbook/engineering/rules-as-code/build-gates","/playbook/testing/non-functional-tests/load-tests","/playbook/app-ui/tests-and-budgets/route-bundle-budget"],"sources":[]},{"chapter":"Performance","name":"Send video that starts fast and costs little","level":"Recommended","rule":"Use a `video` instead of an animated GIF. Give every video a `poster`, and set `preload` to `none` or `metadata` unless it plays at once.","check":"A crawl finds no animated GIF. Every `video` has a `poster`, and a `preload` of `none` or `metadata` unless it has `autoplay muted`. A range request for each video file answers 206 with a `Content-Range` header.","url":"https://agentwranglers.se/playbook/website/performance/video-delivery","related":["/playbook/website/accessibility/captions-and-transcripts"],"sources":[{"label":"HTML Standard – The preload attribute","url":"https://html.spec.whatwg.org/multipage/media.html#attr-media-preload"},{"label":"RFC 9110 – Range Requests","url":"https://www.rfc-editor.org/rfc/rfc9110.html#section-14"}]},{"chapter":"SEO","name":"Tell crawlers where they may go","level":"Recommended","rule":"Serve a plain-text `/robots.txt` at the root of every host. It allows public content, blocks only private paths and names the sitemap.","check":"`curl -sI https://example.com/robots.txt` shows status 200 and `content-type: text/plain`. The body has no `Disallow: /` line for `User-agent: *`, and its `Sitemap:` URL answers 200. Search Console's robots.txt report shows no errors.","measured":"On a reference site, removing robots.txt entirely took Lighthouse SEO from 92 to 100. The 92 came only from its `Content-Signal` lines, so the score does not reward having a robots.txt.","url":"https://agentwranglers.se/playbook/website/seo/robots-txt","related":["/playbook/website/seo/meta-robots","/playbook/website/seo/faceted-navigation"],"sources":[{"label":"RFC 9309 – Robots Exclusion Protocol","url":"https://www.rfc-editor.org/rfc/rfc9309"},{"label":"Google Search Central – How Google interprets robots.txt","url":"https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt"}]},{"chapter":"SEO","name":"List every public page in a sitemap","level":"Recommended","rule":"Publish an XML sitemap, a list of your pages for search engines. List only canonical public URLs that answer 200, each with an honest last-changed date.","check":"A test fetches every `<loc>`. Each answers 200 without a redirect, carries no `noindex`, and its canonical equals the `<loc>`.","url":"https://agentwranglers.se/playbook/website/seo/xml-sitemaps","related":["/playbook/website/seo/search-console","/playbook/website/seo/sitemap-index","/playbook/website/seo/image-sitemaps","/playbook/website/seo/indexnow"],"sources":[{"label":"Google Search Central – Build and submit a sitemap","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap"},{"label":"W3C Date and Time Formats","url":"https://www.w3.org/TR/NOTE-datetime"}]},{"chapter":"SEO","name":"Split sitemaps only when huge","level":"Optional","rule":"Split the sitemap only when one file would pass 50,000 URLs or 50 MB uncompressed. List the parts in a sitemap index. Below those limits, keep one file.","check":"A test counts the `<loc>` entries and bytes of every sitemap file. Each stays under 50,000 URLs and 50 MB uncompressed. Every sitemap listed in the index answers 200.","url":"https://agentwranglers.se/playbook/website/seo/sitemap-index","related":["/playbook/website/seo/xml-sitemaps"],"sources":[{"label":"Google Search Central – Manage large sitemaps","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps"}]},{"chapter":"SEO","name":"List key images in the sitemap","level":"Optional","rule":"List important images in the sitemap with the image extension. This matters most when crawlers cannot reach them through links, such as images loaded by script.","check":"A test fetches every `<image:loc>`. Each answers 200 with an `image/*` content type.","url":"https://agentwranglers.se/playbook/website/seo/image-sitemaps","related":["/playbook/website/seo/xml-sitemaps"],"sources":[{"label":"Google Search Central – Image sitemaps","url":"https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps"}]},{"chapter":"SEO","name":"Keep addresses short and readable","level":"Recommended","rule":"Keep addresses lowercase, hyphenated, descriptive and shallow. Leave out file endings and session parameters, and treat addresses as permanent.","check":"A crawl matches every URL against `^https://example\\.com(/[a-z0-9]+(-[a-z0-9]+)*)*$`, adapted to your chosen slash style. No URL fails.","url":"https://agentwranglers.se/playbook/website/seo/url-structure","related":["/playbook/website/redirects-and-canonicals/redirect-map"],"sources":[{"label":"Google Search Central – URL structure","url":"https://developers.google.com/search/docs/crawling-indexing/url-structure"},{"label":"RFC 3986 – URI Generic Syntax","url":"https://www.rfc-editor.org/rfc/rfc3986"}]},{"chapter":"SEO","name":"Send the content in the HTML","level":"Recommended","rule":"Put each page's content, metadata, links and structured data in the HTML the server sends. Don't leave them to JavaScript to build later.","check":"For every page type, a test fetches the HTML without running scripts, for example with `curl -s URL`. It finds the `h1` text, the canonical, the JSON-LD and the main links. The page also works with JavaScript off.","measured":"On a reference site, we built the same content in the browser instead of the HTML. Mobile Performance dropped from 99 to 85, and layout shift rose from 0 to 0.295.","url":"https://agentwranglers.se/playbook/website/seo/server-side-rendering","related":["/playbook/website/html-basics/title","/playbook/website/site-architecture/progressive-enhancement","/playbook/stack/web-frameworks/nextjs"],"sources":[{"label":"Google Search Central – JavaScript SEO basics","url":"https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics"}]},{"chapter":"SEO","name":"Serve phones the same content","level":"Recommended","rule":"Give phone visitors the same content, links, metadata and structured data as desktop visitors. Google indexes the phone version of every page.","check":"Fetch each page type once with a phone user agent and once with a desktop one. The `h1`, link count, canonical and JSON-LD match.","url":"https://agentwranglers.se/playbook/website/seo/mobile-first-indexing","related":["/playbook/website/accessibility/hidden-until-found"],"sources":[{"label":"Google Search Central – Mobile-first indexing","url":"https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing"}]},{"chapter":"SEO","name":"Keep private pages out of search","level":"Recommended","rule":"Leave public pages open to search engines. Mark error, private, utility and internal search-result responses `noindex`, with a meta tag or `X-Robots-Tag` header.","check":"Lighthouse `is-crawlable` passes on every public page. A test requests an error page, a search-result page and a private page, and each carries `noindex` in the meta tag or header.","measured":"On a reference site, an accidental `noindex` dropped Lighthouse SEO from 92 to 61.","url":"https://agentwranglers.se/playbook/website/seo/meta-robots","related":["/playbook/website/seo/robots-txt","/playbook/adversarial-testing/access-and-accounts/deny-by-default"],"sources":[{"label":"Google Search Central – Robots meta tag and X-Robots-Tag","url":"https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag"}]},{"chapter":"SEO","name":"Use one h1 and keep headings in order","level":"Recommended","rule":"Give every page exactly one `h1`, and a heading outline that never skips a level. Never use headings just for their look.","check":"The build counts `<h1>` on every page and fails on anything but one. axe `heading-order` passes.","url":"https://agentwranglers.se/playbook/website/seo/heading-hierarchy","related":["/playbook/website/accessibility/semantic-html"],"sources":[{"label":"HTML Standard – Headings and outlines","url":"https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines"},{"label":"WCAG 2.2 – 1.3.1 Info and Relationships","url":"https://www.w3.org/TR/WCAG22/#info-and-relationships"}]},{"chapter":"SEO","name":"Link every page from another","level":"Recommended","rule":"Link every page from others with `<a href>` links and text that says where they go, so no page is left orphaned.","check":"A crawler starting at the home page and following only `<a href>` links reaches every page in the sitemap.","url":"https://agentwranglers.se/playbook/website/seo/internal-linking","related":["/playbook/website/accessibility/link-text","/playbook/website/accessibility/multiple-ways"],"sources":[{"label":"Google Search Central – Crawlable links","url":"https://developers.google.com/search/docs/crawling-indexing/links-crawlable"}]},{"chapter":"SEO","name":"Link related pages both ways","level":"Recommended","rule":"Build a related list on every page from the links in your content. It holds the pages it links to and the pages that link to it. Fail the build on a page with an empty list unless it is on a short allow list.","check":"Remove the only link to and from one page. The build stops and names it. Every link in the machine-readable index has a matching link back.","measured":"On a reference site, rule pages with at least one related page went from 571 to 850 of 1,099 after one pass of new links.","url":"https://agentwranglers.se/playbook/website/seo/two-way-related-links","related":["/playbook/website/ready-for-ai-agents/machine-readable-formats"],"sources":[]},{"chapter":"SEO","name":"Give the glossary its own page","level":"Recommended","rule":"Put definitions on one glossary page with an anchor for each term. Never append a long list of terms to a hub or a landing page.","check":"The glossary answers 200 at its own address and is in the sitemap. Every term has a unique `id`. The hub has no more than 10 defined terms in its own text.","measured":"On a reference site, moving 138 terms off the hub cut its source from about 2,600 to 200 words.","url":"https://agentwranglers.se/playbook/website/seo/glossary-page","related":["/playbook/engineering/documentation/cite-primary-sources"],"sources":[{"label":"Schema.org DefinedTermSet","url":"https://schema.org/DefinedTermSet"}]},{"chapter":"SEO","name":"Give paginated lists their own addresses","level":"Recommended","rule":"Give every page of a long list its own URL, linked with plain `<a href>` links. Each page carries a canonical to itself, never to page one.","check":"A crawler following only `<a href>` reaches the last page of every list. Each page's canonical equals its own URL.","url":"https://agentwranglers.se/playbook/website/seo/pagination","related":["/playbook/website/redirects-and-canonicals/canonical-signals"],"sources":[{"label":"Google Search Central – Pagination and incremental loading","url":"https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading"}]},{"chapter":"SEO","name":"Keep filter combinations out of the crawl","level":"Recommended","rule":"Stop crawlers from following every combination of filters and sorting. Let them crawl only the filtered pages you want found.","check":"A crawl from the home page finds no URL with more than one filter parameter. An empty filter combination answers 404.","url":"https://agentwranglers.se/playbook/website/seo/faceted-navigation","related":["/playbook/website/seo/robots-txt"],"sources":[{"label":"Google Search Central – Managing faceted navigation","url":"https://developers.google.com/search/docs/crawling-indexing/crawling-managing-faceted-navigation"}]},{"chapter":"SEO","name":"Describe each page for machines","level":"Recommended","rule":"Describe each page with schema.org JSON-LD, a block of data search engines read. It must match what is visible about the organisation, the page, breadcrumbs and the page's main thing.","check":"The build parses every page's JSON-LD and fails on invalid JSON. Google's Rich Results Test and the Schema.org validator report no errors on each page type.","url":"https://agentwranglers.se/playbook/website/seo/structured-data","related":["/playbook/email/automation-and-agents/email-markup","/playbook/website/seo/breadcrumbs"],"sources":[{"label":"Schema.org – Getting started","url":"https://schema.org/docs/gs.html"},{"label":"W3C JSON-LD 1.1","url":"https://www.w3.org/TR/json-ld11/"},{"label":"Google Search Central – Intro to structured data","url":"https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data"}]},{"chapter":"SEO","name":"Show where the page sits","level":"Recommended","rule":"Show a breadcrumb trail on inner pages, with the current page not linked. Mark it up as a matching `BreadcrumbList`.","check":"A test reads the visible trail and the JSON-LD on every inner page. The names and URLs match in the same order.","url":"https://agentwranglers.se/playbook/website/seo/breadcrumbs","related":["/playbook/website/seo/structured-data"],"sources":[{"label":"Google Search Central – Breadcrumb structured data","url":"https://developers.google.com/search/docs/appearance/structured-data/breadcrumb"},{"label":"WAI-ARIA APG – Breadcrumb pattern","url":"https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/"}]},{"chapter":"SEO","name":"Watch how search engines see the site","level":"Recommended","rule":"Verify the site in Google Search Console and Bing Webmaster Tools, submit the sitemap, and read the indexing reports after every large change.","check":"Both tools show the domain verified and the sitemap read with no errors. The DNS TXT records exist.","url":"https://agentwranglers.se/playbook/website/seo/search-console","related":["/playbook/website/seo/xml-sitemaps","/playbook/website/redirects-and-canonicals/watch-not-found"],"sources":[]},{"chapter":"SEO","name":"Tell search engines what changed","level":"Optional","rule":"Tell search engines that support IndexNow, such as Bing, Yandex and Seznam, about new and changed pages. Prove the site is yours with a key file served from it.","check":"The API answers 200 or 202.","url":"https://agentwranglers.se/playbook/website/seo/indexnow","related":["/playbook/website/html-basics/websub","/playbook/website/seo/xml-sitemaps"],"sources":[]},{"chapter":"SEO","name":"Mark links you do not vouch for","level":"Recommended","rule":"Mark links in user content with `rel=\"ugc\"`, paid links with `rel=\"sponsored\"`, and other links you don't vouch for with `rel=\"nofollow\"`.","check":"Post a comment with a link on a test copy. The rendered link carries `rel=\"ugc nofollow\"`. A crawl finds no paid link without `sponsored`.","url":"https://agentwranglers.se/playbook/website/seo/user-link-attributes","related":["/playbook/adversarial-testing/hostile-agents-and-bots/mark-user-content"],"sources":[{"label":"Google Search Central – Qualify outbound links","url":"https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links"}]},{"chapter":"SEO","name":"Show the same date to people and machines","level":"Recommended","rule":"Show a visible published or updated date on every article, and give the same date in `datePublished` and `dateModified`. Never give a future date, or the date of an event the page describes.","check":"A build test reads each article. Its visible `<time datetime>` equals `datePublished` or `dateModified`, `dateModified` equals the sitemap `lastmod`, and no date is later than the build.","url":"https://agentwranglers.se/playbook/website/seo/publication-dates","related":["/playbook/api-design/requests-and-responses/dates-and-times"],"sources":[{"label":"Google Search Central – Influence the byline date","url":"https://developers.google.com/search/docs/appearance/publication-dates"}]},{"chapter":"SEO","name":"Choose what search and AI answers may quote","level":"Optional","rule":"Keep text out of search snippets with `data-nosnippet`, and limit whole pages with `nosnippet` or `max-snippet`. At Google, these also limit what AI Overviews and AI Mode use.","check":"A test fetches each page on the list of limited pages. Its robots meta tag or `X-Robots-Tag` carries the chosen directive, and every `data-nosnippet` sits on a `span`, `div` or `section`.","url":"https://agentwranglers.se/playbook/website/seo/snippet-controls","related":["/playbook/website/ready-for-ai-agents/robots-for-ai-crawlers"],"sources":[{"label":"Google Search Central – Robots meta tag, data-nosnippet and X-Robots-Tag","url":"https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag"}]},{"chapter":"Redirects and canonicals","name":"Keep every old address in one redirect map","level":"Recommended","rule":"Keep every address you have ever published in one redirect map, and check it before any other route. Never delete an entry.","check":"A test requests every entry and expects one 301 to an address that answers 200. The build fails if an entry points at a page that does not exist. No sitemap address ends in a server file type.","measured":"On a reference site, restructuring a guide moved 129 addresses in one change. Every old link kept working in one hop, which a test proves on every build.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/redirect-map","related":["/playbook/website/seo/url-structure","/playbook/adversarial-testing/requests-and-files/block-open-redirects","/playbook/website/redirects-and-canonicals/redirect-by","/playbook/accounts/profile-and-account-lifecycle/username-change"],"sources":[{"label":"RFC 9110 – 301 Moved Permanently","url":"https://www.rfc-editor.org/rfc/rfc9110#status.301"},{"label":"Google Search Central – Redirects and Google Search","url":"https://developers.google.com/search/docs/crawling-indexing/301-redirects"}]},{"chapter":"Redirects and canonicals","name":"Send each old page to its closest new page","level":"Recommended","rule":"Redirect each old address to the new page that best replaces it. Never send many old pages to the home page.","check":"A test reads the map. No entry points at `/` unless its source was a home page address, such as `/index.html`. Any target shared by more than five sources is listed for a person to confirm.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/closest-match","related":["/playbook/website/redirects-and-canonicals/redirect-codes","/playbook/website/redirects-and-canonicals/watch-not-found"],"sources":[{"label":"Google Search Central – Redirects and Google Search","url":"https://developers.google.com/search/docs/crawling-indexing/301-redirects"}]},{"chapter":"Redirects and canonicals","name":"Answer 404 or 410 when a page is gone for good","level":"Recommended","rule":"Answer unknown addresses and content removed with no replacement with 404 or 410 and a helpful page. Never show \"not found\" with a 200, and never send unknown pages to the home page.","check":"Request a removed address and a made-up one, such as `curl -s -o /dev/null -w '%{http_code}' https://example.com/no-such-page`. Each answers 404 or 410, never 200 and never a redirect to the home page.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/gone-pages","related":["/playbook/website/when-things-break/error-pages"],"sources":[{"label":"RFC 9110 – 404 Not Found","url":"https://www.rfc-editor.org/rfc/rfc9110#status.404"},{"label":"RFC 9110 – 410 Gone","url":"https://www.rfc-editor.org/rfc/rfc9110#status.410"},{"label":"Google Search Central – HTTP status codes","url":"https://developers.google.com/search/docs/crawling-indexing/http-network-errors"}]},{"chapter":"Redirects and canonicals","name":"Link only to final addresses","level":"Recommended","rule":"Link only to addresses that answer 200. Never link to a redirect from your own pages, sitemap, feeds or canonical tags.","check":"The build rejects any page whose links point at a redirect. Fetching every sitemap address returns 200 with no redirect.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/final-addresses","related":["/playbook/website/redirects-and-canonicals/check-outbound-links"],"sources":[]},{"chapter":"Redirects and canonicals","name":"Use the right redirect code","level":"Recommended","rule":"Use a server-side permanent redirect (301 or 308) for anything that moved for good. Use 302 or 307 only for short-term moves, and 303 only to show a result page after a form post.","check":"`curl -sI` on an old address shows 301 or 308 and a `location` header with the final address. `curl -sIL` shows at most one redirect before a 200.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/redirect-codes","related":["/playbook/website/redirects-and-canonicals/closest-match","/playbook/api-design/resources-and-methods/status-codes","/playbook/website/redirects-and-canonicals/client-side-redirects"],"sources":[{"label":"RFC 9110 – Redirection 3xx","url":"https://www.rfc-editor.org/rfc/rfc9110#status.3xx"},{"label":"Google Search Central – HTTP status codes","url":"https://developers.google.com/search/docs/crawling-indexing/http-network-errors"}]},{"chapter":"Redirects and canonicals","name":"Avoid meta refresh and JavaScript redirects","level":"Avoid","rule":"Don't redirect with `<meta http-equiv=\"refresh\">` or JavaScript. Do it on the server.","check":"Search the built pages for `http-equiv=\"refresh\"`, `location.href =`, `location.replace(` and `location.assign(`. None appears.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/client-side-redirects","related":["/playbook/website/redirects-and-canonicals/redirect-codes"],"sources":[{"label":"Google Search Central – Redirects and Google Search","url":"https://developers.google.com/search/docs/crawling-indexing/301-redirects"}]},{"chapter":"Redirects and canonicals","name":"Make every canonical signal agree","level":"Recommended","rule":"Give each page one canonical address, and make the canonical tag, sitemap, Open Graph address, hreflang links and internal links all use exactly that address.","check":"A test reads each page's canonical, `og:url` and sitemap entry and expects the same address, answering 200 without a redirect. Each page has exactly one canonical tag. The Lighthouse `canonical` audit passes.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/canonical-signals","related":["/playbook/website/html-basics/open-graph","/playbook/website/seo/pagination"],"sources":[{"label":"RFC 6596 – The Canonical Link Relation","url":"https://www.rfc-editor.org/rfc/rfc6596"},{"label":"Google Search Central – Consolidate duplicate URLs","url":"https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls"}]},{"chapter":"Redirects and canonicals","name":"Move a site or domain without losing links","level":"Recommended","rule":"When a whole site or domain moves, redirect every old address one to one. Keep the redirects as long as possible, and at least a year.","check":"A test requests every address from the old sitemap on the old domain, over HTTP and HTTPS. Each answers one 301 to the matching page on the new one, which answers 200.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/site-moves","related":["/playbook/infrastructure/networking-and-dns/lower-ttl-before-changes"],"sources":[{"label":"Google Search Central – Site moves with URL changes","url":"https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes"}]},{"chapter":"Redirects and canonicals","name":"Watch for broken old links","level":"Recommended","rule":"Every week, list the missing addresses people still request, and add a redirect for each one with a replacement.","check":"Run the report weekly. Each of the 20 most requested 404 paths has a redirect entry or is a known probe on an ignore list.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/watch-not-found","related":["/playbook/website/seo/search-console","/playbook/website/redirects-and-canonicals/closest-match"],"sources":[]},{"chapter":"Redirects and canonicals","name":"Check the links you send people to","level":"Recommended","rule":"Every week, fetch every link that points to another site, and alert when one fails twice in a row.","check":"Point one link at an address that answers 404. The next run alerts with that address.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/check-outbound-links","related":["/playbook/engineering/documentation/watch-cited-sources","/playbook/website/redirects-and-canonicals/final-addresses"],"sources":[]},{"chapter":"Redirects and canonicals","name":"Say who made each redirect","level":"Optional","rule":"Name the system that made each redirect in a `Redirect-By` header, so you can trace redirects.","check":"`curl -sI` on a redirect shows the header.","url":"https://agentwranglers.se/playbook/website/redirects-and-canonicals/redirect-by","related":["/playbook/website/redirects-and-canonicals/redirect-map"],"sources":[]},{"chapter":"Accessibility","name":"Make text easy to read","level":"Required","risk":"law","rule":"Give text at least 4.5:1 contrast against its background, and large text at least 3:1. Give control borders, focus rings and meaningful icons at least 3:1. Meet this in light and dark mode.","check":"axe `color-contrast` passes in light and dark mode. A unit test computes every token pair from the stylesheet and fails below the thresholds.","measured":"On a reference site, a paler grey for secondary text took Lighthouse Accessibility from 100 to 96.","url":"https://agentwranglers.se/playbook/website/accessibility/color-contrast","related":["/playbook/website/accessibility/focus-indicators","/playbook/email/layout-and-accessibility/email-contrast","/playbook/email/layout-and-accessibility/readable-text"],"sources":[{"label":"WCAG 2.2 – 1.4.3 Contrast (Minimum)","url":"https://www.w3.org/TR/WCAG22/#contrast-minimum"},{"label":"WCAG 2.2 – 1.4.11 Non-text Contrast","url":"https://www.w3.org/TR/WCAG22/#non-text-contrast"}]},{"chapter":"Accessibility","name":"Let the browser pick readable text colour","level":"Optional","rule":"Let the browser pick readable black or white text on brand colours with `contrast-color()`, after a fallback you have checked.","check":"A unit test computes the contrast of the fallback colour and of black or white on every brand colour. Each pair used reaches 4.5:1.","url":"https://agentwranglers.se/playbook/website/accessibility/contrast-color","related":["/playbook/email/layout-and-accessibility/email-contrast"],"sources":[{"label":"CSS Color 5 – The contrast-color function","url":"https://www.w3.org/TR/css-color-5/#contrast-color"}]},{"chapter":"Accessibility","name":"Work in high-contrast mode","level":"Recommended","rule":"Test the site in forced-colours mode, a high-contrast setting in Windows. Repair what it flattens under `forced-colors: active`.","check":"A browser test emulates forced colours, for example Playwright's `emulateMedia({ forcedColors: 'active' })`. Every button, input and card has a computed border or outline that is not `none`.","url":"https://agentwranglers.se/playbook/website/accessibility/forced-colors","related":["/playbook/website/accessibility/not-color-alone"],"sources":[{"label":"Media Queries 5 – The forced-colors feature","url":"https://www.w3.org/TR/mediaqueries-5/#forced-colors"}]},{"chapter":"Accessibility","name":"Describe images in words","level":"Required","risk":"law","rule":"Give every `<img>` an `alt` attribute. Say what it shows if it carries meaning, or leave it empty if it is decoration. This is WCAG criterion 1.1.1 (A).","check":"axe `image-alt` passes. A test fails on any `alt` that equals the file name or starts with \"image of\" or \"picture of\".","measured":"On a reference site, removing the hero image's `alt` attribute took Lighthouse Accessibility from 100 to 95 and SEO from 92 to 85.","url":"https://agentwranglers.se/playbook/website/accessibility/image-alt-text","related":["/playbook/email/layout-and-accessibility/image-alt-text","/playbook/email/layout-and-accessibility/live-text"],"sources":[{"label":"WCAG 2.2 – 1.1.1 Non-text Content","url":"https://www.w3.org/TR/WCAG22/#non-text-content"},{"label":"WCAG 2.2 – 1.4.5 Images of Text","url":"https://www.w3.org/TR/WCAG22/#images-of-text"}]},{"chapter":"Accessibility","name":"Label every form field","level":"Required","risk":"law","rule":"Give every form field a visible label tied to it with `<label for>`. A placeholder is not a label. These are WCAG criteria 1.3.1, 3.3.2 and 4.1.2 (A).","check":"axe `label` passes. A browser test clicks each label, and focus lands in its field.","url":"https://agentwranglers.se/playbook/website/accessibility/form-labels","related":["/playbook/app-ui/forms/validation-timing"],"sources":[{"label":"WCAG 2.2 – 1.3.1 Info and Relationships","url":"https://www.w3.org/TR/WCAG22/#info-and-relationships"},{"label":"WCAG 2.2 – 3.3.2 Labels or Instructions","url":"https://www.w3.org/TR/WCAG22/#labels-or-instructions"},{"label":"WCAG 2.2 – 4.1.2 Name, Role, Value","url":"https://www.w3.org/TR/WCAG22/#name-role-value"}]},{"chapter":"Accessibility","name":"Put the visible words in the name","level":"Required","risk":"law","rule":"When a control shows text, its accessible name must contain that text, ideally at the start. This is WCAG criterion 2.5.3 (A).","check":"axe `label-content-name-mismatch` passes. A test finds no control with visible text whose `aria-label` leaves that text out.","url":"https://agentwranglers.se/playbook/website/accessibility/label-in-name","related":["/playbook/testing/end-to-end-and-browser-tests/stable-selectors"],"sources":[{"label":"WCAG 2.2 – 2.5.3 Label in Name","url":"https://www.w3.org/TR/WCAG22/#label-in-name"}]},{"chapter":"Accessibility","name":"Make everything work by keyboard","level":"Required","risk":"law","rule":"Every link, button and control must work with just a keyboard. Tab moves in reading order, and focus never gets stuck. These are WCAG criteria 2.1.1, 2.1.2 and 2.4.3 (A).","check":"A browser test presses Tab through each page type and records every element that takes focus. Every visible link, button and field is reached, focus returns to the start, and axe `tabindex` passes.","url":"https://agentwranglers.se/playbook/website/accessibility/keyboard-navigation","related":["/playbook/app-ui/lists-and-tables/grid-keyboard","/playbook/website/accessibility/character-key-shortcuts","/playbook/website/accessibility/focus-not-obscured"],"sources":[{"label":"WCAG 2.2 – 2.1.1 Keyboard","url":"https://www.w3.org/TR/WCAG22/#keyboard"},{"label":"WCAG 2.2 – 2.1.2 No Keyboard Trap","url":"https://www.w3.org/TR/WCAG22/#no-keyboard-trap"},{"label":"WCAG 2.2 – 2.4.3 Focus Order","url":"https://www.w3.org/TR/WCAG22/#focus-order"}]},{"chapter":"Accessibility","name":"Never take over single keys","level":"Required","risk":"law","rule":"Never use a shortcut that is a single letter, number or symbol key unless people can turn it off or remap it. This is WCAG criterion 2.1.4 (A).","check":"A browser test presses each letter and digit with focus on the page body. Nothing changes on the page.","url":"https://agentwranglers.se/playbook/website/accessibility/character-key-shortcuts","related":["/playbook/website/accessibility/keyboard-navigation"],"sources":[{"label":"WCAG 2.2 – 2.1.4 Character Key Shortcuts","url":"https://www.w3.org/TR/WCAG22/#character-key-shortcuts"}]},{"chapter":"Accessibility","name":"Always show where focus is","level":"Required","risk":"law","rule":"Show a clear focus ring on every control when someone reaches it with the keyboard. This is WCAG criterion 2.4.7 (AA).","check":"A browser test tabs to every control in light and dark mode. Each one's computed `outline-style` is not `none` and its `outline-width` is at least 2px.","url":"https://agentwranglers.se/playbook/website/accessibility/focus-indicators","related":["/playbook/website/accessibility/color-contrast"],"sources":[{"label":"WCAG 2.2 – 2.4.7 Focus Visible","url":"https://www.w3.org/TR/WCAG22/#focus-visible"},{"label":"WCAG 2.2 – 1.4.11 Non-text Contrast","url":"https://www.w3.org/TR/WCAG22/#non-text-contrast"},{"label":"WCAG 2.2 – 2.4.13 Focus Appearance","url":"https://www.w3.org/TR/WCAG22/#focus-appearance"}]},{"chapter":"Accessibility","name":"Never hide the focused element","level":"Required","risk":"law","rule":"Never let a sticky header, cookie banner or chat widget cover the element that has keyboard focus. WCAG 2.2 criterion 2.4.11 (AA) forbids hiding it entirely. Aim to hide no part of it, as 2.4.12 (AAA) asks.","check":"A browser test tabs through each long page. For every focused element, `document.elementFromPoint` at its centre returns the element or something inside it.","url":"https://agentwranglers.se/playbook/website/accessibility/focus-not-obscured","related":["/playbook/website/accessibility/keyboard-navigation"],"sources":[{"label":"WCAG 2.2 – 2.4.11 Focus Not Obscured (Minimum)","url":"https://www.w3.org/TR/WCAG22/#focus-not-obscured-minimum"},{"label":"WCAG 2.2 – 2.4.12 Focus Not Obscured (Enhanced)","url":"https://www.w3.org/TR/WCAG22/#focus-not-obscured-enhanced"}]},{"chapter":"Accessibility","name":"Let people skip to the content","level":"Required","risk":"law","rule":"Make a \"Skip to main content\" link the first thing that takes focus on every page. It meets WCAG criterion 2.4.1 (A) together with landmarks.","check":"A browser test presses Tab once after load. Focus is on a visible link to `#main`. Pressing Enter moves focus into `main`.","url":"https://agentwranglers.se/playbook/website/accessibility/skip-links","related":["/playbook/website/accessibility/semantic-html"],"sources":[{"label":"WCAG 2.2 – 2.4.1 Bypass Blocks","url":"https://www.w3.org/TR/WCAG22/#bypass-blocks"}]},{"chapter":"Accessibility","name":"Lock the page behind open menus","level":"Recommended","rule":"Make everything behind an open menu or dialog unreachable with the `inert` attribute.","check":"A browser test opens the menu and presses Tab 50 times. Focus never lands on an element inside the parts marked `inert`.","url":"https://agentwranglers.se/playbook/website/accessibility/inert-attribute","related":["/playbook/app-ui/feedback-and-dialogs/managed-focus"],"sources":[{"label":"HTML Standard – The inert attribute","url":"https://html.spec.whatwg.org/multipage/interaction.html#the-inert-attribute"}]},{"chapter":"Accessibility","name":"Use the right element for the job","level":"Required","risk":"law","rule":"Use landmark elements (`header`, `nav`, `main`, `footer`) and the right element for each job, with one `main` per page. This is WCAG criterion 1.3.1 (A).","check":"axe `landmark-one-main`, `region`, `landmark-unique`, `list` and `listitem` pass.","url":"https://agentwranglers.se/playbook/website/accessibility/semantic-html","related":["/playbook/website/seo/heading-hierarchy","/playbook/website/accessibility/skip-links","/playbook/email/layout-and-accessibility/semantic-structure"],"sources":[{"label":"WCAG 2.2 – 1.3.1 Info and Relationships","url":"https://www.w3.org/TR/WCAG22/#info-and-relationships"}]},{"chapter":"Accessibility","name":"Reach for HTML before ARIA","level":"Recommended","rule":"Use native HTML elements first. Add ARIA, extra attributes for screen readers, only for what no native element can say.","check":"axe reports no violations of its `aria-*` rules. A search of the templates finds no `role=\"button\"` or `role=\"link\"`.","url":"https://agentwranglers.se/playbook/website/accessibility/aria-usage","related":["/playbook/website/accessibility/native-interactive-elements","/playbook/website/accessibility/css-state-selectors"],"sources":[{"label":"W3C ARIA in HTML","url":"https://www.w3.org/TR/html-aria/"},{"label":"W3C Using ARIA","url":"https://www.w3.org/TR/using-aria/"},{"label":"WCAG 2.2 – 4.1.2 Name, Role, Value","url":"https://www.w3.org/TR/WCAG22/#name-role-value"}]},{"chapter":"Accessibility","name":"Say where each link goes","level":"Required","risk":"law","rule":"Write link text that says where the link goes. Never write \"click here\" or \"read more\". This meets WCAG criterion 2.4.4 (A).","check":"Lighthouse `link-text` passes. A test fails on any link whose accessible name is only \"click here\", \"here\", \"more\" or \"read more\".","measured":"On a reference site, renaming two footer links \"Click here\" and \"Read more\" took Lighthouse SEO from 92 to 85.","url":"https://agentwranglers.se/playbook/website/accessibility/link-text","related":["/playbook/website/seo/internal-linking","/playbook/website/accessibility/empty-links-buttons"],"sources":[{"label":"WCAG 2.2 – 2.4.4 Link Purpose (In Context)","url":"https://www.w3.org/TR/WCAG22/#link-purpose-in-context"}]},{"chapter":"Accessibility","name":"Make links look like links","level":"Recommended","rule":"Underline every text link in content with one subtle, consistent style. Leave it off only in navigation bars and on links styled as buttons, where the shape already says \"link\".","check":"axe `link-in-text-block` passes. A search of the stylesheet finds `text-decoration: none` only on buttons and header navigation.","url":"https://agentwranglers.se/playbook/website/accessibility/links-look-like-links","related":["/playbook/website/accessibility/not-color-alone"],"sources":[{"label":"WCAG 2.2 – 1.4.1 Use of Color","url":"https://www.w3.org/TR/WCAG22/#use-of-color"}]},{"chapter":"Accessibility","name":"Never use colour as the only signal","level":"Required","risk":"law","rule":"Never show meaning by colour alone, such as red for an error or a coloured line in a chart. Add text, an icon, a pattern or an underline. This is WCAG criterion 1.4.1 (A).","check":"axe `link-in-text-block` passes. A browser test takes greyscale screenshots of error, required and chart states, and each still shows a word, icon or pattern.","url":"https://agentwranglers.se/playbook/website/accessibility/not-color-alone","related":["/playbook/website/accessibility/forced-colors","/playbook/website/accessibility/links-look-like-links","/playbook/email/layout-and-accessibility/email-contrast"],"sources":[{"label":"WCAG 2.2 – 1.4.1 Use of Color","url":"https://www.w3.org/TR/WCAG22/#use-of-color"}]},{"chapter":"Accessibility","name":"Name every link and button","level":"Required","risk":"law","rule":"Never ship a link or button without a name a screen reader can read. Give icon-only controls an `aria-label`. This is WCAG criterion 4.1.2 (A).","check":"axe `button-name` and `link-name` pass.","url":"https://agentwranglers.se/playbook/website/accessibility/empty-links-buttons","related":["/playbook/website/accessibility/link-text","/playbook/testing/non-functional-tests/accessibility-in-ci"],"sources":[{"label":"WCAG 2.2 – 4.1.2 Name, Role, Value","url":"https://www.w3.org/TR/WCAG22/#name-role-value"}]},{"chapter":"Accessibility","name":"Explain form errors next to the field","level":"Required","risk":"law","rule":"Show form errors as text next to the field, tie them to it with `aria-describedby`, and say how to fix them. These are WCAG criteria 3.3.1 (A) and 3.3.3 (AA).","check":"A browser test submits each form empty. Every invalid field has `aria-invalid=\"true\"` and an `aria-describedby` pointing at visible, non-empty text. Focus is on the first error or the summary.","url":"https://agentwranglers.se/playbook/website/accessibility/form-errors","related":["/playbook/app-ui/data-views/disabled-with-reason","/playbook/app-ui/forms/validation-timing","/playbook/app-ui/forms/server-errors-on-fields","/playbook/app-ui/feedback-and-dialogs/errors-stay-on-screen"],"sources":[{"label":"WCAG 2.2 – 3.3.1 Error Identification","url":"https://www.w3.org/TR/WCAG22/#error-identification"},{"label":"WCAG 2.2 – 3.3.3 Error Suggestion","url":"https://www.w3.org/TR/WCAG22/#error-suggestion"}]},{"chapter":"Accessibility","name":"Let people check before they commit","level":"Required","risk":"law","rule":"Before a purchase, a legal commitment, or a change or deletion of stored data, let people review and fix, confirm, or undo. This is WCAG criterion 3.3.4 (AA).","check":"A browser test runs each such flow. Before the final action it finds a review step, a confirmation or an undo control.","url":"https://agentwranglers.se/playbook/website/accessibility/error-prevention","related":["/playbook/app-ui/feedback-and-dialogs/confirm-or-undo","/playbook/payments/subscriptions/recurring-consent"],"sources":[{"label":"WCAG 2.2 – 3.3.4 Error Prevention (Legal, Financial, Data)","url":"https://www.w3.org/TR/WCAG22/#error-prevention-legal-financial-data"}]},{"chapter":"Accessibility","name":"Announce changes without moving focus","level":"Required","risk":"law","rule":"Announce changes such as \"Copied\" or \"3 results\" through a live region that is already in the page, without moving focus. This is WCAG criterion 4.1.3 (AA).","check":"A browser test triggers each action. The live region was in the initial HTML, now holds the message, and `document.activeElement` did not change.","url":"https://agentwranglers.se/playbook/website/accessibility/status-messages","related":["/playbook/app-ui/feedback-and-dialogs/toasts-for-status","/playbook/ai-features/latency-cost-and-fallbacks/accessible-streaming","/playbook/app-ui/forms/autosave","/playbook/app-ui/forms/search-combobox","/playbook/app-ui/lists-and-tables/sortable-headers","/playbook/notifications/in-app-notifications/accessible-inbox"],"sources":[{"label":"WCAG 2.2 – 4.1.3 Status Messages","url":"https://www.w3.org/TR/WCAG22/#status-messages"}]},{"chapter":"Accessibility","name":"Make signing in easy for everyone","level":"Required","risk":"law","applies":["accounts"],"rule":"Never make any sign-in step depend only on remembering, transcribing or solving a puzzle. Allow paste and password managers. This is WCAG 2.2 criterion 3.3.8 (AA).","check":"A browser test pastes into the username, password and code fields, and each value arrives. Every field has an `autocomplete` token.","url":"https://agentwranglers.se/playbook/website/accessibility/accessible-authentication","related":["/playbook/accounts/sign-up/minimal-sign-up","/playbook/notifications/text-messages/sms-code-autofill"],"sources":[{"label":"WCAG 2.2 – 3.3.8 Accessible Authentication (Minimum)","url":"https://www.w3.org/TR/WCAG22/#accessible-authentication-minimum"}]},{"chapter":"Accessibility","name":"Never ask for the same thing twice","level":"Required","risk":"law","rule":"Never ask people for the same information twice in one process. Fill it in for them or let them pick it. This is WCAG 2.2 criterion 3.3.7 (A).","check":"A browser test walks every multi-step flow. Any later field asking for earlier data is filled in or offers a choice.","url":"https://agentwranglers.se/playbook/website/accessibility/redundant-entry","related":["/playbook/app-ui/forms/keep-user-input","/playbook/app-ui/forms/multi-step-forms"],"sources":[{"label":"WCAG 2.2 – 3.3.7 Redundant Entry","url":"https://www.w3.org/TR/WCAG22/#redundant-entry"}]},{"chapter":"Accessibility","name":"Keep help in the same place","level":"Required","risk":"law","rule":"Put help and contact links in the same order relative to the rest of the page on every page. This is WCAG 2.2 criterion 3.2.6 (A).","check":"A test reads the help links on every page type. Their order among the page's other parts is the same everywhere.","url":"https://agentwranglers.se/playbook/website/accessibility/consistent-help","related":["/playbook/website/site-architecture/shared-html-modules-only"],"sources":[{"label":"WCAG 2.2 – 3.2.6 Consistent Help","url":"https://www.w3.org/TR/WCAG22/#consistent-help"}]},{"chapter":"Accessibility","name":"Keep navigation in the same order","level":"Required","risk":"law","rule":"Show repeated navigation in the same order on every page, and give the same function the same name everywhere. These are WCAG criteria 3.2.3 and 3.2.4 (AA).","check":"A test reads the main navigation's links on every page type. The list and order are identical.","url":"https://agentwranglers.se/playbook/website/accessibility/consistent-navigation","related":["/playbook/website/site-architecture/shared-html-modules-only"],"sources":[{"label":"WCAG 2.2 – 3.2.3 Consistent Navigation","url":"https://www.w3.org/TR/WCAG22/#consistent-navigation"},{"label":"WCAG 2.2 – 3.2.4 Consistent Identification","url":"https://www.w3.org/TR/WCAG22/#consistent-identification"}]},{"chapter":"Accessibility","name":"Offer two ways to every page","level":"Required","risk":"law","rule":"Give people at least two ways to reach each page, such as navigation plus site search or a page listing every page. This is WCAG criterion 2.4.5 (AA).","check":"A test finds every sitemap page both through the navigation and in the search results or HTML sitemap.","url":"https://agentwranglers.se/playbook/website/accessibility/multiple-ways","related":["/playbook/website/seo/internal-linking"],"sources":[{"label":"WCAG 2.2 – 2.4.5 Multiple Ways","url":"https://www.w3.org/TR/WCAG22/#multiple-ways"}]},{"chapter":"Accessibility","name":"Change nothing on focus or input alone","level":"Required","risk":"law","rule":"Never open a new page, submit a form or move focus just because a field got focus or its value changed. These are WCAG criteria 3.2.1 and 3.2.2 (A).","check":"A browser test focuses and changes every field. The URL and the number of open windows stay the same.","url":"https://agentwranglers.se/playbook/website/accessibility/no-surprise-changes","related":["/playbook/app-ui/forms/validation-timing"],"sources":[{"label":"WCAG 2.2 – 3.2.1 On Focus","url":"https://www.w3.org/TR/WCAG22/#on-focus"},{"label":"WCAG 2.2 – 3.2.2 On Input","url":"https://www.w3.org/TR/WCAG22/#on-input"}]},{"chapter":"Accessibility","name":"Show hover content that people can reach","level":"Required","risk":"law","rule":"Content that appears on hover or focus must be dismissible with Escape, hoverable without vanishing, and stay until people move away. This is WCAG criterion 1.4.13 (AA).","check":"A browser test hovers each tooltip trigger, moves the pointer onto the tooltip, and it stays. Pressing Escape closes it.","url":"https://agentwranglers.se/playbook/website/accessibility/content-on-hover-focus","related":["/playbook/website/interface/popover-api"],"sources":[{"label":"WCAG 2.2 – 1.4.13 Content on Hover or Focus","url":"https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus"}]},{"chapter":"Accessibility","name":"Work in both orientations","level":"Required","risk":"law","rule":"Never lock the page to portrait or landscape. This is WCAG criterion 1.3.4 (AA).","check":"A browser test loads each page type at 800×360 and 360×800. Both show all content without sideways scrolling.","url":"https://agentwranglers.se/playbook/website/accessibility/orientation","related":["/playbook/website/when-things-break/pwa-manifest"],"sources":[{"label":"WCAG 2.2 – 1.3.4 Orientation","url":"https://www.w3.org/TR/WCAG22/#orientation"}]},{"chapter":"Accessibility","name":"Never use flashing content","level":"Required","risk":"law","rule":"Never show anything that flashes more than three times in one second. This is WCAG criterion 2.3.1 (A).","check":"A search of the stylesheets finds no repeating animation shorter than 333 ms that changes opacity, colour or visibility. Every published video passes a flash analysis tool.","url":"https://agentwranglers.se/playbook/website/accessibility/no-flashing","related":["/playbook/website/accessibility/reduced-motion"],"sources":[{"label":"WCAG 2.2 – 2.3.1 Three Flashes or Below Threshold","url":"https://www.w3.org/TR/WCAG22/#three-flashes-or-below-threshold"}]},{"chapter":"Accessibility","name":"Respect people who turn motion off","level":"Recommended","rule":"Honour `prefers-reduced-motion` and stop or reduce animation, parallax and smooth scrolling. Never start motion by itself.","check":"A browser test with reduced motion emulated loads each page type. One second later, `document.getAnimations()` has nothing running.","url":"https://agentwranglers.se/playbook/website/accessibility/reduced-motion","related":["/playbook/website/interface/scroll-driven-animations","/playbook/website/accessibility/no-flashing"],"sources":[{"label":"Media Queries 5 – The prefers-reduced-motion feature","url":"https://www.w3.org/TR/mediaqueries-5/#prefers-reduced-motion"},{"label":"WCAG 2.2 – 2.3.3 Animation from Interactions","url":"https://www.w3.org/TR/WCAG22/#animation-from-interactions"}]},{"chapter":"Accessibility","name":"Give people enough time","level":"Required","risk":"law","rule":"Let people turn off, lengthen or extend every time limit. Let them pause anything that moves or updates by itself for more than five seconds. These are WCAG criteria 2.2.1 and 2.2.2 (A).","check":"A browser test with a fake clock waits out each time limit. A warning appears at least 2 minutes before the end, and one click extends it. Every auto-moving element has a pause control.","url":"https://agentwranglers.se/playbook/website/accessibility/time-limits","related":["/playbook/adversarial-testing/access-and-accounts/session-cookies","/playbook/app-ui/permissions-flags-and-failures/session-expiry","/playbook/app-ui/feedback-and-dialogs/toasts-for-status","/playbook/notifications/in-app-notifications/accessible-inbox"],"sources":[{"label":"WCAG 2.2 – 2.2.1 Timing Adjustable","url":"https://www.w3.org/TR/WCAG22/#timing-adjustable"},{"label":"WCAG 2.2 – 2.2.2 Pause, Stop, Hide","url":"https://www.w3.org/TR/WCAG22/#pause-stop-hide"}]},{"chapter":"Accessibility","name":"Know which accessibility law applies","level":"Recommended","rule":"Find out which accessibility laws cover the site. Meet WCAG 2.2 AA, which includes the WCAG 2.1 AA level most laws point to.","check":"The site has an accessibility statement with a date, the WCAG version and level it meets, its known gaps and a way to report problems.","url":"https://agentwranglers.se/playbook/website/accessibility/accessibility-law","related":["/playbook/compliance/product-and-sector-laws/accessibility-act-duty","/playbook/compliance/scope-and-ownership/scope-from-facts"],"sources":[{"label":"European Commission – European Accessibility Act","url":"https://commission.europa.eu/strategy-and-policy/policies/justice-and-fundamental-rights/disability/european-accessibility-act-eaa_en"},{"label":"ADA.gov – Title II web and mobile accessibility rule","url":"https://www.ada.gov/resources/2024-03-08-web-rule/"},{"label":"W3C WCAG 2.2","url":"https://www.w3.org/TR/WCAG22/"}]},{"chapter":"Accessibility","name":"Fix the site instead of adding overlays","level":"Avoid","rule":"Never install an accessibility overlay widget. Fix the site itself.","check":"A test lists every script host the pages load. None is on a kept list of known overlay vendors, such as accessiBe, UserWay, AudioEye and EqualWeb.","url":"https://agentwranglers.se/playbook/website/accessibility/accessibility-overlays","related":["/playbook/compliance/product-and-sector-laws/accessibility-act-duty"],"sources":[]},{"chapter":"Accessibility","name":"Caption video, transcribe audio","level":"Required","risk":"law","rule":"Caption every video, describe what it shows that is not said, and give a transcript for audio. These are WCAG criteria 1.2.1, 1.2.2 (A), 1.2.4 and 1.2.5 (AA).","check":"axe `video-caption` passes. A test finds a captions track on every `<video>` and a transcript link next to every `<audio>`.","url":"https://agentwranglers.se/playbook/website/accessibility/captions-and-transcripts","related":["/playbook/website/performance/video-delivery"],"sources":[{"label":"WCAG 2.2 – 1.2.1 Audio-only and Video-only (Prerecorded)","url":"https://www.w3.org/TR/WCAG22/#audio-only-and-video-only-prerecorded"},{"label":"WCAG 2.2 – 1.2.2 Captions (Prerecorded)","url":"https://www.w3.org/TR/WCAG22/#captions-prerecorded"},{"label":"WCAG 2.2 – 1.2.5 Audio Description (Prerecorded)","url":"https://www.w3.org/TR/WCAG22/#audio-description-prerecorded"}]},{"chapter":"Accessibility","name":"Give tables headers","level":"Required","risk":"law","rule":"Mark up data tables with `th`, `scope` and a caption or label. Use tables only for data, never for layout. This is WCAG criterion 1.3.1 (A).","check":"axe `th-has-data-cells` and `td-headers-attr` pass. A test finds a `caption` or `aria-label` on every `table`.","url":"https://agentwranglers.se/playbook/website/accessibility/data-tables","related":["/playbook/app-ui/lists-and-tables/sortable-headers"],"sources":[{"label":"WCAG 2.2 – 1.3.1 Info and Relationships","url":"https://www.w3.org/TR/WCAG22/#info-and-relationships"},{"label":"W3C WAI Tables Tutorial","url":"https://www.w3.org/WAI/tutorials/tables/"}]},{"chapter":"Accessibility","name":"Never make people scroll sideways","level":"Required","risk":"law","rule":"Never let a page or a component scroll sideways with a visible scrollbar, at any width from 320 CSS px up. This meets WCAG criterion 1.4.10 (AA).","check":"Open every sitemap URL in a browser at 320 px and at desktop width. Fail if `document.documentElement.scrollWidth` is wider than the window. Also fail if any `overflow-x: auto` element has `scrollWidth > clientWidth` and a visible scrollbar. axe `scrollable-region-focusable` passes.","measured":"On a reference site, this check covered 227 pages at two widths. It found 0 problems after the change.","url":"https://agentwranglers.se/playbook/website/accessibility/no-sideways-scrolling","related":["/playbook/website/interface/container-queries","/playbook/testing/end-to-end-and-browser-tests/browsers-and-sizes","/playbook/website/accessibility/text-resize"],"sources":[{"label":"WCAG 2.2 – 1.4.10 Reflow","url":"https://www.w3.org/TR/WCAG22/#reflow"}]},{"chapter":"Accessibility","name":"Let text grow without breaking the page","level":"Required","risk":"law","rule":"Keep all content readable at 200% text zoom and with wider letter, word and line spacing. Never block pinch zoom. These are WCAG criteria 1.4.4 and 1.4.12 (AA).","check":"axe `meta-viewport` passes. A browser test applies the 1.4.12 spacing and then 200% zoom. No element with text has `scrollHeight` greater than `clientHeight` while its overflow is hidden.","url":"https://agentwranglers.se/playbook/website/accessibility/text-resize","related":["/playbook/website/html-basics/meta-viewport","/playbook/website/accessibility/no-sideways-scrolling"],"sources":[{"label":"WCAG 2.2 – 1.4.4 Resize Text","url":"https://www.w3.org/TR/WCAG22/#resize-text"},{"label":"WCAG 2.2 – 1.4.12 Text Spacing","url":"https://www.w3.org/TR/WCAG22/#text-spacing"}]},{"chapter":"Accessibility","name":"Make tap targets big enough","level":"Required","risk":"law","rule":"Make every pointer target at least 24 by 24 CSS pixels, as WCAG 2.2 criterion 2.5.8 (AA) asks. Make icon-only buttons 44 by 44, as 2.5.5 (AAA) asks.","check":"axe `target-size` passes. A browser test measures every icon-only button, and each bounding box is at least 44 by 44 px.","url":"https://agentwranglers.se/playbook/website/accessibility/touch-target-size","related":["/playbook/website/accessibility/pointer-gestures","/playbook/email/layout-and-accessibility/email-buttons"],"sources":[{"label":"WCAG 2.2 – 2.5.8 Target Size (Minimum)","url":"https://www.w3.org/TR/WCAG22/#target-size-minimum"},{"label":"WCAG 2.2 – 2.5.5 Target Size (Enhanced)","url":"https://www.w3.org/TR/WCAG22/#target-size-enhanced"}]},{"chapter":"Accessibility","name":"Offer a way that needs no dragging","level":"Required","risk":"law","rule":"Give every drag a single-pointer alternative, such as buttons. This is WCAG 2.2 criterion 2.5.7 (AA).","check":"For each feature with drag, a browser test completes the same task with clicks only.","url":"https://agentwranglers.se/playbook/website/accessibility/dragging-movements","related":["/playbook/website/accessibility/pointer-gestures","/playbook/app-ui/forms/file-upload-field"],"sources":[{"label":"WCAG 2.2 – 2.5.7 Dragging Movements","url":"https://www.w3.org/TR/WCAG22/#dragging-movements"}]},{"chapter":"Accessibility","name":"Make every gesture work with one tap","level":"Required","risk":"law","rule":"Make every action that needs a multi-finger or path gesture also work with single taps. Act on release, not on press. These are WCAG criteria 2.5.1 and 2.5.2 (A).","check":"A search of the scripts finds no `pointerdown`, `mousedown` or `touchstart` handler that completes an action. Each gesture feature has a test that completes it with clicks.","url":"https://agentwranglers.se/playbook/website/accessibility/pointer-gestures","related":["/playbook/website/accessibility/dragging-movements","/playbook/website/accessibility/touch-target-size"],"sources":[{"label":"WCAG 2.2 – 2.5.1 Pointer Gestures","url":"https://www.w3.org/TR/WCAG22/#pointer-gestures"},{"label":"WCAG 2.2 – 2.5.2 Pointer Cancellation","url":"https://www.w3.org/TR/WCAG22/#pointer-cancellation"},{"label":"WCAG 2.2 – 2.5.4 Motion Actuation","url":"https://www.w3.org/TR/WCAG22/#motion-actuation"}]},{"chapter":"Accessibility","name":"Let find-in-page open hidden text","level":"Recommended","rule":"Hide folded content in a way find-in-page can still reach, such as `<details>` or `hidden=\"until-found\"`.","check":"A test finds no `display: none` accordion panels. Searching with Ctrl+F for text in a closed section opens it.","url":"https://agentwranglers.se/playbook/website/accessibility/hidden-until-found","related":["/playbook/website/seo/mobile-first-indexing","/playbook/website/accessibility/native-interactive-elements"],"sources":[{"label":"HTML Standard – The hidden until-found state","url":"https://html.spec.whatwg.org/multipage/interaction.html#attr-hidden-until-found-state"}]},{"chapter":"Accessibility","name":"Show the right keyboard on phones","level":"Required","risk":"law","rule":"Give every field that asks about the person the right `autocomplete` token, as WCAG criterion 1.3.5 (AA) asks. Give inputs the right `type` and `inputmode` too.","check":"axe `autocomplete-valid` passes. A test finds an `autocomplete` token on every input for a name, email, phone or address.","url":"https://agentwranglers.se/playbook/website/accessibility/mobile-form-inputs","related":["/playbook/app-ui/dates-and-numbers/locale-number-input"],"sources":[{"label":"WCAG 2.2 – 1.3.5 Identify Input Purpose","url":"https://www.w3.org/TR/WCAG22/#identify-input-purpose"},{"label":"HTML Standard – Autofill","url":"https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill"}]},{"chapter":"Accessibility","name":"Build with native controls","level":"Recommended","rule":"Build controls from native elements (`button`, `a`, `details`, `dialog`, `popover`) instead of scripted `div`s.","check":"A test finds no `div` or `span` with a click handler, for example with a lint rule such as `no-static-element-interactions`.","url":"https://agentwranglers.se/playbook/website/accessibility/native-interactive-elements","related":["/playbook/website/accessibility/aria-usage","/playbook/website/accessibility/hidden-until-found","/playbook/app-ui/styles-and-components/shared-components"],"sources":[{"label":"W3C Using ARIA","url":"https://www.w3.org/TR/using-aria/"},{"label":"WCAG 2.2 – 4.1.2 Name, Role, Value","url":"https://www.w3.org/TR/WCAG22/#name-role-value"}]},{"chapter":"Accessibility","name":"Style from the state","level":"Recommended","rule":"Style interface state from the state itself with selectors like `:has()`, `:popover-open` and `[aria-expanded]`, not from toggled classes.","check":"A search of the scripts finds no `classList.toggle` or `classList.add` next to a change of `aria-expanded`, `open` or `checked`.","url":"https://agentwranglers.se/playbook/website/accessibility/css-state-selectors","related":["/playbook/website/accessibility/aria-usage"],"sources":[{"label":"Selectors Level 4 – The has pseudo-class","url":"https://www.w3.org/TR/selectors-4/#relational"}]},{"chapter":"Accessibility","name":"Never play sound by itself","level":"Required","risk":"law","rule":"Never start sound when a page loads. If any audio plays by itself for more than 3 seconds, give a control that pauses or stops it. This is WCAG criterion 1.4.2 (A).","check":"A browser test loads each page type and waits 4 seconds. Every `audio` and `video` element is paused or muted, and every element with `autoplay` also has `muted`.","url":"https://agentwranglers.se/playbook/website/accessibility/audio-control","related":["/playbook/website/privacy/click-to-load-embeds"],"sources":[{"label":"WCAG 2.2 – 1.4.2 Audio Control","url":"https://www.w3.org/TR/WCAG22/#audio-control"}]},{"chapter":"Accessibility","name":"Give instructions that work without seeing the page","level":"Required","risk":"law","rule":"Never write instructions that rely only on shape, colour, size, position or sound. \"Press the round button on the right\" fails. This is WCAG criterion 1.3.3 (A).","check":"A test searches the page text for \"on the left\", \"on the right\", \"above\", \"below\", \"round\", \"square\", \"green\", \"red\" and \"the icon\". Every sentence with a hit also names a visible label in quotes or bold.","url":"https://agentwranglers.se/playbook/website/accessibility/sensory-characteristics","related":["/playbook/website/languages-and-regions/rtl-support"],"sources":[{"label":"WCAG 2.2 – 1.3.3 Sensory Characteristics","url":"https://www.w3.org/TR/WCAG22/#sensory-characteristics"}]},{"chapter":"Security","name":"Make browsers always use HTTPS","level":"Required","risk":"takeover","rule":"Send `Strict-Transport-Security` so browsers always use HTTPS. Give it a long `max-age` (at least a year) and `includeSubDomains`, on HTTPS responses only.","check":"`curl -sI https://example.com/` and a missing page both show the header with `max-age` of at least 31536000. `curl -sI http://example.com/` shows no such header.","url":"https://agentwranglers.se/playbook/website/security/hsts","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data","/playbook/adversarial-testing/supply-chain-and-setup/security-headers"],"sources":[{"label":"RFC 6797: HTTP Strict Transport Security","url":"https://www.rfc-editor.org/rfc/rfc6797"}]},{"chapter":"Security","name":"Load nothing over plain HTTP","level":"Recommended","rule":"Load every file over HTTPS. Add `upgrade-insecure-requests` as a safety net.","check":"`grep -rnE '(src|href|action|srcset)=\"http://' dist/` finds nothing in the built pages. The browser console shows no mixed-content warnings.","url":"https://agentwranglers.se/playbook/website/security/mixed-content","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data"],"sources":[{"label":"W3C Mixed Content","url":"https://www.w3.org/TR/mixed-content/"},{"label":"W3C Upgrade Insecure Requests","url":"https://www.w3.org/TR/upgrade-insecure-requests/"}]},{"chapter":"Security","name":"Allow only the scripts you wrote","level":"Recommended","rule":"Send a strict Content Security Policy, a header that says which scripts may run. Allow scripts only by nonce or hash, with `'strict-dynamic'`, `object-src 'none'`, `base-uri 'none'` and no `unsafe-inline`.","check":"`curl -sI` shows a `script-src` with a nonce or hash and without `'unsafe-inline'`, `'unsafe-eval'` or `*`. The console shows no violations. A CSP evaluator, such as Google's, reports no high-severity findings.","url":"https://agentwranglers.se/playbook/website/security/content-security-policy","related":["/playbook/website/privacy/third-party-scripts","/playbook/adversarial-testing/injection/cross-site-scripting","/playbook/adversarial-testing/supply-chain-and-setup/third-party-scripts","/playbook/adversarial-testing/supply-chain-and-setup/security-headers","/playbook/stack/delivery-and-infrastructure/nginx-or-caddy"],"sources":[{"label":"W3C Content Security Policy Level 3","url":"https://www.w3.org/TR/CSP3/"},{"label":"OWASP Content Security Policy Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html"}]},{"chapter":"Security","name":"Collect reports of blocked content","level":"Recommended","rule":"Name a place for browsers to send policy errors with `Reporting-Endpoints`. Log those reports without any personal data.","check":"Break the policy on a test page in two browsers. Each report appears in the log as one line with a path and a directive. A 1 MB POST answers 413.","url":"https://agentwranglers.se/playbook/website/security/reporting-endpoints","related":["/playbook/website/security/rate-limits","/playbook/adversarial-testing/supply-chain-and-setup/security-logging"],"sources":[{"label":"W3C Reporting API","url":"https://www.w3.org/TR/reporting-1/"},{"label":"CSP Level 3 (the report-to directive)","url":"https://www.w3.org/TR/CSP3/#directive-report-to"}]},{"chapter":"Security","name":"Tell people how to report a hole","level":"Recommended","rule":"Publish `/.well-known/security.txt` with a contact and an `Expires` date in the future. It tells people where to report security problems.","check":"`curl -sI https://example.com/.well-known/security.txt` answers 200 with `text/plain; charset=utf-8`. A test parses `Expires:` and fails when it is in the past or more than a year ahead.","url":"https://agentwranglers.se/playbook/website/security/security-txt","related":["/playbook/website/standard-addresses/well-known-overview","/playbook/email/unsubscribe-and-bounces/postmaster-abuse","/playbook/compliance/product-and-sector-laws/cra-vulnerability-reporting","/playbook/compliance/product-and-sector-laws/cra-support-period"],"sources":[{"label":"RFC 9116: A File Format to Aid in Security Vulnerability Disclosure","url":"https://www.rfc-editor.org/rfc/rfc9116"}]},{"chapter":"Security","name":"Stop browsers guessing file types","level":"Recommended","rule":"Send `X-Content-Type-Options: nosniff` on every response, so browsers don't guess file types. Give every response its correct Content-Type.","check":"`curl -sI` on HTML, CSS, JS, JSON and images shows `nosniff` and the right type.","url":"https://agentwranglers.se/playbook/website/security/x-content-type-options","related":["/playbook/adversarial-testing/supply-chain-and-setup/security-headers"],"sources":[{"label":"Fetch Standard: X-Content-Type-Options","url":"https://fetch.spec.whatwg.org/#x-content-type-options-header"}]},{"chapter":"Security","name":"Refuse to be framed","level":"Required","risk":"takeover","applies":["accounts"],"rule":"Stop other sites from showing yours inside a frame. Use `frame-ancestors 'none'` (or a list) in the CSP, plus `X-Frame-Options` for older browsers.","check":"`curl -sI` on a page and an error page shows `frame-ancestors` in the CSP and `X-Frame-Options`. A test page on another origin that frames the site shows a blocked frame.","url":"https://agentwranglers.se/playbook/website/security/frame-ancestors","related":["/playbook/adversarial-testing/supply-chain-and-setup/security-headers","/playbook/adversarial-testing/requests-and-files/postmessage-origin"],"sources":[{"label":"CSP Level 3 (the frame-ancestors directive)","url":"https://www.w3.org/TR/CSP3/#directive-frame-ancestors"},{"label":"OWASP HTTP Headers Cheat Sheet (X-Frame-Options)","url":"https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-frame-options"}]},{"chapter":"Security","name":"Refuse odd requests from other sites","level":"Recommended","rule":"Browsers send `Sec-Fetch-*` headers that say where a request came from. Use them to refuse cross-site requests that are not normal page visits.","check":"`curl -s -o /dev/null -w '%{http_code}' -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Mode: cors' URL` prints 403. The same request with `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document` prints 200.","url":"https://agentwranglers.se/playbook/website/security/fetch-metadata","related":["/playbook/adversarial-testing/access-and-accounts/cross-site-requests"],"sources":[{"label":"W3C Fetch Metadata Request Headers","url":"https://www.w3.org/TR/fetch-metadata/"}]},{"chapter":"Security","name":"Keep other sites' windows apart","level":"Recommended","rule":"Send `Cross-Origin-Opener-Policy: same-origin` and a `Cross-Origin-Resource-Policy`. These keep other sites' windows and pages away from yours. Add COEP only if you need full isolation.","check":"`curl -sI` shows both headers on pages and files. Every page still loads its embeds, sign-in popups and payment popups in a browser test.","url":"https://agentwranglers.se/playbook/website/security/cross-origin-isolation","related":["/playbook/adversarial-testing/supply-chain-and-setup/security-headers"],"sources":[{"label":"HTML Standard (Cross-origin opener policies)","url":"https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policies"},{"label":"Fetch Standard (Cross-Origin-Resource-Policy)","url":"https://fetch.spec.whatwg.org/#cross-origin-resource-policy-header"}]},{"chapter":"Security","name":"Share less in the Referer header","level":"Recommended","rule":"Send `Referrer-Policy: strict-origin-when-cross-origin` or stricter. It limits what other sites learn about the page a visitor came from.","check":"`curl -sI` on a page and an error page shows `strict-origin-when-cross-origin`, `same-origin` or `no-referrer`.","url":"https://agentwranglers.se/playbook/website/security/referrer-policy","related":["/playbook/adversarial-testing/data-and-secrets/private-data-out-of-logs"],"sources":[{"label":"W3C Referrer Policy","url":"https://www.w3.org/TR/referrer-policy/"}]},{"chapter":"Security","name":"Switch off browser features you don't use","level":"Recommended","rule":"Use `Permissions-Policy` to turn off every browser feature the site does not use. Allow the rest only for your own origin.","check":"`curl -sI` shows the header. In a Chromium browser console, `document.featurePolicy.allowedFeatures()` lists none of the features you turned off. Features you use still work.","url":"https://agentwranglers.se/playbook/website/security/permissions-policy","related":["/playbook/adversarial-testing/supply-chain-and-setup/security-headers"],"sources":[{"label":"W3C Permissions Policy","url":"https://www.w3.org/TR/permissions-policy/"}]},{"chapter":"Security","name":"Lock scripts to a known hash","level":"Recommended","rule":"Put an `integrity` hash, a fingerprint of the file, on every script and stylesheet. Let the browser refuse any script without one.","check":"A build test finds `integrity` on every `<script src>` and `<link rel=\"stylesheet\">`. Change one byte of the bundle on a test server, and the browser refuses it.","url":"https://agentwranglers.se/playbook/website/security/subresource-integrity","related":["/playbook/adversarial-testing/supply-chain-and-setup/third-party-scripts"],"sources":[{"label":"W3C Subresource Integrity","url":"https://www.w3.org/TR/sri/"},{"label":"W3C Subresource Integrity Level 2","url":"https://www.w3.org/TR/sri-2/"}]},{"chapter":"Security","name":"Send a hash of the body","level":"Optional","rule":"Send a `Content-Digest`, a hash of the response body, on files clients download to verify, such as Markdown copies and downloads. RFC 9530 defines it.","check":"Fetch the file without compression. The base64 SHA-256 of the body equals the value in its `Content-Digest`.","url":"https://agentwranglers.se/playbook/website/security/digest-fields","related":["/playbook/website/ready-for-ai-agents/markdown-source-endpoints"],"sources":[{"label":"RFC 9530: Digest Fields","url":"https://www.rfc-editor.org/rfc/rfc9530"}]},{"chapter":"Security","name":"Lock down risky DOM writes","level":"Recommended","rule":"Require Trusted Types in the CSP, so scripts can't write raw HTML into the page. Send every remaining case through one small named policy.","check":"With the header on, a browser test visits every page and uses its main features. The console shows no Trusted Types violations.","url":"https://agentwranglers.se/playbook/website/security/trusted-types","related":["/playbook/adversarial-testing/injection/cross-site-scripting","/playbook/html-css/html-structure-and-reuse/template-element"],"sources":[{"label":"W3C Trusted Types","url":"https://www.w3.org/TR/trusted-types/"}]},{"chapter":"Security","name":"Drop the old XSS header","level":"Avoid","rule":"Do not turn on `X-XSS-Protection`. The filter it controls is gone from current browsers, and in old ones it could open holes. Rely on the Content Security Policy instead.","check":"`curl -sI` shows no `X-XSS-Protection`, or shows it with the value `0`.","url":"https://agentwranglers.se/playbook/website/security/x-xss-protection","related":["/playbook/adversarial-testing/injection/cross-site-scripting"],"sources":[{"label":"OWASP HTTP Headers Cheat Sheet (X-XSS-Protection)","url":"https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-xss-protection"}]},{"chapter":"Security","name":"Set cookies safely or not at all","level":"Required","risk":"takeover","applies":["accounts"],"rule":"Set every cookie with `Secure` and an explicit `SameSite`. Add `HttpOnly` to every cookie that no script reads. Use `SameSite=None` only for a cookie that must work inside another site.","check":"A test reads every `Set-Cookie` header the site sends. Each has `Secure` and `SameSite`, and each one scripts don't read has `HttpOnly`.","url":"https://agentwranglers.se/playbook/website/security/cookie-attributes","related":["/playbook/adversarial-testing/access-and-accounts/session-cookies","/playbook/adversarial-testing/access-and-accounts/cross-site-requests"],"sources":[{"label":"RFC 6265 (HTTP State Management Mechanism)","url":"https://www.rfc-editor.org/rfc/rfc6265"},{"label":"IETF draft RFC 6265bis (Cookies)","url":"https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/"}]},{"chapter":"Security","name":"Wipe browser data on sign-out","level":"Optional","applies":["accounts"],"rule":"Send `Clear-Site-Data` when a person signs out. Then their data does not stay behind in the browser.","check":"A test signs out and reads the response headers, which include `Clear-Site-Data`. In DevTools, the site's cookies and storage are empty afterwards.","url":"https://agentwranglers.se/playbook/website/security/clear-site-data","related":["/playbook/accounts/sessions-and-security-activity/revoke-sessions","/playbook/accounts/sessions-and-security-activity/sign-out-link"],"sources":[{"label":"W3C Clear Site Data","url":"https://www.w3.org/TR/clear-site-data/"}]},{"chapter":"Security","name":"Limit how often anyone can call your APIs","level":"Required","risk":"outage","rule":"Put a rate limit on every endpoint that does work for each request, such as APIs, agent endpoints, forms and report collectors.","check":"Send more requests than the limit in a loop. The extra ones get 429 with `Retry-After`, and normal pages are not affected.","url":"https://agentwranglers.se/playbook/website/security/rate-limits","related":["/playbook/website/security/reporting-endpoints","/playbook/api-design/errors/retry-after","/playbook/api-design/reliability/rate-limit-headers","/playbook/adversarial-testing/access-and-accounts/login-guessing","/playbook/website/when-things-break/browser-error-reports","/playbook/operations/capacity-and-cost/load-shedding","/playbook/adversarial-testing/abuse-and-limits/limit-requests","/playbook/adversarial-testing/hostile-agents-and-bots/limit-bulk-reads"],"sources":[{"label":"RFC 6585 (Additional HTTP Status Codes)","url":"https://www.rfc-editor.org/rfc/rfc6585"},{"label":"RFC 9110 (Retry-After)","url":"https://www.rfc-editor.org/rfc/rfc9110#field.retry-after"},{"label":"OWASP API4:2023 Unrestricted Resource Consumption","url":"https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/"}]},{"chapter":"Hosting and network","name":"Serve everything over HTTPS","level":"Required","risk":"data","rule":"Serve every page over HTTPS, the encrypted web, using TLS 1.2 or 1.3 and a valid certificate chain. Redirect plain HTTP to HTTPS.","check":"SSL Labs or `testssl.sh` gives grade A or better. `curl -sI http://www.example.com/` answers 301 with `location: https://www.example.com/`.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/https-tls","related":["/playbook/api-design/api-security/api-https-only"],"sources":[{"label":"RFC 9846 (TLS 1.3)","url":"https://www.rfc-editor.org/rfc/rfc9846"},{"label":"RFC 9325 (Recommendations for Secure Use of TLS)","url":"https://www.rfc-editor.org/rfc/rfc9325"}]},{"chapter":"Hosting and network","name":"Turn off old encryption","level":"Recommended","rule":"Allow only TLS 1.2 and 1.3, with ECDHE key exchange and AEAD ciphers. Turn off TLS compression and 0-RTT for requests that change data.","check":"`curl --tlsv1.1 --tls-max 1.1 --ciphers 'DEFAULT@SECLEVEL=0' https://example.com/` fails. Without that cipher flag, OpenSSL 3 refuses TLS 1.1 on its own side. `testssl.sh` reports no weak ciphers and no known flaws.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/tls-configuration","related":["/playbook/adversarial-testing/data-and-secrets/encrypt-data"],"sources":[{"label":"RFC 9325 (Recommendations for Secure Use of TLS)","url":"https://www.rfc-editor.org/rfc/rfc9325"},{"label":"NIST SP 800-52 Rev. 2 (TLS Guidelines)","url":"https://csrc.nist.gov/pubs/sp/800/52/r2/final"}]},{"chapter":"Hosting and network","name":"Serve over HTTP/2 or HTTP/3","level":"Recommended","rule":"Serve the site over HTTP/2 at least, and over HTTP/3 where the server supports it.","check":"`curl -so /dev/null -w '%{http_version}' https://example.com/` prints 2. With HTTP/3 on, `curl --http3-only` prints 3.","measured":"In an earlier lab at a 100 ms round trip, moving from HTTP/1.1 to HTTP/2 took \"everything loaded\" from 624 to 526 ms.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/http3","related":["/playbook/website/performance/early-hints","/playbook/website/performance/http1-workarounds"],"sources":[{"label":"RFC 9113 (HTTP/2)","url":"https://www.rfc-editor.org/rfc/rfc9113"},{"label":"RFC 9114 (HTTP/3)","url":"https://www.rfc-editor.org/rfc/rfc9114"},{"label":"RFC 7838 (HTTP Alternative Services)","url":"https://www.rfc-editor.org/rfc/rfc7838"}]},{"chapter":"Hosting and network","name":"Send more in the first round trip","level":"Optional","rule":"Let the server's first send carry the whole page. Raise the TCP initial congestion window. That is how much the server sends before waiting for a reply. Then the whole compressed HTML goes out without waiting.","check":"`ip route show` lists `initcwnd 32`. In a lab with added delay, the HTML arrives after 3 round trips (TCP, TLS 1.3, request) instead of 4.","measured":"On a reference site in an earlier lab at a 100 ms round trip, HTML arrival went from 406 to 307 ms. First paint went from 446 to 345 ms.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/tcp-initial-congestion-window","related":["/playbook/website/performance/compression"],"sources":[{"label":"RFC 6928: Increasing TCP's Initial Window","url":"https://www.rfc-editor.org/rfc/rfc6928"}]},{"chapter":"Hosting and network","name":"Renew certificates automatically","level":"Required","risk":"outage","rule":"Renew every certificate automatically, and get an alert when one has under 14 days left.","check":"`certbot renew --dry-run` succeeds. The outside monitor alerts when any certificate has under 14 days left.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/certificate-renewal","related":["/playbook/operations/capacity-and-cost/exhaustion-alerts","/playbook/compliance/scope-and-ownership/compliance-calendar"],"sources":[{"label":"RFC 8555 (ACME)","url":"https://www.rfc-editor.org/rfc/rfc8555"},{"label":"RFC 9773 (ACME Renewal Information)","url":"https://www.rfc-editor.org/rfc/rfc9773"},{"label":"CA/Browser Forum Ballot SC-081v3","url":"https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/"}]},{"chapter":"Hosting and network","name":"Choose who may issue your certificates","level":"Recommended","rule":"Publish DNS CAA records. They list which certificate authorities may sign certificates for your name. All others must refuse.","check":"`dig +short CAA example.com` returns your records, and a certificate renewal still succeeds.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/caa-records","related":["/playbook/infrastructure/networking-and-dns/dns-as-code"],"sources":[{"label":"RFC 8659 (DNS CAA Resource Record)","url":"https://www.rfc-editor.org/rfc/rfc8659"},{"label":"RFC 8657 (CAA accounturi and validationmethods)","url":"https://www.rfc-editor.org/rfc/rfc8657"}]},{"chapter":"Hosting and network","name":"Sign your DNS","level":"Optional","rule":"Sign the DNS zone with DNSSEC, so answers can't be forged. Then add the DS record where you bought the domain.","check":"`dig @1.1.1.1 +dnssec example.com A` shows the `ad` flag. DNSViz shows a complete chain with no errors.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/dnssec","related":["/playbook/infrastructure/networking-and-dns/dns-as-code"],"sources":[{"label":"RFC 4033: DNS Security Introduction and Requirements","url":"https://www.rfc-editor.org/rfc/rfc4033"}]},{"chapter":"Hosting and network","name":"Host where routes are signed","level":"Recommended","rule":"Host the site and its DNS with providers that sign their internet routes with RPKI. They should also drop invalid routes.","check":"internet.nl, or another RPKI checker, reports every IP address of the site and its name servers as valid.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/rpki","related":[],"sources":[{"label":"RFC 6480 (An Infrastructure to Support Secure Internet Routing)","url":"https://www.rfc-editor.org/rfc/rfc6480"},{"label":"RFC 6811 (BGP Prefix Origin Validation)","url":"https://www.rfc-editor.org/rfc/rfc6811"}]},{"chapter":"Hosting and network","name":"Use TLSA records only where checked","level":"Optional","rule":"Publish TLSA records only for services whose clients check them, such as mail servers. Browsers do not check them.","check":"`dig +short TLSA _443._tcp.example.com` returns nothing, unless a client you serve checks it.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/dane","related":["/playbook/email/sending-domain-and-authentication/smtp-dane"],"sources":[{"label":"RFC 6698 (DANE TLSA)","url":"https://www.rfc-editor.org/rfc/rfc6698"},{"label":"RFC 7672 (SMTP Security via DANE)","url":"https://www.rfc-editor.org/rfc/rfc7672"}]},{"chapter":"Hosting and network","name":"Be reachable over IPv6","level":"Recommended","rule":"Make the site and its DNS reachable over IPv6 with AAAA records, as well as over IPv4.","check":"`curl -6 -sI https://example.com/` answers like `curl -4`. internet.nl reports IPv6 for the site and its name servers.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/ipv6","related":["/playbook/infrastructure/networking-and-dns/deny-by-default-firewall"],"sources":[{"label":"RFC 3596: DNS Extensions to Support IPv6","url":"https://www.rfc-editor.org/rfc/rfc3596"}]},{"chapter":"Hosting and network","name":"Keep the domain name yours","level":"Required","risk":"takeover","rule":"Renew the domain automatically, lock it against transfers and protect the registrar account with two-factor login. A lapsed or stolen domain takes down the site and its email.","check":"A `whois example.com` or RDAP query shows `clientTransferProhibited` and an expiry date over 60 days away. The monitor alerts when it drops under 30 days.","url":"https://agentwranglers.se/playbook/website/hosting-and-network/domain-renewal","related":["/playbook/adversarial-testing/access-and-accounts/two-factor-login"],"sources":[{"label":"RFC 5731: EPP Domain Name Mapping","url":"https://www.rfc-editor.org/rfc/rfc5731"}]},{"chapter":"Standard addresses","name":"Serve standard files at fixed addresses","level":"Recommended","rule":"Serve files under `/.well-known/` at their exact paths, with the right content types and no sign-in. Open them to other sites where they are meant for agents.","check":"Fetch each file without cookies. Each returns 200 with the right type, and CORS where intended. A made-up name such as `/.well-known/no-such-file` returns 404.","url":"https://agentwranglers.se/playbook/website/standard-addresses/well-known-overview","related":["/playbook/website/security/security-txt","/playbook/adversarial-testing/data-and-secrets/no-hidden-files"],"sources":[{"label":"RFC 8615 (Well-Known URIs)","url":"https://www.rfc-editor.org/rfc/rfc8615"},{"label":"IANA Well-Known URIs registry","url":"https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml"}]},{"chapter":"Standard addresses","name":"Point to the change-password page","level":"Optional","applies":["accounts"],"rule":"If people have accounts, redirect `/.well-known/change-password` to the page where they change their password.","check":"`curl -sI https://example.com/.well-known/change-password` shows a 302, 303 or 307 with a `Location` on the password page. The test address above returns 404.","url":"https://agentwranglers.se/playbook/website/standard-addresses/change-password","related":["/playbook/adversarial-testing/access-and-accounts/reauthenticate-sensitive-changes"],"sources":[{"label":"W3C A Well-Known URL for Changing Passwords","url":"https://www.w3.org/TR/change-password-url/"}]},{"chapter":"Standard addresses","name":"List the sites that share passkeys","level":"Optional","applies":["accounts"],"rule":"If you accept passkeys across several of your domains, list the related origins in `/.well-known/webauthn`.","check":"`curl -sI https://example.com/.well-known/webauthn` returns 200 as `application/json`. A passkey made on one listed origin signs in on another.","url":"https://agentwranglers.se/playbook/website/standard-addresses/webauthn","related":["/playbook/accounts/sign-in-methods/passkey-verification"],"sources":[{"label":"WebAuthn Level 3 (Related Origin Requests)","url":"https://www.w3.org/TR/webauthn-3/#sctn-related-origins"}]},{"chapter":"Standard addresses","name":"Point to the passkey pages","level":"Optional","applies":["accounts"],"rule":"If people can sign in with passkeys, publish `/.well-known/passkey-endpoints` with the pages where they add and manage them.","check":"`curl -s https://example.com/.well-known/passkey-endpoints` returns JSON, and both URLs in it answer 200 after sign-in.","url":"https://agentwranglers.se/playbook/website/standard-addresses/passkey-endpoints","related":["/playbook/accounts/sign-in-methods/passkeys-first","/playbook/accounts/sign-in-methods/passkey-signals"],"sources":[{"label":"W3C A Well-Known URL for Passkey Endpoints","url":"https://www.w3.org/TR/passkey-endpoints/"}]},{"chapter":"Standard addresses","name":"Publish identity provider settings","level":"Optional","applies":["accounts"],"rule":"If you are an OpenID identity provider, publish discovery data at `/.well-known/openid-configuration`.","check":"Fetch the file. The `issuer` matches the host exactly, and the `jwks_uri` answers 200 with a JSON key set. An OIDC client library finds the provider from the issuer URL.","url":"https://agentwranglers.se/playbook/website/standard-addresses/openid-configuration","related":["/playbook/adversarial-testing/access-and-accounts/single-sign-on"],"sources":[]},{"chapter":"Standard addresses","name":"Publish OAuth server settings","level":"Optional","applies":["accounts"],"rule":"If you run an OAuth authorisation server, publish its details at `/.well-known/oauth-authorization-server`.","check":"Fetch the file. Its `issuer` equals the issuer URL exactly, and every endpoint it lists answers.","url":"https://agentwranglers.se/playbook/website/standard-addresses/oauth-authorization-server","related":["/playbook/api-design/api-security/token-scopes"],"sources":[{"label":"RFC 8414 (OAuth 2.0 Authorization Server Metadata)","url":"https://www.rfc-editor.org/rfc/rfc8414"},{"label":"RFC 7636 (PKCE)","url":"https://www.rfc-editor.org/rfc/rfc7636"}]},{"chapter":"Standard addresses","name":"Explain how to get API access","level":"Optional","applies":["public-api"],"rule":"If an API needs OAuth, describe how to get access at `/.well-known/oauth-protected-resource`.","check":"An unauthenticated request to the API returns 401 with `resource_metadata` in `WWW-Authenticate`. That URL returns the JSON, and its `resource` matches the API.","url":"https://agentwranglers.se/playbook/website/standard-addresses/oauth-protected-resource","related":["/playbook/adversarial-testing/ai-agents-and-models/mcp-token-audience"],"sources":[{"label":"RFC 9728 (OAuth 2.0 Protected Resource Metadata)","url":"https://www.rfc-editor.org/rfc/rfc9728"},{"label":"MCP specification (Authorization)","url":"https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization"}]},{"chapter":"Standard addresses","name":"Say you honour privacy signals","level":"Optional","rule":"If you honour Global Privacy Control, a browser signal that means \"don't sell or share my data\", say so in `/.well-known/gpc.json`.","check":"The file answers 200 as JSON.","url":"https://agentwranglers.se/playbook/website/standard-addresses/gpc-json","related":["/playbook/compliance/us-state-privacy-laws/sales-and-sharing-inventory"],"sources":[{"label":"W3C Global Privacy Control","url":"https://www.w3.org/TR/gpc/"}]},{"chapter":"Standard addresses","name":"List your APIs in one file","level":"Recommended","applies":["public-api"],"rule":"List your public APIs and machine-readable files at `/.well-known/api-catalog`, as an RFC 9727 linkset (a JSON list of links).","check":"`curl -sI` on the catalog shows `application/linkset+json` and the `api-catalog` link. Every URL listed in it answers 200.","url":"https://agentwranglers.se/playbook/website/standard-addresses/api-catalog","related":["/playbook/api-design/api-security/inventory","/playbook/api-design/contracts-and-docs/agent-discovery"],"sources":[{"label":"RFC 9727 (api-catalog)","url":"https://www.rfc-editor.org/rfc/rfc9727"},{"label":"RFC 9264 (Linkset)","url":"https://www.rfc-editor.org/rfc/rfc9264"}]},{"chapter":"Standard addresses","name":"Answer account lookups","level":"Optional","applies":["accounts"],"rule":"If you have accounts that others look up, answer `/.well-known/webfinger` queries.","check":"`curl -sI 'https://example.com/.well-known/webfinger?resource=acct:ana@example.com'` answers 200 as `application/jrd+json` with `Access-Control-Allow-Origin: *`. An unknown account answers 404.","url":"https://agentwranglers.se/playbook/website/standard-addresses/webfinger","related":["/playbook/adversarial-testing/abuse-and-limits/no-account-enumeration","/playbook/website/standard-addresses/nodeinfo"],"sources":[{"label":"RFC 7033: WebFinger","url":"https://www.rfc-editor.org/rfc/rfc7033"}]},{"chapter":"Standard addresses","name":"Open links in your iOS app","level":"Optional","rule":"If you have an iOS app, publish `/.well-known/apple-app-site-association` so links open in it.","check":"`curl https://app-site-association.cdn-apple.com/a/v1/example.com` returns your current file. A link to a listed path opens the app on iOS.","url":"https://agentwranglers.se/playbook/website/standard-addresses/apple-app-site-association","related":["/playbook/notifications/push-notifications/push-deep-links"],"sources":[{"label":"Apple: Supporting associated domains","url":"https://developer.apple.com/documentation/xcode/supporting-associated-domains"}]},{"chapter":"Standard addresses","name":"Open links in your Android app","level":"Optional","rule":"If you have an Android app, publish `/.well-known/assetlinks.json` so links open in it.","check":"Google's Digital Asset Links API lists the app for the site. A link to the site opens the app on Android.","url":"https://agentwranglers.se/playbook/website/standard-addresses/assetlinks-json","related":["/playbook/notifications/push-notifications/push-deep-links"],"sources":[{"label":"Google Digital Asset Links (Getting started)","url":"https://developers.google.com/digital-asset-links/v1/getting-started"}]},{"chapter":"Standard addresses","name":"Describe a federated server","level":"Optional","rule":"If you run a federated server, such as a Mastodon-style social server, publish `/.well-known/nodeinfo`.","check":"`curl -s https://example.com/.well-known/nodeinfo` returns the links. The linked document answers 200 with `software.name` and `usage`.","url":"https://agentwranglers.se/playbook/website/standard-addresses/nodeinfo","related":["/playbook/website/standard-addresses/webfinger"],"sources":[]},{"chapter":"Standard addresses","name":"Limit prefetch proxies only if needed","level":"Optional","rule":"Publish `/.well-known/traffic-advice` only to limit private prefetch proxies, which load pages early for privacy browsers. Leaving it out welcomes them.","check":"The file answers 200 with that content type.","url":"https://agentwranglers.se/playbook/website/standard-addresses/traffic-advice","related":["/playbook/website/performance/speculation-rules"],"sources":[]},{"chapter":"Standard addresses","name":"Require encrypted mail delivery","level":"Recommended","rule":"If the domain receives email, publish an MTA-STS policy, so other mail servers deliver to it only over checked TLS.","check":"`curl https://mta-sts.example.com/.well-known/mta-sts.txt` returns the policy as `text/plain`. A mail test such as internet.nl shows MTA-STS.","url":"https://agentwranglers.se/playbook/website/standard-addresses/mta-sts","related":["/playbook/email/sending-domain-and-authentication/smtp-tls"],"sources":[{"label":"RFC 8461 (SMTP MTA Strict Transport Security)","url":"https://www.rfc-editor.org/rfc/rfc8461"},{"label":"RFC 8460 (SMTP TLS Reporting)","url":"https://www.rfc-editor.org/rfc/rfc8460"}]},{"chapter":"Ready for AI agents","name":"Make the site easy for AI agents","level":"Recommended","rule":"Make the site easy for AI agents to read and use. Put content in the HTML, and add structured data, Markdown copies, clear crawler rules and discovery files.","check":"Every discovery file answers 200 with the right content type. The HTML of every page, fetched without running scripts, contains its `h1` and main text.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/agent-readiness-overview","related":["/playbook/api-design/contracts-and-docs/agent-discovery"],"sources":[]},{"chapter":"Ready for AI agents","name":"Give AI tools a site guide","level":"Recommended","rule":"Publish `/llms.txt`, a short Markdown guide to your site for AI tools. Give the site's name, a one-line summary, and links to the Markdown version of each important page.","check":"Fetch it. It answers 200 as `text/markdown` or `text/plain`, starts with one `#` heading, is under 16 KB, and every linked page answers 200. The home page links to it inside `<main>`.","measured":"On a reference site, llms.txt listed every chapter and weighed 56 KB. Listing only each playbook's overview cut it from 56 KB to about 12 KB.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/llms-txt","related":["/playbook/ai-assisted-development/context-and-memory/fetchable-docs","/playbook/website/ready-for-ai-agents/llms-full-txt"],"sources":[]},{"chapter":"Ready for AI agents","name":"Offer all content in one file","level":"Optional","rule":"Publish `/llms-full.txt` with the full text of every public page in one Markdown file.","check":"A test counts the pages in the file. Every public page appears exactly once.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/llms-full-txt","related":["/playbook/website/ready-for-ai-agents/llms-txt"],"sources":[]},{"chapter":"Ready for AI agents","name":"Offer every page as Markdown","level":"Recommended","rule":"Serve every page as Markdown too. Use its address plus `.md`, and answer requests with `Accept: text/markdown`. Point back to the HTML as the canonical copy.","check":"`curl -sI -H 'Accept: text/markdown' URL` shows `Content-Type: text/markdown`, `Vary: Accept` and the canonical `Link`. A plain browser request to the same URL gets HTML with `Vary: Accept`.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/markdown-source-endpoints","related":["/playbook/website/security/digest-fields"],"sources":[{"label":"RFC 7763 (The text/markdown Media Type)","url":"https://www.rfc-editor.org/rfc/rfc7763"},{"label":"RFC 9110 (Vary)","url":"https://www.rfc-editor.org/rfc/rfc9110#field.vary"}]},{"chapter":"Ready for AI agents","name":"Show agents the same page as people","level":"Avoid","rule":"Never serve AI crawlers or agents different content from what people see, chosen by user agent, IP address or bot signature.","check":"Fetch each page with a browser user agent and with an AI crawler's user agent, such as GPTBot. The visible text of the two answers is identical.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/same-content-for-agents","related":["/playbook/website/ready-for-ai-agents/robots-for-ai-crawlers","/playbook/website/ready-for-ai-agents/machine-readable-formats"],"sources":[{"label":"Google Search spam policies (Cloaking)","url":"https://developers.google.com/search/docs/essentials/spam-policies#cloaking"}]},{"chapter":"Ready for AI agents","name":"Decide what each AI crawler may do","level":"Recommended","rule":"Decide what each AI crawler may do. Write it in robots.txt as its own user-agent group.","check":"A unit test parses the served robots.txt with a robots parser and asserts allowed or blocked for each named agent. Server logs show no blocked agent fetching blocked paths.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/robots-for-ai-crawlers","related":["/playbook/website/seo/snippet-controls","/playbook/website/ready-for-ai-agents/same-content-for-agents","/playbook/adversarial-testing/hostile-agents-and-bots/limit-bulk-reads","/playbook/website/ready-for-ai-agents/content-signals"],"sources":[{"label":"RFC 9309 (Robots Exclusion Protocol)","url":"https://www.rfc-editor.org/rfc/rfc9309"},{"label":"Google's common crawlers","url":"https://developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers"}]},{"chapter":"Ready for AI agents","name":"Say how your content may be used","level":"Optional","rule":"Use `Content-Signal` lines in robots.txt to say whether your content may be used for search, AI answers or AI training.","check":"Fetch robots.txt. Every user-agent group has one `Content-Signal` line with your three choices.","measured":"On a reference site, removing these lines raises Lighthouse SEO from 92 to 100, because of the \"unknown directive\" warning. RFC 9309 has crawlers use only the lines they can parse, so other crawlers skip them. Keep them if you want the signals.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/content-signals","related":["/playbook/website/ready-for-ai-agents/robots-for-ai-crawlers","/playbook/website/ready-for-ai-agents/tdmrep"],"sources":[{"label":"IETF draft: AI Preferences vocabulary","url":"https://datatracker.ietf.org/doc/draft-ietf-aipref-vocab/"}]},{"chapter":"Ready for AI agents","name":"State your text-mining choice","level":"Optional","rule":"State whether you reserve text-and-data-mining rights. Use a `tdm-reservation` header and `/.well-known/tdmrep.json`, matching robots.txt.","check":"`curl -sI` on any page shows `tdm-reservation`. The value matches `/.well-known/tdmrep.json` and your robots.txt choices.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/tdmrep","related":["/playbook/website/ready-for-ai-agents/content-signals"],"sources":[{"label":"W3C Community Group TDM Reservation Protocol","url":"https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/"},{"label":"Directive (EU) 2019/790, Article 4","url":"https://eur-lex.europa.eu/eli/dir/2019/790/oj"}]},{"chapter":"Ready for AI agents","name":"Sign your bot's requests","level":"Optional","rule":"If you run a crawler or agent, sign its requests with Web Bot Auth. As a site, don't block unsigned bots by default.","check":"A verifier, such as the open-source libraries linked from the draft, accepts your signed request. The key directory answers 200 over HTTPS.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/web-bot-auth","related":["/playbook/adversarial-testing/hostile-agents-and-bots/verify-agent-identity"],"sources":[{"label":"RFC 9421 (HTTP Message Signatures)","url":"https://www.rfc-editor.org/rfc/rfc9421"},{"label":"IETF draft (Web Bot Auth HTTP signatures protocol)","url":"https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/"}]},{"chapter":"Ready for AI agents","name":"Explain how agents sign up","level":"Optional","applies":["public-api"],"rule":"If agents must register before using your service, explain how in an `auth.md` file at the root of your service.","check":"`/auth.md` answers 200. Every endpoint it names also appears in `/.well-known/oauth-protected-resource`.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/auth-md","related":["/playbook/accounts/roles-and-permissions/delegated-permissions"],"sources":[]},{"chapter":"Ready for AI agents","name":"Publish each page's data as a file","level":"Recommended","rule":"Publish each page's JSON-LD (structured facts) in the page and as its own file. Then agents can read the facts without parsing HTML.","check":"A test parses the embedded graph and the file for every page. They are identical, and both pass a validator such as validator.schema.org.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/structured-data-for-agents","related":["/playbook/website/ready-for-ai-agents/schemamap"],"sources":[{"label":"W3C JSON-LD 1.1","url":"https://www.w3.org/TR/json-ld11/"}]},{"chapter":"Ready for AI agents","name":"Offer content machines can read","level":"Recommended","rule":"Offer the site's content as a sitemap, feeds, Markdown and JSON-LD.","check":"A test reads the page list. Every public page appears in the sitemap and has a Markdown copy and a JSON-LD file, and every feed entry is a listed page. Any data file over 100 KB accepts a filter that cuts the answer below 100 KB.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/machine-readable-formats","related":["/playbook/website/seo/two-way-related-links","/playbook/website/ready-for-ai-agents/same-content-for-agents"],"sources":[{"label":"RFC 4287 (The Atom Syndication Format)","url":"https://www.rfc-editor.org/rfc/rfc4287"},{"label":"W3C JSON-LD 1.1","url":"https://www.w3.org/TR/json-ld11/"}]},{"chapter":"Ready for AI agents","name":"Advertise agent files in headers","level":"Recommended","rule":"List your discovery files in HTTP `Link` headers on every response. These include llms.txt, the sitemap, feeds, the API catalog and agent cards.","check":"`curl -sI` on any page shows the header, and every target it lists answers 200.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/link-headers","related":["/playbook/website/html-basics/feed-discovery","/playbook/api-design/contracts-and-docs/agent-discovery"],"sources":[{"label":"RFC 8288 (Web Linking)","url":"https://www.rfc-editor.org/rfc/rfc8288"},{"label":"IANA Link Relations registry","url":"https://www.iana.org/assignments/link-relations/link-relations.xhtml"},{"label":"RFC 9727 (api-catalog)","url":"https://www.rfc-editor.org/rfc/rfc9727"}]},{"chapter":"Ready for AI agents","name":"Describe your MCP tools honestly","level":"Optional","rule":"If you run an MCP server, a way for AI assistants to use your tools, publish a server card and label every tool honestly.","check":"The card validates against its JSON schema. An MCP inspector connects, and lists every tool with its hints. A `tools/list` POST with only `Accept: application/json` gets the tool list.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/mcp-and-tool-discovery","related":["/playbook/stack/ai-software/mcp-for-tools","/playbook/adversarial-testing/ai-agents-and-models/pinned-tools"],"sources":[{"label":"MCP specification (Tools)","url":"https://modelcontextprotocol.io/specification/2026-07-28/server/tools"},{"label":"SEP-2127 (MCP Server Cards)","url":"https://modelcontextprotocol.io/seps/2127"}]},{"chapter":"Ready for AI agents","name":"Publish an agent card","level":"Optional","rule":"If you run an A2A agent, one that talks to other agents, publish its card at `/.well-known/agent-card.json`. Keep a working endpoint behind it.","check":"Fetch the card and validate it against the A2A schema. Send a JSON-RPC `SendMessage` with `A2A-Version: 1.0`, and get a message or task back.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/a2a-agent-cards","related":["/playbook/website/ready-for-ai-agents/agentic-resource-discovery"],"sources":[]},{"chapter":"Ready for AI agents","name":"List skills agents can learn","level":"Optional","rule":"Publish agent skills as `SKILL.md` files, listed in `/.well-known/agent-skills/index.json`. Give each one a digest, a hash of its exact bytes.","check":"Hash the served file again. The digest matches.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/agent-skills-discovery","related":["/playbook/ai-assisted-development/agent-skills/project-skills-in-repo","/playbook/adversarial-testing/ai-agents-and-models/pinned-tools"],"sources":[]},{"chapter":"Ready for AI agents","name":"List agent endpoints in DNS","level":"Optional","rule":"List your agent endpoints in DNS with SVCB records, as the DNS-AID draft describes. Sign the zone with DNSSEC so answers can be trusted.","check":"A DNS query such as `dig SVCB _index._agents.example.com` returns the record, with the `ad` flag set when DNSSEC validates.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/dns-aid","related":["/playbook/infrastructure/networking-and-dns/dns-as-code"],"sources":[{"label":"IETF draft (DNS-AID)","url":"https://datatracker.ietf.org/doc/draft-mozleywilliams-dnsop-dnsaid/"},{"label":"RFC 9460 (SVCB and HTTPS DNS records)","url":"https://www.rfc-editor.org/rfc/rfc9460"}]},{"chapter":"Ready for AI agents","name":"Keep one catalogue of agent tools","level":"Optional","rule":"List your MCP servers, agents and skills in `/.well-known/ai-catalog.json`. Link it from every page.","check":"The catalog parses as JSON. Every entry's URL answers 200 with the media type named in its `type`. Every page has the `rel=\"ai-catalog\"` link.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/agentic-resource-discovery","related":["/playbook/website/ready-for-ai-agents/a2a-agent-cards","/playbook/api-design/contracts-and-docs/agent-discovery"],"sources":[]},{"chapter":"Ready for AI agents","name":"Answer questions from your own pages","level":"Optional","rule":"Offer an endpoint that answers plain-language questions from your own content, with sources.","check":"Ask three questions your pages answer and one they don't. Include one that only a deep page answers, such as a guide. Each answer cites sources that answer 200, and the fourth says it has no answer.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/nlweb","related":["/playbook/adversarial-testing/ai-agents-and-models/grounded-answers"],"sources":[]},{"chapter":"Ready for AI agents","name":"Give in-browser agents your tools","level":"Optional","rule":"Register the site's tools with the browser through WebMCP. Mark the ones with effects, and report expected failures in the tool's answer.","check":"In a browser with WebMCP turned on, the tools appear with their schemas. Calling each one with a bad input returns a readable failure.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/webmcp","related":["/playbook/adversarial-testing/ai-agents-and-models/human-approval"],"sources":[]},{"chapter":"Ready for AI agents","name":"Package your knowledge for agents","level":"Optional","rule":"Publish the site's knowledge as an Open Knowledge Format bundle. It has an index, one typed Markdown file per concept, and a change log.","check":"Every file linked from the index answers 200, and every concept file has front matter with a non-empty `type`.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/okf-bundle","related":[],"sources":[]},{"chapter":"Ready for AI agents","name":"Index every page's data file","level":"Optional","rule":"List every JSON-LD file with its types in `/schemamap.xml`. Link it from the page head.","check":"Every listed JSON-LD URL answers 200, and every public page appears in the file.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/schemamap","related":["/playbook/website/ready-for-ai-agents/structured-data-for-agents"],"sources":[]},{"chapter":"Ready for AI agents","name":"Ask agents to credit you","level":"Optional","rule":"If agents may reuse your content in their own work, tell them how to credit it, on every page and in the Markdown copy.","check":"Fetch any page and its Markdown copy. Both contain the credit line with that page's own address.","url":"https://agentwranglers.se/playbook/website/ready-for-ai-agents/ask-for-credit","related":["/playbook/ai-assisted-development/context-and-memory/playbooks-as-references"],"sources":[]},{"chapter":"Privacy","name":"Explain what you keep and why","level":"Required","risk":"law","applies":["eu-users"],"rule":"Publish a full privacy policy, linked from every page. It says who you are, what you collect and why, the legal basis, retention, recipients, rights and complaints.","check":"A test confirms every page links to the policy. A second test fetches the policy and finds a heading for each Article 13 item, from the controller's identity to the right to complain.","url":"https://agentwranglers.se/playbook/website/privacy/privacy-policy","related":["/playbook/compliance/records-and-data-map/records-of-processing","/playbook/compliance/scope-and-ownership/eu-representative"],"sources":[{"label":"GDPR Articles 13 and 14","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Privacy","name":"Store nothing extra without a yes","level":"Required","risk":"law","applies":["eu-users"],"rule":"Ask before storing anything non-essential. Make saying no as easy as saying yes, and store nothing until people agree.","check":"With a fresh browser profile, no non-essential cookies, storage or third-party requests appear before consent. After clicking reject, the same holds. Withdrawing consent from the footer link removes the cookies it allowed.","url":"https://agentwranglers.se/playbook/website/privacy/cookie-consent","related":["/playbook/website/privacy/preference-storage","/playbook/data/product-analytics/analytics-consent"],"sources":[{"label":"ePrivacy Directive 2002/58/EC, Article 5(3)","url":"https://eur-lex.europa.eu/eli/dir/2002/58/oj"},{"label":"EDPB Guidelines 05/2020 on consent","url":"https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en"}]},{"chapter":"Privacy","name":"Treat the privacy signal as a no","level":"Recommended","rule":"Treat a `Sec-GPC: 1` signal as an opt-out of selling or sharing data, and say so in the privacy policy.","check":"Load the site with GPC turned on. No requests go to advertising or sharing partners, and `/.well-known/gpc.json` answers with `\"gpc\": true`.","url":"https://agentwranglers.se/playbook/website/privacy/global-privacy-control","related":["/playbook/data/product-analytics/analytics-consent","/playbook/compliance/us-state-privacy-laws/sales-and-sharing-inventory"],"sources":[{"label":"W3C Global Privacy Control","url":"https://www.w3.org/TR/gpc/"},{"label":"California Privacy Protection Agency regulations","url":"https://cppa.ca.gov/regulations/"}]},{"chapter":"Privacy","name":"Host everything yourself for privacy","level":"Recommended","rule":"Avoid third-party scripts. Host what you need yourself, so visitors' data does not go to other companies.","check":"A browser test loads every page and collects the origin of each request. Only your own origin appears, plus any listed in the privacy policy.","url":"https://agentwranglers.se/playbook/website/privacy/third-party-scripts","related":["/playbook/website/security/content-security-policy","/playbook/adversarial-testing/supply-chain-and-setup/third-party-scripts"],"sources":[]},{"chapter":"Privacy","name":"Ask before embedded content uses cookies","level":"Optional","rule":"If embedded content needs its own cookies, ask for them with the Storage Access API. Don't rely on third-party cookies.","check":"In a browser that blocks third-party cookies, the embed shows a button first. After a click, it shows the signed-in view.","url":"https://agentwranglers.se/playbook/website/privacy/storage-access-api","related":["/playbook/website/privacy/click-to-load-embeds"],"sources":[]},{"chapter":"Privacy","name":"Count visits without tracking people","level":"Recommended","rule":"Measure without tracking people. Use no analytics, or analytics without cookies or personal data.","check":"With a fresh browser profile, a visit sets no cookies or storage for analytics. The stored analytics rows contain no IP address, user agent or ID.","url":"https://agentwranglers.se/playbook/website/privacy/analytics-privacy","related":["/playbook/website/performance/field-measurement","/playbook/data/product-analytics/tracking-plan","/playbook/data/product-analytics/analytics-consent","/playbook/stack/product-services/plausible-analytics"],"sources":[{"label":"EDPB Guidelines 2/2023 on Article 5(3) ePrivacy","url":"https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en"}]},{"chapter":"Privacy","name":"Keep only what you need and delete it on time","level":"Required","risk":"law","applies":["eu-users"],"rule":"Collect only the data a task needs. Keep it only for its set retention period, and delete it on schedule and on request.","check":"A test stores a record older than its retention period, runs the scheduled delete job, and the record is gone. A search of a day's access log finds no IP address.","url":"https://agentwranglers.se/playbook/website/privacy/data-minimization","related":["/playbook/compliance/records-and-data-map/retention-schedule","/playbook/adversarial-testing/data-and-secrets/private-data-out-of-logs","/playbook/data/personal-data/minimal-columns","/playbook/payments/refunds-disputes-and-fraud/dispute-evidence","/playbook/payments/tax/customer-location-evidence","/playbook/accounts/sign-up/minimal-sign-up","/playbook/accounts/sessions-and-security-activity/security-activity-log","/playbook/compliance/audit-logs-and-access/audit-log-events"],"sources":[{"label":"GDPR Article 5(1)(c) and (e)","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"},{"label":"CJEU Case C-582/14 Breyer","url":"https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62014CJ0582"}]},{"chapter":"Privacy","name":"Store each preference where it is needed","level":"Recommended","rule":"Keep a visitor's preferences in local storage when only the browser needs them. Use a cookie only when the server must know the preference before it answers.","check":"Set the preference and reload. It shows at once with no flash, and requests carry no cookie unless the server reads it.","url":"https://agentwranglers.se/playbook/website/privacy/preference-storage","related":["/playbook/website/privacy/cookie-consent"],"sources":[]},{"chapter":"Privacy","name":"Keep personal data out of addresses","level":"Required","risk":"data","rule":"Never put names, email addresses or other personal data in URLs. Send them in the body of a POST request instead. Only single-use links from an email may carry a random token.","check":"Submit every form with test data. Search the access log and the browser history for the test values, and find none. Open an email link, and the access log shows the path without its token.","url":"https://agentwranglers.se/playbook/website/privacy/personal-data-out-of-urls","related":["/playbook/email/email-security/sign-in-links","/playbook/api-design/api-security/no-secrets-in-urls","/playbook/email/email-privacy/private-links","/playbook/app-ui/client-and-server-state/state-in-url"],"sources":[{"label":"RFC 9110 (Sensitive Information in URIs)","url":"https://www.rfc-editor.org/rfc/rfc9110#sensitive.information.in.uris"},{"label":"OWASP Information Exposure Through Query Strings","url":"https://community.owasp.org/vulnerabilities/Information_exposure_through_query_strings_in_url"}]},{"chapter":"Privacy","name":"Sign a contract with every processor","level":"Required","risk":"law","applies":["eu-users"],"rule":"Have a data processing agreement with every service that handles personal data for you, such as hosting, email or analytics.","check":"Every processor in the data map has a signed agreement on file, and appears as a recipient in the privacy policy.","url":"https://agentwranglers.se/playbook/website/privacy/processor-agreements","related":["/playbook/compliance/processors-and-transfers/vendor-register","/playbook/compliance/processors-and-transfers/dpa-article-28","/playbook/compliance/processors-and-transfers/subprocessor-list","/playbook/compliance/processors-and-transfers/transfer-basis"],"sources":[{"label":"GDPR Article 28","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"},{"label":"Decision (EU) 2021/914 on Standard Contractual Clauses","url":"https://eur-lex.europa.eu/eli/dec_impl/2021/914/oj"},{"label":"Decision (EU) 2023/1795 on the Data Privacy Framework","url":"https://eur-lex.europa.eu/eli/dec_impl/2023/1795/oj"}]},{"chapter":"Privacy","name":"Plan for a data breach","level":"Recommended","rule":"Write down what to do when personal data leaks, before it happens.","check":"Once a year, run a practice breach with the plan. The team reaches the 72-hour report step on time.","url":"https://agentwranglers.se/playbook/website/privacy/breach-plan","related":["/playbook/operations/incidents/security-incidents","/playbook/compliance/breaches-and-reporting/reporting-clocks","/playbook/compliance/breaches-and-reporting/breach-register","/playbook/compliance/breaches-and-reporting/breach-notice-templates","/playbook/compliance/us-state-privacy-laws/us-breach-notice-laws"],"sources":[{"label":"GDPR Articles 33 and 34","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Privacy","name":"Let people see and delete their data","level":"Required","risk":"law","applies":["eu-users"],"rule":"Give people a working way to get a copy of their data, correct it and have it deleted, and answer within one month.","check":"Create a test person with data in every store. Ask for their data and then for its deletion. The export is complete. A search of every store finds no trace of their ID or email, except records the law requires. Then restore a backup taken before the deletion. The same search again finds no trace, and the erasure log holds their ID only.","url":"https://agentwranglers.se/playbook/website/privacy/data-requests","related":["/playbook/operations/disaster-recovery/backup-retention","/playbook/accounts/profile-and-account-lifecycle/account-deletion","/playbook/accounts/profile-and-account-lifecycle/self-service-export","/playbook/api-design/files-and-uploads/delete-files-with-owner","/playbook/data/schema-design/deletion-design","/playbook/data/personal-data/retention-jobs","/playbook/ai-features/logs-privacy-and-feedback/erase-from-ai-stores","/playbook/compliance/us-state-privacy-laws/request-deadlines-by-law"],"sources":[{"label":"GDPR Articles 12 to 22","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Privacy","name":"Say when people talk to an AI","level":"Required","risk":"law","applies":["ai-features","eu-users"],"rule":"Tell people when they are talking to an AI system, and label AI-made images, audio and video that could pass as genuine.","check":"Open every AI feature as a new visitor. Each one says it is an AI before or at the first answer. A metadata reader, such as a C2PA verify tool, finds a label in each generated file.","url":"https://agentwranglers.se/playbook/website/privacy/disclose-ai","related":["/playbook/ai-features/guardrails-and-human-review/label-ai-output","/playbook/compliance/ai-act/ai-transparency-map"],"sources":[{"label":"AI Act, Regulation (EU) 2024/1689, Article 50","url":"https://eur-lex.europa.eu/eli/reg/2024/1689/oj"}]},{"chapter":"Privacy","name":"Load embedded videos and maps only after a click","level":"Recommended","rule":"Show a still image and a button in place of each third-party embed. Load the video, map or social post only after the visitor clicks.","check":"With a fresh browser profile, load each page with an embed. No request goes to the provider until the button is clicked.","url":"https://agentwranglers.se/playbook/website/privacy/click-to-load-embeds","related":["/playbook/website/accessibility/audio-control","/playbook/website/privacy/storage-access-api"],"sources":[{"label":"ePrivacy Directive 2002/58/EC, Article 5(3)","url":"https://eur-lex.europa.eu/eli/dir/2002/58/oj"},{"label":"CJEU Case C-40/17 Fashion ID","url":"https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62017CJ0040"}]},{"chapter":"When things break","name":"Make error pages helpful and honest","level":"Recommended","rule":"Serve 404 and 500 pages with the right status codes and the site's own navigation. Never show stack traces.","check":"Request a missing page and force an error. They answer 404 and 500, look like the site, and show no stack trace. The 500 page's reference appears in the server log.","url":"https://agentwranglers.se/playbook/website/when-things-break/error-pages","related":["/playbook/website/redirects-and-canonicals/gone-pages","/playbook/adversarial-testing/data-and-secrets/short-errors"],"sources":[{"label":"RFC 9110 (404 Not Found)","url":"https://www.rfc-editor.org/rfc/rfc9110#status.404"},{"label":"OWASP Error Handling Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html"}]},{"chapter":"When things break","name":"Say when you'll be back","level":"Recommended","rule":"During planned maintenance, answer 503 with `Retry-After` and a clear page. Keep the health check working.","check":"With the switch on, `curl -sI` on a page answers 503 with `Retry-After`, and the health check answers 200.","url":"https://agentwranglers.se/playbook/website/when-things-break/maintenance-pages","related":["/playbook/operations/incidents/status-updates"],"sources":[{"label":"RFC 9110 (503 Service Unavailable)","url":"https://www.rfc-editor.org/rfc/rfc9110#status.503"},{"label":"Google crawling docs (HTTP status codes)","url":"https://developers.google.com/crawling/docs/troubleshooting/http-status-codes"}]},{"chapter":"When things break","name":"Keep working when a script or service fails","level":"Recommended","rule":"When a script, a font or another company's service fails at runtime, the page still shows its content and its main actions still work.","check":"In the browser's developer tools, block each third-party domain and then the main script. Each page still shows its content and its main links, with no uncaught errors.","url":"https://agentwranglers.se/playbook/website/when-things-break/graceful-degradation","related":["/playbook/website/site-architecture/progressive-enhancement","/playbook/app-ui/data-views/partial-results"],"sources":[]},{"chapter":"When things break","name":"Hide buttons that need JavaScript until it runs","level":"Recommended","rule":"Never show a button that does nothing. Hide controls that need JavaScript until the script is running, without moving the page.","check":"Turn JavaScript off. No button is visible that does nothing, and layout shift stays at 0 with JavaScript on.","url":"https://agentwranglers.se/playbook/website/when-things-break/hide-script-only-controls","related":["/playbook/website/site-architecture/progressive-enhancement"],"sources":[]},{"chapter":"When things break","name":"Keep working when the network drops","level":"Optional","rule":"Add a service worker, a small script that sits between the page and the network. Show an offline page and cached pages when the network fails.","check":"In DevTools, go offline. A visited page or the offline page appears.","measured":"On a reference site, a service worker warmed its cache with the page's links after load. In an earlier lab run, the first click went from 216 to 106 ms in Firefox. In WebKit, which did not prefetch, it went from 373 to 180 ms. In Chrome it made no difference beyond prefetch and the HTTP cache.","url":"https://agentwranglers.se/playbook/website/when-things-break/offline-support","related":["/playbook/app-ui/permissions-flags-and-failures/offline-behaviour"],"sources":[{"label":"W3C Service Workers","url":"https://www.w3.org/TR/service-workers/"}]},{"chapter":"When things break","name":"Let people install the site","level":"Recommended","rule":"Publish a web app manifest with a name, start address, colours, and 192 and 512 px icons, including a maskable one.","check":"`curl -sI` on the manifest answers 200 as `application/manifest+json`, and each icon in it answers 200. DevTools › Application › Manifest shows no errors.","url":"https://agentwranglers.se/playbook/website/when-things-break/pwa-manifest","related":["/playbook/website/html-basics/favicons","/playbook/website/accessibility/orientation"],"sources":[{"label":"W3C Web Application Manifest","url":"https://www.w3.org/TR/appmanifest/"}]},{"chapter":"When things break","name":"Warn before retiring an address","level":"Optional","rule":"Before you retire an address or API, announce it with `Deprecation` and `Sunset` headers.","check":"`curl -sI` on the retiring address shows both headers and the link, and the migration notes answer 200.","url":"https://agentwranglers.se/playbook/website/when-things-break/deprecation-and-sunset","related":["/playbook/api-design/changes-over-time/deprecation-headers","/playbook/api-design/resources-and-methods/stable-addresses","/playbook/api-design/changes-over-time/versioning"],"sources":[{"label":"RFC 9745 (The Deprecation HTTP Response Header Field)","url":"https://www.rfc-editor.org/rfc/rfc9745"},{"label":"RFC 8594 (The Sunset HTTP Header Field)","url":"https://www.rfc-editor.org/rfc/rfc8594"}]},{"chapter":"When things break","name":"Serve the last good page when the server fails","level":"Recommended","rule":"Let caches keep serving the last good copy of a public page for up to a day when the app fails or times out.","check":"Load a page through the cache, then stop the app. The same page still answers 200 from the cache, and a page never cached answers the 500 page.","url":"https://agentwranglers.se/playbook/website/when-things-break/serve-stale-on-error","related":["/playbook/adversarial-testing/requests-and-files/private-responses-uncached"],"sources":[{"label":"RFC 5861: HTTP Cache-Control Extensions for Stale Content","url":"https://www.rfc-editor.org/rfc/rfc5861"}]},{"chapter":"When things break","name":"Collect script errors from visitors' browsers","level":"Recommended","rule":"Send every uncaught script error and rejected promise from the browser to your own endpoint, and alert when they rise.","check":"On a test page, throw an error and reject a promise. Both appear in the server log within a minute, with the build and path and no query string.","url":"https://agentwranglers.se/playbook/website/when-things-break/browser-error-reports","related":["/playbook/website/security/rate-limits","/playbook/operations/alerts-and-on-call/alert-on-symptoms","/playbook/app-ui/permissions-flags-and-failures/error-boundaries","/playbook/stack/operations-tools/sentry"],"sources":[{"label":"HTML Standard (Runtime script errors)","url":"https://html.spec.whatwg.org/multipage/webappapis.html#runtime-script-errors"},{"label":"HTML Standard (Unhandled promise rejections)","url":"https://html.spec.whatwg.org/multipage/webappapis.html#unhandled-promise-rejections"},{"label":"W3C Beacon","url":"https://www.w3.org/TR/beacon/"}]},{"chapter":"Languages and regions","name":"Give each language its own path","level":"Recommended","rule":"On sites with several languages, give each language one clear address pattern, such as `/en/` and `/sv/`, and keep it stable. A one-language site needs no prefix.","check":"A test lists every page. Each address carries exactly one known language in the chosen pattern, and no address has a `lang` query parameter.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/international-url-structure","related":["/playbook/website/languages-and-regions/hreflang"],"sources":[{"label":"Google Search Central: Managing multi-regional and multilingual sites","url":"https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites"}]},{"chapter":"Languages and regions","name":"Link language versions together","level":"Recommended","rule":"On sites with several languages, link each language version of a page to the others with `hreflang`. A one-language site should not use it.","check":"A test reads every page's `hreflang` links. Each target answers 200, is canonical to itself, and links back.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/hreflang","related":["/playbook/website/languages-and-regions/international-url-structure","/playbook/website/languages-and-regions/sitemap-hreflang"],"sources":[{"label":"Google Search Central (Localized versions of your pages)","url":"https://developers.google.com/search/docs/specialty/international/localized-versions"},{"label":"RFC 5646 (Tags for Identifying Languages)","url":"https://www.rfc-editor.org/rfc/rfc5646"}]},{"chapter":"Languages and regions","name":"Translate the metadata too","level":"Recommended","rule":"Set the language in `lang`, `og:locale` and `inLanguage` in structured data, and write alt text in that language.","check":"A test reads each language version. `lang`, `og:locale` and `inLanguage` agree, and the title differs from the default language.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/localised-metadata","related":["/playbook/website/html-basics/html-lang"],"sources":[{"label":"HTML Standard (The lang attribute)","url":"https://html.spec.whatwg.org/multipage/dom.html#the-lang-and-xml:lang-attributes"},{"label":"Schema.org inLanguage","url":"https://schema.org/inLanguage"}]},{"chapter":"Languages and regions","name":"List language versions in the sitemap","level":"Optional","rule":"On sites with several languages, list each page's language versions in the sitemap with `hreflang`.","check":"The sitemap validates, and every version listed for a `url` lists the same set of versions. Where pages also carry `hreflang` links, the sitemap lists the same set as the HTML.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/sitemap-hreflang","related":["/playbook/website/languages-and-regions/hreflang"],"sources":[{"label":"Google Search Central: Localized versions in a sitemap","url":"https://developers.google.com/search/docs/specialty/international/localized-versions#sitemap"}]},{"chapter":"Languages and regions","name":"Never redirect by location","level":"Avoid","rule":"Never send people to another language or country by IP address or `Accept-Language`. Suggest it and let them choose.","check":"Request each page with several `Accept-Language` values and no cookies. Every answer is 200 with the same canonical address, never a 3xx.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/avoid-auto-geo-redirects","related":["/playbook/website/languages-and-regions/language-switcher"],"sources":[{"label":"Google Search Central (Locale-adaptive pages)","url":"https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages"},{"label":"Google Search Central (Managing multi-regional and multilingual sites)","url":"https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites"}]},{"chapter":"Languages and regions","name":"Mark foreign words","level":"Required","risk":"law","rule":"Mark every passage in another language with its own `lang` attribute.","check":"An accessibility checker such as axe reports no `valid-lang` failures. A test keeps a list of the foreign phrases the site uses, and finds each one inside an element with a matching `lang`.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/lang-attribute","related":["/playbook/email/message-basics/language-and-direction","/playbook/website/languages-and-regions/translate-attribute"],"sources":[{"label":"WCAG 2.2 (3.1.2 Language of Parts)","url":"https://www.w3.org/TR/WCAG22/#language-of-parts"},{"label":"RFC 5646 (Tags for Identifying Languages)","url":"https://www.rfc-editor.org/rfc/rfc5646"}]},{"chapter":"Languages and regions","name":"Keep names out of machine translation","level":"Optional","rule":"Protect names, code and addresses from machine translation with `translate=\"no\"`.","check":"A test finds `translate=\"no\"` on every `code` element and every element that holds the brand name or an email address.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/translate-attribute","related":["/playbook/website/languages-and-regions/lang-attribute"],"sources":[{"label":"HTML Standard: The translate attribute","url":"https://html.spec.whatwg.org/multipage/dom.html#attr-translate"}]},{"chapter":"Languages and regions","name":"Let people pick their language","level":"Recommended","rule":"On sites with several languages, offer a switcher that names each language in that language and keeps people on the same page.","check":"From any page, each switcher link leads to the same page in the other language and answers 200.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/language-switcher","related":["/playbook/website/languages-and-regions/avoid-auto-geo-redirects"],"sources":[]},{"chapter":"Languages and regions","name":"Support right-to-left text","level":"Recommended","rule":"Support right-to-left languages with `dir`, logical CSS properties and `dir=\"auto\"` on user input.","check":"Load a page with `dir=\"rtl\"`. Nothing overlaps or scrolls sideways, and icons that point a direction are mirrored.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/rtl-support","related":["/playbook/website/accessibility/sensory-characteristics","/playbook/email/message-basics/language-and-direction","/playbook/website/languages-and-regions/writing-modes"],"sources":[{"label":"HTML Standard (The dir attribute)","url":"https://html.spec.whatwg.org/multipage/dom.html#the-dir-attribute"},{"label":"W3C CSS Logical Properties and Values Level 1","url":"https://www.w3.org/TR/css-logical-1/"}]},{"chapter":"Languages and regions","name":"Support vertical and CJK text","level":"Optional","rule":"Where Chinese, Japanese or Korean (CJK) text appears, support vertical writing and their line breaking with `writing-mode` and the right line-break rules.","check":"Text in the CJK pages breaks between phrases, and vertical blocks read top to bottom, right to left.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/writing-modes","related":["/playbook/website/languages-and-regions/rtl-support"],"sources":[{"label":"W3C CSS Writing Modes Level 4","url":"https://www.w3.org/TR/css-writing-modes-4/"},{"label":"CSS Text Level 4 (word-break auto-phrase)","url":"https://www.w3.org/TR/css-text-4/#valdef-word-break-auto-phrase"}]},{"chapter":"Languages and regions","name":"Format dates and numbers for the reader","level":"Recommended","rule":"Format dates, numbers and money for the reader's locale with a formatting library. Show times with a named time zone.","check":"A unit test formats a fixed date and amount for each supported locale. The output matches the expected strings.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/locale-content","related":["/playbook/api-design/requests-and-responses/dates-and-times","/playbook/app-ui/dates-and-numbers/user-time-zone"],"sources":[{"label":"ECMA-402 (ECMAScript Internationalization API)","url":"https://tc39.es/ecma402/"},{"label":"Unicode CLDR","url":"https://cldr.unicode.org/"}]},{"chapter":"Languages and regions","name":"Get plurals right in every language","level":"Recommended","rule":"Choose plural forms from the language's plural rules instead of adding an \"s\".","check":"A unit test runs the counts 0, 1, 2, 5 and 21 for each language. Each picks the right form.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/plural-rules","related":["/playbook/website/languages-and-regions/whole-sentence-messages","/playbook/app-ui/dates-and-numbers/relative-times"],"sources":[{"label":"Unicode CLDR language plural rules","url":"https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html"},{"label":"ECMA-402 (PluralRules objects)","url":"https://tc39.es/ecma402/#pluralrules-objects"}]},{"chapter":"Languages and regions","name":"Translate whole sentences","level":"Recommended","rule":"On sites with several languages, or one planned, keep every visible text in a message file. Use one entry per whole sentence with named placeholders, and never build a sentence from translated pieces.","check":"A lint rule finds no text in templates outside the message calls. A test finds no message that is joined to another with `+` or string formatting.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/whole-sentence-messages","related":["/playbook/website/languages-and-regions/plural-rules"],"sources":[{"label":"Unicode LDML Part 9 (MessageFormat)","url":"https://www.unicode.org/reports/tr35/tr35-messageFormat.html"}]},{"chapter":"Languages and regions","name":"Test with a pseudo-locale","level":"Recommended","rule":"When a site will be translated, build a pseudo-locale that makes every text longer and accented. Run the page tests in it before any translation exists.","check":"The browser tests pass in the pseudo-locale. A test finds no visible text node without the brackets, and no element that scrolls sideways.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/pseudo-localisation","related":["/playbook/app-ui/tests-and-budgets/visual-regression"],"sources":[]},{"chapter":"Languages and regions","name":"Sort and compare text by locale","level":"Recommended","rule":"Sort and compare visible text with a collator for the reader's locale, and normalise Unicode before comparing or storing it.","check":"A unit test sorts a fixed list per locale and gets the expected order. Swedish puts \"ä\" after \"z\", and German puts it after \"a\".","url":"https://agentwranglers.se/playbook/website/languages-and-regions/locale-collation","related":["/playbook/data/operations/collation-versions","/playbook/app-ui/lists-and-tables/server-sort-and-filter"],"sources":[{"label":"UTS #10 (Unicode Collation Algorithm)","url":"https://www.unicode.org/reports/tr10/"},{"label":"UAX #15 (Unicode Normalization Forms)","url":"https://www.unicode.org/reports/tr15/"}]},{"chapter":"Languages and regions","name":"Accept names and addresses from every country","level":"Recommended","rule":"Let people enter names and addresses as they are written in their country. Never reject letters, word counts or field shapes your own country does not use.","check":"A form test submits \"José O'Brien-Núñez\", \"李小龙\" and the single name \"Suharto\", each with an address in Hong Kong, which has no postal codes. Every one is accepted and stored unchanged.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/international-names-and-addresses","related":["/playbook/notifications/text-messages/e164-phone-numbers"],"sources":[{"label":"W3C Personal names around the world","url":"https://www.w3.org/International/questions/qa-personal-names"},{"label":"HTML Standard (Autofill)","url":"https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill"}]},{"chapter":"Languages and regions","name":"Make non-ASCII domains work","level":"Optional","rule":"If you use a domain name with non-Latin or accented letters, make its punycode form work everywhere the name appears.","check":"`curl -sI https://xn--bcher-kva.example/` answers 200 with a valid certificate.","url":"https://agentwranglers.se/playbook/website/languages-and-regions/idn-support","related":["/playbook/email/message-basics/international-addresses"],"sources":[{"label":"RFC 5890 (IDNA Definitions)","url":"https://www.rfc-editor.org/rfc/rfc5890"},{"label":"RFC 3492 (Punycode)","url":"https://www.rfc-editor.org/rfc/rfc3492"},{"label":"RFC 6531 (SMTP Extension for Internationalized Email)","url":"https://www.rfc-editor.org/rfc/rfc6531"}]}]}