Skip to content

::Last-Child – Ted Drake

Accessibility | Inclusion | Belonging | DEI AF

Learn from your AI Development process

early morning traffic in Paris

Summary

Have Claude create a test strategy from your projects to save time and improve quality on your next AI development project.

You’ve started an AI project and have a good v1. To get here, you probably had to do a lot of checking, adding requests, changing logic, etc. Don’t let that process go to waste.

Use this prompt: 


Based on the work done in this project. Create a test plan that could have been created at the start to reduce the churn. What should we have set as baselines in advance. This will make the next project more efficient. Write this in a manner that would be easily adopted within a future claude code project.

This should trigger Claude to create a new test-plan-template.md (or similar named file) that includes a phased approach to building your next application. Using this file at the start of your next project will not only save you time, it will increase your quality, and save money. 

Here’s the document created after building the Student Personas project

 

# Test Plan Template — Accessible Static Web App with PDF Output

A baseline test plan for projects that combine a WCAG-compliant web app with a data-driven accessible PDF. Written as a checklist Claude Code can execute incrementally as features are built. Establishes acceptance criteria *before* coding so churn is minimised.

This document was retrospectively derived from the Student Support Persona Cards project. Use it at the start of similar projects.

## How to use this in Claude Code

Copy this file to your project as `docs/test-plan.md`. At the start of each work session, reference it:

> “Work through the unchecked items in docs/test-plan.md for the [section] phase.”

Claude Code will run the checks, mark items `[x]`, and flag failures before moving on. Never ship a feature before its test section is checked.

## Phase 0 — Project Baselines (set BEFORE writing code)

These are decisions that, if made late, cause rework. Settle them first.

### URL Strategy

– [ ] Decided: clean slugs via `.htaccess` OR query strings OR hash routing?

– [ ] If slugs: `slugify()` function written once in Python AND replicated exactly in JS — verified they produce identical output for all edge cases (spaces, ampersands, parentheses, accents)

– [ ] Slug edge case test: titles containing `&`, `/`, `(`, `)`, `,`, `’`, accented characters

“`python

# Canonical slugify — replicate this exactly in JavaScript

import re

def slugify(title):

    s = title.lower()

    s = s.replace(‘&’, ‘and’).replace(‘/’, ‘-‘)

    s = re.sub(r”[^a-z0-9\s-]”, ”, s)

    s = re.sub(r'[\s-]+’, ‘-‘, s).strip(‘-‘)

    return s

“`

“`javascript

// Must match Python output exactly

function slugify(str) {

    return String(str)

        .toLowerCase()

        .replace(/&/g, ‘and’)

        .replace(/[^a-z0-9]+/g, ‘-‘)

        .replace(/^-|-$/g, ”);

}

“`

– [ ] Slug parity test run: all data titles slugified in both Python and JS — zero mismatches

### Data Contract

– [ ] JSON schema documented (field names, types, required vs optional, array vs string)

– [ ] All optional fields have `.get(“field”, default)` / `.at(“field”, default:)` handling — no KeyError on missing data

– [ ] Test card with minimal fields (only required) renders without errors

– [ ] Test card with maximum fields (all optional populated) renders without errors

### Cache Busting

– [ ] Query string version decided: `data/cards.json?v=1`

– [ ] Version increment procedure documented (where to change it, when to change it)

– [ ] Confirmed: server sets appropriate `Cache-Control` headers for JSON

### Accessibility Non-Negotiables (agree before first line of HTML)

– [ ] Heading hierarchy plan written down: which element = H1, H2, H3 on each page type

– [ ] ARIA patterns identified for interactive components (tabs, filters, modals)

– [ ] Color palette contrast ratios pre-checked: all text/background pairs ≥ 4.5:1 (AA)

– [ ] Touch target minimum: 44×44px (recommended) or 24×24px (WCAG 2.5.8 minimum)

– [ ] Font choice confirmed accessible (consider Atkinson Hyperlegible for learning contexts)

### PDF Accessibility Strategy (if PDF required)

– [ ] PDF generator chosen — **decide before writing generator code:**

  – Typst (`brew install typst`, `–pdf-standard ua-1`) — native PDF/UA, recommended

  – ReportLab + pikepdf — viable but requires ~200 lines of post-processing

  – WeasyPrint — partial PDF/UA, not yet reliable

– [ ] Heading hierarchy for PDF documented separately (PDF H1/H2 structure may differ from HTML)

– [ ] Named destination strategy for ToC links decided (Typst handles natively; ReportLab requires AnchorFlowable + afterFlowable tracking)

## Phase 1 — HTML Structure

### Semantic Structure

– [ ] Single `<h1>` per page

– [ ] Heading levels are consecutive — no skipped levels (e.g. H1→H3)

