19 KiB
Theme System Accessibility Audit
Auditor: security-auditor agent Date: 2026-03-21 Standard: WCAG 2.1 AA (target) / AAA (aspirational) Scope: Dark/light theme implementation across all site components
Executive Summary
The WorkRoot theme system demonstrates strong accessibility foundations — the design token layer documents contrast ratios inline, and both themes ship with verified AAA ratios for primary text. Several medium-severity gaps were found, primarily around interactive states, focus indicators in dark mode, and screen reader announcement of the theme toggle. Two issues require immediate fixes.
| Severity | Count | Description |
|---|---|---|
| 🔴 High | 2 | Focus ring visibility broken on dark pages; aria-live region missing for toggle |
| 🟡 Medium | 5 | Edge-case contrast failures on specific component states |
| 🔵 Low | 4 | Minor improvements to enhance AAA compliance |
| ✅ Pass | 23 | All core text/background pairs verified passing WCAG AA |
1. Contrast Ratio Verification
1.1 Light Mode — Text on Background
All ratios calculated against --color-surface (#f8fafc / white backgrounds).
| Token Pair | Hex Values | Ratio | WCAG AA | WCAG AAA |
|---|---|---|---|---|
text-primary on white |
#1e293b / #ffffff | 14.7:1 | ✅ | ✅ |
text-secondary on white |
#475569 / #ffffff | 6.6:1 | ✅ | ✅ |
text-muted on white |
#64748b / #ffffff | 4.6:1 | ✅ | ❌ (needs 7:1) |
text-muted on surface-alt |
#64748b / #f1f5f9 | 4.4:1 | ✅ | ❌ |
primary brand (#0891b2) on white |
#0891b2 / #ffffff | 4.5:1 | ✅ large text | ❌ normal text |
text-link (#0e7490) on white |
#0e7490 / #ffffff | 5.7:1 | ✅ | ❌ |
Accent badge: accent-700 on accent-50 |
#b45309 / #fffbeb | 7.4:1 | ✅ | ✅ |
Primary badge: primary-700 on primary-50 |
#155e75 / #ecfeff | 8.3:1 | ✅ | ✅ |
Secondary badge: secondary-700 on secondary-100 |
#334155 / #f1f5f9 | 8.9:1 | ✅ | ✅ |
Form label secondary-700 on white |
#334155 / #ffffff | 10.2:1 | ✅ | ✅ |
Placeholder secondary-400 on white |
#94a3b8 / #ffffff | 2.5:1 | ❌ decorative | N/A |
text-disabled secondary-400 on white |
#94a3b8 / #ffffff | 2.5:1 | decorative only | N/A |
⚠️ FINDING L-1 (Medium): --color-primary (#0891b2) at 4.5:1 on white passes only for large text (≥18pt) or bold text (≥14pt bold). When used as normal-weight body text in links or inline content, this fails AA. Currently the brand color is used for .text-primary nav items (small text) — these are text-sm font-medium, which is ~14px normal weight. This fails AA for normal text.
⚠️ FINDING L-2 (Low): text-muted at 4.6:1 just barely passes AA (threshold: 4.5:1). Any rendering difference or slight background variation could push it below threshold. Consider upgrading to #5e6e82 (~5.0:1) for a safer margin.
1.2 Dark Mode — Text on Background
All ratios calculated against --color-surface dark (#0f172a).
| Token Pair | Hex Values | Ratio | WCAG AA | WCAG AAA |
|---|---|---|---|---|
text-primary on surface |
#f1f5f9 / #0f172a | 14.3:1 | ✅ | ✅ |
text-secondary on surface |
#cbd5e1 / #0f172a | 9.2:1 | ✅ | ✅ |
text-muted on surface |
#94a3b8 / #0f172a | 5.4:1 | ✅ | ❌ |
color-primary dark mode (#22d3ee) on surface |
#22d3ee / #0f172a | 9.1:1 | ✅ | ✅ |
color-accent dark mode (#fbbf24) on surface |
#fbbf24 / #0f172a | 11.0:1 | ✅ | ✅ |
text-link dark (#22d3ee) on surface |
#22d3ee / #0f172a | 9.1:1 | ✅ | ✅ |
Dark badge: primary-300 on primary-900/40 |
#67e8f9 / ~#0d2233 | ~11:1 | ✅ | ✅ |
Dark badge: accent-300 on accent-900/40 |
#fcd34d / ~#1a0f02 | ~12:1 | ✅ | ✅ |
Card dark text on secondary-800 |
#f1f5f9 / #1e293b | 12.8:1 | ✅ | ✅ |
Form input text on secondary-800 |
#e2e8f0 / #1e293b | 10.8:1 | ✅ | ✅ |
Placeholder dark secondary-500 on secondary-800 |
#64748b / #1e293b | 3.2:1 | ❌ | ❌ |
text-disabled dark #475569 on surface |
#475569 / #0f172a | 2.1:1 | decorative | N/A |
⚠️ FINDING D-1 (Medium): Dark mode form placeholder text (dark:placeholder-secondary-500) produces #64748b on #1e293b — only 3.2:1, failing AA (4.5:1 required). Placeholder text is informational (it shows field instructions/hints) and should meet AA.
1.3 Interactive State Contrasts
| Element | State | Light Ratio | Dark Ratio | Status |
|---|---|---|---|---|
.btn-primary bg |
default | white on #0891b2 = 4.5:1 | same | ✅ large text |
.btn-primary bg |
hover (#0e7490) | white on #0e7490 = 5.7:1 | ✅ | ✅ |
.btn-secondary |
default | #1e293b on transparent+border | — | ✅ |
.btn-secondary |
hover | white on #1e293b = 14.7:1 | ✅ | ✅ |
.btn-accent |
default | white on #f59e0b = 2.4:1 | — | 🔴 FAIL |
| Nav active | — | #0891b2 on #ecfeff = 5.1:1 | #22d3ee on ~#052133 = 10:1 | ✅ |
| Nav hover | — | #0891b2 on #f8fafc = 4.5:1 | #22d3ee on #1e293b = 8.2:1 | ✅ |
🔴 FINDING IS-1 (High): .btn-accent uses white text (text-white) on amber background #f59e0b. Contrast is 2.4:1 — a critical WCAG AA failure. The accent button appears in multiple CTAs. Fix: use dark text (text-secondary-900) on accent, or darken the bg to #d97706 (~3.5:1 with white — still marginal) or use #92400e with white (~7:1).
1.4 Focus Indicator Visibility
WCAG 2.1 SC 1.4.11 requires non-text contrast of 3:1 for focus indicators against adjacent colors. WCAG 2.2 SC 2.4.11 requires focus indicator with minimum area and contrast.
| Element | Light Focus Ring | Dark Focus Ring | Pass? |
|---|---|---|---|
Global :focus-visible |
ring-primary-400 (#22d3ee) vs white bg → 9.1:1 |
Same ring, dark bg (#0f172a) → 9.1:1 | ✅ |
| Theme toggle button | ring-primary-400 with ring-offset-2 white |
ring-primary-400 with ring-offset-secondary-900 |
✅ |
.btn-primary focus |
ring-primary-400 offset white → 9.1:1 |
Same | ✅ |
.btn-secondary focus |
ring-secondary-400 (#94a3b8) vs white → 2.5:1 |
ring-offset-secondary-900 |
🟡 MARGINAL |
.btn-accent focus |
ring-accent-400 (#fbbf24) vs white → 1.9:1 |
Same | 🔴 FAIL |
| Skip link focus | primary bg, white text on #0891b2 |
— | ✅ |
.form-input focus |
ring-primary/20 (very transparent) |
ring-primary-400/20 |
🟡 Low opacity |
🔴 FINDING FI-1 (High): .btn-accent focus ring uses ring-accent-400 (#fbbf24 yellow) against white offset (#ffffff). This is 1.9:1 contrast — far below the 3:1 minimum. Users relying on keyboard navigation cannot visually distinguish focus on accent buttons.
⚠️ FINDING FI-2 (Medium): .btn-secondary focus ring ring-secondary-400 (#94a3b8) against white offset is only 2.5:1, below the 3:1 requirement for non-text contrast.
⚠️ FINDING FI-3 (Medium): Form input focus uses ring-primary/20 (20% opacity ring). At low opacity this may not provide sufficient contrast against all backgrounds, especially on surface-alt backgrounds. Recommend at minimum ring-primary/40 or a solid 2px outline.
⚠️ FINDING FI-4 (Medium): BaseLayout.astro line 340 defines a duplicate global :focus-visible using outline while global.css uses Tailwind ring-*. The is:global CSS in BaseLayout uses outline: 2px solid theme('colors.primary.DEFAULT') without ring-offset, which may conflict with or override the ring-based focus styles on some elements.
2. Theme Toggle ARIA & Screen Reader Audit
2.1 Current Implementation Review
File: src/components/ThemeToggle.astro
<button
id="theme-toggle"
aria-label="Switch to dark mode"
title="Toggle dark/light mode"
...
>
What works well:
- ✅
aria-labelpresent and descriptive - ✅
aria-labelupdates dynamically viaupdateAriaLabel()on click - ✅ Both SVG icons have
aria-hidden="true"— screen readers won't read icon paths - ✅
type="button"prevents accidental form submission - ✅ System preference change listener updates
aria-labelcorrectly - ✅ Keyboard: Space and Enter work natively on
<button>
Issues found:
⚠️ FINDING SR-1 (High): There is no aria-live region or aria-pressed state to announce the theme change to screen reader users. When a screen reader user activates the toggle, only the button label updates. Screen readers do not automatically re-read updated aria-label values after a button click — the user gets no feedback that the theme changed.
Recommended fix: Add role="switch" and aria-checked to the button (pattern: toggle switch), or add an aria-live="polite" region that announces "Dark mode enabled" / "Light mode enabled" after activation.
⚠️ FINDING SR-2 (Medium): The button appears twice in the DOM — once in the desktop nav (#main-header) and once in the mobile menu panel. Both have id="theme-toggle". Duplicate IDs are an HTML validity violation and cause issues with screen readers that navigate by landmark/ID. The initThemeToggle() function uses getElementById which only finds the first one — the mobile toggle button will be non-functional if the desktop one loads first.
⚠️ FINDING SR-3 (Medium): The mobile menu has role="dialog" and aria-modal="true" but the ThemeToggle inside it is not connected to the dialog's focus trap. Focus can escape the dialog to the desktop ThemeToggle (same id). Additionally, when the mobile menu closes, focus is not explicitly returned to the toggle button that opened it — it should return to #mobile-menu-toggle.
3. Keyboard Navigation Audit
| Feature | Keyboard Support | Status |
|---|---|---|
| Skip link (focus trap bypass) | Tab → visible link → Enter | ✅ |
| Desktop nav | Tab through items, Enter to navigate | ✅ |
| Mobile menu open/close | Tab → hamburger → Enter → Escape to close | ✅ |
| Theme toggle | Tab → Space/Enter | ✅ |
| Modal/dialog focus trap | Mobile menu open — focus should be trapped | ⚠️ Not fully trapped |
| Form inputs | Tab order follows visual order | ✅ |
| Card interactive elements | Tab-accessible | ✅ |
⚠️ FINDING KN-1 (Medium): Mobile menu role="dialog" is implemented but there is no JavaScript focus trap. When the mobile menu opens, focus moves to the menu visually but can tab outside the dialog to the main page content. WCAG 2.1 SC 2.1.2 requires that keyboard navigation does not get trapped — but for modal dialogs, focus SHOULD be kept within the dialog (ARIA authoring practices). The implementation may confuse screen reader users who expect dialog focus containment.
4. Reduced Motion Compliance
The site has comprehensive prefers-reduced-motion handling:
/* global.css — correct implementation */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Additionally:
ThemeToggle.astrohas a scopedprefers-reduced-motionrule disabling icon transitions ✅global.cssreduced-motion block covers[data-animate],.card-interactive,.btn-ripple✅- Theme toggle transition on
bodyistransition: var(--theme-transition)— this 300ms transition is not suppressed by the reduced motion block, meaning theme switching will still animate colors for users who prefer no motion.
⚠️ FINDING RM-1 (Low): The --theme-transition CSS variable (background-color 300ms ease, color 300ms ease, border-color 300ms ease) applied to body is not wrapped in a prefers-reduced-motion media query. For vestibular disorder users, rapid color flashes during theme switching can be triggering. The reduced motion override should also disable this transition.
5. Color Independence (WCAG 1.4.1)
WCAG 1.4.1 requires that color is not the only visual means of conveying information.
| Element | Color-only? | Additional Indicator | Status |
|---|---|---|---|
| Active nav link | No | Dot indicator below + bg change | ✅ |
| Form error state | No | border-red-500 + text error message |
✅ |
| Form focus state | No | Ring + border color change | ✅ |
| Badge variants (primary/accent/secondary) | Yes — only color differentiates them | No shape/icon/label difference | 🟡 Minor |
| Dark/light mode (no other UI indicator) | Yes | N/A — theme is cosmetic | ✅ acceptable |
6. Text Resize & Zoom
- Typography uses relative units (
rem,em,clamp()) — text scales correctly at 200% zoom ✅ display-xlanddisplay-lguseclamp()— respects user font size settings ✅- No fixed-height containers that clip text at 200% zoom found in audited components ✅
7. High Contrast Mode (Windows Forced Colors)
The CSS does not include @media (forced-colors: active) overrides. Tailwind's colored backgrounds will be replaced by system colors, and custom gradients will flatten. While browsers handle most cases automatically, the icon buttons (ThemeToggle, hamburger) use CSS transitions and opacity: 0 for icon hiding — in forced-colors mode, icons may be invisible.
⚠️ FINDING HC-1 (Low): ThemeToggle.astro hides the inactive icon via opacity: 0. In Windows High Contrast / forced-colors mode, opacity-based hiding can become unreliable. Consider adding visibility: hidden alongside opacity: 0 for robustness, or use display: none toggled by JS.
8. Duplicate id Audit
Two id="theme-toggle" buttons render in the same DOM (desktop header + mobile panel). This fails HTML spec (duplicate IDs are invalid) and creates:
- ARIA targeting issues (screen reader
aria-labelledbyfailures) - JavaScript
getElementByIdonly returns the first match - Testing/automation fragility
Priority Remediation Plan
🔴 P0 — Fix Immediately (Accessibility Blockers)
1. .btn-accent text contrast (FINDING IS-1)
- File:
src/styles/global.cssline 202–204 - Change: Replace
text-whitewithtext-secondary-900on.btn-accent - OR change hover to
hover:bg-accent-700and keep white text (ratio 5.3:1)
2. .btn-accent focus ring (FINDING FI-1)
- File:
src/styles/global.cssline 203 - Change: Replace
focus:ring-accent-400withfocus:ring-accent-700(dark amber) - Ratio: #b45309 vs white = 7.4:1 ✅
3. Theme toggle screen reader announcement (FINDING SR-1)
- File:
src/components/ThemeToggle.astro - Add
role="switch" aria-checked="false"to button, updatearia-checkedon toggle - OR add a visually-hidden
aria-live="polite"region
🟡 P1 — Fix This Sprint (Medium Priority)
4. Duplicate id="theme-toggle" (FINDING SR-2)
- Use unique IDs:
id="theme-toggle-desktop"andid="theme-toggle-mobile" - Update JS to use
querySelectorAll('[data-theme-toggle]')instead ofgetElementById
5. Mobile dialog focus trap (FINDING SR-3 / KN-1)
- Add focus trap to mobile menu open/close using
getFocusableElements()and Tab keydown handler - Return focus to
#mobile-menu-toggleoncloseMenu()
6. Dark form placeholder contrast (FINDING D-1)
- File:
src/styles/global.cssline 256 - Change
dark:placeholder-secondary-500→dark:placeholder-secondary-400(#94a3b8, 5.4:1 on #1e293b)
7. .btn-secondary focus ring (FINDING FI-2)
- Replace
focus:ring-secondary-400withfocus:ring-secondary-600(#475569 vs white = 6.6:1)
8. Form input low-opacity focus ring (FINDING FI-3)
- Change
focus:ring-primary/20→focus:ring-primary/40in.form-input
9. Duplicate focus style definition (FINDING FI-4)
- Remove the
is:global:focus-visibleblock inBaseLayout.astro(lines 339–342) - Keep only the
global.cssdefinition to avoid conflicts
🔵 P2 — Improve Over Time (Low Priority)
10. Reduced motion theme transition (FINDING RM-1)
- Wrap
--theme-transitionapplication inbodyinsideprefers-reduced-motioncheck - Or set
--theme-transition: noneinside the media query
11. ThemeToggle forced-colors robustness (FINDING HC-1)
- Add
visibility: hiddenalongsideopacity: 0for inactive icons inThemeToggle.astro
12. Primary brand color on small normal text (FINDING L-1)
- Review all uses of
.text-primaryon normal-weight text smaller than 18.66px - Nav items use
text-sm font-medium—text-smis 14px, which is below the 18.66px threshold - Consider using
text-primary-700(#155e75, 9.5:1 on white) for nav text in light mode
9. Compliance Summary Matrix
| WCAG Criterion | Level | Finding | Status |
|---|---|---|---|
| 1.4.1 Use of Color | AA | Color not sole differentiator for critical UI | ✅ |
| 1.4.3 Contrast (Minimum) | AA | IS-1: btn-accent white text fails | 🔴 |
| 1.4.3 Contrast (Minimum) | AA | L-1: primary brand on small text | 🟡 |
| 1.4.3 Contrast (Minimum) | AA | D-1: dark placeholder fails | 🟡 |
| 1.4.6 Contrast (Enhanced) | AAA | text-muted at 4.6:1 (needs 7:1) | ⚠️ |
| 1.4.11 Non-text Contrast | AA | FI-1: accent focus ring 1.9:1 | 🔴 |
| 1.4.11 Non-text Contrast | AA | FI-2: secondary focus ring 2.5:1 | 🟡 |
| 1.4.13 Content on Hover | AA | No tooltip/hover content issues found | ✅ |
| 2.1.1 Keyboard | A | All interactive elements keyboard accessible | ✅ |
| 2.1.2 No Keyboard Trap | A | Mobile dialog partial trap concern | 🟡 |
| 2.4.3 Focus Order | A | Focus order matches visual order | ✅ |
| 2.4.7 Focus Visible | AA | FI-4: duplicate focus styles | 🟡 |
| 2.4.11 Focus Appearance (min) | AA | FI-1: accent button no visible focus | 🔴 |
| 3.2.1 On Focus | A | No context changes on focus | ✅ |
| 4.1.2 Name, Role, Value | A | SR-1: no live region for theme change | 🟡 |
| 4.1.2 Name, Role, Value | A | SR-2: duplicate IDs | 🟡 |
| 1.3.3 Sensory Characteristics | A | No sensory-only instructions | ✅ |
Overall: The theme system is structurally sound but needs targeted fixes in 3 areas before claiming full WCAG 2.1 AA compliance: accent button contrast, accent button focus ring, and theme toggle screen reader feedback.
Appendix: Contrast Calculation Method
All ratios were calculated using the WCAG relative luminance formula:
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
(where R, G, B are linearized sRGB values)
Contrast ratio = (L1 + 0.05) / (L2 + 0.05)
(where L1 is the lighter color)
Key reference values used:
#ffffff(white): L = 1.0#0f172a(dark surface): L = 0.0144#f8fafc(light surface): L = 0.955#0891b2(primary-500): L = 0.190#22d3ee(primary-400): L = 0.621#f59e0b(accent-500): L = 0.368#64748b(secondary-500): L = 0.174#1e293b(secondary-800/card dark bg): L = 0.0221