Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #11

Accessible Markup Basics and HTML in Frameworks: Native First, the Accessibility Tree, Angular Host Elements and Razor

Mert Özen Nov 14, 2026 17 min 6 views
Accessible Markup Basics and HTML in Frameworks: Native First, the Accessibility Tree, Angular Host Elements and Razor

Accessible markup starts with native elements first, choosing hidden or inert to hide things, avoiding positive tabindex, setting lang and dir correctly, and reading the HTML your framework produces from the browser's output. In this post we measure a focusable button inside aria-hidden, the capital İ that changes with lang="tr", Angular host elements inside ul without list items and Razor Tag Helpers that produce no aria-invalid, and put angular-eslint and the Nu Html Checker into CI.

Version note: This post follows the November 2026 state of the HTML (HyperText Markup Language) Living Standard and Baseline; the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65), axe-core 4.14, Nu Html Checker 26.11.9, Angular 22.2 with angular-eslint 22.6, and .NET 10 (ASP.NET Core Razor Pages).

Guess First

Bookstore's checkout page has three sections that aren't shown to the user right now. Three different developers hid them in three different ways:

{{site/hiding.html}}

Which buttons does a keyboard user reach while tabbing through the page? Which headings and buttons does a screen reader announce? Which section is visible on screen but can't be clicked? If you think all three are "hidden", one of them will surprise you.

In the previous post, "Tables and Tabular Data: caption, thead, th scope, colgroup and Responsive Tables", we saw how one host element in Angular emptied a whole table for a screen reader. In this post we generalise that lesson to the whole page: the basics of accessible markup and checking the HTML frameworks produce. ARIA (Accessible Rich Internet Applications), focus management and screen reader testing are topics for the upcoming Accessibility (a11y) series; the introduction here is deliberately short.

Native First: When to Put ARIA Down

We saw the same thing in almost every post of this series: the right HTML element brings the right role in the accessibility tree, keyboard behaviour and state information for free. In "Text Semantics", button worked with Enter and Space, the clickable div did not. In "Native Interactive Elements 1", dialog managed focus and inert. In the previous post, tr became a row and app-order-row did not.

The first rule in the W3C's (World Wide Web Consortium) guide to using ARIA says the same: if a native HTML element already has the meaning and behaviour you need, use it instead of turning an element into something else with ARIA. ARIA changes how an element appears in the tree, but it adds no behaviour: a div with role="button" is announced as "button" by a screen reader, but it isn't focusable with Tab and doesn't react to Enter; you have to write those yourself. So when you see a role attribute in your own code, the first question should be: can I use a native element instead? The answer is usually yes. The cases where it is no (tabs, combobox, tree, menu) and the right use of ARIA are covered in the a11y series.

hidden, aria-hidden and inert

Back to the guessing question. A script that measures the three sections with four questions:

const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('site/hiding.html', 'utf8'));

  // which headings and buttons are in the accessibility tree?
  const cdp = await page.context().newCDPSession(page);
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const inTree = new Set(nodes.filter(n => !n.ignored && ['heading', 'button'].includes(n.role?.value))
    .map(n => n.name.value));

  // which buttons can be reached in order with Tab?
  const tabStops = [];
  for (let i = 0; i < 3; i++) {
    await page.keyboard.press('Tab');
    tabStops.push(await page.evaluate(() =>
      document.activeElement === document.body ? '(body)' : document.activeElement.textContent));
  }

  for (const id of ['with-hidden', 'with-aria-hidden', 'with-inert']) {
    const section = page.locator(`#${id}`);
    const button = section.locator('button');
    const name = await button.textContent();
    let clicked = 'yes';
    try { await button.click({ timeout: 500 }); } catch { clicked = 'no'; }
    console.log(`${id.padEnd(17)} visible=${String(await section.isVisible()).padEnd(5)} ` +
      `inTree=${String(inTree.has(name)).padEnd(5)} tabbable=${String(tabStops.includes(name)).padEnd(5)} clickable=${clicked}`);
  }
  console.log('tab order:', tabStops.join(' -> '));

  await page.addScriptTag({ path: require.resolve('axe-core') });
  console.log('axe:', await page.evaluate(async () =>
    (await axe.run()).violations.map(v => `${v.id} (${v.impact})`).join(', ') || 'no violations'));
  await browser.close();
})();
with-hidden       visible=false inTree=false tabbable=false clickable=no
with-aria-hidden  visible=true  inTree=false tabbable=true  clickable=yes
with-inert        visible=true  inTree=false tabbable=false clickable=no
tab order: Use this address -> Place order -> (body)
axe: aria-hidden-focus (serious)

