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:
243
docs/plans/theme-toggle.md
Normal file
243
docs/plans/theme-toggle.md
Normal 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 ~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.
|
||||
29
shared/css/theme.css
Normal file
29
shared/css/theme.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Onepager neutral light-mode defaults.
|
||||
*
|
||||
* Linked into every site via <link rel="stylesheet" href="/shared/css/theme.css">
|
||||
* BEFORE the per-site <style> block, so site-specific [data-theme="light"]
|
||||
* overrides win on cascade.
|
||||
*
|
||||
* Variables targeted are the common set used across templates and custom sites.
|
||||
* Sites with custom palettes (gold, olive, octopus, etc.) supply their own
|
||||
* [data-theme="light"] block to override these defaults.
|
||||
*/
|
||||
[data-theme="light"] {
|
||||
--bg: #faf9f6;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-surface: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #4a4a4a;
|
||||
--text-dimmed: #4a4a4a;
|
||||
--text-muted: #6b6b6b; /* AA on white: 4.85:1 */
|
||||
--text-secondary: #4a4a4a;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
63
shared/theme.js
Normal file
63
shared/theme.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Theme switcher for onepager sites.
|
||||
*
|
||||
* Sets data-theme="light"|"dark" on <html>. Persists to localStorage
|
||||
* ("onepager-theme"). Initial-default falls back to prefers-color-scheme.
|
||||
*
|
||||
* Anti-FOUC companion: this file expects an inline IIFE in <head> to set
|
||||
* data-theme BEFORE CSS loads, so the first paint is correct. This file
|
||||
* only handles runtime toggling and exposes window.onepagerTheme.
|
||||
*
|
||||
* Per-site opt-out: <html data-theme-lock="dark"> forces dark and ignores
|
||||
* localStorage. toggles.js consumes data-theme-lock to hide the theme button.
|
||||
*/
|
||||
(function () {
|
||||
var KEY = 'onepager-theme';
|
||||
var SUPPORTED = ['light', 'dark'];
|
||||
|
||||
function lock() {
|
||||
var v = document.documentElement.getAttribute('data-theme-lock');
|
||||
return v && SUPPORTED.indexOf(v) !== -1 ? v : null;
|
||||
}
|
||||
|
||||
function detect() {
|
||||
var locked = lock();
|
||||
if (locked) return locked;
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem(KEY); } catch (e) { /* private browsing */ }
|
||||
if (stored && SUPPORTED.indexOf(stored) !== -1) return stored;
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
function get() {
|
||||
return document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
}
|
||||
|
||||
function set(theme) {
|
||||
if (lock()) return;
|
||||
if (SUPPORTED.indexOf(theme) === -1) return;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem(KEY, theme); } catch (e) { /* private browsing */ }
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
set(get() === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
// Reconcile with anti-FOUC inline script. If <html> already has data-theme
|
||||
// set (by anti-FOUC IIFE), keep it. Otherwise apply detect().
|
||||
if (!document.documentElement.getAttribute('data-theme')) {
|
||||
document.documentElement.setAttribute('data-theme', detect());
|
||||
}
|
||||
|
||||
window.onepagerTheme = {
|
||||
get: get,
|
||||
set: set,
|
||||
toggle: toggle,
|
||||
detect: detect,
|
||||
isLocked: function () { return lock() !== null; }
|
||||
};
|
||||
})();
|
||||
132
shared/toggles.js
Normal file
132
shared/toggles.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Combined Theme + Language toggle widget for onepager sites.
|
||||
*
|
||||
* Auto-injects a fixed top-right pill with two buttons:
|
||||
* [☀/🌙] [DE/EN]
|
||||
*
|
||||
* Depends on theme.js (window.onepagerTheme) and i18n.js ([data-i18n-toggle]).
|
||||
* Include all three in this order at end of <body>:
|
||||
* <script src="/shared/theme.js"></script>
|
||||
* <script src="/shared/i18n.js"></script>
|
||||
* <script src="/shared/toggles.js"></script>
|
||||
*
|
||||
* Per-site opt-out:
|
||||
* <html data-no-toggles> — skip both buttons
|
||||
* <html data-theme-lock="dark"> — hide theme button, keep lang
|
||||
* <script data-no-lang> — on toggles.js tag, hide lang button
|
||||
*/
|
||||
(function () {
|
||||
if (document.documentElement.hasAttribute('data-no-toggles')) return;
|
||||
|
||||
var script = document.currentScript;
|
||||
var hideLang = script && script.hasAttribute('data-no-lang');
|
||||
var hideTheme = !window.onepagerTheme || window.onepagerTheme.isLocked();
|
||||
|
||||
if (hideTheme && hideLang) return;
|
||||
|
||||
var SUN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>';
|
||||
var MOON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||
|
||||
function inject() {
|
||||
if (document.getElementById('onepager-toggles')) return;
|
||||
|
||||
var pill = document.createElement('div');
|
||||
pill.id = 'onepager-toggles';
|
||||
pill.style.cssText = [
|
||||
'position:fixed',
|
||||
'top:12px',
|
||||
'right:12px',
|
||||
'z-index:9999',
|
||||
'display:flex',
|
||||
'gap:0',
|
||||
'align-items:stretch',
|
||||
'border-radius:999px',
|
||||
'padding:3px',
|
||||
'background:var(--bg-card,rgba(255,255,255,0.6))',
|
||||
'border:1px solid var(--border,rgba(127,127,127,0.25))',
|
||||
'backdrop-filter:blur(8px)',
|
||||
'-webkit-backdrop-filter:blur(8px)',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,0.08)',
|
||||
'font-family:inherit'
|
||||
].join(';');
|
||||
|
||||
var btnStyle = [
|
||||
'background:transparent',
|
||||
'border:0',
|
||||
'color:var(--text,inherit)',
|
||||
'cursor:pointer',
|
||||
'padding:6px 10px',
|
||||
'font-size:0.7rem',
|
||||
'font-weight:500',
|
||||
'letter-spacing:0.05em',
|
||||
'border-radius:999px',
|
||||
'display:inline-flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'min-width:34px',
|
||||
'min-height:28px',
|
||||
'line-height:1',
|
||||
'transition:background 0.15s,color 0.15s',
|
||||
'opacity:0.85'
|
||||
].join(';');
|
||||
|
||||
if (!hideTheme) {
|
||||
var themeBtn = document.createElement('button');
|
||||
themeBtn.type = 'button';
|
||||
themeBtn.id = 'onepager-theme-toggle';
|
||||
themeBtn.style.cssText = btnStyle;
|
||||
themeBtn.setAttribute('aria-label', 'Toggle light/dark theme');
|
||||
themeBtn.title = 'Hell/Dunkel umschalten';
|
||||
function renderTheme() {
|
||||
var isLight = window.onepagerTheme && window.onepagerTheme.get() === 'light';
|
||||
themeBtn.innerHTML = isLight ? MOON : SUN;
|
||||
}
|
||||
renderTheme();
|
||||
themeBtn.addEventListener('click', function () {
|
||||
if (window.onepagerTheme) window.onepagerTheme.toggle();
|
||||
renderTheme();
|
||||
});
|
||||
themeBtn.addEventListener('mouseenter', function () { themeBtn.style.opacity = '1'; });
|
||||
themeBtn.addEventListener('mouseleave', function () { themeBtn.style.opacity = '0.85'; });
|
||||
pill.appendChild(themeBtn);
|
||||
}
|
||||
|
||||
if (!hideLang) {
|
||||
if (!hideTheme) {
|
||||
var sep = document.createElement('span');
|
||||
sep.style.cssText = 'width:1px;background:var(--border,rgba(127,127,127,0.25));margin:4px 0;';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
pill.appendChild(sep);
|
||||
}
|
||||
var langBtn = document.createElement('button');
|
||||
langBtn.type = 'button';
|
||||
langBtn.id = 'onepager-lang-toggle';
|
||||
langBtn.setAttribute('data-i18n-toggle', '');
|
||||
langBtn.style.cssText = btnStyle;
|
||||
langBtn.title = 'Maschinell übersetzt / Machine-translated — German is the original.';
|
||||
// i18n.js fills text & aria-label and binds click; placeholder until then:
|
||||
langBtn.textContent = (document.documentElement.lang || 'de') === 'de' ? 'EN' : 'DE';
|
||||
langBtn.addEventListener('mouseenter', function () { langBtn.style.opacity = '1'; });
|
||||
langBtn.addEventListener('mouseleave', function () { langBtn.style.opacity = '0.85'; });
|
||||
pill.appendChild(langBtn);
|
||||
}
|
||||
|
||||
document.body.appendChild(pill);
|
||||
|
||||
// If i18n.js already initialised (loaded before us), it won't have bound
|
||||
// the new button — re-bind manually.
|
||||
if (!hideLang && window.onepagerI18n) {
|
||||
var lb = document.getElementById('onepager-lang-toggle');
|
||||
if (lb) {
|
||||
lb.addEventListener('click', window.onepagerI18n.toggle);
|
||||
lb.textContent = (document.documentElement.lang || 'de') === 'de' ? 'EN' : 'DE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', inject);
|
||||
} else {
|
||||
inject();
|
||||
}
|
||||
})();
|
||||
@@ -3,9 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<title>deineSei.de — Wir bauen dir deine Seite</title>
|
||||
<meta name="description" content="deineSei.de — Deine Webseite, gebaut in Minuten. Domain, Design, Hosting — alles aus einer Hand.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌐</text></svg>">
|
||||
<link rel="stylesheet" href="/shared/css/theme.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=Inter:wght@300;400;500&display=swap');
|
||||
|
||||
@@ -25,6 +27,20 @@
|
||||
--green-glow: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #fafafd;
|
||||
--bg-card: #ffffff;
|
||||
--border: rgba(20, 20, 40, 0.10);
|
||||
--text: #15151f;
|
||||
--text-dim: #4a4a5e;
|
||||
--text-muted: #6e6e85;
|
||||
--accent: #4f46e5; /* indigo-600, AA on white */
|
||||
--accent-glow: rgba(79, 70, 229, 0.15);
|
||||
--accent-subtle: rgba(79, 70, 229, 0.06);
|
||||
--green: #15803d; /* AA on white */
|
||||
--green-glow: rgba(21, 128, 61, 0.10);
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
@@ -366,6 +382,8 @@
|
||||
</footer>
|
||||
|
||||
<script src="/shared/ai-disclosure.js" data-tone="playful"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/i18n.js"></script>
|
||||
<script src="/shared/toggles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<title data-de="Otto — dein sideKIck" data-en="Otto — your AI sideKIck">Otto — dein sideKIck</title>
|
||||
<meta name="description" content="Otto ist Matthias' persönlicher sideKIck. E-Mail, Kalender, Infrastruktur, WhatsApp — alles im Griff." data-de="Otto ist Matthias' persönlicher sideKIck. E-Mail, Kalender, Infrastruktur, WhatsApp — alles im Griff." data-en="Otto is Matthias' personal AI sideKIck. Email, calendar, infrastructure, WhatsApp — all under control.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐙</text></svg>">
|
||||
<link rel="stylesheet" href="/shared/css/theme.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500&display=swap');
|
||||
|
||||
@@ -25,6 +27,20 @@
|
||||
--teal-glow: rgba(64, 200, 184, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #faf9f6;
|
||||
--bg-card: #ffffff;
|
||||
--border: rgba(20, 20, 40, 0.10);
|
||||
--text: #1a1a2e;
|
||||
--text-dim: #4a4a6e;
|
||||
--text-muted: #6b6b8a;
|
||||
--octopus: #c63a2c; /* AA on cream: 4.7:1 */
|
||||
--octopus-glow: rgba(198, 58, 44, 0.12);
|
||||
--octopus-subtle: rgba(198, 58, 44, 0.05);
|
||||
--teal: #1f8a7c; /* AA on cream */
|
||||
--teal-glow: rgba(31, 138, 124, 0.10);
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
@@ -441,7 +457,9 @@
|
||||
</footer>
|
||||
|
||||
<script src="/shared/ai-disclosure.js" data-tone="playful"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/i18n.js"></script>
|
||||
<script src="/shared/toggles.js"></script>
|
||||
<script src="/shared/impressum.js" data-owner="flexsiebels" data-variant="full"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<title>KIlitär — KI im Einsatz</title>
|
||||
<meta name="description" content="KIlitär — KI + Militär. Strategische KI-Operationen. Satirische Militär-KI für den Alltag.">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>★</text></svg>">
|
||||
<link rel="stylesheet" href="/shared/css/theme.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@@ -26,6 +28,21 @@
|
||||
--red-star: #cc2200;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f4f2e8; /* khaki cream */
|
||||
--bg-elevated: #fbfaf2;
|
||||
--bg-card: #ffffff;
|
||||
--border: rgba(60, 70, 30, 0.14);
|
||||
--text: #1a1f10;
|
||||
--text-dim: #4a5230;
|
||||
--text-muted: #6b7245;
|
||||
--olive: #4a6b18; /* deeper olive, AA on cream */
|
||||
--olive-light: #6b8e23;
|
||||
--olive-glow: rgba(74, 107, 24, 0.12);
|
||||
--amber: #8a6614;
|
||||
--red-star: #a01c00;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
@@ -296,6 +313,8 @@
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/shared/ai-disclosure.js" data-tone="playful"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/i18n.js"></script>
|
||||
<script src="/shared/toggles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<title>ParagraphenrAIter — KI trifft Jura</title>
|
||||
<link rel="stylesheet" href="/shared/css/theme.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@@ -27,6 +29,20 @@
|
||||
--accent-red: #8b3a3a;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--gold: #8a6f1e; /* deep gold, AA on cream (5.4:1) */
|
||||
--gold-light: #c9a84c; /* original gold becomes accent-light */
|
||||
--gold-dim: #6e5818; /* darker, AA on white */
|
||||
--dark: #f8f6ee; /* warm parchment */
|
||||
--dark-surface: #ffffff;
|
||||
--dark-card: #ffffff;
|
||||
--dark-border: rgba(60, 50, 20, 0.12);
|
||||
--text: #1a1815;
|
||||
--text-dim: #5a554a;
|
||||
--accent-blue: #2a4f7c;
|
||||
--accent-red: #6a2828;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@@ -733,6 +749,8 @@
|
||||
</footer>
|
||||
|
||||
<script src="/shared/ai-disclosure.js" data-tone="playful"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/i18n.js"></script>
|
||||
<script src="/shared/toggles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<title{{title_i18n}}>{{title}}</title>
|
||||
<meta name="description" content="{{description}}"{{description_i18n}}>
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>{{favicon}}</text></svg>">
|
||||
{{fonts}}
|
||||
{{schema_jsonld}}
|
||||
<link rel="stylesheet" href="/shared/css/theme.css">
|
||||
<style>
|
||||
{{css_variables}}
|
||||
{{css_responsive}}
|
||||
@@ -20,6 +22,8 @@
|
||||
<body class="noise-overlay">
|
||||
{{body}}
|
||||
<script src="/shared/ai-disclosure.js" data-tone="{{disclosure_tone}}"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/i18n.js"></script>
|
||||
<script src="/shared/toggles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,16 +4,52 @@
|
||||
Extracts CSS custom properties for backgrounds and text colors,
|
||||
computes WCAG contrast ratio for likely text-on-bg pairs, flags
|
||||
violations.
|
||||
|
||||
Modes:
|
||||
--dark (default) Audit :root / [data-theme="dark"] palette
|
||||
--light Audit [data-theme="light"] palette
|
||||
--both Audit both, separately
|
||||
|
||||
Light-mode audit picks up site-specific [data-theme="light"] blocks.
|
||||
Sites without their own light block fall through to shared/css/theme.css
|
||||
(which is checked separately and used as a fallback).
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SITES_DIR = Path(__file__).resolve().parent.parent / "sites"
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SITES_DIR = ROOT / "sites"
|
||||
SHARED_THEME_CSS = ROOT / "shared" / "css" / "theme.css"
|
||||
|
||||
HEX_RE = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
||||
VAR_DECL_RE = re.compile(r"--([\w-]+)\s*:\s*([^;]+);")
|
||||
|
||||
# Match a CSS rule block by selector. Returns body of the block.
|
||||
def block_body(css, selector_pattern):
|
||||
"""Return list of bodies from CSS rules whose selector matches the regex."""
|
||||
bodies = []
|
||||
# Walk char-by-char to handle nested braces correctly
|
||||
for match in re.finditer(selector_pattern, css):
|
||||
start = match.end()
|
||||
# Find opening brace right after selector
|
||||
idx = css.find("{", start)
|
||||
if idx == -1:
|
||||
continue
|
||||
depth = 1
|
||||
i = idx + 1
|
||||
while i < len(css) and depth > 0:
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
if depth == 0:
|
||||
bodies.append(css[idx + 1 : i - 1])
|
||||
return bodies
|
||||
|
||||
|
||||
def hex_to_rgb(h):
|
||||
h = h.lstrip("#")
|
||||
if len(h) == 3:
|
||||
@@ -27,6 +63,7 @@ def hex_to_rgb(h):
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def relative_luminance(rgb):
|
||||
def channel(c):
|
||||
c /= 255
|
||||
@@ -34,92 +71,177 @@ def relative_luminance(rgb):
|
||||
r, g, b = (channel(c) for c in rgb)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast_ratio(rgb1, rgb2):
|
||||
l1 = relative_luminance(rgb1)
|
||||
l2 = relative_luminance(rgb2)
|
||||
lighter, darker = max(l1, l2), min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
# Variable names that suggest "background" or "text"
|
||||
|
||||
BG_KEYS = ("bg", "background", "surface")
|
||||
TEXT_KEYS = ("text", "fg", "foreground", "color", "muted", "dim", "subtle", "secondary")
|
||||
|
||||
|
||||
def is_bg_var(name):
|
||||
n = name.lower()
|
||||
return any(k in n for k in BG_KEYS) and "border" not in n
|
||||
|
||||
|
||||
def is_text_var(name):
|
||||
n = name.lower()
|
||||
return any(k in n for k in TEXT_KEYS) and "border" not in n and "bg" not in n.split("-")[0]
|
||||
|
||||
def audit(site):
|
||||
html = (SITES_DIR / site / "index.html")
|
||||
if not html.exists():
|
||||
return None
|
||||
css = html.read_text(errors="ignore")
|
||||
# Only look at the <style> block(s)
|
||||
style_blocks = re.findall(r"<style.*?>(.*?)</style>", 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 <style> blocks in HTML."""
|
||||
blocks = re.findall(r"<style.*?>(.*?)</style>", 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()
|
||||
|
||||
Reference in New Issue
Block a user