import Chart from "chart.js/auto"; import { useEffect, useMemo, useRef, useState } from "react"; // ============================================================================ // Inline SVG icons (avoids the lucide-react module dependency) // ============================================================================ const SvgIcon = ({ children, className = "w-4 h-4", strokeWidth = 2 }) => ( ); const Play = (p) => ( ); const Pause = (p) => ( ); const RotateCcw = (p) => ( ); const AlertTriangle = (p) => ( ); const Check = (p) => ( ); const ShieldAlert = (p) => ( ); const Archive = (p) => ( ); const GitPullRequest = (p) => ( ); const Database = (p) => ( ); const TrendingDown = (p) => ( ); // ============================================================================ // Model: simulate four a11y-testing adoption strategies over 52 weeks // ============================================================================ // // Shared mechanics: // - Team starts with N existing violations. // - Each week: `newPerWeek` violations introduced (PR regressions), // `fixesPerWeek` violations fixed (intentional a11y work). // - Backlog never goes below zero. // // Per-strategy interpretation of "violations CI would flag on this week's PR": // // A. Gate on zero → every violation is a failure. // B. Suppressions file → only NEW unsuppressed violations fail. Devs // suppress 50% of new ones immediately; the // other 50% fail CI until someone updates the // file. Suppressions never get pruned when the // referenced violation is fixed, so "staleness" // grows unbounded. // C. Per-test opt-in → coverage grows ~1.5% per week (capped at 80%). // Failures = coverage × backlog. // D. Snapshot baselines → baseline matches current state on first run. // New violations fail CI; fixes ratchet the // baseline down automatically. Assume new // regressions get fixed same week (caught in // PR), so net failures ≈ 0. // // This is a simplified model. The point is the SHAPE of each curve over // time, not a precise prediction. // ============================================================================ function simulate({ starting, newPerWeek, fixesPerWeek }) { const weeks = []; let backlog = starting; let suppressions = starting; // all known violations suppressed on day 1 let staleSuppressions = 0; // suppressions that no longer match a live violation let unsuppressedBacklog = 0; // new violations not yet in suppressions file let optIn = 0; // fraction 0..0.8 for (let w = 0; w <= 52; w++) { if (w > 0) { const added = newPerWeek; const applied = Math.min(fixesPerWeek, backlog); backlog = backlog + added - applied; const newlySuppressed = Math.floor(added * 0.5); suppressions += newlySuppressed; unsuppressedBacklog += added - newlySuppressed; staleSuppressions += applied; optIn = Math.min(0.8, optIn + 0.015); } const gateOnZeroFail = backlog; const suppressionsFail = unsuppressedBacklog; const optInFail = Math.round(backlog * optIn); const snapshotBaselineSize = Math.max(0, starting - fixesPerWeek * w); weeks.push({ week: w, backlog, gateOnZeroFail, suppressionsFail, suppressionsStale: staleSuppressions, optInFail, optInCoverage: optIn, snapshotBaseline: snapshotBaselineSize, snapshotRatchetedAway: starting - snapshotBaselineSize, }); } return weeks; } // ============================================================================ // UI primitives // ============================================================================ function Slider({ label, value, onChange, min, max, step = 1, suffix = "" }) { return ( ); } function Badge({ tone, children }) { const tones = { red: "bg-rose-100 text-rose-800 ring-rose-200", amber: "bg-amber-100 text-amber-800 ring-amber-200", green: "bg-emerald-100 text-emerald-800 ring-emerald-200", slate: "bg-slate-100 text-slate-700 ring-slate-200", }; return ( {children} ); } // ============================================================================ // Chart.js plugin: vertical "now" line at the current week // ============================================================================ const nowLinePlugin = { id: "nowLine", afterDraw(chart, _args, options) { const week = options?.week; if (week == null) return; const xScale = chart.scales.x; if (!xScale) return; const x = xScale.getPixelForValue(week); const { ctx, chartArea } = chart; ctx.save(); ctx.strokeStyle = "#0f172a"; ctx.setLineDash([2, 3]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); ctx.restore(); }, }; // ============================================================================ // ChartPanel: one with Chart.js instance, imperatively updated // ============================================================================ function ChartPanel({ labels, datasets, week, extraOptions = {} }) { const canvasRef = useRef(null); const chartRef = useRef(null); useEffect(() => { if (!canvasRef.current) return; chartRef.current = new Chart(canvasRef.current, { type: "line", data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, scales: { x: { display: false, type: "linear", min: 0, max: 52 }, y: { display: false, beginAtZero: true }, ...extraOptions.scales, }, plugins: { legend: { display: false }, tooltip: { mode: "index", intersect: false, backgroundColor: "#ffffff", titleColor: "#475569", bodyColor: "#0f172a", borderColor: "#e2e8f0", borderWidth: 1, padding: 8, titleFont: { size: 11, family: "ui-monospace, SFMono-Regular, monospace" }, bodyFont: { size: 11 }, displayColors: true, boxWidth: 8, boxHeight: 8, usePointStyle: true, callbacks: { title: (items) => `week ${items[0].parsed.x}`, }, }, nowLine: { week }, }, interaction: { mode: "index", intersect: false, axis: "x" }, elements: { point: { radius: 0 } }, }, plugins: [nowLinePlugin], }); return () => { chartRef.current?.destroy(); chartRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const chart = chartRef.current; if (!chart) return; chart.data.labels = labels; chart.data.datasets = datasets; chart.options.plugins.nowLine.week = week; chart.update("none"); }, [labels, datasets, week]); return (
); } // ============================================================================ // StrategyCard: title, icon, ChartPanel, CI status // ============================================================================ function StrategyCard({ title, blurb, icon: Icon, accent, status, week, children }) { return (

