docs(adr): ADR-0020: Theming Architecture
@aRustyDev ci sta già lavorando.
Dal 5/1/2026.
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
ADR-0020: Theming Architecture
Status
Accepted
Context
mdbook-htmx must support visual customization while maintaining consistency, accessibility, and ease of use. Users need to:
- Switch between light and dark themes
- Customize colors to match their brand
- Create entirely custom themes
- Support system preference detection
- Persist theme preferences
The theming system must work across static deployments (GitHub Pages) and dynamic servers (Cloudflare Workers).
Decision Drivers
- Accessibility - WCAG 2.1 AA contrast requirements
- Consistency - Unified look across all pages
- Customization - Easy brand alignment
- Performance - No layout shift on load
- 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:
: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:
/* 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);
}
/* 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:
/* 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
// 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:
<!-- In <head> before stylesheets -->
<script>
(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);
})();
</script>
HTMX Integration
Theme toggle with HTMX:
<button
id="theme-toggle"
class="btn btn-icon"
hx-post="/api/theme/toggle"
hx-swap="none"
hx-on::after-request="themeManager.toggle()"
aria-label="Toggle theme"
>
<span class="icon-sun" data-show-theme="dark"></span>
<span class="icon-moon" data-show-theme="light"></span>
<span class="icon-system" data-show-theme="system"></span>
</button>
<style>
[data-show-theme] { display: none; }
[data-theme="light"] [data-show-theme="light"] { display: inline; }
[data-theme="dark"] [data-show-theme="dark"] { display: inline; }
</style>
Custom Theming
User Custom Theme
Allow users to define custom themes:
# 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:
/* themes/custom.css */
[data-theme="custom"] {
--primary-500: #2563eb;
--primary-600: #1d4ed8;
--color-bg: #fefefe;
--color-text: #1a1a1a;
}
Brand Theme Extension
/* 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:
// src/theme/accessibility.rs
use palette::{Srgb, color_difference::Wcag21RelativeContrast};
pub fn validate_contrast(tokens: &SemanticTokens) -> Vec<ContrastViolation> {
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::<Srgb>().unwrap();
let bg = tokens.get(bg_name).parse::<Srgb>().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:
@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
@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
[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
// 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
// For server-rendered pages
interface ThemeAPI {
get(): Promise<'light' | 'dark' | 'system'>;
set(theme: 'light' | 'dark' | 'system'): Promise<void>;
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
- Lingua principale
- Rust
- Stelle
- 0
- Fork
- 1
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di aRustyDev/mdbook-htmx
-
feat(ci): Implement cross-repo-validate compliance for docs syncForse di nuovo libera @aRustyDev l’ha presa 280 giorni fa e non c’è nessuna pull request aperta. Apertaci enhancement
aRustyDev/mdbook-htmx#58 · 1 commento · 1 assegnatario ·
-
docs(htmx): Explain how HTMX worksForse di nuovo libera @aRustyDev l’ha presa 282 giorni fa e non c’è nessuna pull request aperta. Aperta
aRustyDev/mdbook-htmx#48 · 3 commenti · 1 assegnatario ·
-
docs(mdbook-htmx): Parent Issue for documentationForse di nuovo libera @aRustyDev l’ha presa 282 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#47 · 1 assegnatario ·
-
docs(examples): Secure Self-Hosted Meilisearch via CloudflareForse di nuovo libera @aRustyDev l’ha presa 282 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#46 · 1 assegnatario ·
-
docs(examples): Kubernetes with MeilisearchForse di nuovo libera @aRustyDev l’ha presa 282 giorni fa e non c’è nessuna pull request aperta. Apertadocumentation
aRustyDev/mdbook-htmx#45 · 1 assegnatario ·
Tutte le issue di aRustyDev/mdbook-htmx
Issue simili
-
Four problem loader browser tests fail on WindowsForse già presa @Patriciaath999 l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
I maintainer di solito rispondono entro 1 giorno
-
Claude Code guards in .claude/settings.json never block: they read unset variables and exit 1Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
starkware-libs/sequencer#15229 ·
I maintainer di solito rispondono entro 1 giorno
-
`fnox set` ignores piped stdin when `--description` is given, saving the secret without a valueAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
ActivityWatch/aw-notify-rs#47 · 1 commento ·