Chandra App

WCAG AA Accessibility Audit

May 2026  •  Source-code audit  •  React PWA (Vite + Tailwind CSS)

Overall Verdict: NOT WCAG AA COMPLIANT

16 issues found  •  13 FAIL  •  1 PARTIAL  •  Tested against WCAG 2.1 Levels A & AA

1. Audit Overview

This report covers a static source-code accessibility audit of the Chandra progressive web app. All 13 source files (App.jsx, Home.jsx, Calendar.jsx, Panchang.jsx, Settings.jsx, MoonVisual.jsx, DatePickerSheet.jsx, InstallPrompt.jsx, SettingsContext.jsx, SubscriptionContext.jsx, main.jsx, index.css, App.css, and index.html) were reviewed against WCAG 2.1 Success Criteria at Level A and Level AA.

This audit is based on manual code inspection. It covers structural, semantic, contrast, keyboard, and ARIA concerns but does not replace automated scanner testing (axe-core, WAVE) or real assistive technology testing with a screen reader (VoiceOver, NVDA).

Files ReviewedWCAG Level TargetedIssues FoundResult
13 source files (JSX + CSS + HTML) WCAG 2.1 Level AA 16 (13 FAIL, 1 PARTIAL) NOT COMPLIANT

2. Issue Summary

Issue / Criterion File(s) Affected WCAG SC Level Verdict
Resize Text — viewport blocks user zoom (user-scalable=no) index.html1.4.4AA FAIL
Color Contrast — text-gray-500 at 12 px (~4.3:1 on gray-900, ~3.1:1 on gray-800) Home, Settings, Calendar1.4.3AA FAIL
Non-text Contrast — Toggle/SubToggle track has no visible boundary against page background Settings.jsx1.4.11AA FAIL
Keyboard Access — Calendar day cells & festival list use <div onClick> Calendar.jsx2.1.1A FAIL
Focus Visible — city search input applies focus:outline-none Settings.jsx2.4.7AA FAIL
Name, Role, Value — Toggle & SubToggle have no accessible name (aria-label) Settings.jsx4.1.2A FAIL
Name, Role, Value — prev/next chevron buttons (‹ ›) have no aria-label Panchang, Calendar, DatePickerSheet4.1.2A FAIL
Labels or Instructions — city search input has no <label> Settings.jsx3.3.2A FAIL
Focus Management — modals/sheets have no focus trap, Escape handler, or auto-focus Calendar, DatePickerSheet2.1.2A FAIL
Non-text Content — MoonVisual SVG has no role="img" or accessible name MoonVisual.jsx1.1.1A PARTIAL
Bypass Blocks — no skip-to-content link App.jsx2.4.1A FAIL
Focus Order — active nav tab missing aria-current="page" App.jsx2.4.3A FAIL
Info & Relationships — bottom nav is a plain <div>, no <nav> landmark App.jsx1.3.1A FAIL
Info & Relationships — calendar grid uses <div>, no grid/table semantics Calendar.jsx1.3.1A FAIL
Status Messages — loading/error states have no aria-live or role="status" Home.jsx, Panchang.jsx4.1.3AA FAIL
Status Messages — notification preview banner has no role="alert" Settings.jsx4.1.3AA FAIL

3. Detailed Findings & Recommendations

3.1   Text Cannot Be Resized — SC 1.4.4 (AA)  FAIL

File: index.html

The viewport meta tag contains maximum-scale=1.0 and user-scalable=no. These attributes prevent browser-level pinch-zoom, which means low-vision users who rely on text enlargement to read content cannot use the app at a comfortable size. WCAG 1.4.4 requires text to be resizable up to 200% without loss of content or functionality.

<meta name="viewport" content="width=device-width, initial-scale=1.0,
      maximum-scale=1.0, user-scalable=no" />   <!-- PROBLEM -->

Recommended fix:

3.2   Insufficient Color Contrast — SC 1.4.3 (AA)  FAIL

Files: Home.jsx, Settings.jsx, Calendar.jsx

Multiple instances of Tailwind's text-gray-500 (#6B7280) are used as hint text, sublabels, and secondary content at 12 px (text-xs). WCAG 1.4.3 requires at least 4.5:1 contrast for normal-sized text (under 18 pt / 24 px).

