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

244 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.