# 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 ~50–200ms 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.
