Files
CompanySite/.agents/security-auditor/THEME_ACCESSIBILITY_AUDIT.md
T
Clintchiz 0614ae6f85
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
Latest Updated Pages
2026-03-22 14:37:17 +05:30

19 KiB
Raw Blame History

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-label present and descriptive
  • aria-label updates dynamically via updateAriaLabel() 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-label correctly
  • 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.astro has a scoped prefers-reduced-motion rule disabling icon transitions
  • global.css reduced-motion block covers [data-animate], .card-interactive, .btn-ripple
  • Theme toggle transition on body is transition: 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-xl and display-lg use clamp() — 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-labelledby failures)
  • JavaScript getElementById only 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.css line 202204
  • Change: Replace text-white with text-secondary-900 on .btn-accent
  • OR change hover to hover:bg-accent-700 and keep white text (ratio 5.3:1)

2. .btn-accent focus ring (FINDING FI-1)

  • File: src/styles/global.css line 203
  • Change: Replace focus:ring-accent-400 with focus: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, update aria-checked on 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" and id="theme-toggle-mobile"
  • Update JS to use querySelectorAll('[data-theme-toggle]') instead of getElementById

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-toggle on closeMenu()

6. Dark form placeholder contrast (FINDING D-1)

  • File: src/styles/global.css line 256
  • Change dark:placeholder-secondary-500dark:placeholder-secondary-400 (#94a3b8, 5.4:1 on #1e293b)

7. .btn-secondary focus ring (FINDING FI-2)

  • Replace focus:ring-secondary-400 with focus:ring-secondary-600 (#475569 vs white = 6.6:1)

8. Form input low-opacity focus ring (FINDING FI-3)

  • Change focus:ring-primary/20focus:ring-primary/40 in .form-input

9. Duplicate focus style definition (FINDING FI-4)

  • Remove the is:global :focus-visible block in BaseLayout.astro (lines 339342)
  • Keep only the global.css definition to avoid conflicts

🔵 P2 — Improve Over Time (Low Priority)

10. Reduced motion theme transition (FINDING RM-1)

  • Wrap --theme-transition application in body inside prefers-reduced-motion check
  • Or set --theme-transition: none inside the media query

11. ThemeToggle forced-colors robustness (FINDING HC-1)

  • Add visibility: hidden alongside opacity: 0 for inactive icons in ThemeToggle.astro

12. Primary brand color on small normal text (FINDING L-1)

  • Review all uses of .text-primary on normal-weight text smaller than 18.66px
  • Nav items use text-sm font-mediumtext-sm is 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