– [ ] All `<section>` elements have `aria-labelledby` pointing to a heading

– [ ] `<header role=”banner”>`, `<main id=”main-content”>`, `<footer role=”contentinfo”>` present

– [ ] Skip link: `<a class=”skip-link” href=”#main-content”>Skip to main content</a>` as first focusable element

– [ ] `<html lang=”en”>` set

### Lists

– [ ] All styled lists (CSS removes bullets/padding) have `role=”list”` — fixes VoiceOver + Safari issue

– [ ] `<li>` elements only appear inside `<ul>` or `<ol>`

### Images

– [ ] Every `<img>` has `alt` attribute (empty string `alt=””` for decorative)

– [ ] SVG icons used inline or as `<img>` have `aria-hidden=”true”` if decorative

– [ ] SVG icons have `<title>` if meaningful

### Links

– [ ] No empty `href` or `href=”#”` on interactive elements — use `<button>` instead

– [ ] External links: `target=”_blank”` + `rel=”noopener noreferrer”` + visually-hidden ” (opens in new tab)”

– [ ] No duplicate link text for different destinations (or use `aria-label` / `aria-labelledby` to disambiguate)

### Forms / Buttons

– [ ] Every `<button>` has visible text or `aria-label`

– [ ] Filter/toggle buttons use `aria-pressed=”true/false”` (not `aria-selected`)

– [ ] `aria-pressed` state updated in JavaScript on click

## Phase 2 — CSS

### Layout

– [ ] Max-width applied to inner wrapper (`<div class=”header-inner”>`) not to the element itself — prevents background color being clipped

– [ ] `overflow: hidden` on card components clips content to border-radius correctly

– [ ] No `white-space: nowrap` on text that should wrap at larger font sizes

### Focus Styles

– [ ] `:focus-visible` outline on all interactive elements — minimum 3px solid, 2px offset

– [ ] Focus style not suppressed with `outline: none` without an equivalent replacement

– [ ] Card stretched-link pattern: `::after { position: absolute; inset: 0 }` on link, `position: relative` on card container

### Hover

– [ ] Hover: border/outline change only (no box-shadow that causes layout shift, no transform)

– [ ] `:has(.link:focus-visible)` used for card-level focus ring when using stretched link pattern

### Print

– [ ] `@media print` block present

– [ ] Navigation, print buttons, grade filter hidden in print: `display: none !important`

– [ ] Print-only content shown: `.print-all-grades { display: block !important }`

– [ ] URLs shown on print for all source links: `.sources-url-text { display: inline }` (hidden on screen)

– [ ] Page canonical URL shown below title on print: `.card-print-url { display: block }`

– [ ] Background colors print correctly: `color-adjust: exact` / `-webkit-print-color-adjust: exact` on colored boxes

– [ ] IEP and callout boxes use `background: #f9f9f9` in print (not color)

### Responsive

– [ ] Grid uses `auto-fill` or `auto-fit` with `minmax()` — no fixed column counts that break at small sizes

– [ ] Text does not overflow containers when browser font size increased to 200% (WCAG 1.4.4)

– [ ] `overflow-wrap: break-word` or `hyphens: auto` on headings to prevent overflow

## Phase 3 — JavaScript

### Data Loading

– [ ] `fetch()` has `.catch()` error handler that renders a meaningful error message

– [ ] JSON parse errors are caught and surfaced to user (not swallowed silently)

– [ ] Loading state shown during fetch: `role=”status” aria-live=”polite”`

– [ ] ID/slug resolution fails gracefully with human-readable error + link back to index

### ARIA Tabs (if used)

– [ ] `role=”tablist”` on container, `role=”tab”` on buttons, `role=”tabpanel”` on panels

– [ ] Active tab: `aria-selected=”true”`, inactive: `aria-selected=”false”`

– [ ] Active tab: `tabindex=”0″`, inactive tabs: `tabindex=”-1″` (roving tabindex)

– [ ] Arrow keys navigate between tabs (ArrowLeft/ArrowRight); Home/End jump to first/last

– [ ] Tab key moves focus into the active panel (not to the next tab)

– [ ] Panels: `aria-labelledby` pointing to their tab button; inactive panels have `hidden` attribute

### Filter Buttons (if used)

– [ ] `aria-pressed` toggled correctly on click

– [ ] Filtered-out items hidden with class (not `display:none` — use class + CSS)

– [ ] Empty category sections hidden when all cards filtered: `section.hidden = true`

– [ ] Filter state survives page reload OR is reset to “all” with clear visual indication

### URL / Routing

– [ ] Slug extracted from `window.location.pathname` as primary lookup method

– [ ] `?id=N` query string as fallback (local dev compatibility)

– [ ] `slugify()` handles all data titles correctly — verified against full data set

