Skip to content

Accessibility

HiLMS is a learning platform, and a lesson nobody can reach is not a lesson. This chapter is what the system does about that, where each piece lives, and what it does not claim.

WCAG 2.2 level AA across the student front-end, plus the AAA wins that cost nothing: 7:1 for body text on the two page surfaces, and 44 px hit areas on every main control. The admin panel is held to the same rules wherever Filament lets us reach — which is most of what we put into it, and none of what Filament draws itself.

No conformance claim is published. What is tested is what is written down here; the rest is work in progress like everything else.

Four groups of people decided the shape of it:

  • deaf or hard of hearing — a video carries WebVTT caption tracks, one per language; video, audio and embedded players all offer a transcript;
  • blind, or using a screen reader — landmarks, headings, names on everything operable, live regions for anything that changes without a page load;
  • motor impairments — everything reachable and operable from a keyboard, in a sensible order, with a focus ring that is always visible and targets big enough to hit;
  • colour-blind — no colour ever carries meaning alone: a status has an icon and a word, a link is underlined at rest, a selected chip says aria-pressed.

Hilms\Theme\Contract\Accessibility is the third contract a theme answers for, beside the tokens and the views. Eighteen rules, each with the WCAG success criterion it comes from, a title and what it means. Eight carry Severity::Error: a static check can settle them, and a theme fails on them. Ten carry Severity::Advice: nothing static can settle them — the axe scan, the suite’s structural tests, or a person at a keyboard does — and they are written down so that whoever builds a theme knows what is expected.

Id Criterion What it asks
tokens.contrast 1.4.3, 1.4.6, 1.4.11 Every pair of colours carries its contrast
markup.link-name 2.4.4 Every link has a name
markup.button-name 4.1.2 Every button has a name
markup.img-alt 1.1.1 Every image answers for its alternative text
markup.iframe-title 4.1.2 Every frame has a title
markup.outline 2.4.7, 2.4.11 Nothing removes the focus outline
markup.disclosure 4.1.2 A control that opens something says whether it is open
markup.tabindex 2.4.3 No positive tabindex
skeleton.skip-link 2.4.1 Every page opens with a skip link
skeleton.landmarks 1.3.1, 2.4.1 Landmarks are named and nested right
skeleton.lang 3.1.1, 3.1.2 The language is declared
focus.visible 2.4.7, 2.4.11 Focus is visible on everything that takes it
motion.reduced 2.3.3 Motion answers prefers-reduced-motion
colors.forced 1.4.1, 1.4.12 The theme survives forced colours and more contrast
live.regions 4.1.3 Every change announces itself
forms.errors 3.3.1, 3.3.2, 3.3.3 A form says what went wrong and where
media.captions 1.2.2, 1.2.3 Media is offered another way
targets.size 2.5.8 Main controls are big enough to hit

A test compares this table with Accessibility::rules(), so a rule that is added and not documented fails the suite.

Every page is the same shape, and three layouts draw it:

  • components/layouts/base.blade.php — the document. The first thing in the body is <a href="#main" class="skip-link">, because it is the first thing a keyboard reaches.

  • components/layouts/app.blade.php — a named <nav> for the main menu and a second for the account cluster; one <main id="main" tabindex="-1"> holding the page; exactly one page footer, the shell’s last row and never inside <main> or another sectioning element, where a <footer> is that section’s foot and no contentinfo landmark at all; and, when the page fills the rail slot, a labelled <aside> beside main rather than inside it, because a side column is not the page’s main content. The slot names itself through a label attribute (“About this page” by default, “Course navigation” in the player).

    Three things about that column are accessibility decisions rather than layout ones, and Theming has the mechanics. It comes after <main> in the document and first on screen, so the skip link stays honest, the page’s h1 comes before the column’s headings and nobody’s tab order changes. A column collapsed on a wide screen is visibility: hidden and not merely translated away, so it leaves the tab order and the accessibility tree rather than lurking in both. And below the width where two columns stop fitting it is a drawer with everything else inert — the modern answer, which is why no focus trap is hand-rolled — carrying a close button of its own, because the toggle that opened it is inert behind the panel, and closing it gives focus back to whatever opened it.

  • components/layouts/guest.blade.php and errors/layout.blade.php — the same skeleton in miniature.

