Meridian handbook
Features

Accessibility

What the engine does for accessibility on public pages and what a template author must still do, the accessibility checks in engine-dmo check and engine-dmo a11y audit, the alt text policy, and the accessibility statement, issues register and conformance report the engine generates.

The engine takes care of the parts of accessibility that are mechanical and easy to get wrong in every project: alt text that cannot be skipped, accessible forms with no CAPTCHA, the language of fallback text, third-party embeds that load nothing until asked, and checks that catch the common template mistakes before they ship. It cannot make a page accessible on its own: the markup, the design and the words are yours. This page lists both halves, then the tools for evidence and the statement.

What the engine does

Images

  • Alt text is required. With media.require_alt_text (the default), an image needs alt text in the source locale at upload and in the page’s locale at publish, unless a person marks it decorative. A missing value is an error, not a warning. See Media library.
  • Decorative is explicit. A decorative image renders alt="", and the library counts decorative images apart so the marking cannot hide a backlog.
  • Bad alt text is refused: over 200 characters, or opening with “image of”, “photo of” and the like.
  • Language of fallback text. When an image’s alt text or caption comes from a fallback locale, img(), picture() and figure() write lang on the element (WCAG 3.1.2), and each site sets per locale whether that is allowed, warned or blocked at publish (accessibility.alt_fallback).
  • Credits. figure() writes the licence credit in the caption; check warns when a template renders an image another way without printing it.

Forms

The form an endpoint draws (render() on a form field, enquiryForm(), subscribeForm()) writes a visible label for every input, its help text, autocomplete tokens you declare, an error summary with role="alert" that focus moves to after a failed post, each error tied to its field with aria-invalid and aria-describedby, and what the visitor typed kept. It works without JavaScript. Spam is stopped with a honeypot, a minimum fill time and a proof of work: never a CAPTCHA. See Forms and submissions.

Embeds and third parties

embed(url, title=) renders a YouTube, Vimeo, Google Maps, OpenStreetMap, Instagram or Spotify embed as a facade: a notice naming the provider, a real <button> that loads it, and a plain link to it. Nothing loads from the third party until the visitor acts. Give title so the button and the frame have a name. Any other address is printed as a plain link.

Analytics sets no cookie and stores no personal data, and a public page sets no cookie for an anonymous visitor, so the site needs no consent banner (Analytics).

Pages the engine renders itself

The newsletter’s confirmation, preference centre and unsubscribe pages, when you do not supply your own templates, are plain pages with no script and no tracking. The transactional email layout sets the language and is a single readable column with a dark-mode palette.

Fragments and live updates

When part of a page is replaced after a form post or a filter, the {% focus %} tag moves focus to a selector and {% announce %} tells screen readers what changed, so an in-place update is not silent.

What a template author must do

The engine cannot check these for you, or can only warn:

  • Set the page language and direction: <html lang="{{ locale.code }}" dir="{{ locale.direction }}">.
  • Give every page a title (head() writes it) and exactly one h1, with headings in order.
  • Provide a skip link to <main>, and use landmarks: header, nav with an aria-label when there is more than one, main, footer.
  • Mark the current page in navigation with aria-current="page" (item.isCurrent).
  • Write link text that makes sense out of context. “Read more” needs the item’s name.
  • Meet colour contrast (4.5:1 for text, 3:1 for large text and controls), show a visible focus indicator, and do not convey meaning by colour alone.
  • Let the page zoom and reflow: never maximum-scale or user-scalable=no in the viewport.
  • Respect prefers-reduced-motion for animation.
  • Use a real <button> for actions and a real <a href> for navigation. Never a positive tabindex.
  • When you write your own form markup instead of render(), keep a visible <label> for each control, an error summary, and aria-invalid and aria-describedby on fields with errors. A placeholder is not a label.
  • Give every embed() a title, every iframe a title, and transcripts or captions for audio and video.
  • Print an image’s credit when you render it with img() or picture().

Checks

In engine-dmo check

engine-dmo check lints every template, reported at the line and column:

CodeSeverityFinds
positive-tabindexerrorA tabindex above 0, which takes the element out of the visual order.
placeholder-labelwarningAn input or textarea named only by its placeholder: no label for its id, no enclosing label, no aria-label or aria-labelledby.
one-h1warningA template that can render more than one h1 on a page. Alternative branches of an if count as one.
image-creditwarningAn image rendered with img() or picture() whose credit is printed nowhere in the file.
img-no-sizeswarningAn <img> with a width-descriptor srcset and no sizes.

--strict makes the warnings errors, for a CI gate.

On rendered pages

engine-dmo a11y audit renders pages through the real pipeline and runs the automated checks on the HTML, exiting 1 on a failure:

engine-dmo a11y audit --site main                    # a sample of 20 pages across the site's routes
engine-dmo a11y audit --site main --all
engine-dmo a11y audit --site main --path / --path /visit
RuleWCAGSeverityChecks
html-lang3.1.1failThe page declares its language.
page-title2.4.2failThe page has a title.
image-alt1.1.1failEvery image has a text alternative, empty for decoration.
control-name4.1.2failEvery link, button and form control has an accessible name.
frame-title4.1.2failEvery frame has a title.
label-in-name2.5.3failA control’s accessible name contains its visible text.
placeholder-name3.3.2failNo control is named only by its placeholder.
missing-reference1.3.1failEvery id a label or ARIA reference names is on the page.
positive-tabindex2.4.3failNo positive tabindex.
viewport-zoom1.4.4failThe viewport does not stop zooming.
duplicate-id4.1.2warnNo id is used twice.
one-h12.4.6warnExactly one h1.
heading-order1.3.1warnHeading levels do not skip.

engine-dmo render <path> --json includes the same findings for one page. Both need the database. These checks are evidence, not a verdict: a page that passes every rule can still fail WCAG, so test with a keyboard and a screen reader as well.

The accessibility statement

Set the statement’s facts per site:

# config/sites/<site>/site.yaml
accessibility:
  statement_path: /accessibility
  conformance: "WCAG 2.2 AA"
  contact_email: access@example.org
  last_reviewed: "2026-10-01"
KeyMeaning
statement_pathWhere the site publishes its statement.
conformanceThe standard you claim against.
contact_emailWhere people report a problem or ask for another format.
last_reviewedWhen the statement was last reviewed.
alt_fallback, alt_fallback_localesThe alt text fallback policy (Media library).

Record the problems you know about in the issues register:

# config/accessibility.yaml
issues:
  - key: map-keyboard
    summary: The venue map cannot be panned with the keyboard
    severity: major
    clause: "2.1.1"
    surface: site
    owner: web team
    target: "2026-12-31"
    found: "2026-10-01"
KeyMeaning
keyA short id.
summaryThe problem in plain words, as the statement will print it.
severityblocker, major, minor or note.
clauseThe WCAG success criterion or EN 301 549 clause, where one applies.
surfacesite, admin, portal or pdf.
ownerWho will fix it.
targetThe date it will be fixed by, or empty with a reason.
reasonWhy it is not fixed yet.
foundWhen it was found.

Then generate the documents:

engine-dmo a11y issues                                  # the register, with owners and target dates
engine-dmo a11y statement --site main --format md --out statement.md
engine-dmo a11y report --site main --format vpat --out acr.html
engine-dmo a11y report --site main --no-audit           # from the register alone, no database
CommandProduces
a11y issuesThe register as a table.
a11y statementA statement scaffold in the structure the UK public sector accessibility regulations and the EU Web Accessibility Directive expect, from site.yaml and the register, with bracketed gaps for what only you can say. --format html (default) or md.
a11y reportAn Accessibility Conformance Report scaffold, EN 301 549 clause by clause, from the automated checks and the register. --format md (default), json or vpat (the VPAT 2.5 table as HTML). --sample and --all choose the pages audited; --no-audit leaves the automated checks out. Every clause is marked as needing a person’s review.

The statement’s enforcement procedure is always the UK one (the Equality and Human Rights Commission, the 2018 public sector bodies regulations and the Equality Advisory and Support Service), whatever the site’s country. A site outside the UK, or one not covered by those regulations, must replace that section with its own body and law before publishing.

a11y issues and a11y statement read configuration only. Publish the statement as a page at statement_path and keep the register current: the statement is regenerated from it, not typed once and left.