{title}

{blurb}

{children}
{status} week {week}
); } // ============================================================================ // Main App // ============================================================================ export default function App() { const [starting, setStarting] = useState(200); const [newPerWeek, setNewPerWeek] = useState(3); const [fixesPerWeek, setFixesPerWeek] = useState(1); const [week, setWeek] = useState(0); const [playing, setPlaying] = useState(false); const timerRef = useRef(null); useEffect(() => { if (!playing) return; timerRef.current = setInterval(() => { setWeek((w) => { if (w >= 52) { setPlaying(false); return 52; } return w + 1; }); }, 220); return () => clearInterval(timerRef.current); }, [playing]); const raw = useMemo( () => simulate({ starting, newPerWeek, fixesPerWeek }), [starting, newPerWeek, fixesPerWeek], ); const labels = useMemo(() => raw.map((r) => r.week), [raw]); const current = raw[week]; // Dataset builders per strategy const dsGateOnZero = useMemo( () => [ { label: "violations flagged", data: raw.map((r) => ({ x: r.week, y: r.gateOnZeroFail })), borderColor: "#e11d48", backgroundColor: "rgba(244, 63, 94, 0.35)", fill: "origin", borderWidth: 2, tension: 0.15, }, ], [raw], ); const dsSuppress = useMemo( () => [ { label: "stale entries", data: raw.map((r) => ({ x: r.week, y: r.suppressionsStale })), borderColor: "#b45309", backgroundColor: "rgba(245, 158, 11, 0.30)", fill: "origin", borderWidth: 2, tension: 0.15, }, { label: "unsuppressed (CI red)", data: raw.map((r) => ({ x: r.week, y: r.suppressionsFail })), borderColor: "#e11d48", backgroundColor: "rgba(244, 63, 94, 0.15)", fill: false, borderWidth: 2, tension: 0.15, }, ], [raw], ); const dsOptIn = useMemo( () => [ { label: "backlog (all tests)", data: raw.map((r) => ({ x: r.week, y: r.backlog })), borderColor: "#94a3b8", backgroundColor: "rgba(148, 163, 184, 0.20)", fill: "origin", borderWidth: 1, borderDash: [2, 2], tension: 0.15, }, { label: "flagged in opted-in tests", data: raw.map((r) => ({ x: r.week, y: r.optInFail })), borderColor: "#4f46e5", backgroundColor: "rgba(99, 102, 241, 0.25)", fill: "origin", borderWidth: 2, tension: 0.15, }, { label: "coverage", data: raw.map((r) => ({ x: r.week, y: r.optInCoverage * 100 })), borderColor: "#a5b4fc", backgroundColor: "transparent", fill: false, borderWidth: 2, yAxisID: "y2", tension: 0.15, borderDash: [4, 2], }, ], [raw], ); const dsSnap = useMemo( () => [ { label: "remaining in baseline", data: raw.map((r) => ({ x: r.week, y: r.snapshotBaseline })), borderColor: "#059669", backgroundColor: "rgba(16, 185, 129, 0.30)", fill: "origin", borderWidth: 2, tension: 0.15, }, { label: "ratcheted away (fixes)", data: raw.map((r) => ({ x: r.week, y: r.snapshotRatchetedAway })), borderColor: "#0f766e", backgroundColor: "transparent", fill: false, borderWidth: 2, borderDash: [4, 2], tension: 0.15, }, ], [raw], ); const statusGateOnZero = current.gateOnZeroFail === 0 ? ( CI green ) : ( CI red ({current.gateOnZeroFail} flagged) ); const statusSuppress = current.suppressionsFail === 0 ? ( {current.suppressionsStale} stale suppressions ) : ( CI red ({current.suppressionsFail} unsuppressed) ); const statusOptIn = ( {Math.round(current.optInCoverage * 100)}% coverage ); const statusSnap = ( baseline: {current.snapshotBaseline} ); const reset = () => { setWeek(0); setPlaying(false); }; const narrative = useMemo( () => buildNarrative({ current, starting, newPerWeek, fixesPerWeek, week }), [current, starting, newPerWeek, fixesPerWeek, week], ); return (

