Design System

Chandra

A calm, accessible, India-first design system for the Chandra Moon Tracker PWA.
Version 1.0 — Revised & Accessibility-Validated.

WCAG 2.1 AA React + Tailwind CSS Inter + Noto Serif Devanagari Lucide Icons Dark-First Mobile PWA
1

Design Philosophy

"A modern Indian astronomy companion — calm, trustworthy, and night-friendly. Deeply rooted in lunar tradition without feeling like a religious app."

Four Design Principles

PrincipleWhat it means in practiceWhat to avoid
Night-FirstDark backgrounds everywhere. Moon visuals shine against depth.Pure #000 backgrounds; harsh neon glows
Calm PrecisionClean typography, generous whitespace, clear hierarchy. Numbers and times are never ambiguous.Dense information walls; cluttered cards
Culturally GroundedSanskrit terms rendered in Noto Serif Devanagari. Gold as sacred accent. Subtle celestial textures.Temple motifs, heavy ornament, overly devotional imagery
Accessible by DefaultEvery component ships with WCAG AA contrast, keyboard support, and ARIA attributes baked in.Retrofitting a11y after launch; treating it as optional
2

Typography

Font Stack

RoleFontUsageLoading 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

TokenSizeWeightLine HeightUsage
--text-hero32px7001.2Moon phase display name, hero numbers
--text-section24px6001.2Page headings (Panchang, Calendar, Settings)
--text-card-title18px6001.3Card titles, sheet headers
--text-body16px4001.5Primary body text — minimum for all readable content
--text-secondary14px4001.5Supporting labels, descriptions
--text-meta12px5001.3Timestamps, 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

/* In index.html <head> */
/* Step 1: preconnect to Google Fonts */
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

/* Step 2: Load Inter eagerly — subset to latin */
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

/* Step 3: Noto Serif Devanagari — loaded lazily in Panchang.jsx */
// In Panchang.jsx useEffect:
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);
3

Color System

🔴 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.
TokenHexStateApplies to
--gold-default#DDBB6ADefaultPrimary CTA background
--gold-hover#E8C87AHover / focusSlightly lighter — warmth
--gold-active#C9A85APressed / activeDarker — conveying press
--indigo-cta-default#3D4ECCDefaultSecondary CTA background
--indigo-cta-hover#4A5BD6Hover / focusSlightly lighter
--indigo-cta-active#3040B8Pressed / activeDarker
--indigo-decorative#5B6CFFStaticChips, icons, borders (decorative only)
--blue-focus#8EA8FFFocus ringUsed as outline color — never as button fill
4

Spacing System

8px base grid. All spacing tokens are multiples or half-multiples of 8px. Touch targets are governed separately — see Accessibility Standards.

TokenValuePrimary Usage
--space-xs4pxIcon-to-text gaps, tight internal padding
--space-sm8pxBetween related elements within a component
--space-md16pxInternal card padding, form field gaps
--space-lg24pxBetween card sections, sheet padding
--space-xl32pxBetween major sections on a page
--space-xxl48pxPage top padding, hero spacing
--touch-target44pxMinimum 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.
5

Elevation & Shadows

On dark backgrounds, elevation is communicated through subtle background lightening (layer tinting) more than drop shadows. Both are provided for flexibility.

TokenShadow ValueUsage
--shadow-sm0 1px 3px rgba(0,0,0,0.4)Chips, small action buttons
--shadow-md0 4px 12px rgba(0,0,0,0.5)Cards floating on main background
--shadow-lg0 8px 24px rgba(0,0,0,0.6)Bottom sheets, modals
--shadow-glow-gold0 0 20px rgba(221,187,106,0.18)Full moon state — moon visual card only
--shadow-glow-blue0 0 20px rgba(142,168,255,0.12)Focus ring glow on dark surfaces
6

Border Radius

TokenValueUsage
--radius-sm8pxSmall inline elements, badges, tags
--radius-md12pxButtons, inputs, small cards
--radius-lg16pxStandard cards, modals
--radius-xl24pxBottom sheets (top corners), large panels
--radius-full999pxChips, pill badges, avatar rings

Border Colors NEW

These were missing from the original system. Define them explicitly to prevent ad-hoc border color decisions.

TokenValueUsage
--border-subtlergba(255,255,255,0.06)Barely visible dividers, inner card sections
--border-defaultrgba(255,255,255,0.12)Standard card borders, input outlines (unfocused)
--border-emphasisrgba(221,187,106,0.35)Highlighted cards, active selections, festival days
--border-focus#8EA8FFFocused input/button borders (paired with focus ring)
--border-error#D45C5CInvalid input fields
7

Motion & Animation

