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
244 lines
12 KiB
Markdown
244 lines
12 KiB
Markdown
# 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.
|