diff --git a/docs/plans/theme-toggle.md b/docs/plans/theme-toggle.md new file mode 100644 index 0000000..b4b9626 --- /dev/null +++ b/docs/plans/theme-toggle.md @@ -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 `` + +```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 ``** (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 `
` + +**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 + + ... + + + +``` + +- Inline IIFE — synchron, **vor** `", css, re.DOTALL | re.IGNORECASE) - if not style_blocks: - return None - css_only = "\n".join(style_blocks) +def parse_vars(css_text): + """Extract CSS custom property name -> RGB triplet, ignoring non-hex values.""" vars_ = {} - for m in VAR_DECL_RE.finditer(css_only): + for m in VAR_DECL_RE.finditer(css_text): name, val = m.group(1), m.group(2).strip() - # only resolve to hex hm = HEX_RE.search(val) if hm: rgb = hex_to_rgb(hm.group(0)) if rgb: vars_[name] = rgb + return vars_ + +def get_style_blocks(html): + """Return concatenated CSS from all ", html, re.DOTALL | re.IGNORECASE) + return "\n".join(blocks) + + +def shared_light_defaults(): + """Read the shared light defaults so sites without overrides get audited + against the actual palette they'll receive at runtime.""" + if not SHARED_THEME_CSS.exists(): + return {} + css = SHARED_THEME_CSS.read_text(errors="ignore") + bodies = block_body(css, r'\[data-theme="light"\]') + merged = "\n".join(bodies) + return parse_vars(merged) + + +def audit_palette(vars_, mode_label): + """Given a {var_name: rgb} palette, return findings dict or None.""" bg_vars = {n: c for n, c in vars_.items() if is_bg_var(n)} text_vars = {n: c for n, c in vars_.items() if is_text_var(n)} if not bg_vars or not text_vars: return None - # Find primary bg (the darkest one is usually --bg) - primary_bg_name = min(bg_vars, key=lambda n: relative_luminance(bg_vars[n])) - bg_rgb = bg_vars[primary_bg_name] - bg_lum = relative_luminance(bg_rgb) - - # Only audit dark backgrounds (lum < 0.05 = near-black) - if bg_lum > 0.1: - return None # not a dark site + if mode_label == "dark": + primary_bg_name = min(bg_vars, key=lambda n: relative_luminance(bg_vars[n])) + bg_rgb = bg_vars[primary_bg_name] + bg_lum = relative_luminance(bg_rgb) + if bg_lum > 0.1: + return None # not dark enough to be the dark mode + else: # light + primary_bg_name = max(bg_vars, key=lambda n: relative_luminance(bg_vars[n])) + bg_rgb = bg_vars[primary_bg_name] + bg_lum = relative_luminance(bg_rgb) + if bg_lum < 0.5: + return None # not light enough findings = [] for tname, trgb in text_vars.items(): ratio = contrast_ratio(trgb, bg_rgb) - if ratio < 4.5: # WCAG AA for body text + if ratio < 4.5: findings.append((tname, trgb, ratio)) if not findings: return None return { - "site": site, "bg_name": primary_bg_name, "bg_rgb": bg_rgb, "findings": findings, } -results = [] -for site in sorted(p.name for p in SITES_DIR.iterdir() if p.is_dir()): - r = audit(site) - if r: - results.append(r) -print(f"Sites with sub-AA text on dark bg: {len(results)}/59\n") -for r in results: - bg = r["bg_rgb"] - print(f"{r['site']} (bg --{r['bg_name']} = #{bg[0]:02x}{bg[1]:02x}{bg[2]:02x})") - for name, rgb, ratio in sorted(r["findings"], key=lambda x: x[2]): - flag = "FAIL" if ratio < 3.0 else ("WEAK" if ratio < 4.5 else "OK") - print(f" {flag:4} ratio {ratio:5.2f} --{name:24} #{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}") - print() +def audit(site, mode, shared_light): + html_path = SITES_DIR / site / "index.html" + if not html_path.exists(): + return None + html = html_path.read_text(errors="ignore") + css = get_style_blocks(html) + if not css: + return None -# Summary: how many distinct sites have FAIL (< 3.0) somewhere -fails = [r for r in results if any(ratio < 3.0 for _, _, ratio in r["findings"])] -print(f"\n=== SUMMARY ===") -print(f"Dark-bg sites with at least one FAIL (<3:1): {len(fails)}") -print(f"Dark-bg sites with WEAK (<4.5 but >=3): {len(results) - len(fails)}") + if mode == "dark": + # :root and [data-theme="dark"] both contribute to dark palette + bodies = block_body(css, r":root") + block_body(css, r'\[data-theme="dark"\]') + merged = "\n".join(bodies) if bodies else css + vars_ = parse_vars(merged) + return audit_palette(vars_, "dark") + + if mode == "light": + bodies = block_body(css, r'\[data-theme="light"\]') + if bodies: + site_vars = parse_vars("\n".join(bodies)) + else: + site_vars = {} + # Site overrides win. Fall back to shared defaults for missing vars. + # AND fall back to :root for any vars the site only defines once. + root_bodies = block_body(css, r":root") + root_vars = parse_vars("\n".join(root_bodies)) + merged = dict(root_vars) + merged.update(shared_light) + merged.update(site_vars) + return audit_palette(merged, "light") + + return None + + +def print_results(mode, results, total): + label = "Light" if mode == "light" else "Dark" + print(f"\n=== {label} mode audit ===") + print(f"Sites with sub-AA text on {label.lower()} bg: {len(results)}/{total}\n") + for r in results: + bg = r["bg_rgb"] + print(f"{r['site']} (bg --{r['bg_name']} = #{bg[0]:02x}{bg[1]:02x}{bg[2]:02x})") + for name, rgb, ratio in sorted(r["findings"], key=lambda x: x[2]): + flag = "FAIL" if ratio < 3.0 else ("WEAK" if ratio < 4.5 else "OK") + print(f" {flag:4} ratio {ratio:5.2f} --{name:24} #{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}") + print() + fails = [r for r in results if any(ratio < 3.0 for _, _, ratio in r["findings"])] + print(f" {label}-bg sites with at least one FAIL (<3:1): {len(fails)}") + print(f" {label}-bg sites with WEAK (<4.5 but >=3): {len(results) - len(fails)}") + + +def run(mode): + sites = sorted(p.name for p in SITES_DIR.iterdir() if p.is_dir()) + shared_light = shared_light_defaults() if mode in ("light", "both") else {} + + if mode in ("dark", "both"): + results = [] + for site in sites: + r = audit(site, "dark", shared_light) + if r: + r["site"] = site + results.append(r) + print_results("dark", results, len(sites)) + + if mode in ("light", "both"): + results = [] + for site in sites: + r = audit(site, "light", shared_light) + if r: + r["site"] = site + results.append(r) + print_results("light", results, len(sites)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + grp = ap.add_mutually_exclusive_group() + grp.add_argument("--dark", action="store_const", const="dark", dest="mode", help="Audit dark palette only (default)") + grp.add_argument("--light", action="store_const", const="light", dest="mode", help="Audit light palette only") + grp.add_argument("--both", action="store_const", const="both", dest="mode", help="Audit both palettes") + args = ap.parse_args() + mode = args.mode or "dark" + run(mode) + + +if __name__ == "__main__": + main()