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
12 KiB
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 auflang. Alle 59 Sites annotiert.- 33 Dark-Sites haben unique-Palette (Issue #12 Audit + Lift).
- Existing Toggle-Position: Footer — kleine Pill
EN/DEplus 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-schemeMedia-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-themeAttribut + 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'swindow.onepagerTheme). - Sites mit eigenem custom-Toggle-UI (zukünftig denkbar) können
toggles.jsweglassen, abertheme.jsbehalten.
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-schemePattern: 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 ~50–200ms 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 aufprefers-color-scheme. Fallback-Fallback aufdark. - 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 nurdata-i18n-toggleund i18n.js verbindet ihn automatisch.shared/ai-disclosure.js— Vorbild für auto-injection:MutationObserveraufdocumentElementAttributes, 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:
- Manueller Klick-Test in Chrome + Firefox: Toggle zwischen Modi, Reload, andere Tab → State persistent.
prefers-color-scheme: lightals initial-Default greift bei erstem Besuch ohne localStorage.- Kein FOUC bei Hard-Reload (Network throttling auf Slow 3G).
tools/contrast-audit.py --lightläuft sauber durch.
Implementation-Reihenfolge
shared/css/theme.css— neutrale Light-Defaults (~30 LoC).shared/theme.js— Logik-Modul, exposeswindow.onepagerTheme, MutationObserver, FOUC-Companion (~50 LoC).shared/toggles.js— UI-Widget Auto-Injection (~80 LoC).- Inline Anti-FOUC-Script: in
templates/base.htmleinbauen, plus manuell in 4 Pilot-Sites. - Pilot-Site CSS:
[data-theme="light"]Block je Site, ~10 Zeilen. - Pilot-Site Removal: existing Footer-Toggle-Button kann bleiben (i18n.js fängt beide), oder wird in Shift-2 systematisch entfernt.
tools/contrast-audit.py—--lightFlag.- 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::beforeOverlays 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
inheritfür Farben. ✅ Kein Bruch.
Done-Definition Shift-1
- Design-Doc commit
shared/theme.js,shared/toggles.js,shared/css/theme.cssimplementierttemplates/base.htmlmit Anti-FOUC + Theme-Link- 4 Pilot-Sites: ichbinotto, paragraphenraiter, kilitaer, deinesei — Anti-FOUC inline + light overrides + script-Includes
tools/contrast-audit.pymit--lightMode- 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.