## Phase 4 — Accessibility Audit

Run these tools in order. Fix failures before proceeding to the next phase.

### Automated (axe-core)

“`bash

npm install –save-dev axe-core jest jest-environment-jsdom

# or use the axe DevTools browser extension

“`

– [ ] 0 axe violations on index page

– [ ] 0 axe violations on card detail page (test with at least 3 different card IDs)

– [ ] 0 axe violations on error state (test with invalid ID)

**Common violations to check manually even if axe passes:**

– [ ] `aria-label` on element with no ARIA role (`aria-prohibited-attr`) — remove label or add role

– [ ] Empty `aria-labelledby` value — remove attribute or point to valid ID

– [ ] `role=”group”` on `<div>` with `aria-label` — correct or remove

### Manual keyboard test

– [ ] Tab order is logical — matches visual reading order

– [ ] All interactive elements reachable by keyboard

– [ ] No keyboard trap — Tab always moves focus forward

– [ ] Stretched card links: entire card is clickable, focus ring appears on card (not just the button)

– [ ] Grade filter buttons fully operable by keyboard

### Screen reader spot-check

– [ ] VoiceOver (macOS Safari): H key navigates headings correctly

– [ ] VoiceOver: Tab navigates to all interactive elements

– [ ] VoiceOver: Dynamic content updates announced (filter changes, card loading)

– [ ] NVDA (Windows, if available): same checks

### Colour contrast

– [ ] All body text on backgrounds: ≥ 4.5:1

– [ ] Large text (18pt+ or 14pt+ bold): ≥ 3:1

– [ ] UI components (buttons, inputs, focus rings): ≥ 3:1 against adjacent colors

– [ ] White text on coloured backgrounds (blue bars, grade headers): verify each color

“`python

# Quick contrast check

from colorsys import rgb_to_hls

def relative_luminance(hex_color):

    r, g, b = [int(hex_color[i:i+2], 16)/255 for i in (1,3,5)]

    def linearize(c): return c/12.92 if c <= 0.04045 else ((c+0.055)/1.055)**2.4

    return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b)

def contrast(c1, c2):

    l1, l2 = relative_luminance(c1), relative_luminance(c2)

    lighter, darker = max(l1,l2), min(l1,l2)

    return (lighter + 0.05) / (darker + 0.05)

# Pre-check all pairs before starting design

pairs = [

    (“#ffffff”, “#1d4e89”),  # white on blue

    (“#ffffff”, “#1a5276”),  # white on elem grade

    (“#ffffff”, “#145a32”),  # white on middle grade

    (“#ffffff”, “#6e2f0c”),  # white on high grade

    (“#1a1a1a”, “#ffffff”),  # dark text on white

    (“#1a1a1a”, “#fffde7”),  # dark text on IEP yellow

    (“#1a1a1a”, “#e8f5e9”),  # dark text on family green

    (“#1a1a1a”, “#f4f4f0”),  # dark text on profile grey

]

for fg, bg in pairs:

    ratio = contrast(fg, bg)

    status = “✅” if ratio >= 4.5 else “⚠️ FAIL”

    print(f”{status}  {fg} on {bg}: {ratio:.1f}:1″)

“`

## Phase 5 — PDF Accessibility Audit

### Pre-build checklist

– [ ] Heading hierarchy in PDF is consecutive — no skipped levels

– [ ] If Typst: `–pdf-standard ua-1` flag used — compiler enforces hierarchy

– [ ] If ReportLab: pikepdf post-processor planned before writing generator

### Automated audit script

“`python

# Run after every PDF build

import pypdf, pikepdf, re

from pathlib import Path

def audit_pdf(pdf_path):

    pdf_pk = pikepdf.open(pdf_path)

    pdf_py = pypdf.PdfReader(pdf_path)

    root = pdf_pk.Root

    results = []

    checks = [

        (“/Title in metadata”,      bool(pdf_py.metadata.get(“/Title”))),

        (“/Lang on root”,            str(root.get(“/Lang”,””)) not in (“”, “””)),

        (“MarkInfo/Marked = true”,   bool(root.get(“/MarkInfo”,{}).get(“/Marked”,False))),

        (“StructTreeRoot present”,   root.get(“/StructTreeRoot”) is not None),

        (“XMP metadata present”,     root.get(“/Metadata”) is not None),

        (“Outlines/bookmarks”,       bool(root.get(“/Outlines”,{}).get(“/First”))),

        (“Named destinations”,       root.get(“/Names”,{}).get(“/Dests”) is not None),

        (“DisplayDocTitle = true”,   bool(root.get(“/ViewerPreferences”,{}).get(“/DisplayDocTitle”,False))),

    ]

    # Page-level checks

    pages_with_tabs = sum(1 for p in pdf_pk.pages if p.get(“/Tabs”))

    checks.append((f”All pages have /Tabs ({len(pdf_pk.pages)} pages)”,

                   pages_with_tabs == len(pdf_pk.pages)))

    # Content stream BDC markers (sample)

    page = pdf_pk.pages[min(3, len(pdf_pk.pages)-1)]

    contents = page.get(“/Contents”)

    if contents:

        try:

            stream = bytes(contents.read_bytes()) if not isinstance(contents, pikepdf.Array) \

                     else b””.join(bytes(c.read_bytes()) for c in contents)

            bdc = len(re.findall(rb”\bBDC\b”, stream))

            checks.append((f”BDC content markers on sample page ({bdc} found)”, bdc > 0))

        except: pass

    passed = sum(1 for _, v in checks if v)

    print(f”\nPDF Audit: {Path(pdf_path).name}”)

    for label, ok in checks:

        print(f”  {‘✅’ if ok else ‘❌’}  {label}”)

    print(f”\n  {passed}/{len(checks)} checks passed”)

    return passed == len(checks)

“`

