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
| Element | Size | Weight |
|---|---|---|
| Base content | 16px | 475 |
| Body text (p, li, span) | max(0.875rem, 1em) | 475 |
| Headings h1-h3 | 1.75/1.4/1.15rem | inherited |
| Headings h4-h5 | 1/0.9rem | 600 |
| strong/b | inherited | 650 |
| Table headers | 0.8125rem | 600 |
| Table cells | 0.875rem | 475 |
| Buttons | max(0.8rem, 1em) | 525 |
| Inputs | 0.9375rem | 475 |
Dark Color Palette
| Role | Value | Usage |
|---|---|---|
| Shell background | #141210 | Main content area |
| Sidebar | #0d0c0a | Darkest element |
| Header | #1a1816 | Top bar, table headers |
| Card surface | #1e1c19 | Cards, containers, notes |
| Elevated surface | #2a2725 | Tags, pills, progress bars |
| Body text | #e0dbd4 | Primary readable text |
| Headings | #f0ece6 | Near-white for prominence |
| Muted text | #a09a92 | Labels, descriptions |
| Primary (sage) | #9aab88 | Links, accents (lighter than light-mode #7d8a6d) |
| Borders | rgba(255,255,255,0.08) | Universal subtle dividers |
Related Documentation
docs/solutions/ui-patterns/astro-page-brand-token-consistency.md— CSS token system and brand propertiesdocs/solutions/build-errors/tailwind-v4-astro-integration.md— CSS-first Tailwind v4 setupdocs/solutions/architecture-patterns/bulk-site-scaffolding-and-shared-components.md— Shared component extractionapps/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
- Instrument it — log which selectors actually fire, creating a concrete migration backlog
- Migrate by frequency — fix the top 10 most-triggered selectors per sprint
- Gate it — move behind a
data-legacy-patchattribute; 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:
- CSS variable overrides — free, catches compliant components
- Global style overrides with wildcard selectors — catches naming-convention-following components
- Explicit class targeting — catches known non-compliant components
- 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.