TokenValueUsage
--duration-fast150msHover states, color transitions
--duration-base200msButton press, fade, small scale
--duration-slow300msSheet slide-up, modal entrance
--ease-outcubic-bezier(0.0, 0.0, 0.2, 1)Entrances — elements coming in feel snappy
--ease-incubic-bezier(0.4, 0.0, 1, 1)Exits — elements leaving accelerate out
--ease-springcubic-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.
/* Correct pattern */
.sheet {
  transform: translateY(100%);
}
@media (prefers-reduced-motion: no-preference) {
  .sheet {
    transition: transform var(--duration-slow) var(--ease-out);
  }
}
8

Buttons

Button Variants
Small Variant
Disabled State
Coming Soon

Button Specifications

VariantBackgroundText ColorContrastUse for
Primary#DDBB6A (Moon Gold)#0B102010.5:1 ✅Most important action on screen. Maximum 1 per view.
Secondary#3D4ECC (CTA Indigo)#F5F7FA6.3:1 ✅Supporting actions, navigation CTAs
GhostTransparent#DDBB6A (border too)8.5:1 on bg ✅Tertiary actions, "Learn more" links
Danger#D45C5C (Grahan Red)#F5F7FA5.1:1 ✅Destructive actions (unsubscribe, delete)
Disabled#1D2440#70778BExempt (WCAG)Inactive / not yet available features

Accessibility Requirements — All Buttons