hidden is the clearest: the section is not on screen, not in the tree, not reachable with Tab, not clickable. Because the browser's default style gives it display: none, from the user's point of view it's as if it never existed. One caution: if you override display with another value in CSS (Cascading Style Sheets), hidden loses its visual effect.

inert leaves the section on screen but removes it from every kind of interaction: not in the tree, not reachable with Tab, not clickable. That is what the browser applied to the page behind a modal dialog in "Native Interactive Elements 1"; the inert attribute lets you apply it to any section by hand. It is the right tool for a payment section that must not be touched until a step is completed, or for the content behind a drawer menu you wrote yourself while it is open.

aria-hidden="true" is the trap. The section left the tree, and a screen reader doesn't hear the "Saved addresses" heading; but the "Use this address" button is the first stop in the Tab order and is clickable. A screen reader user moving with the keyboard ends up focused on something that doesn't exist and can't hear what it is. axe flagged exactly this as a serious violation, aria-hidden-focus. aria-hidden is right only for content that repeats meaning visually and has no interaction (e.g. an icon next to its text); if there is anything focusable inside, it is wrong.

tabindex: 0, -1 and the Cost of a Positive Value

tabindex is used in three ways. 0 adds an element that normally can't be focused to the natural Tab order, like the scrollable table wrapper in the previous post. -1 makes an element focusable from JavaScript without putting it in the Tab order; for an error summary, or a heading you move focus to after a route change. Positive values set the order by hand and are almost always wrong:

{{site/tabindex.html}}
const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('site/tabindex.html', 'utf8'));
  const stops = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press('Tab');
    stops.push(await page.evaluate(() => {
      const e = document.activeElement;
      return e.labels?.[0]?.textContent ?? e.textContent;
    }));
  }
  console.log('tab order:', stops.join(' -> '));
  await page.addScriptTag({ path: require.resolve('axe-core') });
  console.log('axe:', await page.evaluate(async () =>
    (await axe.run()).violations.map(v => `${v.id} (${v.impact})`).join(', ') || 'no violations'));
  await browser.close();
})();
tab order: Search books -> Bookstore -> Catalog -> Cart -> Add to cart
axe: tabindex (serious)

The search box was given tabindex="1" so that "the user reaches it first". The result: the first Tab goes to the search box at the end of the header, then focus jumps back to the start of the page, to the logo, and continues in the normal order from there. The visual order and the focus order now differ; elements with a positive tabindex always come before all others, and once a second positive value is added to the page the order becomes unpredictable. axe found this serious too. If you want the search box to come first, write it first in the DOM (Document Object Model).

lang and dir

We have written the lang attribute on html since the first post of the series. A screen reader uses it to decide which language's pronunciation to use; a Turkish book title on an English page is read with English rules unless it gets lang="tr". But lang affects not only the screen reader but also the browser's text processing rules:

{{site/lang.html}}
const pw = require('playwright');
const fs = require('fs');