Hilms\Theme\Contract\Shell reads that skeleton off the rendered page — one main#main that takes focus, a skip link reaching it, one page footer inside the shell, and on a page with a rail one named panel with a toggle naming it — and hilms:theme:check, the ops theme health check and ShellContractTest all read its verdict (Theming).

A page has exactly one <h1>. A lesson and a course draw their title; a page draws its own title unless a block does it instead — the page header and the hero both do, wherever in the tree they sit, so one inside a grid cell counts exactly as much as one at the top. Below that, Hilms\Blocks\Rules\HeadingHierarchy validates the block editor: a heading goes down one level at a time, over the whole tree. A screen reader’s list of headings is how most of its users find their way around a long page, and a jump from h2 to h4 tells them a level is missing without saying what. SinglePageHeading is the other half: two blocks drawing the page heading would leave a reader with two answers to “what is this page”. The editor’s outline marks both with the very sentence the rule fails with (The block editor).

The palette is warm stone neutrals with a deep ocean blue accent and colour-blind-safe statuses. It is not a matter of taste, and it is not checked by eye:

  • Hilms\Theme\Contract\ContrastPairs lists every pair of tokens that ends up on top of the other, with the ratio WCAG asks of it — 7:1 for body text on the page surfaces, 4.5 for every other text pair, 3 for the focus ring and the strong border. --color-border is deliberately unpaired: an ordinary divider carries no information.
  • TokenContrastTest computes all of them in the light scheme and the dark one — the system’s dark is the dark set written again, so it owes nothing of its own — and lists every failure at once rather than stopping at the first.
  • The ratios come from the token model (Design tokens). TokenSet::literal() follows aliases to the colour a scheme ends up with, Oklch converts OKLCH — and #hex and rgb() — through OKLab to gamut-clamped linear sRGB, and Contrast turns two luminances into a ratio. Nothing on Packagist reads OKLCH; spatie/color has Contrast::ratio() but no OKLCH, which is why those two are ours.
  • The four accessibility tokens carry floors in the token file ($extensions.hilms.min): a focus ring of at least 2 px, an offset of 1 px, a target of 1.5 rem — WCAG 2.5.8’s 24 px — and an underline 0.1 em below its text. A theme’s default that falls below its floor is refused when the file is read.
  • Component colours are paired too, because a style can now change a button’s background without touching the accent (chapter 26): a button’s label on its background and under the pointer, the secondary kind’s, links on the three surfaces and on the accent tint, a field’s typed text on its background and its outline on its background and on the page (3:1), the site name on the header, an icon button’s icon on the header (3:1), a pressed chip’s label, headings on every surface a block’s look may take, the footer’s text, the rail’s group headings, and the progress bar’s fill on its track (3:1). The closest of all the default theme’s pairs is the strong border on the sunken surface in light, 3.25 of 3.
  • A surface is offered to a block’s look only where its inks are paired. Every surface a look offers is held to the text, secondary text, headings and links drawn on it (BlockLookTest), and each box-like block’s own surface to exactly the inks that block draws on it — the hero’s heading and lead, the listing’s code, the question’s prompt, explanation, retry link and verdicts, the accordion’s reading text — because a pair nothing draws is a false warning in the editor. The bold callout draws every ink in --color-surface on the kind’s colour, and those four pairs are in the contract too.
  • Every value of an option and a variant is checked by axe. The split export draws each as a document of its own, so npm run a11y reads the centred header, the bold callout and the rest, not only the defaults.
  • A generated palette meets every pair by construction (chapter 26): ContrastSolver moves only lightness until every pair a colour is drawn in holds its ratio plus a margin, and the suite sweeps 216 seeds to hold the generator to that.
  • Mail is drawn from the same tokens. MailPalette takes each colour of the mail theme from the token the site draws the same thing with (Styles), so a style that keeps the site’s pairs keeps its mail readable. A card given a surface of its own is the one gap: no pair judges --card-surface itself, on the site or in mail.

