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; } +}