From abf767d985d3d3a05cf5257d649a6b814af1828f Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 8 Jul 2026 15:20:58 +0200 Subject: [PATCH 1/5] docs(design): Explore the AnuPpuccin and Soft Paper theme candidates Reconstruct the theme discussion (held outside this repository's tooling) into a design document: AnuPpuccin (Catppuccin Latte/Mocha) as the default theme and Soft Paper (warm paper light, Catppuccin Frappe dark) as the generated alternate, with no runtime switching in v1. Every text-on- background pair carries a WCAG contrast ratio computed from the upstream palettes; light-mode accents that fail 4.5:1 are darkened and marked as adjusted. Includes the shared structural tokens (type, spacing, shape, focus) and how BEM components consume the tokens. --- docs/design/theme-exploration.md | 111 +++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/design/theme-exploration.md diff --git a/docs/design/theme-exploration.md b/docs/design/theme-exploration.md new file mode 100644 index 0000000..045087e --- /dev/null +++ b/docs/design/theme-exploration.md @@ -0,0 +1,111 @@ +# Theme Exploration + +Candidate visual themes for the learn-dev frontend, with design tokens and +WCAG-checked color pairs. + + +## Decision + +- **Default theme: Catppuccin** (Catppuccin palette: Latte flavor in light + mode, Mocha in dark mode). +- **Alternate: Soft Paper** (warm paper palette; its dark side is + Catppuccin Frappe). Its stylesheet is generated and kept in the repo. +- **No runtime theme switching in v1** to keep things simple: both themes + define the **same token names**, so switching means changing a single + `` element. Dark mode follows the OS preference + (`prefers-color-scheme`), which is not a "switcher". + +## Constraints + +- **WCAG 2.1 AA / RGAA**: body text needs a contrast ratio of at least + **4.5:1** against its background; large text and UI components need + **3:1**. Every color pair below carries its computed ratio (see the + Method appendix); values adjusted from the upstream palette to reach + compliance are marked **(adjusted)**. +- Colors are consumed exclusively through **CSS custom properties (design + tokens)**; BEM components never hardcode a color. +- Both light and dark variants are defined for each candidate. + +## Candidate A (default): Catppuccin + +Upstream palette: the official [Catppuccin palette](https://github.com/catppuccin/palette) +(Latte and Mocha flavors, MIT licensed). Initially considered through +AnuPpuccin, an Obsidian skin of Catppuccin (GPL-3.0); no AnuPpuccin code or +values are used, only the official Catppuccin palette, so the theme is named +after its real upstream. Pastel accents are designed for dark backgrounds, +so three light-mode values are darkened to pass 4.5:1. + +| Token | Light (Latte) | Ratio on bg | Dark (Mocha) | Ratio on bg | +|---|---|---|---|---| +| `--color-bg` | `#eff1f5` (base) | | `#1e1e2e` (base) | | +| `--color-surface` | `#e6e9ef` (mantle) | | `#181825` (mantle) | | +| `--color-border` | `#ccd0da` (surface0) | | `#313244` (surface0) | | +| `--color-text` | `#4c4f69` (text) | 7.06:1 | `#cdd6f4` (text) | 11.34:1 | +| `--color-text-muted` | `#5c5f77` (subtext1) | 5.53:1 | `#bac2de` (subtext1) | 9.26:1 | +| `--color-primary` | `#8839ef` (mauve) | 4.79:1 | `#cba6f7` (mauve) | 8.07:1 | +| `--color-link` | `#1a5cd7` **(adjusted** from blue `#1e66f5`, 4.34:1**)** | 5.22:1 | `#89b4fa` (blue) | 7.79:1 | +| `--color-success` | `#2f7a1f` **(adjusted** from green `#40a02b`, 2.96:1**)** | 4.73:1 | `#a6e3a1` (green) | 11.03:1 | +| `--color-warning` | `#8f5b08` **(adjusted** from yellow `#df8e1d`, 2.31:1**)** | 5.06:1 | `#f9e2af` (yellow) | 12.91:1 | +| `--color-error` | `#d20f39` (red) | 4.80:1 | `#f38ba8` (red) | 7.08:1 | +| `--color-on-primary` (button text) | `#ffffff` | 5.41:1 on primary | `#11111b` (crust) | 9.23:1 on primary | +| `--color-focus` | `#1e66f5` (blue, 3:1 UI requirement) | | `#89b4fa` (blue) | | + +Character: fresh, slightly playful pastels; the mauve primary gives the +learning platform a distinctive identity without feeling corporate. + +## Candidate B (alternate): Soft Paper + +Upstream: [nickmilo/soft-paper](https://github.com/nickmilo/soft-paper) +(Obsidian theme). Interesting finding: Soft Paper is itself built on +Catppuccin variables; its light palette is a custom warm paper set and its +dark palette is essentially **Catppuccin Frappe**. Three light-mode +semantic colors are darkened to pass 4.5:1. + +| Token | Light (paper) | Ratio on bg | Dark (Frappe) | Ratio on bg | +|---|---|---|---|---| +| `--color-bg` | `#eee6dd` | | `#303446` | | +| `--color-surface` | `#e6dbd1` | | `#292c3c` | | +| `--color-border` | `#dcd3cb` | | `#414459` | | +| `--color-text` | `#575279` | 5.89:1 | `#c6ceef` | 7.90:1 | +| `--color-text-muted` | `#525252` | 6.32:1 | `#b5bddc` | 6.61:1 | +| `--color-primary` | `#286983` | 4.94:1 | `#8caaee` | 5.34:1 | +| `--color-link` | `#286983` | 4.94:1 | `#8caaee` | 5.34:1 | +| `--color-success` | `#2f6a4a` **(adjusted** from `#3f7d5b`, 3.96:1**)** | 5.18:1 | `#67c48f` | 5.79:1 | +| `--color-warning` | `#7d570c` **(adjusted** from `#96690f`, 3.93:1**)** | 5.25:1 | `#c9be3e` | 6.40:1 | +| `--color-error` | `#94425a` **(adjusted** from `#a34e63`, 4.44:1**)** | 5.34:1 | `#e78284` | 4.65:1 | +| `--color-on-primary` (button text) | `#ffffff` | 6.11:1 on primary | `#232634` (crust) | 6.51:1 on primary | +| `--color-focus` | `#286983` | | `#8caaee` | | + +Character: calm, warm, reflective; reads like paper. Lower-key than +Catppuccin, closer to a reading environment than an interactive app. + +## Structural tokens (theme-independent) + +Defined once in `base.css`, identical for both themes: + +| Group | Tokens | +|---|---| +| Typography | `--font-body`: system-ui stack; `--font-size-{sm,base,lg,xl,2xl}`: 0.875 / 1 / 1.125 / 1.375 / 1.75 rem; `--line-height`: 1.6 | +| Spacing | `--space-{1..6}`: 0.25 / 0.5 / 1 / 1.5 / 2 / 3 rem | +| Shape | `--radius-sm`: 4px; `--radius`: 8px; `--shadow`: soft single-layer | +| Focus | 2px solid `--color-focus` outline with 2px offset, never removed | + +## How the tokens are consumed + +- Each theme is one stylesheet defining the same custom property names on + `:root` (light values) and inside `@media (prefers-color-scheme: dark)` + (dark values). +- `base.css` holds resets, typography, and all BEM components + (`.site-header`, `.form__field`, `.button--primary`, `.alert--error`, ...) + written only against token names. +- The active theme is the one `` in the page head: + `css/theme-catppuccin.css` (default). Switching to Soft Paper = replacing + that one href with `css/theme-soft-paper.css`. + +## Appendix: method + +Ratios computed with the WCAG 2.x relative-luminance formula +(`(L1 + 0.05) / (L2 + 0.05)` over linearized sRGB), by a Python script run +against the upstream palettes fetched from their repositories. Thresholds: +4.5:1 for normal text, 3:1 for large text and UI components. All values in +the tables above are script outputs, not estimates. From 266d13e525d8fc0cdfca5aa4e71268b64548b4fa Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 8 Jul 2026 15:28:52 +0200 Subject: [PATCH 2/5] feat(design): Add theme stylesheets and accessible HTML mockups Add the Catppuccin (default) and Soft Paper (alternate) theme stylesheets, both defining the same design tokens on :root with dark variants via prefers-color-scheme, plus base.css holding the structural tokens and all BEM components written only against token names. The default theme is named after its real upstream, the MIT licensed Catppuccin palette (it was initially considered through AnuPpuccin, an Obsidian skin of Catppuccin, from which no code or values are used). Add static mockups (index gallery, home, login, register, dashboard) that mirror the future Thymeleaf structure: skip link, header/nav/main/footer landmarks, labelled forms, a register error state wired with aria-describedby and aria-invalid plus a role=alert summary, accessible progress bars, visible focus styles, and a CSS-only staggered reveal that is disabled under prefers-reduced-motion. Typography: Atkinson Hyperlegible for body text (designed for low-vision legibility), Sora for display, JetBrains Mono for the decorative code card. Verified in a headless browser: light (Latte) and dark (Mocha) renders, the accessibility tree (landmarks, alert role, labelled fields), and the computed aria-describedby association on the invalid email field. --- docs/design/mockups/css/base.css | 549 +++++++++++++++++++ docs/design/mockups/css/theme-catppuccin.css | 65 +++ docs/design/mockups/css/theme-soft-paper.css | 64 +++ docs/design/mockups/dashboard.html | 85 +++ docs/design/mockups/home.html | 92 ++++ docs/design/mockups/index.html | 67 +++ docs/design/mockups/login.html | 67 +++ docs/design/mockups/register.html | 80 +++ 8 files changed, 1069 insertions(+) create mode 100644 docs/design/mockups/css/base.css create mode 100644 docs/design/mockups/css/theme-catppuccin.css create mode 100644 docs/design/mockups/css/theme-soft-paper.css create mode 100644 docs/design/mockups/dashboard.html create mode 100644 docs/design/mockups/home.html create mode 100644 docs/design/mockups/index.html create mode 100644 docs/design/mockups/login.html create mode 100644 docs/design/mockups/register.html 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 + 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 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 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 @@ + + + + + + Dashboard — learn-dev + + + + + + + + + + + +
+

Welcome back, carol 👋

+

Here is where you left off.

+ +

Your numbers

+
    +
  • + 3 + Courses enrolled +
  • +
  • + 27 + Lessons completed +
  • +
  • + 6 + Day streak +
  • +
+ +
+

Your courses

+
    +
  • +

    Java fundamentals

    +

    12 of 16 lessons completed

    + +
  • +
  • +

    SQL and relational databases

    +

    9 of 14 lessons completed

    + +
  • +
  • +

    Accessible HTML and CSS

    +

    6 of 18 lessons completed

    + +
  • +
+
+
+ +
+ +
+ + diff --git a/docs/design/mockups/home.html b/docs/design/mockups/home.html new file mode 100644 index 0000000..aa8ecee --- /dev/null +++ b/docs/design/mockups/home.html @@ -0,0 +1,92 @@ + + + + + + learn-dev — Learn programming by doing + + + + + + + + + + + +
+
+
+

+ Learn programming by doing +

+

+ Interactive lessons, hands-on exercises, and instant feedback. + Build real skills at your own pace, one lesson at a time. +

+ +
+ +
+ +
+

Why learn-dev?

+
    +
  • + +

    Interactive lessons

    +

    + Read, try, and experiment in the same place. Every concept comes + with editable examples. +

    +
  • +
  • + +

    Instant feedback

    +

    + Automated assessment tells you what works and what to fix, + the moment you submit. +

    +
  • +
  • + +

    Track your progress

    +

    + Courses, lessons, and streaks: watch your skills grow on your + personal dashboard. +

    +
  • +
+
+
+ +
+ +
+ + diff --git a/docs/design/mockups/index.html b/docs/design/mockups/index.html new file mode 100644 index 0000000..78ae02e --- /dev/null +++ b/docs/design/mockups/index.html @@ -0,0 +1,67 @@ + + + + + + learn-dev mockups + + + + + + + + + + + +
+

Mockup gallery

+

+ Static HTML mockups for the learn-dev frontend, styled by the + Catppuccin theme (default). Structure, BEM classes, + and accessibility wiring mirror the future Thymeleaf templates. + See docs/design/theme-exploration.md for tokens and + WCAG contrast ratios. +

+ +
    +
  • +

    Home

    +

    Hero, features, decorative code card.

    +
  • +
  • +

    Log in

    +

    Post-registration success state.

    +
  • +
  • +

    Register

    +

    Field-error state with aria wiring.

    +
  • +
  • +

    Dashboard

    +

    Stats and course progress (authenticated).

    +
  • +
+ +

Themes

+

+ Dark mode follows the OS preference (prefers-color-scheme). + To preview the alternate Soft Paper theme, edit the + theme <link> in a page head to point at + css/theme-soft-paper.css: both themes define the same + token names (no runtime switcher in v1, by decision). +

+
+ +
+ +
+ + diff --git a/docs/design/mockups/login.html b/docs/design/mockups/login.html new file mode 100644 index 0000000..69a497a --- /dev/null +++ b/docs/design/mockups/login.html @@ -0,0 +1,67 @@ + + + + + + Log in — learn-dev + + + + + + + + + + + +
+ +

+ Account created, please log in. +

+ +
+

Log in

+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ +
+ +
+ + diff --git a/docs/design/mockups/register.html b/docs/design/mockups/register.html new file mode 100644 index 0000000..7a1293c --- /dev/null +++ b/docs/design/mockups/register.html @@ -0,0 +1,80 @@ + + + + + + Create an account — learn-dev + + + + + + + + + + + +
+ + + +
+

Create an account

+ +
+
+ + 3 to 50 characters. + +
+ +
+ + + Email already registered +
+ +
+ + At least 12 characters. + +
+ + +
+ + +
+
+ +
+ +
+ + From 5790e1c4824090394ee405385899615fe1b67edb Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 9 Jul 2026 14:33:17 +0200 Subject: [PATCH 3/5] docs(design): Add mockup and stylesheet study docs (EN and FR) Add the annotated design documentation for the mockups: - mockups-explained.md and its French counterpart mockups-explained-fr.md (kept in sync): a cross-file walkthrough of register.html and home.html covering the HTML structure (tags, ARIA wiring, RGAA 4.1 criteria) and the CSS (BEM convention, design tokens, specificity, layout idioms). - One companion doc per mockup page under docs/design/mockups/ explaining every tag, its attributes, its structural partners, the alternatives rejected, and the RGAA criteria it serves. - One companion doc per stylesheet explaining every rule (base.css) and every token with its upstream origin and WCAG contrast ratio (both theme files). --- docs/design/mockups-explained-fr.md | 676 ++++++++ docs/design/mockups-explained.md | 621 +++++++ docs/design/mockups/base-css-explained.md | 1459 +++++++++++++++++ .../mockups/dashboard-html-explained.md | 259 +++ docs/design/mockups/home-html-explained.md | 616 +++++++ docs/design/mockups/index-html-explained.md | 139 ++ docs/design/mockups/login-html-explained.md | 249 +++ .../design/mockups/register-html-explained.md | 269 +++ .../mockups/theme-catppuccin-css-explained.md | 338 ++++ .../mockups/theme-soft-paper-css-explained.md | 248 +++ 10 files changed, 4874 insertions(+) create mode 100644 docs/design/mockups-explained-fr.md create mode 100644 docs/design/mockups-explained.md create mode 100644 docs/design/mockups/base-css-explained.md create mode 100644 docs/design/mockups/dashboard-html-explained.md create mode 100644 docs/design/mockups/home-html-explained.md create mode 100644 docs/design/mockups/index-html-explained.md create mode 100644 docs/design/mockups/login-html-explained.md create mode 100644 docs/design/mockups/register-html-explained.md create mode 100644 docs/design/mockups/theme-catppuccin-css-explained.md create mode 100644 docs/design/mockups/theme-soft-paper-css-explained.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/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/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, +`<html>`, `<head>`, 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 `<title>` 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, `
`, `
`, `