A style an administrator lays over the theme (Styles) is judged by the same contract, and on purpose it is advised, not policed: the Styles editor lists every pair that falls short and every floor that is broken as the form is being written, and never refuses to save or activate over it. An operator who must promise a client WCAG sets HILMS_STYLES_ENFORCE_ACCESSIBILITY=true; then a style that falls short cannot be activated, and the active style cannot be saved into falling short. The shipped theme is held to the contract by the suite either way.

Colour never carries meaning alone. A callout draws an icon and the name of its kind, a quiz result draws a tick or a cross beside the word, a link is underlined at rest, and a selected chip says aria-pressed. A lesson nobody may open yet is the same idea in a curriculum: faint ink and a padlock, plus the word “Locked” for a screen reader, because the absence of a link is not a signal and an icon is not a name.

themes/hilms/css/a11y.css is the baseline, imported by app.css and never by preview.css, which loads into the admin panel where Filament has a focus style of its own. Everything in it is built from tokens:

  • :focus-visible draws --focus-ring-width of --color-focus at --focus-ring-offset. The offset is what makes the ring two-coloured: the gap shows whatever is behind the control, so the ring reads on a page, on a card and on the accent alike.
  • .skip-link sits out of the way until it is focused.
  • prefers-contrast: more promotes --color-border and --color-ink-faint to their stronger neighbours by naming those tokens, so each colour scheme answers with its own values. The two declarations are !important: a style writes its tokens at a higher specificity than :root, and a person’s preference must still win.
  • forced-colors: active gets back what the system palette flattens: the progress fill, a pressed chip, the skip link, an image inside a figure.
  • prefers-reduced-motion zeroes the two motion tokens — !important for the same reason — and stops the animations and the smooth scrolling that do not read them.

--target-min is 2.75rem. Every button, chip and toggle is at least that across through min-h/min-w, while its type and padding stay as they were — the header keeps its rhythm and its hit areas are still 44 px. A menu entry is built from the same token, so a menu an editor filled with short words is still a row of targets and not a row of words.

Every field joins its hint and its error through aria-describedby, naming only ids that are really on the page: a describedby pointing at nothing is read as nothing. A field that failed carries aria-invalid; a required one gets a * on its label.

Every form that validates opens with <x-ui.error-summary>: a role="alert" box with tabindex="-1" and data-focus-on-load, which resources/js/a11y.js focuses when the page comes back, listing each message as a link to its field. The link goes to the element id, not the field name — three forms on /account all call their password field “password” — so the summary takes an ids map where the two differ.

A control that repeats down a list carries an aria-label naming the item: “Remove” five times over says nothing. A control that is working goes aria-disabled and aria-busy rather than disabled, which takes focus off the button the person just pressed.

<x-ui.live> is a role="status" aria-live="polite" aria-atomic="true" region, sr-only unless visible, and in the page from the first paint: a live region added to the document at the moment it fills is announced by nothing.

Focus is moved by event, never by an inline handler — the front-end policy allows nothing inline:

$this->dispatch('hilms:focus', selector: '#'.$this->resultId());
document.addEventListener('livewire:init', () => {
Livewire.on('hilms:focus', ({ selector }) => requestAnimationFrame(() => focus(selector)));
});

The quiz dispatches it to the result box after grading and to the first question on a retry. The catalogue says how many courses a filter left in a live region, the single question announces its feedback, and the curriculum marks the lesson being read with aria-current="page".

