{"playbook":"App interface","url":"https://agentwranglers.se/playbook/app-ui","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":"Styles and components","name":"Take every style value from design tokens","level":"Recommended","rule":"Take every colour, space, font size, radius, shadow, duration and layer order from a named design token. Never write a raw value in a component.","check":"The style linter fails on a hex colour, `rgb()`, a pixel size or a `z-index` number written outside the token files. Run it on a branch with one such value added, and it fails.","url":"https://agentwranglers.se/playbook/app-ui/styles-and-components/design-tokens","related":["/playbook/website/interface/colour-as-tokens","/playbook/stack/web-frameworks/plain-css","/playbook/html-css/structure-and-files/five-style-layers","/playbook/html-css/naming-and-state/component-custom-properties"],"sources":[]},{"chapter":"Styles and components","name":"Name tokens by purpose","level":"Recommended","rule":"Give components tokens named by purpose, such as `color-danger` or `space-section`, which point to a base palette. Components never use the base palette directly.","check":"Search the component folder for base token names, such as `--blue-` or `--space-4`. There are no matches.","url":"https://agentwranglers.se/playbook/app-ui/styles-and-components/purpose-tokens","related":["/playbook/website/html-basics/color-scheme","/playbook/website/interface/colour-as-tokens","/playbook/html-css/naming-and-state/name-by-role"],"sources":[]},{"chapter":"Styles and components","name":"Build each control once","level":"Recommended","rule":"Build each control once in a shared component library, and use it on every screen. Never style a raw button, input, select or table on a page.","check":"The linter fails when a screen outside the library uses `<button>`, `<input>`, `<select>` or `<table>` directly.","url":"https://agentwranglers.se/playbook/app-ui/styles-and-components/shared-components","related":["/playbook/website/site-architecture/module-class-names","/playbook/website/accessibility/native-interactive-elements","/playbook/html-css/html-structure-and-reuse/one-root-per-template"],"sources":[]},{"chapter":"Styles and components","name":"Change components through variants","level":"Recommended","rule":"Change how a component looks only through its own options, such as `variant=\"danger\"` or `size=\"small\"`. Never override its classes from the outside.","check":"Search screen styles for the library's class prefix and for `!important`. There are no matches.","url":"https://agentwranglers.se/playbook/app-ui/styles-and-components/component-variants","related":["/playbook/website/site-architecture/module-class-names","/playbook/html-css/structure-and-files/component-stylesheet","/playbook/html-css/html-structure-and-reuse/partials-take-data"],"sources":[]},{"chapter":"Styles and components","name":"Show every component state in a catalogue","level":"Recommended","rule":"Give every shared component a catalogue entry for each state it can show, such as default, loading, empty, error and disabled.","check":"A script lists every component file without a stories file next to it. The list is empty. For each component with a `status` type, the stories file exports one story per `status` value.","url":"https://agentwranglers.se/playbook/app-ui/styles-and-components/component-catalogue","related":["/playbook/app-ui/tests-and-budgets/visual-regression","/playbook/testing/non-functional-tests/accessibility-in-ci","/playbook/html-css/naming-and-state/state-in-attributes"],"sources":[]},{"chapter":"Data views","name":"Give every data view all its states","level":"Recommended","rule":"Give every view that loads data a distinct state for loading, empty, error and success. Add partial when it combines sources. Render a designed screen for each.","check":"The [component tests](https://agentwranglers.se/playbook/app-ui/tests-and-budgets/component-state-tests) render every data view in each state. A script lists every data view whose tests lack one of its state names. The list is empty.","url":"https://agentwranglers.se/playbook/app-ui/data-views/every-data-state","related":["/playbook/architecture/data-shapes/states-as-types","/playbook/app-ui/tests-and-budgets/component-state-tests"],"sources":[]},{"chapter":"Data views","name":"Show loading without flicker","level":"Recommended","rule":"Show a loading indicator only when loading takes longer than 300 ms. Once shown, keep it for at least 500 ms. Reserve the final size, so nothing jumps.","check":"A browser test delays the data by 100 ms. A mutation observer records that no indicator appeared at any moment. With a 2 second delay, the indicator appears, and layout shift for the region stays under 0.01. With a 400 ms delay, the indicator stays for at least 500 ms.","url":"https://agentwranglers.se/playbook/app-ui/data-views/loading-without-flicker","related":["/playbook/website/performance/core-web-vitals"],"sources":[{"label":"WAI-ARIA 1.2 on aria-busy","url":"https://www.w3.org/TR/wai-aria-1.2/#aria-busy"}]},{"chapter":"Data views","name":"Tell an empty start from an empty search","level":"Recommended","rule":"Show two different empty states. A new account with no records explains the feature and offers the first action. A search or filter with no matches names the filter and offers to clear it.","check":"Component tests render the view with no records and no filter, then with a filter and no matches. Each shows its own heading and button.","url":"https://agentwranglers.se/playbook/app-ui/data-views/empty-states","related":["/playbook/api-design/lists/empty-lists","/playbook/ai-features/retrieval/no-match-answer"],"sources":[]},{"chapter":"Data views","name":"Show errors where the data should be","level":"Recommended","rule":"When data fails to load, show the error inside the view that failed, with a plain cause, a retry button and a reference. Keep any older data visible and marked as out of date.","check":"A component test fails the request with a 503. The view shows the message, a working \"Try again\" button and the reference. Other panels on the screen still render.","url":"https://agentwranglers.se/playbook/app-ui/data-views/error-in-place","related":["/playbook/ai-features/guardrails-and-human-review/refusals-and-truncation","/playbook/api-design/errors/problem-details"],"sources":[{"label":"RFC 9457 Problem Details for HTTP APIs","url":"https://www.rfc-editor.org/rfc/rfc9457"}]},{"chapter":"Data views","name":"Show what loaded when one part fails","level":"Recommended","rule":"When a view combines several sources and one fails, show the parts that loaded and name the part that is missing.","check":"A test fails one of three sources. The other two render, the failed part shows its notice, and no total is shown as complete.","url":"https://agentwranglers.se/playbook/app-ui/data-views/partial-results","related":["/playbook/website/when-things-break/graceful-degradation"],"sources":[]},{"chapter":"Data views","name":"Say why a control is unavailable","level":"Recommended","rule":"When a control can't be used yet, say why in visible text next to it, and what would make it work.","check":"A browser test finds every disabled button and every `aria-disabled=\"true\"` element. Each has an `aria-describedby` that points to visible, non-empty text.","url":"https://agentwranglers.se/playbook/app-ui/data-views/disabled-with-reason","related":["/playbook/website/accessibility/form-errors","/playbook/app-ui/permissions-flags-and-failures/capabilities-from-server"],"sources":[]},{"chapter":"Dates and numbers","name":"Show times in the user's time zone","level":"Recommended","rule":"Show every moment in time in the user's chosen time zone, and name the zone wherever people in different zones see the same screen.","check":"Run the component tests in two far-apart zones, such as `TZ=Pacific/Kiritimati` and `TZ=America/Los_Angeles`, with the profile zone set to `Europe/Stockholm`. Every rendered time is the same in both runs.","url":"https://agentwranglers.se/playbook/app-ui/dates-and-numbers/user-time-zone","related":["/playbook/api-design/requests-and-responses/dates-and-times","/playbook/website/languages-and-regions/locale-content","/playbook/notifications/channels-and-preferences/quiet-hours"],"sources":[{"label":"IANA Time Zone Database","url":"https://www.iana.org/time-zones"}]},{"chapter":"Dates and numbers","name":"Keep calendar dates as dates","level":"Required","risk":"data","rule":"Keep a value with no time of day, such as a due date, as a plain `YYYY-MM-DD` date everywhere. Never turn it into a moment in time.","check":"Run the date tests with `TZ=America/Los_Angeles` and `TZ=Pacific/Auckland`. A due date of `2026-03-05` saves and shows as 5 March in both.","url":"https://agentwranglers.se/playbook/app-ui/dates-and-numbers/calendar-dates","related":["/playbook/api-design/requests-and-responses/dates-and-times","/playbook/data/schema-design/exact-types"],"sources":[{"label":"TC39 Temporal proposal","url":"https://tc39.es/proposal-temporal/"},{"label":"RFC 3339 Date and Time on the Internet","url":"https://www.rfc-editor.org/rfc/rfc3339"}]},{"chapter":"Dates and numbers","name":"Show relative times with the exact time beside them","level":"Optional","rule":"Put each relative time, such as \"5 minutes ago\", in a `<time>` element with the exact time in `datetime`. Show the exact local time in a tooltip on hover and on keyboard focus.","check":"A component test renders a time from 90 seconds ago. It shows \"2 minutes ago\" or \"1 minute ago\", and the `<time>` element carries the full ISO time. Focusing it by keyboard shows the exact local time.","url":"https://agentwranglers.se/playbook/app-ui/dates-and-numbers/relative-times","related":["/playbook/website/languages-and-regions/plural-rules"],"sources":[{"label":"HTML Standard on the time element","url":"https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-time-element"},{"label":"WCAG 2.2 criterion 1.4.13 Content on Hover or Focus","url":"https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus"},{"label":"ECMA-402 Intl.RelativeTimeFormat","url":"https://tc39.es/ecma402/#relativetimeformat-objects"}]},{"chapter":"Dates and numbers","name":"Read numbers the way people type them","level":"Recommended","rule":"Read numbers and amounts in the user's locale, so `1 234,50` in Sweden and `1,234.50` in the US both mean the same amount. Never parse money as a floating-point number.","check":"Unit tests parse `1 234,50` in `sv-SE`, typed with a plain space, and `1,234.50` in `en-US` into the same value, `1234.50`. The texts `12abc` and, in `en-US`, `1.234,50` are rejected.","url":"https://agentwranglers.se/playbook/app-ui/dates-and-numbers/locale-number-input","related":["/playbook/website/accessibility/mobile-form-inputs","/playbook/api-design/requests-and-responses/money"],"sources":[{"label":"HTML Standard on the inputmode attribute","url":"https://html.spec.whatwg.org/multipage/interaction.html#attr-inputmode"},{"label":"ECMA-402 Intl.NumberFormat","url":"https://tc39.es/ecma402/#numberformat-objects"}]},{"chapter":"Forms","name":"Check fields when people leave them","level":"Recommended","rule":"Check a field first when focus leaves it, then on every change once it shows an error, and check all fields on submit. Never show an error on a field nobody has touched.","check":"A browser test types one letter into the email field. No error shows. It tabs away, and the error shows. It fixes the value, and the error goes away before focus leaves.","url":"https://agentwranglers.se/playbook/app-ui/forms/validation-timing","related":["/playbook/website/accessibility/no-surprise-changes","/playbook/website/accessibility/form-errors","/playbook/website/accessibility/form-labels"],"sources":[{"label":"WCAG 2.2 criterion 3.3.1 Error Identification","url":"https://www.w3.org/TR/WCAG22/#error-identification"},{"label":"HTML Standard on the :user-invalid selector","url":"https://html.spec.whatwg.org/multipage/semantics-other.html#selector-user-invalid"}]},{"chapter":"Forms","name":"Check every field on the server too","level":"Required","risk":"data","rule":"Validate every field on the server with the same rules the form uses, and treat the browser's checks as a convenience only.","check":"Send each form's request with a tool such as curl, with one invalid field at a time and no browser. Every request gets 422 and names the field.","url":"https://agentwranglers.se/playbook/app-ui/forms/server-side-validation","related":["/playbook/data/schema-design/constraints","/playbook/api-design/errors/validation-errors"],"sources":[{"label":"OWASP Input Validation Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html"},{"label":"JSON Schema specification","url":"https://json-schema.org/specification"}]},{"chapter":"Forms","name":"Put server errors on their fields","level":"Recommended","rule":"When the server rejects a form, show each error next to the field it names. Show errors that name no field in a summary at the top of the form.","check":"A component test answers the submit with a 422 that names one known field and one unknown pointer. The known field shows its message, and the summary shows the other.","url":"https://agentwranglers.se/playbook/app-ui/forms/server-errors-on-fields","related":["/playbook/api-design/errors/validation-errors","/playbook/website/accessibility/form-errors"],"sources":[{"label":"RFC 9457 Problem Details for HTTP APIs","url":"https://www.rfc-editor.org/rfc/rfc9457"},{"label":"RFC 6901 JSON Pointer","url":"https://www.rfc-editor.org/rfc/rfc6901"},{"label":"WCAG 2.2 criterion 3.3.1 Error Identification","url":"https://www.w3.org/TR/WCAG22/#error-identification"}]},{"chapter":"Forms","name":"Never lose what people typed","level":"Required","risk":"data","rule":"Keep every value people entered after a failed submit, a lost connection, an expired session or an accidental reload. Never clear a form unless it was saved.","check":"A browser test fills a form with more than 5 fields, reloads the page and finds the values again. A second test submits with a 500 from the server, and every field still holds its value.","url":"https://agentwranglers.se/playbook/app-ui/forms/keep-user-input","related":["/playbook/website/accessibility/redundant-entry"],"sources":[{"label":"WCAG 2.2 criterion 2.2.5 Re-authenticating","url":"https://www.w3.org/TR/WCAG22/#re-authenticating"}]},{"chapter":"Forms","name":"Save each form only once","level":"Required","risk":"money","rule":"Make a double click, a second Enter or a retry save the record once. Block repeat submits in the form and reject duplicates on the server.","check":"A browser test clicks submit three times within 100 ms, and a second test sends the same request twice with one key. Each test creates exactly one record.","url":"https://agentwranglers.se/playbook/app-ui/forms/single-submit","related":["/playbook/api-design/reliability/idempotency-keys"],"sources":[{"label":"Expired IETF draft on the Idempotency-Key HTTP Header Field","url":"https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/"}]},{"chapter":"Forms","name":"Save drafts on their own","level":"Recommended","rule":"When a screen saves without a button, save within 2 seconds of the last change and when the page is hidden. Show the save state in words, and never overwrite someone else's change.","check":"A browser test types, waits 3 seconds and reloads, and the text is there. A second test changes the record from another session first. The editor shows the conflict, and neither change is lost.","url":"https://agentwranglers.se/playbook/app-ui/forms/autosave","related":["/playbook/api-design/reliability/optimistic-concurrency","/playbook/website/accessibility/status-messages"],"sources":[{"label":"RFC 9110 HTTP Semantics on If-Match","url":"https://www.rfc-editor.org/rfc/rfc9110#field.if-match"},{"label":"Fetch Standard on the keepalive flag","url":"https://fetch.spec.whatwg.org/#request-keepalive-flag"}]},{"chapter":"Forms","name":"Warn before leaving unsaved changes","level":"Recommended","rule":"When a form has unsaved changes, warn before the tab closes or the app moves to another screen. Never warn when nothing changed.","check":"A browser test edits a field and closes the page with unload handlers on. A `beforeunload` dialog appears. Without an edit, no dialog appears. After an edit, clicking an in-app link shows the router's warning and keeps the screen.","url":"https://agentwranglers.se/playbook/app-ui/forms/unsaved-changes-warning","related":["/playbook/website/performance/bfcache"],"sources":[{"label":"HTML Standard on the beforeunload event","url":"https://html.spec.whatwg.org/multipage/indices.html#event-beforeunload"}]},{"chapter":"Forms","name":"Build search-as-you-type fields as a combobox","level":"Recommended","rule":"Give every field that suggests matches while people type the combobox role and keys from the WAI-ARIA Authoring Practices. Announce how many matches came back.","check":"A browser test types 5 letters within 200 ms into each search field, and the server gets 1 request. Down Arrow sets `aria-activedescendant` while focus stays on the field. Escape sets `aria-expanded=\"false\"`, and the status region names the match count.","url":"https://agentwranglers.se/playbook/app-ui/forms/search-combobox","related":["/playbook/app-ui/client-and-server-state/latest-request-wins","/playbook/website/accessibility/status-messages"],"sources":[{"label":"ARIA APG combobox pattern","url":"https://www.w3.org/WAI/ARIA/apg/patterns/combobox/"},{"label":"WCAG 2.2 criterion 4.1.2 Name, Role, Value","url":"https://www.w3.org/TR/WCAG22/#name-role-value"},{"label":"WCAG 2.2 criterion 4.1.3 Status Messages","url":"https://www.w3.org/TR/WCAG22/#status-messages"}]},{"chapter":"Forms","name":"Check files in the browser before they upload","level":"Recommended","rule":"Pick files with a native file input and check each file's type and size before sending it. Show progress and a Cancel button for each file. Keep every check on the server too.","check":"A browser test picks a file 1 byte over the limit and a file of a type missing from `accept`. Neither is sent, and each message names its file. A second test starts a 5 MB upload, sees the progress value rise, presses Cancel, and the request is aborted.","url":"https://agentwranglers.se/playbook/app-ui/forms/file-upload-field","related":["/playbook/website/accessibility/dragging-movements","/playbook/api-design/files-and-uploads/confirm-uploads","/playbook/adversarial-testing/requests-and-files/safe-file-uploads"],"sources":[{"label":"HTML Standard on the file upload input","url":"https://html.spec.whatwg.org/multipage/input.html#file-upload-state-%28type=file%29"},{"label":"XMLHttpRequest Standard on upload progress","url":"https://xhr.spec.whatwg.org/#dom-xmlhttprequest-upload"}]},{"chapter":"Forms","name":"Show where people are in a multi-step form","level":"Recommended","rule":"Put the step number and the total in the title and heading of each step, such as \"Step 2 of 4\". Give each step its own address, and keep every answer when people go back.","check":"A browser test fills steps 1 and 2, presses the browser's Back button, and finds step 1 with its values and its own address. It reloads step 2, and the values are still there. Every step's title starts with \"Step N of M\".","url":"https://agentwranglers.se/playbook/app-ui/forms/multi-step-forms","related":["/playbook/app-ui/client-and-server-state/state-in-url","/playbook/website/accessibility/redundant-entry"],"sources":[{"label":"W3C WAI tutorial on multi-page forms","url":"https://www.w3.org/WAI/tutorials/forms/multi-page/"},{"label":"WCAG 2.2 criterion 3.3.7 Redundant Entry","url":"https://www.w3.org/TR/WCAG22/#redundant-entry"},{"label":"WAI-ARIA 1.2 on aria-current","url":"https://www.w3.org/TR/wai-aria-1.2/#aria-current"}]},{"chapter":"Client and server state","name":"Keep server data in one cache","level":"Recommended","rule":"Keep data that comes from the server in one data-fetching cache, and read it from there on every screen. Never copy it into a global store or component state.","check":"A search of the global store files finds no import of the API client and no call to `fetch`. A test opens the same record on two screens, changes it on one, and both show the new value.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/server-state-cache","related":["/playbook/data/caches-and-copies/source-of-truth"],"sources":[]},{"chapter":"Client and server state","name":"Key cached data by every input","level":"Required","risk":"data","applies":["accounts"],"rule":"Build every cache key from all the inputs that change the answer, including the account, the signed-in user, the filters, the sort and the page.","check":"A test signs in to account A, loads orders, switches to account B and loads orders. No request answer or rendered row from account A appears.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/complete-cache-keys","related":["/playbook/website/performance/vary","/playbook/app-ui/client-and-server-state/latest-request-wins"],"sources":[]},{"chapter":"Client and server state","name":"Show the answer to the latest request only","level":"Recommended","rule":"When a new request replaces an older one, such as a new search term, abort the older request and never render its answer.","check":"A browser test types \"ab\" and then \"abc\", and holds back the answer for \"ab\" until the results for \"abc\" show. The results for \"abc\" still show after the held answer is released.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/latest-request-wins","related":["/playbook/app-ui/forms/search-combobox","/playbook/app-ui/client-and-server-state/complete-cache-keys"],"sources":[{"label":"DOM Standard on AbortController","url":"https://dom.spec.whatwg.org/#interface-abortcontroller"}]},{"chapter":"Client and server state","name":"Refresh every view a change touches","level":"Recommended","rule":"After a successful change, update or refetch every cached query that shows the changed record, including lists, counts and totals.","check":"A test edits an order's status on its detail screen, then opens the list and the dashboard count. Both show the new status without a page reload.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/refresh-after-change","related":["/playbook/data/caches-and-copies/cache-freshness","/playbook/notifications/in-app-notifications/read-state-sync"],"sources":[]},{"chapter":"Client and server state","name":"Undo optimistic updates that fail","level":"Recommended","rule":"When the screen changes before the server answers, keep a copy of the old data. If the request fails, put it back and say so in words. Refetch either way.","check":"A test makes the server answer 500 to a rename. The old name comes back within one second, a message says the change was not saved, and the input keeps focus.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/optimistic-with-rollback","related":["/playbook/app-ui/feedback-and-dialogs/confirm-or-undo"],"sources":[]},{"chapter":"Client and server state","name":"Keep screen state in the address","level":"Recommended","rule":"Put every choice that changes what a screen shows in the address, such as filters, search, sort, page, tab and the open record. Reloading or sharing the link shows the same screen.","check":"A browser test sets a filter, a sort and page 3, copies the address and opens it in a new context. The same rows show. Back returns to the previous page of results.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/state-in-url","related":["/playbook/accounts/organisations-and-teams/organisation-in-url","/playbook/app-ui/forms/multi-step-forms","/playbook/website/privacy/personal-data-out-of-urls","/playbook/app-ui/lists-and-tables/load-more-button"],"sources":[]},{"chapter":"Client and server state","name":"Compute values instead of storing them","level":"Recommended","rule":"Compute totals, counts, filtered lists and labels from the source data when you render. Never keep them in their own state to update by hand.","check":"A search of components and stores for state named `total`, `count` or `filtered`, such as `setTotal(` or `state.itemCount =`, finds no matches.","url":"https://agentwranglers.se/playbook/app-ui/client-and-server-state/derived-values","related":["/playbook/data/schema-design/normalise"],"sources":[]},{"chapter":"Lists and tables","name":"Never render thousands of rows","level":"Required","risk":"outage","rule":"Keep at most 200 rows in the page at once. Page long lists on the server, or render only the visible rows with a virtual list.","check":"Load a list with 10,000 records in a browser test. The page holds no more than 200 row elements at the start and after scrolling to the end.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/bounded-lists","related":["/playbook/api-design/lists/cursor-pagination"],"sources":[]},{"chapter":"Lists and tables","name":"Load more with a button","level":"Recommended","rule":"Load further results with page links or a \"Load more\" button, not endless scrolling. After loading, move focus to the first new item.","check":"A browser test presses \"Load more\" with the keyboard. The new items appear, focus is on the first new one, and the footer can still be reached.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/load-more-button","related":["/playbook/app-ui/client-and-server-state/state-in-url","/playbook/api-design/lists/cursor-pagination"],"sources":[{"label":"WCAG 2.2 criterion 2.4.3 Focus Order","url":"https://www.w3.org/TR/WCAG22/#focus-order"}]},{"chapter":"Lists and tables","name":"Sort and filter on the server","level":"Recommended","rule":"When a list is paged, sort, filter and search on the server across all records. Never sort only the rows already loaded.","check":"Load a list of 500 records with a page size of 50, and sort by amount. The first row is the largest amount among all 500, not among the first 50.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/server-sort-and-filter","related":["/playbook/data/queries-and-indexes/index-the-queries-you-run","/playbook/api-design/lists/sorting","/playbook/api-design/lists/filtering","/playbook/website/languages-and-regions/locale-collation"],"sources":[]},{"chapter":"Lists and tables","name":"Make sortable columns buttons","level":"Recommended","rule":"Put a button inside each sortable column header, and set `aria-sort` on the header of the sorted column only.","check":"A browser test tabs to a header button and presses Enter. That header has `aria-sort`, no other header has it, and the status region names the order.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/sortable-headers","related":["/playbook/website/accessibility/data-tables","/playbook/website/accessibility/status-messages"],"sources":[{"label":"WAI-ARIA 1.2 on aria-sort","url":"https://www.w3.org/TR/wai-aria-1.2/#aria-sort"},{"label":"ARIA APG sortable table example","url":"https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/"}]},{"chapter":"Lists and tables","name":"Use grid keys only for editable grids","level":"Recommended","rule":"Keep read-only tables as plain tables with normal Tab order. Add arrow-key movement, with one Tab stop, only to grids whose cells people edit or act on.","check":"A browser test tabs into the grid once, moves with the arrow keys, and edits a cell with Enter. Exactly one cell has `tabindex=\"0\"` after each move, Right Arrow on the last column stays put, and one more Tab leaves the grid.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/grid-keyboard","related":["/playbook/website/accessibility/keyboard-navigation"],"sources":[{"label":"ARIA APG grid pattern","url":"https://www.w3.org/WAI/ARIA/apg/patterns/grid/"}]},{"chapter":"Lists and tables","name":"Key rows by their ID","level":"Required","risk":"data","rule":"Key every rendered list item by the record's stable ID, never by its position in the list.","check":"A lint rule bans the index as a key, such as `react/no-array-index-key` from eslint-plugin-react. Where no such rule exists, a search for `:key=\"index\"` or `key={i}` finds nothing. A browser test types into the row for one record, removes the row above it, and the typed text stays with its record.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/stable-row-keys","related":["/playbook/data/schema-design/stable-keys"],"sources":[]},{"chapter":"Lists and tables","name":"Say what select all selects","level":"Recommended","rule":"When people select all rows, say whether that means this page or every matching record, and show the count before any bulk action.","check":"A browser test selects all on a filtered list of 120 records with 50 per page, then picks every match. The button says 120, and the server archives exactly those 120.","url":"https://agentwranglers.se/playbook/app-ui/lists-and-tables/bulk-selection","related":["/playbook/app-ui/feedback-and-dialogs/confirm-or-undo"],"sources":[]},{"chapter":"Feedback and dialogs","name":"Use toasts only to confirm finished work","level":"Recommended","rule":"Use a toast only to confirm that something worked, such as \"Invoice sent\". Announce it through a live region, keep it for at least 6 seconds, and pause it while hovered or focused.","check":"A browser test sends an invoice. The status region, present before the click, holds the message. Focus stays on the button, and the toast is still there after 5 seconds. While the pointer rests on it, it is still there after 10 seconds.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/toasts-for-status","related":["/playbook/website/accessibility/status-messages","/playbook/website/accessibility/time-limits"],"sources":[{"label":"WCAG 2.2 criterion 4.1.3 Status Messages","url":"https://www.w3.org/TR/WCAG22/#status-messages"},{"label":"WCAG 2.2 criterion 2.2.1 Timing Adjustable","url":"https://www.w3.org/TR/WCAG22/#timing-adjustable"}]},{"chapter":"Feedback and dialogs","name":"Keep errors that need action on screen","level":"Recommended","rule":"Show an error that people must act on next to its cause, and keep it until it is fixed. Never put it only in a toast that disappears.","check":"A browser test makes a save fail. After 30 seconds the error text is still visible and names the next step.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/errors-stay-on-screen","related":["/playbook/website/accessibility/form-errors"],"sources":[{"label":"WCAG 2.2 criterion 3.3.1 Error Identification","url":"https://www.w3.org/TR/WCAG22/#error-identification"}]},{"chapter":"Feedback and dialogs","name":"Put focus somewhere sensible after every change","level":"Required","risk":"law","rule":"After a client-side page change, move focus to the new page's heading and update the title. When the focused element disappears, move focus to the next item, the previous item or the control that opened it.","check":"A browser test follows an in-app link, then deletes the last row of a list. After each step, `document.activeElement` is not `body`, and after the link it is the new `h1`.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/managed-focus","related":["/playbook/website/accessibility/inert-attribute","/playbook/website/interface/dialog-element"],"sources":[{"label":"WCAG 2.2 criterion 2.4.3 Focus Order","url":"https://www.w3.org/TR/WCAG22/#focus-order"},{"label":"WCAG 2.2 criterion 2.4.2 Page Titled","url":"https://www.w3.org/TR/WCAG22/#page-titled"}]},{"chapter":"Feedback and dialogs","name":"Keep dialogs for short tasks","level":"Recommended","rule":"Use a modal dialog only for a short task that must finish before anything else, such as a confirmation. Give longer tasks their own page with its own address.","check":"A browser test opens each modal and counts open `dialog:modal` elements. There is never more than one, no modal holds more than 5 form fields, and no modal is open right after any page loads.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/dialogs-for-short-tasks","related":["/playbook/website/interface/dialog-element","/playbook/website/interface/popover-api"],"sources":[{"label":"HTML Standard on the dialog element","url":"https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element"},{"label":"ARIA APG modal dialog pattern","url":"https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/"}]},{"chapter":"Feedback and dialogs","name":"Let people undo or confirm destructive actions","level":"Required","risk":"data","rule":"For deletes and other changes that lose data, offer undo when the change can be reversed, and ask for confirmation when it can't. Name the object and the count in both.","check":"A browser test deletes a record and presses Undo, and the record is back with the same ID. A test of each action that can't be reversed finds a confirmation whose button names the action.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/confirm-or-undo","related":["/playbook/website/accessibility/error-prevention","/playbook/ai-features/deciding-to-use-a-model/design-for-wrong-answers","/playbook/app-ui/client-and-server-state/optimistic-with-rollback","/playbook/app-ui/lists-and-tables/bulk-selection"],"sources":[{"label":"WCAG 2.2 criterion 3.3.4 Error Prevention","url":"https://www.w3.org/TR/WCAG22/#error-prevention-legal-financial-data"}]},{"chapter":"Feedback and dialogs","name":"Show progress for long actions","level":"Recommended","rule":"When an action takes longer than 10 seconds, run it in the background, show its progress, and let people leave the screen. Tell them when it is done.","check":"A browser test starts an export of 5,000 rows, leaves for another screen and returns. The progress is still shown, and the finished export is listed with a download link.","url":"https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/long-action-progress","related":["/playbook/ai-features/latency-cost-and-fallbacks/stream-answers","/playbook/api-design/reliability/long-running-jobs"],"sources":[{"label":"HTML Standard on the progress element","url":"https://html.spec.whatwg.org/multipage/form-elements.html#the-progress-element"}]},{"chapter":"Permissions, flags and failures","name":"Enforce permissions on the server","level":"Required","risk":"takeover","applies":["accounts"],"rule":"Treat hidden and disabled buttons as a convenience only. The server checks the user's permission on every request, whatever the screen shows.","check":"For each action the screen hides from a role, a test calls the API directly as that role. Every call gets 403 or 404, and the data is unchanged.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/server-enforces-permissions","related":["/playbook/adversarial-testing/access-and-accounts/function-access","/playbook/adversarial-testing/access-and-accounts/object-access"],"sources":[{"label":"OWASP Authorization Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html"}]},{"chapter":"Permissions, flags and failures","name":"Let the server say what each user may do","level":"Recommended","applies":["accounts"],"rule":"Send the actions the user may take on each record with the record, and show controls from that list. Never rebuild the permission rules in the client.","check":"Search the client for role names, such as `'admin'` or `role ===`. There are no matches outside the sign-in code.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/capabilities-from-server","related":["/playbook/app-ui/data-views/disabled-with-reason","/playbook/accounts/roles-and-permissions/permissions-not-roles"],"sources":[]},{"chapter":"Permissions, flags and failures","name":"Survive an expired session","level":"Required","risk":"data","applies":["accounts"],"rule":"When a session expires, keep the screen and the unsaved work. Let the person sign in again in place, then repeat the request that failed.","check":"A browser test fills a form, deletes the session cookie and submits. A sign-in prompt appears, and after signing in the form is saved with the values typed before.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/session-expiry","related":["/playbook/website/accessibility/time-limits"],"sources":[{"label":"WCAG 2.2 criterion 2.2.1 Timing Adjustable","url":"https://www.w3.org/TR/WCAG22/#timing-adjustable"},{"label":"WCAG 2.2 criterion 2.2.5 Re-authenticating","url":"https://www.w3.org/TR/WCAG22/#re-authenticating"},{"label":"RFC 9457 Problem Details for HTTP APIs","url":"https://www.rfc-editor.org/rfc/rfc9457"}]},{"chapter":"Permissions, flags and failures","name":"Read feature flags before the first paint","level":"Recommended","rule":"Decide feature flags on the server and send them with the first page or data load. A flagged feature then never flashes in or out. The API checks the same flag.","check":"A browser test loads the screen with the flag off and records every frame for one second. The flagged control never appears. A direct API call to the flagged route gets 404.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/feature-flags-in-ui","related":["/playbook/engineering/running-in-production/deploy-is-not-release","/playbook/architecture/dead-code/remove-stale-flags"],"sources":[]},{"chapter":"Permissions, flags and failures","name":"Contain each crash to its panel","level":"Required","risk":"outage","rule":"Wrap each route and each independent panel in an error boundary. One broken part then shows a fallback, and the rest keeps working. Report every caught error.","check":"A test makes one panel throw during render. The other panels and the navigation still render, the fallback shows a retry button, and one error report is sent.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/error-boundaries","related":["/playbook/website/when-things-break/browser-error-reports"],"sources":[]},{"chapter":"Permissions, flags and failures","name":"Give every request a time limit","level":"Required","risk":"outage","rule":"Give every request from the screen a time limit, say when a request is slow, and retry only reads and requests with an idempotency key.","check":"A browser test holds the orders request open and never answers it. \"Still working\" appears after 3 seconds, and the error view with a retry button appears at the time limit. A second test holds a write without an idempotency key open, and it is sent once only.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/request-timeouts","related":["/playbook/api-design/reliability/retry-backoff","/playbook/api-design/reliability/timeouts","/playbook/api-design/reliability/idempotency-keys"],"sources":[{"label":"DOM Standard on AbortSignal.timeout()","url":"https://dom.spec.whatwg.org/#dom-abortsignal-timeout"}]},{"chapter":"Permissions, flags and failures","name":"Say when the network is gone","level":"Recommended","rule":"When the connection drops, say so in a banner and keep everything already loaded on screen. Either queue changes with idempotency keys and send them on reconnect, or block them with a reason.","check":"A browser test goes offline, edits two records and goes back online. The banner shows while offline, and the server has both changes exactly once.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/offline-behaviour","related":["/playbook/website/when-things-break/offline-support"],"sources":[]},{"chapter":"Permissions, flags and failures","name":"Recover when a new release replaces the old files","level":"Recommended","rule":"When a tab still runs the old release and a code chunk fails to load after a deploy, reload once to get the new release. Keep older files online as [keep the previous build's assets](https://agentwranglers.se/playbook/website/site-architecture/keep-the-previous-build-s-assets) sets.","check":"Deploy a release to staging, then click an in-app link in a tab opened before the deploy. The next screen loads, and the tab reloads at most once.","url":"https://agentwranglers.se/playbook/app-ui/permissions-flags-and-failures/new-release-reload","related":["/playbook/website/site-architecture/keep-the-previous-build-s-assets"],"sources":[]},{"chapter":"Tests and budgets","name":"Test every state of every data view","level":"Recommended","rule":"Write a component test for each state of each data view. Fake the network at the request level, and find elements by role and name.","check":"The suite has one passing test per state for every data view. Deleting the empty-state branch of any view makes a test fail.","url":"https://agentwranglers.se/playbook/app-ui/tests-and-budgets/component-state-tests","related":["/playbook/app-ui/data-views/every-data-state","/playbook/testing/end-to-end-and-browser-tests/stable-selectors","/playbook/testing/non-functional-tests/accessibility-in-ci"],"sources":[]},{"chapter":"Tests and budgets","name":"Compare screenshots of every component state","level":"Recommended","rule":"Take a screenshot of every catalogue story at a phone and a desktop width, in light and dark mode. Fail the build when one changes without a new baseline.","check":"Change one token's colour on a branch. The visual tests fail and name every story that changed.","url":"https://agentwranglers.se/playbook/app-ui/tests-and-budgets/visual-regression","related":["/playbook/website/languages-and-regions/pseudo-localisation","/playbook/app-ui/styles-and-components/component-catalogue","/playbook/testing/test-quality/small-snapshots"],"sources":[]},{"chapter":"Tests and budgets","name":"Give each route a size budget","level":"Recommended","rule":"Give the first screen and each route its own budget for compressed JavaScript, and fail the build when a change goes over it.","check":"Import a large library into one route on a branch. The build fails and names that route and its budget.","url":"https://agentwranglers.se/playbook/app-ui/tests-and-budgets/route-bundle-budget","related":["/playbook/website/performance/performance-budget"],"sources":[]},{"chapter":"Tests and budgets","name":"Load heavy parts when they are needed","level":"Recommended","rule":"Load each route, and each widget over 20 kB compressed, such as a chart, an editor or a map, only when someone opens it.","check":"Open the first screen with the network log on. No file from a route or widget that is not on screen is requested.","url":"https://agentwranglers.se/playbook/app-ui/tests-and-budgets/split-heavy-code","related":["/playbook/website/performance/script-loading"],"sources":[]}]}