Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .claude/skills/a11y-check/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <skill dir>/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/<a post>/ /blog/author/ /blog/author/<id>/
/blog/category/ /blog/category/<slug>/ /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.
95 changes: 95 additions & 0 deletions .claude/skills/a11y-check/axe.mjs
Original file line number Diff line number Diff line change
@@ -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 <base-url> <path>...
*/
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))
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
4 changes: 3 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(),
],
},
},
Expand Down
Loading