A Livewire view’s element ids come from render(), not from $this in the template: the design catalogue renders those views with sample data and no component behind them.

  • An uploaded video renders <source> plus one <track kind="captions"> per caption track of its library file, the first one default. The tracks belong to the recording, one WebVTT file per language in the asset’s captions collection, edited on the file in the media library, so every lesson that shows the recording offers them and the video block’s settings say so and link there. A track may be in any language there is, not only the ones the installation speaks: a recording may be subtitled in a language nobody here teaches in.
  • A .vtt file sniffs as text/plain, so the library’s caption upload accepts text/plain too and App\Rules\WebVtt tells a caption file from a stray note: the word WEBVTT alone on the first line, behind an optional byte-order mark.
  • noplaybackrate is gone: slowing a recording down is how some people follow it.
  • A YouTube embed carries cc_load_policy=1&cc_lang_pref=<locale>, which is as far as a hosted player lets us go. Every frame has a title; the embed block’s is required.
  • Video, audio and embed take a transcript, rendered into a <details> by <x-ui.transcript> through RichText::html().
  • An image says what it shows or says it shows nothing. What a picture shows is written once, on its library file, per installation language; the image, gallery (per item) and hero blocks carry a live decorative toggle and an optional “Alternative text for this place only” (Hilms\Blocks\Filament\AltText). While the toggle is off, Save requires the block’s own words only when the file says nothing in the language of the record showing it nor in the default language, and says so pointing at the library; while it is on, the field is hidden and the view writes alt="". BlockAssets::alt() reads the block’s words, then the file’s in the record’s language, then the default language’s.
  • A gallery is a list of items, each with its own picture, words and caption — the only shape in which a picture can say what it shows. An item’s link is named after its caption or its words, and “Open the image in a new tab” when the picture is decorative.
  • The uploaded player’s watermark is aria-hidden and takes no pointer, so it is never read and never in the way; its drift stops under prefers-reduced-motion. The full-screen button that replaces the browser’s own on a marked player keeps one name and says its state through aria-pressed, as a toggle button must, is --target-min large, and is hidden where full screen does not exist rather than left doing nothing.
  • Hilms\Blocks\Filament\RichEditorField is the one rich editor: structure only. No text colour (a colour no token defines is a colour no contrast test can see), no alignment, no file attachments. A table has no caption or header scope, because the editor cannot express either; that is a known limit, not an oversight.

A course declares the language its lessons are written in — courses.language, a BCP-47 tag the editor picks from Hilms\Languages\Support\Languages. The theme puts it as lang on what the course itself wrote (its title, summary, description, curriculum, card and the player’s article, from which every lesson inherits it) and nowhere else: the interface around it stays in the visitor’s language, and <html lang> is the UI locale. A course card also says in words, after the lesson count, when a course is not in the language being read, because a flag is not a language and a colour says nothing. The rest of it — the list an installation speaks, prefixed addresses, the switch and the hreflang links — is chapter 18.

Filament draws most of it, but everything we put in answers for itself: every block builder is reorderable with buttons as well as by dragging; a block preview is inert, because a preview is a picture of a block and its links and fields would otherwise sit in the editor’s tab order; the sidebar’s action blocks carry visible headings; the two “Copy” buttons on a client’s credentials name what each copies and say out loud that it was copied; the activity diff is a table with a caption, column scopes and a row header; impersonation asks first and explains itself in the dialog rather than in a tooltip only a pointer can reach; every assistant action is built from a name and a label, because a sparkles icon names nothing; and the Settings overview puts an h2 over each group and an h3 holding the one link over each card, labels its search field and announces how many sections it found in a status region (Settings).

The block editor answers for three more of its own. A refused save is never silent: a block’s fields live on a side panel, so Filament had nowhere to draw the message and Save did nothing anybody could explain — now every page of the panel sends a persistent “Not saved” toast, the block at fault wears a mark whose accessible name is the first message and whose tooltip is all of them, and the panel repeats them under its heading. The words carry it and never the colour. Going to a block moves focus: the outline’s entries and the duplicate action both scroll the block into view and focus its header, because a page that moves without moving focus leaves the keyboard behind, and the scroll is instant under prefers-reduced-motion. And the picker is a keyboard’s: a search field, arrow keys between the entries, Enter on the first match, Escape back to the trigger, and a word when nothing matches.