### Manual PDF checks

– [ ] ToC entries are internal links (click → jumps within PDF, no “Open in Another App” dialog)

– [ ] Bookmarks panel populated in PDF viewer

– [ ] Text is selectable and copy-pastes correctly

– [ ] Reading order is logical when using screen reader (test with Adobe Acrobat Read Aloud)

– [ ] Page numbers visible in footer

– [ ] US Letter page size: `float(mbox.width)/72 == 8.5`, `float(mbox.height)/72 == 11.0`

### Typst-specific

– [ ] `typst compile –pdf-standard ua-1` exits 0 (no errors)

– [ ] ToC populated: `depth: 2` used if card titles are H2

– [ ] Outline `#outline()` only shows headings with `outlined: true`

– [ ] Show rules include heading element in output (not replaced with plain block) — otherwise ToC empty

### ReportLab-specific

– [ ] `AnchorFlowable` used (not `canvas.bookmarkHorizontal` — produces nothing)

– [ ] `afterFlowable()` hook on `BaseDocTemplate` subclass tracks page numbers

– [ ] pikepdf post-processor rewrites `/URI “card-N”` annotations to `/GoTo` with named dest

– [ ] `/Names /Dests` table built from `anchor_page_map` — 0 dangling destinations

– [ ] All `Table()` wrappers used for coloured boxes (not `Paragraph borderPadding` — unreliable)

## Phase 6 — Deployment Checklist

### Server configuration

– [ ] `.htaccess` slug rewrites deployed to correct directory (not parent)

– [ ] `AllowOverride All` enabled on server — test by visiting one slug URL

– [ ] `Cache-Control` headers set appropriately for JSON data files

– [ ] All favicon files uploaded: `favicon.ico`, `favicon.svg`, `favicon-16×16.png`, `favicon-32×32.png`, `apple-touch-icon.png`, `android-chrome-192×192.png`, `android-chrome-512×512.png`, `site.webmanifest`

### Cache busting on deploy

– [ ] If JSON data changed: increment `?v=N` in JS `fetch()` call before deploying

– [ ] Hard-refresh browser after deploy to confirm cache cleared

### Smoke tests after deploy

– [ ] Index page loads and cards display

– [ ] Grade filter buttons work

– [ ] At least one slug URL resolves correctly (e.g. `/dyslexia`)

– [ ] At least one URL containing `&` in title resolves correctly (e.g. `/stuttering-and-fluency`)

– [ ] PDF download link works and file opens

– [ ] Favicon appears in browser tab

– [ ] Mobile: touch targets tappable, layout not broken at 375px width

## Phase 7 — Regression Tests (run before any major change)

### Data change (cards.json updated)

– [ ] Increment `?v=N` in JS fetch

– [ ] Rebuild PDF: `./typst/build.sh` or `python3 generate_pdf.py –force`

– [ ] Spot-check 3 affected cards in browser

– [ ] Re-run axe on at least one affected card

### New card category added

– [ ] Category added to `CATEGORY_ORDER` list in PDF generator

– [ ] `.htaccess` updated with new slugs

– [ ] Index page section added (if hand-authored) or regenerated

### Slug change (title renamed)

– [ ] Old slug added as redirect in `.htaccess` (301 redirect to new slug)

– [ ] JS `slugify()` and Python `slugify()` verified to produce same new slug

– [ ] PDF ToC links verified to resolve

### CSS change

– [ ] Check at 16px, 24px, 32px browser font sizes (200%, 300% zoom equivalent)

– [ ] Check on mobile (375px viewport)

– [ ] Re-run axe — CSS changes can introduce contrast failures

– [ ] Print preview still looks correct