RequirementImplementation
Minimum touch targetmin-height: 44px; min-width: 44px
Visible focus ring:focus-visible { outline: 2px solid #8EA8FF; outline-offset: 2px; }
No outline suppressionNever use outline: none without a visible alternative
Disabled stateUse disabled attribute (not just aria-disabled) to remove from tab order
Icon-only buttonsMust have aria-label describing the action
Loading stateAdd aria-busy="true" while loading; disable interaction
// Correct button component template
const Button = ({ variant = 'primary', label, onClick, disabled, ariaLabel }) => (
  <button
    onClick={onClick}
    disabled={disabled}
    aria-label={ariaLabel}   // required for icon-only buttons
    className={`btn btn-${variant} ${disabled ? 'btn-disabled' : ''}`}
    style={{ minHeight: 44 }}
  >
    {label}
  </button>
);
9

Inputs & Forms

Input States

Input Accessibility Requirements

RequirementImplementationFixes Issue
Always use <label>Each input must have an associated <label htmlFor> or aria-labelSC 3.3.2 FAIL in audit
Placeholder not a labelPlaceholder is supplementary hint only. Label must persist when field has value.SC 3.3.2
Visible focus ringBorder changes to --border-focus + box-shadow ring with --blue-focus. Never outline: none.SC 2.4.7 FAIL in audit
Error associationError messages use id + aria-describedby on the inputSC 3.3.1
Min touch targetmin-height: 44pxWCAG 2.5.5
// Correct input pattern
<div>
  <label htmlFor="city-search" className="sr-only">Search for a city</label>
  <input
    id="city-search"
    type="text"
    placeholder="Search city or state…"
    className="... focus-visible:ring-2 focus-visible:ring-[#8EA8FF]
               focus-visible:ring-offset-1 focus-visible:ring-offset-[#0B1020]"
    aria-describedby={error ? "city-error" : undefined}
  />
  {error && <p id="city-error" role="alert" className="text-[#D45C5C]">{error}</p>}
</div>
10

Toggles

Toggle Variants — Standard pill + thumb design
Enable all alerts — On
Festival alerts — Off
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

PartSpecNotes
Track (pill)44 × 26 px, border-radius 13 pxGold (#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.
Transitionleft 0.2s cubic-bezier(0.34,1.56,0.64,1)Slight spring bounce for a tactile feel. Suppressed if prefers-reduced-motion: reduce.
DisabledEntire component at opacity: 0.45No interaction. WCAG exempts disabled controls from contrast requirements.

Toggle Accessibility Requirements

RequirementImplementationFixes Issue
Accessible nameEvery toggle must receive an aria-label describing what it controlsSC 4.1.2 FAIL in audit
Role + staterole="switch" + aria-checked={on} on the button elementSC 4.1.2 — screen reader announces "on/off" automatically
Visible boundaryTrack has border: 1.5px solid rgba(255,255,255,0.15) to achieve ≥ 3:1 against card backgroundSC 1.4.11 FAIL in audit
Focus ring:focus-visible shows 2px #8EA8FF outline around the trackSC 2.4.7
Min touch targetEntire button element: min-height: 44px; min-width: 44px via paddingWCAG 2.5.5
// Toggle component — pill + thumb, accessible
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>
);

/* Suppress animation for users who prefer reduced motion */
/* @media (prefers-reduced-motion: reduce) { transition: none !important; } */
11

Cards

Dashami Tithi
Shukla Paksha
Moonrise19:04
Moonset06:21
Today's Highlight
Ganesh Chaturthi
Tap for full Panchang →

Card Types & Usage

TypeBackgroundBorderUse for
Standard#151B2F--border-defaultInfo display, Panchang rows, settings sections
Highlighted#151B2F--border-emphasis (gold)Today's highlight, festival cards, active states
AlertSemantic tint (e.g., indigo-950 for eclipse)Semantic borderEclipse banners, Ekadashi notices
Tappable#151B2F → #1D2440 on hover--border-defaultFestival 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.
12

Chips & Badges

Dashami Hasta Nakshatra Ganesh Chaturthi 🌑 Surya Grahan

Chip Color System

TypeBackgroundTextBorderUse for
Tithirgba(93,169,233,0.15)#8EC8F5rgba(93,169,233,0.3)Tithi names, lunar day indicators
Nakshatrargba(142,168,255,0.12)#8EA8FFrgba(142,168,255,0.25)Nakshatra labels, pada indicators
Festivalrgba(79,175,124,0.15)#7ECFA6rgba(79,175,124,0.3)Festival names, auspicious days
Eclipsergba(217,144,54,0.15)#F0B060rgba(217,144,54,0.3)Eclipse alerts, Grahan notices
13

Modals & Bottom Sheets

Accessibility Requirements

RequirementImplementationFixes Issue
Auto-focus on openUse useEffect to focus the first focusable element or the close button when the modal mountsSC 2.1.2 FAIL in audit
Focus trapTab and Shift+Tab must cycle only within the open modal. Use focus-trap-react library.SC 2.1.2 FAIL in audit
Escape key closeuseEffect adds a keydown listener for Escape that calls onClose()SC 2.1.2 FAIL in audit
Restore focus on closeStore document.activeElement before opening; call .focus() on it after closingSC 2.4.3
ARIA rolerole="dialog" + aria-modal="true" + aria-labelledby pointing to the sheet titleSC 4.1.2
Backdrop closeBackdrop click closes the modal AND a keyboard equivalent (Escape) existsSC 2.1.1
// Correct Modal/BottomSheet pattern (abbreviated)
import FocusTrap from 'focus-trap-react';

const BottomSheet = ({ open, onClose, title, children }) => {
  const triggerRef = useRef(null);

  useEffect(() => {
    if (open) {
      triggerRef.current = document.activeElement;  // save focus origin
    } else {
      triggerRef.current?.focus();  // restore on close
    }
  }, [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>
  );
};
15

Lunar Phase Color System

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
PhaseBg TintCard Accent BorderGlow
Amavasya (New Moon)#100E1E#5A547ENone
Shukla Paksha (Waxing)#0F1528#7B9EDBrgba(123,158,219,0.08)
Purnima (Full Moon)#181428#DDBB6Argba(221,187,106,0.18)
Krishna Paksha (Waning)#111524#6E7CA8None
16

Iconography & Emoji Migration

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 EmojiContextLucide Replacementaria-hidden
🌙Today tab navMoontrue (text label present)
🗓️Calendar tab navCalendartrue
📿Panchang tab navClock or custom SVGtrue
⚙️Settings tab navSettingstrue
🔔Subscribe button (top bar)Bell / BellRingtrue (button has aria-label)
📍City location labelMapPintrue (city name follows)
🌅Brahma Muhurta timing labelSunrisetrue
Abhijit Muhurta labelSparklestrue
🐍Rahu Kaal labelAlertTriangletrue
Yamagandam labelTimertrue
Settings saved / selected stateChecktrue
Close modal/sheet buttonXfalse — 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

EmojiPhase Name (Hindi/Sanskrit)Usage in AppAccessibility Requirement
🌑Amavasya (New Moon)Calendar day cell, Panchang headerWrap 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 GibbousCalendar day cell
🌕Purnima (Full Moon)Calendar day cell, Panchang header, Home highlight
🌖Waning Gibbous (Krishna Paksha)Calendar day cell
🌗Last QuarterCalendar day cell
🌘Waning CrescentCalendar 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)

RuleDetail
SizeNavigation tabs: 22×22px. Card/inline icons: 18×18px. Small inline: 16×16px.
Stroke widthAlways strokeWidth={1.75}. Never use filled variants in this system.
ColorInherit from parent text color via currentColor, or set explicitly using a design token value.
aria-hiddenSet aria-hidden="true" on all Lucide icons — the accessible name comes from the surrounding button's aria-label or adjacent visible text.
Icon-only buttonsNever rely solely on the icon for communication. The parent <button> must have an explicit aria-label.
17

Complete Token Reference

/* ── index.css — complete token set ── */
:root {

  /* Backgrounds */
  --bg-primary:        #0B1020;   /* Main app background */
  --bg-surface:        #151B2F;   /* Cards, sheets */
  --bg-elevated:       #1D2440;   /* Hover, elevated cards */
  --bg-overlay:        rgba(0,0,0,0.65); /* Modal backdrop */

  /* Brand */
  --gold:              #DDBB6A;
  --gold-hover:        #E8C87A;
  --gold-active:       #C9A85A;
  --indigo-cta:        #3D4ECC;   /* Button bg — white text 6.3:1 ✅ */
  --indigo-cta-hover:  #4A5BD6;
  --indigo-cta-active: #3040B8;
  --indigo-deco:       #5B6CFF;   /* Decorative only — NOT button bg */
  --blue-light:        #8EA8FF;   /* Focus ring, links — 8.5:1 on bg ✅ */

  /* Text */
  --text-primary:      #F5F7FA;   /* 18.4:1 on --bg-primary ✅ */
  --text-secondary:    #B5BDD1;   /* 10.4:1 on --bg-primary ✅ */
  --text-disabled:     #70778B;   /* Disabled states ONLY — 4.35:1 (exempt) */
  --text-inverse:      #0B1020;   /* Dark text on gold/light backgrounds */

  /* Semantic */
  --success:           #4FAF7C;   /* 7.1:1 on --bg-primary ✅ */
  --warning:           #D99036;   /* 7.5:1 on --bg-primary ✅ */
  --error:             #D45C5C;   /* 5.1:1 on --bg-primary ✅ */
  --info:              #5DA9E9;

  /* Borders */
  --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 (accessibility) */
  --focus-ring-color:  #8EA8FF;
  --focus-ring-width:  2px;
  --focus-ring-offset: 2px;

  /* Shadows */
  --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);

  /* Spacing */
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 16px;
  --space-lg: 24px;
  --space-xl: 32px;
  --space-xxl: 48px;
  --touch-target: 44px;

  /* Border radius */
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 16px;
  --radius-xl: 24px;
  --radius-full: 999px;

  /* Motion */
  --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-index scale */
  --z-base:    0;
  --z-sticky:  40;   /* Top bar, bottom nav */
  --z-overlay: 50;   /* Bottom sheets, date picker */
  --z-modal:   60;   /* Modals, dialogs */
  --z-toast:   70;   /* Toast notifications, alerts */
  --z-top:     80;   /* Critical overlays only */

  /* Typography */
  --font-ui:   'Inter', -apple-system, 'Segoe UI', Arial, sans-serif;
  --font-deva: 'Noto Serif Devanagari', serif;   /* Lazy loaded */
  --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;
}

/* Global focus ring — single definition */
:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

Tailwind Config Extension

// tailwind.config.js
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' }
    }
  }
};
18

