diff --git a/.claude/skills/a11y-check/SKILL.md b/.claude/skills/a11y-check/SKILL.md new file mode 100644 index 00000000..f3cf56df --- /dev/null +++ b/.claude/skills/a11y-check/SKILL.md @@ -0,0 +1,79 @@ +--- +name: a11y-check +description: Check ddev.com pages for accessibility problems with axe-core and Lighthouse, in both themes and at desktop and phone width, before a change that touches markup, colors, or layout is committed. Use when asked to check accessibility, contrast, or PageSpeed scores, or after changing a layout, component, or global.css. +when_to_use: > + Triggered by "check accessibility", "run axe", "a11y", "contrast", "WCAG", + "PageSpeed", "Lighthouse score", or a change to src/layouts/, + src/components/, or src/styles/. +--- + +# Checking accessibility + +Run the tools on the host, not in the DDEV web container, which has no +browser. Both need Chrome or Chromium; set `CHROME_PATH` to it when it is not +`/usr/bin/chromium`, as on macOS: + +```bash +export CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +``` + +## 1. Build + +Check the production build, not the dev server, whose toolbar adds markup of +its own: + +```bash +ddev npm run build +base=$(ddev describe -j | jq -r .raw.primary_url) +``` + +## 2. axe-core + +Install the packages in `~/tmp`, never in the repository's `package.json`, +and run [`axe.mjs`](axe.mjs) from there: + +```bash +mkdir -p ~/tmp/a11y-tools && cd ~/tmp/a11y-tools +[ -d node_modules ] || { npm init -y >/dev/null && npm install puppeteer-core@24 axe-core@4; } +node /axe.mjs "$base" / /blog/ /download/ > axe.json +jq 'group_by(.id) | map({id: .[0].id, count: length, example: .[0]})' axe.json +``` + +Pass every page type the change can reach. For a change to the layout, +header, footer, or `global.css`, that is all of them: + +```text +/ /about/ /blog/ /blog// /blog/author/ /blog/author// +/blog/category/ /blog/category// /brand/ /download/ /get-started/ +/sponsor/ /usage-stats/ +``` + +For the post, pick one whose code blocks have comments in them, such as +`/blog/shopware-on-ddev/`, since comments are the lightest code color. + +The script checks each path in light and dark at 1400 and 390 px wide, since +some problems show in only one theme or at one width. It emulates reduced +motion, which stops the home page terminal on its `ddev describe` screen, +then plays the `ddev start` screen and checks the terminal again. + +Fix every violation the change introduced. To tell which were there before, +run the same paths with `https://ddev.com` as the base; report those, and +leave them for a separate PR unless asked. + +## 3. Lighthouse + +The PageSpeed Insights API shares its quota across everyone who calls it +without a key, so it usually fails. Run Lighthouse locally instead: + +```bash +npx -y lighthouse@12 "$base/" --quiet --output=json --output-path=lh.json \ + --chrome-flags="--headless=new --ignore-certificate-errors" +jq '.categories | map_values(.score * 100)' lh.json +``` + +Add `--preset=desktop` for the desktop scores. Locally, best practices loses +points to a 404 for `/s/sponsorship-data.json`, which `public/_redirects` +answers on Cloudflare; ignore that one. + +For the live site, run the same command against `https://ddev.com/`, or a +PR's Cloudflare preview. diff --git a/.claude/skills/a11y-check/axe.mjs b/.claude/skills/a11y-check/axe.mjs new file mode 100644 index 00000000..d279b738 --- /dev/null +++ b/.claude/skills/a11y-check/axe.mjs @@ -0,0 +1,95 @@ +/** + * Runs axe-core on each path, in both themes, at desktop and phone width, and + * prints the violations as JSON. Modules resolve from the working directory, + * so the repository's package.json doesn't need them. + * + * Usage: node axe.mjs ... + */ +import { createRequire } from "module" +import { readFileSync } from "fs" + +const require = createRequire(`${process.cwd()}/`) +const puppeteer = require("puppeteer-core") +const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8") + +const [base, ...paths] = process.argv.slice(2) +const viewports = [ + { width: 1400, height: 900 }, + { width: 390, height: 844, isMobile: true, hasTouch: true }, +] + +const browser = await puppeteer.launch({ + executablePath: process.env.CHROME_PATH ?? "/usr/bin/chromium", + args: ["--no-sandbox", "--ignore-certificate-errors"], +}) +const violations = [] +for (const path of paths) { + for (const scheme of ["light", "dark"]) { + for (const viewport of viewports) { + const page = await browser.newPage() + await page.setViewport(viewport) + // Reduced motion stops the home page terminal on one screen, so every + // run checks the same markup. + await page.emulateMediaFeatures([ + { name: "prefers-color-scheme", value: scheme }, + { name: "prefers-reduced-motion", value: "reduce" }, + ]) + await page.goto(base + path, { + waitUntil: "networkidle2", + timeout: 60000, + }) + await page.evaluate(axeSource) + const check = async (context) => { + const result = await page.evaluate( + (context) => + axe.run(context, { + runOnly: { + type: "tag", + values: [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa", + "best-practice", + ], + }, + }), + context + ) + for (const violation of result.violations) { + for (const node of violation.nodes) { + violations.push({ + path, + scheme, + width: viewport.width, + id: violation.id, + impact: violation.impact, + target: node.target.join(" "), + html: node.html.slice(0, 160), + summary: node.failureSummary?.split("\n")[1]?.trim(), + }) + } + } + } + await check("html") + // The terminal's other screen, `ddev start`, plays once and is paused + // at its end, since reduced motion only ever shows `ddev describe`. + if (await page.$("#animation-toggle").then((e) => e?.isVisible())) { + await page.click("#animation-toggle") + await page.waitForFunction( + () => + document + .getElementById("animation-stage") + .textContent.includes("alternate URLs"), + { timeout: 60000 } + ) + await page.click("#animation-toggle") + await check(".terminal-wrapper") + } + await page.close() + } + } +} +await browser.close() +console.log(JSON.stringify(violations, null, 1)) diff --git a/AGENTS.md b/AGENTS.md index d5bc03e1..00785cc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ its own. | Screenshots or feature images for a post | `.claude/skills/blog-images/SKILL.md` | | Adding or updating a featured sponsor | `.claude/skills/add-sponsor/SKILL.md` | | Bumping npm dependencies | `.claude/skills/bump-deps/SKILL.md` | +| Accessibility checks with axe-core and Lighthouse | `.claude/skills/a11y-check/SKILL.md` | | A commit or PR | `.claude/skills/ddev-commit/SKILL.md` | | A comment, in any code file | `.claude/rules/comments.md` | diff --git a/astro.config.mjs b/astro.config.mjs index 27bb94b8..c3d22966 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -25,6 +25,7 @@ import searchIndex from "./src/lib/search-index.js" import sitemap from "@astrojs/sitemap" import tailwindcss from "@tailwindcss/vite" import { addCopyButton } from "shiki-transformer-copy-button" +import { focusableCode } from "./src/lib/shiki-focusable-code.mjs" import { SHIKI_THEMES } from "./src/const.ts" import { unified } from "@astrojs/markdown-remark" @@ -134,8 +135,9 @@ export default defineConfig({ // toggle: 3000, // 3 seconds transformers: [ addCopyButton({ - // visibility: 'hover', // if you want it to only show on hover + button: { "aria-label": "Copy code" }, }), + focusableCode(), ], }, }, diff --git a/src/components/AnimatedTerminal.astro b/src/components/AnimatedTerminal.astro index c5a7d8ed..022a47af 100644 --- a/src/components/AnimatedTerminal.astro +++ b/src/components/AnimatedTerminal.astro @@ -6,43 +6,44 @@ * things drift out of sync!) */ import { getLatestReleaseVersion } from "../lib/api" +import { PauseIcon, PlayIcon } from "@heroicons/react/20/solid/index.js" const latestRelease = await getLatestReleaseVersion() // Pre-rendered ddev describe output — used for both the animation and noscript fallback const versionText = `DDEV version: ${latestRelease}` -const versionLine = `│ ${versionText}${" ".repeat(83 - versionText.length)} │` +const versionLine = `│ ${versionText}${" ".repeat(85 - versionText.length)} │` const describeOutput = `ddev describe -┌─────────────────────────────────────────────────────────────────────────────────────┐ -│ Project: my-project ~/dev/my-project https://my-project.ddev.site │ -│ Docker platform: docker-desktop │ -│ Router: traefik │ +┌───────────────────────────────────────────────────────────────────────────────────────┐ +│ Project: my-project ~/dev/my-project https://my-project.ddev.site │ +│ Docker platform: linux-docker │ +│ Router: traefik │ ${versionLine} -├────────────┬──────┬────────────────────────────────────────────┬────────────────────┤ -│ SERVICE │ STAT │ URL/PORT │ INFO │ -├────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ -│ web │ OK │ https://my-project.ddev.site │ php PHP 8.4 │ -│ │ │ InDocker -> Host: │ Server: nginx-fpm │ -│ │ │ - web:80 -> 127.0.0.1:51046 │ Docroot: '' │ -│ │ │ - web:443 -> 127.0.0.1:51047 │ Perf mode: none │ -│ │ │ - web:8025 -> 127.0.0.1:51048 │ NodeJS: 24 │ -├────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ -│ db │ OK │ InDocker -> Host: │ mysql:8.0 │ -│ │ │ - db:3306 -> 127.0.0.1:51045 │ User/Pass: 'db/db' │ -│ │ │ │ or 'root/root' │ -├────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ -│ xhgui │ OK │ https://my-project.ddev.site:8142 │ │ -│ │ │ InDocker: │ │ -│ │ │ - xhgui:80 │ │ -│ │ │ Launch: ddev xhgui │ │ -├────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ -│ Mailpit │ │ Mailpit: https://my-project.ddev.site:8026 │ │ -│ │ │ Launch: ddev mailpit │ │ -├────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ -│ All URLs │ │ https://my-project.ddev.site, │ │ -│ │ │ https://127.0.0.1:51047, │ │ -│ │ │ http://my-project.ddev.site, │ │ -│ │ │ http://127.0.0.1:51046 │ │ -└────────────┴──────┴────────────────────────────────────────────┴────────────────────┘` +├──────────────┬──────┬────────────────────────────────────────────┬────────────────────┤ +│ SERVICE │ STAT │ URL/PORT │ INFO │ +├──────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ +│ web │ OK │ https://my-project.ddev.site │ php PHP 8.4 │ +│ │ │ InDocker -> Host: │ Server: nginx-fpm │ +│ │ │ - web:80 -> 127.0.0.1:32865 │ Docroot: 'public' │ +│ │ │ - web:443 -> 127.0.0.1:32866 │ Perf mode: none │ +│ │ │ - web:8025 -> 127.0.0.1:32867 │ Node.js: 24 │ +├──────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ +│ db │ OK │ InDocker -> Host: │ mariadb:11.8 │ +│ │ │ - db:3306 -> 127.0.0.1:32868 │ User/Pass: 'db/db' │ +│ │ │ │ or 'root/root' │ +├──────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ +│ xhgui │ OK │ https://my-project.ddev.site:8142 │ │ +│ │ │ InDocker: │ │ +│ │ │ - xhgui:80 │ │ +│ │ │ Launch: ddev xhgui │ │ +├──────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ +│ Mailpit │ │ Mailpit: https://my-project.ddev.site:8026 │ │ +│ │ │ Launch: ddev mailpit │ │ +├──────────────┼──────┼────────────────────────────────────────────┼────────────────────┤ +│ Project URLs │ │ https://my-project.ddev.site │ │ +│ │ │ https://127.0.0.1:32866 │ │ +│ │ │ http://my-project.ddev.site │ │ +│ │ │ http://127.0.0.1:32865 │ │ +└──────────────┴──────┴────────────────────────────────────────────┴────────────────────┘` --- @@ -251,9 +319,10 @@ Waiting for ddev-router to become ready... ready in 1.0s /* data-theme is set by the inline head script before body renders, * so these rules apply on the very first paint with no flash. */ html[data-theme] #noscript-pre { display: none; } - html:not([data-theme]) #animated-pre { display: none; } + html:not([data-theme]) #animated-pre, + html:not([data-theme]) #animation-toggle { display: none; } - /* The output is a fixed 87x32 character grid: this font size fits 87 columns + /* The output is a fixed 89x32 character grid: this font size fits 89 columns * inside the 40rem the caller gives it, and the height is in `em` so the box * stays in step with the font. */ .terminal-body { @@ -264,12 +333,26 @@ Waiting for ddev-router to become ready... ready in 1.0s
-
+
+ +
-
+

