dark-mode css astro scoped-css admin adventure-weddings theming

Adventure Weddings CRM — Dark Mode Retrofit for 690+ Scoped Astro Pages

Dark Mode Retrofit for 690+ Scoped Astro Pages

Problem

The Adventure Weddings CRM admin dashboard has 690+ pages (365+ social planner pages alone). Each page uses Astro’s scoped <style> blocks with hardcoded light-mode colors:

/* Typical scoped style in a child page */
.stat-card { background: white; border: 1px solid #e5ded5; }
.tag { background: #f5f3ef; color: var(--brand-color-muted); }
.post-row { border-bottom: 1px solid #f5f3ef; }

Astro compiles scoped styles to hashed selectors like .stat-card[data-astro-xxxx]. The values white and #f5f3ef are baked into the compiled CSS — they never reference CSS custom properties, so overriding --brand-color-bg on a parent element has no effect.

The monospace typewriter body font (Inconsolata) at default weight on light backgrounds was also hard to read.

Root Cause

Astro’s scoped styles encourage writing literal color values because the scope isolation makes it feel “safe.” Over 101 build cycles, ~365 pages were generated with consistent but hardcoded light-mode palettes. A simple CSS variable swap at the layout level cannot reach these compiled values.

Solution: Three-Layer Override Architecture

All changes live in a single file: AdminLayout.astro. No child pages were modified.

Layer 1 — CSS Custom Property Overrides

Override brand tokens at the .admin-content scope. Child pages that do reference var(--brand-color-*) pick these up automatically:

.admin-content {
  --brand-color-bg: #141210;
  --brand-color-text: #e0dbd4;
  --brand-color-border: rgba(255,255,255,0.1);
  --brand-color-muted: #a09a92;
  --brand-color-primary: #9aab88;
  --brand-color-accent: #c9baa8;
  --brand-color-dark: #f0ece6;
  --brand-color-dark-bg: #1e1c19;
  color: #e0dbd4;
  font-weight: 475;
  font-size: 16px;
}

Layer 2 — Global Style Overrides

Astro’s <style is:global> emits styles without scope hashing, so they match against scoped child elements. Two patterns:

Explicit class targeting (~60 card classes, ~40 badge classes, ~12 progress bar classes):

.admin-content :global(.stat-card),
.admin-content :global(.client-card),
/* ... ~60 explicit card class names ... */
.admin-content :global(.learning-card) {
  background: #1e1c19 !important;
  border-color: rgba(255,255,255,0.1);
}

Attribute-suffix wildcard selectors (catches unknown/future components):

.admin-content [class$="-row"],
.admin-content [class$="-item"],
.admin-content [class$="-card"] { font-size: 0.875rem; }

.admin-content [class*="-name"],
.admin-content [class*="-title"] { color: #f0ece6; font-weight: 600; }

.admin-content [class*="muted"],
.admin-content [class*="-notes"],
.admin-content [class*="-desc"] { color: #a09a92; }

Status badges preserve semantic colors with darkened backgrounds:

/* Green: live / done / active */
.admin-content :global(.status-live),
.admin-content :global(.badge-yes) {
  background: rgba(34, 197, 94, 0.15) !important;
  color: #6ee7a0;
}

/* Amber: draft / pending */
.admin-content :global(.status-draft) {
  background: rgba(245, 158, 11, 0.15) !important;
  color: #fbbf24;
}

/* Red: blocked / urgent */
.admin-content :global(.status-blocked) {
  background: rgba(239, 68, 68, 0.15) !important;
  color: #fca5a5;
}

Layer 3 — Runtime JavaScript Catch-All

Catches anything CSS couldn’t statically anticipate — computed backgrounds that resolve to light values at runtime:

document.addEventListener('DOMContentLoaded', () => {
  const content = document.querySelector('.admin-content');
  if (!content) return;

  const lightBgs = new Set([
    'rgb(255, 255, 255)',   // white
    'rgb(250, 248, 244)',   // #faf8f4
    'rgb(245, 243, 239)',   // #f5f3ef
    'rgb(245, 242, 236)',   // #f5f2ec
    'rgb(240, 240, 240)',   // #f0f0f0
    // ... more known light backgrounds
  ]);

  content.querySelectorAll('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundColor;
    if (lightBgs.has(bg)) {
      el.style.backgroundColor = '#1e1c19';
      return;
    }
    // Numeric fallback: catch near-white not in the explicit set
    const match = bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
    if (match) {
      const [, r, g, b] = match.map(Number);
      if (r > 230 && g > 228 && b > 220) {
        el.style.backgroundColor = '#1e1c19';
      }
    }
  });
});

Font Size & Weight Scaling

ElementSizeWeight
Base content16px475
Body text (p, li, span)max(0.875rem, 1em)475
Headings h1-h31.75/1.4/1.15reminherited
Headings h4-h51/0.9rem600
strong/binherited650
Table headers0.8125rem600
Table cells0.875rem475
Buttonsmax(0.8rem, 1em)525
Inputs0.9375rem475

Dark Color Palette

RoleValueUsage
Shell background#141210Main content area
Sidebar#0d0c0aDarkest element
Header#1a1816Top bar, table headers
Card surface#1e1c19Cards, containers, notes
Elevated surface#2a2725Tags, pills, progress bars
Body text#e0dbd4Primary readable text
Headings#f0ece6Near-white for prominence
Muted text#a09a92Labels, descriptions
Primary (sage)#9aab88Links, accents (lighter than light-mode #7d8a6d)
Bordersrgba(255,255,255,0.08)Universal subtle dividers
  • docs/solutions/ui-patterns/astro-page-brand-token-consistency.md — CSS token system and brand properties
  • docs/solutions/build-errors/tailwind-v4-astro-integration.md — CSS-first Tailwind v4 setup
  • docs/solutions/architecture-patterns/bulk-site-scaffolding-and-shared-components.md — Shared component extraction
  • apps/adventure-weddings/CLAUDE.md — Design system specification

Prevention Strategies

Immediate: Stylelint Rule

Block hardcoded colors at author time with declaration-property-value-disallowed-list:

background: /^(white|black|#fff|#ffffff|rgb\(255)/
color: /same pattern/
border-color: /same pattern/

Medium-term: Semantic Token Layer

Establish a closed palette in packages/ui/src/styles/tokens.css with no --color-pure-white — only semantic tokens like --color-surface, --color-background. If a developer can’t find a token, they extend the system rather than reaching for a hex value.

Long-term: Deprecate the Runtime Catch-All

  1. Instrument it — log which selectors actually fire, creating a concrete migration backlog
  2. Migrate by frequency — fix the top 10 most-triggered selectors per sprint
  3. Gate it — move behind a data-legacy-patch attribute; new pages never opt in

Testing: Dark Mode Smoke Test

Run a CI step that loads pages with prefers-color-scheme: dark, checks all elements for WCAG AA contrast ratio (4.5:1 minimum). Catches invisible-text bugs without stored baseline images.

Key Insight

When retrofitting dark mode onto a large codebase with scoped styles, the priority order is:

  1. CSS variable overrides — free, catches compliant components
  2. Global style overrides with wildcard selectors — catches naming-convention-following components
  3. Explicit class targeting — catches known non-compliant components
  4. Runtime JS — catches everything else as a safety net

Each layer reduces the work of the next. The runtime JS should be viewed as temporary debt, not a permanent solution.