May 2026 • Source-code audit • React PWA (Vite + Tailwind CSS)
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 Reviewed | WCAG Level Targeted | Issues Found | Result |
|---|---|---|---|
| 13 source files (JSX + CSS + HTML) | WCAG 2.1 Level AA | 16 (13 FAIL, 1 PARTIAL) | NOT COMPLIANT |
| Issue / Criterion | File(s) Affected | WCAG SC | Level | Verdict |
|---|---|---|---|---|
Resize Text — viewport blocks user zoom (user-scalable=no) |
index.html | 1.4.4 | AA | FAIL |
Color Contrast — text-gray-500 at 12 px (~4.3:1 on gray-900, ~3.1:1 on gray-800) |
Home, Settings, Calendar | 1.4.3 | AA | FAIL |
| Non-text Contrast — Toggle/SubToggle track has no visible boundary against page background | Settings.jsx | 1.4.11 | AA | FAIL |
Keyboard Access — Calendar day cells & festival list use <div onClick> |
Calendar.jsx | 2.1.1 | A | FAIL |
Focus Visible — city search input applies focus:outline-none |
Settings.jsx | 2.4.7 | AA | FAIL |
| Name, Role, Value — Toggle & SubToggle have no accessible name (aria-label) | Settings.jsx | 4.1.2 | A | FAIL |
| Name, Role, Value — prev/next chevron buttons (‹ ›) have no aria-label | Panchang, Calendar, DatePickerSheet | 4.1.2 | A | FAIL |
Labels or Instructions — city search input has no <label> |
Settings.jsx | 3.3.2 | A | FAIL |
| Focus Management — modals/sheets have no focus trap, Escape handler, or auto-focus | Calendar, DatePickerSheet | 2.1.2 | A | FAIL |
Non-text Content — MoonVisual SVG has no role="img" or accessible name |
MoonVisual.jsx | 1.1.1 | A | PARTIAL |
| Bypass Blocks — no skip-to-content link | App.jsx | 2.4.1 | A | FAIL |
Focus Order — active nav tab missing aria-current="page" |
App.jsx | 2.4.3 | A | FAIL |
Info & Relationships — bottom nav is a plain <div>, no <nav> landmark |
App.jsx | 1.3.1 | A | FAIL |
Info & Relationships — calendar grid uses <div>, no grid/table semantics |
Calendar.jsx | 1.3.1 | A | FAIL |
Status Messages — loading/error states have no aria-live or role="status" |
Home.jsx, Panchang.jsx | 4.1.3 | AA | FAIL |
Status Messages — notification preview banner has no role="alert" |
Settings.jsx | 4.1.3 | AA | FAIL |
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:
maximum-scale=1.0 and user-scalable=no.<meta name="viewport" content="width=device-width, initial-scale=1.0" />touch-action: manipulation in CSS on interactive elements instead — this stops the 300 ms delay without locking zoom.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 color | Background | Ratio | Required | Result |
|---|---|---|---|---|
| text-gray-500 (#6B7280) | bg-gray-900 (#111827) | ~4.31:1 | 4.5:1 | FAIL |
| text-gray-500 (#6B7280) | bg-gray-800 (#1F2937) | ~3.05:1 | 4.5:1 | FAIL |
| text-gray-400 (#9CA3AF) | bg-gray-900 (#111827) | ~7.34:1 | 4.5:1 | PASS |
Affected locations include:
<p className="text-gray-500 text-xs mb-1">🌙 Moonrise</p>Recommended fix:
text-gray-500 to text-gray-400, which achieves ~7.3:1 on bg-gray-900 and ~5.2:1 on bg-gray-800.text-gray-500 at text-xs (12 px) or smaller on dark backgrounds.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:
border: '1px solid rgba(255,255,255,0.3)'. This creates a clear boundary that achieves >3:1 contrast against both the on and off backgrounds.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.
<div onClick={() => day && openModal(day)}> — up to 31 cells per month, all unreachable by keyboard<div onClick={() => openModal(day)}>Recommended fix:
<div> elements with <button> elements, or add role="button" tabIndex={day ? 0 : -1} to the existing divs.onKeyDown handler that calls openModal when Enter or Space is pressed.<div>s with <button> elements styled as cards.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:
focus:outline-none.focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-1 focus-visible:ring-offset-gray-900Both 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" />
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
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}
...
/>
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:
ref.focus() in a useEffect.focus-trap-react library) so Tab cycles only within the open modal.keydown listener that calls closeModal() when Escape is pressed.// 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
};
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>
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">
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).
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>
Several asynchronous state changes are never communicated to screen readers via live regions:
aria-live region. Screen readers do not announce when loading completes.role="alert".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={{...}}>
The items below are not strict WCAG failures but are accessibility best practices that would meaningfully improve the experience for screen reader users.
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>
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.
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.
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.
| Priority | Issues | Rationale |
|---|---|---|
| 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. |
After applying source-code fixes, validate with the following steps:
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).End of Report — Chandra App WCAG AA Audit • May 2026