aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0020: Theming Architecture
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0020: Theming Architecture
## Status
Accepted
## Context
mdbook-htmx must support visual customization while maintaining consistency, accessibility, and ease of use. Users need to:
1. Switch between light and dark themes
2. Customize colors to match their brand
3. Create entirely custom themes
4. Support system preference detection
5. Persist theme preferences
The theming system must work across static deployments (GitHub Pages) and dynamic servers (Cloudflare Workers).
## Decision Drivers
1. **Accessibility** - WCAG 2.1 AA contrast requirements
2. **Consistency** - Unified look across all pages
3. **Customization** - Easy brand alignment
4. **Performance** - No layout shift on load
5. **Maintainability** - Single source of truth for design tokens
## Decision
**Use CSS Custom Properties with a semantic token layer and theme files.**
### Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Theme Layer Stack │
├─────────────────────────────────────────────────────────────┤
│ Component Styles │
│ ├── .btn { background: var(--btn-bg); } │
│ ├── .card { border-color: var(--card-border); } │
│ └── Uses semantic tokens only │
├─────────────────────────────────────────────────────────────┤
│ Semantic Tokens (Theme-Specific) │
│ ├── --color-bg: var(--gray-50); /* light */ │
│ ├── --color-bg: var(--gray-900); /* dark */ │
│ └── Mapped differently per theme │
├─────────────────────────────────────────────────────────────┤
│ Primitive Tokens (Constant) │
│ ├── --gray-50: #f9fafb; │
│ ├── --gray-900: #111827; │
│ └── Same across all themes │
└─────────────────────────────────────────────────────────────┘
```
### Token Structure
#### Primitive Tokens (base/variables.css)
Raw color values and scales:
```css
:root {
/* Color Primitives - Never used directly in components */
/* Gray Scale */
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--gray-950: #030712;
/* Primary (Indigo) */
--primary-50: #eef2ff;
--primary-100: #e0e7ff;
--primary-200: #c7d2fe;
--primary-300: #a5b4fc;
--primary-400: #818cf8;
--primary-500: #6366f1;
--primary-600: #4f46e5;
--primary-700: #4338ca;
--primary-800: #3730a3;
--primary-900: #312e81;
/* Semantic Colors */
--success-500: #22c55e;
--warning-500: #f59e0b;
--error-500: #ef4444;
--info-500: #3b82f6;
}
```
#### Semantic Tokens (theme files)
Meaning-based tokens that change per theme:
```css
/* themes/light.css */
:root,
[data-theme="light"] {
/* Backgrounds */
--color-bg: var(--gray-50);
--color-bg-secondary: var(--white);
--color-bg-tertiary: var(--gray-100);
--color-bg-elevated: var(--white);
--color-bg-overlay: rgba(0, 0, 0, 0.5);
/* Text */
--color-text: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-tertiary: var(--gray-500);
--color-text-inverse: var(--white);
--color-text-link: var(--primary-600);
--color-text-link-hover: var(--primary-700);
/* Borders */
--color-border: var(--gray-200);
--color-border-strong: var(--gray-300);
--color-border-focus: var(--primary-500);
/* Interactive */
--color-primary: var(--primary-600);
--color-primary-hover: var(--primary-700);
--color-primary-active: var(--primary-800);
/* Status */
--color-success: var(--success-500);
--color-warning: var(--warning-500);
--color-error: var(--error-500);
--color-info: var(--info-500);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
```
```css
/* themes/dark.css */
[data-theme="dark"] {
/* Backgrounds - Inverted */
--color-bg: var(--gray-900);
--color-bg-secondary: var(--gray-800);
--color-bg-tertiary: var(--gray-700);
--color-bg-elevated: var(--gray-800);
--color-bg-overlay: rgba(0, 0, 0, 0.7);
/* Text - Inverted with adjusted contrast */
--color-text: var(--gray-100);
--color-text-secondary: var(--gray-300);
--color-text-tertiary: var(--gray-400);
--color-text-inverse: var(--gray-900);
--color-text-link: var(--primary-400);
--color-text-link-hover: var(--primary-300);
/* Borders - Lighter for visibility */
--color-border: var(--gray-700);
--color-border-strong: var(--gray-600);
--color-border-focus: var(--primary-400);
/* Interactive - Lighter variants */
--color-primary: var(--primary-500);
--color-primary-hover: var(--primary-400);
--color-primary-active: var(--primary-300);
/* Shadows - More subtle in dark mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
```
### Component Token Usage
Components reference only semantic tokens:
```css
/* components/buttons.css */
.btn {
background: var(--color-primary);
color: var(--color-text-inverse);
border: 1px solid var(--color-primary);
}
.btn:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-text);
border-color: var(--color-border);
}
/* components/cards.css */
.card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
```
### Theme Switching
#### JavaScript Implementation
```javascript
// theme.js
class ThemeManager {
constructor() {
this.storageKey = 'theme';
this.defaultTheme = 'system';
}
init() {
// Apply saved or system theme
const saved = this.getSaved();
this.apply(saved);
// Listen for system changes
this.watchSystem();
}
getSaved() {
return localStorage.getItem(this.storageKey) || this.defaultTheme;
}
getSystem() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
getEffective(preference) {
return preference === 'system' ? this.getSystem() : preference;
}
apply(preference) {
const theme = this.getEffective(preference);
// Set data attribute
document.documentElement.setAttribute('data-theme', theme);
// Update meta theme-color
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = getComputedStyle(document.documentElement)
.getPropertyValue('--color-bg').trim();
}
// Store preference
localStorage.setItem(this.storageKey, preference);
// Dispatch event
window.dispatchEvent(new CustomEvent('themechange', {
detail: { theme, preference }
}));
}
toggle() {
const current = this.getSaved();
const next = current === 'dark' ? 'light' :
current === 'light' ? 'system' : 'dark';
this.apply(next);
return next;
}
watchSystem() {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (this.getSaved() === 'system') {
this.apply('system');
}
});
}
}
window.themeManager = new ThemeManager();
document.addEventListener('DOMContentLoaded', () => {
window.themeManager.init();
});
```
#### Flash Prevention
Prevent theme flash on page load:
```html
(function() {
const saved = localStorage.getItem('theme') || 'system';
const theme = saved === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: saved;
document.documentElement.setAttribute('data-theme', theme);
})();
```
#### HTMX Integration
Theme toggle with HTMX:
```html
[data-show-theme] { display: none; }
[data-theme="light"] [data-show-theme="light"] { display: inline; }
[data-theme="dark"] [data-show-theme="dark"] { display: inline; }
```
### Custom Theming
#### User Custom Theme
Allow users to define custom themes:
```toml
# book.toml
[output.htmx.theme]
default = "light"
custom = "custom"
[output.htmx.theme.colors]
# Override primitive tokens
primary-500 = "#2563eb"
primary-600 = "#1d4ed8"
# Override semantic tokens
color-bg = "#fefefe"
color-text = "#1a1a1a"
```
Generates:
```css
/* themes/custom.css */
[data-theme="custom"] {
--primary-500: #2563eb;
--primary-600: #1d4ed8;
--color-bg: #fefefe;
--color-text: #1a1a1a;
}
```
#### Brand Theme Extension
```css
/* themes/brand.css */
@import './light.css';
[data-theme="brand"] {
/* Override brand colors */
--primary-500: var(--brand-primary);
--primary-600: var(--brand-primary-dark);
/* Keep other light theme values */
}
```
### Accessibility Compliance
#### Contrast Checking
Build-time contrast validation:
```rust
// src/theme/accessibility.rs
use palette::{Srgb, color_difference::Wcag21RelativeContrast};
pub fn validate_contrast(tokens: &SemanticTokens) -> Vec {
let mut violations = vec![];
// Text on background must be 4.5:1 (AA)
let pairs = [
("color-text", "color-bg"),
("color-text-secondary", "color-bg"),
("color-text-link", "color-bg"),
];
for (fg_name, bg_name) in pairs {
let fg = tokens.get(fg_name).parse::().unwrap();
let bg = tokens.get(bg_name).parse::().unwrap();
let ratio = fg.relative_contrast(bg);
if ratio < 4.5 {
violations.push(ContrastViolation {
foreground: fg_name.to_string(),
background: bg_name.to_string(),
ratio,
required: 4.5,
});
}
}
violations
}
```
#### High Contrast Mode
Support system high contrast:
```css
@media (prefers-contrast: more) {
:root {
--color-border: var(--gray-400);
--color-border-strong: var(--gray-600);
--shadow-sm: none;
--shadow-md: 0 0 0 2px var(--color-border);
}
}
@media (forced-colors: active) {
.btn {
border: 2px solid currentColor;
}
.card {
border: 2px solid currentColor;
}
}
```
### Reduced Motion Support
```css
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
### Theme File Structure
```
book/assets/css/
├── base/
│ ├── reset.css
│ ├── variables.css # Primitive tokens
│ └── typography.css
├── themes/
│ ├── light.css # Light semantic tokens
│ ├── dark.css # Dark semantic tokens
│ └── custom.css # User-defined (generated)
├── components/
│ └── ... # Use semantic tokens only
└── main.css # Import order
```
### Configuration
```toml
[output.htmx.theme]
# Default theme on first visit
default = "system" # light | dark | system
# Available themes
available = ["light", "dark"]
# Allow user theme switching
switching = true
# Persist preference
persist = true # Uses localStorage
# Validate contrast on build
validate-contrast = true
# Custom token overrides
[output.htmx.theme.tokens]
primary-500 = "#2563eb"
```
## Implementation
### Build Pipeline
```rust
// src/theme/build.rs
pub fn build_theme(config: &ThemeConfig, dest: &Path) -> Result<()> {
// 1. Copy base theme files
copy_theme_files(dest)?;
// 2. Generate custom theme if configured
if let Some(tokens) = &config.custom_tokens {
generate_custom_theme(tokens, dest)?;
}
// 3. Validate contrast
if config.validate_contrast {
let violations = validate_all_themes(dest)?;
if !violations.is_empty() {
warn!("Contrast violations found: {:?}", violations);
}
}
Ok(())
}
```
### Runtime Theme API
```typescript
// For server-rendered pages
interface ThemeAPI {
get(): Promise<'light' | 'dark' | 'system'>;
set(theme: 'light' | 'dark' | 'system'): Promise;
toggle(): Promise<'light' | 'dark' | 'system'>;
}
// Cloudflare Worker implementation
export const themeAPI: ThemeAPI = {
async get() {
// Read from cookie or default
const cookie = getCookie('theme');
return cookie || 'system';
},
async set(theme) {
setCookie('theme', theme, {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
secure: true
});
},
async toggle() {
const current = await this.get();
const next = current === 'dark' ? 'light' :
current === 'light' ? 'system' : 'dark';
await this.set(next);
return next;
}
};
```
## Consequences
### Positive
- Clean separation between primitives and semantics
- Easy theme creation via token overrides
- Accessible by default with contrast validation
- No flash with inline script
- Works on static and dynamic deployments
### Negative
- Requires CSS Custom Properties support (IE11 excluded)
- Token layer adds abstraction
- Build validation adds overhead
### Mitigation
- IE11 is EOL; acceptable tradeoff
- Good token naming reduces confusion
- Validation runs only in CI or on build flag
## Alternatives Considered
### CSS-in-JS
Use styled-components or similar.
**Rejected** because:
- Requires JavaScript runtime
- Larger bundle size
- Doesn't work with HTMX architecture
### Sass Variables
Use $variables compiled at build time.
**Rejected** because:
- Can't switch themes at runtime
- Requires full rebuild for changes
- CSS Custom Properties are now widely supported
### Multiple Stylesheet Approach
Serve different CSS files per theme.
**Rejected** because:
- FOUC on theme switch
- Caching complexity
- Doesn't support system preference
## References
- [CSS Custom Properties (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)
- [Design Tokens (W3C)](https://www.w3.org/community/design-tokens/)
- [WCAG 2.1 Contrast Requirements](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
- [prefers-color-scheme (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)
- [Tailwind CSS Color System](https://tailwindcss.com/docs/customizing-colors)
Contributor guide
Assessment
This issue has not been assessed yet.