Files
onepager/docs/plans/theme-toggle.md
mAi a221367c46 feat: #13 Light/Dark + EN/DE Toggle (Shift-1 Design + Pilot)
Architektur:
- shared/theme.js — Logik (data-theme attr auf <html>, localStorage, prefers-color-scheme fallback, data-theme-lock opt-out)
- shared/toggles.js — fixed top-right Pill mit Sun/Moon SVG + DE/EN Button (auto-injected, hängt sich an i18n.js's [data-i18n-toggle] Pattern)
- shared/css/theme.css — neutrale Light-Defaults (cream bg, AA-konforme grays)
- templates/base.html — Anti-FOUC inline IIFE im <head>, theme.css linked vor inline <style>, scripts in body
- tools/contrast-audit.py — neue --light/--dark/--both Modi, parsed [data-theme="light"] + shared fallback

Pilot auf 4 Sites:
- ichbinotto.de (Octopus rot/teal)
- paragraphenraiter.de (Gold)
- kilitaer.de (Olive)
- deinesei.de (Indigo)

Audit-Ergebnis:
- Dark mode: 0/59 Verstöße (regression-frei)
- Light mode: 14/59 Sites brauchen per-site overrides für sub-AA Akzent-Vars (Shift-2 follow-up)

Out of Scope (Shift-2):
- Rollout auf restliche 55 Sites
- Per-Site Light-Palette-Verfeinerung wo neutral-Default nicht trägt
- Per-Site Opt-Out (data-theme-lock) für aesthetisch dark-only Satire-Sites

Design-Doc: docs/plans/theme-toggle.md
2026-05-07 17:05:12 +02:00

12 KiB
Raw Blame History

Theme + Sprache Toggle (Issue #13) — Design

Status: Design (Shift-1, Inventor) — Pilot auf 4 Sites. Awaiting m's go-ahead vor Rollout. Branch: mai/cronus/issue-13-light-dark-en Issue: m/onepager#13

Was bereits da ist (Stand 2026-05-07)

  • shared/i18n.js — DE/EN Toggle, [data-i18n-toggle] Buttons, navigator.language Detection, localStorage Persistence (onepager-lang), MutationObserver auf lang. Alle 59 Sites annotiert.
  • 33 Dark-Sites haben unique-Palette (Issue #12 Audit + Lift).
  • Existing Toggle-Position: Footer — kleine Pill EN/DE plus statische Disclaimer-Zeile "Maschinell übersetzt".
  • 4 Light-Templates existieren (person-light.html, product-light.html) — werden derzeit von keiner Site benutzt; Custom-Sites haben hardcoded Dark.

Ziel

Kombiniertes Toggle-Widget oben rechts, fixed. Zwei Buttons in einer Pill:

┌──────────────┐
│ ☀  │  EN     │   ← Dark-Mode aktiv: Sonne (Klick → light), "EN" (Klick → english)
└──────────────┘
  • Theme-Toggle: Sonne/Mond Icon, persistiert in localStorage("onepager-theme"), Initial-Default = prefers-color-scheme Media-Query.
  • Lang-Toggle: existing [data-i18n-toggle] Pattern (recht-Button im Widget bekommt das Attribut, i18n.js übernimmt Logik).

Architektur-Entscheidungen

1. Eine Datei oder zwei?

Entscheidung: Zwei Dateien — shared/theme.js (theme logic) + shared/toggles.js (UI widget).

  • Trennung Logic ↔ UI — theme.js ist die Quelle der Wahrheit für data-theme Attribut + localStorage. Tests, alternative UI, programmatischer Aufruf bleiben möglich.
  • toggles.js ist ein optionales UI-Layer das beide bestehenden Skripte konsumiert (i18n.js's [data-i18n-toggle] + theme.js's window.onepagerTheme).
  • Sites mit eigenem custom-Toggle-UI (zukünftig denkbar) können toggles.js weglassen, aber theme.js behalten.

2. CSS-Pattern: [data-theme] Attribut auf <html>

/* Default = dark, no attr */
:root {
  --bg: #0a0a0c;
  --text: #e8e8ed;
  --accent: #c9a84c;
}

/* Light overrides — gleiche Variablen, andere Werte */
[data-theme="light"] {
  --bg: #faf9f6;
  --text: #1a1a1a;
  /* --accent bleibt erhalten (Site-Identität) */
}

Warum [data-theme] auf <html> (nicht class="light"):

  • HTML-Attribut ist von der CSS-Cascade aus exakt gleich spezifisch wie :root. Per Source-Order kann man Defaults setzen und überschreiben — vorhersehbar.
  • Kompatibel mit dem prefers-color-scheme Pattern: ein Dataset-Hook ohne Klassen-Kollision.
  • Inline-Anti-FOUC-Script muss nur ein Attribut setzen, nicht eine Klasse + DOM-Manipulation.

3. Anti-FOUC: Inline-Pre-Script in <head>

Kritisch. Wenn der Theme erst nach Page-Load gesetzt wird, sieht der User für ~50200ms das Default-Theme bevor sein gespeichertes Theme angewandt wird → flackernder Modus-Switch.

<head>
  ...
  <script>
    (function(){
      try {
        var t = localStorage.getItem('onepager-theme');
        if (!t) t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
        document.documentElement.setAttribute('data-theme', t);
      } catch(e) {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    })();
  </script>
  <style>...</style>
</head>
  • Inline IIFE — synchron, vor <style> ausgeführt.
  • Try/catch: Private-Browsing-Modi werfen bei localStorage.getItem. Fallback auf prefers-color-scheme. Fallback-Fallback auf dark.
  • Etwa 14 Zeilen minified — Inline-Cost vernachlässigbar.

4. Light-Palette: Hybrid (Shared Default + Per-Site Override)

Entscheidung: shared/css/theme.css enthält neutrale Light-Defaults für gemeinsame Variablen-Namen (--bg, --text, --text-dim, --text-muted, --border, --bg-card, --bg-elevated).

/* shared/css/theme.css */
[data-theme="light"] {
  --bg: #faf9f6;            /* warmes Off-White */
  --bg-elevated: #ffffff;
  --bg-card: #ffffff;
  --text: #1a1a1a;
  --text-dim: #5a5a5a;
  --text-muted: #8a8a8a;
  --border: rgba(0,0,0,0.08);
}

Pro Site, wenn der neutral-Default nicht trägt (Gold-, Olive-, Octopus-Sites): eigenes [data-theme="light"] Block im Site-Inline-<style> nach dem Light-Default-Link → überschreibt punktuell.

Trade-off: Vs. eine zentrale "alle-Sites-gleich-light" Lösung verliert man pro Site Identität sonst nicht. Diese Hybrid hält Identität (Akzent + ggf. tönende Hintergründe), gibt aber 90% der Sites einen passablen Light-Modus ohne manuelle Arbeit.

5. Per-Site Akzent-Behandlung in Light

Site Dark-Akzent Light-Akzent (Pilot-Vorschlag) Begründung
ichbinotto.de Octopus #e85040 (rot) gleicher Octopus, evtl. #d23a2a minimal-darker Identität bleibt; Kontrast auf Cream-BG ≥ 4.5:1
paragraphenraiter.de Gold #c9a84c #a0822a (deeper Gold) Gold auf weiß ist sub-WCAG; muss tiefer
kilitaer.de Olive #6b8e23 gleiches Olive, ggf. #557018 Olive ist mid-luminance, hält auf cream
deinesei.de Indigo #6366f1 gleicher Indigo Indigo-Akzent funktioniert beidseitig

Regel: Akzent bleibt visuell konstant wo möglich. Nur wenn WCAG AA (4.5:1) für Text-Akzente bzw. 3:1 für Large/UI verletzt → punktuell verdunkeln (lightness -10..-15%).

6. Toggle-Widget Position + Visuals

position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
gap: 0;
border-radius: 999px;
backdrop-filter: blur(8px);
background: var(--bg-card, rgba(255,255,255,0.6));
border: 1px solid var(--border, rgba(0,0,0,0.08));
padding: 4px;
  • Backdrop-blur — nötig damit das Pill über Hero-Bildern lesbar bleibt (kilitaer hat Camo-Pattern, paragraphenraiter Gold-Glow).
  • CSS-Variablen als Background/Border — passt sich automatisch dem aktiven Theme an.
  • Mobile: 12px statt 16px (Daumen-Reach), Buttons 36×36px (Touch-Mindestmaß).
  • Desktop ≥ 768px: 16px Abstand.
  • Icons: Inline-SVG für Sun/Moon (kein Emoji-Rendering-Drift across OS), Text "DE"/"EN" für Lang.

7. Per-Site Opt-Out

Manche Satire-Sites (kilibri, killusion, killionaer, killuminati) leben von der Dark-Stimmung. Light-Mode kippt deren Aesthetik komplett.

Mechanismus:

<html lang="de" data-theme-lock="dark">

theme.js prüft das Attribut: wenn data-theme-lock gesetzt ist, wird dessen Wert erzwungen, localStorage ignoriert, und toggles.js blendet den Theme-Button aus (Lang-Button bleibt). Pro Site auswählbar in Shift-2 nach m's Review der Pilot-Screenshots.

8. Audit-Erweiterung: Light-Mode-Kontrast

tools/contrast-audit.py aktuell prüft nur :root (Dark). Erweitert zu:

  • Parse beide Block-Typen: :root { ... } und [data-theme="light"] { ... }
  • Für Light-Mode-Audit: light-bg gegen alle text-Variablen prüfen
  • Output beide Tabellen, Light-Mode-Findings separat

Wenn ein Site keinen [data-theme="light"] Block hat, fällt es auf den Shared-Default zurück. Audit kann das simulieren indem es shared/css/theme.css als Override mitliest, falls die Site das Theme-Linkt einbindet.

Out of Scope (Shift-2)

  • Rollout auf alle 59 Sites (Shift-2 / Coder)
  • Per-Site Light-Palette-Verfeinerung wo neutral-Default nicht trägt
  • Per-Site Opt-Out-Liste basierend auf m's Pilot-Review
  • Removal der existierenden Footer-Toggle-Buttons (während Pilot bleibt sie redundant aktiv — i18n.js fängt beide ab)

Out of Scope (komplett)

  • Sepia/High-Contrast/Tritan Modi
  • Auto-Switch nach Tageszeit
  • Custom-Site-Inhalte (SVG/Bilder), die Dark-BG voraussetzen — pro-Site-Issue im Bedarfsfall

Reuse / Bestehende Patterns

  • shared/i18n.js — Lang-Toggle bleibt komplett unverändert. Neuer Top-Right-Button bekommt nur data-i18n-toggle und i18n.js verbindet ihn automatisch.
  • shared/ai-disclosure.js — Vorbild für auto-injection: MutationObserver auf documentElement Attributes, IIFE im File-Tail. Theme.js folgt dem Muster.
  • tools/contrast-audit.py — bestehende Logik (HEX-Parser, WCAG-Ratio, Variable-Klassifikation) bleibt; nur ein zweiter Audit-Pass für [data-theme="light"] wird ergänzt.

Pilot-Sites

Site Spektrum-Begründung
ichbinotto.de m's Beobachtungs-Origin, Octopus-Akzent, persönliche Brand-Site
paragraphenraiter.de Gold-Theme — anspruchsvolle Light-Variante (Gold auf weiß = WCAG-Drama)
kilitaer.de Olive — Wortspiel-Satire-Site, mittlere Luminanz, Camo-BG-Pattern
deinesei.de Indigo — Standard-Web-Akzent, "deinesei-shared-pattern" das auf 14 weitere Sites rippelt wenn's hier hält

Pilot-Validation:

  1. Manueller Klick-Test in Chrome + Firefox: Toggle zwischen Modi, Reload, andere Tab → State persistent.
  2. prefers-color-scheme: light als initial-Default greift bei erstem Besuch ohne localStorage.
  3. Kein FOUC bei Hard-Reload (Network throttling auf Slow 3G).
  4. tools/contrast-audit.py --light läuft sauber durch.

Implementation-Reihenfolge

  1. shared/css/theme.css — neutrale Light-Defaults (~30 LoC).
  2. shared/theme.js — Logik-Modul, exposes window.onepagerTheme, MutationObserver, FOUC-Companion (~50 LoC).
  3. shared/toggles.js — UI-Widget Auto-Injection (~80 LoC).
  4. Inline Anti-FOUC-Script: in templates/base.html einbauen, plus manuell in 4 Pilot-Sites.
  5. Pilot-Site CSS: [data-theme="light"] Block je Site, ~10 Zeilen.
  6. Pilot-Site Removal: existing Footer-Toggle-Button kann bleiben (i18n.js fängt beide), oder wird in Shift-2 systematisch entfernt.
  7. tools/contrast-audit.py--light Flag.
  8. README/Adding-a-New-Site Doku update (kommt mit Shift-2).

Ausgewählte Trade-offs (kompakt)

Frage Gewählt Alternative Warum
Toggle-Position Fixed top-right In-Footer / In-Header m's Vorgabe + State-Visibility unabhängig vom Scroll
Storage-Key onepager-theme gleicher key wie i18n? Trennung — Lang und Theme sind unabhängig
FOUC-Prevention Inline IIFE in <head> externes Script + render-blocking Synchron geht nur inline
Light-Default-Verteilung Shared CSS-Link + Site-Override Nur per-Site / nur shared 90% kostenlos, 10% manuell
class="dark" vs. data-theme data-theme Attribut Klasse Spez-Verhalten klarer; SSR-frei
Lang+Theme im selben File Zwei Dateien (theme.js + toggles.js) Eine Mega-Datei Logik vs. UI trennen, Test+Reuse
Akzent-Invert in Light Akzent erhalten Mathematisch invertieren Identität geht vor Algorithmus

Bekannte Risiken / Anti-Empfehlungen

  • Body-Background-Image-Sites (kilitaer Camo, deinesei Noise): die Hintergrund-background-image: Url(s) sind oft so dunkel dass auch [data-theme="light"] cream-bg darunter durchschimmert nicht. Lösung: body::before Overlays unter [data-theme="light"] opacity reduzieren.
  • CSS-only Animations mit hardcoded Hex (statt CSS-Variablen): manche Sites animieren via from { background: #060610 }. Diese müssen pro Site gefixt werden; nicht aus shared lösbar.
  • AI-Disclosure-Footer uses var(--text-muted). Wird im Light-Modus automatisch dunkler. Kein Bruch.
  • Impressum-Overlay uses inherit für Farben. Kein Bruch.

Done-Definition Shift-1

  • Design-Doc commit
  • shared/theme.js, shared/toggles.js, shared/css/theme.css implementiert
  • templates/base.html mit Anti-FOUC + Theme-Link
  • 4 Pilot-Sites: ichbinotto, paragraphenraiter, kilitaer, deinesei — Anti-FOUC inline + light overrides + script-Includes
  • tools/contrast-audit.py mit --light Mode
  • Branch gepusht
  • Issue-Comment mit DESIGN READY FOR REVIEW

Inventor-Self-Assessment (Shift-2 Eignung)

Ich (cronus) wäre gut geeignet für Shift-2-Rollout, weil:

  • Pattern + Edge-Cases bereits durch alle 4 Pilot-Site-Stile durchgegangen
  • Shared-Files getestet und stabilisiert
  • Audit-Tool kann fail-fast vor Rollout

Aber Shift-2 ist Bulk-Mechanik (54 Sites × <head> editieren + opt-out-Liste pflegen). Das passt eher zu einem Coder-Worker mit Bulk-Scripting-Fokus. Final-Decision liegt bei head + m.