Pulsefeed supports two themes: Dark (default) and Light, implemented via Flask session storage and CSS class-based overrides.
Location: Flask session in app.py
# Default to dark theme if not set
theme = session.get('theme', 'dark')
# Apply to all templates via context
@app.context_processor
def inject_theme():
return {'theme': session.get('theme', 'dark')}All templates include theme class on body tag:
<body class="theme-{{ session.get('theme', 'dark') }}">
<!-- Page content -->
</body>This creates:
- Dark theme:
<body class="theme-dark">(or no class, since dark is default) - Light theme:
<body class="theme-light">
Settings page: /settings route in app.py
@app.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
theme = request.form.get('theme', 'dark')
session['theme'] = theme
flash(f'Theme changed to {theme}', 'success')
return redirect(request.referrer or url_for('index'))
return render_template('settings.html')Settings template: templates/settings.html
<form method="post">
<div class="mb-3">
<label class="form-label">Theme</label>
<select name="theme" class="form-select">
<option value="dark" {% if session.get('theme', 'dark') == 'dark' %}selected{% endif %}>
Dark
</option>
<option value="light" {% if session.get('theme') == 'light' %}selected{% endif %}>
Light
</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>Location: static/styles.css
- Base styles (lines ~1-550): Apply to all elements, optimized for dark theme
- Light theme overrides (lines ~550+):
.theme-lightselector overrides for light theme
/* Base dark theme styles */
.card {
background: linear-gradient(135deg, #1a2642 0%, #0f1726 100%);
border: 1px solid rgba(58, 93, 176, 0.3);
color: #e6edf3;
}
/* Light theme overrides */
.theme-light .card {
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
color: #1e293b;
}All .theme-light overrides are grouped together in styles.css (around line 550+) for easy maintenance.
Dark theme:
background: linear-gradient(135deg, #1a2642 0%, #0f1726 100%);
border: 1px solid rgba(58, 93, 176, 0.3);
color: #e6edf3;Light theme:
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
color: #1e293b;Why gradients: Adds visual depth, makes cards feel elevated
Dark theme:
border: 1px solid rgba(58, 93, 176, 0.3); /* Subtle blue tint */Light theme:
border: 1px solid #cbd5e1; /* Neutral gray */Pattern: Borders should be slightly lighter than background for subtle definition
Layered shadows for elevation:
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15), /* Larger, softer shadow */
0 1px 3px rgba(0, 0, 0, 0.1); /* Smaller, sharper shadow */Why two shadows: Creates more realistic depth perception
Pattern: Subtle background change with smooth transition
.button {
background: #3a5db0;
transition: background 0.2s ease;
}
.button:hover {
background: #4a6dc0; /* Slightly lighter */
}Duration: 0.2s for instant feedback, 0.3s for larger transitions (like collapse animations)
WCAG AA minimum: 4.5:1 for normal text, 3:1 for large text
Dark theme:
- Background:
#0d1117(very dark) - Text:
#e6edf3(very light) - Contrast ratio: ~14:1 ✅
Light theme:
- Background:
#ffffff(white) - Text:
#1e293b(very dark) - Contrast ratio: ~15:1 ✅
Always use high-contrast colors:
/* Dark theme */
.form-label {
color: #e6edf3;
}
/* Light theme */
.theme-light .form-label {
color: #1f2937;
}Why critical: Low-contrast labels are a common accessibility violation
Add to main CSS section (~lines 1-550):
.new-component {
background: linear-gradient(135deg, #1a2642 0%, #0f1726 100%);
border: 1px solid rgba(58, 93, 176, 0.3);
border-radius: 8px;
padding: 1rem;
color: #e6edf3;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 1px 3px rgba(0, 0, 0, 0.1);
}
.new-component:hover {
background: linear-gradient(135deg, #1f2d4f 0%, #12192e 100%);
transition: background 0.2s ease;
}Add to .theme-light section (~lines 550+):
.theme-light .new-component {
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
color: #1e293b;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.05),
0 1px 3px rgba(0, 0, 0, 0.03);
}
.theme-light .new-component:hover {
background: linear-gradient(135deg, #e5ebf1 0%, #d6dde6 100%);
}Manual testing:
- Navigate to Settings page
- Switch to Light theme
- Navigate to page with new component
- Verify colors, contrast, and readability
- Test hover states and animations
- Switch back to Dark theme
- Repeat verification
Automated contrast checking:
- Use browser DevTools > Accessibility panel
- Check contrast ratios meet WCAG AA (4.5:1 minimum)
git add static/styles.css
git commit -m "Add themed styles for [new-component]
- Add dark theme styles with gradient background
- Add light theme overrides for accessibility
- Ensure WCAG AA contrast compliance in both themes"Storage: Flask session (session['category_colors'])
DEFAULT_CATEGORY_COLORS = {
"event": "#3b82f6", # Blue
"meeting": "#a855f7", # Purple
"reminder": "#f97316", # Orange
"birthday": "#ec4899", # Pink
"holiday": "#10b981" # Green
}
def get_category_color(category: str) -> str:
"""Get color for a category from session or defaults."""
category_colors = session.get('category_colors', {})
return category_colors.get(category, DEFAULT_CATEGORY_COLORS.get(category, "#6b7280"))Pattern: Replace dropdowns with visual button grids that use CSS custom properties for dynamic colors
.category-radio-btn {
border-left: 4px solid var(--category-color, #6b7280);
color: #e6edf3 !important; /* IMPORTANT: !important needed to override Bootstrap */
}HTML Usage:
<label class="btn category-radio-btn" for="catEvent"
style="--category-color: {{ category_colors['event'] }}">
<span class="category-emoji">📅</span>
<span class="category-label">Event</span>
</label>Why !important: Bootstrap button styles set text color on various states (:hover, :focus, :checked). Without !important, text can become unreadable when background darkens.
Same pattern applies to checklist selection:
<label class="btn checklist-radio-btn" for="checklist1"
style="--checklist-color: {{ checklist.color or '#3a5db0' }}">
<span class="checklist-label">{{ checklist.title }}</span>
</label>Special "New Checklist" button: Uses dashed green border to visually distinguish from existing checklists.
.checklist-radio-btn.new-checklist {
border-left-color: #22c55e;
border-style: dashed;
}Pattern: Unified card components with colored left borders and hover animations
Dark theme:
.event-card, .task-card {
background: rgba(59, 93, 176, 0.08) !important;
border: 1px solid rgba(59, 93, 176, 0.2) !important;
border-left: 4px solid var(--item-color, #3b82f6) !important;
border-radius: 8px !important;
padding: 0.875rem 1rem !important;
transition: all 0.2s ease;
cursor: pointer;
}
.event-card:hover, .task-card:hover {
background: rgba(59, 93, 176, 0.12) !important;
border-color: rgba(59, 93, 176, 0.35) !important;
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}Light theme:
.theme-light .event-card,
.theme-light .task-card {
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-left: 4px solid var(--item-color, #3b82f6) !important;
}
.theme-light .event-card:hover,
.theme-light .task-card:hover {
background: #f9fafb !important;
border-color: #d1d5db !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}Usage in templates:
<!-- Events use category colors -->
<div class="event-card" data-category="meeting"
style="--item-color: {{ get_category_color('meeting') }};">
<!-- Event content -->
</div>
<!-- Tasks use checklist colors -->
<div class="task-card"
style="--item-color: {{ checklist.color or '#3a5db0' }};">
<!-- Task content -->
</div>Important notes:
- ALWAYS use CSS custom property
--item-colorfor dynamic colors - Cards replace
<li>list items - use<div class="p-3">wrappers instead of<ul> - The
render_itemmacro in_item_macros.htmloutputs cards, not list items - Color coding: Events use category colors, Tasks use checklist colors
Dark theme:
.alert-info {
background: rgba(58, 93, 176, 0.2); /* Semi-transparent blue */
border: 1px solid rgba(58, 93, 176, 0.4);
color: #e6edf3;
}
.alert-info strong {
color: #93c5fd; /* Lighter blue for emphasis */
}Light theme:
.theme-light .alert-info {
background: #dbeafe; /* Solid light blue */
border: 1px solid #93c5fd;
color: #1e3a8a; /* Dark blue text */
}
.theme-light .alert-info strong {
color: #1e40af; /* Darker blue for emphasis */
}Dark theme:
.btn-primary {
background: #3a5db0;
border: 1px solid #4a6dc0;
color: #ffffff;
}
.btn-primary:hover {
background: #4a6dc0;
border: 1px solid #5a7dd0;
}Light theme:
.theme-light .btn-primary {
background: #3b82f6;
border: 1px solid #2563eb;
color: #ffffff;
}
.theme-light .btn-primary:hover {
background: #2563eb;
border: 1px solid #1d4ed8;
}Dark theme:
.form-control {
background: #161b22;
border: 1px solid #30363d;
color: #e6edf3;
}
.form-control:focus {
background: #0d1117;
border-color: #3a5db0;
box-shadow: 0 0 0 3px rgba(58, 93, 176, 0.3);
}Light theme:
.theme-light .form-control {
background: #ffffff;
border: 1px solid #d1d5db;
color: #1f2937;
}
.theme-light .form-control:focus {
background: #ffffff;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}Dark theme:
.sidebar {
background: #0d1117;
border-right: 1px solid #21262d;
}
.sidebar a {
color: #8b949e;
}
.sidebar a:hover {
background: rgba(58, 93, 176, 0.1);
color: #e6edf3;
}Light theme:
.theme-light .sidebar {
background: #f9fafb;
border-right: 1px solid #e5e7eb;
}
.theme-light .sidebar a {
color: #6b7280;
}
.theme-light .sidebar a:hover {
background: rgba(59, 130, 246, 0.1);
color: #1f2937;
}| Element | Color | Hex | Usage |
|---|---|---|---|
| Background (darkest) | Very dark gray | #0d1117 |
Page background |
| Background (dark) | Dark gray | #161b22 |
Cards, inputs |
| Background (medium) | Medium gray | #21262d |
Borders, dividers |
| Primary blue | Blue | #3a5db0 |
Buttons, links |
| Primary blue (light) | Light blue | #93c5fd |
Highlights, badges |
| Text (primary) | Very light gray | #e6edf3 |
Body text |
| Text (secondary) | Medium gray | #8b949e |
Secondary text |
| Element | Color | Hex | Usage |
|---|---|---|---|
| Background (lightest) | White | #ffffff |
Page background |
| Background (light) | Very light gray | #f9fafb |
Cards, sidebar |
| Background (medium) | Light gray | #e5e7eb |
Borders, dividers |
| Primary blue | Blue | #3b82f6 |
Buttons, links |
| Primary blue (dark) | Dark blue | #1e40af |
Highlights, emphasis |
| Text (primary) | Very dark gray | #1f2937 |
Body text |
| Text (secondary) | Medium gray | #6b7280 |
Secondary text |
/* ==========================================
Lines 1-50: Reset & Base Styles
========================================== */
/* ==========================================
Lines 51-200: Layout (Grid, Flexbox)
========================================== */
/* ==========================================
Lines 201-350: Components (Buttons, Forms)
========================================== */
/* ==========================================
Lines 351-500: Page-Specific Styles
========================================== */
/* ==========================================
Lines 501-550: Utilities & Helpers
========================================== */
/* ==========================================
Lines 550+: LIGHT THEME OVERRIDES
========================================== */
.theme-light {
/* All light theme overrides grouped here */
}
.theme-light .component-1 { }
.theme-light .component-2 { }
/* ... etc ... */Why this structure:
- Easy to find dark theme styles (main sections)
- Easy to find light theme overrides (all in one place)
- Prevents duplicate or conflicting styles
/* Added new component styles */
.new-component {
background: #1a2642;
color: #e6edf3;
}
/* FORGOT to add .theme-light override */
/* Component is now unreadable in light theme! */✅ Always add both:
.new-component {
background: #1a2642;
color: #e6edf3;
}
.theme-light .new-component {
background: #f0f4f8;
color: #1e293b;
}/* Don't repeat color values */
.component-a { color: #e6edf3; }
.component-b { color: #e6edf3; }
.component-c { color: #e6edf3; }✅ Use consistent palette (consider CSS variables in future):
/* Consistent color usage */
.component-a,
.component-b,
.component-c {
color: #e6edf3;
}.theme-light .text-secondary {
color: #d1d5db; /* Light gray on white = 1.8:1 contrast ❌ */
}✅ Check contrast ratios:
.theme-light .text-secondary {
color: #6b7280; /* Medium gray on white = 4.6:1 contrast ✅ */
}/* Line 100 */
.component-a { }
.theme-light .component-a { } /* Override here */
/* Line 300 */
.component-b { }
.theme-light .component-b { } /* Override here */
/* Hard to maintain! */✅ Group all overrides together:
/* Lines 1-500: All dark theme styles */
.component-a { }
.component-b { }
/* Lines 500+: All light theme overrides grouped */
.theme-light .component-a { }
.theme-light .component-b { }When adding new themed components:
- Dark theme styles added in main CSS section
- Light theme overrides added in
.theme-lightsection - Text contrast ≥ 4.5:1 in dark theme (checked with DevTools)
- Text contrast ≥ 4.5:1 in light theme (checked with DevTools)
- Hover states work in both themes
- Focus states visible in both themes
- Animations smooth in both themes
- Component tested on actual page in both themes
- Screenshots taken for documentation (optional)
Consider migrating to CSS variables for easier theme management:
:root {
--bg-primary: #0d1117;
--text-primary: #e6edf3;
--border-color: rgba(58, 93, 176, 0.3);
}
.theme-light {
--bg-primary: #ffffff;
--text-primary: #1f2937;
--border-color: #cbd5e1;
}
.component {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}Benefits:
- Single source of truth for colors
- Easier to add new themes
- Fewer overrides needed
Not implemented yet - current system works well for two themes.