An annotated walkthrough of two mockup files (mockups/register.html and mockups/home.html) and the CSS that styles them (mockups/css/base.css + mockups/css/theme-catppuccin.css). For each element: what tag was chosen and why, what problem it solves, the ARIA involved, and which RGAA 4.1 criteria it satisfies (RGAA is the French application of WCAG 2.1 AA that the DWWM certification references).
Note
🇫🇷 French version: mockups-explained-fr.md. Keep the two files in sync.
Files chosen because together they exercise the whole system:
register.htmlis the accessibility showcase (forms, errors, ARIA);home.htmlis the layout showcase (grids, decorative content, components).
<!DOCTYPE html>
<html lang="en"><!DOCTYPE html>switches the browser to standards mode. Without it, browsers emulate 1990s quirks (broken box model, inconsistent rendering).lang="en"declares the page language. Screen readers pick their speech synthesis voice from it; search engines and translators use it too. RGAA 8.3/8.4 (langue par defaut presente et pertinente). This is why the language decision matters before the real frontend: the attribute must match the actual content language.
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create an account — learn-dev</title>charsetfirst, so the parser never mis-decodes bytes.viewportmakes mobile browsers render at device width instead of a zoomed-out 980px canvas; prerequisite for any responsive behavior (RGAA 10.11, contenu consultable quelle que soit l'orientation / largeur).<title>is unique per page and pattern-consistent ("Page — site"). It is what a screen-reader user hears first, what tabs and bookmarks show. RGAA 8.5/8.6 (titre de page present et pertinent).
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=..." rel="stylesheet">
<link rel="stylesheet" href="css/theme-catppuccin.css">
<link rel="stylesheet" href="css/base.css">preconnectopens the TCP/TLS connection to the font host early (saves a round trip when the font CSS is requested).- Stylesheet order is the theme mechanism: the theme file only defines
custom properties (design tokens);
base.cssconsumes them. Loading a different theme file swaps every color with zero change tobase.css.
<a class="skip-link" href="#main">Skip to main content</a>- Problem solved: keyboard and screen-reader users otherwise must tab
through the entire header and nav on every page before reaching content.
The skip link is the first focusable element and jumps straight to
<main id="main">. - It is an ordinary anchor to a fragment; no ARIA needed.
- CSS hides it off-screen until it receives focus (see Part 2.4), so sighted mouse users never see it, keyboard users always can.
- RGAA 12.7 (lien d'evitement ou d'acces rapide a la zone de contenu principal). One of the most-checked criteria in audits.
<header class="site-header">...</header>
<nav class="site-header__nav" aria-label="Main">...</nav>
<main class="site-main" id="main">...</main>
<footer class="site-footer">...</footer>- These four tags produce ARIA landmarks for free:
banner,navigation,main,contentinfo. Screen readers expose a landmark menu, so users jump between zones without tabbing. This was verified in the accessibility tree of the rendered page (rolesbanner,navigation "Main",main,contentinfoall present). aria-label="Main"on<nav>: names the navigation zone. Required the moment a page can contain more than one<nav>(main menu, footer menu, breadcrumb); labeling from day one costs nothing and scales.- Why not
<div class="header">: a div has no role; the landmark menu would be empty, and RGAA 9.2 / 12.6 would fail. - RGAA 9.2 (structure du document coherente: header, main, footer), RGAA 12.6 (zones de regroupement atteignables ou activables).
<ul class="nav__list">
<li><a class="nav__link" href="home.html">Home</a></li>
<li><a class="nav__link" href="register.html" aria-current="page">Sign up</a></li>
</ul><ul>/<li>: navigation is a list of links; the list semantics let a screen reader announce "list, 3 items", giving users the size of the menu upfront. RGAA 9.3 (listes correctement structurees).aria-current="page"marks the link matching the current page. A screen reader announces "current page"; CSS also styles it (bold + mauve underline), so the information exists in both channels: assistive tech and vision. That duality is the core of RGAA 3.1 (l'information n'est pas donnee uniquement par la couleur).- Link texts ("Home", "Log in", "Sign up") are explicit out of context: RGAA 6.1 (chaque lien est explicite).
register.html: one <h1> ("Create an account").
home.html: <h1> (hero title) then <h2> ("Why learn-dev?") then one
<h3> per feature card.
- Screen-reader users navigate by headings (the
Hkey) more than by any other mechanism. The hierarchy is strictly decreasing with no skipped levels, and there is exactly one<h1>per page. - RGAA 9.1 (information structuree par des titres pertinents).
<p class="alert alert--error reveal" role="alert">
Your registration could not be completed. Check the highlighted field below.
</p>role="alert": turns the paragraph into an assertive live region: when the page (re)renders with an error, screen readers announce it immediately without the user having to find it. In the real Thymeleaf template this block will be conditionally rendered after a failed POST.- Placed before the form so it is encountered first in reading order.
- RGAA 11.11 (le controle de saisie est accompagne de suggestions d'erreur) and part of the WCAG 4.1.3 "status messages" behavior.
<form action="#" method="post" novalidate>method="post": registration mutates state; GET would leak the password into URLs, logs, and history.novalidate: mockup-only. It suppresses native browser validation so the server-rendered error state can be demonstrated (that is how the Spring/Thymeleaf app behaves: Bean Validation runs server-side and the page re-renders with errors).
<label class="form__label" for="username">Username</label>
<span class="form__hint" id="username-hint">3 to 50 characters.</span>
<input class="form__input" type="text" id="username" name="username"
autocomplete="username" aria-describedby="username-hint" required><label for>+id: the programmatic association between text and field. Clicking the label focuses the field (bigger touch target), and a screen reader announces "Username, edit text" when the input gets focus. RGAA 11.1 (chaque champ a une etiquette) — the single most-audited form criterion.<span class="form__hint" id>+aria-describedby: the hint is attached as the input's accessible description: announced after the label, but not part of the name. Sighted users see it above the field; screen-reader users hear it in context. RGAA 11.4/11.5 family (etiquettes et champs accoles, indications de saisie).autocomplete="username": tells browsers and password managers the field's purpose, enabling autofill. RGAA 11.13 (la finalite du champ peut etre deduite) = WCAG 1.3.5 "Identify Input Purpose".required: expresses the constraint in the markup (accessibility tree exposes "required"); the server still revalidates.
<input class="form__input form__input--invalid" type="email" id="email"
name="email" autocomplete="email" aria-describedby="email-error"
aria-invalid="true" value="carol@example.org" required>
<span class="form__error" id="email-error">Email already registered</span>The error-state pattern, and the heart of this mockup:
type="email"gives semantic keyboards on mobile and native format checking (RGAA 11.10, controle de saisie pertinent).aria-invalid="true"flags the field as failed in the accessibility tree: screen readers announce "invalid entry".aria-describedby="email-error"points at the error message, so focusing the field reads: "Email, edit text, invalid entry, Email already registered". The error text is findable from the field, not just visually nearby. This was verified in the rendered page: the computed description of#emailis exactly "Email already registered".- The visual channel is redundant with the programmatic one: red border
(
--invalidmodifier class) and bold red text below — never color alone (RGAA 3.1), with a contrast of 4.80:1 for the error color on the page background (RGAA 3.2, contraste des textes >= 4.5:1). - RGAA 11.11 (erreur identifiee + suggestion de correction).
<button class="button button--primary" type="submit">Create my account</button>- A true
<button type="submit">, not a styled<a>or<div>: submits on click and on Enter/Space, is focusable, and exposes thebuttonrole natively. No ARIA needed — the first rule of ARIA is to prefer native elements.
<div class="code-card reveal reveal--4" aria-hidden="true">
<code class="code-card__code">...</code>
</div>aria-hidden="true": the card is eye candy — a stylized Java snippet signaling "programming" visually. For a screen reader it would be noise (hearing raw code read aloud adds nothing to the hero's message), so the subtree is removed from the accessibility tree entirely. RGAA 1.2 analog (contenu decoratif ignore par les technologies d'assistance).<div>is correct here precisely because the element carries no semantics: it is presentation only.<code>inside keeps honest semantics for the code text (and gets the mono font from acode { font-family: var(--font-mono) }rule).- Same reasoning for the feature-card emoji:
<span class="feature-card__icon" aria-hidden="true">⚡</span>— the adjacent<h3>already carries the meaning; the emoji would otherwise be read as "high voltage sign".
<ul class="features">
<li class="feature-card">
<h3 class="feature-card__title">Interactive lessons</h3>
<p class="feature-card__text">...</p>
</li>
...
</ul>- Three parallel items = a list, so
<ul>/<li>(RGAA 9.3), not three sibling divs. "List, 3 items" tells the user the shape of the content. - Each card holds a heading + paragraph, giving the H-key navigation a stop per feature.
Every class follows BEM (block__element--modifier):
| Piece | Syntax | Examples from these pages |
|---|---|---|
| Block: standalone component | .block |
.site-header, .form-card, .code-card, .alert, .button |
| Element: a part that only makes sense inside its block | .block__element |
.site-header__brand, .form__input, .hero__title, .code-card__code |
| Modifier: a variant or state of a block/element | .block--modifier, .block__element--modifier |
.button--primary, .alert--error, .form__input--invalid, .site-main--narrow |
Why BEM here:
- Flat specificity: every selector is a single class, i.e. specificity
(0,1,0). No descendant chains, so no specificity wars: any rule can be overridden by another single class later in the cascade. - Self-documenting:
form__errortells you exactly where it lives;button--ghosttells you it is a variant of.button. - Grep-friendly: searching
feature-cardfinds the whole component. - IDs are never used for styling (only for
label for, fragment targets like#main, andaria-describedbyplumbing). An ID selector (#main, specificity(1,0,0)) would crush every class rule and break the flat model.
:root {
--color-bg: #eff1f5;
--color-text: #4c4f69;
--color-primary: #8839ef;
...
}
@media (prefers-color-scheme: dark) {
:root { --color-bg: #1e1e2e; ... }
}:rootis the<html>element with pseudo-class specificity(0,1,0); custom properties declared there inherit into every element, making them global design tokens.--color-*custom properties are consumed withvar(--color-*)inbase.css. No component ever contains a hex value, so:- swapping the theme
<link>re-skins the entire site; - dark mode is one
@media (prefers-color-scheme: dark)block that reassigns the same token names — components are untouched.
- swapping the theme
color-scheme: light darktells the browser both schemes are supported, so native widgets (inputs, scrollbars) follow along.- Structural tokens in
base.css(--space-1..6on a 0.25rem scale,--font-size-*on a modular scale,--radius,--shadow) play the same role for geometry. All sizes are in rem, so everything scales when the user changes the browser's base font size (RGAA 10.4, texte agrandissable a 200%).
*, *::before, *::after { box-sizing: border-box; }- Universal selector, specificity
(0,0,0)— deliberately the weakest rule in the file, so anything can override it.border-boxmakeswidthinclude padding and border: the intuitive box model (width: 100%plus padding no longer overflows — exactly what.form__inputrelies on).
body {
font-family: var(--font-body);
font-size: var(--font-size-base);
line-height: var(--line-height); /* 1.6, unitless */
color: var(--color-text);
background: var(--color-bg);
}- Set once on
body, inherited everywhere (type selector,(0,0,1)). - Unitless
line-height: 1.6is a multiplier that inherits correctly at any font size (a unit value likepxwould freeze it). 1.6 satisfies the RGAA/WCAG comfort recommendation (>= 1.5, RGAA 10.12 context). - The body font is Atkinson Hyperlegible, designed by the Braille Institute for low-vision legibility (distinct b/d/p/q letterforms): a typography choice that is an accessibility feature.
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}- The single most important accessibility rule in the file. Every focusable element gets a visible 2px outline in the focus color (Catppuccin blue, chosen for the 3:1 UI-component contrast requirement).
:focus-visible(not:focus) fires for keyboard focus but not for mouse clicks, so the outline never annoys pointer users and never disappears for keyboard users. The stylesheet never writesoutline: none. RGAA 10.7 (focus visible).
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}- Users with vestibular disorders opt out of motion at the OS level; this
honors it by killing all animation. The only
!importantin the project: justified because this preference must beat any other rule. RGAA 13.x family (contenu en mouvement controle par l'utilisateur).
.skip-link {
position: absolute;
top: -3rem; /* parked above the viewport */
transition: top 150ms ease;
}
.skip-link:focus-visible { top: 0; } /* slides in when focused */- Off-screen positioning (not
display: none!) keeps the link focusable:display: nonewould remove it from the tab order and defeat the point. - On focus,
top: 0brings it into view; the compound selector.skip-link:focus-visiblehas specificity(0,2,0)and beats.skip-link(0,1,0)— state rules must outweigh base rules, and here the pseudo-class provides exactly one extra specificity point.
.site-header { border-bottom: 1px solid var(--color-border); background: var(--color-surface); }
.site-header__inner {
max-width: var(--content-width); /* 64rem */
margin: 0 auto; /* the classic centering idiom */
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.site-header__nav { margin-left: auto; }- The block paints edge-to-edge (border + surface); the
__innerelement constrains content to a readable column and centers it with auto margins. This two-layer pattern repeats in the footer. - Flexbox for the header because it is a one-dimensional row;
align-items: centervertically centers brand and nav;margin-left: autoon the nav absorbs all free space, pushing the nav to the right without floats or positioning;flex-wraplets the nav drop to a second line on narrow screens instead of overflowing.
.nav__link[aria-current="page"] {
font-weight: 700;
color: var(--color-primary);
box-shadow: inset 0 -2px 0 var(--color-primary);
}- Styling driven by the ARIA attribute itself: the state lives once in the markup and CSS reads it — impossible for the visual state and the announced state to disagree.
- Specificity
(0,2,0)(class + attribute selector), so it cleanly overrides.nav__link(0,1,0). - The "underline" is an inset box-shadow rather than
text-decoration, so it sits at the link's padding edge and does not double with the hover underline.
.button { /* base: font, padding 0.65em/1.4em, radius, cursor, transition */ }
.button--primary { background: var(--color-primary); color: var(--color-on-primary); }
.button--ghost { background: transparent; color: var(--color-text); border-color: var(--color-border); }- Markup opts in with both classes:
class="button button--primary". Base and modifier have equal specificity(0,1,0); the modifier wins for the properties it redefines purely by source order (it appears later in the file) — the intended BEM mechanism, no!important, no nesting. - Padding in em scales with the button's own font size; radius and colors come from tokens. The white-on-mauve pair measures 5.41:1 (RGAA 3.2; buttons are text, so 4.5:1 applies, not just the 3:1 UI minimum).
- The hover lift (
transform: translateY(-1px)+ shadow) is a transition, therefore disabled automatically by the reduced-motion block.
.hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-5);
align-items: center;
}
@media (max-width: 46rem) { .hero { grid-template-columns: 1fr; } }- Grid because the hero is two-dimensional (columns whose heights must
align). The
frunits give the text column 55% of the space, the code card 45%, without magic pixel numbers. - The media query in rem (46rem ≈ 736px at default zoom) collapses to one column: breakpoints in rem respect user font-size settings (RGAA 10.11, reflow).
.code-card {
background: var(--code-bg); /* stays Mocha-dark even in light mode */
font-family: var(--font-mono);
rotate: 1.5deg;
position: relative;
}
.code-card::before {
content: "";
position: absolute;
width: 10px; height: 10px; border-radius: 50%;
background: var(--color-error);
box-shadow: 18px 0 0 var(--color-warning), 36px 0 0 var(--color-success);
}- The card intentionally keeps the dark palette in both modes: it is a
"terminal window" motif, and its syntax colors (
--code-*tokens) are the Mocha pastels, which pass contrast on the dark background. ::before+ box-shadow trick: one pseudo-element paints all three macOS "traffic light" dots — the two extra dots are solid, offset box-shadows (18px and 36px to the right). Zero extra markup for pure decoration, which is exactly what pseudo-elements are for (and being CSS content, it is invisible to screen readers — consistent with the card'saria-hidden).position: relativeon the card establishes the containing block that the absolutely-positioned pseudo-element is placed against.rotate: 1.5deg(the modern individual-transform property) gives the hand-placed sticker feel;.code-card__code { white-space: pre; overflow-x: auto; }preserves code indentation and scrolls horizontally inside the card rather than breaking the page (RGAA 10.11 again).
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--space-4);
}- The
auto-fit+minmaxidiom: as many equal columns as fit, each at least 15rem, each stretching to share leftovers. Three cards on desktop, two on tablet, one on phone — responsive with no media query at all.
.site-main--narrow { max-width: var(--form-width); } /* 26rem column */- A modifier on the
mainblock swaps the 64rem content column for a 26rem one: forms read best in a narrow measure. One class in the markup (class="site-main site-main--narrow"), no duplicate layout code.
.form__input {
width: 100%;
font: inherit;
border: 1px solid var(--color-border);
background: var(--color-bg);
}font: inheritis load-bearing: form controls do not inherit fonts by default; without this, inputs render in the OS font at 13px, breaking both the design and the zoom behavior.width: 100%is safe because of the globalborder-box.
.form__input:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 1px;
border-color: var(--color-focus);
}
.form__input--invalid { border-color: var(--color-error); }
.form__error { color: var(--color-error); font-weight: 700; font-size: var(--font-size-sm); }- Focus recolors the border and draws the outline (belt and braces for RGAA 10.7).
.form__inputand.form__input--invalidtie at(0,1,0); the modifier winsborder-colorby source order. But.form__input:focus-visibleis(0,2,0), so while focused, the focus color deliberately beats the error color — you always see where you are; the error is still conveyed by the message andaria-invalid.- The error text is bold and red and programmatically linked: three channels (RGAA 3.1).
.alert { border: 1px solid; border-radius: var(--radius); background: var(--color-surface); }
.alert--error { color: var(--color-error); border-color: var(--color-error); }
.alert--success { color: var(--color-success); border-color: var(--color-success); }- The base rule declares
border: 1px solidwithout a color: CSS then usescurrentColorfor the border, and each modifier only needs to setcolor+border-color... in fact settingcoloralone would suffice for the border thanks to currentColor; the explicitborder-colorkeeps the intent readable. Every variant's text passes 4.5:1 on the page background (see the theme exploration tables).
@keyframes rise-in {
from { opacity: 0; translate: 0 10px; }
to { opacity: 1; translate: 0 0; }
}
.reveal { animation: rise-in 500ms ease both; }
.reveal--2 { animation-delay: 100ms; } /* --3: 200ms, --4: 300ms */- One keyframe, staggered by delay-modifier classes: the hero title, lead, buttons, and code card rise in sequence — a single orchestrated page-load moment instead of scattered effects.
animation-fill-mode: both(thebothkeyword) applies thefromstate before the animation starts (no flash of final state) and holds thetostate after.- 10px of travel and 500ms: perceptible, not theatrical. And the whole
thing is erased by the
prefers-reduced-motionblock for users who need that (the page is fully usable with zero animation).
| RGAA criterion (theme) | Where it is satisfied |
|---|---|
| 3.1 information pas seulement par la couleur | error = border + bold text + aria-invalid; current nav = bold + underline + aria-current |
| 3.2 contraste des textes (4.5:1) | every token pair computed in theme-exploration.md (worst pair on these pages: 4.73:1) |
| 6.1 liens explicites | "Create your account", "Source on GitHub", "Log in" |
| 8.3/8.4 langue de page | <html lang="en"> (to become fr when the language decision lands) |
| 8.5/8.6 titre de page | unique, patterned <title> per page |
| 9.1 titres pertinents | single h1, ordered h2/h3 |
| 9.2 structure coherente | header/nav/main/footer landmarks |
| 9.3 listes | nav list, features list |
| 10.4 agrandissement du texte | all sizes in rem/em, unitless line-height |
| 10.7 focus visible | global :focus-visible outline, never removed |
| 10.11 reflow / responsive | rem breakpoints, auto-fit grids, internal overflow |
| 11.1 etiquettes de champs | <label for> on every input |
| 11.10 controle de saisie | type="email", required |
| 11.11 erreurs et suggestions | role="alert" summary + per-field message via aria-describedby |
| 11.13 finalite des champs | `autocomplete="username |
| 12.7 lien d'evitement | .skip-link to #main |
| 1.2 decoration ignoree | aria-hidden on code card and emoji |
The authoritative styling sources are mockups/css/base.css and mockups/css/theme-catppuccin.css; contrast figures come from theme-exploration.md. Per-file companion docs live in mockups/.