Tool What it settles
bin/artisan hilms:theme:a11y [name] [--json] The eight Error rules: every contrast pair of the chain’s tokens in both schemes, and the theme’s own Blade through Hilms\Theme\Support\MarkupGuard; a token file it cannot read stops it
TokenContrastTest The same pairs for themes/hilms, in both schemes
toBeAccessibleDocument($kind) A rendered document: the language, the images, the frames, duplicate ids, a positive tabindex, an unlabelled control, a link or button with no name, and for a page the skeleton as well
ViewContractTest Every contract view, in both schemes, as a document
tests/Feature/AccessiblePagesTest.php The documents those views add up to: the home page, an informational page, a page laid out with a grid, a course listing, a course, a lesson, the page students land on, both under the default language and under another prefix, the login and registration forms, /account and a 404
tests/Feature/BlockPagesTest.php The listings a page is built from, as a browser receives them, a grid cell included
bin/artisan hilms:design:export --split + npm run a11y axe-core over every document of the split export, wcag2a wcag2aa wcag21a wcag21aa wcag22aa
bin/artisan hilms:design:cascade + npm run cascade That the contrast and motion preferences still win over a style, and a style over the theme, in every colour scheme and both load orders (Styles)
StyleAudit in the Styles editor The contrast pairs and the floors of a style, as advice — or refused under the operator’s switch

MarkupGuard blanks out Blade echoes and directives before it reads a tag: both hold -> and =>, and a > inside an attribute ends the tag halfway and hides the very attribute being looked for. Its checks are heuristics on purpose — a template is not a document, and the document is what axe reads.

npm run a11y runs on the developer’s machine, not in CI: it needs Node and a Chromium, and it only moves when the theme does. The workflow is written out in .github/workflows/ci.yml and commented out.

hilms:make-theme scaffolds the baseline: a11y.css (in public/ and listed in the manifest for a theme with no build, in css/ and imported by the entry for one with a build, its contrast and motion preferences !important like the default theme’s), and, for a theme with no parent, the layouts and the live region with the skeleton already in them and a copy of the default theme’s tokens, floors included. Then:

  1. Change the tokens in tokens.json, run hilms:theme:tokens <name>, and run hilms:theme:a11y <name> until it is silent. A pair whose tokens the chain does not declare is skipped — a theme that inherits its whole palette has nothing of its own to answer for.
  2. Style with tokens alone. Never outline-none; restyle the ring with the focus tokens.
  3. Keep the skeleton: the skip link first, one <main id="main">, a name on every nav and aside, one h1. A side column comes after <main> in the document however it is drawn, and a hidden one leaves the accessibility tree.
  4. Announce every change through <x-ui.live> and move focus with hilms:focus.
  5. Let long words break. A page must reflow at 320 px (WCAG 1.4.10) whatever type scale a style picks, and the steepest the generators offer sets a hero’s heading at about 76 px on a phone, where one long word would push the page sideways: the default theme’s <x-ui.heading> carries wrap-break-word, and .prose sets overflow-wrap: break-word.
  6. Export and scan: hilms:design:export --split then npm run a11y. Every value of every option and variant the theme declares is drawn as a document of its own, and --style=<slug> scans the documents in a style made in the panel.
  • Rich-text tables carry no caption and no header scope; the editor cannot express them. A block of its own is the answer when data needs them.
  • A hosted video player’s captions are the provider’s business. We ask YouTube for them in the interface language and can do no more; an uploaded video is the one we control.
  • The admin panel’s own chrome — Filament’s tables, modals and notifications — is Filament’s to fix. What we add to it is ours and is held to these rules.
  • The axe scan reads the design catalogue, which is every view with sample data. It is not a substitute for a person at a keyboard, and the batch that built all this ended with one.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.