"A modern Indian astronomy companion — calm, trustworthy, and night-friendly. Deeply rooted in lunar tradition without feeling like a religious app."
Four Design Principles
| Principle | What it means in practice | What to avoid |
| Night-First | Dark backgrounds everywhere. Moon visuals shine against depth. | Pure #000 backgrounds; harsh neon glows |
| Calm Precision | Clean typography, generous whitespace, clear hierarchy. Numbers and times are never ambiguous. | Dense information walls; cluttered cards |
| Culturally Grounded | Sanskrit terms rendered in Noto Serif Devanagari. Gold as sacred accent. Subtle celestial textures. | Temple motifs, heavy ornament, overly devotional imagery |
| Accessible by Default | Every component ships with WCAG AA contrast, keyboard support, and ARIA attributes baked in. | Retrofitting a11y after launch; treating it as optional |
Font Stack
| Role | Font | Usage | Loading Strategy |
| Primary UI |
Inter |
All body text, labels, numbers, navigation, forms |
Preload via <link rel="preconnect">; subset latin only in MVP |
| Cultural Accent |
Noto Serif Devanagari |
Panchang headings, Sanskrit terms, festival names |
Lazy-load only when Panchang tab activates. Subset to ~200 glyphs used in app. Target ≤ 150 KB after subsetting. |
| Regional Expansion |
Noto Sans Tamil / Telugu / Kannada |
Future multilingual support |
Load only for selected language. Never preload all variants. |
| System Fallback |
-apple-system, Segoe UI, Arial |
Renders immediately before Inter loads |
Always specified as fallback |
⚠ Performance Warning — Noto Serif Devanagari
The full Noto Serif Devanagari font is 400–900 KB. On 3G connections (common on budget Indian Android devices), this adds 2–5 seconds of delay if loaded eagerly. Always use font-display: swap, and load this font only when the Panchang screen is first rendered. Use Google Fonts Unicode Range subsetting to reduce to the characters actually used.
Type Scale
| Token | Size | Weight | Line Height | Usage |
--text-hero | 32px | 700 | 1.2 | Moon phase display name, hero numbers |
--text-section | 24px | 600 | 1.2 | Page headings (Panchang, Calendar, Settings) |
--text-card-title | 18px | 600 | 1.3 | Card titles, sheet headers |
--text-body | 16px | 400 | 1.5 | Primary body text — minimum for all readable content |
--text-secondary | 14px | 400 | 1.5 | Supporting labels, descriptions |
--text-meta | 12px | 500 | 1.3 | Timestamps, captions, footnotes only — never for primary info |
ℹ Accessibility note on 12px metadata text
WCAG 1.4.3 applies to all text including 12px. The 12px size is acceptable for truly secondary metadata (timestamps, version labels) if and only if the contrast ratio is ≥ 4.5:1. Never use it for primary information, time-critical data (moonrise/moonset times), or festival names.
Type Scale Preview — Dark Surface
Dashami Tithi
Today's Panchang
Shukla Paksha · Purnimant
This is body text used for descriptions and supporting information in the app.
Moonrise: 19:04 · Moonset: 06:21 · Sunrise: 05:42
Updated 5 min ago
CSS Implementation
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
const link = document.createElement('link');
link.href = 'https://fonts.googleapis.com/css2?family=Noto+Serif+Devanagari:wght@400;600&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
🔴 Key change from original design system
The original "Cosmic Indigo" #5B6CFF with white text achieves only ~4.17:1 contrast — below the WCAG AA 4.5:1 threshold. It is retained for decorative/informational use (chips, icons, active borders) but is NO LONGER used as a button background with white text. Two new purpose-specific tokens replace it for CTAs.
Background Palette
Deep Night Blue
#0B1020
Main app background
Lunar Slate
#151B2F
Cards, bottom sheets
Soft Indigo
#1D2440
Hover states, elevated cards
Overlay
rgba(0,0,0,0.65)
Modal backdrop
Brand Colors
Soft Moon Gold
#DDBB6A
Primary CTA bg, accents
CTA Indigo NEW
#3D4ECC
Secondary CTA bg only
Cosmic Indigo
#5B6CFF
Chips, icons, decorative
Bright Moon Blue
#8EA8FF
Focus ring, links, hover text
Text Colors
Moon White
#F5F7FA
Primary text
Mist Grey
#B5BDD1
Secondary text
Lunar Grey
#70778B
Disabled ONLY — see note
Text Inverse
#0B1020
Dark text on gold/light bg
⚠ Lunar Grey #70778B — Disabled states only
This color achieves approximately 4.35:1 on #0B1020 — just below the WCAG AA threshold of 4.5:1. WCAG 1.4.3 exempts disabled UI components from contrast requirements, so this token is valid for disabled buttons, inactive toggles, and grayed-out form fields. It must NEVER be used for readable or active content. For all active subdued text, use Mist Grey #B5BDD1 instead.
Semantic Colors
Sacred Green
#4FAF7C
Success states
Eclipse Amber
#D99036
Warnings, inauspicious
Grahan Red RENAMED
#D45C5C
Errors, critical alerts
Sky Blue
#5DA9E9
Info, neutral alerts
Contrast Ratio Reference
All combinations verified against WCAG 2.1 SC 1.4.3. ✅ = passes AA. ❌ = fails.
Moon White on Deep Night Blue
#F5F7FA on #0B1020 — Primary body text
~18.4:1 ✅
Mist Grey on Deep Night Blue
#B5BDD1 on #0B1020 — Secondary text
~10.4:1 ✅
Mist Grey on Lunar Slate
#B5BDD1 on #151B2F — Text on cards
~9.6:1 ✅
Deep Night Blue on Moon Gold
#0B1020 on #DDBB6A — Primary CTA button text
~10.5:1 ✅
Moon White on CTA Indigo
#F5F7FA on #3D4ECC — Secondary CTA button text
~6.3:1 ✅
Moon White on Cosmic Indigo
#F5F7FA on #5B6CFF — Do NOT use as button background with white text
~4.17:1 ❌
Bright Moon Blue on Deep Night Blue
#8EA8FF on #0B1020 — Focus ring, links, hover text
~8.5:1 ✅
Sacred Green on Deep Night Blue
#4FAF7C on #0B1020 — Success labels
~7.1:1 ✅
Grahan Red on Deep Night Blue
#D45C5C on #0B1020 — Error labels
~5.1:1 ✅
Lunar Grey on Deep Night Blue
#70778B on #0B1020 — Disabled states only (WCAG exempts disabled elements)
~4.35:1 ⚠
Interactive State Color Logic
ℹ Corrected from original: active states should be DARKER, not lighter
The original design assigned a lighter colour (#8EA8FF) as the "active" state of the indigo (#5B6CFF). In standard interaction design, a pressed/active state should be visually deeper than the default to convey physical depression. Lighter variants belong to hover states.
| Token | Hex | State | Applies to |
--gold-default | #DDBB6A | Default | Primary CTA background |
--gold-hover | #E8C87A | Hover / focus | Slightly lighter — warmth |
--gold-active | #C9A85A | Pressed / active | Darker — conveying press |
--indigo-cta-default | #3D4ECC | Default | Secondary CTA background |
--indigo-cta-hover | #4A5BD6 | Hover / focus | Slightly lighter |
--indigo-cta-active | #3040B8 | Pressed / active | Darker |
--indigo-decorative | #5B6CFF | Static | Chips, icons, borders (decorative only) |
--blue-focus | #8EA8FF | Focus ring | Used as outline color — never as button fill |
8px base grid. All spacing tokens are multiples or half-multiples of 8px. Touch targets are governed separately — see Accessibility Standards.
| Token | Value | Primary Usage |
--space-xs | 4px | Icon-to-text gaps, tight internal padding |
--space-sm | 8px | Between related elements within a component |
--space-md | 16px | Internal card padding, form field gaps |
--space-lg | 24px | Between card sections, sheet padding |
--space-xl | 32px | Between major sections on a page |
--space-xxl | 48px | Page top padding, hero spacing |
--touch-target | 44px | Minimum height/width for all interactive elements |
ℹ Touch target note
WCAG 2.5.5 (Level AAA) and Apple/Google HIG both recommend 44×44px minimum touch targets. This should be enforced on all buttons, nav items, toggles, and tappable list items — even if the visual element appears smaller. Use min-height: 44px combined with padding to achieve this without inflating visual size.
On dark backgrounds, elevation is communicated through subtle background lightening (layer tinting) more than drop shadows. Both are provided for flexibility.
| Token | Shadow Value | Usage |
--shadow-sm | 0 1px 3px rgba(0,0,0,0.4) | Chips, small action buttons |
--shadow-md | 0 4px 12px rgba(0,0,0,0.5) | Cards floating on main background |
--shadow-lg | 0 8px 24px rgba(0,0,0,0.6) | Bottom sheets, modals |
--shadow-glow-gold | 0 0 20px rgba(221,187,106,0.18) | Full moon state — moon visual card only |
--shadow-glow-blue | 0 0 20px rgba(142,168,255,0.12) | Focus ring glow on dark surfaces |
| Token | Value | Usage |
--radius-sm | 8px | Small inline elements, badges, tags |
--radius-md | 12px | Buttons, inputs, small cards |
--radius-lg | 16px | Standard cards, modals |
--radius-xl | 24px | Bottom sheets (top corners), large panels |
--radius-full | 999px | Chips, pill badges, avatar rings |
Border Colors NEW
These were missing from the original system. Define them explicitly to prevent ad-hoc border color decisions.
| Token | Value | Usage |
--border-subtle | rgba(255,255,255,0.06) | Barely visible dividers, inner card sections |
--border-default | rgba(255,255,255,0.12) | Standard card borders, input outlines (unfocused) |
--border-emphasis | rgba(221,187,106,0.35) | Highlighted cards, active selections, festival days |
--border-focus | #8EA8FF | Focused input/button borders (paired with focus ring) |
--border-error | #D45C5C | Invalid input fields |
| Token | Value | Usage |
--duration-fast | 150ms | Hover states, color transitions |
--duration-base | 200ms | Button press, fade, small scale |
--duration-slow | 300ms | Sheet slide-up, modal entrance |
--ease-out | cubic-bezier(0.0, 0.0, 0.2, 1) | Entrances — elements coming in feel snappy |
--ease-in | cubic-bezier(0.4, 0.0, 1, 1) | Exits — elements leaving accelerate out |
--ease-spring | cubic-bezier(0.34, 1.56, 0.64, 1) | Delightful bounce — used sparingly (e.g., subscription success tick) |
ℹ Respect prefers-reduced-motion
Always wrap non-essential animations in a @media (prefers-reduced-motion: no-preference) query. Users who have enabled reduced motion in their OS settings (many elderly users and people with vestibular disorders) should see instant transitions.
.sheet {
transform: translateY(100%);
}
@media (prefers-reduced-motion: no-preference) {
.sheet {
transition: transform var(--duration-slow) var(--ease-out);
}
}
Toggle Variants — Standard pill + thumb design
Push notifications — Disabled
TRACK (pill)
44 × 26 px — gold when on
THUMB (circle)
20 × 20 px — always white
Thumb slides: left = off (3 px from edge), right = on (21 px from left edge). Spring easing on transition.
Toggle Anatomy Rules
| Part | Spec | Notes |
| Track (pill) | 44 × 26 px, border-radius 13 px | Gold (#DDBB6A) when ON; dark (#2A3050) when OFF. Always has 1.5px white border so it's visible against card backgrounds (SC 1.4.11). |
| Thumb (circle) | 20 × 20 px, border-radius 50% | Always #F5F7FA (off-white). top: 3px. left: 3px when OFF, left: 21px when ON. |
| Transition | left 0.2s cubic-bezier(0.34,1.56,0.64,1) | Slight spring bounce for a tactile feel. Suppressed if prefers-reduced-motion: reduce. |
| Disabled | Entire component at opacity: 0.45 | No interaction. WCAG exempts disabled controls from contrast requirements. |
Toggle Accessibility Requirements
| Requirement | Implementation | Fixes Issue |
| Accessible name | Every toggle must receive an aria-label describing what it controls | SC 4.1.2 FAIL in audit |
| Role + state | role="switch" + aria-checked={on} on the button element | SC 4.1.2 — screen reader announces "on/off" automatically |
| Visible boundary | Track has border: 1.5px solid rgba(255,255,255,0.15) to achieve ≥ 3:1 against card background | SC 1.4.11 FAIL in audit |
| Focus ring | :focus-visible shows 2px #8EA8FF outline around the track | SC 2.4.7 |
| Min touch target | Entire button element: min-height: 44px; min-width: 44px via padding | WCAG 2.5.5 |
const Toggle = ({ on, onToggle, label }) => (
<button
role="switch"
aria-checked={on}
aria-label={label}
onClick={onToggle}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8EA8FF] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0B1020]"
style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center',
width: 44, height: 26,
borderRadius: 13, padding: 0, border: 'none',
background: on ? 'var(--gold)' : '#2A3050',
boxShadow: '0 0 0 1.5px rgba(255,255,255,0.15)',
cursor: 'pointer', flexShrink: 0,
transition: 'background 0.2s ease',
}}
>
<span style={{
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
left: on ? 19 : 2,
width: 20, height: 20,
borderRadius: '50%',
background: '#F5F7FA',
boxShadow: '0 1px 4px rgba(0,0,0,0.45)',
transition: 'left 0.2s cubic-bezier(0.34,1.56,0.64,1)',
}} />
</button>
);
Dashami Tithi
Shukla Paksha
Moonrise19:04
Moonset06:21
Today's Highlight
Ganesh Chaturthi
Tap for full Panchang →
Card Types & Usage
| Type | Background | Border | Use for |
| Standard | #151B2F | --border-default | Info display, Panchang rows, settings sections |
| Highlighted | #151B2F | --border-emphasis (gold) | Today's highlight, festival cards, active states |
| Alert | Semantic tint (e.g., indigo-950 for eclipse) | Semantic border | Eclipse banners, Ekadashi notices |
| Tappable | #151B2F → #1D2440 on hover | --border-default | Festival list items, calendar upcoming events |
ℹ Tappable cards must be keyboard accessible
Any card that responds to a tap/click must be a <button> element (or have role="button" + tabIndex={0} + onKeyDown). Plain <div onClick> is inaccessible to keyboard users. This is the fix for SC 2.1.1 in the audit.
Dashami
Hasta Nakshatra
Ganesh Chaturthi
🌑 Surya Grahan
Chip Color System
| Type | Background | Text | Border | Use for |
| Tithi | rgba(93,169,233,0.15) | #8EC8F5 | rgba(93,169,233,0.3) | Tithi names, lunar day indicators |
| Nakshatra | rgba(142,168,255,0.12) | #8EA8FF | rgba(142,168,255,0.25) | Nakshatra labels, pada indicators |
| Festival | rgba(79,175,124,0.15) | #7ECFA6 | rgba(79,175,124,0.3) | Festival names, auspicious days |
| Eclipse | rgba(217,144,54,0.15) | #F0B060 | rgba(217,144,54,0.3) | Eclipse alerts, Grahan notices |
Accessibility Requirements
| Requirement | Implementation | Fixes Issue |
| Auto-focus on open | Use useEffect to focus the first focusable element or the close button when the modal mounts | SC 2.1.2 FAIL in audit |
| Focus trap | Tab and Shift+Tab must cycle only within the open modal. Use focus-trap-react library. | SC 2.1.2 FAIL in audit |
| Escape key close | useEffect adds a keydown listener for Escape that calls onClose() | SC 2.1.2 FAIL in audit |
| Restore focus on close | Store document.activeElement before opening; call .focus() on it after closing | SC 2.4.3 |
| ARIA role | role="dialog" + aria-modal="true" + aria-labelledby pointing to the sheet title | SC 4.1.2 |
| Backdrop close | Backdrop click closes the modal AND a keyboard equivalent (Escape) exists | SC 2.1.1 |
import FocusTrap from 'focus-trap-react';
const BottomSheet = ({ open, onClose, title, children }) => {
const triggerRef = useRef(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
} else {
triggerRef.current?.focus();
}
}, [open]);
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
if (open) document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [open, onClose]);
if (!open) return null;
return (
<FocusTrap>
<div className="fixed inset-0 z-50 flex items-end">
<div className="absolute inset-0 bg-black/65" onClick={onClose} />
<div role="dialog" aria-modal="true" aria-labelledby="sheet-title"
className="relative w-full rounded-t-3xl bg-[#151B2F] ...">
<h2 id="sheet-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close">✕</button>
</div>
</div>
</FocusTrap>
);
};
Bottom Navigation — Active: Home
Navigation Accessibility Requirements
| Requirement | Implementation | Fixes Issue |
| Semantic landmark | Wrap bottom nav in <nav aria-label="Main navigation"> | SC 1.3.1 FAIL in audit |
| Active state signal | aria-current="page" on the active tab button | SC 2.4.3 FAIL in audit |
| Icon labels | Each nav button must have either visible text label (as shown) or an aria-label | SC 4.1.2 |
| Skip navigation | Visually hidden skip link before the top bar: <a href="#main">Skip to content</a> | SC 2.4.1 FAIL in audit |
| Use Lucide icons, not emoji | Lucide SVG icons are visual-only; they are not announced by screen readers when paired with a visible text label | Verbosity issue |
Phase-adaptive tinting provides emotional continuity as the lunar cycle progresses. These are subtle background and accent shifts — never jarring. Applied to: card border emphasis, page background tint (very low opacity), and moon visual glow.
Amavasya
New Moon
Bg tint: #100E1E
Accent: #5A547E
Shukla Paksha
Waxing / Bright
Bg tint: #0F1528
Accent: #7B9EDB
Purnima
Full Moon
Bg tint: #181428
Accent: #DDBB6A + glow
Krishna Paksha
Waning / Dark
Bg tint: #111524
Accent: #6E7CA8
| Phase | Bg Tint | Card Accent Border | Glow |
| Amavasya (New Moon) | #100E1E | #5A547E | None |
| Shukla Paksha (Waxing) | #0F1528 | #7B9EDB | rgba(123,158,219,0.08) |
| Purnima (Full Moon) | #181428 | #DDBB6A | rgba(221,187,106,0.18) |
| Krishna Paksha (Waning) | #111524 | #6E7CA8 | None |
This system has two distinct emoji policies depending on context. UI chrome emoji (nav tabs, buttons, headings, settings) must be replaced with Lucide React SVG icons. Moon phase and cultural emoji in content contexts must be kept — Lucide has no moon phase equivalents, and the emoji representations are accurate, universally understood, and culturally appropriate for this app.
🔴 Rule 1 — UI Chrome Emoji: Replace with Lucide
Emoji used as decorative icons in navigation, buttons, headings, and form labels must be replaced with Lucide SVG icons. These emoji are read aloud by screen readers ("prayer beads", "gear", "calendar") and cause confusing announcements. Lucide icons are aria-hidden by default and get their accessible name from the surrounding label or button's aria-label.
UI Chrome Emoji → Lucide Replacement Map
| Current Emoji | Context | Lucide Replacement | aria-hidden |
| 🌙 | Today tab nav | Moon | true (text label present) |
| 🗓️ | Calendar tab nav | Calendar | true |
| 📿 | Panchang tab nav | Clock or custom SVG | true |
| ⚙️ | Settings tab nav | Settings | true |
| 🔔 | Subscribe button (top bar) | Bell / BellRing | true (button has aria-label) |
| 📍 | City location label | MapPin | true (city name follows) |
| 🌅 | Brahma Muhurta timing label | Sunrise | true |
| ✨ | Abhijit Muhurta label | Sparkles | true |
| 🐍 | Rahu Kaal label | AlertTriangle | true |
| ⏳ | Yamagandam label | Timer | true |
| ✓ | Settings saved / selected state | Check | true |
| ✕ | Close modal/sheet button | X | false — button needs aria-label="Close" |
✅ Rule 2 — Moon Phase Emoji: Keep As-Is
The eight Unicode moon phase emoji (🌑🌒🌓🌔🌕🌖🌗🌘) must be retained in the Calendar component and anywhere moon phases are displayed in content. Lucide does not provide moon phase icons — only a single generic crescent Moon icon. The Unicode phase emoji are accurate representations of each phase, render crisply on all platforms, and are the best available option for this use case. They are a deliberate design choice, not a workaround.
Moon Phase Emoji — Retention Map
| Emoji | Phase Name (Hindi/Sanskrit) | Usage in App | Accessibility Requirement |
| 🌑 | Amavasya (New Moon) | Calendar day cell, Panchang header | Wrap in <span role="img" aria-label="[Phase name]"> so screen readers announce the phase name, not the Unicode description |
| 🌒 | Waxing Crescent (Shukla Paksha) | Calendar day cell |
| 🌓 | First Quarter (Ashtami) | Calendar day cell |
| 🌔 | Waxing Gibbous | Calendar day cell |
| 🌕 | Purnima (Full Moon) | Calendar day cell, Panchang header, Home highlight |
| 🌖 | Waning Gibbous (Krishna Paksha) | Calendar day cell |
| 🌗 | Last Quarter | Calendar day cell |
| 🌘 | Waning Crescent | Calendar day cell |
ℹ Other cultural emoji — case by case
Emoji used for cultural or festival meaning (e.g., 🌸 Vasant Panchami, 🪔 Diwali, 🎋 Janmashtami) may be retained where they appear in content descriptions, not UI chrome. Always wrap them with role="img" and a descriptive aria-label. They must never appear in navigation, button labels, or form inputs.
Icon Usage Rules (Lucide)
| Rule | Detail |
| Size | Navigation tabs: 22×22px. Card/inline icons: 18×18px. Small inline: 16×16px. |
| Stroke width | Always strokeWidth={1.75}. Never use filled variants in this system. |
| Color | Inherit from parent text color via currentColor, or set explicitly using a design token value. |
| aria-hidden | Set aria-hidden="true" on all Lucide icons — the accessible name comes from the surrounding button's aria-label or adjacent visible text. |
| Icon-only buttons | Never rely solely on the icon for communication. The parent <button> must have an explicit aria-label. |
:root {
--bg-primary: #0B1020;
--bg-surface: #151B2F;
--bg-elevated: #1D2440;
--bg-overlay: rgba(0,0,0,0.65);
--gold: #DDBB6A;
--gold-hover: #E8C87A;
--gold-active: #C9A85A;
--indigo-cta: #3D4ECC;
--indigo-cta-hover: #4A5BD6;
--indigo-cta-active: #3040B8;
--indigo-deco: #5B6CFF;
--blue-light: #8EA8FF;
--text-primary: #F5F7FA;
--text-secondary: #B5BDD1;
--text-disabled: #70778B;
--text-inverse: #0B1020;
--success: #4FAF7C;
--warning: #D99036;
--error: #D45C5C;
--info: #5DA9E9;
--border-subtle: rgba(255,255,255,0.06);
--border-default: rgba(255,255,255,0.12);
--border-emphasis: rgba(221,187,106,0.35);
--border-focus: #8EA8FF;
--border-error: #D45C5C;
--focus-ring-color: #8EA8FF;
--focus-ring-width: 2px;
--focus-ring-offset: 2px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
--shadow-glow-gold: 0 0 20px rgba(221,187,106,0.18);
--shadow-glow-blue: 0 0 16px rgba(142,168,255,0.15);
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-xxl: 48px;
--touch-target: 44px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 999px;
--dur-fast: 150ms;
--dur-base: 200ms;
--dur-slow: 300ms;
--ease-out: cubic-bezier(0.0, 0.0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0.0, 1.0, 1);
--ease-spring:cubic-bezier(0.34, 1.56, 0.64, 1);
--z-base: 0;
--z-sticky: 40;
--z-overlay: 50;
--z-modal: 60;
--z-toast: 70;
--z-top: 80;
--font-ui: 'Inter', -apple-system, 'Segoe UI', Arial, sans-serif;
--font-deva: 'Noto Serif Devanagari', serif;
--text-hero: 32px; --weight-hero: 700;
--text-section: 24px; --weight-section: 600;
--text-title: 18px; --weight-title: 600;
--text-body: 16px; --weight-body: 400;
--text-secondary-sz: 14px;
--text-meta: 12px; --weight-meta: 500;
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
Tailwind Config Extension
module.exports = {
theme: {
extend: {
colors: {
chandra: {
'bg': '#0B1020',
'surface': '#151B2F',
'elevated': '#1D2440',
'gold': '#DDBB6A',
'gold-hover': '#E8C87A',
'indigo-cta': '#3D4ECC',
'indigo-deco':'#5B6CFF',
'blue-light': '#8EA8FF',
'text': '#F5F7FA',
'muted': '#B5BDD1',
'disabled': '#70778B',
'success': '#4FAF7C',
'warning': '#D99036',
'error': '#D45C5C',
}
},
fontFamily: {
ui: ['Inter', 'system-ui', 'Arial', 'sans-serif'],
deva: ['Noto Serif Devanagari', 'serif'],
},
borderRadius: {
sm: '8px', md: '12px', lg: '16px', xl: '24px',
},
spacing: { 'touch': '44px' }
}
}
};
Six phases, ordered by dependency. Each phase is independently shippable — you can pause after any phase and the app remains functional and improved. Total estimated effort for a solo developer: 10–14 working days.
0
Phase 0 — Design Review & Sign-off
1–2 days · Before any code
Review this design system document. Finalise token values, confirm button color approach (gold primary / indigo secondary), approve the emoji→icon mapping table, and confirm the Lucide icon choices for Panchang tab. No code changes until this is signed off.
DesignSign-off required
1
Phase 1 — Token Foundation
1 day
Update tailwind.config.js with the Chandra color tokens. Add all CSS custom properties to index.css. Add Inter font preconnect to index.html. Remove user-scalable=no and maximum-scale=1.0 from viewport meta. Add the global :focus-visible rule. This phase has no visual impact on the current app — it only adds infrastructure.
Fixes SC 1.4.4 (zoom)Fixes focus ring foundationinfra
2
Phase 2 — Primitive Components
3–4 days
Build the /src/components/ui/ folder with accessible primitives: Button, Input (with label), Toggle (with aria-label), BottomSheet / Modal (with focus trap + Escape), Card, Chip. Install lucide-react and focus-trap-react. Each component implements the accessibility spec from this document. The existing pages are not yet changed — this is additive only.
SC 4.1.2 toggle nameSC 2.1.2 focus trapSC 3.3.2 input labelinfra
3
Phase 3 — App Shell Migration
1–2 days
Migrate App.jsx: replace emoji nav icons with Lucide, add <nav aria-label> wrapper, add aria-current="page" to active tab, add skip-to-content link, update z-index values to use token scale. Migrate top bar: replace bell emoji with Lucide Bell, update colors to new tokens.
SC 1.3.1 nav landmarkSC 2.4.1 skip linkSC 2.4.3 aria-currentVisual
4
Phase 4 — Page-by-Page Migration
4–5 days
Migrate each page to the new primitives, color tokens, and typography. Recommended order:
Settings.jsx (most a11y issues — city input label, toggle names, contrast fixes, focus ring, notification banner aria-live) →
Home.jsx (gray-500 contrast, loading/error aria-live, MoonVisual aria-hidden, location pin icon) →
Calendar.jsx (div→button for day cells, BottomSheet primitive, grid semantics, festival list cards) →
Panchang.jsx (chevron aria-labels, date picker sheet, Noto Serif Devanagari lazy load) →
SubscribeSheet.jsx (BottomSheet primitive replaces bespoke implementation).
SC 2.1.1 keyboardSC 1.4.3 contrastSC 4.1.3 live regionsVisual
5
Phase 5 — Validation & QA
1–2 days
Keyboard-only navigation session through every screen. Run axe-core in Chrome DevTools against each page. Spot-check with VoiceOver on iOS (the primary deployment target). Verify all contrast ratios with the WebAIM Contrast Checker on the live rendered app (computed backgrounds may differ from CSS values). Confirm font loading performance on a throttled 3G connection. Fix any regressions found.
axe-core scanVoiceOver testKeyboard testPerf check
The migration is designed to be zero-downtime and incremental. At no point should you find yourself with a half-broken app that can't be deployed. The existing app stays live on Vercel throughout.
Branch Strategy
main (live on Vercel)
└── design-system/phase-1-tokens ← merged first, no visual change
└── design-system/phase-2-primitives ← merged second, additive only
└── design-system/phase-3-app-shell ← first visible change
└── design-system/phase-4-pages ← one PR per page file
└── design-system/phase-5-qa ← final fixes before merge to main
File-by-File Change Map
| File | Change Type | Notes |
index.html | Edit | Remove zoom lock, add Inter preconnect, add skip link |
tailwind.config.js | Edit | Add Chandra color tokens and font families |
src/index.css | Edit | Add CSS custom properties, global :focus-visible rule |
src/App.jsx | Edit | nav landmark, aria-current, Lucide icons, skip link target |
src/pages/Settings.jsx | Edit | New Toggle/Input primitives, contrast fixes, aria-live toast |
src/pages/Home.jsx | Edit | Color tokens, aria-live, MoonVisual aria-hidden, Lucide icons |
src/pages/Calendar.jsx | Edit | div→button cells, BottomSheet primitive, grid semantics |
src/pages/Panchang.jsx | Edit | aria-labels on chevrons, DatePickerSheet, Noto lazy load |
src/components/MoonVisual.jsx | Edit | Add aria-hidden="true" to SVG |
src/components/SubscribeSheet.jsx | Edit | Adopt BottomSheet primitive |
src/components/InstallPrompt.jsx | Edit | Add role="dialog", aria-label on buttons |
src/components/ui/ | New folder | Button.jsx, Input.jsx, Toggle.jsx, BottomSheet.jsx, Card.jsx, Chip.jsx, Icon.jsx |
src/components/DatePickerSheet.jsx | Edit | Adopt BottomSheet primitive, fix backdrop keyboard dismiss |
CLAUDE.md | Update | Add design system section with token references and rules |
What Does NOT Change
✅ These files are untouched by this migration
moonUtils.js, festivals.js, cities.js, eclipseUtils.js, SettingsContext.jsx, SubscriptionContext.jsx, main.jsx, firebase.js, analytics.js, notifications.js, vite.config.js, firestore.rules. The logic layer is completely isolated from the design system. No calculation or data code changes.
Rollback Plan
Since each phase is a separate branch merged to main, any phase can be reverted individually with a standard git revert. Vercel will automatically redeploy the previous version. No database changes are involved.
Definition of Done
| Check | Acceptance Criteria |
| Contrast | Zero contrast failures in axe-core scan across all 4 pages |
| Keyboard | Every interactive element reachable and operable by Tab / Enter / Space / Escape |
| Screen reader | VoiceOver on iOS announces nav tabs, toggles, and modal titles correctly |
| Zoom | App is fully usable at 200% browser zoom with no content cutoff |
| Performance | Noto Serif Devanagari does not appear in initial page load. LCP unaffected. |
| Visual | All emoji UI icons replaced with Lucide equivalents |
| Tokens | No raw Tailwind color classes (gray-900, yellow-300 etc.) remain in page files — all replaced with Chandra token classes |
ℹ One final note on sequencing
Phases 1 and 2 (tokens + primitives) can be done by a developer independently while visual design decisions for Phase 3+ are still being finalised. The infrastructure investment pays dividends from day one — the token system alone eliminates an entire class of future accessibility regressions, regardless of which visual direction is ultimately chosen.
Chandra Design System v1.0 — For Review · Not yet implemented · May 2026