(async () => {
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    const page = await browser.newPage();
    await page.setContent(fs.readFileSync('site/lang.html', 'utf8'));
    console.log(`--- ${name} ${browser.version()}`);
    // text-transform uses the language's rules: which capital letter does "i" become?
    for (const id of ['badge-en', 'badge-tr']) {
      console.log(`${id}: ${await page.locator(`#${id}`).innerText()}`);
    }
    // dir="auto": the direction comes from the first strong character of the text
    for (const id of ['review-1', 'review-2']) {
      console.log(`${id}: direction=${await page.locator(`#${id}`).evaluate(e => getComputedStyle(e).direction)}`);
    }
    await browser.close();
  }
})();
--- chromium 158.0.8127.18
badge-en: SHIPS FROM ISTANBUL
badge-tr: İSTANBUL DEPOSUNDAN
review-1: direction=ltr
review-2: direction=rtl
--- webkit 27.2
badge-en: SHIPS FROM ISTANBUL
badge-tr: İSTANBUL DEPOSUNDAN
review-1: direction=ltr
review-2: direction=rtl

The same CSS rule (text-transform: uppercase) turned the same word "istanbul" into "ISTANBUL" in the English paragraph and "İSTANBUL" in the paragraph with lang="tr". Both engines gave the same result. A layout left with lang="en" on a Turkish site mixes up "I" and "İ" in every uppercase button and badge. Hyphenation (hyphens: auto) and quotation marks change with the language too.

dir is the writing direction. Bookstore's reader reviews can come in any language, and you can't know a review's direction in advance. dir="auto" tells the browser to take the direction from the first strong character of the text: the English review became ltr, the Arabic review rtl. For any text the user writes and whose language you don't know (reviews, user names, search queries), dir="auto" is the right default.

Reading the Accessibility Tree in DevTools

I made most of the measurements in this series by reading Chromium's accessibility tree with a script. In day-to-day work you can do the same in Chrome DevTools. When you select an element in the Elements panel and open the Accessibility tab in the side pane, you see that element's role, its computed name and where the name came from (label, alt, aria-labelledby, content). With the "full-page accessibility tree" option in the same tab turned on, you can switch the Elements panel between the DOM view and the accessibility tree view. The Elements panel itself is covered in the "Getting Started with Chrome DevTools and the Elements Panel" post of the Browser DevTools series.

The best way to use this view is to ask a question: "What does a screen reader hear when it enters this page?" Are the landmarks named, are the heading levels in order, does every button and link have a name, is the list really a list, is the table really a table? The generic cells from the previous post and the empty list below stand out at first glance in this view.

Angular: Host Elements, Attribute Selectors and Host Bindings

The more common form of the table problem from the previous post is lists. Bookstore's "Recommended for you" section, with three versions of the same book card:

import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-card',
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookCard {
  readonly book = input.required<Book>();
}
import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-card-role',
  host: { role: 'listitem' },
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookCardHostRole {
  readonly book = input.required<Book>();
}
import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'li[appBookItem]',
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookItem {
  readonly book = input.required<Book>({ alias: 'appBookItem' });
}
  <h2>Recommended for you</h2>
  <ul id="books-element">
    @for (book of books; track book.id) {
      <app-book-card [book]="book" />
    }
  </ul>
  <ul id="books-host-role">
    @for (book of books; track book.id) {
      <app-book-card-role [book]="book" />
    }
  </ul>
  <ul id="books-attribute">
    @for (book of books; track book.id) {
      <li [appBookItem]="book"></li>
    }
  </ul>

I ran the previous post's angular-dom.js with the lists' ids this time, then gave the lists to the checker:

node angular-dom.js books-element books-host-role books-attribute
books-element     list   
books-host-role   list   listitem=2
books-attribute   list   listitem=2
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const path = require('path');

const dist = path.resolve('bookstore-web/dist/bookstore-web/browser');
const server = http.createServer((req, res) => {
  const file = path.join(dist, req.url === '/' ? 'index.html' : req.url);
  res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8');
  res.end(fs.readFileSync(file));
}).listen(18895);

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:18895/');
  await page.waitForSelector('#books-attribute li');
  // the broken table from the previous post stops the checker with a fatal error; remove it to see the lists
  await page.evaluate(() => document.getElementById('orders-element').remove());
  fs.mkdirSync('rendered-lists', { recursive: true });
  fs.writeFileSync('rendered-lists/account.html', await page.content());
  await browser.close();
  server.close();
})();
"file:/site/account.html":9.455-9.469: error: Element “app-book-card” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.537-9.551: error: Element “app-book-card” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.662-9.697: error: Element “app-book-card-role” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.770-9.805: error: Element “app-book-card-role” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)

The first list, with an element selector, has no listitem at all: the direct children of the ul are app-book-cards. A screen reader announces a list but finds no items in it; the "list, 2 items" information is lost. The checker gives an error for every card as well.

The second list tries to fix it with a host binding: the component's host metadata adds role="listitem" to the app-book-card-role element Angular creates. From the accessibility tree's point of view this worked: there are two listitems. But from HTML's point of view there is still a non-li element inside the ul, and the checker still gives errors. A host binding is an acceptable patch where you can't change the host element (e.g. when wrapping a third-party component); it is also a good place to add a class or aria-* to the host.

The third list is the same fix as the table row in the previous post: selector: 'li[appBookItem]'. Angular attaches the component to the li in the template itself; both the accessibility tree and the checker are happy. That is the rule for every component that goes inside an element with a strict content model, such as a table, list, select or dl: make the host the element that should be there.

Razor: Checking Tag Helper Output

In Razor the situation is reversed: Tag Helpers produce HTML for you, and that HTML usually never gets looked at. The email field in Bookstore's checkout, with ASP.NET Core's standard Tag Helpers:

{{razor/Pages/Checkout.cshtml}}
{{razor/Pages/Checkout.cshtml.cs}}

A script that reads the produced HTML from the browser in two states, when the page first opens and after the empty form is submitted:

