diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0371299..04490e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,27 @@ Registration (`POST /auth/register`) is handled by `AuthController` and `RegistrationService` (server-side bean validation plus duplicate username/email detection). +#### Design: Mockups and Wireframes + +The frontend look and feel is specified before code: + +- **[Figma file](https://www.figma.com/design/2q1Rt5NGbQ1w8gRtRGoF4A)** + (read-only): low-fidelity wireframes (page structure) and high-fidelity + mockups (Catppuccin theme). +- **[Browsable HTML mockups](docs/design/mockups/index.html)**: the same + pages as static HTML/CSS; their markup and BEM classes are the blueprint + for the future Thymeleaf templates. +- **[docs/design/theme-exploration.md](docs/design/theme-exploration.md)**: + the design tokens and their WCAG contrast ratios; + [mockups-explained.md](docs/design/mockups-explained.md) + (FR: [mockups-explained-fr.md](docs/design/mockups-explained-fr.md)) and + the per-file docs in [docs/design/mockups/](docs/design/mockups/) explain + every tag and CSS rule. + +Frontend contributions are expected to follow the mockups, consume colors +only through the design tokens, and use the BEM naming convention (see +[Code Style and Formatting](#code-style-and-formatting)). + #### MonoRepo We use a **monorepo**, that is a Git repository containing mainly both the **frontend and** the **backend**. diff --git a/README.md b/README.md index 18e7786..7671f6f 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,12 @@ visit [this link](https://github.com/users/ebouchut/projects/7/views/3). - [GLOSSARY.md](GLOSSARY.md) — definitions of the domain and technical terms used across the project (🇫🇷 French version: [GLOSSAIRE.md](GLOSSAIRE.md)). - [Architecture Decision Records](docs/adr/README.md) — A list of design decisions and their trade-offs. +- [Mockups and wireframes (Figma)](https://www.figma.com/design/2q1Rt5NGbQ1w8gRtRGoF4A) — + read-only Figma file with the high-fidelity mockups (Catppuccin theme) and the + low-fidelity wireframes of the frontend pages; the browsable HTML mockups and + their study docs live in [docs/design/](docs/design/theme-exploration.md) + (see [mockups-explained.md](docs/design/mockups-explained.md), + 🇫🇷 [mockups-explained-fr.md](docs/design/mockups-explained-fr.md)). - [Test coverage (Codecov)](https://app.codecov.io/gh/ebouchut/learn-dev) — live coverage dashboard fed by CI (see [ADR-0012](docs/adr/0012-publish-test-coverage-to-codecov.md)); diff --git a/docs/design/mockups-explained-fr.md b/docs/design/mockups-explained-fr.md new file mode 100644 index 0000000..a225ef5 --- /dev/null +++ b/docs/design/mockups-explained-fr.md @@ -0,0 +1,676 @@ +# Les maquettes de learn-dev expliquées : register.html et home.html + +Une lecture commentée de deux fichiers de maquette +([mockups/register.html](mockups/register.html) et +[mockups/home.html](mockups/home.html)) +et du CSS qui les met en forme ([mockups/css/base.css](mockups/css/base.css) + +[mockups/css/theme-catppuccin.css](mockups/css/theme-catppuccin.css)). +Pour chaque élément : la balise choisie et pourquoi, le problème qu'elle +résout, l'ARIA impliqué, et les critères RGAA 4.1 satisfaits (le RGAA est la +déclinaison française de WCAG 2.1 AA à laquelle la certification DWWM fait +référence). + +> [!NOTE] +> 🇬🇧 English version: [mockups-explained.md](mockups-explained.md). +> Les deux fichiers doivent rester synchronisés. + +> Ces fichiers ont été choisis parce qu'à eux deux ils sollicitent tout le système : +> `register.html` est la vitrine de l'accessibilité (formulaires, erreurs, ARIA) ; +> `home.html` est la vitrine de la mise en page (grilles, contenu décoratif, composants). + +--- + +## Partie 1 : structure HTML + +### 1.1 Le squelette du document (les deux pages) + +```html + + +``` + +- **``** fait basculer le navigateur en mode standard. Sans + lui, les navigateurs émulent les bizarreries des années 1990 (modèle de + boîte cassé, rendu incohérent). +- **`lang="en"`** déclare la langue de la page. Les lecteurs d'écran en + déduisent leur voix de synthèse vocale ; les moteurs de recherche et les + traducteurs s'en servent aussi. **RGAA 8.3/8.4** (langue par défaut + présente et pertinente). C'est pourquoi la décision sur la langue compte + avant le vrai frontend : l'attribut doit correspondre à la langue réelle + du contenu. + +```html + + +Create an account — learn-dev +``` + +- **`charset`** en premier, pour que l'analyseur ne décode jamais les octets + de travers. +- **`viewport`** fait que les navigateurs mobiles affichent la page à la + largeur de l'appareil au lieu d'un canevas dézoomé de 980px ; c'est le + prérequis de tout comportement responsive + (**RGAA 10.11**, contenu consultable quelle que soit l'orientation ou la + largeur). +- **``** est unique par page et suit un motif constant + ("Page — site"). C'est la première chose qu'entend un utilisateur de + lecteur d'écran, et ce qu'affichent les onglets et les marque-pages. + **RGAA 8.5/8.6** (titre de page présent et pertinent). + +```html +<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"> +``` + +- `preconnect` ouvre tôt la connexion TCP/TLS vers l'hébergeur de polices + (on économise un aller-retour au moment où le CSS des polices est demandé). +- **L'ordre des feuilles de style est le mécanisme de thème** : le fichier + de thème ne définit que des propriétés personnalisées (custom properties), + les design tokens ; `base.css` les consomme. Charger un autre fichier de + thème remplace toutes les couleurs sans le moindre changement dans + `base.css`. + +### 1.2 Le lien d'évitement (les deux pages) + +```html +<a class="skip-link" href="#main">Skip to main content</a> +``` + +- **Problème résolu** : sans lui, les utilisateurs au clavier et de lecteur + d'écran doivent tabuler à travers tout l'en-tête et la navigation sur + *chaque page* avant d'atteindre le contenu. Le lien d'évitement est le + premier élément focalisable et saute directement vers `<main id="main">`. +- C'est une ancre ordinaire vers un fragment ; aucun ARIA nécessaire. +- Le CSS le masque hors écran jusqu'à ce qu'il reçoive le focus (voir la + partie 2.4) : les utilisateurs voyants à la souris ne le voient jamais, + les utilisateurs au clavier le voient toujours. +- **RGAA 12.7** (lien d'évitement ou d'accès rapide à la zone de contenu + principal). Un des critères les plus vérifiés en audit. + +### 1.3 Zones de repère : header, nav, main, footer (les deux pages) + +```html +<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> +``` + +- Ces quatre balises produisent **des zones de repère (landmarks) ARIA + gratuitement** : `banner`, `navigation`, `main`, `contentinfo`. Les + lecteurs d'écran exposent un menu des zones de repère, ce qui permet de + sauter d'une zone à l'autre sans tabuler. Cela a été vérifié dans l'arbre + d'accessibilité de la page rendue (les rôles `banner`, + `navigation "Main"`, `main`, `contentinfo` sont tous présents). +- **`aria-label="Main"` sur `<nav>`** : nomme la zone de navigation. + Indispensable dès qu'une page peut contenir plus d'un `<nav>` (menu + principal, menu de pied de page, fil d'Ariane) ; nommer dès le premier + jour ne coûte rien et passe à l'échelle. +- **Pourquoi pas `<div class="header">`** : une div n'a pas de rôle ; le + menu des zones de repère serait vide, et RGAA 9.2 / 12.6 échoueraient. +- **RGAA 9.2** (structure du document cohérente : header, main, footer), + **RGAA 12.6** (zones de regroupement atteignables ou activables). + +### 1.4 La liste de navigation et la page courante (les deux pages) + +```html +<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>`** : la navigation est une *liste de liens* ; la sémantique + de liste permet au lecteur d'écran d'annoncer « liste, 3 éléments », ce + qui donne d'emblée la taille du menu à l'utilisateur. **RGAA 9.3** (listes + correctement structurées). +- **`aria-current="page"`** marque le lien correspondant à la page courante. + Un lecteur d'écran annonce « page courante » ; le CSS le met aussi en + forme (gras + soulignement mauve), donc l'information existe dans les + *deux* canaux : technologies d'assistance et vision. Cette dualité est le + cœur de **RGAA 3.1** (l'information n'est pas donnée uniquement par la + couleur). +- Les intitulés de liens ("Home", "Log in", "Sign up") sont explicites hors + contexte : **RGAA 6.1** (chaque lien est explicite). + +### 1.5 La hiérarchie des titres + +`register.html` : un seul `<h1>` ("Create an account"). +`home.html` : `<h1>` (titre du hero) puis `<h2>` ("Why learn-dev?") puis un +`<h3>` par carte de fonctionnalité. + +- Les utilisateurs de lecteur d'écran naviguent par titres (la touche `H`) + plus que par tout autre mécanisme. La hiérarchie est strictement + décroissante, sans niveau sauté, et il y a exactement un `<h1>` par page. +- **RGAA 9.1** (information structurée par des titres pertinents). + +### 1.6 Le formulaire d'inscription (register.html), champ par champ + +```html +<p class="alert alert--error reveal" role="alert"> + Your registration could not be completed. Check the highlighted field below. +</p> +``` + +- **`role="alert"`** : transforme le paragraphe en **région live** + assertive : quand la page s'affiche (ou se réaffiche) avec une erreur, les + lecteurs d'écran l'annoncent immédiatement, sans que l'utilisateur ait à + la chercher. Dans le vrai template Thymeleaf, ce bloc sera rendu + conditionnellement après un POST en échec. +- Placé **avant** le formulaire, pour être rencontré en premier dans l'ordre + de lecture. +- **RGAA 11.11** (le contrôle de saisie est accompagné de suggestions + d'erreur), et une partie du comportement « status messages » de + WCAG 4.1.3. + +```html +<form action="#" method="post" novalidate> +``` + +- **`method="post"`** : l'inscription modifie l'état ; GET ferait fuiter le + mot de passe dans les URL, les journaux et l'historique. +- **`novalidate`** : réservé à la maquette. Il désactive la validation + native du navigateur pour pouvoir démontrer l'état d'erreur *rendu côté + serveur* (c'est ainsi que se comporte l'application Spring/Thymeleaf : + Bean Validation s'exécute côté serveur et la page se réaffiche avec les + erreurs). + +```html +<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`** : l'association *programmatique* entre le texte + et le champ. Cliquer sur l'étiquette donne le focus au champ (cible + tactile plus grande), et un lecteur d'écran annonce « Username, édition » + quand le champ reçoit le focus. **RGAA 11.1** (chaque champ a une + étiquette) : le critère de formulaire le plus audité, et de loin. +- **`<span class="form__hint" id>` + `aria-describedby`** : l'indication de + saisie est rattachée au champ comme *description accessible* : annoncée + après l'étiquette, mais sans faire partie du nom. Les utilisateurs voyants + la voient au-dessus du champ ; les utilisateurs de lecteur d'écran + l'entendent en contexte. Famille **RGAA 11.4/11.5** (étiquettes et champs + accolés, indications de saisie). +- **`autocomplete="username"`** : indique aux navigateurs et aux + gestionnaires de mots de passe la *finalité* du champ, ce qui active le + remplissage automatique. **RGAA 11.13** (la finalité du champ peut être + déduite) = WCAG 1.3.5 « Identify Input Purpose ». +- **`required`** : exprime la contrainte dans le balisage (l'arbre + d'accessibilité expose « obligatoire ») ; le serveur revalide de toute + façon. + +```html +<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> +``` + +Le motif d'état d'erreur, et le cœur de cette maquette : + +- **`type="email"`** apporte les claviers sémantiques sur mobile et la + vérification native du format (**RGAA 11.10**, contrôle de saisie + pertinent). +- **`aria-invalid="true"`** signale le champ comme en erreur dans l'arbre + d'accessibilité : les lecteurs d'écran annoncent « saisie non valide ». +- **`aria-describedby="email-error"`** pointe vers le message d'erreur ; + donner le focus au champ fait donc lire : « Email, édition, saisie non + valide, Email already registered ». Le texte d'erreur est *retrouvable + depuis le champ*, pas seulement proche visuellement. Cela a été vérifié + dans la page rendue : la description calculée de `#email` est exactement + "Email already registered". +- Le canal visuel est redondant avec le canal programmatique : bordure rouge + (classe modificatrice `--invalid`) **et** texte rouge en gras en dessous ; + jamais la couleur seule (**RGAA 3.1**), avec un contraste de 4.80:1 pour + la couleur d'erreur sur le fond de la page (**RGAA 3.2**, contraste des + textes >= 4.5:1). +- **RGAA 11.11** (erreur identifiée + suggestion de correction). + +```html +<button class="button button--primary" type="submit">Create my account</button> +``` + +- Un vrai `<button type="submit">`, pas un `<a>` ni un `<div>` stylés : il + soumet au clic *et* avec Entrée/Espace, il est focalisable, et il expose + nativement le rôle `button`. Aucun ARIA nécessaire : la première règle + d'ARIA est de préférer les éléments natifs. + +### 1.7 La carte de code décorative (home.html) + +```html +<div class="code-card reveal reveal--4" aria-hidden="true"> + <code class="code-card__code">...</code> +</div> +``` + +- **`aria-hidden="true"`** : la carte est purement *décorative* : un extrait + Java stylisé qui signale visuellement « programmation ». Pour un lecteur + d'écran, ce serait du bruit (entendre du code brut lu à voix haute + n'ajoute rien au message du hero), donc le sous-arbre est entièrement + retiré de l'arbre d'accessibilité. Analogue de **RGAA 1.2** (contenu + décoratif ignoré par les technologies d'assistance). +- **`<div>`** est correct ici précisément *parce que* l'élément ne porte + aucune sémantique : c'est de la présentation pure. +- **`<code>`** à l'intérieur conserve une sémantique honnête pour le texte + du code (et reçoit la police mono via une règle + `code { font-family: var(--font-mono) }`). +- Même raisonnement pour l'emoji des cartes de fonctionnalités, + `<span class="feature-card__icon" aria-hidden="true">⚡</span>` : le + `<h3>` adjacent porte déjà le sens ; sans cela, l'emoji serait lu + « haute tension ». + +### 1.8 Les cartes de fonctionnalités sous forme de liste (home.html) + +```html +<ul class="features"> + <li class="feature-card"> + <h3 class="feature-card__title">Interactive lessons</h3> + <p class="feature-card__text">...</p> + </li> + ... +</ul> +``` + +- Trois éléments parallèles = une **liste**, donc `<ul>/<li>` (RGAA 9.3), et + non trois divs sœurs. « Liste, 3 éléments » indique à l'utilisateur la + forme du contenu. +- Chaque carte contient un titre + un paragraphe, ce qui donne à la + navigation par la touche H un arrêt par fonctionnalité. + +--- + +## Partie 2 : mise en page et CSS + +### 2.1 Convention de nommage : BEM + +Chaque classe suit **BEM** (`block__element--modifier`) : + +| Rôle | Syntaxe | Exemples tirés de ces pages | +|---|---|---| +| Block : composant autonome | `.block` | `.site-header`, `.form-card`, `.code-card`, `.alert`, `.button` | +| Element : une partie qui n'a de sens qu'à l'intérieur de son block | `.block__element` | `.site-header__brand`, `.form__input`, `.hero__title`, `.code-card__code` | +| Modifier : une variante ou un état d'un block/element | `.block--modifier`, `.block__element--modifier` | `.button--primary`, `.alert--error`, `.form__input--invalid`, `.site-main--narrow` | + +Pourquoi BEM ici : + +- **Spécificité plate** : chaque sélecteur est une classe unique, soit une + spécificité `(0,1,0)`. Pas de chaînes de descendants, donc pas de guerres + de spécificité : n'importe quelle règle peut être surchargée par une autre + classe unique placée plus loin dans la cascade. +- **Auto-documenté** : `form__error` dit exactement où il vit ; + `button--ghost` dit qu'il s'agit d'une variante de `.button`. +- **Facile à rechercher (grep)** : chercher `feature-card` retrouve tout le + composant. +- Les ID ne servent **jamais au style** (seulement au `label for`, aux + cibles de fragment comme `#main` et à la tuyauterie `aria-describedby`). + Un sélecteur d'ID (`#main`, spécificité `(1,0,0)`) écraserait toutes les + règles de classe et casserait le modèle plat. + +### 2.2 Les design tokens (fichier de thème + tokens structurels) + +```css +:root { + --color-bg: #eff1f5; + --color-text: #4c4f69; + --color-primary: #8839ef; + ... +} +@media (prefers-color-scheme: dark) { + :root { --color-bg: #1e1e2e; ... } +} +``` + +- **`:root`** est l'élément `<html>` avec une spécificité de pseudo-classe + `(0,1,0)` ; les propriétés personnalisées qui y sont déclarées **héritent + dans chaque élément**, ce qui en fait des design tokens globaux. +- **Les propriétés personnalisées `--color-*`** sont consommées avec + `var(--color-*)` dans `base.css`. Aucun composant ne contient jamais de + valeur hexadécimale, donc : + 1. remplacer le `<link>` de thème rhabille le site entier ; + 2. le mode sombre est un seul bloc `@media (prefers-color-scheme: dark)` + qui réaffecte les mêmes noms de tokens : les composants restent + intacts. +- **`color-scheme: light dark`** indique au navigateur que les deux schémas + sont pris en charge, donc les widgets natifs (champs, barres de + défilement) suivent le mouvement. +- Les tokens structurels de `base.css` (`--space-1..6` sur une échelle de + 0.25rem, `--font-size-*` sur une échelle modulaire, `--radius`, + `--shadow`) jouent le même rôle pour la géométrie. Toutes les tailles sont + en **rem**, donc tout s'adapte quand l'utilisateur change la taille de + police de base du navigateur (**RGAA 10.4**, texte agrandissable à 200%). + +### 2.3 Reset et typographie + +```css +*, *::before, *::after { box-sizing: border-box; } +``` + +- Sélecteur universel, spécificité `(0,0,0)` : délibérément la règle la plus + faible du fichier, pour que n'importe quoi puisse la surcharger. + `border-box` fait que `width` inclut le padding et la bordure : le modèle + de boîte intuitif (`width: 100%` plus du padding ne déborde plus, ce sur + quoi `.form__input` s'appuie exactement). + +```css +body { + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: var(--line-height); /* 1.6, sans unité */ + color: var(--color-text); + background: var(--color-bg); +} +``` + +- Défini une fois sur `body`, hérité partout (sélecteur de type, `(0,0,1)`). +- **Le `line-height: 1.6` sans unité** est un multiplicateur qui hérite + correctement à n'importe quelle taille de police (une valeur avec unité, + comme des `px`, le figerait). 1.6 satisfait la recommandation de confort + RGAA/WCAG (>= 1.5, contexte **RGAA 10.12**). +- La police du corps de texte est **Atkinson Hyperlegible**, conçue par le + Braille Institute pour la lisibilité en basse vision (formes de lettres + b/d/p/q bien distinctes) : un choix typographique qui *est* une + fonctionnalité d'accessibilité. + +```css +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} +``` + +- **La règle d'accessibilité la plus importante du fichier.** Chaque élément + focalisable reçoit un contour visible de 2px dans la couleur de focus + (bleu Catppuccin, choisi pour l'exigence de contraste de 3:1 des + composants d'interface). +- `:focus-visible` (et non `:focus`) se déclenche pour le focus clavier mais + pas pour les clics de souris : le contour ne gêne donc jamais les + utilisateurs à la souris et ne disparaît jamais pour les utilisateurs au + clavier. La feuille de style n'écrit **jamais** `outline: none`. + **RGAA 10.7** (focus visible). + +```css +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} +``` + +- Les utilisateurs atteints de troubles vestibulaires désactivent les + animations au niveau du système ; cette règle respecte ce choix en + supprimant toute animation. C'est le seul `!important` du projet : + justifié parce que cette préférence doit l'emporter sur *n'importe + quelle* autre règle. Famille **RGAA 13.x** (contenu en mouvement contrôlé + par l'utilisateur). + +### 2.4 La mécanique du lien d'évitement + +```css +.skip-link { + position: absolute; + top: -3rem; /* garé au-dessus de la zone d'affichage */ + transition: top 150ms ease; +} +.skip-link:focus-visible { top: 0; } /* glisse en vue au focus */ +``` + +- Le positionnement hors écran (pas `display: none` !) garde le lien + focalisable : `display: none` le retirerait de l'ordre de tabulation et + ruinerait tout l'intérêt du mécanisme. +- Au focus, `top: 0` le fait apparaître ; le sélecteur composé + `.skip-link:focus-visible` a une spécificité `(0,2,0)` et bat `.skip-link` + `(0,1,0)` : les règles d'état doivent peser plus lourd que les règles de + base, et ici la pseudo-classe fournit exactement le point de spécificité + supplémentaire qu'il faut. + +### 2.5 En-tête et navigation + +```css +.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; /* l'idiome classique de centrage */ + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +.site-header__nav { margin-left: auto; } +``` + +- Le block peint d'un bord à l'autre (bordure + surface) ; l'élément + `__inner` contraint le contenu à une colonne lisible et le centre avec des + marges automatiques. Ce motif à deux couches se répète dans le pied de + page. +- **Flexbox** pour l'en-tête parce que c'est une rangée unidimensionnelle ; + `align-items: center` centre verticalement la marque et la navigation ; + `margin-left: auto` sur la nav absorbe tout l'espace libre et pousse la + navigation à droite, sans float ni positionnement ; `flex-wrap` laisse la + navigation passer sur une deuxième ligne sur les écrans étroits au lieu de + déborder. + +```css +.nav__link[aria-current="page"] { + font-weight: 700; + color: var(--color-primary); + box-shadow: inset 0 -2px 0 var(--color-primary); +} +``` + +- **Un style piloté par l'attribut ARIA lui-même** : l'état vit à un seul + endroit, dans le balisage, et le CSS le lit ; impossible que l'état visuel + et l'état annoncé divergent. +- Spécificité `(0,2,0)` (classe + sélecteur d'attribut), qui surcharge donc + proprement `.nav__link` `(0,1,0)`. +- Le « soulignement » est un box-shadow interne plutôt qu'un + `text-decoration` : il se place au bord du padding du lien et ne se cumule + pas avec le soulignement du survol. + +### 2.6 Les boutons + +```css +.button { /* base : police, 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); } +``` + +- Le balisage s'abonne avec les deux classes : + `class="button button--primary"`. La base et le modificateur ont la même + spécificité `(0,1,0)` ; le modificateur gagne, pour les propriétés qu'il + redéfinit, uniquement par **ordre dans la source** (il apparaît plus loin + dans le fichier) : c'est le mécanisme BEM voulu, sans `!important` et sans + imbrication. +- Le padding en **em** s'adapte à la taille de police du bouton lui-même ; + l'arrondi et les couleurs viennent des tokens. La paire blanc sur mauve + mesure 5.41:1 (**RGAA 3.2** ; les boutons sont du texte, donc c'est le + seuil de 4.5:1 qui s'applique, pas seulement le minimum de 3:1 des + composants d'interface). +- L'effet de soulèvement au survol (`transform: translateY(-1px)` + ombre) + est une transition, donc automatiquement désactivé par le bloc + reduced-motion. + +### 2.7 La grille du hero et la carte de code (home.html) + +```css +.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** parce que le hero est bidimensionnel (des colonnes dont les + hauteurs doivent s'aligner). Les unités `fr` donnent 55% de l'espace à la + colonne de texte, 45% à la carte de code, sans nombres magiques en pixels. +- La **media query en rem** (46rem ≈ 736px au zoom par défaut) replie le + tout sur une seule colonne : des points de rupture en rem respectent les + réglages de taille de police de l'utilisateur (**RGAA 10.11**, reflow). + +```css +.code-card { + background: var(--code-bg); /* reste en Mocha sombre, même en mode clair */ + 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); +} +``` + +- La carte garde volontairement la palette sombre dans les deux modes : + c'est un motif « fenêtre de terminal », et ses couleurs de syntaxe (les + tokens `--code-*`) sont les pastels Mocha, qui passent le contraste sur le + fond sombre. +- **L'astuce `::before` + box-shadow** : un seul pseudo-élément peint les + trois pastilles « feux tricolores » de macOS ; les deux pastilles + supplémentaires sont des box-shadows pleins et décalés (de 18px et 36px + vers la droite). Zéro balisage supplémentaire pour de la pure décoration, + ce qui est exactement le rôle des pseudo-éléments (et comme il s'agit de + contenu CSS, il est invisible pour les lecteurs d'écran, en cohérence avec + l'`aria-hidden` de la carte). +- `position: relative` sur la carte établit le bloc conteneur par rapport + auquel le pseudo-élément positionné en absolu est placé. +- `rotate: 1.5deg` (la propriété de transformation individuelle moderne) + donne l'effet d'autocollant posé à la main ; + `.code-card__code { white-space: pre; overflow-x: auto; }` + préserve l'indentation du code et fait défiler horizontalement *à + l'intérieur de la carte* plutôt que de casser la page (**RGAA 10.11**, + encore). + +```css +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--space-4); +} +``` + +- **L'idiome `auto-fit` + `minmax`** : autant de colonnes égales que + possible, chacune d'au moins 15rem, chacune s'étirant pour se partager le + reste. Trois cartes sur ordinateur, deux sur tablette, une sur téléphone : + responsive **sans aucune media query**. + +### 2.8 Le formulaire (register.html) + +```css +.site-main--narrow { max-width: var(--form-width); } /* colonne de 26rem */ +``` + +- Un modificateur sur le block `main` remplace la colonne de contenu de + 64rem par une colonne de 26rem : un formulaire se lit mieux dans une + mesure étroite. Une seule classe dans le balisage + (`class="site-main site-main--narrow"`), aucun code de mise en page + dupliqué. + +```css +.form__input { + width: 100%; + font: inherit; + border: 1px solid var(--color-border); + background: var(--color-bg); +} +``` + +- **`font: inherit`** est indispensable : les contrôles de formulaire + n'héritent *pas* des polices par défaut ; sans cela, les champs + s'affichent dans la police du système à 13px, ce qui casse à la fois le + design et le comportement au zoom. +- `width: 100%` est sans risque grâce au `border-box` global. + +```css +.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); } +``` + +- Le focus recolore la bordure *et* dessine le contour (ceinture et + bretelles pour RGAA 10.7). +- `.form__input` et `.form__input--invalid` sont à égalité à `(0,1,0)` ; le + modificateur gagne `border-color` par ordre dans la source. Mais + `.form__input:focus-visible` est à `(0,2,0)`, donc **pendant le focus, la + couleur de focus bat délibérément la couleur d'erreur** : on voit toujours + où l'on se trouve ; l'erreur reste portée par le message et par + `aria-invalid`. +- Le texte d'erreur est en gras *et* rouge *et* lié programmatiquement : + trois canaux (RGAA 3.1). + +```css +.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); } +``` + +- La règle de base déclare `border: 1px solid` **sans couleur** : le CSS + utilise alors `currentColor` pour la bordure, et chaque modificateur n'a + plus qu'à définir `color` + `border-color`... en fait, définir `color` + seul suffirait pour la bordure grâce à currentColor ; le `border-color` + explicite garde l'intention lisible. Le texte de chaque variante passe le + seuil de 4.5:1 sur le fond de la page (voir les tableaux de l'exploration + des thèmes). + +### 2.9 L'animation d'apparition + +```css +@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 */ +``` + +- Une seule keyframe, étagée par des classes modificatrices de délai : le + titre du hero, le chapeau, les boutons et la carte de code apparaissent en + séquence, un unique moment orchestré au chargement de la page plutôt que + des effets dispersés. +- `animation-fill-mode: both` (le mot-clé `both`) applique l'état `from` + avant le début de l'animation (pas de flash de l'état final) et conserve + l'état `to` après. +- 10px de déplacement et 500ms : perceptible, pas théâtral. Et l'ensemble + est effacé par le bloc `prefers-reduced-motion` pour les utilisateurs qui + en ont besoin (la page reste pleinement utilisable sans aucune animation). + +--- + +## Correspondance RGAA rapide (ce qu'un auditeur vérifierait sur ces deux pages) + +| Critère RGAA (thématique) | Où il est satisfait | +|---|---| +| 3.1 information pas seulement par la couleur | erreur = bordure + texte en gras + `aria-invalid` ; nav courante = gras + soulignement + `aria-current` | +| 3.2 contraste des textes (4.5:1) | chaque paire de tokens calculée dans [theme-exploration.md](theme-exploration.md) (pire paire sur ces 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">` (deviendra `fr` quand la décision sur la langue sera actée) | +| 8.5/8.6 titre de page | `<title>` unique et suivant un motif constant sur chaque page | +| 9.1 titres pertinents | un seul `h1`, `h2`/`h3` ordonnés | +| 9.2 structure cohérente | zones de repère `header`/`nav`/`main`/`footer` | +| 9.3 listes | liste de navigation, liste des fonctionnalités | +| 10.4 agrandissement du texte | toutes les tailles en rem/em, line-height sans unité | +| 10.7 focus visible | contour `:focus-visible` global, jamais retiré | +| 10.11 reflow / responsive | points de rupture en rem, grilles auto-fit, débordement interne | +| 11.1 étiquettes de champs | `<label for>` sur chaque champ | +| 11.10 contrôle de saisie | `type="email"`, `required` | +| 11.11 erreurs et suggestions | résumé `role="alert"` + message par champ via `aria-describedby` | +| 11.13 finalité des champs | `autocomplete="username|email|new-password"` | +| 12.7 lien d'évitement | `.skip-link` vers `#main` | +| 1.2 décoration ignorée | `aria-hidden` sur la carte de code et l'emoji | + +--- + +*Les sources de style faisant autorité sont +[mockups/css/base.css](mockups/css/base.css) et +[mockups/css/theme-catppuccin.css](mockups/css/theme-catppuccin.css) ; +les chiffres de contraste viennent de [theme-exploration.md](theme-exploration.md). +Les documents compagnons de chaque fichier se trouvent dans [mockups/](mockups/).* diff --git a/docs/design/mockups-explained.md b/docs/design/mockups-explained.md new file mode 100644 index 0000000..91211ab --- /dev/null +++ b/docs/design/mockups-explained.md @@ -0,0 +1,621 @@ +# learn-dev mockups explained: register.html and home.html + +An annotated walkthrough of two mockup files +([mockups/register.html](mockups/register.html) and +[mockups/home.html](mockups/home.html)) +and the CSS that styles them ([mockups/css/base.css](mockups/css/base.css) + +[mockups/css/theme-catppuccin.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](mockups-explained-fr.md). +> Keep the two files in sync. + +> Files chosen because together they exercise the whole system: +> `register.html` is the accessibility showcase (forms, errors, ARIA); +> `home.html` is the layout showcase (grids, decorative content, components). + +--- + +## Part 1: HTML structure + +### 1.1 The document shell (both pages) + +```html +<!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. + +```html +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Create an account — learn-dev +``` + +- **`charset`** first, so the parser never mis-decodes bytes. +- **`viewport`** makes 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). +- **``** 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). + +```html +<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"> +``` + +- `preconnect` opens 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.css` consumes them. Loading a + different theme file swaps every color with zero change to `base.css`. + +### 1.2 The skip link (both pages) + +```html +<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. + +### 1.3 Landmarks: header, nav, main, footer (both pages) + +```html +<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 (roles `banner`, + `navigation "Main"`, `main`, `contentinfo` all 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). + +### 1.4 Navigation list and the current page (both pages) + +```html +<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). + +### 1.5 Headings hierarchy + +`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 `H` key) 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). + +### 1.6 The registration form (register.html), field by field + +```html +<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. + +```html +<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). + +```html +<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. + +```html +<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 `#email` is exactly "Email already registered". +- The visual channel is redundant with the programmatic one: red border + (`--invalid` modifier 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). + +```html +<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 the `button` + role natively. No ARIA needed — the first rule of ARIA is to prefer + native elements. + +### 1.7 The decorative code card (home.html) + +```html +<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 a `code { 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". + +### 1.8 The feature cards as a list (home.html) + +```html +<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. + +--- + +## Part 2: Layout and CSS + +### 2.1 Naming convention: BEM + +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__error` tells you exactly where it lives; + `button--ghost` tells you it is a variant of `.button`. +- **Grep-friendly**: searching `feature-card` finds the whole component. +- IDs are **never used for styling** (only for `label for`, fragment + targets like `#main`, and `aria-describedby` plumbing). An ID selector + (`#main`, specificity `(1,0,0)`) would crush every class rule and break + the flat model. + +### 2.2 Design tokens (theme file + structural tokens) + +```css +:root { + --color-bg: #eff1f5; + --color-text: #4c4f69; + --color-primary: #8839ef; + ... +} +@media (prefers-color-scheme: dark) { + :root { --color-bg: #1e1e2e; ... } +} +``` + +- **`:root`** is 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 with `var(--color-*)` in + `base.css`. No component ever contains a hex value, so: + 1. swapping the theme `<link>` re-skins the entire site; + 2. dark mode is one `@media (prefers-color-scheme: dark)` block that + reassigns the same token names — components are untouched. +- **`color-scheme: light dark`** tells the browser both schemes are + supported, so native widgets (inputs, scrollbars) follow along. +- Structural tokens in `base.css` (`--space-1..6` on 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%). + +### 2.3 Reset and typography + +```css +*, *::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-box` makes `width` + include padding and border: the intuitive box model (`width: 100%` plus + padding no longer overflows — exactly what `.form__input` relies on). + +```css +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.6`** is a multiplier that inherits correctly + at any font size (a unit value like `px` would 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. + +```css +: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** writes + `outline: none`. **RGAA 10.7** (focus visible). + +```css +@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 `!important` in the project: + justified because this preference must beat *any* other rule. + **RGAA 13.x** family (contenu en mouvement controle par l'utilisateur). + +### 2.4 The skip link mechanics + +```css +.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: none` would remove it from the tab order and defeat the point. +- On focus, `top: 0` brings it into view; the compound selector + `.skip-link:focus-visible` has 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. + +### 2.5 Header and navigation + +```css +.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 `__inner` element + 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: center` vertically centers brand and nav; + `margin-left: auto` on the nav absorbs all free space, pushing the nav to + the right without floats or positioning; `flex-wrap` lets the nav drop to + a second line on narrow screens instead of overflowing. + +```css +.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. + +### 2.6 Buttons + +```css +.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. + +### 2.7 The hero grid and the code card (home.html) + +```css +.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 `fr` units 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). + +```css +.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's + `aria-hidden`). +- `position: relative` on 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). + +```css +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--space-4); +} +``` + +- The **`auto-fit` + `minmax` idiom**: 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**. + +### 2.8 The form (register.html) + +```css +.site-main--narrow { max-width: var(--form-width); } /* 26rem column */ +``` + +- A modifier on the `main` block 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. + +```css +.form__input { + width: 100%; + font: inherit; + border: 1px solid var(--color-border); + background: var(--color-bg); +} +``` + +- **`font: inherit`** is 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 global `border-box`. + +```css +.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__input` and `.form__input--invalid` tie at `(0,1,0)`; the modifier + wins `border-color` by source order. But + `.form__input:focus-visible` is `(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 and `aria-invalid`. +- The error text is bold *and* red *and* programmatically linked: three + channels (RGAA 3.1). + +```css +.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 solid` **without a color**: CSS then + uses `currentColor` for the border, and each modifier only needs to set + `color` + `border-color`... in fact setting `color` alone would suffice + for the border thanks to currentColor; the explicit `border-color` keeps + the intent readable. Every variant's text passes 4.5:1 on the page + background (see the theme exploration tables). + +### 2.9 The reveal animation + +```css +@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` (the `both` keyword) applies the `from` state + before the animation starts (no flash of final state) and holds the `to` + state after. +- 10px of travel and 500ms: perceptible, not theatrical. And the whole + thing is erased by the `prefers-reduced-motion` block for users who need + that (the page is fully usable with zero animation). + +--- + +## RGAA quick map (what an auditor would check on these two pages) + +| 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](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|email|new-password"` | +| 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](mockups/css/base.css) and +[mockups/css/theme-catppuccin.css](mockups/css/theme-catppuccin.css); +contrast figures come from [theme-exploration.md](theme-exploration.md). +Per-file companion docs live in [mockups/](mockups/).* diff --git a/docs/design/mockups/base-css-explained.md b/docs/design/mockups/base-css-explained.md new file mode 100644 index 0000000..4910140 --- /dev/null +++ b/docs/design/mockups/base-css-explained.md @@ -0,0 +1,1459 @@ +# `base.css` explained, rule by rule + +An educational walkthrough of `css/base.css`, the theme-independent +stylesheet of the learn-dev mockups. It is written for a developer preparing +the French DWWM (Developpeur Web et Web Mobile) certification: every rule is +quoted, every declaration is explained (what it does and why that value was +chosen), and the interactions between rules (cascade, specificity, source +order) are called out explicitly, because that is exactly what an examiner +will probe. + +Companion documents: + +- [theme-catppuccin-css-explained.md](theme-catppuccin-css-explained.md) + (default theme, plus the explanation of the token architecture) +- [theme-soft-paper-css-explained.md](theme-soft-paper-css-explained.md) + (alternate theme) + +`base.css` never hardcodes a color. Every `var(--color-*)` and +`var(--code-*)` it consumes is defined by whichever theme stylesheet is +linked in the page head. `base.css` itself only defines the **structural** +tokens: fonts, sizes, spacing, radii, shadow, layout widths. + +--- + +## Primer: the BEM naming convention + +Every component class in this file follows **BEM** +(Block, Element, Modifier), with the `block__element--modifier` syntax: + +- **Block**: a standalone component, e.g. `.button`, `.form-card`, + `.site-header`. The name describes purpose, not appearance. +- **Element**: a part that only makes sense inside its block, joined with + a double underscore: `.site-header__brand`, `.form__input`, + `.stat-card__value`. +- **Modifier**: a variant of a block or element, joined with a double + hyphen: `.button--primary`, `.site-main--narrow`, + `.form__input--invalid`. + +Why BEM matters for the cascade: **every selector is a single class**, so +almost every rule has specificity `(0,1,0)`. Nothing wins by being "more +specific"; variants win by **source order** (the modifier rule is written +after the base rule) or by an added pseudo-class/attribute. That makes the +stylesheet predictable: to know which rule applies, you mostly just read +top to bottom. + +### Reading specificity: the `(a,b,c)` triple + +Throughout this document, specificity is noted as `(a,b,c)`: + +- `a` counts ID selectors (`#foo`), never used in this file; +- `b` counts classes (`.foo`), attribute selectors (`[aria-current]`), + and pseudo-classes (`:hover`, `:focus-visible`); +- `c` counts element types (`body`, `h1`) and pseudo-elements + (`::before`). + +Higher `a` beats any `b`; higher `b` beats any `c`. On a tie, the **later +rule in source order** wins. `!important` sits outside this system and +beats normal declarations regardless of specificity (used exactly once in +this file, in the reduced-motion block, see below). + +### `em` vs `rem`, once and for all + +Both units appear in this file, deliberately: + +- **`rem`** is relative to the **root** (`<html>`) font size. Used for the + type scale, spacing scale and layout widths, so that a user who raises + their browser's default font size scales the *whole page* consistently. + This is an accessibility requirement in practice: `px` media queries and + `px` font sizes ignore that user preference. +- **`em`** is relative to the **current element's own font size**. Used + where a measurement should follow the text it decorates: button padding + (`0.65em 1.4em` grows with the button label), `code { font-size: 0.9em }` + (always 90% of the surrounding prose, whatever its size), + `text-underline-offset: 0.2em` (the underline gap scales with the link + text). + +Rule of thumb: `rem` for page-level rhythm, `em` for element-local, +text-relative measurements. + +--- + +## Section: Structural tokens + +### `:root` (structural custom properties) + +```css +:root { + --font-display: "Sora", ui-sans-serif, sans-serif; + --font-body: "Atkinson Hyperlegible", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-2xl: 1.75rem; + --font-size-3xl: 2.5rem; + --line-height: 1.6; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2rem; + --space-6: 3rem; + + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 14px; + --shadow: 0 1px 3px rgb(0 0 0 / 0.08), 0 4px 14px rgb(0 0 0 / 0.06); + + --content-width: 64rem; + --form-width: 26rem; +} +``` + +- `:root` is a pseudo-class matching the document root (`<html>`), with + specificity `(0,1,0)`. Custom properties declared here are **inherited by + every element**, which is what makes them usable as global design tokens. +- **Font stacks**: each token names a webfont first, then fallbacks. + - `--font-display` (headings, buttons, big numbers): "Sora", a geometric + display face; falls back to `ui-sans-serif` (the platform UI font) + then generic `sans-serif`. + - `--font-body`: "Atkinson Hyperlegible", a font designed by the Braille + Institute specifically for low-vision readability (distinct letterforms + for I/l/1, b/d, etc.), a deliberate accessibility choice for a learning + platform; `system-ui` is an extra fallback. + - `--font-mono`: "JetBrains Mono" for code, then `ui-monospace` + (SF Mono, Cascadia...) and generic `monospace`. +- **Type scale** in `rem` (see the em/rem primer above): 0.875 / 1 / 1.125 / + 1.375 / 1.75 / 2.5 rem, i.e. 14 / 16 / 18 / 22 / 28 / 40 px at the default + 16 px root. A small modular scale: each step is visibly distinct without + hardcoding pixel values, and everything grows if the user raises the + default font size. +- `--line-height: 1.6` is **unitless on purpose**. A unitless line-height + is a *factor* recomputed against each element's own font size when + inherited. If you wrote `1.6em` or `160%`, the *computed pixel value* of + the body would be inherited, so a large heading would get the body's + line height in pixels and its lines would overlap. Unitless is the only + safe inheritable form. 1.6 (rather than the browser default of roughly + 1.2) is a readability choice for long-form learning content and matches + WCAG 1.4.12 (text spacing) comfort levels. +- **Spacing scale** `--space-1` through `--space-6`: 0.25 to 3 rem + (4 to 48 px at default size). Every margin/padding/gap in the file uses + one of these six values, which produces a consistent vertical rhythm and + makes redesign a one-line change. +- **Radii** in `px`, not `rem`: corner rounding is a decorative constant + that should *not* grow with the user's font size (a 4 px curve is a + 4 px curve). Three sizes: `sm` for small chips/focus rings, default for + buttons/inputs, `lg` for cards. +- `--shadow` stacks **two layers**: a tight one (`0 1px 3px`, 8% black) + simulating contact, and a wide soft one (`0 4px 14px`, 6% black) + simulating ambient light. Layered shadows look softer and more realistic + than a single big blur. Note the modern space-separated + `rgb(0 0 0 / 0.08)` alpha syntax. +- `--content-width: 64rem` (1024 px at default) caps line length for + readability; `--form-width: 26rem` (416 px) is a much narrower cap for + authentication forms, where a full-width input would be hostile. + +--- + +## Section: Reset-lite and typography + +### Universal `box-sizing` + +```css +*, +*::before, +*::after { + box-sizing: border-box; +} +``` + +- The classic minimal reset. `border-box` makes `width`/`height` include + padding and border, so `.form__input { width: 100%; padding: ... }` + never overflows its container. Without it, `width: 100%` plus padding + would exceed 100%. +- The selector list explicitly includes `::before`/`::after` because the + universal selector `*` does **not** match pseudo-elements. +- Specificity `(0,0,0)`: `*` counts for nothing, so any other rule can + override this trivially. That is exactly what you want from a reset. + +### `body` + +```css +body { + margin: 0; + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: var(--line-height); + color: var(--color-text); + background: var(--color-bg); +} +``` + +- `margin: 0` removes the browser's default 8 px body margin (the header + and footer must reach the viewport edges). +- Typography is set once here and **inherited** by everything: font + family, base size, the unitless line height. +- `color` and `background` are the first two **theme tokens** consumed: + the page ink and paper. Setting both together is also an accessibility + habit: never set a text color without controlling what it sits on. + +### Headings + +```css +h1, +h2, +h3 { + font-family: var(--font-display); + line-height: 1.2; + margin: 0 0 var(--space-3); + letter-spacing: -0.015em; +} +``` + +- Only three heading levels are styled because the mockups use only three; + a real design system would go to `h6`. +- `--font-display` switches headings to Sora, creating the display/body + font pairing. +- `line-height: 1.2` overrides the inherited 1.6: large text needs tighter + leading or multi-line headings fall apart visually. Again unitless. +- `margin: 0 0 var(--space-3)` zeroes the browser's default top margin and + keeps only a bottom margin, a "spacing flows downward" convention that + avoids adjacent-margin surprises. +- `letter-spacing: -0.015em`: large display type looks better slightly + tightened; in `em` so the tracking scales with the heading size. + +### Paragraphs + +```css +p { + margin: 0 0 var(--space-3); +} +``` + +- Same downward-only margin policy as headings, using the same spacing + token, so headings and paragraphs share one vertical rhythm. + +### Links + +```css +a { + color: var(--color-link); + text-underline-offset: 0.2em; +} + +a:hover { + text-decoration-thickness: 2px; +} +``` + +- `--color-link` is a dedicated theme token (distinct from + `--color-primary`), guaranteed by both themes to pass the 4.5:1 contrast + ratio on the page background. +- The default underline is **kept** (WCAG 1.4.1: color must not be the + only cue that something is a link); `text-underline-offset: 0.2em` + only pushes it below the descenders for legibility, scaling with the + text (`em`). +- On hover the underline gets thicker instead of appearing/disappearing: + a state change with no layout shift. `a:hover` is `(0,1,1)` vs `a` at + `(0,0,1)`, so it wins on specificity, not just order. + +### Images + +```css +img { + max-width: 100%; +} +``` + +- The standard fluid-image rule: an image can never overflow its + container, mandatory for responsive layouts. `max-width` (not `width`) + so small images keep their natural size. + +### Inline code + +```css +code { + font-family: var(--font-mono); + font-size: 0.9em; +} +``` + +- Switches to the mono stack. `0.9em` (element-relative, see the primer) + compensates for monospace fonts looking optically larger than the + surrounding proportional text, at every size where `code` appears. + +### Global focus indicator + +```css +/* Visible focus, never removed (WCAG 2.4.7) */ +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; + border-radius: var(--radius-sm); +} +``` + +- WCAG 2.4.7 requires a visible keyboard focus indicator. This rule styles + it instead of the anti-pattern `outline: none`. +- `:focus-visible` (not `:focus`) lets the browser show the ring for + keyboard/assistive navigation but skip it after a mouse click, removing + the historical motivation for deleting outlines. +- `outline` (not `border`) because outlines take **no layout space**: + focusing an element never shifts the page. +- `outline-offset: 2px` puts a gap between the element and the ring so the + ring stays visible whatever the element's own background. +- `border-radius` rounds the ring itself (modern browsers make the outline + follow the radius). +- `--color-focus` is a theme token chosen to meet the WCAG 3:1 contrast + requirement for UI components. +- Specificity `(0,1,0)`. Keep this number in mind: the form input defines + its own `.form__input:focus-visible` at `(0,2,0)` which overrides this + rule (covered below). + +### Reduced motion kill switch + +```css +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} +``` + +- `prefers-reduced-motion: reduce` is set by users whose OS accessibility + settings ask for less motion (vestibular disorders, distraction). +- The selector is the weakest possible, `(0,0,0)`, so it could never win + against `.reveal { animation: ... }` at `(0,1,0)` on its own. That is + why **`!important` is legitimate here**: it is a user-preference + override that must beat every animation and transition in the file + (`.skip-link`'s `transition: top`, `.button`'s transform transition, + the `.reveal` animation) and any future ones, without having to know + about them. This is the textbook justified use of `!important`. +- Because `.reveal` uses `animation: rise-in 500ms ease both` (the `both` + fill mode applies the `to` keyframe), cancelling the animation leaves + elements in their normal, fully visible state: content is never lost, + only the movement. + +--- + +## Section: Utilities + +### `.visually-hidden` + +```css +/* Visually hidden but available to assistive technology */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} +``` + +- The standard "screen-reader only" utility. `display: none` or + `visibility: hidden` would hide content from assistive technology too; + this recipe hides it *visually only*. +- Each declaration plays a role in the trick: + - `position: absolute` removes it from the flow (no layout gap); + - `width/height: 1px` shrinks it to one pixel (a 0x0 box may be skipped + by some screen readers, 1x1 is the safe historical choice); + - `clip-path: inset(50%)` clips even that pixel invisible (modern + replacement for the deprecated `clip: rect(...)`); + - `overflow: hidden` prevents content spilling out of the 1 px box; + - `padding: 0; border: 0` remove anything that would enlarge the box; + - `margin: -1px` pulls the 1 px box out of any layout interaction; + - `white-space: nowrap` prevents word-per-line wrapping, which would + make some screen readers announce words with pauses between them. +- Used in the mockups for labels and headings that sighted users infer + from layout but screen-reader users need announced. + +--- + +## Section: Skip link + +### `.skip-link` and its focus state + +```css +.skip-link { + position: absolute; + left: var(--space-3); + top: -3rem; + z-index: 10; + padding: var(--space-2) var(--space-3); + background: var(--color-primary); + color: var(--color-on-primary); + border-radius: 0 0 var(--radius) var(--radius); + text-decoration: none; + transition: top 150ms ease; +} + +.skip-link:focus-visible { + top: 0; +} +``` + +- A skip link is the first focusable element of the page; it jumps + keyboard users straight to `<main>` (WCAG 2.4.1, bypass blocks) so they + need not tab through the whole navigation on every page. +- The hiding technique is the interesting part: the link is positioned + **absolutely** and parked at `top: -3rem`, i.e. fully *above* the + viewport. Unlike `display: none`, an off-screen element **stays + focusable**, which is the whole point. +- Declaration by declaration: + - `position: absolute` takes it out of the flow so it never leaves a + gap at the top of the page; + - `left: var(--space-3)` aligns it near the top-left corner, where + keyboard users expect it; + - `top: -3rem` hides it (the value just needs to exceed the link's own + height; in `rem` so it scales with the user's font size and can never + half-show a taller-than-expected link); + - `z-index: 10` keeps it above the header when it slides in; + - `padding` gives it a comfortable touch/click target; + - `background: var(--color-primary)` with `color: var(--color-on-primary)` + is the themed high-contrast pair reserved for primary surfaces; + - `border-radius: 0 0 var(--radius) var(--radius)` rounds **only the + bottom corners** (the four-value corner order is top-left, top-right, + bottom-right, bottom-left), so the link looks like a tab sliding down + from the viewport edge; + - `text-decoration: none` drops the underline: here the button-like + styling carries the affordance; + - `transition: top 150ms ease` animates the slide (and is neutralized + by the reduced-motion block above, where the link then appears + instantly). +- **Interaction**: `.skip-link:focus-visible { top: 0 }` has specificity + `(0,2,0)` (one class + one pseudo-class), beating `.skip-link` at + `(0,1,0)`. When the link receives keyboard focus, only `top` changes, + and the transition declared on the *base* rule animates it in. Putting + the transition on the base rule (not the focus rule) means it also + animates back out on blur. +- The global `:focus-visible` outline still applies on top of this rule + (different properties, no conflict): the skip link gets both the slide + and the focus ring. + +--- + +## Section: Site header + +### `.site-header` + +```css +.site-header { + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} +``` + +- The `<header>` landmark's skin. `--color-surface` is the theme's "one + step off the background" tone; the 1 px `--color-border` bottom edge + separates it from the page without a heavy shadow. +- Note what is *absent*: no width or padding. The header background spans + the full viewport; the content is constrained by the inner wrapper + below. This two-element pattern (full-bleed background, capped content) + is used identically by the footer. + +### `.site-header__inner`: the flex header + +```css +.site-header__inner { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +``` + +- **`max-width` + `margin: 0 auto`** is the canonical centering idiom for + block content: the box is capped at `--content-width` (64rem) and the + two `auto` horizontal margins split the leftover space equally, so the + content column is centered at any viewport wider than the cap, and + simply full-width (minus padding) below it. No media query needed. +- `padding: var(--space-3) var(--space-4)` (1rem vertical, 1.5rem + horizontal): the horizontal padding is what keeps content off the + screen edge on small viewports. +- `display: flex` lays out brand and nav on one row; `align-items: + center` aligns them on the cross axis regardless of their different + heights; `gap: var(--space-4)` spaces them without margins. +- `flex-wrap: wrap` is the cheap responsive strategy: on a narrow screen + the nav wraps to a second line instead of overflowing. Combined with + the `margin-left: auto` trick below, this gives a usable mobile header + with zero media queries. + +### `.site-header__brand` and `.site-header__brand-mark` + +```css +.site-header__brand { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-text); + text-decoration: none; +} + +.site-header__brand-mark { + color: var(--color-primary); +} +``` + +- The brand is an `<a>` to the home page, styled as a wordmark: display + font, bold, slightly larger, and crucially `color: var(--color-text)` + which **overrides the `a { color: var(--color-link) }` element rule**: + `(0,1,0)` class beats `(0,0,1)` element, whatever the source order. + `text-decoration: none` drops the underline (acceptable because a + masthead brand is a well-understood convention). +- `__brand-mark` is a `<span>` wrapping part of the logo text and painting + it in the primary color: a one-token accent that changes with the theme. + +### `.site-header__nav`: the `margin-left: auto` idiom + +```css +.site-header__nav { + margin-left: auto; +} +``` + +- The deep idiom of this header. In a flex container, an **`auto` margin + absorbs all the free space on that side** before `justify-content` even + gets a say. Putting `margin-left: auto` on the nav pushes it to the + right edge while the brand stays left: the classic + "logo left, nav right" layout in one declaration, with no extra wrapper + and no `justify-content: space-between` (which would also spread any + future third child in ways you might not want). +- When `flex-wrap` moves the nav to its own line, the same `auto` margin + then right-aligns it on that line, still sensible. + +### `.nav__list` + +```css +.nav__list { + list-style: none; + display: flex; + gap: var(--space-3); + margin: 0; + padding: 0; + align-items: center; +} +``` + +- The nav is a real `<ul>` (screen readers announce "list, N items", + useful information), visually flattened: `list-style: none` removes + bullets, `margin: 0; padding: 0` remove the default indentation. +- `display: flex` + `gap` lays the items horizontally with even spacing; + `align-items: center` keeps links and the nav button (if any) on one + midline. +- Note `nav` is its own BEM block (`.nav__list`, `.nav__link`), not an + element of `site-header`: it could be reused elsewhere (a footer nav) + without renaming. + +### `.nav__link`, hover, and current page + +```css +.nav__link { + text-decoration: none; + color: var(--color-text); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); +} + +.nav__link:hover { + color: var(--color-primary); + text-decoration: underline; +} + +.nav__link[aria-current="page"] { + font-weight: 700; + color: var(--color-primary); + box-shadow: inset 0 -2px 0 var(--color-primary); +} +``` + +- Base state: text-colored (overrides the `a` element rule again, + `(0,1,0)` vs `(0,0,1)`), no underline; the padding enlarges the + clickable/tappable target beyond the text itself, and the small radius + shapes the focus outline around that padded box. +- Hover restores an underline *and* recolors: two cues, not just color. + `.nav__link:hover` is `(0,2,0)`, beating the base rule. +- The current page is marked in the HTML with `aria-current="page"`, the + correct accessible way to say "you are here" (screen readers announce + it). The CSS **selects on the ARIA attribute itself**, + `.nav__link[aria-current="page"]` at `(0,2,0)`: styling and semantics + cannot drift apart, because the style only exists if the attribute is + set. +- The "underline" for the current page is `box-shadow: inset 0 -2px 0`, + an inset shadow with no blur, offset 2 px upward from the bottom edge: + a 2 px bar inside the padded box. Compared to `border-bottom` it takes + no layout space, and compared to `text-decoration` it spans the full + padded width and stays clear of descenders. +- Since `:hover` and `[aria-current="page"]` rules tie at `(0,2,0)`, + hovering the current link applies **both**; the identical + `color: var(--color-primary)` makes any override moot, and the two + effects (underline + bar) simply combine. + +--- + +## Section: Layout + +### `.site-main` and `.site-main--narrow` + +```css +.site-main { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-6); +} + +.site-main--narrow { + max-width: var(--form-width); +} +``` + +- The `<main>` landmark gets the same `max-width` + `margin: 0 auto` + centering idiom as the header inner (see above). Three-value padding: + 2rem top, 1.5rem sides, 3rem bottom (more air before the footer). +- `--narrow` is a BEM modifier used on the login/register pages: it only + swaps the cap from 64rem to `--form-width` (26rem). Both classes sit on + the same element (`class="site-main site-main--narrow"`); both rules are + `(0,1,0)`, so the modifier wins `max-width` purely by **source order**, + and inherits everything else from the base rule. This is the same + pattern as `.button--primary` below. + +### `.site-footer` and `.site-footer__inner` + +```css +.site-footer { + border-top: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.site-footer__inner { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-4); + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + justify-content: space-between; +} +``` + +- Mirror of the header: full-bleed surface with a top border, capped and + centered inner wrapper. Footer text is demoted to muted color and small + size (it is metadata, not content); both themes still guarantee the + muted color passes 4.5:1. +- The inner flex row uses `justify-content: space-between` (copyright + left, links right) rather than the `margin-left: auto` trick: with + exactly two children the two approaches are equivalent, and + `space-between` reads naturally here. `flex-wrap: wrap` again handles + narrow screens by stacking. + +--- + +## Section: Buttons + +### `.button` (base block) + +```css +.button { + display: inline-block; + font-family: var(--font-display); + font-size: var(--font-size-base); + font-weight: 600; + padding: 0.65em 1.4em; + border-radius: var(--radius); + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: transform 120ms ease, box-shadow 120ms ease; +} +``` + +- One class shared by `<button>` elements and link-buttons (`<a + class="button ...">`), hence declarations that only matter for one of + the two: `text-decoration: none` neutralizes the link underline, + `display: inline-block`, `font-family`/`font-size` and `cursor: pointer` + normalize the `<a>` against the `<button>` (buttons do not inherit page + fonts by default; links do not get the pointer cursor by default). +- `padding: 0.65em 1.4em` in **`em`**: the hit area scales with the + button's own font size, so a future large CTA stays proportioned + (see the em/rem primer). +- `border: 1px solid transparent` is a subtle trick: the base button + reserves the border's 1 px of space even when invisible, so + `.button--ghost`, which colors the border, is **exactly the same size** + as `.button--primary`. Without it, adding a border in the modifier + would make ghost buttons 2 px larger and misalign rows of buttons. +- The `transition` names exactly the two properties the hover state + changes (transform, box-shadow); transitioning `all` would be wasteful + and can animate surprises. + +### `.button:hover` + +```css +.button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} +``` + +- The hover feedback: a 1 px lift plus the token shadow, reading as the + button rising toward the cursor. `transform` and `box-shadow` are both + **compositor-friendly** properties: animating them does not trigger + layout or repaint of surrounding content (animating `margin-top` + instead would reflow the page). +- `(0,2,0)`, wins over any single-class rule. Disabled by the + reduced-motion block only in its *transition* (the state still changes, + just instantly). + +### `.button--primary` + +```css +.button--primary { + background: var(--color-primary); + color: var(--color-on-primary); +} +``` + +- The filled call-to-action variant. `--color-on-primary` exists as its + own token because "text on a primary surface" needs its own contrast + guarantee (both themes document ratios above 4.5:1 for this pair). +- **Source-order dependency, worth memorizing**: `.button` and + `.button--primary` are both `(0,1,0)`. On an element carrying both + classes, the modifier's `background` and `color` win **only because + this rule appears later in the file**. If someone reordered the file, + primary buttons would silently lose their fill. This is the BEM + convention's one discipline: base block first, modifiers after. + +### `.button--ghost` and its hover + +```css +.button--ghost { + background: transparent; + color: var(--color-text); + border-color: var(--color-border); +} + +.button--ghost:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} +``` + +- The secondary, outlined variant: transparent fill, text-colored label, + and it colors the border that `.button` already reserved as transparent + (only `border-color` needs setting, width and style are inherited from + the base shorthand). +- `.button--ghost:hover` at `(0,2,0)` recolors border and label; it + **combines** with `.button:hover` (also `(0,2,0)`, different + properties): a hovered ghost button lifts, casts a shadow, *and* turns + primary-colored. Same-specificity rules only compete on the properties + they share. + +--- + +## Section: Hero (home) + +### `.hero`: the `fr` grid + +```css +.hero { + display: grid; + gap: var(--space-5); + align-items: center; + grid-template-columns: 1.1fr 0.9fr; + padding: var(--space-6) 0; +} + +@media (max-width: 46rem) { + .hero { + grid-template-columns: 1fr; + } +} +``` + +- A two-column grid: text on the left, decorative code card on the right. +- **The `fr` unit** distributes the container's *free* space by ratio. + `1.1fr 0.9fr` gives the text column 1.1 / (1.1 + 0.9) = 55% and the + card 45%. Writing it as `1.1fr 0.9fr` rather than `55% 45%` matters: + `fr` shares the space **left after the `gap` is subtracted**, whereas + percentages ignore the gap and would overflow by exactly the gap width. + The slight asymmetry gives the headline room to breathe while keeping + the card prominent. +- `align-items: center` vertically centers the shorter column against the + taller one; `padding: var(--space-6) 0` adds generous vertical air + (horizontal padding is already provided by `.site-main`). +- **The 46rem breakpoint**: below roughly 736 px the grid collapses to a + single `1fr` column (text above card). Two things are worth defending + in an exam: + - the breakpoint is in **`rem`**, so a user with a larger default font + size hits the single-column layout sooner, exactly when the + two-column layout would actually run out of room for their bigger + text. A `px` breakpoint ignores that user setting; + - the value is **content-driven** (the point where two columns get + cramped), not a device name ("tablet"). Media queries at `(0,1,0)` + do not add specificity; this override wins because the `@media` block + appears **after** the base `.hero` rule, same specificity, later + source order. + +### `.hero__title`, `.hero__title-accent`, `.hero__lead`, `.hero__actions` + +```css +.hero__title { + font-size: var(--font-size-3xl); +} + +.hero__title-accent { + color: var(--color-primary); +} + +.hero__lead { + font-size: var(--font-size-lg); + color: var(--color-text-muted); + max-width: 34rem; +} + +.hero__actions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + margin-top: var(--space-4); +} +``` + +- `__title` just picks the largest step of the type scale; family, + tightened line height and letter spacing come from the `h1` element + rule (the class rule and element rule style **different properties**, + so they compose rather than compete). +- `__title-accent` is a `<span>` painting key words in the theme primary, + same one-token accent technique as the brand mark. +- `__lead` is the standfirst: one step up from body size, muted color, + and `max-width: 34rem` caps the line length at roughly 65 characters, + the classic readability measure, independent of the grid column width. +- `__actions` is a small flex row for the two CTA buttons: token gap + instead of margins between buttons, and `flex-wrap` so the pair stacks + on very narrow screens instead of overflowing. + +### `.code-card`: the decorative signature element + +```css +/* Decorative code card: signature element, hidden from AT */ +.code-card { + background: var(--code-bg); + color: var(--code-text); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + border-radius: var(--radius-lg); + padding: var(--space-4); + box-shadow: var(--shadow); + rotate: 1.5deg; + position: relative; +} +``` + +- A fake code-editor window in the hero. In the HTML it carries + `aria-hidden="true"`: it is pure eye candy, so screen readers skip it + entirely; that is also why its colors are allowed to come from the + decorative `--code-*` token family, which carries **no contrast + guarantee**. +- `--code-bg`/`--code-text` keep an editor-dark look in both themes and + both color schemes; mono font and small size complete the illusion. +- `rotate: 1.5deg` is the modern **individual transform property** + (equivalent to `transform: rotate(1.5deg)` but composable without + overwriting other transforms): the slight tilt makes the card read as a + casually placed object. +- `position: relative` is not decoration: it establishes the **containing + block** for the absolutely positioned `::before` traffic lights that + follow. Without it, the dots would position themselves against the page. + +### `.code-card::before`: traffic lights with one box-shadow + +```css +.code-card::before { + content: ""; + position: absolute; + inset: var(--space-3) auto auto var(--space-4); + 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 macOS window "traffic lights" (red, yellow, green), drawn **without + any HTML**: perfect pseudo-element material, since they are pure + decoration. +- `content: ""` is mandatory or the pseudo-element does not render at all. +- `inset: var(--space-3) auto auto var(--space-4)` is the four-value + shorthand for `top right bottom left`: top 1rem, left 1.5rem, the two + `auto` values leaving right/bottom unconstrained. One line instead of + four properties. +- A 10 px square with `border-radius: 50%` becomes the first dot, filled + with `--color-error` (red). +- **The trick**: the other two dots are *box shadows* of the first. + A box shadow with zero blur and zero spread (`18px 0 0`) is an exact, + sharp **copy of the element's shape**, offset 18 px right; the second + copy sits at 36 px. Each shadow takes its own color: yellow + (`--color-warning`) then green (`--color-success`). Three dots, one + element, zero extra markup. (10 px dot + 8 px gap = the 18 px step.) +- These are the semantic status colors used decoratively; harmless, since + the whole card is aria-hidden. + +### `.code-card__code` and the syntax color spans + +```css +.code-card__code { + display: block; + margin-top: var(--space-4); + white-space: pre; + overflow-x: auto; +} + +.code-card__keyword { color: var(--code-keyword); } +.code-card__string { color: var(--code-string); } +.code-card__function { color: var(--code-function); } +.code-card__comment { color: var(--code-comment); } +``` + +- The `<code>` element is inline by default; `display: block` lets it + take margins and scroll. `margin-top` clears the traffic-light row. +- `white-space: pre` preserves the code's indentation and line breaks + exactly as written in the HTML (the code is *not* wrapped in `<pre>`, + so CSS restores that behavior). +- `overflow-x: auto` adds a horizontal scrollbar only if a long line + overflows, instead of breaking the layout: the standard guard for + preformatted content on small screens. +- The four one-liner classes are hand-rolled syntax highlighting: `<span>` + elements in the markup pick keyword/string/function/comment colors from + the theme's decorative token family. Single-declaration rules formatted + on one line, an accepted readability exception. + +--- + +## Section: Feature cards (home) + +### `.features`: the auto-fit grid + +```css +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--space-4); + padding: 0; + margin: var(--space-5) 0 0; + list-style: none; +} +``` + +- The signature **responsive grid without media queries**, worth + unpacking term by term: + - `minmax(15rem, 1fr)`: each column may shrink to 15rem (240 px) but + no further, and may grow to an equal share (`1fr`) of the row; + - `auto-fit`: the browser creates **as many columns as fit** at their + minimum width, then collapses the empty tracks and lets the real + columns stretch to fill the row. + - Net effect: 3 columns on a desktop, 2 on a small laptop, 1 on a + phone, each transition happening exactly when the cards would drop + below a readable 15rem, with zero breakpoints to maintain. + - (The sibling keyword `auto-fill` would keep the empty tracks, leaving + cards at minimum width with a gap on the right; `auto-fit` is the one + you want when the *content* should fill the row.) + - The minimum is in `rem` for the same accessibility reason as the hero + breakpoint: bigger user font size means wider minimum, means fewer + columns, exactly when needed. +- The container is a `<ul>` (a list of features, announced as such), so + `list-style: none`, `padding: 0` and a top-only margin flatten the + default list styling, as with `.nav__list`. + +### `.feature-card` and its elements + +```css +.feature-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.feature-card__icon { + font-size: var(--font-size-2xl); + display: block; + margin-bottom: var(--space-2); +} + +.feature-card__title { + font-size: var(--font-size-lg); + margin-bottom: var(--space-2); +} + +.feature-card__text { + color: var(--color-text-muted); + margin: 0; +} +``` + +- `.feature-card` is the standard card recipe of this design system, + reused by `.stat-card` and `.course-card`: surface background, 1 px + border for definition in both light and dark schemes (where shadows + read poorly), large radius, token padding. Borders instead of shadows + for resting cards is deliberate; the shadow token is reserved for + elevation moments (hover, form card, code card). +- `__icon` is an emoji `<span>` (with `aria-hidden` in the markup): + `display: block` puts it on its own line and lets `margin-bottom` apply + (vertical margins do not affect inline elements). +- `__title` overrides only the size of the `h3` element rule; margin + bottom is tightened from the heading default (`--space-3`) to + `--space-2` for a compact card. `(0,1,0)` class beats `(0,0,1)` + element for the properties they share. +- `__text` is muted (supporting copy) and drops the paragraph's default + bottom margin so the card's own padding defines the bottom edge. + +--- + +## Section: Forms + +### `.form-card` and `.form-card__title` + +```css +.form-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow); +} + +.form-card__title { + font-size: var(--font-size-2xl); + margin-bottom: var(--space-4); +} +``` + +- The card around the login/register form: same recipe as + `.feature-card`, but with more padding and the token shadow. The shadow + singles out the form as *the* interactive object of an auth page (it + sits inside `.site-main--narrow`, so it is already a narrow centered + column). +- The title tunes the `h1` element rule: size from the scale, margin + opened to `--space-4` before the first field. + +### `.form__group`, `.form__label`, `.form__hint` + +```css +.form__group { + margin-bottom: var(--space-4); +} + +.form__label { + display: block; + font-weight: 700; + margin-bottom: var(--space-1); +} + +.form__hint { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-1); +} +``` + +- `__group` is the wrapper for one label + hint + input + error unit; + its only job is the vertical rhythm between fields. +- `__label`: `<label>` is inline by default; `display: block` stacks it + above its input (the accessible pattern, always visible, never a + placeholder-as-label) with a tight margin. Bold, full-contrast text: + labels are primary content. +- `__hint` is the format hint ("at least 12 characters..."), a block line + between label and input: small, muted. In the markup it carries an `id` + referenced by the input's `aria-describedby`, so screen readers read + the hint with the field; the CSS class and the ARIA wiring work + together but independently. + +### `.form__input` and `font: inherit` + +```css +.form__input { + width: 100%; + font: inherit; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.6em 0.8em; +} +``` + +- `width: 100%` fills the form column (safe thanks to the global + `border-box` sizing: padding and border are included in that 100%). +- **`font: inherit` is the load-bearing declaration.** Form controls are + the one place browsers do *not* inherit typography: user-agent + stylesheets give `<input>`, `<select>` and `<textarea>` their own font + family and size (often 13 px). The `font` **shorthand** resets family, + size, weight, style and line-height in one go to the surrounding page + values, so inputs match body text. Bonus: keeping input font size at + 1rem (16 px) prevents iOS Safari's automatic zoom-on-focus, which + triggers on inputs below 16 px. +- Ink and paper are re-asserted from tokens (`--color-text` on + `--color-bg`): the input deliberately uses the page *background* color + while sitting on the card's *surface* color, so the field reads as a + recessed well. The 1 px token border keeps it visible in dark mode. +- Padding in `em` so the comfortable hit area follows the (inherited) + font size. + +### `.form__input:focus-visible` vs the global focus rule + +```css +.form__input:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 1px; + border-color: var(--color-focus); +} +``` + +- **Interaction to know cold.** The global `:focus-visible` rule is + `(0,1,0)`; this rule is `(0,2,0)` (class + pseudo-class) and overrides + it for the properties both declare, on inputs only. It re-declares the + same 2 px outline but tightens `outline-offset` from 2 px to 1 px (a + floating ring 2 px away from a bordered box looks detached) and + *additionally* recolors the input's own border to the focus color, so + the focused field reads as a single strong ring rather than a border + plus a distant halo. +- A subtlety on `border-radius`: the global focus rule sets + `border-radius: var(--radius-sm)` and this input rule does not + re-declare it, yet the focused input keeps its 8 px radius. Why: the + base `.form__input` rule sets `border-radius: var(--radius)` at the + same `(0,1,0)` specificity as the global `:focus-visible` rule, and + `.form__input` comes **later in the file**, so it wins that one + property on the source-order tiebreak. The exam-worthy lesson: + overrides happen **per property**, never per rule. + +### `.form__input--invalid` and `.form__error` + +```css +.form__input--invalid { + border-color: var(--color-error); +} + +.form__error { + display: block; + color: var(--color-error); + font-size: var(--font-size-sm); + font-weight: 700; + margin-top: var(--space-1); +} +``` + +- The invalid modifier recolors only the border. Same `(0,1,0)` as + `.form__input`, later in source, so it wins `border-color`; and when + the invalid field is focused, `.form__input:focus-visible` at + `(0,2,0)` wins the border back to the focus color, an acceptable and + even desirable behavior (while you are fixing the field, the focus + state dominates). +- Color is never the only error signal (WCAG 1.4.1): the modifier is + paired in the markup with `aria-invalid="true"` and with a visible + `.form__error` message whose `id` is referenced by the input's + `aria-describedby`. The message is block-level, small but **bold** (a + second non-color cue), error-colored (a token guaranteed to pass 4.5:1 + by both themes). + +### `.form__footer` + +```css +.form__footer { + margin-top: var(--space-4); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} +``` + +- The "No account yet? Register" line under the submit button: separated + by a token margin, demoted to small muted text. The link inside it + still gets the `a` element rule's `--color-link`, keeping it + distinguishable and compliant. + +--- + +## Section: Alerts + +### `.alert` and its three modifiers + +```css +.alert { + border: 1px solid; + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + background: var(--color-surface); +} + +.alert--success { + color: var(--color-success); + border-color: var(--color-success); +} + +.alert--error { + color: var(--color-error); + border-color: var(--color-error); +} + +.alert--info { + color: var(--color-link); + border-color: var(--color-link); +} +``` + +- The flash-message component (used with `role="alert"` or `role="status"` + in the markup so screen readers announce it on arrival). +- The base rule declares `border: 1px solid` **without a color**, on + purpose: a border with no explicit color defaults to `currentColor`, + the element's text color. The modifiers each set `color` and + `border-color` together anyway, but the colorless shorthand means even + a modifier that only set `color` would get a matching border for free; + it also documents the intent (border always matches ink). +- Each modifier maps one semantic state to one theme token pair, text and + border in the same status color on the neutral surface background. All + three status tokens are contrast-checked at 4.5:1 by the themes, so + colored *text* (not just a colored stripe) stays legible. +- `--info` reuses `--color-link` rather than introducing a fourth status + token: informational blue and link blue are the same voice in this + design, one less token to keep compliant. +- Note there is no `.alert--warning` in the mockups; `--color-warning` + exists as a token but is consumed only by the code card's traffic + lights (see the theme documents). +- Modifier-after-base source order again: same `(0,1,0)` specificity, + later rule wins the shared properties. + +--- + +## Section: Dashboard + +### `.page-title` + +```css +.page-title { + font-size: var(--font-size-2xl); +} +``` + +- Dashboard pages use a quieter `h1` than the hero: one class overriding + only the size (the `h1` element rule supplies family, leading, + tracking, margin). Note the page hierarchy is still `h1`; only the + *visual* scale changes, semantics never bend to style. + +### `.stats` + +```css +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: var(--space-4); + margin: var(--space-4) 0 var(--space-5); + padding: 0; + list-style: none; +} +``` + +- Same `repeat(auto-fit, minmax(..., 1fr))` idiom as `.features` (see + there for the full unpacking), with a smaller 11rem minimum: stat cards + hold a number and a label, so they can pack four across where feature + cards fit three. +- Again a flattened `<ul>`; three-value margin gives space above and + below the row (`--space-4` top, `--space-5` bottom, 0 sides). + +### `.stat-card`, `.stat-card__value`, `.stat-card__label` + +```css +.stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.stat-card__value { + display: block; + font-family: var(--font-display); + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--color-primary); +} + +.stat-card__label { + color: var(--color-text-muted); +} +``` + +- The card shell is the same recipe as `.feature-card` (surface, border, + large radius, padding). +- `__value` is the big KPI number: display font at the largest scale + step, bold, primary-colored, and `display: block` so it stacks above + its label (the markup keeps the number first, label second, matching + reading order for screen readers). +- `__label` is the muted caption under it. + +### `.course-list` and `.course-card` + +```css +.course-list { + list-style: none; + margin: var(--space-4) 0 0; + padding: 0; + display: grid; + gap: var(--space-3); +} + +.course-card { + display: grid; + gap: var(--space-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} +``` + +- `.course-list` is a flattened `<ul>` again, but a **single-column** + grid: `display: grid` with no `grid-template-columns` defaults to one + column, used purely for its `gap` (uniform spacing between cards with + no first/last margin exceptions). A common modern idiom: grid as a + vertical stack with gap. +- `.course-card` does the same *inside* the card: title, meta line and + progress bar stack with a tight uniform `--space-2` gap, which is why + its child elements can zero their own margins. Shell styling is the + standard card recipe. + +### `.course-card__title`, `.course-card__meta`, `.course-card__progress` + +```css +.course-card__title { + font-size: var(--font-size-lg); + margin: 0; +} + +.course-card__meta { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin: 0; +} + +.course-card__progress { + accent-color: var(--color-primary); + width: 100%; + height: 0.6rem; +} +``` + +- Title and meta zero their element-rule margins (`h3` and `p` defaults + from the top of the file) because the card's grid `gap` now owns the + spacing: one source of truth instead of margin arithmetic. +- `__progress` styles a native `<progress>` element (which carries its + own accessible semantics: role, value, max, announced by screen + readers, no ARIA needed): + - **`accent-color`** is the modern one-line way to theme native widgets + (progress bars, checkboxes, radios, range sliders) without the old + forest of `::-webkit-progress-*` pseudo-elements; the browser keeps + rendering the control natively but fills it with the primary token; + - `width: 100%` stretches it to the card; `height: 0.6rem` slims the + default bar to an elegant strip, in `rem` so it scales with user + font size. + - Trade-off acknowledged: `accent-color` themes the *fill* but leaves + the track color to the browser; full track control would require the + vendor pseudo-elements this file deliberately avoids. + +--- + +## Section: Page-load reveal + +### `@keyframes rise-in` and `.reveal` + +```css +@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; } +.reveal--3 { animation-delay: 200ms; } +.reveal--4 { animation-delay: 300ms; } +``` + +- A CSS-only entrance: elements fade in while drifting up 10 px. +- `@keyframes` defines the named animation; `from`/`to` are aliases for + `0%`/`100%`. It uses `translate` (the individual transform property, + like `rotate` on the code card) and `opacity`: **both + compositor-animatable**, so the animation never causes layout work. +- `.reveal` applies it via the `animation` shorthand: name, 500ms + duration, `ease` timing, and the crucial **`both` fill mode**: + - *backwards* fill applies the `from` state during any + `animation-delay` (without it, a delayed element would flash fully + visible, then jump to invisible when its animation starts); + - *forwards* fill holds the `to` state after finishing. + - `both` = the two combined. With staggered delays, `both` (or at least + `backwards`) is not optional polish, it is what prevents the flash. +- **Staggering via `animation-delay`**: the modifiers `--2`, `--3`, `--4` + reuse the *same* keyframes and only offset the start by 100ms steps. + In the markup, successive blocks get `reveal`, `reveal reveal--2`, + `reveal reveal--3`..., producing a cascade where each section follows + the previous one. One animation definition, four entrance timings: + cheap to maintain, and the 100ms step is short enough that the page + feels alive, not slow. +- Modifier-after-base order matters once more (all `(0,1,0)`), though + here they set a property the base rule's shorthand only resets + implicitly: the `animation` shorthand sets `animation-delay: 0s`, so + the modifiers **must** come after `.reveal` or the shorthand would wipe + their delay. +- **Reduced motion**: the media block at the top of the file kills this + animation with `animation: none !important`. Thanks to fill mode + semantics, the elements then simply sit in their natural, fully + visible state: motion is progressive enhancement here, never a + gatekeeper to content. + +--- + +## Recap: rule interactions worth remembering + +| Interaction | Winner | Mechanism | +|---|---|---| +| `.button` vs `.button--primary` (background, color) | `.button--primary` | Tie at (0,1,0); modifier written later, source order wins | +| `.site-main` vs `.site-main--narrow` (max-width) | `.site-main--narrow` | Same tie-plus-source-order pattern | +| `.alert` vs `.alert--success/--error/--info` (color, border-color) | The modifier | Same pattern; base border falls back to `currentColor` | +| `.reveal` vs `.reveal--2/3/4` (animation-delay) | The modifier | Same pattern; the `animation` shorthand resets delay to 0s, so modifiers must follow it | +| `a { color }` vs `.site-header__brand`, `.nav__link` | The class | (0,1,0) beats (0,0,1), regardless of order | +| `h1/h3` element rules vs `.hero__title`, `.feature-card__title`, `.page-title` | Compose | Different properties mostly; where shared (font-size, margin), the class wins on specificity | +| `.skip-link` vs `.skip-link:focus-visible` (top) | The focus rule | (0,2,0) beats (0,1,0); transition on the base rule animates both directions | +| Global `:focus-visible` vs `.form__input:focus-visible` | The input rule | (0,2,0) beats (0,1,0), but only for the properties it declares; overrides are per property | +| `.nav__link` vs `:hover` vs `[aria-current="page"]` | Both pseudo/attribute rules | Each (0,2,0) beats the base; against each other they tie and merge (identical color) | +| `.form__input--invalid` vs `.form__input:focus-visible` (border-color) | The focus rule | (0,2,0) beats (0,1,0): while focused, focus color wins over error color | +| `.hero` base vs its `@media (max-width: 46rem)` override | The media rule | Media queries add no specificity; the block is later in source | +| Everything animated vs `prefers-reduced-motion` block | The media block | `(0,0,0)` selector but `!important`: user preference outranks all authors' animations | + +If one meta-lesson survives the exam: in a BEM stylesheet almost +everything is `(0,1,0)`, so **order is architecture**. Base blocks before +modifiers, base states before pseudo-class states, and the two deliberate +escapes from that flat world are the global reset at `(0,0,0)` and the +reduced-motion `!important`. diff --git a/docs/design/mockups/css/base.css b/docs/design/mockups/css/base.css new file mode 100644 index 0000000..27af86e --- /dev/null +++ b/docs/design/mockups/css/base.css @@ -0,0 +1,549 @@ +/* + learn-dev base stylesheet: structural tokens + BEM components. + Theme-independent: every color comes from the token names defined by the + active theme stylesheet (theme-catppuccin.css by default). + Conventions: BEM (block__element--modifier), 2-space indentation. + Accessibility: visible focus everywhere, reduced-motion respected, + form errors wired for aria-describedby, landmarks styled not created here. +*/ + +/* ---------- Structural tokens ---------- */ + +:root { + --font-display: "Sora", ui-sans-serif, sans-serif; + --font-body: "Atkinson Hyperlegible", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-2xl: 1.75rem; + --font-size-3xl: 2.5rem; + --line-height: 1.6; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2rem; + --space-6: 3rem; + + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 14px; + --shadow: 0 1px 3px rgb(0 0 0 / 0.08), 0 4px 14px rgb(0 0 0 / 0.06); + + --content-width: 64rem; + --form-width: 26rem; +} + +/* ---------- Reset-lite and typography ---------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: var(--line-height); + color: var(--color-text); + background: var(--color-bg); +} + +h1, +h2, +h3 { + font-family: var(--font-display); + line-height: 1.2; + margin: 0 0 var(--space-3); + letter-spacing: -0.015em; +} + +p { + margin: 0 0 var(--space-3); +} + +a { + color: var(--color-link); + text-underline-offset: 0.2em; +} + +a:hover { + text-decoration-thickness: 2px; +} + +img { + max-width: 100%; +} + +code { + font-family: var(--font-mono); + font-size: 0.9em; +} + +/* Visible focus, never removed (WCAG 2.4.7) */ +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + +/* ---------- Utilities ---------- */ + +/* Visually hidden but available to assistive technology */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* ---------- Skip link ---------- */ + +.skip-link { + position: absolute; + left: var(--space-3); + top: -3rem; + z-index: 10; + padding: var(--space-2) var(--space-3); + background: var(--color-primary); + color: var(--color-on-primary); + border-radius: 0 0 var(--radius) var(--radius); + text-decoration: none; + transition: top 150ms ease; +} + +.skip-link:focus-visible { + top: 0; +} + +/* ---------- Site header ---------- */ + +.site-header { + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.site-header__inner { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} + +.site-header__brand { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-text); + text-decoration: none; +} + +.site-header__brand-mark { + color: var(--color-primary); +} + +.site-header__nav { + margin-left: auto; +} + +.nav__list { + list-style: none; + display: flex; + gap: var(--space-3); + margin: 0; + padding: 0; + align-items: center; +} + +.nav__link { + text-decoration: none; + color: var(--color-text); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); +} + +.nav__link:hover { + color: var(--color-primary); + text-decoration: underline; +} + +.nav__link[aria-current="page"] { + font-weight: 700; + color: var(--color-primary); + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +/* ---------- Layout ---------- */ + +.site-main { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-6); +} + +.site-main--narrow { + max-width: var(--form-width); +} + +.site-footer { + border-top: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.site-footer__inner { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-4); + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + justify-content: space-between; +} + +/* ---------- Buttons ---------- */ + +.button { + display: inline-block; + font-family: var(--font-display); + font-size: var(--font-size-base); + font-weight: 600; + padding: 0.65em 1.4em; + border-radius: var(--radius); + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.button--primary { + background: var(--color-primary); + color: var(--color-on-primary); +} + +.button--ghost { + background: transparent; + color: var(--color-text); + border-color: var(--color-border); +} + +.button--ghost:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ---------- Hero (home) ---------- */ + +.hero { + display: grid; + gap: var(--space-5); + align-items: center; + grid-template-columns: 1.1fr 0.9fr; + padding: var(--space-6) 0; +} + +@media (max-width: 46rem) { + .hero { + grid-template-columns: 1fr; + } +} + +.hero__title { + font-size: var(--font-size-3xl); +} + +.hero__title-accent { + color: var(--color-primary); +} + +.hero__lead { + font-size: var(--font-size-lg); + color: var(--color-text-muted); + max-width: 34rem; +} + +.hero__actions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + margin-top: var(--space-4); +} + +/* Decorative code card: signature element, hidden from AT */ +.code-card { + background: var(--code-bg); + color: var(--code-text); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + border-radius: var(--radius-lg); + padding: var(--space-4); + box-shadow: var(--shadow); + rotate: 1.5deg; + position: relative; +} + +.code-card::before { + content: ""; + position: absolute; + inset: var(--space-3) auto auto var(--space-4); + 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); +} + +.code-card__code { + display: block; + margin-top: var(--space-4); + white-space: pre; + overflow-x: auto; +} + +.code-card__keyword { color: var(--code-keyword); } +.code-card__string { color: var(--code-string); } +.code-card__function { color: var(--code-function); } +.code-card__comment { color: var(--code-comment); } + +/* ---------- Feature cards (home) ---------- */ + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--space-4); + padding: 0; + margin: var(--space-5) 0 0; + list-style: none; +} + +.feature-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.feature-card__icon { + font-size: var(--font-size-2xl); + display: block; + margin-bottom: var(--space-2); +} + +.feature-card__title { + font-size: var(--font-size-lg); + margin-bottom: var(--space-2); +} + +.feature-card__text { + color: var(--color-text-muted); + margin: 0; +} + +/* ---------- Forms ---------- */ + +.form-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow); +} + +.form-card__title { + font-size: var(--font-size-2xl); + margin-bottom: var(--space-4); +} + +.form__group { + margin-bottom: var(--space-4); +} + +.form__label { + display: block; + font-weight: 700; + margin-bottom: var(--space-1); +} + +.form__hint { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-1); +} + +.form__input { + width: 100%; + font: inherit; + color: var(--color-text); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.6em 0.8em; +} + +.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 { + display: block; + color: var(--color-error); + font-size: var(--font-size-sm); + font-weight: 700; + margin-top: var(--space-1); +} + +.form__footer { + margin-top: var(--space-4); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +/* ---------- Alerts ---------- */ + +.alert { + border: 1px solid; + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + background: var(--color-surface); +} + +.alert--success { + color: var(--color-success); + border-color: var(--color-success); +} + +.alert--error { + color: var(--color-error); + border-color: var(--color-error); +} + +.alert--info { + color: var(--color-link); + border-color: var(--color-link); +} + +/* ---------- Dashboard ---------- */ + +.page-title { + font-size: var(--font-size-2xl); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: var(--space-4); + margin: var(--space-4) 0 var(--space-5); + padding: 0; + list-style: none; +} + +.stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.stat-card__value { + display: block; + font-family: var(--font-display); + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--color-primary); +} + +.stat-card__label { + color: var(--color-text-muted); +} + +.course-list { + list-style: none; + margin: var(--space-4) 0 0; + padding: 0; + display: grid; + gap: var(--space-3); +} + +.course-card { + display: grid; + gap: var(--space-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.course-card__title { + font-size: var(--font-size-lg); + margin: 0; +} + +.course-card__meta { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin: 0; +} + +.course-card__progress { + accent-color: var(--color-primary); + width: 100%; + height: 0.6rem; +} + +/* ---------- Page-load reveal (CSS only, reduced-motion safe) ---------- */ + +@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; } +.reveal--3 { animation-delay: 200ms; } +.reveal--4 { animation-delay: 300ms; } diff --git a/docs/design/mockups/css/theme-catppuccin.css b/docs/design/mockups/css/theme-catppuccin.css new file mode 100644 index 0000000..a0c8b6b --- /dev/null +++ b/docs/design/mockups/css/theme-catppuccin.css @@ -0,0 +1,65 @@ +/* + learn-dev theme: Catppuccin (DEFAULT) + Palette: Catppuccin Latte (light) / Mocha (dark), official values from + https://github.com/catppuccin/palette + Values marked "adjusted" are darkened from upstream to reach the WCAG 2.1 + AA contrast ratio of 4.5:1 on --color-bg; every pair is documented with + its computed ratio in docs/design/theme-exploration.md + Both themes define the SAME token names: switching theme = swapping the + <link> to theme-soft-paper.css (no runtime switcher in v1). +*/ + +:root { + color-scheme: light dark; + + /* Latte (light) */ + --color-bg: #eff1f5; /* base */ + --color-surface: #e6e9ef; /* mantle */ + --color-surface-deep: #dce0e8; /* crust */ + --color-border: #ccd0da; /* surface0 */ + --color-text: #4c4f69; /* text, 7.06:1 */ + --color-text-muted: #5c5f77; /* subtext1, 5.53:1 */ + --color-primary: #8839ef; /* mauve, 4.79:1 */ + --color-on-primary: #ffffff; /* 5.41:1 on primary */ + --color-link: #1a5cd7; /* adjusted from blue #1e66f5, 5.22:1 */ + --color-success: #2f7a1f; /* adjusted from green #40a02b, 4.73:1 */ + --color-warning: #8f5b08; /* adjusted from yellow #df8e1d, 5.06:1 */ + --color-error: #d20f39; /* red, 4.80:1 */ + --color-focus: #1e66f5; /* blue, UI 3:1 requirement */ + --color-primary-soft: #eadcfd; /* decorative wash behind hero art */ + + /* Decorative syntax colors (code card is aria-hidden eye candy) */ + --code-bg: #1e1e2e; + --code-text: #cdd6f4; + --code-keyword: #cba6f7; + --code-string: #a6e3a1; + --code-function: #89b4fa; + --code-comment: #9399b2; +} + +@media (prefers-color-scheme: dark) { + :root { + /* Mocha (dark), all upstream values pass AA */ + --color-bg: #1e1e2e; /* base */ + --color-surface: #181825; /* mantle */ + --color-surface-deep: #11111b; /* crust */ + --color-border: #313244; /* surface0 */ + --color-text: #cdd6f4; /* text, 11.34:1 */ + --color-text-muted: #bac2de; /* subtext1, 9.26:1 */ + --color-primary: #cba6f7; /* mauve, 8.07:1 */ + --color-on-primary: #11111b; /* crust, 9.23:1 on primary */ + --color-link: #89b4fa; /* blue, 7.79:1 */ + --color-success: #a6e3a1; /* green, 11.03:1 */ + --color-warning: #f9e2af; /* yellow, 12.91:1 */ + --color-error: #f38ba8; /* red, 7.08:1 */ + --color-focus: #89b4fa; /* blue */ + --color-primary-soft: #2b2440; + + --code-bg: #11111b; + --code-text: #cdd6f4; + --code-keyword: #cba6f7; + --code-string: #a6e3a1; + --code-function: #89b4fa; + --code-comment: #9399b2; + } +} diff --git a/docs/design/mockups/css/theme-soft-paper.css b/docs/design/mockups/css/theme-soft-paper.css new file mode 100644 index 0000000..65c05b9 --- /dev/null +++ b/docs/design/mockups/css/theme-soft-paper.css @@ -0,0 +1,64 @@ +/* + learn-dev theme: Soft Paper (ALTERNATE, generated but not linked) + Palette extracted from https://github.com/nickmilo/soft-paper (Obsidian + theme): custom warm paper light palette; the dark side is essentially + Catppuccin Frappe. + Values marked "adjusted" are darkened from upstream to reach WCAG 2.1 AA + 4.5:1 on --color-bg; ratios documented in docs/design/theme-exploration.md + Same token names as theme-catppuccin.css: to activate this theme, point + the <link> in the page head at this file instead. +*/ + +:root { + color-scheme: light dark; + + /* Warm paper (light) */ + --color-bg: #eee6dd; + --color-surface: #e6dbd1; + --color-surface-deep: #ddd0c6; + --color-border: #dcd3cb; + --color-text: #575279; /* 5.89:1 */ + --color-text-muted: #525252; /* 6.32:1 */ + --color-primary: #286983; /* 4.94:1 */ + --color-on-primary: #ffffff; /* 6.11:1 on primary */ + --color-link: #286983; /* 4.94:1 */ + --color-success: #2f6a4a; /* adjusted from #3f7d5b, 5.18:1 */ + --color-warning: #7d570c; /* adjusted from #96690f, 5.25:1 */ + --color-error: #94425a; /* adjusted from #a34e63, 5.34:1 */ + --color-focus: #286983; + --color-primary-soft: #dfe9ec; + + --code-bg: #303446; + --code-text: #c6ceef; + --code-keyword: #bb93d6; + --code-string: #67c48f; + --code-function: #8caaee; + --code-comment: #838ba7; +} + +@media (prefers-color-scheme: dark) { + :root { + /* Frappe (dark), all values pass AA */ + --color-bg: #303446; + --color-surface: #292c3c; + --color-surface-deep: #232634; + --color-border: #414459; + --color-text: #c6ceef; /* 7.90:1 */ + --color-text-muted: #b5bddc; /* 6.61:1 */ + --color-primary: #8caaee; /* 5.34:1 */ + --color-on-primary: #232634; /* 6.51:1 on primary */ + --color-link: #8caaee; /* 5.34:1 */ + --color-success: #67c48f; /* 5.79:1 */ + --color-warning: #c9be3e; /* 6.40:1 */ + --color-error: #e78284; /* 4.65:1 */ + --color-focus: #8caaee; + --color-primary-soft: #3b415c; + + --code-bg: #232634; + --code-text: #c6ceef; + --code-keyword: #bb93d6; + --code-string: #67c48f; + --code-function: #8caaee; + --code-comment: #838ba7; + } +} diff --git a/docs/design/mockups/dashboard-html-explained.md b/docs/design/mockups/dashboard-html-explained.md new file mode 100644 index 0000000..b882e7a --- /dev/null +++ b/docs/design/mockups/dashboard-html-explained.md @@ -0,0 +1,259 @@ +# dashboard.html, tag by tag + +An educational walkthrough of `dashboard.html`, the authenticated home of the +learn-dev mockups. The mockup freezes the state of a logged-in user ("carol") +with three enrolled courses. Two elements deserve special attention on this +page and get their own sections: the **`<progress>` element** and the **logout +POST form**. + +The document shell (doctype, `html lang`, `head` metadata, font and stylesheet +`link`s, skip link, `header`, `footer`) is identical to the home page and is +explained in depth in [home-html-explained.md](home-html-explained.md). +Section 1 lists it and covers the one real difference: the header now belongs +to an authenticated context. + +--- + +## 1. Shared shell, with an authenticated twist + +| Tag | Reminder | +|---|---| +| `<!DOCTYPE html>`, `html lang="en"` | Standards mode; page language (RGAA 8.3) | +| `meta charset` / `meta viewport` | UTF-8; responsive viewport | +| `title` | `Dashboard ... learn-dev` (RGAA 8.5) | +| `link` preconnect / stylesheet | Font-origin warm-up; theme then base CSS | +| `a.skip-link` + `main#main` | Skip repeated blocks (RGAA 12.7) | +| `header` > `div` > brand `a` + `nav aria-label="Main"` | Banner + navigation landmarks (RGAA 9.2, 12.2) | +| `footer` > `div` > `p` | Contentinfo landmark | + +The nav content changes because the user is signed in: the public Home / Log in / +Sign up entries are replaced by Dashboard (current page) and a Log out control. + +```html +<ul class="nav__list"> + <li><a class="nav__link" href="dashboard.html" aria-current="page">Dashboard</a></li> + <li> + <!-- Logout is a POST in the real app (CSRF); styled as a ghost button --> + <form action="#" method="post"> + <button class="button button--ghost" type="submit">Log out</button> + </form> + </li> +</ul> +``` + +`aria-current="page"` marks Dashboard as the current location, as on every page +(see [home-html-explained.md](home-html-explained.md)). The second `<li>` is the +interesting one. + +--- + +## 2. The logout POST form (why not a plain link) + +```html +<form action="#" method="post"> + <button class="button button--ghost" type="submit">Log out</button> +</form> +``` + +Visually this is one ghost button in the menu; structurally it is a **whole form +whose only control is its submit button**. That is deliberate, and being able to +justify it is worth real points in a DWWM security discussion. + +- **Logout changes server state.** It invalidates the session. The HTTP + semantics are clear: **GET must be safe** (no state change, cacheable, + prefetchable, repeatable); state changes belong to **POST** (or other unsafe + methods). Logout is a state change, so it is a POST. +- **Prefetch danger of a GET logout.** Browsers, link-prefetching extensions, + proxies, and crawlers feel free to *pre-fetch* GET links to speed up + navigation, precisely because GET promises to be safe. A `<a href="/logout">` + can therefore be silently fetched the moment the page renders, logging the + user out without any click. This is not theoretical; accelerator extensions + and some browser preloading features have caused exactly this bug in + production apps. +- **CSRF.** A GET logout is trivially forgeable from any third-party page + (`<img src="https://app.example/logout">` suffices) to log users out against + their will (a nuisance form of Cross-Site Request Forgery). A POST protected + by a CSRF token is not. In the real app, Thymeleaf's `th:action` on this form + makes Spring Security inject a hidden `_csrf` input automatically, and Spring + Security's `LogoutFilter` **only accepts POST** for `/logout` when CSRF + protection is on (its default). The mockup's `action="#"` is the placeholder + for that. +- **`<button>` not `<a>`.** The rule from the other pages, applied in reverse: + logging out is an *action*, not a navigation, so it is a `<button + type="submit">` inside a form. It is styled with the shared + `button button--ghost` classes so it sits quietly in the menu, proof that the + visual layer and the element choice are independent decisions. A JavaScript + `onclick` on a link could send a POST too, but would break without JS and + misrepresent the control's role to assistive technology. +- **Position.** The form lives inside the nav's `<li>`, keeping the menu a + proper list (RGAA **9.3**) even though one entry is a form rather than a link. + +--- + +## 3. Page heading and intro + +```html +<h1 class="page-title reveal">Welcome back, <strong>carol</strong> 👋</h1> +<p class="reveal reveal--2">Here is where you left off.</p> +``` + +- **`<h1>`.** The unique top-level heading (RGAA **9.1**), personalized with the + username the server will interpolate. +- **`<strong>`.** Genuine strong importance, not just bold styling: the username + is the key information in the sentence (whose dashboard is this?). Screen + readers may convey the emphasis. A `<span class="bold">` would style the same + but carry no meaning; `<b>` is the styling-only fallback. When the weight *is* + the message, `<strong>` is right. +- The waving-hand emoji sits in plain text here (unlike the decorative icons on + the home page, which are wrapped in `aria-hidden` spans); it will be read by + screen readers as "waving hand". Defensible either way; hiding it would be the + stricter choice. + +--- + +## 4. The stats strip + +```html +<h2 class="visually-hidden" id="stats-title">Your numbers</h2> +<ul class="stats reveal reveal--2" aria-labelledby="stats-title"> + <li class="stat-card"> + <span class="stat-card__value">3</span> + <span class="stat-card__label">Courses enrolled</span> + </li> + ... +</ul> +``` + +- **`<h2 class="visually-hidden">`.** The stat cards are visually + self-explanatory, so a visible "Your numbers" heading would be clutter; but + removing the heading entirely would leave a hole in the heading outline and an + unnamed list. The compromise: keep the heading in the DOM, hide it with the + `visually-hidden` CSS technique (clip/1px trick), which hides it from sighted + users but **keeps it in the accessibility tree**. This is the exact opposite + tool to `aria-hidden="true"` (visible but not announced): visually-hidden is + *invisible but announced*. Screen reader users get the outline entry and can + jump to the stats by heading (RGAA **9.1**). +- **`aria-labelledby="stats-title"` on the `<ul>`.** Names the list after the + hidden heading, so it is announced as "Your numbers, list, 3 items". Same + naming-by-reference pattern as the `<section>` elements + (see [home-html-explained.md](home-html-explained.md)). +- **`<ul>`/`<li>`.** Three cards of the same kind = a list (RGAA **9.3**), + giving assistive tech the "3 items" count for free. +- **The two `<span>`s.** Value and label are inline, meaning-free wrappers + stacked by CSS. Reading order in the DOM is value then label ("3, Courses + enrolled"), which reads naturally. Neither is a heading: "Courses enrolled" is + a *caption of a number*, not a section title, and making every card label a + heading would pollute the outline. + +--- + +## 5. The courses section + +```html +<section aria-labelledby="courses-title" class="reveal reveal--3"> + <h2 id="courses-title">Your courses</h2> + <ul class="course-list"> + <li class="course-card"> + <h3 class="course-card__title">Java fundamentals</h3> + <p class="course-card__meta">12 of 16 lessons completed</p> + <progress class="course-card__progress" max="16" value="12" + aria-label="Java fundamentals progress: 12 of 16 lessons"></progress> + </li> + ... + </ul> +</section> +``` + +- **`<section aria-labelledby="courses-title">` + `<h2 id="courses-title">`.** + The named-region pattern: the section becomes a `region` landmark named "Your + courses" by its own visible heading (RGAA **9.2**, **9.1**). Full discussion + in [home-html-explained.md](home-html-explained.md). +- **`<ul class="course-list">` / `<li class="course-card">`.** A list of course + cards (RGAA **9.3**), same reasoning as the features grid on the home page. +- **`<h3>`.** Each course name is a third-level heading under the "Your courses" + h2: the outline stays strictly nested (h1 > h2 > h3) and a screen reader user + can jump course to course by heading. +- **`<p class="course-card__meta">`.** The human-readable progress sentence + ("12 of 16 lessons completed"). Note that the information exists **as text** + before the graphical bar even appears; the bar could disappear and nothing + would be lost. That redundancy is the backbone of the accessibility story here + (RGAA **3.1**: never by presentation alone). + +### `<progress>`, in depth + +```html +<progress class="course-card__progress" max="16" value="12" + aria-label="Java fundamentals progress: 12 of 16 lessons"></progress> +``` + +- **Role.** The native HTML element for *task completion*. It maps to the ARIA + **`progressbar` role automatically**, and, crucially, it derives and exposes + the ARIA value properties from its attributes with zero extra work: + `aria-valuemin` (0), `aria-valuemax` (from `max`), `aria-valuenow` (from + `value`). A screen reader announces something like "progress bar, 75%". One + element, correct semantics, no ARIA bookkeeping. +- **`max="16"` and `value="12"`.** + - `max`: the total amount of work, a positive float; here the number of + lessons in the course. Defaults to 1 if omitted. + - `value`: the completed amount, between 0 and `max`. + - The pair expresses the real domain numbers (12 of 16) rather than a + pre-chewed percentage (`max="100" value="75"`). Both are valid; keeping the + domain values means the markup states the truth and the browser does the + division. In the Thymeleaf template these become + `th:attr="max=${course.totalLessons}, value=${course.completedLessons}"` or + equivalent. + - Omitting `value` entirely would switch the element to its + **indeterminate** state (animated "busy" bar, no `aria-valuenow`), which is + for unknown durations, not for known fractions like these. +- **`aria-label="Java fundamentals progress: 12 of 16 lessons"`.** A + progressbar exposes *values* but has no inherent *name*: three bare bars in a + row would each announce only "progress bar, 75%", "64%", "33%", leaving a + screen reader user to guess which course each belongs to. The `aria-label` + gives each bar a self-contained accessible name that repeats the course and + the raw numbers. Each of the three bars has its own label naming its own + course. An alternative wiring would be `aria-labelledby` pointing at the + card's `<h3>` id; the literal label was chosen so the announcement includes + the exact lesson counts without depending on reading order. +- **Empty content.** The element has no fallback content between its tags; + every current browser renders `<progress>` natively, and the adjacent `<p>` + already carries the text version. +- **Position and partners.** Inside the course `<li>`, after the `<h3>` (which + identifies the course) and the meta `<p>` (which states the numbers in + prose). The three express the same fact through three channels: outline, + text, and graphic/ARIA values. +- **Why not the alternative (`div` bar).** The ubiquitous + `<div class="bar"><div class="fill" style="width:75%"></div></div>` is, to + assistive technology, **two empty boxes**: no role, no values, no name. To + fix it you must hand-write `role="progressbar"`, `aria-valuemin`, + `aria-valuemax`, `aria-valuenow`, and a name, then keep `aria-valuenow` in + sync with the width forever. The native element does all of that by existing. + The honest tradeoff: `<progress>` is harder to *style* consistently across + browsers (it needs vendor pseudo-elements like `::-webkit-progress-value`), + which is why so many sites reach for divs; these mockups accept the styling + cost to keep the semantics. Also worth distinguishing: `<meter>` looks + similar but means a *measurement within a known range* (disk usage, score), + not *progress toward completing a task*; course completion is task progress, + hence `<progress>`. + +--- + +## 6. Recap table + +| Tag | Implicit ARIA role | Key attributes here | Works with | Main purpose on this page | +|---|---|---|---|---| +| shell tags | see home-html-explained.md | `aria-current="page"` on Dashboard | shell | Shared frame, authenticated nav | +| `form` (in nav) | none | `action="#"`, `method="post"` | its submit button | Logout as state-changing POST (CSRF, no prefetch) | +| `button` | button | `type="submit"` | logout form | The logout action (button = act, link = navigate) | +| `main` | main | `id="main"` | skip link | Unique content landmark | +| `h1` | heading level 1 | `class` | outline | Personalized page title (RGAA 9.1) | +| `strong` | strong | none | h1 text | Genuine importance of the username | +| `h2.visually-hidden` | heading level 2 | `id="stats-title"`, hidden by CSS | ul `aria-labelledby` | Outline entry without visual clutter | +| `ul.stats` | list | `aria-labelledby="stats-title"` | hidden h2, li | Named list of stat cards (RGAA 9.3) | +| `li.stat-card` | listitem | `class` | spans | One statistic | +| `span` | none | `class` | stat cards | Value / label styling hooks | +| `section` | region (named) | `aria-labelledby="courses-title"` | h2 id | Courses landmark (RGAA 9.2) | +| `h2` / `h3` | heading 2 / 3 | `id` (h2) | section, cards | Outline: section then one h3 per course | +| `ul.course-list` / `li.course-card` | list / listitem | `class` | h3, p, progress | List of courses (RGAA 9.3) | +| `p.course-card__meta` | paragraph | `class` | progress | Progress as plain text (RGAA 3.1) | +| `progress` | progressbar | `max`, `value`, `aria-label` | h3, meta p | Native completion bar with derived ARIA values | +| `footer` | contentinfo | `class` | p | Site info landmark | diff --git a/docs/design/mockups/dashboard.html b/docs/design/mockups/dashboard.html new file mode 100644 index 0000000..d43905b --- /dev/null +++ b/docs/design/mockups/dashboard.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Dashboard — learn-dev + + + + + + + + + + + +
+

