diff --git a/Tools/image-veroeffentlichen.sh b/Tools/image-veroeffentlichen.sh
index 23f8b18..a882af8 100755
--- a/Tools/image-veroeffentlichen.sh
+++ b/Tools/image-veroeffentlichen.sh
@@ -43,6 +43,16 @@ if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
exit 1
fi
+# **Vor dem Bauen, nicht danach.** Laufen App und Browser farblich
+# auseinander, soll das auffallen, bevor ein Image daraus wird.
+if command -v python3 >/dev/null && [ -f Tools/tokens-pruefen.py ]; then
+ python3 Tools/tokens-pruefen.py || {
+ echo
+ echo "Abgebrochen. Mit FLARO_DRIFT_EGAL=1 trotzdem bauen."
+ [ "${FLARO_DRIFT_EGAL:-}" = "1" ] || exit 1
+ }
+fi
+
MARKEN=(-t "$BILD:latest")
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
diff --git a/Tools/tokens-pruefen.py b/Tools/tokens-pruefen.py
new file mode 100755
index 0000000..920b751
--- /dev/null
+++ b/Tools/tokens-pruefen.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""Prüft, ob App und Weboberfläche dieselben Farben und Formen benutzen.
+
+**Warum es das braucht.** Die Werte stehen an mehreren Stellen von Hand
+abgeschrieben — in Swift für die Apps, in CSS für den Browser. Sie sind
+auseinandergelaufen, und zwar unsichtbar: `#1D1D21` gegen `#1D1D22`,
+`#A0A0A8` gegen `#B4B4BE`, Radius 8 gegen 6. Einzeln merkt das niemand.
+Zusammen sorgen sie dafür, dass App und Browser nicht wie dasselbe Programm
+aussehen.
+
+**Warum kein Generator.** Einer müsste Swift erzeugen, und erzeugter Swift
+lässt sich schlechter lesen und kommentieren als geschriebener. Diese Prüfung
+kostet weniger und fängt dasselbe ab: sie sagt, dass etwas auseinanderläuft,
+und überlässt die Entscheidung dem Menschen.
+
+Aufruf: python3 Tools/tokens-pruefen.py
+Rückgabe: 0, wenn alles übereinstimmt; sonst 1.
+"""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+WURZEL = Path(__file__).resolve().parent.parent
+SWIFT_FARBEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/FlaroColor.swift"
+SWIFT_AKZENT = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/AccentOption.swift"
+SWIFT_FORMEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/FlaroFont.swift"
+CSS = WURZEL / "web/app/src/style.css"
+
+# Was womit zusammengehört. Links der Swift-Name, rechts das CSS-Token.
+FARBEN = {
+ "canvas": "--canvas",
+ "surface": "--surface",
+ "elevated": "--elevated",
+ "placeholder": "--placeholder",
+ "textPrimary": "--text",
+ "textSecondary": "--text-dim",
+ "textMuted": "--text-muted",
+ "destructive": "--destructive",
+}
+
+# Radien: Swift-Name → CSS-Token. `sheet` und `panel` haben im Browser kein
+# Gegenstück — dort gibt es keine Blätter und keine schwebende Seitenleiste.
+RADIEN = {
+ "card": "--r-karte",
+ "control": "--r-flaeche",
+}
+
+
+def hex_aus_swift(text: str, name: str) -> str | None:
+ """Liest `static let name = RGB(hex: 0xAABBCC)` oder `RGB(0xAABBCC)`."""
+ m = re.search(rf"\b{name}\b[^\n=]*=\s*[^\n]*?0x([0-9A-Fa-f]{{6}})", text)
+ return "#" + m.group(1).upper() if m else None
+
+
+def ohne_tvos(text: str) -> str:
+ """Entfernt die tvOS-Zweige.
+
+ **Sonst vergleicht man den falschen Wert.** `FlaroRadius.card` ist 8 —
+ und auf tvOS 12, weil aus drei Metern eine stärkere Rundung nötig ist.
+ Der Browser läuft nie auf einem Fernseher; massgeblich ist der andere Zweig.
+ """
+ return re.sub(r"#if os\(tvOS\).*?(?:#else(.*?))?#endif", lambda m: m.group(1) or "",
+ text, flags=re.S)
+
+
+def zahl_aus_swift(text: str, name: str) -> float | None:
+ """Liest den ersten Zahlenwert hinter `static let name`."""
+ m = re.search(rf"\b{name}\b\s*:\s*CGFloat\s*(?:\{{[^}}]*?return\s*)?([0-9]+(?:\.[0-9]+)?)", text, re.S)
+ if m:
+ return float(m.group(1))
+ m = re.search(rf"\b{name}\b[^\n=]*=\s*([0-9]+(?:\.[0-9]+)?)", text)
+ return float(m.group(1)) if m else None
+
+
+def token_aus_css(text: str, name: str) -> str | None:
+ m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
+ return m.group(1).strip() if m else None
+
+
+def main() -> int:
+ fehlend = [p for p in (SWIFT_FARBEN, SWIFT_AKZENT, SWIFT_FORMEN, CSS) if not p.exists()]
+ if fehlend:
+ for p in fehlend:
+ print(f"Nicht gefunden: {p.relative_to(WURZEL)}")
+ return 1
+
+ farben_swift = SWIFT_FARBEN.read_text(encoding="utf-8")
+ akzent_swift = SWIFT_AKZENT.read_text(encoding="utf-8")
+ formen_swift = ohne_tvos(SWIFT_FORMEN.read_text(encoding="utf-8"))
+ css = CSS.read_text(encoding="utf-8")
+
+ abweichungen: list[str] = []
+ geprueft = 0
+
+ for swift_name, css_name in FARBEN.items():
+ a = hex_aus_swift(farben_swift, swift_name)
+ b = token_aus_css(css, css_name)
+ if a is None or b is None:
+ abweichungen.append(f" {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
+ continue
+ geprueft += 1
+ if a.upper() != b.upper():
+ abweichungen.append(f" {swift_name}: Swift {a}, CSS {css_name} {b}")
+
+ # Die Vorgabefarbe der App gegen den Akzent im Browser.
+ vorgabe = re.search(r'identifier\s*==\s*"([a-z]+)"', akzent_swift)
+ if not vorgabe:
+ vorgabe = re.search(r'\.first\s*\{\s*\$0\.id\s*==\s*"([a-z]+)"', akzent_swift)
+ if vorgabe:
+ hex_vorgabe = hex_aus_swift(akzent_swift, vorgabe.group(1))
+ akzent_css = token_aus_css(css, "--accent")
+ if hex_vorgabe and akzent_css:
+ geprueft += 1
+ if hex_vorgabe.upper() != akzent_css.upper():
+ abweichungen.append(
+ f" Vorgabe-Akzent: Swift {vorgabe.group(1)} {hex_vorgabe}, CSS {akzent_css}")
+
+ for swift_name, css_name in RADIEN.items():
+ a = zahl_aus_swift(formen_swift, swift_name)
+ b = token_aus_css(css, css_name)
+ if a is None or b is None:
+ abweichungen.append(f" Radius {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
+ continue
+ geprueft += 1
+ b_zahl = float(re.sub(r"[^0-9.]", "", b) or 0)
+ if abs(a - b_zahl) > 0.01:
+ abweichungen.append(f" Radius {swift_name}: Swift {a:g}, CSS {css_name} {b}")
+
+ if abweichungen:
+ print(f"App und Weboberfläche laufen auseinander ({len(abweichungen)} Stelle(n)):")
+ print("\n".join(abweichungen))
+ print("\nFlaroDesign ist massgeblich — die CSS zieht nach.")
+ return 1
+
+ print(f"App und Weboberfläche stimmen überein ({geprueft} Werte geprüft).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/web/app/index.html b/web/app/index.html
index 63001e4..5f2c4d2 100644
--- a/web/app/index.html
+++ b/web/app/index.html
@@ -5,6 +5,13 @@
Flaro
+
+
+
+
diff --git a/web/app/src/style.css b/web/app/src/style.css
index b6efe1b..a8c0a3e 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -9,27 +9,81 @@
* Vorbild ist die Anordnung, nicht das Aussehen. */
:root {
- --accent: #C04AC9;
- --accent-dim: #8E3595;
- --accent-soft: rgba(192, 74, 201, .16);
+ /* --- Akzent ------------------------------------------------------------
+ *
+ * **Lilac, nicht Orchidee.** Die Apps starten ab Werk mit `#9B8AD4`
+ * (`AccentOption.swift`), der Web-Client stand auf `#C04AC9`. Dieselbe
+ * Software in zwei Farben ist keine Gestaltung, sondern Drift.
+ *
+ * Die beiden abgeleiteten Werte werden **gerechnet**, nicht gepflegt —
+ * sonst müsste man sie bei jedem Farbwechsel von Hand mitziehen und
+ * vergisst es beim zweiten Mal. */
+ --accent: #9B8AD4;
+ --accent-dim: color-mix(in srgb, var(--accent) 72%, black);
+ --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
+ /* --- Flächen -----------------------------------------------------------
+ *
+ * Auf FlaroDesign zurückgeführt: `elevated`, `placeholder`, `text`,
+ * `text-dim`, `text-muted` und die Trennlinie wichen in der letzten
+ * Stelle ab — unsichtbar einzeln, zusammen aber genau der Grund, warum
+ * App und Browser nicht wie dasselbe Programm aussahen. */
--canvas: #0B0B0D;
--surface: #141417;
- --elevated: #1D1D22;
- --raised: #26262C;
- --placeholder: #202026;
- --line: rgba(255, 255, 255, .08);
+ --elevated: #1D1D21;
+ --raised: #26262B;
+ --placeholder: #26262B;
+ --line: rgba(255, 255, 255, .10);
- --text: #F2F2F5;
- --text-dim: #B4B4BE;
- --text-muted: #8A8A95;
- --text-faint: #65656F;
+ /* Kopf- und Seitenleiste. **Eigenes Token, weil es vorher als `#0E0E11`
+ * hart im Regelwerk stand** — und sich damit jedem Theme entzog. */
+ --chrome: #0E0E11;
+
+ --text: #F5F5F7;
+ --text-dim: #A0A0A8;
+ --text-muted: #6E6E76;
+ --text-faint: #55555C;
--destructive: #E5484D;
+ --destructive-dim: color-mix(in srgb, var(--destructive) 78%, black);
+
+ /* Schatten als Token, nicht fünfmal fest verdrahtet. */
+ --schatten-karte: 0 8px 24px rgba(0, 0, 0, .55);
+ --schatten-plakat: 0 12px 40px rgba(0, 0, 0, .6);
+ --schatten-dialog: 0 24px 64px rgba(0, 0, 0, .7);
--s-tight: 4px; --s-snug: 8px; --s-regular: 12px;
--s-loose: 16px; --s-section: 24px; --s-page: 32px;
- --r-control: 8px; --r-card: 6px; --r-pill: 999px;
+ /* --- Formen ------------------------------------------------------------
+ *
+ * **Drei Radien statt vier, und keiner mehr zufällig.** Vorher: 6 px auf
+ * Plakaten, 8 px auf Karten und Feldern, 999 px auf *jedem* Knopf, dazu
+ * zweimal nacktes 3 px. Vollrunde Primärknöpfe neben 8-px-Karten ergeben
+ * keine Formensprache — und sie sind der zweitstärkste Hinweis darauf,
+ * dass eine Oberfläche nicht entworfen, sondern zusammengesetzt wurde.
+ *
+ * 8 und 10 sind dieselben Werte wie in FlaroDesign. */
+ --r-karte: 8px;
+ --r-flaeche: 10px;
+ --r-knopf: 6px;
+ --r-pill: 999px;
+
+ /* Rückwärtsnamen, solange noch Regeln daran hängen. */
+ --r-card: var(--r-karte);
+ --r-control: var(--r-flaeche);
+
+ /* --- Schrift -----------------------------------------------------------
+ *
+ * **Eine Skala statt sechs Zufallswerten.** Die Überschriften standen auf
+ * 34 / 26 / 22 / 20 / 19 / 17 px — Werte ohne Verhältnis zueinander, alle
+ * in 700, mit fast gleichem negativem Spacing. Das erzeugt keine
+ * Hierarchie, sondern Rauschen. */
+ --t-hero: 32px;
+ --t-titel: 24px;
+ --t-abschnitt: 19px;
+ --t-gross: 15px;
+ --t-normal: 13px;
+ --t-klein: 11px;
--sidebar-w: 240px;
--topbar-h: 56px;
@@ -45,7 +99,7 @@ body {
margin: 0;
background: var(--canvas);
color: var(--text);
- font: 400 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font: 400 14px/1.55 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
@@ -65,11 +119,11 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
position: fixed; inset: 0 0 auto 0; height: var(--topbar-h); z-index: 30;
display: flex; align-items: center; gap: var(--s-loose);
padding: 0 var(--s-loose);
- background: #0E0E11;
+ background: var(--chrome);
border-bottom: 1px solid var(--line);
}
#topbar .marke {
- font-size: 20px; font-weight: 700; letter-spacing: -.02em;
+ font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.02em;
color: var(--accent); user-select: none;
}
#topbar .suchfeld {
@@ -101,7 +155,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
#chrome {
position: fixed; top: var(--topbar-h); bottom: 0; left: 0;
width: var(--sidebar-w); overflow-y: auto;
- background: #0E0E11;
+ background: var(--chrome);
border-right: 1px solid var(--line);
padding: var(--s-loose) 0;
display: flex; flex-direction: column; gap: 2px;
@@ -160,7 +214,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r-control); padding: var(--s-section);
}
-.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; color: var(--accent); }
+.login-card h1 { margin: 0; font-size: var(--t-titel); letter-spacing: -.02em; color: var(--accent); }
.bleiben {
display: flex; align-items: center; gap: var(--s-snug);
color: var(--text-dim); font-size: 13px; cursor: pointer; user-select: none;
@@ -174,7 +228,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
margin-bottom: var(--s-section);
}
.seitenkopf .wer { min-width: 0; }
-.seitenkopf h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
+.seitenkopf h1 { margin: 0; font-size: var(--t-titel); font-weight: 700; letter-spacing: -.02em; }
.seitenkopf .quelle {
font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint);
}
@@ -257,8 +311,8 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.plakat {
position: relative; width: 100%; aspect-ratio: 2 / 3;
background: var(--placeholder);
- border-radius: var(--r-card); overflow: hidden;
- transition: transform .15s ease, box-shadow .15s ease;
+ border-radius: var(--r-karte); overflow: hidden;
+ transition: transform .18s ease-out, box-shadow .18s ease-out;
}
.plakat img {
width: 100%; height: 100%; object-fit: cover;
@@ -266,19 +320,31 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
}
.plakat.geladen img { opacity: 1; }
-.kachel:hover .plakat {
- transform: translateY(-2px);
- box-shadow: 0 8px 24px rgba(0, 0, 0, .55);
+/* **Hover mit Inhalt.** Zwei Bildpunkte Anheben waren kaum zu sehen; ein
+ leichtes Hochskalieren hebt die Kachel aus der Reihe heraus, so wie bei
+ Netflix. Nur `transform` und `box-shadow`, damit nichts umbricht. */
+.kachel:hover .plakat,
+.kachel:focus-visible .plakat {
+ transform: scale(1.04);
+ box-shadow: var(--schatten-karte);
+ z-index: 3;
}
-/* Der Abspielknopf erscheint beim Überfahren, mittig. */
+/* **Der Abspielknopf als Kreis unten links, nicht als Vollflächen-Overlay.**
+ Das Overlay legte sich über das ganze Plakat und verdeckte genau das Bild,
+ nach dem man gerade sucht. */
.schweber {
- position: absolute; inset: 0; display: grid; place-items: center;
- background: rgba(0, 0, 0, .45);
- opacity: 0; transition: opacity .15s ease;
+ position: absolute; left: var(--s-snug); bottom: var(--s-snug);
+ display: grid; place-items: center;
+ width: 38px; height: 38px; border-radius: 50%;
+ background: rgba(10, 10, 12, .78);
+ backdrop-filter: blur(6px);
+ opacity: 0; transform: translateY(6px);
+ transition: opacity .18s ease-out, transform .18s ease-out;
}
-.schweber svg { width: 44px; height: 44px; color: #fff; filter: drop-shadow(0 2px 8px rgba(0,0,0,.6)); }
-.kachel:hover .schweber { opacity: 1; }
+.schweber svg { width: 20px; height: 20px; color: #fff; }
+.kachel:hover .schweber,
+.kachel:focus-visible .schweber { opacity: 1; transform: none; }
/* **Haken und Zahl sitzen in derselben Ecke**, weil sie dieselbe Frage
beantworten: ist hier noch etwas offen? */
@@ -302,7 +368,10 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.kacheltext { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.kacheltext strong {
- font-weight: 500; font-size: 13px;
+ /* **Lauter als die Zeile darunter.** Mit 13 px/500 war der Titel leiser
+ als manche Metazeile daneben — die Typografie erzeugte damit keine
+ Hierarchie, sondern Rauschen. */
+ font-weight: 600; font-size: 14px; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kacheltext span {
@@ -319,7 +388,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
display: flex; align-items: center; gap: var(--s-regular);
margin-bottom: var(--s-regular);
}
-.reihe-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; }
+.reihe-kopf h2 { margin: 0; font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; }
.reihe-kopf .mehr {
background: none; border: 0; padding: 0; font-size: 13px; color: var(--accent);
}
@@ -371,7 +440,7 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
max-width: 420px; max-height: 110px; margin-bottom: var(--s-regular);
object-fit: contain; object-position: left bottom; display: block;
}
-.detail-text h1 { margin: 0 0 var(--s-snug); font-size: 34px; font-weight: 700; letter-spacing: -.03em; }
+.detail-text h1 { margin: 0 0 var(--s-snug); font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em; }
.detail-text .unter { margin: 0 0 var(--s-regular); font-size: 17px; color: var(--text-dim); }
.eckdaten {
@@ -393,13 +462,21 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.btn {
display: inline-flex; align-items: center; gap: var(--s-snug);
- background: var(--elevated); border: 0; border-radius: var(--r-pill);
+ background: var(--elevated); border: 0; border-radius: var(--r-knopf);
padding: 9px var(--s-loose); color: var(--text);
}
.btn:hover { background: var(--raised); }
.btn:disabled { opacity: .5; cursor: default; }
.btn svg { width: 18px; height: 18px; }
-.btn-primary { background: var(--accent); color: #fff; font-weight: 600; }
+/* **Der Primärknopf ist hell, nicht farbig.**
+ *
+ * Genau das macht Netflix: der Abspielknopf ist weiss mit schwarzer Schrift,
+ * die Markenfarbe erscheint nur im Schriftzug. Der Akzent sass hier auf neun
+ * Dingen gleichzeitig — Marke, Navigation, Knopf, Fortschritt, Plakette,
+ * Stern, Fokus, Filter, Kontokreis. Wenn alles Akzent ist, markiert er
+ * nichts. */
+.btn-primary { background: var(--text); color: var(--canvas); font-weight: 600; }
+.btn-primary:hover { background: #fff; }
.btn-primary:hover { background: var(--accent-dim); }
/* Runde Knöpfe für alles ohne Beschriftung — wie Plex' Merkliste und Haken. */
@@ -442,8 +519,8 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
/* ---------- Kleinteile ---------- */
-h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(--s-regular); }
-.seitentitel { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
+h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; margin: 0 0 var(--s-regular); }
+.seitentitel { margin: 0; font-size: var(--t-titel); font-weight: 700; letter-spacing: -.02em; }
.einstellungen { display: flex; flex-direction: column; gap: var(--s-regular); margin-bottom: var(--s-page); }
.einstellung {
@@ -541,7 +618,7 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
}
.btn-gefahr { background: var(--destructive); color: #fff; font-weight: 600; }
-.btn-gefahr:hover { background: #C53338; }
+.btn-gefahr:hover { background: var(--destructive-dim); }
/* Der Spieler über allem. */
.pl-fill {
@@ -656,3 +733,40 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
font-size: 15px;
color: var(--text-muted);
}
+
+
+/* --- Fokus und Bewegung --------------------------------------------------
+ *
+ * **Ein sichtbarer Fokusring auf allem, was man bedienen kann.** Vorher
+ * färbte sich allenfalls ein Feldrahmen; mit der Tastatur war die Oberfläche
+ * praktisch unbedienbar, weil man nicht sah, wo man gerade ist. */
+
+:where(button, a, input, select, textarea, [tabindex]):focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: var(--r-knopf);
+}
+
+.kachel:focus-visible { outline: none; }
+.kachel:focus-visible .plakat {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+/* **Wer „Bewegung reduzieren" eingestellt hat, meint es.**
+ *
+ * Das gilt im ganzen Projekt bisher nirgends. Animationen ganz abzuschalten
+ * wäre falsch — ein Zustandswechsel ohne jede Rückmeldung wirkt wie ein
+ * Aussetzer. Auf einen Hauch zu kürzen behält die Rückmeldung und nimmt die
+ * Bewegung. */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: .01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .01ms !important;
+ scroll-behavior: auto !important;
+ }
+
+ .kachel:hover .plakat,
+ .kachel:focus-visible .plakat { transform: none; }
+}