const { chromium } = require('playwright');

const url = `http://localhost:5292${process.argv[2] ?? '/checkout'}`;

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto(url);
  const field = () => page.evaluate(() =>
    [...document.querySelectorAll('form > :not(button):not([type=hidden])')].map(e => e.outerHTML).join('\n'));
  console.log('--- GET\n' + await field());

  // submit the empty form; form.submit() skips native validation, so we see the server's response
  await Promise.all([page.waitForNavigation(), page.evaluate(() => document.querySelector('form').submit())]);
  console.log('--- POST (empty)\n' + await field());
  const ax = await page.getByLabel('Email address').evaluate(i =>
    `invalid=${i.getAttribute('aria-invalid')} describedby=${i.getAttribute('aria-describedby')}`);
  console.log(ax);
  await browser.close();
})();
--- GET
<label for="Input_Email">Email address</label>
<input autocomplete="email" type="email" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span class="field-validation-valid" data-valmsg-for="Input.Email" data-valmsg-replace="true"></span>
--- POST (empty)
<label for="Input_Email">Email address</label>
<input autocomplete="email" type="email" class="input-validation-error" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span class="field-validation-error" data-valmsg-for="Input.Email" data-valmsg-replace="true">The Email address field is required.</span>
invalid=null describedby=null

The good parts: the label's for and the input's id match (Input_Email), the label text comes from [Display], and [EmailAddress] produced type="email". There are three missing parts. [Required] produces no required attribute; the rule lives only in data-val-* attributes for jQuery Unobtrusive Validation, so the native validation from "HTML Forms 2" is not in play. When the server returns the error, only a CSS class (input-validation-error) is added to the input; there is no aria-invalid, so a screen reader doesn't know the field is invalid. And the error message span is not connected to the input (no aria-describedby): a screen reader doesn't read the error message when the field gets focus. The fixed version:

{{razor/Pages/CheckoutFixed.cshtml}}
node taghelper.js /checkout-fixed
--- GET
<label for="Input_Email">Email address</label>
<input autocomplete="email" required="" aria-describedby="email-error" type="email" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span id="email-error" class="field-validation-valid" data-valmsg-for="Input.Email" data-valmsg-replace="true"></span>
--- POST (empty)
<label for="Input_Email">Email address</label>
<input autocomplete="email" required="" aria-describedby="email-error" aria-invalid="true" type="email" class="input-validation-error" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span id="email-error" class="field-validation-error" data-valmsg-for="Input.Email" data-valmsg-replace="true">The Email address field is required.</span>
invalid=true describedby=email-error

Now required is native, the error message has an id and the input is connected to it with aria-describedby; when the server returns an error, aria-invalid="true" is printed. Because Razor doesn't write an attribute whose value is null, there is no aria-invalid on first load. Collecting this in a custom Tag Helper or a partial view instead of repeating it for every field is the natural next step; writing Tag Helpers and the validation details are in the ASP.NET Core MVC (Model-View-Controller) and Razor Pages series. The lesson here is more general: read the HTML your framework produces from the output that goes to the browser, not from the source code.

In Production: Putting Validation into CI

Every problem in this post can be caught with a tool, as long as the tool runs on every pull request. I suggest two layers. The first is linting at the template level: angular-eslint's templateAccessibility config is already enabled in the eslint.config.js that comes with ng add angular-eslint. A faulty component and the relevant part of the config:

import { Component, input, signal } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-preview',
  template: `
    <img [src]="'/covers/' + book().id + '.webp'" width="200" height="300">
    <div class="preview-toggle" (click)="open.set(!open())">Preview</div>
    <input type="search" tabindex="2">
  `,
})
export class BookPreview {
  readonly book = input.required<Book>();
  protected readonly open = signal(false);
}
      '@angular-eslint/component-selector': [
        'error',
        [
          { type: 'element', prefix: 'app', style: 'kebab-case' },
          { type: 'attribute', prefix: 'app', style: 'camelCase' },
        ],
      ],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/no-positive-tabindex': 'error',
      '@angular-eslint/template/button-has-type': 'error',
    },
  },
]);
npx ng lint

Linting "bookstore-web"...

src/app/books/book-preview.ts
  7:5   error  <img/> element must have a text alternative                                             @angular-eslint/template/alt-text
  8:5   error  click must be accompanied by either keyup, keydown or keypress event for accessibility  @angular-eslint/template/click-events-have-key-events
  8:5   error  Elements with interaction handlers must be focusable                                    @angular-eslint/template/interactive-supports-focus
  9:36  error  The `tabindex` attribute should not be positive                                         @angular-eslint/template/no-positive-tabindex