Text colorBackgroundRatioRequiredResult
text-gray-500 (#6B7280)bg-gray-900 (#111827) ~4.31:14.5:1FAIL
text-gray-500 (#6B7280)bg-gray-800 (#1F2937) ~3.05:14.5:1FAIL
text-gray-400 (#9CA3AF)bg-gray-900 (#111827) ~7.34:14.5:1PASS

Affected locations include:

Recommended fix:

3.3   Toggle Non-text Contrast — SC 1.4.11 (AA)  FAIL

File: Settings.jsx — Toggle and SubToggle components

The toggle track (pill container) has no visible border or outline delineating it from the surrounding card background. WCAG 1.4.11 requires UI component boundaries to achieve at least 3:1 contrast against adjacent colors. The gray-700 toggle background (#374151) against the gray-900 card background (#111827) yields approximately 2.0:1 — well below the 3:1 threshold.

Recommended fix:

3.4   Non-Keyboard-Accessible Elements — SC 2.1.1 (A)  FAIL

File: Calendar.jsx

Two groups of interactive elements use plain <div> elements with onClick handlers. Keyboard users — including screen reader users who Tab through pages — cannot reach or activate these elements at all because divs are not focusable and have no implicit button role.

Recommended fix:

3.5   Focus Indicator Removed — SC 2.4.7 (AA)  FAIL

File: Settings.jsx — city search input

The city search input applies focus:outline-none, removing the browser's default focus ring. The only visual change on focus is a border-color shift (focus:border-yellow-600). A single color change on a border does not constitute a sufficiently visible focus indicator under WCAG 2.4.7.

className="... focus:border-yellow-600 focus:outline-none ..."   // PROBLEM

Recommended fix:

3.6   Toggle Buttons Have No Accessible Name — SC 4.1.2 (A)  FAIL

File: Settings.jsx — Toggle and SubToggle components

Both components use aria-pressed correctly but have no textual accessible name. The button contains only an inner <span> for the visual knob with no visible label, aria-label, or aria-labelledby. A screen reader announces only "button" with no context about what is being toggled.

Recommended fix:

// Add a `label` prop and apply it as aria-label
const Toggle = ({ on, onToggle, label }) => (
  <button onClick={onToggle} aria-pressed={on} aria-label={label} ...>
    ...
  </button>
)

// Usage:
<Toggle on={notifEnabled} onToggle={...} label="Enable all alerts" />
<SubToggle on={notifPrefs.festivals} ... label="Festival alerts" />
<SubToggle on={notifPrefs.eclipses}  ... label="Eclipse alerts" />

3.7   Chevron Buttons Have No Accessible Name — SC 4.1.2 (A)  FAIL

Files: Panchang.jsx, Calendar.jsx, DatePickerSheet.jsx

All previous/next navigation uses the single-character glyphs ‹ and › as button content with no aria-label. Screen readers will announce these as "single left-pointing angle quotation mark" — giving the user no indication of what the button does.

Recommended fix:

// Panchang.jsx
<button onClick={() => changeDate(-1)} aria-label="Previous day" className="...">‹</button>
<button onClick={() => changeDate(1)}  aria-label="Next day"     className="...">›</button>

// Calendar.jsx
<button onClick={prevMonth} aria-label="Previous month" className="...">‹</button>
<button onClick={nextMonth} aria-label="Next month"     className="...">›</button>

// DatePickerSheet.jsx — same pattern for both chevrons

3.8   City Search Input Has No Label — SC 3.3.2 (A)  FAIL

File: Settings.jsx

The city search <input> relies solely on its placeholder attribute ("Search city or state…") for identification. WCAG 3.3.2 requires a persistent label. Placeholder text disappears once the user begins typing and is not reliably announced as a label by all screen readers.

Recommended fix:

// Add a visually hidden label (Tailwind's sr-only class)
<label htmlFor="city-search" className="sr-only">Search for a city</label>
<input
  id="city-search"
  type="text"
  placeholder="Search city or state..."
  value={citySearch}
  ...
/>

3.9   Modal Sheets Lack Focus Management — SC 2.1.2 (A)  FAIL

Files: Calendar.jsx (day detail modal), DatePickerSheet.jsx

When a modal/sheet opens, keyboard focus stays on the trigger element. Users can Tab past the modal backdrop and reach elements underneath. Additionally, there is no Escape key handler. WCAG 2.1.2 requires that users can move keyboard focus out of any component, and WCAG 2.1.1 requires all functionality to be keyboard operable.

Recommended fix:

// In Calendar.jsx openModal()
const triggerRef = useRef(null);
const openModal = (day) => {
  triggerRef.current = document.activeElement; // remember where focus was
  setSelectedDay(day);
};
const closeModal = () => {
  setSelectedDay(null);
  triggerRef.current?.focus(); // restore focus
};

3.10   Moon SVG Has No Accessible Name — SC 1.1.1 (A)  PARTIAL

File: MoonVisual.jsx

The SVG rendering the moon visual has no role="img", <title>, or aria-label. The phase name text below the SVG does provide a text equivalent nearby, but the SVG itself is exposed to assistive technology as an anonymous container and may cause screen readers to read internal SVG element text verbatim.

Recommended fix (simplest — mark as decorative):

<svg
  width={SIZE} height={SIZE}
  viewBox={`0 0 ${SIZE} ${SIZE}`}
  aria-hidden="true"          {/* decorative — phase name text below conveys meaning */}
  xmlns="http://www.w3.org/2000/svg"
>

Alternative (if you want the SVG to be the primary description):

<svg role="img" aria-labelledby="moon-title">
  <title id="moon-title">{getPhaseName(phase)} — {Math.round(phase * 100)}% of cycle</title>
  ...
</svg>

3.11   No Skip Navigation Link — SC 2.4.1 (A)  FAIL

File: App.jsx

There is no "skip to main content" link. Keyboard users must Tab through the persistent top bar and all four bottom navigation tabs on every page before reaching the main content. WCAG 2.4.1 requires a mechanism to bypass repeated navigation blocks.

Recommended fix:

// In App.jsx, before the top bar:
<a
  href="#main-content"
  className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2
             focus:z-50 focus:bg-yellow-400 focus:text-gray-900 focus:px-4
             focus:py-2 focus:rounded focus:outline-none"
>
  Skip to main content
</a>

// Add id to the content wrapper:
<div id="main-content" className="pt-14 pb-20">

3.12   Active Nav Tab Missing aria-current — SC 2.4.3 (A)  FAIL

File: App.jsx — bottom navigation

The active tab is visually indicated with text-yellow-300, but there is no programmatic indicator. Screen reader users cannot determine which tab is currently active without aria-current="page".

Recommended fix:

<button
  onClick={() => navigate('home')}
  aria-current={screen === 'home' ? 'page' : undefined}
  className={`flex flex-col items-center gap-1 ...`}
>

Apply the same pattern to all four tab buttons (home, calendar, panchang, settings).

3.13   Missing Landmark Roles — SC 1.3.1 (A)  FAIL

Files: App.jsx, Calendar.jsx

Bottom navigation is a plain <div>. Screen reader users who navigate by landmark (a very common strategy) cannot jump to the navigation region. Wrap it in a <nav> element.

Calendar grid uses a plain CSS grid with <div> cells. Day-name column headers (Sun, Mon, …) have no programmatic relationship to the cells beneath them, making the calendar unusable for screen reader users.

Recommended fix:

// App.jsx — wrap bottom nav
<nav aria-label="Main navigation" className="fixed bottom-0 ...">
  {/* tab buttons */}
</nav>

// Calendar.jsx — use role="grid" semantics
<div role="grid" aria-label="Lunar calendar" className="grid grid-cols-7 gap-1">
  {/* header row */}
  {dayNames.map(day => (
    <div key={day} role="columnheader" aria-label={day} className="...">{day}</div>
  ))}
  {/* day cells */}
  {calendarDays.map((day, idx) => (
    <div key={idx} role="gridcell" ...>
      <button onClick={...} aria-label={day ? `${day.day} ${monthNames[currentDate.getMonth()]}` : undefined}>
        ...
      </button>
    </div>
  ))}
</div>

3.14   Dynamic State Changes Not Announced — SC 4.1.3 (AA)  FAIL

Files: Home.jsx, Panchang.jsx, Settings.jsx

Several asynchronous state changes are never communicated to screen readers via live regions:

Recommended fix:

// Loading / status messages (Home.jsx, Panchang.jsx)
<div role="status" aria-live="polite" aria-atomic="true">
  {loading ? "Calculating moon data, please wait." : ""}
</div>

// Error message (Home.jsx)
<p role="alert" className="text-center text-red-400">
  Could not load moon data. Please refresh.
</p>

// Settings saved toast (Settings.jsx)
{saved && (
  <div role="status" aria-live="polite" className="...">
    <p className="text-green-300 text-sm">Settings saved</p>
  </div>
)}

// Notification preview banner (Settings.jsx NotificationPreview component)
<div role="alert" aria-live="assertive" style={{...}}>

4. Additional Recommendations

The items below are not strict WCAG failures but are accessibility best practices that would meaningfully improve the experience for screen reader users.

4.1   Emoji in Headings and Navigation

Emoji inside heading and paragraph text (⚙️ Settings, 📿 Panchang, 🌙 Moonrise, etc.) are announced verbosely by screen readers ("gear Settings", "prayer beads Panchang"). When the emoji are decorative and duplicate adjacent visible text, hide them:

<h1>
  <span aria-hidden="true">⚙️ </span>Settings
</h1>

When the emoji convey unique meaning (such as the phase emoji in the calendar), use role="img" with an aria-label:

<span role="img" aria-label="Waxing crescent moon">🌒</span>

4.2   Install Banner Accessibility

The InstallPrompt banner appears at the top of the screen with no ARIA role. Add role="dialog" or role="alertdialog" with an aria-label. The "Later" button should include aria-label="Dismiss install prompt" to provide clear context.

4.3   Subscription Status Green Dot

The green dot in App.jsx indicating subscription status is a purely visual indicator with no accessible text. The adjacent "Subscribed" label does convey the status, but add aria-hidden="true" to the dot's <span> to prevent it from being announced as an empty element.

4.4   DatePickerSheet Backdrop Keyboard Dismiss

The backdrop <div> in DatePickerSheet.jsx intercepts pointer clicks to close the sheet but has no keyboard equivalent. Add a keydown listener on the backdrop (or the sheet container) for Escape.


5. Remediation Priority

PriorityIssuesRationale
P0 3.1 Zoom disabled (SC 1.4.4)
3.4 Keyboard-inaccessible calendar (SC 2.1.1)
3.9 No focus management in modals (SC 2.1.2)
Completely blocks access for low-vision users who zoom and for keyboard/switch-access users. One-line fix for zoom; moderate effort for focus management.
P1 3.6 Toggle accessible name (SC 4.1.2)
3.7 Chevron aria-labels (SC 4.1.2)
3.8 Input label (SC 3.3.2)
3.2 Color contrast (SC 1.4.3)
Screen reader users cannot understand or operate key controls. Contrast affects a broad user base. All are quick wins — low effort, high impact.
P2 3.5 Focus visible (SC 2.4.7)
3.11 Skip nav (SC 2.4.1)
3.12 aria-current (SC 2.4.3)
3.13 Landmark roles (SC 1.3.1)
3.14 Live regions (SC 4.1.3)
Important for navigation efficiency and dynamic content awareness. Do not block access to core features. Moderate effort.
P3 3.10 SVG alt text (SC 1.1.1)
3.3 Toggle border contrast (SC 1.4.11)
Section 4 — additional recommendations
Polish-level improvements. Address after higher-priority items are resolved.

6. Next Steps

After applying source-code fixes, validate with the following steps:

  1. Automated scan: Run axe-core (browser extension or axe.run() in the console) against the live PWA to catch issues not visible in source code alone (computed roles, actual rendered contrast).
  2. Screen reader testing: Test with VoiceOver on iOS (the primary mobile PWA platform) and NVDA + Chrome on desktop. Pay particular attention to the calendar grid, toggle controls, and the DatePickerSheet.
  3. Contrast validation: Verify all colour changes using the WebAIM Contrast Checker before shipping.
  4. Keyboard-only session: Navigate the full app (Today → Calendar → Panchang → Settings) using only Tab, Shift+Tab, Enter, Space, and Escape. Confirm all controls are reachable and operable.
  5. Re-audit: After remediation, re-run this checklist to confirm all issues are resolved and no regressions introduced.

End of Report  —  Chandra App WCAG AA Audit  •  May 2026