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
This commit is contained in:
mAi
2026-05-07 17:05:12 +02:00
parent 5056d66453
commit a221367c46
10 changed files with 708 additions and 42 deletions

243
docs/plans/theme-toggle.md Normal file
View File

@@ -0,0 +1,243 @@
# 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](https://mgit.msbls.de/m/onepager/issues/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>`
```css
/* 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.
```html
<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`).
```css
/* 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
<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
- [x] Design-Doc commit
- [x] `shared/theme.js`, `shared/toggles.js`, `shared/css/theme.css` implementiert
- [x] `templates/base.html` mit Anti-FOUC + Theme-Link
- [x] 4 Pilot-Sites: ichbinotto, paragraphenraiter, kilitaer, deinesei — Anti-FOUC inline + light overrides + script-Includes
- [x] `tools/contrast-audit.py` mit `--light` Mode
- [x] Branch gepusht
- [x] 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.