In short: two ways of using a page differ from a mouse on a big screen. A phone is a small, touch-driven browser on a slow network, which sites adapt to with a viewport tag and CSS media queries; a desktop Chrome can pretend to be one by overriding its screen size, touch support and User-Agent, and seleniumbase-rs does that with its mobile mode. Assistive technology reads a second, simpler view of the page that the browser builds next to the picture, the accessibility tree, where each node has a role (“button”), a name (“Sign in”) and states. Pages written with the right HTML give that tree for free, and tests can use it too: finding an element by role and name is more stable than a CSS class, and it fails when the page is inaccessible.
What is different on a phone#
- A small screen, held upright or sideways, that changes shape when the address bar slides away or the on-screen keyboard opens.
- Touch, not a mouse. There is no pointer to hover with, fingers are imprecise, and gestures (swipe, pinch) exist.
- Weaker hardware and networks. A phone CPU is several times slower than a laptop, and the connection comes and goes.
- Different browsers. Chrome and Samsung Internet on Android use the Blink engine, Firefox on Android uses Gecko, and Safari on iPhone uses WebKit. On an iPhone, other browsers have traditionally been WebKit with a different skin. See browser engines.
The practical result: a page that works perfectly on a desktop Chrome can be unusable on a phone, and a test that passes on a desktop does not show it.
Making a page fit: responsive design#
The viewport meta tag#
Early phones were handed pages made for desktops, so they pretended to have a screen about 980 pixels wide and shrank the result to fit. A modern page tells the browser to stop doing that:
width=device-width means “lay the page out as wide as the real screen”.
Without the tag, a phone draws the desktop layout tiny, and the CSS rules meant
for small screens never apply. Two viewports are involved: the layout
viewport, which CSS sizes things against, and the visual viewport, the part
you currently see, which changes when you pinch-zoom or when the keyboard opens.
CSS pixels and the pixel ratio#
A phone screen that is 1170 physical pixels wide is called 390 wide by the
browser. The unit in CSS (and in window.innerWidth) is the CSS pixel, and
the device pixel ratio (DPR, window.devicePixelRatio) says how many
physical pixels make one CSS pixel; here 3. A sharp image for that screen needs
three times the pixels in each direction, which is what srcset is for (see
compression and caching).
Media queries#
CSS changes itself according to the device:
The ones to know: width (with min- and max-), orientation (portrait or
landscape), resolution, hover, pointer, prefers-color-scheme,
prefers-reduced-motion. Layout tools such as flexbox, grid and container
queries make a component adapt to the space it is given. Sites that serve
different HTML to phones (a separate mobile site, or the same URL with other
content) decide with the User-Agent and client hints; see
detection.
Touch#
- Touch generates
touchstartandtouchend, and, for compatibility, mouse events after them. Modern code uses pointer events (pointerdown,pointerup), which cover mouse, pen and finger. - Hover menus do not work, because there is nothing to hover with. A menu that opens on hover needs a tap alternative.
- Controls must be big enough to hit. WCAG 2.2 asks for at least 24 by 24 CSS pixels as a minimum and recommends 44 by 44 (see accessibility).
- A
clickstill fires on a tap, so most pages work with a click in a test.
Device emulation#
Chrome’s DevTools has a device toolbar that turns a desktop tab into a phone. It is built from a few overrides, each a command of the DevTools Protocol:
| What is changed | Command | Effect on the page |
|---|---|---|
Screen size, pixel ratio and the mobile flag | Emulation.setDeviceMetricsOverride | innerWidth, devicePixelRatio and media queries change; the meta viewport tag is honoured; scrollbars become overlays |
| Touch input | Emulation.setTouchEmulationEnabled | ontouchstart exists, maxTouchPoints is above zero, pointer: coarse matches |
| User-Agent and client hints | Emulation.setUserAgentOverride | The User-Agent header and navigator.userAgent say “Android” and “Mobile”; Sec-CH-UA-Mobile becomes ?1 |
A typical Chrome user agent on an Android phone reads like this. The Mobile
word is the signal most servers look for:
Other settings usually go with it: network and CPU throttling, a geolocation, an orientation.
Emulation or a real device?#
| Emulation in desktop Chrome | A real phone (or a device cloud) | |
|---|---|---|
| Speed and cost | Instant, free, runs in CI | Slow to set up, costs money or hardware |
| Control from a test | Full, through the protocol | Through WebDriver with Appium, or Chrome remote debugging over USB for Android |
| Engine | Always Chromium | The real engine: WebKit on iPhone, Gecko on Firefox |
| Layout and CSS | Good for breakpoints and responsive rules | Exact, including safe areas and notches |
| Keyboard, address bar, gestures | Not reproduced | Real, and a classic source of bugs |
| CPU, memory, GPU | The desktop’s, unless throttled | The real thing |
Use emulation to catch the large share of problems cheaply (the layout breaks at 390 pixels, a hover menu, a missing viewport tag) and a few real devices for what only they can show. Do not assume that a pass in emulated Chrome means the page works in iPhone Safari.
Who the web has to work for#
People use the web with a keyboard only, a screen reader that speaks the page, a magnifier, voice control, a switch, or a high-contrast screen. Some of them always do, some for a while (a broken arm), some in a moment (a phone in bright sun, a noisy train). Accessibility (“a11y”: a, eleven letters, y) is making sure the page works for all of them. In many countries it is also a legal duty for shops, banks and public services.
Assistive technology does not look at pixels. It asks the browser what is on the page, and the browser answers from the accessibility tree.
The accessibility tree#
From markup to a screen reader. The browser does the middle steps.
The accessibility tree is a filtered copy of the DOM:
it leaves out what has no meaning for a person (a purely decorative div, an
element hidden with display: none) and adds what the DOM only implies. Take
this markup:
The browser derives:
| Element | Role | Name | State |
|---|---|---|---|
nav | navigation | ”Main” | |
a | link | ”Shop” | |
main | main | ||
h1 | heading, level 1 | ”Welcome” | |
input | textbox | ”Email” | required |
button | button | ”Sign in” |
You can see the tree in Chrome: DevTools, the Elements panel, the Accessibility tab.
Roles, names and states#
Every node answers three questions.
- Role: what is it? A button, a link, a heading, a checkbox, a dialog, a
landmark such as
navigationormain. HTML elements carry a built-in role (an implicit role). Theroleattribute can set one explicitly. - Name: what is it called? The label a screen reader speaks. The browser
works it out from the best source available: an
aria-labelledbyreference, then anaria-label, then the content or the element’s own text, alabelelement for a form control, thealtof an image, thetitleas the last resort. - State and properties: what condition is it in? Checked, expanded, disabled, selected, required, invalid, busy.
ARIA (Accessible Rich Internet Applications) is a set of attributes
(role, aria-label, aria-expanded, aria-live and many more) for cases
where HTML has no element for the job. Its first rule is to use a native element
when one exists.
| Accessible | Not accessible | |
|---|---|---|
| A button | <button>Save</button>: role, name, keyboard, focus, all built in | <div class="btn" onclick="save()">Save</div>: no role, cannot be focused, Enter does nothing |
| Hiding an icon’s meaning | <button aria-label="Close">x</button> gives the name “Close” | <button>x</button> is announced as “x, button” |
| A toggle | aria-expanded="true" on the trigger, updated by script | A class that only changes colour |
ARIA changes only what is announced, never what the element does. Adding
role="button" to a div does not make it focusable or respond to the keyboard.
Bad ARIA is worse than none, because it promises something the page does not
deliver.
Screen readers and the keyboard#
A screen reader speaks the page (or sends it to a braille display) and lets the user move through it without a mouse: NVDA and JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android, Orca on Linux. People jump from heading to heading, list the links or the form fields, or move between landmarks. That is why headings need to be real headings in a sensible order, and why a link that says only “click here” is useless out of context.
The keyboard is the other half. Everything a mouse can do must work without one:
- Tab moves focus to the next interactive element, Shift+Tab goes back. The order follows the DOM, so reordering with CSS can make focus jump around.
- Enter follows a link or presses a button; Space presses a button or toggles a checkbox; Escape closes a dialog or menu; the arrow keys move inside one widget (tabs, menus, radio groups).
- The focus must be visible, so the user can see where they are. Removing the
outline with
outline: noneand giving nothing back is a common failure. tabindex="0"puts an element in the tab order,tabindex="-1"allows focus from a script only. Avoid positive values.- A modal dialog keeps focus inside while open and returns it to the trigger when closed. A “skip to content” link at the top spares keyboard users from tabbing through the whole menu on every page.
WCAG in one table#
The Web Content Accessibility Guidelines (WCAG) are the standard behind most laws. Versions 2.1 and 2.2 are in use. Each requirement has a level: A (the basics), AA (the usual target and legal requirement), AAA (the highest, not expected everywhere). They fall under four principles, remembered as POUR.
| Principle | Idea | Examples |
|---|---|---|
| Perceivable | Information can be seen or heard by everyone | Text alternatives for images; captions; text contrast of at least 4.5 to 1 (3 to 1 for large text); content readable when zoomed to 200% |
| Operable | Everything can be used with different inputs | All functions by keyboard; no keyboard trap; visible focus; enough time; targets big enough to hit |
| Understandable | The page behaves predictably and helps with mistakes | Page language set; labels on every field; clear error messages |
| Robust | It works with current and future assistive technology | Valid markup; correct name, role and value for custom controls; status messages announced (aria-live) |
Why accessible selectors are stable#
Compare three ways to find the same button:
| Selector | Breaks when |
|---|---|
div.container > div:nth-child(3) > button | A wrapper is added or the order changes |
.btn-primary.css-1x2y3z | A designer renames a class or a build re-hashes it |
| role “button” with the name “Sign in” | The button stops being a button, or its label changes: both are changes a user would notice |
A role-and-name selector asks what a person would ask: “the Sign in button”.
Developers can restyle, wrap and reorder freely without breaking the test.
When such a selector cannot find an element that is obviously there, the
page has a real defect: no label, a clickable div, a missing role. The test
turns into a free accessibility check. Playwright’s getByRole and Testing
Library made this style popular for exactly that reason (see
selectors and the DOM for the other kinds of selector).
Its weak spots: the name is text, so copy changes and translations change it (use an exact match, or test with a fixed language), and two buttons with the same name need a parent to tell them apart.
Automated audits with axe-core#
axe-core is an open-source JavaScript engine from Deque that checks a page against WCAG rules. It runs inside the page and returns a list of violations, each with a rule id, an impact (minor, moderate, serious, critical), an explanation and the nodes at fault. Lighthouse’s accessibility score is built on it (see rendering and performance).
An automated tool finds a real share of problems, such as a missing alt, a form
field without a label or low contrast, but not all: it cannot tell whether an
alt text is meaningful, or whether the focus order makes sense. A real audit
adds a keyboard pass and a screen reader pass.
How seleniumbase-rs uses it#
Mobile mode#
seleniumbase-rs has a mobile mode that follows Python SeleniumBase’s
--mobile option.
BrowserConfig::with_mobile(true)turns Chrome’s mobile emulation on with the default screen,DeviceMetrics::default(): 390 by 715 CSS pixels at a pixel ratio of 3 (Python’sconstants.Mobile), and an Android Chrome user agent (MOBILE_USER_AGENT).BrowserConfig::with_device_metrics(metrics)chooses another screen, and turns mobile on as well. It is the counterpart of Python’s--metrics, also written--device-metrics, and of itsd_width,d_heightandd_p_rsettings. Here setting metrics is enough, while Python applies them only together with--mobile.DeviceMetrics::new(width, height, pixel_ratio)builds the screen, and a text"412,732,3"parses into one (FromStr), in theWIDTH,HEIGHT,RATIOform Python’s option takes. The three parts must be above zero, and the ratio may be fractional (2.625).- On the command line, the
sbrstool has a global--mobileswitch (“Use a mobile device emulation profile”).
The switch is global: put it with any command that starts a browser. See
sbrs --help for the commands.
What happens underneath#
The way the emulation reaches Chrome depends on the session.
- A normal WebDriver session passes Chrome’s
mobileEmulationexperimental option (ingoog:chromeOptions) with the device metrics and, when there is one, the user agent. - In undetected mode that capability would give the session away, so the
session sends the DevTools commands of the table above instead:
Emulation.setDeviceMetricsOverridewithmobile: true,Emulation.setTouchEmulationEnabledwith five touch points, andEmulation.setUserAgentOverridecarrying client hints that say “Android” and “mobile”. See detection. - These overrides belong to a tab. The session sends them again for each
window it opens or switches to, and
apply_mobile_emulationon the browser session repeats them for a window you switched to some other way.
The same three commands work on any DevTools session. In the pure CDP engine,
send them with Page::execute, for example
Emulation.setDeviceMetricsOverride with width, height,
deviceScaleFactor and mobile: true.
Mobile mode changes what the page does. A site may redirect a phone User-Agent to another host, hide its desktop menu, or serve a smaller bundle. Hover-only controls cannot be reached. Write mobile tests against the mobile flow on purpose, and keep desktop tests separate.
Role selectors and audits#
seleniumbase-rs does not bundle axe-core. It has its own pieces, which cover different ground.
Role and label selectors#
SelectorBuf::role(role, name, exact) builds a selector for an ARIA role with an
optional accessible name, the counterpart of Playwright’s getByRole.
SelectorBuf::label(text, exact) finds a form control by its label, as
getByLabel does.
These are approximations, written as XPath, because the engine does not ask
the browser for the real accessibility tree. An element matches a role through
its role attribute or as the native element for it (button, a with href,
h1 to h6, input type="checkbox" and so on). The name matches the
aria-label, text, value, title or alt, or the text of its label. Not
followed: aria-labelledby, roles that depend on context, hidden elements, and
states such as checked. A test can still pass against markup that the real
tree would name differently.
The audit_page tool#
The MCP servers that have the DevTools tools (cdp and sb) offer
audit_page. It takes the page’s HTML as it is now, after its scripts ran, and
runs HtmlInspector over it. It accepts severity (all or error) and
limit (50 by default, 500 at most) and returns the numbers of errors and
warnings, a count per rule, and the issues with the rule, message and a CSS
selector for the element. Its own note says what it cannot judge: colour
contrast, focus order and what a screen reader says. See MCP.
HtmlInspector in your own tests#
seleniumbase_rs::api::html_inspector::HtmlInspector::inspect(html) is a static
checker with stable rule ids, so it can be diffed in CI. It does not run
JavaScript, so give it the DOM as the browser has it now.
| Rule | Severity | Reports |
|---|---|---|
document-lang | error | html has no lang |
image-alt | error | an image has no alt attribute |
interactive-name | error | a link or button has no accessible name |
form-control-label | error | an input, select or textarea has no label |
duplicate-id | error | an id is used more than once |
invalid-aria-reference | error | aria-labelledby or -describedby names a missing id |
heading-order | warning | a heading level is skipped |
main-landmark | warning | there is no main landmark |
The table shows the main rules; the module lists them all (for example
skip-link, page-h1, table-headers, link-text).
Running axe-core yourself#
Load the axe-core file into the page and run it. Both engines can evaluate
JavaScript, and Page::evaluate on the pure CDP engine awaits the promise that
axe.run() returns.
Start with HtmlInspector for the cheap, repeatable checks in every run, add
axe-core for contrast and ARIA rules on key pages, and keep manual keyboard and
screen-reader passes for releases.
Where to go next#
Blink, WebKit and Gecko, and why the engine matters on a phone.
How sites read the User-Agent, client hints and touch support.
Throttling the CPU and network to test a slow device.
The tree the accessibility tree is built from, and the other selector kinds.
Evaluating JavaScript in the page, which is how an axe run happens.
Headings, titles and structure also help search engines.