Four ways to enable accessibility tests on a codebase with a backlog

Drag the sliders. Press play. Watch what "CI green" and "violations flagged" look like under each strategy over a year. The premise: the codebase already has a few hundred a11y violations, new regressions land at a certain rate, fixes land at another. Which strategy actually holds up?

{/* Controls */}
{ setPlaying(false); setWeek(Number(e.target.value)); }} className="flex-1 accent-indigo-600" /> week {week} / 52
{/* Four strategy cards */}
{/* Narrative */}

What a developer sees this week

{narrative.map((n) => (
{n.title}
{n.text}
))}
{/* Week-52 summary */}

Week-52 outcome

Strategy Coverage Violations still flagged CI health trend Notes
); } // ============================================================================ // Narrative generator // ============================================================================ function buildNarrative({ current, starting, newPerWeek, fixesPerWeek, week }) { if (!current) return []; const weekLabel = week === 0 ? "Day one." : `Week ${week}.`; return [ { title: "Gate on zero", bg: current.gateOnZeroFail > 0 ? "bg-rose-50/50" : "bg-emerald-50/50", text: current.gateOnZeroFail > 0 ? `${weekLabel} CI has been red since the feature was enabled. ${current.gateOnZeroFail} violations flag every PR. The team disabled the assertion a while ago; no one remembers when.` : `${weekLabel} The backlog finally hit zero. CI is green. This takes ${Math.ceil(starting / Math.max(1, fixesPerWeek - newPerWeek))} weeks at the current rates, if it's reachable at all.`, }, { title: "Suppressions file", bg: current.suppressionsFail > 0 ? "bg-rose-50/50" : "bg-amber-50/50", text: current.suppressionsFail > 0 ? `${weekLabel} ${current.suppressionsFail} new violations slipped through. Someone forgot to update the suppressions file again. CI red until the next housekeeping pass.` : `${weekLabel} CI green, but ${current.suppressionsStale} entries in the file point at violations that were fixed months ago. Code review glosses over the diff.`, }, { title: "Per-test opt-in", bg: "bg-indigo-50/50", text: `${weekLabel} ${Math.round(current.optInCoverage * 100)}% of tests now include an accessibility assertion. The ones that do catch ${current.optInFail} of the ${current.backlog} existing violations. New features and untouched components remain unchecked.`, }, { title: "Snapshot baselines", bg: "bg-emerald-50/50", text: current.snapshotBaseline === 0 ? `${weekLabel} Baseline hit zero. The team drops the \`snapshot\` option on tests whose baselines cleared, and those tests now gate on any violation at all.` : `${weekLabel} Baseline at ${current.snapshotBaseline} violations, down from ${starting}. CI is green. PRs that introduce new violations are caught and fixed before merge; PRs that fix violations see the baseline file shrink in the diff.`, }, ]; } function Row({ name, coverage, flagged, trend, note }) { const trendTone = trend === "green" ? "text-emerald-700" : trend === "flaky" ? "text-amber-700" : trend === "green, patchy" ? "text-indigo-700" : "text-rose-700"; return ( {name} {coverage} {flagged} {trend} {note} ); }