Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

docs(adr): ADR-0020: Theming Architecture

Aperta
#28 0 commenti 0 reazioni 1 assegnatario Vedi su GitHub

@aRustyDev ci sta già lavorando.

Dal 5/1/2026.

Valutazione

Questa issue non è ancora stata valutata.

Descrizione

documentation

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:

: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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di aRustyDev/mdbook-htmx

Tutte le issue di aRustyDev/mdbook-htmx

Issue simili

Altre issue su Rust

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.