Welcome back, carol 👋

+

Here is where you left off.

+ +

Your numbers

+ + +
+

Your courses

+ +
+
+ + + + diff --git a/docs/design/mockups/home-html-explained.md b/docs/design/mockups/home-html-explained.md new file mode 100644 index 0000000..e5c0211 --- /dev/null +++ b/docs/design/mockups/home-html-explained.md @@ -0,0 +1,616 @@ +# home.html, tag by tag + +An educational walkthrough of `home.html`, the public landing page of the learn-dev +mockups. Audience: a developer preparing the French DWWM certification who wants to +understand every tag choice, not just copy it. + +This file is also the **reference document for the shared page shell** (doctype, +``, ``, header, footer, skip link). The other four walkthroughs +(`login-html-explained.md`, `register-html-explained.md`, `dashboard-html-explained.md`, +`index-html-explained.md`) explain their page-specific content in full and point back +here for the shell. + +Note on quoting: the mockup's `` and footer strings contain a typographic dash; +in the snippets below it is elided as `...`. + +--- + +## 1. The document shell + +### `<!DOCTYPE html>` + +```html +<!DOCTYPE html> +``` + +- **Role.** Not a tag but a *document type declaration*. It is the very first thing in + the file and tells the browser to parse the page in **standards mode** (also called + no-quirks mode). Without it, browsers fall back to "quirks mode" and emulate 1990s + layout bugs (different box model, different line-height handling), which breaks + modern CSS. +- **Attributes.** None. The short HTML5 form replaced the long HTML4/XHTML doctypes + with DTD URLs. +- **Position.** First line, before `<html>`. Nothing may precede it except whitespace + or comments. +- **Why not the alternative.** There is no real alternative: omitting it is legal HTML + but opts you into quirks mode, which no one wants. + +### `<html lang="en">` + +```html +<html lang="en"> +``` + +- **Role.** The root element; every other element is its descendant. Maps to the ARIA + `document` role implicitly. +- **Attributes.** + - `lang="en"`: declares the default natural language of the whole page using a + BCP 47 language code. Screen readers pick the correct speech synthesis voice and + pronunciation rules from it; translation tools, spellcheckers, and CSS `hyphens` + also rely on it. The mockups are written in English, hence `en` (the real app + could later serve `fr` pages with `lang="fr"`). +- **Position.** Direct parent of exactly two children: `<head>` and `<body>`. +- **RGAA.** Criterion **8.3** (each page has a default language) and 8.4 (the code is + valid). A missing `lang` is one of the most common audit failures. + +### `<head>` + +```html +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>learn-dev ... Learn programming by doing + ... + +``` + +- **Role.** Machine-readable metadata container: nothing inside it is rendered in the + page body. Holds the character encoding, viewport rule, title, and resource links. +- **Attributes.** None here. +- **Position.** First child of ``; contains ``, ``, `<link>`. +- **Why not the alternative.** No alternative; browsers would auto-create it anyway, + but writing it explicitly keeps the document structure honest and readable. + +### `<meta charset="UTF-8">` + +```html +<meta charset="UTF-8"> +``` + +- **Role.** Declares the byte-to-character encoding of the document. +- **Attributes.** + - `charset="UTF-8"`: the only attribute this form of `<meta>` takes. UTF-8 covers + every script and is the only encoding the HTML spec recommends. It must appear + within the first 1024 bytes of the file so the parser knows the encoding before it + meets any non-ASCII character (this page contains emoji and a ` `, so it + matters concretely). +- **Position.** Ideally the first child of `<head>`, as it is here. +- **Why not the alternative.** The old form + `<meta http-equiv="Content-Type" content="text/html; charset=utf-8">` still works + but is longer for zero benefit. Relying on an HTTP header alone is fragile once the + file is opened from disk, exactly the use case of a static mockup. + +### `<meta name="viewport" content="width=device-width, initial-scale=1">` + +```html +<meta name="viewport" content="width=device-width, initial-scale=1"> +``` + +- **Role.** Instructs mobile browsers how to size the layout viewport. +- **Attributes.** + - `name="viewport"`: selects the viewport metadata key. + - `content="width=device-width, initial-scale=1"`: `width=device-width` makes the + CSS viewport match the physical device width instead of a faked 980px desktop + canvas; `initial-scale=1` starts at 100% zoom. Together they make the responsive + CSS actually take effect on phones. +- **Position.** Child of `<head>`. +- **Accessibility note.** It deliberately does **not** include `user-scalable=no` or + `maximum-scale=1`: blocking pinch-zoom is an accessibility failure (RGAA criterion + **10.8** family on content restitution, WCAG 1.4.4 resize text). + +### `<title>` + +```html +<title>learn-dev ... Learn programming by doing +``` + +- **Role.** The page title shown in the browser tab, bookmarks, history, and search + results. It is the **first thing a screen reader announces** when the page loads, + so it must identify the page uniquely. +- **Attributes.** None. +- **Position.** Child of ``; exactly one per document. +- **Pattern.** Each mockup follows `page name + site name` (or the reverse), so users + juggling several tabs can tell them apart: compare `Log in ... learn-dev` and + `Dashboard ... learn-dev`. +- **RGAA.** Criterion **8.5** (each page has a title) and 8.6 (the title is relevant). + +### `` (four flavours) + +```html + + + + + +``` + +- **Role.** A void element that relates the document to an external resource. The + `rel` attribute decides *how* it relates. +- **Attributes.** + - `rel="preconnect"`: a performance hint. It tells the browser to open the TCP + connection, TLS handshake included, to that origin *now*, before any resource + from it is requested. Google Fonts spans **two** origins: `fonts.googleapis.com` + serves the CSS, `fonts.gstatic.com` serves the font binaries, so each origin gets + its own preconnect. + - `crossorigin` (on the `fonts.gstatic.com` preconnect only): font files are always + fetched in CORS *anonymous* mode, and a preconnected socket is only reusable if + its CORS mode matches the future request. Without `crossorigin`, that preconnect + would warm up a socket the font request cannot use. The CSS file, by contrast, is + fetched without CORS, so its preconnect has no `crossorigin`. Written bare, the + attribute means `crossorigin="anonymous"` (no credentials sent). + - `rel="stylesheet"`: loads and applies a CSS file. + - `href="..."`: the resource URL. The two local stylesheets use relative paths so + the mockups work from disk. Order matters: `theme-catppuccin.css` defines the + design tokens (CSS custom properties), `base.css` consumes them, so the theme + file comes first. Swapping the theme means editing this one `href` + (see `index.html`). + - In the fonts URL, `display=swap` asks the browser to render text immediately + with a fallback font and swap when the webfont arrives (no invisible text). +- **Position.** Children of ``; void elements, no closing tag. +- **Why not the alternative.** `@import` inside CSS would serialize the downloads + (CSS must be parsed before the import is even discovered); `` lets the + preload scanner fetch everything in parallel. + +--- + +## 2. Page skeleton and landmarks + +The `` reads, top to bottom: skip link, `
`, `
`, `