Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

docs(adr): ADR-0020: Theming Architecture

Abierto
#28 0 comentarios 0 reacciones 1 asignado Ver en GitHub

@aRustyDev ya está trabajando en esto.

Desde el 5/1/2026.

Evaluación

Este issue todavía no se ha evaluado.

Descripción

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

Lenguaje dominante
Rust
Estrellas
0
Forks
1
Métricas de merge de PR
Sin PR fusionados en 30 d

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de aRustyDev/mdbook-htmx

Todos los issues de aRustyDev/mdbook-htmx

Issues similares

Más issues de Rust

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.