+ A terminal runs ddev start, which starts the project's containers, then ddev + describe, which lists the project's services and their URLs. +

+
diff --git a/src/components/BlogPostCard.astro b/src/components/BlogPostCard.astro index 4681c4d2..42ed2eb0 100644 --- a/src/components/BlogPostCard.astro +++ b/src/components/BlogPostCard.astro @@ -8,13 +8,16 @@ import { Picture } from 'astro:assets' import { resolveImage } from "../lib/images" interface Props { post: CollectionEntry<"blog"> + // The title's heading level: 3 under a section heading, 2 on a listing page + level?: 2 | 3 } import CategoryPills from "./CategoryPills.astro" import { getEntry } from "astro:content" import { getSlug, formatDate } from "../lib/api" -const { post } = Astro.props +const { post, level = 2 } = Astro.props +const Title = `h${level}` const { remarkPluginFrontmatter } = await render(post) const author = await getEntry("authors", getSlug(post.data.author)) @@ -75,11 +78,11 @@ const categories = post.data.categories.length ? post.data.categories : []
{ diff --git a/src/components/CtaButton.astro b/src/components/CtaButton.astro index ada45c8a..c0b7c0ca 100644 --- a/src/components/CtaButton.astro +++ b/src/components/CtaButton.astro @@ -11,7 +11,7 @@ export interface Props { const typeClasses = { default: `border border-transparent text-white bg-blue-650 hover:bg-blue-800`, - hollow: `border border-blue-650 text-blue-650 hover:border-blue-800 hover:text-blue-800 dark:border-slate-400 dark:text-slate-300 dark:hover:border-white dark:hover:text-white`, + hollow: `border border-blue-700 text-blue-700 hover:border-blue-800 hover:text-blue-800 dark:border-slate-400 dark:text-slate-300 dark:hover:border-white dark:hover:text-white`, } const sizeClasses = { @@ -31,7 +31,6 @@ const { text, href, type = "default", size = "default", target, title } = typeClasses[type]} target={target} title={title} - role="button" > {text} diff --git a/src/components/FeaturedSponsors.astro b/src/components/FeaturedSponsors.astro index 7836d679..d385dd52 100644 --- a/src/components/FeaturedSponsors.astro +++ b/src/components/FeaturedSponsors.astro @@ -1,5 +1,6 @@ --- import featuredSponsors from "../featured-sponsors.json" +import { logoUrl } from "../lib/images" /** * Heights for logos that must render larger than the rest, keyed by the name @@ -36,12 +37,12 @@ regularSponsors.map((sponsor) => { target="_blank" > {`${sponsor.name} @@ -65,12 +66,12 @@ regularSponsors.map((sponsor) => { target="_blank" > {`${sponsor.name} diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 60763ce6..0bf08043 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -19,6 +19,7 @@ import { } from "../const" import Icon from "./Icon.astro" +import { logoUrl } from "../lib/images" /* `me` marks a link that identifies the project itself, for rel="me". */ interface FooterLink { @@ -89,7 +90,7 @@ const feeds = [ DDEV home { + if (event.key === "Escape" && menu.open) { + menu.open = false + menu.querySelector("summary")?.focus() + } + }) + document.addEventListener("click", (event) => { + if (menu.open && !menu.contains(event.target as Node)) menu.open = false + }) + } + // Links leave the bar one at a time as it fills up, instead of all at once at - // a breakpoint that can only guess whether they fit. Opening and closing the - // menu is the browser's job, so there's none of it here. + // a breakpoint that can only guess whether they fit. if (nav && list && logo && icons && menu) { const items = Array.from(list.querySelectorAll("[data-nav-item]")) const menuItems = Array.from(menu.querySelectorAll("[data-nav-item]")) @@ -111,10 +125,10 @@ const navItems = [ aria-label="Main" > diff --git a/src/components/SponsorsBanner.astro b/src/components/SponsorsBanner.astro index 680e72d1..65db044b 100644 --- a/src/components/SponsorsBanner.astro +++ b/src/components/SponsorsBanner.astro @@ -172,7 +172,7 @@ const isOnSustainabilityPage = currentPath === '/blog/sustainability-for-ddev/' } -
+