Execution Plan

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
19

Migration Plan

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

FileChange TypeNotes
index.htmlEditRemove zoom lock, add Inter preconnect, add skip link
tailwind.config.jsEditAdd Chandra color tokens and font families
src/index.cssEditAdd CSS custom properties, global :focus-visible rule
src/App.jsxEditnav landmark, aria-current, Lucide icons, skip link target
src/pages/Settings.jsxEditNew Toggle/Input primitives, contrast fixes, aria-live toast
src/pages/Home.jsxEditColor tokens, aria-live, MoonVisual aria-hidden, Lucide icons
src/pages/Calendar.jsxEditdiv→button cells, BottomSheet primitive, grid semantics
src/pages/Panchang.jsxEditaria-labels on chevrons, DatePickerSheet, Noto lazy load
src/components/MoonVisual.jsxEditAdd aria-hidden="true" to SVG
src/components/SubscribeSheet.jsxEditAdopt BottomSheet primitive
src/components/InstallPrompt.jsxEditAdd role="dialog", aria-label on buttons
src/components/ui/New folderButton.jsx, Input.jsx, Toggle.jsx, BottomSheet.jsx, Card.jsx, Chip.jsx, Icon.jsx
src/components/DatePickerSheet.jsxEditAdopt BottomSheet primitive, fix backdrop keyboard dismiss
CLAUDE.mdUpdateAdd 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

CheckAcceptance Criteria
ContrastZero contrast failures in axe-core scan across all 4 pages
KeyboardEvery interactive element reachable and operable by Tab / Enter / Space / Escape
Screen readerVoiceOver on iOS announces nav tabs, toggles, and modal titles correctly
ZoomApp is fully usable at 200% browser zoom with no content cutoff
PerformanceNoto Serif Devanagari does not appear in initial page load. LCP unaffected.
VisualAll emoji UI icons replaced with Lucide equivalents
TokensNo 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