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.
The target
Section titled “The target”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.
The rule contract
Section titled “The rule contract”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.
The skeleton
Section titled “The skeleton”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 therailslot, 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 alabelattribute (“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’sh1comes before the column’s headings and nobody’s tab order changes. A column collapsed on a wide screen isvisibility: hiddenand 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 elseinert— 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.phpanderrors/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).
Colour
Section titled “Colour”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\ContrastPairslists 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-borderis deliberately unpaired: an ordinary divider carries no information.TokenContrastTestcomputes 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,Oklchconverts OKLCH — and#hexandrgb()— through OKLab to gamut-clamped linear sRGB, andContrastturns two luminances into a ratio. Nothing on Packagist reads OKLCH;spatie/colorhasContrast::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-surfaceon 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 a11yreads the centred header, the bold callout and the rest, not only the defaults. - A generated palette meets every pair by construction (chapter 26):
ContrastSolvermoves 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.
MailPalettetakes 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-surfaceitself, 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.
Focus, motion and targets
Section titled “Focus, motion and targets”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-visibledraws--focus-ring-widthof--color-focusat--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-linksits out of the way until it is focused.prefers-contrast: morepromotes--color-borderand--color-ink-faintto 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: activegets back what the system palette flattens: the progress fill, a pressed chip, the skip link, an image inside a figure.prefers-reduced-motionzeroes the two motion tokens —!importantfor 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.
Live regions and Livewire
Section titled “Live regions and Livewire”<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.
Media and blocks
Section titled “Media and blocks”- An uploaded video renders
<source>plus one<track kind="captions">per caption track of its library file, the first onedefault. The tracks belong to the recording, one WebVTT file per language in the asset’scaptionscollection, 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
.vttfile sniffs astext/plain, so the library’s caption upload acceptstext/plaintoo andApp\Rules\WebVtttells a caption file from a stray note: the wordWEBVTTalone on the first line, behind an optional byte-order mark. noplaybackrateis 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>throughRichText::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
decorativetoggle 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 writesalt="".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-hiddenand takes no pointer, so it is never read and never in the way; its drift stops underprefers-reduced-motion. The full-screen button that replaces the browser’s own on a marked player keeps one name and says its state througharia-pressed, as a toggle button must, is--target-minlarge, and is hidden where full screen does not exist rather than left doing nothing. Hilms\Blocks\Filament\RichEditorFieldis 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.
Language
Section titled “Language”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.
The panel
Section titled “The panel”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.
The tooling
Section titled “The tooling”| 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.
Writing an accessible theme
Section titled “Writing an accessible theme”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:
- Change the tokens in
tokens.json, runhilms:theme:tokens <name>, and runhilms: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. - Style with tokens alone. Never
outline-none; restyle the ring with the focus tokens. - Keep the skeleton: the skip link first, one
<main id="main">, a name on everynavandaside, oneh1. A side column comes after<main>in the document however it is drawn, and a hidden one leaves the accessibility tree. - Announce every change through
<x-ui.live>and move focus withhilms:focus. - 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>carrieswrap-break-word, and.prosesetsoverflow-wrap: break-word. - Export and scan:
hilms:design:export --splitthennpm 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.
Known limits
Section titled “Known limits”- 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.