aRustyDev / aRustyDev/mdbook-htmx

docs(adr): ADR-0020: Theming Architecture

Open
#28 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.