✖ 4 problems (4 errors, 0 warnings)

Lint errors found in the listed files.

Three errors came from templateAccessibility: an img without alt, a clickable div without a keyboard event, and an interactive element that can't be focused. A positive tabindex is not in this config; I added no-positive-tabindex (and button-has-type) to rules myself, otherwise it wasn't caught. Two more notes: the default component-selector rule allowed only element selectors and gave an error for li[appBookItem] in this post and tr[appOrderRow] in the previous one; I changed the config to accept element and attribute selectors separately. And the no-autofocus rule is on: for a deliberate autofocus inside a dialog, as in "Native Interactive Elements 1", you need to turn the rule off for that line with a comment. The rule set in detail is in the upcoming Static Analysis series.

The second layer is the Nu Html Checker on rendered HTML. In a client-side rendered Angular application, dist/index.html is an almost empty shell; give it to the checker and it finds nothing. A small script that opens the page in a browser and saves the final DOM, and a GitHub Actions job:

import { chromium } from 'playwright';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const dist = 'dist/bookstore-web/browser';
const routes = ['/'];
const outDir = process.argv[2] ?? 'rendered';

// SPA: if the file doesn't exist, return index.html
const server = createServer((req, res) => {
  const file = join(dist, req.url);
  let body;
  try { body = readFileSync(file); } catch { body = readFileSync(join(dist, 'index.html')); }
  res.setHeader('Content-Type', req.url.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8');
  res.end(body);
}).listen(4300);

const browser = await chromium.launch();
const page = await browser.newPage();
mkdirSync(outDir, { recursive: true });
for (const route of routes) {
  await page.goto(`http://localhost:4300${route}`, { waitUntil: 'networkidle' });
  const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-');
  writeFileSync(join(outDir, `${name}.html`), await page.content());
  console.log(`rendered ${route} -> ${outDir}/${name}.html`);
}
await browser.close();
server.close();
name: html-quality

on:
  pull_request:

jobs:
  html:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      # template accessibility rules (angular-eslint)
      - run: npx ng lint
      - run: npx ng build
      # in a CSR app dist/index.html is an empty shell; give the checker the DOM from the browser
      - run: npx playwright install --with-deps chromium
      - run: node scripts/render-pages.mjs rendered
      - name: Nu Html Checker
        run: docker run --rm -v "$PWD/rendered:/site" ghcr.io/validator/validator:latest vnu --errors-only /site

I checked the workflow with actionlint and ran its steps locally one by one: lint gave the errors above, render-pages.mjs saved the page, and the checker stopped at the app-order-row error from the previous post. I did not run it on GitHub. If you use SSR (Server-Side Rendering) or prerendering you don't need this step; give the checker the HTML the server produces directly. The same goes for Razor applications: start the application in CI (Continuous Integration), download the critical pages into a folder with curl, and run the checker there.

Common Pitfalls

First, giving aria-hidden="true" to a section with focusable elements inside. Use hidden to hide, inert to remove from interaction.

Second, positive tabindex. Set the order with the order in the DOM.

Third, leaving lang wrong, or not giving lang to text in another language on the page; the "i"/"İ" bug on a Turkish site comes from here.

Fourth, putting an element-selector component inside a ul, ol, tbody, tr or select in Angular. Use an attribute selector that makes the host the element that should be there.

Fifth, never reading the HTML a Tag Helper produces. Even if the label connection is right, the error state and the message connection may be missing.

Sixth, thinking a role brings behaviour too. role="button" adds no keyboard support; button does.

A Small Experiment

★☆☆ Replace aria-hidden="true" in hiding.html with inert and run hiding.js again. Did the axe violation disappear, and how did the Tab order change?

★★☆ Turn on Chrome DevTools' full-page accessibility tree view in your own project and try to read a page only from that view. Did you find a button without a name, a list without list items, or a table turned into generic?

★★★ Add angular-eslint's templateAccessibility config to your Angular project together with no-positive-tabindex and button-has-type, and count the errors. Then render your three most important pages with a script like render-pages.mjs and run them through the Nu Html Checker. Compare what the two found: which errors did only one of them catch?

Throughout the series we took elements one by one and measured them. In the last post we step back and look at the whole picture: what arrived in HTML from 2023 to 2026, what Baseline's newly and widely available distinction means for using them today, and which feature we use in Bookstore today and which with a fallback.