Design System — Komponentenspezifikation
Aufbau dieses Dokuments
Jede Komponente unten ist nach einem einheitlichen Kontrakt dokumentiert:
- Überblick — was die Komponente ist und warum sie im Erlebnis existiert.
- Visuelle Spezifikation — Dimensionen, Aufbau (Layer-Stack), Tokens, die ihr Erscheinungsbild bestimmen.
- Zustände — jeder visuelle Zustand mit Auslöser und Austrittsbedingung.
- Verhalten — Zustandsautomat, Lebenszyklus, Seiteneffekte, Datenabhängigkeiten.
- Bewegung — Dauern, Easings, Choreografie. Immer in Millisekunden + cubic-bezier angegeben.
- Interaktion & UX — Eingabebehandlung (Drücken, Ziehen, Wischen, Tastatur, Fokus), Gesten, Scrollen.
- Barrierefreiheit — semantische Rolle, Labels, Fokusreihenfolge, Mindest-Trefferflächen, Verhalten bei reduzierter Bewegung.
- Verwendete Tokens — jeder DTCG-Token-Pfad, den die Komponente liest, nach Ebene.
- Implementierungsreferenz — Android-spezifische Hinweise; iOS und Web ergänzen hier eigene Hinweise, sobald sie eingeführt werden.
Konventionen:
- Größen werden in dichteunabhängigen Pixeln angegeben (
dp= Android / iOS-Punkt @1x / CSS px / Flutter logical pixel — sie sind für das Layout austauschbar). - Schriftgrößen werden in skalierbaren Pixeln angegeben (
spauf Android, äquivalent zu Dynamic Type auf iOS,remim Web). Beide Plattformen müssen die Schriftgrößen-Einstellung des Nutzers respektieren. - Farben und Deckkraftwerte sind Tokens, niemals Hex-Literale. Token-Pfade folgen der vierstufigen Hierarchie
primitives → semantic → component → views. - Zeit wird in Millisekunden angegeben. Easings werden benannt oder als
cubic-bezier(x1, y1, x2, y2)angegeben. - "Container" bezeichnet eine generische Layout-Box (Android
Box/ iOSUIView/ Webdiv). "Stack" bezeichnet ein vertikales oder horizontales Layout (Column/Row/VStack/HStack/flex).
Token-System
Tokens werden in tokens/{primitive,semantic,component,view}/*.json im W3C-DTCG-Format definiert. Die einzige Quelle der Wahrheit ist die Design-Tokens-API unter /api/v1/tokens; die Plattformen laden den konsolidierten Payload zur Laufzeit und legen ihn über ihre gebündelten Standardwerte.
| Ebene | Zweck | Beispiel |
|---|---|---|
| Primitive | Rohwerte: Farben, Abstände, Schriftstärken, Deckkraft-Stufen. | primitives.opacity.scale80 = 0.8 |
| Semantic | Theme-bewusste Bedeutung: dunkler/heller Text, Rahmen, Oberflächen. | semantic.dark.text.primary = #FFFFFF |
| Component | Komposition pro Komponente. | component.heroVideo.tabletTitleFontSize = 26 |
| View | Komposition pro Bildschirm (bindet Primitives + Semantic an Oberflächen eines spezifischen Bildschirms). | views.home.modalScrim.alpha = 0.8 |
Die Implementierung muss Tokens aus der API lesen; jeder für den Nutzer sichtbare Wert, der kein Token ist, gilt als Bug.
<code>-Tags der API-Benennungskonvention. Von den 250 genannten eindeutigen Pfaden werden 157 (63%) heute vollständig über die API aufgelöst; 70 (28%) existieren als Kotlin-Modell-Defaults und warten auf API-Promotion; 23 (9%) sind reine Spezifikations-Vorschläge, die während der plattformübergreifenden Implementierung formalisiert werden. Siehe Abschnitt Token-Referenz am Ende für die vollständige Statustabelle — vor der Implementierung lesen.
Plattform-Hinweise
packages/android. Verwendet Jetpack Compose mit Material 3. Die Referenz ist verbindlich für das Verhalten — wenn diese Spezifikation und die Android-Quelle abweichen, gilt die Spezifikation, aber die Android-Quelle ist die nächstgelegene existierende Antwort.
index.html zeigt eine Möglichkeit, component-tokens.css zu konsumieren.
Glossar
In diesem Dokument verwendete Begriffe — plattformunabhängig definiert.
| Begriff | Bedeutung |
|---|---|
dp | Dichteunabhängiges Pixel. Ein dp = ein logisches Pixel auf einem "Baseline"-Bildschirm mit 160 dpi. Äquivalent zu: Android dp, iOS-Punkt @ 1×, CSS px, Flutter logical pixel. |
sp | Skalierbares Pixel. Wie dp, skaliert aber mit der vom Nutzer bevorzugten Schriftgröße. Für jede Schriftgröße verwenden. iOS Dynamic Type und CSS rem erfüllen dieselbe Rolle. |
| Container | Eine generische Layout-Box, die weitere Elemente enthält. Entspricht Android Box, iOS UIView / SwiftUI VStack-Wrapper, Web div. |
| Stack | Eine lineare Anordnung von Kindelementen, entweder vertikal oder horizontal. Entspricht Compose Column/Row, SwiftUI VStack/HStack, CSS flex-direction: column/row. |
| Press-Feedback | Die transiente visuelle Bestätigung, wenn ein Nutzer ein interaktives Element antippt. Android = Ripple, iOS = Highlight/Scale, Web = Active-State. |
| Scrim | Eine halbtransparente Überlagerung hinter einer modalen Oberfläche, die den darunterliegenden Inhalt abdunkelt und visuell trennt. Immer antippbar zum Schließen. |
| Slot | Ein benannter Bereich innerhalb einer Eltern-Komponente, in den ein Kind eingefügt wird. Das Eltern-Element gibt Geometrie und Styling des Slots vor; das Kind füllt ihn. Äquivalent zu Compose-Composable-Content-Lambdas, SwiftUI @ViewBuilder-Parametern, Web-Slots / React-Children. |
| Chrome | Wiederverwendbare visuelle Umrandung um Inhalt (Rahmen, Eckenradius, Schatten, Badge-Slot) — die Komponente dekoriert, ohne den Inhalt vorzugeben. |
| Settle | Der Endzustand nach einem Wisch- oder Fling-Gesten, wenn der scrollbare Bereich an einer stabilen Seitenposition zur Ruhe kommt. |
| Trefferfläche | Der transparente antippbare Bereich um ein visuelles Icon oder einen Button. Muss gemäß Barrierefreiheits-Baseline ≥ 48dp × 48dp sein, unabhängig von der sichtbaren Größe. |
| BP / Breakpoint | Viewport-Größenschwelle, die den Layout-Modus umschaltet. Iteration 1 hat zwei aktive: mobile (kürzeste Seite < 600dp) und tablet (≥ 600dp). Siehe Breakpoints. |
| DTCG | W3C Design Tokens Community Group-Format. Die JSON-Struktur, in der Tokens unter tokens/{primitive,semantic,component,view}/*.json verfasst werden. Spezifikation: tr.designtokens.org/format. |
| Token | Ein benannter Designwert (Farbe, Dimension, Deckkraft, Dauer, …), der in DTCG verfasst, von der Design-Tokens-API ausgeliefert und zur Laufzeit von der Plattform konsumiert wird. Die vier Ebenen (Primitive · Semantic · Component · View) komponieren in Richtung spezifischerer Bedeutungen. |
| Vorab-Palette | Laufzeitanalyse eines Bildes, um eine dominante "Basis"-Farbe und eine "Akzent"-Farbe abzuleiten, die zum Einfärben der Hero-Gradients und des umgebenden Bildschirmhintergrunds verwendet werden. Die Android-Referenz verwendet den palette=json-Endpoint von Imgix oder als Fallback die androidx.palette-Bibliothek. |
| Skeleton | Ein Platzhalter-Visual, das angezeigt wird, während der echte Inhalt lädt. Gleiche Form wie die finale Karte / Reihe, gefüllt mit einer kontrastarmen Farbe, die dezent pulsieren darf. |
| Composition Payload | Server-getriebenes JSON, das die Struktur eines Bildschirms beschreibt: welche Abschnitte vorhanden sind, deren Reihenfolge und welcher Inhalt jeden füllt. Der Home-Bildschirm liest dies, um zu bestimmen, welche RowSections in welcher Reihenfolge gerendert werden. |
Theming — Kontrakt für hellen & dunklen Modus
Die App unterstützt zwei Farbschemata. Jede Komponente muss in beiden korrekt funktionieren. Die meisten Oberflächen wechseln automatisch über semantische Tokens — Designer und Implementierer sollten KEIN modus-spezifisches CSS direkt im Komponenten-Code schreiben.
Wie die Modi verdrahtet sind
- System-Ebene: Der aktive Modus wird durch die System-Theme-Präferenz des Nutzers bestimmt (Android System-Theme / iOS
UITraitCollection/ Webprefers-color-scheme). - Semantic-Ebene: Der Großteil der Token-Auflösung liegt in
semantic.{dark,light}.*— jede sichtbare Farbe stammt von dort. Komponenten lesensemantic.{mode}.text.primary,background.canvasusw., und die Laufzeit wählt den korrekten Sub-Tree basierend auf dem aktiven Modus. - Component-/View-Ebene: Manche Komponenten haben strukturelle Unterschiede zwischen den Modi (nicht nur Farbwechsel) — z.B. erscheint der Schlagschatten der Hero-Karte nur im dunklen Modus. Diese strukturellen Regeln sind im jeweiligen Komponenten-Abschnitt unten explizit dokumentiert.
Strukturelle Unterschiede (Zusammenfassung pro Komponente)
| Komponente | Heller Modus | Dunkler Modus |
|---|---|---|
| TopBar | Statusleiste = dunkle Icons auf hell. Hintergrund der Leiste blendet zu semantic.light.background.surface (Pfirsich). | Statusleiste = helle Icons auf dunkel. Hintergrund der Leiste blendet zu semantic.dark.background.canvas (Tiefseegrün). |
| HeroCarousel | Kein Schlagschatten am Hero-Kartenkörper. Rahmenfarbe aus Light-Mode-Semantic. | Schlagschatten bei heroCarousel.elevation = 16dp. Rahmenfarbe aus Dark-Mode-Semantic. |
| HeroVideoCard | Unterer Gradient = 7 Stufen (hellere Rampe). Light-Mode-Textfarben. | Unterer Gradient = 8 Stufen (dichtere Rampe für Textlesbarkeit auf Foto). Dark-Mode-Textfarben. |
| ButtonGroupSection | Primärer Button = vollflächige Oberfläche, KI-Variante verwendet Light-Mode-Akzent-Gradient. | Gleiche Komponente, Dark-Mode-Akzent-Gradient. |
| RowSection | Titeltext aus semantic.light.text.primary. | Titeltext aus semantic.dark.text.primary. |
| CardPoster | Rahmen- + Schattenwerte aus Light-Mode-Tokens. Skeleton-Füllung verwendet helle bgColor. | Dasselbe mit Dark-Mode-Tokens. |
| MenuDrawer | Panel-Hintergrund semantic.light.background.canvas × α 0.85 (Mobile) bzw. × α 0.92 (Tablet-Panel) + Backdrop-Blur (24dp, Android 12+). Zeilen-Hintergrund Color.Transparent — Zeilen erben vom Panel. | Panel semantic.dark.background.canvas × α 0.85 / 0.92 + Backdrop-Blur. Zeilen-Hintergrund Color.Transparent. Press-Feedback ist die einzige zeilenspezifische visuelle Antwort. |
| Home-Bildschirm | Canvas = semantic.light.background.surface (Pfirsich). Gradient mischt Hero-Farbe über Pfirsich. | Canvas = semantic.dark.background.canvas (Tiefseegrün). Gradient mischt Hero-Farbe über Tiefseegrün. |
Jede Zeile gegen den Abschnitt der jeweiligen Komponente unten verifizieren — jeder strukturelle Unterschied wird auch im Audit-Notizen-Block der Komponente erwähnt.
Was (noch) NICHT im Umfang ist
Disziplin beim Umfang ist wichtig. Bisher ausgeliefert: Iteration 1 — Home-Bildschirm + Burger-Menü (iteration-1.html); Iteration 2 — die Lesepläne-Komponenten (HeroGenericCard, HeroPosterCard, CardLandscape, CardAvatar + voller ButtonGroupSection-Umfang; iteration-2.html) sowie der Design-Sync vom 2026-06-10 (festes Scrim-Band der Hero-Karten, Seitenhintergrund vereinheitlicht auf bg.canvas mit nahtloser Gradient-Konvergenz, API-Felder appearance.backgroundColor*, Badge-Textfarbe per Luminanz, Split-Karten-Verankerung).
Implementiert + im Showcase, Doku-Abschnitte ausstehend: CardOverlay und CardSplit sind in der App (Lesepläne-Showcase) und in Figma (Seiten i2 · Card · Overlay/Split) ausgeliefert, haben aber noch keine eigenen Komponenten-Abschnitte in diesem Dokument — geplant für Iteration 3. Inzwischen geliefert und daher aus der Liste entfernt: Pull-to-Refresh (CollectionScreen rendert PullToRefreshBox) · der Lesepläne-Tab als Komponenten-Showcase.
Die folgenden Bildschirme und Features bleiben auch nach Iteration 2 explizit außerhalb des Umfangs und sollen jetzt nicht gebaut oder spezifiziert werden, selbst wenn die Android-Quelle Hinweise darauf enthält. Eine künftige Iteration wird jeden Punkt adressieren.
- Andere Bildschirme als Home + Burger-Menü: Bibellese-Ansicht, Live-TV, TV-Programm, Konto, Suchergebnisse, Detailansichten (Serie / Video / Playlist), Vollbild-Videoplayer. Die Bottom-Nav-Ziele für diese Tabs verweisen irgendwohin, aber die Zielbildschirme sind nicht im Umfang. (Der Lesepläne-Tab beherbergt derzeit den Iteration-2-Komponenten-Showcase — das eigentliche Lesepläne-Produkt bleibt außerhalb des Umfangs.)
- Authentifizierungs-Flows: Der "Anmelden"-Button im Drawer ist sichtbar, aber für den Implementierer eine No-Op-Verdrahtung — die tatsächlichen Anmeldebildschirme sind eine separate Spezifikation.
- Bildschirmübergreifende Routen-Transitions: Wenn der Nutzer eine Karte → Detail antippt, die Plattform-Standardanimation verwenden. Eine dedizierte Motion-Spezifikation folgt später (Shared-Axis / Fade / Push, je nach Plattform-Konvention).
- Internationalisierung über Deutsch hinaus. UI-Text ist in v1 ausschließlich deutsch; künftige Spezifikationen führen
strings.json-Keys für den Rest ein. - Theming über Hell / Dunkel hinaus. Kein Akzentfarben-Picker, keine benutzerdefinierten Themes, keine nutzerspezifische Palette. Das System unterstützt sie auf Token-API-Ebene (modus-bewusst), aber die UI legt sie nicht offen.
- Offline-/Cache-Inhalte. Karten rendern Skeleton- und Fehlerzustände, aber es gibt keinen lokalen Storage-Fallback und kein "Für später gespeichert"-Feature in dieser Phase.
- Analytics, Crash-Reporting, Telemetrie. Der Implementierer sollte das Standard-SDK der Plattform verdrahten, aber es sind keine spezifischen Events spezifiziert.
- Live-Updates über EPG-Polling hinaus. HeroLiveStreamSlide pollt EPG; keine andere Komponente empfängt Push-Updates.
- Benutzerdefiniertes Fokus-Management im Web über den Plattform-Standardring + die Modal-Falle für MenuDrawer hinaus. Die aktuellen Iterationen erfordern keine Tastenkürzel.
Changelog
Änderungen an Spezifikation, Token-API und Referenz-Implementierung — adressiert u. a. den BT-3900 Token-Drift-Report (04.06.2026). Einträge, die das Rendering von Iteration-1-Komponenten (Home) verändern, sind mit [Iteration 1] markiert.
2026-06-11
- [Iteration 1] API:
heroVideo.titleFontSize— Platzhalter behoben (Report-Punkt 1). Die API liefert jetzt die echte absteigende Skala28/24/22/20/18sp(Schwellen <20/<30/<40/<50/≥50 Zeichen, Zeilenhöhe = Größe+2) statt flat 28. Der Token ist runtime-gemergt (⚙️ API) — Clients können den gebündelten Fallback weiter nutzen, beide Quellen stimmen jetzt überein. - Doku: Geist-Token
mobileEyebrowFontSizeentfernt (Report-Punkt 2). Die Eyebrow-Größe kommt — wie im Code — ausheroVideo.tabletDescFontSize(14sp, bewusst auch auf Mobile). Kein neuer API-Token nötig. - Doku an API angeglichen (Report-Punkt 3):
paddingDefault-Tabelle jetzt 16dp (mobil) / 20dp (tablet) statt pauschal 24dp ·surfaceRaised-Replica + Beschriftung auf#252F35korrigiert (vorher „Neutral700 #1A2733", ein Hex außerhalb jeder Palette). - [Iteration 1] Proto: globales Text-Letterspacing entfernt (Report-Punkt 4). Die Material3-
Typographyder Referenz-App setzte (M3-Default)letterSpacing = 0.5spaufbodyLarge/labelSmall— nicht durch die Spec gedeckt. Jetzt 0sp; der frühere ~8px-Versatz gegenüber der BTV-App entfällt. - ⚠️
cardSplit.titleFontSize/Weight— API-Korrektur 14→16 (betrifft Report-Zeile „RowSection-Poster-Fallback"): Der API-Wert14/SemiBoldwar selbst Drift — die Referenz-Implementierung (Kotlin-Truth) rendert16sp/SemiBold/1.2; API + Doku wurden am 10.06. darauf korrigiert (Drift-Gate verifiziert). Bitte den BTV-Fix „16/Medium → 14/SemiBold" auf16/SemiBoldnachziehen — zur Laufzeit liefert die API den Wert bereits. - [Iteration 1] Badges + Fortschrittsbalken auf allen Kartenvarianten: CardPoster und CardLandscape rendern jetzt das Status-/Kategorie-Badge (
tagVariant/tag/contentType, oben links) nativ; CardTop10 reicht Badge + Progress an die innere Karte durch (manuelles Overlay entfernt). - Tablet-Hero: Scrim-Breite modusabhängig —
tabletGradientWidthFractionhell 0.6 / dunkel 0.5 (per-Mode in API + gemergt; vorher nur gebündelt). Begründung: dunkler Text auf hellem Scrim verliert nahe der Ausblendung schneller an Lesbarkeit.
2026-06-10
- [Iteration 1] Hero-Scrim: festes Band statt proportionalem Gradient (HeroVideoCard + HeroGenericCard, mobil). Volle Deckkraft am Anker
contentTopPadding(140) + scrimFadeShift(52) = 192dp(Baseline der ersten Headline-Zeile), RampescrimFadeHeight(240dp)nach oben, darunter Vollton — unabhängig von der Texthöhe. Neue ⚙️-API-TokenscrimFadeShift/scrimFadeHeight; Stop-Kurven (band-relativ) inheroVideo.gradient.stopsdokumentiert. Vorher skalierten die Stop-Anteile mit der Boxhöhe — bei viel Text saßen Eyebrow/Titel auf halbtransparentem Verlauf. - [Iteration 1] Seitenhintergrund vereinheitlicht auf
bg.canvas(beide Modi; hell#F8F9FA, dunkel#080D16): Basis, TopBar-Fade-Ziel und Gradient-Konvergenzpunkt sind identisch; der Seiten-Gradient konvergiert exakt (6. Stop = Canvas, vorher 24 % Rest-Tint = sichtbare Naht). Per-Page-Override viaappearance.backgroundColorDark/Light(neu in der Page-API);appearance.gradientColor*hat jetzt Vorrang vor der extrahierten Hero-Farbe. - [Iteration 1] Badge-Textfarbe per Luminanz (
BtvBadge): weiß auf dunklen/transluzenten,#1A1A1Aauf hellen Hintergründen (Schwelle 0.179). Neuer Tokenbadge.textColorOnLight. - [Iteration 1] Buttons brechen nie um (
maxLines = 1+ Ellipsis, alle ProtoBibleButton-Varianten); auf Tablet-Heroes darf der CTA über die Inhaltsspalte hinaus bis zur Kartenbreite wachsen. - Tablet-Portrait-Zeilenlimits reduziert:
heroVideo.maxLinesPortrait 3/4 → 2/3 (crowded 2/2);heroFeaturederhält eigene Portrait-Limits (2/3). ⚙️ API. - Split-Karte: Titel oben ohne Eyebrow — Info-Bereich behält die feste Höhe, Eyebrow+Titel oben verankert, Footer unten; ohne Eyebrow rückt der Titel nach oben statt eine Leerzeile zu reservieren.
- Karten-Token an Kotlin-Truth angeglichen (Drift seit 092):
imageOnly/overlay.cornerRadius16→12 ·overlay.titleLineHeight1.07→1.2143 ·split.titleFontSize14→16 ·split.titleLineHeight1.07→1.2. Drift-Gate (d2-card) aktualisiert und grün. - Pull-to-Refresh auf Collection-Screens ausgeliefert (
PullToRefreshBox) — aus „Nicht im Umfang" entfernt. - Token-Pipeline-Hinweis (Report-Punkt 5, teilweise): diverse 🔧-Kotlin-Pfade wurden in die ⚙️ API überführt (Scrim-Maße, Hero-Zeilenlimits inkl.
heroVideo.maxLines.*+heroFeatured.tabletPortrait*,badge.textColorOnLight, Tablet-Layout-Fraktionen). Noch offen u. a.: Kern-Button-Farben, Hero-Schriftfamilien, MenuDrawer-Werte,broadcast.live/focusRing— Migrations-Backlog für Iteration 3.
Jeder Eintrag ist im Repo nachvollziehbar (Branch iteration-2, Commits vom 10.–11.06.2026); Token-Werte gegen die Live-API prüfbar (/api/v1/tokens). Figma (Datei JSmrFLCfLv8vTVAZVgEwVl, Seiten i2 ·) wurde synchron aktualisiert inkl. Spec-Karten je Komponente.
Implementierungs-Schnellstart
Eine komprimierte Checkliste für einen Entwickler (oder Agenten), der auf einer Nicht-Android-Plattform kalt startet. Jeder Schritt verweist auf den ausführlichen Abschnitt.
- Token-API-Client verdrahten. Beim App-Start
/api/v1/tokensabrufen. Antwort cachen und beim App-Foreground erneut laden. Die vier Ebenen auf Plattformtypen abbilden (z.B. Swift-Structs / TypeScript-Interfaces). Fallback auf gebündelte Defaults, falls das Netzwerk nicht erreichbar ist — siehe Android-resolveMediathekTokensfür das Merge-Muster. - Zuerst die übergreifenden Kontrakte implementieren. Bewegungssprache (Dauernskala + 6 Easings), Barrierefreiheits-Baseline (48dp-Trefferflächen, Fokusringe, reduzierte Bewegung), Breakpoints (mobile / tablet auf Root-Ebene berechnet, per Kontext propagiert).
- Dann Leaf-Komponenten bottom-up bauen. Mit den kleinsten beginnen: CardPoster. Sein visueller Kontrakt ist in sich geschlossen, der einzige Seiteneffekt ist der Tap-to-Detail-Callback. (Andere Kartenvarianten wie CardLandscape / CardTop10 sind im Android-Quellcode für andere Views vorhanden, aber außerhalb des Iteration-1-Home-Umfangs.)
- Dann die Abschnitts-Container. RowSection (rendert eine horizontal scrollende Reihe von Karten) · ButtonGroupSection. RowSection steuert die Karten-Varianten-Dispatch aus Serverdaten.
- Dann der Hero-Stack. HeroCardShell-Chrome-Wrapper · die vier Slide-Varianten · HeroCarousel-Außenhülle (verwaltet Auto-Advance, Wischen, Farbauflösung aus der Composition-API) · PageIndicator.
- Dann die bildschirmebene Oberflächen. TopBar · BottomNavigation · Home-Bildschirm-Orchestrator, der alles komponiert.
- Schließlich das Overlay. MenuDrawer — das einzige Modal im Umfang von Iteration 1. Fokus-Falle und reduzierte Bewegung korrekt umsetzen.
- Validieren. Den Barrierefreiheits-Audit der Plattform ausführen (axe im Web, Accessibility Inspector auf iOS, Espresso Accessibility auf Android). Eigene Renderings gegen die in jedem Komponenten-Abschnitt eingebetteten Android-Referenzscreenshots vergleichen.
handover.json veröffentlicht — Komponenten, Klassifikation, Bewegungsskala, Breakpoints, Barrierefreiheits-Baseline, Token-Abdeckung, Governance-Gates. Damit lassen sich automatisierte Implementierungen oder Compliance-Prüfungen bootstrappen. Abschnitts-Anker zur Nachvollziehbarkeit zitieren (z.B. handover.html#hero-carousel).
Komponentenmatrix
Komponenten über die bisherigen Iterationen hinweg. Die Spalte Iter. nennt, in welcher Iteration die Komponente in den Lieferumfang kam: 1 = Home + Burger-Menü (iteration-1.html), 2 = Lesepläne-Komponenten (iteration-2.html). Die Spezifikation ist kanonisch und wächst — die Matrix ergänzt sich pro Iteration.
| Komponente | Klasse | Iter. | BP-bewusst | Animiert | Abschnitt |
|---|---|---|---|---|---|
| Home-Bildschirm | Composite (Orchestrator) | 1 | ✓ | Scroll, Hintergrund-Transition | → |
| TopBar | Sticky | 1 | ✓ | Scroll-Fade | → |
| HeroCarousel | Composite · wischbar (inkl. Shell + Indicator) | 1 | ✓ | Auto-Advance · Drag | → |
| HeroVideoCard | Hero-Karte · Video | 1 | ✓ | Bild-Fade | → |
| HeroPosterCard | Hero-Karte · image-only (2-Asset) | 2 | ✓ | Bild-Fade | → |
| HeroGenericCard | Hero-Karte · custom URL | 2 | ✓ | Bild-Fade · Press | → |
| ButtonGroupSection | Section (Single-AI · Multi · Overflow) | 1 · 2 | ✓ | Press-Feedback | → |
| RowSection | Section · horizontales Scrollen | 1 | ✓ | horizontales Scrollen | → |
| CardPoster | Row-Item · 9:16 | 1 | — | Bild-Fade · Press | → |
| CardLandscape | Row-Item · 16:9 image-only (S/M/L) | 2 | — | Bild-Fade · Press | → |
| CardAvatar | Row-Item · Kreis + Label | 2 | — | Press | → |
| MenuDrawer | Modal · Overlay | 1 | ✓ | Slide · Scrim-Fade | → |
12 Komponenten + 1 Home-Orchestrator über Iteration 1 + 2. Jeder numerische Wert in diesem Dokument stammt aus der tatsächlichen Android-Implementierung — File:Line-Zitate erscheinen neben jedem Token, sodass jeder Wert in Sekunden gegen packages/android verifizierbar ist.
handover.json veröffentlicht — nützlich für automatisiertes Tooling, agenten-getriebene Implementierung und CI-Checks, die den Spezifikations-Kontrakt programmatisch konsumieren müssen.
Home-Bildschirm — Layout & Verhalten
Umfang Iteration 1 CompositeDer Home-Bildschirm ("Mediathek") ist die primäre Oberfläche zur Inhaltsentdeckung der App. Er besteht aus einem vertikalen Scroll von eigenständigen Inhaltsblöcken: einer fixierten Top-Leiste, einem wischbaren Hero-Karussell, einem CTA-Streifen "Die Bibel erkunden" über die volle Breite und einem unbestimmten Stapel horizontal scrollender Inhaltszeilen, die von der Page-Composition-API geladen werden.
semantic.{mode}.background.canvasLayout-Struktur
Der Home-Bildschirm ("Mediathek") ist ein vertikaler Scroll eigenständiger Inhaltsblöcke, orchestriert von CollectionScreen (dem server-getriebenen Home-Composer). Er hat drei Verantwortlichkeiten, die die im Umfang befindlichen Komponenten nicht selbst übernehmen: (1) Abschnittskomposition + Abstände, (2) die Sticky-Top-Leiste mit scroll-getriebenem Hintergrund-Fade, und (3) die Hintergrund-Transition — ein Vollbild-Gradient, der die Farbe des aktiven Hero-Slides aufnimmt und sie über die gesamte Canvas blutet.
Callout-Legende
- Canvas — Basis-Hintergrund
- Hintergrund-Gradient-Bleed (die "Hintergrund-Transition")
- HeroCarousel-Block
- Abschnittsabstand
- ButtonGroupSection
- RowSection × N — server-getrieben
Hintergrund-Transition — formaler Kontrakt
Der Hintergrund des Home "folgt" dem aktiven Hero-Slide. Dies ist die wichtigste visuelle Signatur des Designs; sie verbindet Hero → Home-Canvas zu einer kohärenten Oberfläche. Der Implementierer muss diesen Kontrakt exakt reproduzieren.
| Phase | Was passiert | Quelle |
|---|---|---|
| Initiales Laden | Home wird gerendert. Wenn der erste Hero-Slide eine gradientColor-Hex-Angabe in seinem Composition-Payload trägt, wird einmal eine initiale Farbe emittiert — der Hintergrund-Gradient übernimmt diese Farbe. Andernfalls bleibt die Canvas-Farbe als Basis. | HeroCarousel.kt:176–199 |
| Slide settled | HeroCarousel emittiert onBackgroundColorChange(currentPageBgColor) über seinen Callback. Home speichert ihn als heroBackgroundColorArgb-State. Die nächste Recomposition berechnet gradientSourceColor = heroBackgroundColor neu. GradientBackground zeichnet mit der neuen oberen Farbe neu. | CollectionScreen.kt:100–103, 132–139, 263–266 |
| Nutzer zieht Karussell | HeroCarousel emittiert kontinuierlich während des Ziehens — die Farbe wird interpoliert lerp(fromBgColor, toBgColor, dragProgress). Der Hintergrund-Gradient des Home aktualisiert sich in Echtzeit und morpht zwischen den Stimmungen der zwei Slides. | HeroCarousel.kt:137–159 |
| Seitenwechsel (Auto-Advance) | Wie bei "Slide settled" — der Emit erfolgt auf der neuen currentRealPage, nachdem die Auto-Advance-Animation abgeschlossen ist. | HeroCarousel.kt:165–174 |
| Nutzer scrollt nach unten | Der TopBar-Hintergrund blendet ein (Alpha 0 → 1 über 300 px Scroll). Die Headline-Opazität bleibt bei 1. Der Gradient-Bleed (unter der TopBar) bleibt sichtbar, bis der Nutzer darüber hinausscrollt — es gibt keinen separaten scroll-getriebenen Fade auf dem Gradient selbst. | CollectionScreen.kt:141–152 |
| Theme-Wechsel (Dunkel ↔ Hell) | Sowohl baseDarkColor als auch canvasBg werden neu berechnet (heller Modus verwendet background.surface, dunkler verwendet background.canvas). Der Gradient wird neu gemischt. | CollectionScreen.kt:95–97, 327 |
Hintergrund-Gradient — 6 Stufen, vollständig spezifiziert
| Stop-Anteil | Farbe | Hinweise |
|---|---|---|
0.0 | blendOver(gradientColor, canvas, α 0.8) | Oberer Bildschirmrand — stärkster Hero-Farbeinfluss. |
headerStopFraction | blendOver(gradientColor, canvas, α 0.8) | Gleiche Farbe wie 0.0 — hält den oberen Farbton flach unter der TopBar. headerStopFraction = stickyHeaderHeight / screenHeight. |
0.3 | blendOver(gradientColor, canvas, α 0.68) = 0.8 × 0.85 | Beginnt zur Canvas auszublenden. |
0.5 | blendOver(gradientColor, canvas, α 0.48) = 0.8 × 0.6 | Mitte des Bildschirms — halb gemischt. |
0.75 | blendOver(gradientColor, canvas, α 0.24) = 0.8 × 0.3 | Letzter getönter Stop. |
1.0 | canvas (exakt, α 0) | Unten — konvergiert exakt in den Seitenhintergrund, keine Naht. |
ColorTransformations.kt:35 (pageGradientStops) · blendOver(source, destination, sourceAlpha) in ColorTransformations · blendAlpha = 0.8f macht den oberen Bereich "dominant hero, aber nicht reines Hero" · Stop-Kurve identisch zur color-engine (lib/palette.js computeGradientStops), die die per-Item-Paletten der Page-API erzeugt.
Verhalten
Der Server liefert eine collection.sections-Liste. CollectionRenderer rendert sie in Reihenfolge mit LazyColumn und verticalArrangement = Arrangement.spacedBy(section.verticalSpacing). Jeder Abschnitt ist eines von: HeroCarouselSection, ButtonGroupSection, RowSection (POSTER_PORTRAIT im Umfang von Iteration 1). Außerhalb des Umfangs liegende Abschnittstypen (FEATURED, TOP10, AVATAR) sind aktuell im Quellcode vorhanden, werden aber im Iteration-1-Home nicht gerendert.
Live-Beispiel aus https://page-api.beta.bibeltv.de/v1/page/home (Stand Mai 2026) — die Iteration-1-Produktions-Payload trägt genau diese 5 Komponenten in dieser Reihenfolge:
slider_heromit 2card_hero_media-Slides (→ HeroCarousel mit HeroVideoCard)group_buttonmit 1 Button "Die Bibel erkunden" (→ ButtonGroupSection im Single-Item-AI-CTA-Modus)slidermit 3card_poster_small-Karten (→ RowSection mit CardPoster)slidermit 3card_poster_medium-Kartenslidermit 3card_poster_small-Karten
Was das für Iteration 1 bedeutet: die Agentur muss nur die zwei Karten-Typen card_hero_media und card_poster_* ausliefern, plus die Single-Item-AI-CTA-Variante der ButtonGroupSection. Andere Karten-Typen (verse, featured, livestream, landscape, top10, avatar) existieren im Composition-Modell und im Android-Renderer, werden aber von dieser Payload nicht angefordert und sind explizit verschoben. Der Multi-Item-/Overflow-Branch der ButtonGroupSection muss trotzdem implementiert werden — er ist der dokumentierte Vertrag und kann in einer zukünftigen Payload-Variante aktiviert werden ohne Code-Änderung.
Die TopBar wird in einer separaten Box bei zIndex(10f) gerendert, NICHT innerhalb der LazyColumn. Das bedeutet, sie bleibt am oberen Rand geklebt, auch wenn der Inhalt darunter scrollt. Ihr Hintergrund ist der scroll-getriebene Fade (siehe TopBar).
Die System-Statusleiste wird per window.statusBarColor = TRANSPARENT auf transparent gezwungen; isAppearanceLightStatusBars spiegelt das aktuelle Design, sodass die System-Icons lesbar bleiben. Padding für die Höhe der Statusleiste wird innerhalb der TopBar-Komponente hinzugefügt (nicht als Window-Inset auf der Bildschirm-Wurzel), sodass der Gradient-Bleed dahinter durchgreift.
Iteration 1: ja, Pull-to-Refresh ist über PullToRefreshBox verdrahtet. Löst onRefresh aus, das den Composition-Payload erneut lädt. Spinner-Farbe folgt dem Design.CollectionScreen.kt:160–164
Dimensionen
| Eigenschaft | Mobil | Tablet | Token |
|---|---|---|---|
| Horizontales Seitenpadding | 16dp | 32dp | views.home.layout.contentPaddingHorizontal{Mobile,Tablet} |
| Oberes Inhalts-Padding | 8dp | 16dp | views.home.layout.contentPaddingTop{Mobile,Tablet} |
| Abschnittsabstand (zwischen Abschnitten) | 24dp | 32dp | views.home.layout.sectionGap{Mobile,Tablet} |
| Unteres Inhalts-Padding | 24dp | 32dp | views.home.layout.contentPaddingBottom{Mobile,Tablet} |
| Karten-Abstand innerhalb Reihe | 16dp | 16dp | views.home.layout.rowCardSpacing{Mobile,Tablet} |
| Höhe des Sticky-Headers | statusBar + 56dp | statusBar + 56dp | collectionView.headerHeightDp + System-Inset |
| Hintergrund-Gradient — Blend-Alpha | 0.8 | 0.8 | collectionView.gradientBlendAlpha |
Barrierefreiheit
- Der Home ist eine vertikale Scroll-Region — Hilfstechnologien kündigen jeden Abschnitt nacheinander an.
- Der Hintergrund-Gradient ist rein dekorativ — er trägt keine semantische Bedeutung.
- Pull-to-Refresh kündigt "Aktualisieren" beim Auslösen und "Aktualisiert" bei Erfolg an.
- Die Statusleiste verwendet das passende Erscheinungsbild (helle Icons im dunklen Design, dunkle Icons im hellen Design) für die Lesbarkeit.
Audit-Notizen
Keine Regressionen erkannt. Alle Abschnittsabstände werden über views.home.layout.*-Tokens gelesen (Spec 083). Der Hintergrund-Gradient verwendet das tokenisierte collectionView.gradientBlendAlpha = 0.8f. Die 5 Stop-Anteile des Gradients (0.0, headerStopFraction, 0.3, 0.5, 1.0) und die kumulativen Alpha-Multiplikatoren (×0.85, ×0.6, ×0.3) sind Laufzeit-Layoutkonstanten — sie sind zurecht nicht tokenisiert, da sie die Kurvenform definieren, nicht einen Designwert.
Ein Hinweis für Implementierer: Die Hintergrund-Gradient-Logik liegt in einem privaten Composable innerhalb CollectionScreen.kt. Falls iOS/Web die Formel über die Design-Tokens-API teilen möchten, könnten die 5 Stop-Anteile zu views.home.backgroundGradient.stops (ein Array) befördert werden. Für Parität nicht erforderlich — die Mathematik ist klein genug, um sie neu zu implementieren.
Verhalten
Bei der ersten Navigation zum Home: Page-Composition-Payload laden, TopBar sofort rendern (Skeleton-Hero + Skeleton-Reihen darunter), dann Inhalt rendern, sobald er aufgelöst wird. Das Hero-Karussell beginnt erst mit dem Auto-Advance, nachdem das erste Slide-Bild aufgelöst ist.
Vertikales Scrollen blendet die Deckkraft des TopBar-Logos von 1 → 0 über die ersten component.btvTopBar.scrollFadeDistanceDp = 120dp des Scrolls aus und lässt die Cast- und Konto-Icons vollständig sichtbar. Die Hintergrundfarbe der Leiste blendet im selben Bereich von transparent zu semantic.{mode}.background.canvas.
Das Antippen des Home-Tabs, während Home bereits aktiv ist, scrollt den Inhalt mit der plattformüblichen Smooth-Scroll-Animation (≤300ms) nach oben.
Seit Iteration 2 implementiert: PullToRefreshBox (Material 3) umschließt den Inhalt; die Geste löst onRefresh aus und der Indikator verschwindet, sobald der Page-Payload nicht mehr im Loading-Zustand ist.CollectionScreen.kt:157–166
Wenn der Page-Composition-Payload fehlschlägt: TopBar + zentrierter Text "Inhalt konnte nicht geladen werden" mit einem Wiederholungs-Button rendern. Keine Skeletons.
Composition-API-Kontrakt
Der Bildschirm konsumiert den Page-Payload (GET /api/v1/pages/{slug} → PlaylistCollection):
| Feld | Typ | Bedeutung |
|---|---|---|
id | String | Seiten-Slug (z. B. home, mediathek, leseplan). |
title | String? | Optionaler Seitentitel. |
sections | List<Section> | Servergesteuerte Abschnittssequenz; pro Abschnitt siehe den jeweiligen Komponenten-Kontrakt. |
appearance.gradientColorDark / gradientColorLight | String? (Hex) | Quellfarbe des Hintergrund-Gradient-Bleeds pro Modus. Hat Vorrang vor der extrahierten Hero-Farbe (resolvePageGradientSource); fehlt der Wert, ist die extrahierte Hero-Farbe der Fallback, sonst Canvas. |
appearance.backgroundColorDark / backgroundColorLight | String? (Hex) | Seitenhintergrund pro Modus (Basis, TopBar-Fade-Ziel und Gradient-Konvergenzpunkt zugleich). Fallback: Token bg.canvas (#080D16 / #F8F9FA). Vom Server aus PAGE_BACKGROUND (assemblyService) befüllt — muss mit dem Token und der color-engine-Canvas übereinstimmen. |
PlaylistCollectionModels.kt (CollectionAppearance) · CollectionScreen.kt:98–110, 133–140 · playlist-api/src/types/playlist.ts
Referenz




TopBar
Umfang Iteration 1 StickyPersistente Leiste, die oben am Home- (und anderen Top-Level-)Bildschirmen verankert ist. Beherbergt das Marken-Logo, einen Chromecast-Trigger und den Konto-/Burger-Button, der den MenuDrawer öffnet.
Visuelle Spezifikation
Aufbau
Sticky-Leiste, oben am Home-Bildschirm verankert. Beherbergt das Marken-Logo (links), Chromecast-Trigger (rechts) und das Konto-/Burger-Icon, das den MenuDrawer öffnet. Der Hintergrund blendet aus transparent ein, sobald der Nutzer die Seite darunter scrollt.
Callout-Legende
- Header-Container — sticky, scroll-getriebener Hintergrund
- Statusleisten-Inset — system-gestellt
- Marken-Logo (BtvLogo) — links
- Cast-Icon (Chromecast-Trigger)
- Konto-/Menü-Icon — öffnet Burger-Drawer
- Scroll-getriebener Hintergrund-Fade
Dimensionen — alle Tokens, keine Inline-Werte
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Leistenhöhe | 56dp | component.{mode}.collectionView.headerHeightDp | MediathekTokenModels.kt |
| Logohöhe | 22dp | component.{mode}.collectionView.headerLogoHeightDp (oder Äquivalent primitives.spacing.Space5_5) | MediathekTokenModels.kt |
| Linkes Padding | 16dp | component.{mode}.collectionView.headerLeftPadding = semantic.spacing.{bp}.gapDefault | CollectionScreen.kt:283 |
| Rechtes Padding | 8dp | component.{mode}.collectionView.headerRightPadding = semantic.spacing.{bp}.gap2xs | CollectionScreen.kt:283 |
| Action-Icon-Abstand | 0dp (Icons berühren sich) | — Sentinel-Null, exception-getaggt | CollectionScreen.kt:293 |
| Icon-Trefferfläche | 48dp × 48dp | Material 3 IconButton-Default · erfüllt a11y-Minimum | a11y-Baseline |
| z-index | 10 | component.{mode}.collectionView.headerZIndex | CollectionScreen.kt:274 |
| Scroll-Fade-Divisor | 300 (px) | component.{mode}.collectionView.headerFadeScrollDivisor · TokenExempt(behavioral) | CollectionScreen.kt:147 |
Zustände
- Standard (oben im Scroll)
- Hintergrund der Leiste vollständig transparent · Logo und Icons vollständig sichtbar.
- Gescrollt
- Hintergrund-Alpha steigt linear mit dem Scroll. Bei
scrollOffset ≥ 300ist der Hintergrund vollständig undurchsichtigsemantic.{mode}.background.canvas. - Gedrückt (Icon)
- Standard-Material-3-IconButton-Ripple innerhalb einer 48dp-kreisförmigen Trefferfläche · Tint
semantic.{mode}.text.primarybeiprimitives.opacity.scale15. - Deaktiviert (Cast)
- Wenn kein Cast-Gerät verfügbar · Icon-Alpha sinkt auf
primitives.opacity.scale40, IconButton nicht klickbar.
Verhalten
- Logo-Akzent — wird pro aktivem Hero-Slide über den Color-Emit-Callback des Karussells neu berechnet. Der Akzent wird als
logoAccentColor-Parameter anBtvLogoübergeben. - Logo-Tap — in der aktuellen Home-Implementierung nicht an einen Callback gebunden (das Logo ist im Home-Tab dekorativ).
- Cast-Tap — aktuell No-Op (
onClick = { }) — vom Implementierer an den Cast-Picker der Plattform anzubinden. - Menü-Tap — ruft
onBurgerMenuOpenauf, das der bildschirmebenen Orchestrator an den MenuDrawer weiterleitet. - Statusleisten-Behandlung —
window.statusBarColorwird in einemSideEffectauf transparent gezwungen;isAppearanceLightStatusBars = isLightMode, sodass die System-Icons zum Design passen.
Barrierefreiheit
- Beide Action-Icons haben explizite
contentDescription("Streamen", "Konto"). Deutsch ist die einzige Sprache in Iteration 1. - Trefferflächen sind ≥ 48dp dank des Standard-Material-3-IconButton.
- Der Scroll-Fade der Leiste hat keinen Einfluss auf die Barrierefreiheit — Inhalt ist immer erreichbar; nur die visuelle Hintergrund-Opazität ändert sich.
- Fokusreihenfolge: Logo → Cast → Konto/Menü.
Audit-Notizen
Keine Regressionen erkannt. Alle Dimensionen sind token-getrieben; die beiden Verhaltenskonstanten (300f Scroll-Fade-Divisor, 500f scrollOffsetItemMultiplier) sind korrekt als @TokenExempt(behavioral) getaggt, da es Math-Konstanten sind, keine Designwerte.
Offene Lücke: Das onClick des Cast-Icons ist eine No-Op — der Implementierer muss den Cast-Picker der Plattform verdrahten (Android MediaRouter / iOS AVRoutePickerView / Web TBD).
Zustände
- Standard
- Logo bei voller Deckkraft; Hintergrund der Leiste transparent.
- Gescrollt
- Logo-Deckkraft proportional zu
min(1, scrollY / 120dp), linear auf1 - xabgebildet. Hintergrund-Deckkraft der Leiste entspricht. - Gedrückt (Icon)
- Jedes Icon zeigt ein kreisförmiges Press-Feedback bei
opacity.scale15, getintet mitsemantic.{mode}.text.primary, ≤56dp Durchmesser. - Deaktiviert
- Cast-Icon, wenn kein Cast-Gerät verfügbar:
opacity.scale40, kein Press-Feedback.
Verhalten
- Logo-Tap — navigiert zur Wurzel des Home-Tabs. Wenn bereits auf Home, scrollt nach oben.
- Cast-Tap — öffnet den Standard-Cast-Picker der Plattform. iOS:
AVRoutePickerView. Android: Cast-SDKMediaRouteButton. Web: in Iteration 1 nicht implementiert. - Konto-/Menü-Tap — öffnet den MenuDrawer. Mobil: Vollbild-Overlay schiebt von rechts ein. Tablet: Seitenpanel schiebt mit Scrim von rechts ein.
Bewegung
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Scroll | Logo-Deckkraft, Leisten-Hintergrund-Deckkraft | kontinuierlich (1:1 mit Scroll) | linear |
| Icon-Druck | Press-Feedback-Deckkraft | 120ms in / 200ms out | ease-out |
| MenuDrawer öffnen | siehe MenuDrawer | — | — |
Interaktion & UX
- Die Leiste ist nicht erhöht: Sie rendert keinen Schatten. Visuelle Trennung entsteht ausschließlich durch den Hintergrund-Fade.
- Die Leiste muss lesbar bleiben, wenn der Inhalt darunter hell ist; deshalb bleiben die Icons während des Scrollens bei voller Deckkraft.
- Long-Press, Doppeltipp und Rechtsklick haben in Iteration 1 kein definiertes Verhalten.
Barrierefreiheit
- Logo: Rolle image; Label "ProtoBible" (ohne Punkt). Antippbar, daher zusätzlich Rolle button.
- Cast: Rolle button; Label "Cast" (Deutsch: "Übertragen"); Zustand "verfügbar" / "nicht verfügbar".
- Konto/Menü: Rolle button; Label "Menü öffnen" / "Menü schließen" je nach Drawer-Zustand; Zustandsänderungen werden angekündigt.
- Fokusreihenfolge: Logo → (Cast) → Menü. Plattform-Standard-Fokusring verwenden (
semantic.focusRing.{bp}.*). - Der scroll-getriebene Logo-Fade beeinflusst die Sichtbarkeit für Screen Reader nicht — das Logo bleibt in jeder Scrollposition im Accessibility-Tree.
Verwendete Tokens
| Pfad | Ebene |
|---|---|
semantic.{mode}.background.canvas | semantic |
semantic.{mode}.text.primary | semantic |
semantic.icons.{bp}.md | semantic |
semantic.focusRing.{bp}.* | semantic |
component.btvTopBar.horizontalPaddingMobile | component |
component.btvTopBar.horizontalPaddingTablet | component |
component.btvTopBar.verticalPaddingMobile | component |
component.btvTopBar.verticalPaddingTablet | component |
component.btvTopBar.scrollFadeDistanceDp | component |
component.btvTopBar.actionSpacing | component |
component.btvLogo.heightMobile | component |
component.btvLogo.heightTablet | component |
primitives.opacity.scale15 | primitive |
primitives.opacity.scale40 | primitive |
Referenz

Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/MediaCard.kt (TopBar enthalten), packages/android/app/src/main/java/com/protobible/android/navigation/MainNavigation.kt. Der scroll-getriebene Fade wird im Modifier.onScrollChange des Eltern-Bildschirms berechnet und als State-Wert propagiert.
Composition-API-Kontrakt
Keiner. Die TopBar ist rein client-seitig (Logo, Suche, Menü-Trigger) und konsumiert keine Felder aus dem Composition-Payload.
HeroCarousel
Umfang Iteration 1 Composite · wischbarWischbares Karussell am oberen Rand des Home, das zwischen Hero-Kartenvarianten paginiert (HeroVideoCard, HeroBibleVerseCard, HeroFeaturedCard, HeroLiveStreamSlide). Es führt einen Auto-Advance in fester Kadenz aus, bis der Nutzer interagiert, und gibt dann die Kontrolle dauerhaft ab. Bei wechselnder fokussierter Seite emittiert das Karussell die dominante Hintergrundfarbe und eine Logo-Akzentfarbe, die im Composition-API-Payload des Slides mitgeführt werden — der Home-Bildschirm konsumiert dieses Signal, um den Gradient-Bleed hinter dem Karussell zu steuern und die Marke in der TopBar neu einzufärben.
Visuelle Spezifikation
Aufbau
Wischbares Karussell am oberen Rand des Home. Beherbergt Hero-Kartenvarianten (nur HeroVideoCard in Iteration 1). Auto-Advance, bis der Nutzer es berührt, dann dauerhafte Kontrolle. Bei Seitenwechseln emittiert es die Hintergrundfarbe des aktuellen Slides und eine Logo-Akzentfarbe nach oben an den Home-Orchestrator (siehe Home-Bildschirm-Hintergrund-Transition).
Callout-Legende
- HorizontalPager — Virtual-Page-Wrapper
- Aktueller Slide — erhöhte Seite
- Vorheriger / nächster Seiten-Peek
- Wischgeste — manuelle Kontrolle
- PageIndicator — Punkte
- Color-Emit — nach oben an Home-Orchestrator
Dimensionen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Seitenverhältnis (Mobil) | 0.75 (3:4 Hochformat) | component.heroCarousel.aspectRatioMobile | MediathekTokenModels.kt |
| Seitenverhältnis (Tablet) | 0.394 gespeichert als H/W → angewandt als 1f/0.394 ≈ 2.54:1 | component.heroCarousel.aspectRatioTablet | MediathekTokenModels.kt |
| Eckenradius | Token | component.{mode}.heroCarousel.cornerRadius | MediathekTokenModels.kt |
| Content-Padding | Token | component.{mode}.heroCarousel.contentPadding | MediathekTokenModels.kt |
| Abstand zwischen Seiten | Token | component.{mode}.heroCarousel.pageSpacing | MediathekTokenModels.kt |
| Rahmen | 1dp · weiß α 0.15 (dunkel) / ähnlich hell | component.heroCarousel.borderWidth + borderColor | MediathekTokenModels.kt |
| Elevation (nur dunkel) | 16dp | component.heroCarousel.elevation | MediathekTokenModels.kt |
| Indicator-Punktgröße (inaktiv) | Token | component.heroCarousel.indicatorDotSize | MediathekTokenModels.kt |
| Indicator-Auswahlbreite | Token | component.heroCarousel.indicatorDotSelectedWidth | MediathekTokenModels.kt |
| Indicator-Punktabstand | Token | component.heroCarousel.indicatorDotSpacing | MediathekTokenModels.kt |
| Auto-Advance-Intervall | 3000ms | autoAdvanceDuration-Parameter (Default 3000) | HeroCarousel.kt:77 |
| Auto-Advance-Animation | Token | component.heroCarousel.autoAdvanceAnimationDurationMs (≈ 600ms) | MediathekTokenModels.kt |
| Page-Indicator-Animation | Token (≈ 200ms) | component.heroCarousel.pageIndicatorAnimationDurationMs | MediathekTokenModels.kt |
| Inaktiver Punkt-Alpha | 0.3 | component.heroCarousel.pageIndicatorInactiveAlpha | MediathekTokenModels.kt |
| Color-Emit-Schwellenwert | 300ms (Verhalten) | component.heroCarousel.colorEmitThresholdMs · TokenExempt | MediathekTokenModels.kt |
Zustände
- Auto-Advance
- Der Pager schreitet alle 3000ms voran. Animation 600ms mit dem Standard-Easing des Karussells.
- Nutzer-gesteuert
- Erste Berührung bricht Auto-Advance für die Sitzung dauerhaft ab. Der Nutzer wischt frei zwischen Seiten.
- Ziehend
- Die Seite folgt der Fingerposition. Farben emittieren kontinuierlich, interpoliert zwischen aktueller und Zielseite.
- Settling
- Snap-Animation abgeschlossen. Finaler Color-Emit auf der neuen aktuellen Seite.
- Einzelner Slide
- Wenn
slides.size == 1: kein Virtual-Page-Wrapping, keine Indicator-Punkte, kein Auto-Advance, nur eine statische Karte. - Leer
- Wenn
slides.isEmpty(): Das Karussell rendert nichts.
Verhalten
- Virtual Paging — Der Pager wird bei
virtualPageCount / 2initialisiert, sodass der Nutzer praktisch unbegrenzte Vorwärts- und Rückwärts-Swipes hat.virtualToReal()bildet modulo auf einen echten Slide-Index ab. Slide-Inhalt wird auf den echten Index gekeyed. - Farbextraktion — Jeder Slide trägt vorab extrahierte Farben aus dem Composition-Payload (per Spec 083). Das Karussell führt KEINE Laufzeit-Palettenextraktion durch. Es orchestriert nur, welche Farbe wann emittiert wird.
- Farb-Caching — Extrahierte Farben werden in
mutableStateMapOf<Int, Color>gekeyt auf den echten Seitenindex gespeichert. Sobald ein Slide seine Farbe emittiert hat, treffen nachfolgende Besuche den Cache. - Dedup — Beide Emit-Pfade prüfen gegen
lastEmittedBgColor, um doppelte Callbacks während Zustandsübergängen zu vermeiden. - Settle vs. Drag-Emit — Drag emittiert kontinuierlich (interpoliert); Settle emittiert einmalig beim Seitenwechsel. Der 300ms-Schwellenwert verhindert, dass Settle einen Drag-Emit unmittelbar nach einem schnellen Swipe überschreibt.
Barrierefreiheit
- Der Pager ist eine Region mit Rolle region; Slide-Wechsel ankündigen ("Slide 2 von 4: …").
- Auto-Advance pausiert, wenn der Nutzer mit einem fokussierbaren Element innerhalb des Slides interagiert (Compose-Default). Er stoppt auch dauerhaft beim ersten manuellen Wischen.
- Reduzierte Bewegung: Gemäß Bewegungssprache soll Auto-Advance dauerhaft pausieren, wenn die System-Einstellung für reduzierte Bewegung aktiv ist. Der Implementierer muss
Settings.Global.ANIMATOR_DURATION_SCALE/UIAccessibility.isReduceMotionEnabled/prefers-reduced-motionbeobachten. - Indicator-Punkte sind dekorativ — der Pager selbst übernimmt die Positionsansage.
Audit-Notizen
Keine Regressionen erkannt. Alle Dimensionen sind token-getrieben. Der 1000×-Multiplikator für Virtual Paging ist ein beabsichtigter Trick, um Grenzlogik zu vermeiden; keine magische Zahl, die tokenisiert werden müsste.
Hinweis für Implementierer: Die Auto-Advance-Pause-bei-Touch ist dauerhaft für die Sitzung. Manche Produkte setzen stattdessen nach 5s Idle fort. Falls das Design Resume-on-Idle wünscht, ist das eine zukünftige Spezifikation — aktuell nicht im Umfang.
Zustände
- Idle (Default)
- Eine Seite fokussiert; Pager bei ganzzahligem Seitenoffset; Auto-Advance-Schleife läuft in
autoAdvanceIntervalMs-Kadenz. - Auto-Advance
- Programmatisches Scrollen zur nächsten Seite läuft. Ein internes Flag unterdrückt die "Nutzer hat interagiert"-Erkennung, sodass dieser Übergang Auto-Advance nicht stoppt.
- Nutzer ziehend
- Der Zeiger des Nutzers ist unten und der Pager wird gezogen. Auto-Advance ist pausiert. Beim Loslassen setzt sich der Pager auf die nächste Seite.
- Nutzer-settled
- Pager ist nach Nutzerinteraktion auf einer neuen Seite zur Ruhe gekommen. Auto-Advance ist nun für den Rest der Sitzung dauerhaft deaktiviert — er nimmt nicht wieder auf.
- Farbe wird aufgelöst
- Das Bild der neu fokussierten Seite hat seine Palette im Composition-Payload noch nicht geliefert. Die zuvor emittierte Farbe bleibt bestehen, bis die Extraktion abgeschlossen ist.
- Einzelner Slide
- Wenn
slides.length === 1: Der Pager rendert eine Seite, Auto-Advance startet nicht, und der Page-Indicator wird unterdrückt (siehe PageIndicator). - Leer
- Wenn
slides.length === 0: Das Karussell rendert nicht. Der Home-Bildschirm behandelt seinen eigenen Leer-/Fehlerzustand über dieser Komponente.
Verhalten
Der Pager mountet mit seiner initialen Seite auf die Mitte einer virtuell erweiterten Seitenanzahl (echte Slides × 1000) gesetzt, sodass Auto-Advance immer nach vorne scrollen kann, ohne jemals eine Grenze zu erreichen. Der sichtbare Seitenindex wird berechnet als virtualPage modulo realPageCount. Wenn der erste Slide eine vordefinierte gradientColor in seinem Daten-Payload trägt, emittiert das Karussell diese Farbe synchron beim Mount, bevor irgendeine Bildextraktion läuft.
Wenn slides.length > 1 und Auto-Advance aktiviert ist, schläft eine Hintergrundschleife für component.heroCarousel.autoAdvanceIntervalMs = 3000ms, dann animiert sie einen Scroll zur nächsten Seite über component.heroCarousel.autoAdvanceAnimationDurationMs = 600ms mit einer ease-in-out-Kurve. Die Schleife setzt und löscht ein internes "Auto-Advance"-Flag um den animierten Scroll, sodass die Nutzer-Interaktions-Erkennung ihn ignoriert.
Jedes vom Nutzer initiierte Ziehen des Pagers (erkannt als laufender Scroll, während nicht Auto-Advance läuft) setzt ein einseitiges "Nutzer hat interagiert"-Flag. Auto-Advance nimmt für den Rest der Sitzung niemals wieder auf, selbst wenn der Nutzer für längere Zeit untätig bleibt. Programmatische Scrolls (z.B. Tab-Reta) flippen dieses Flag nicht.
Jeder Slide trägt vorab extrahierte Farben, die von der Composition-API geliefert werden. Das Karussell liest diese direkt aus dem Payload des Slides — keine Laufzeit-Extraktion erfolgt auf dem Home-Bildschirm. Das Karussell emittiert zwei Werte über Callbacks:
onBackgroundColorChange(color)— die dominante Basisfarbe, verwendet für den Home-Bildschirm-Gradient-Bleed.onColorsChange({ backgroundGradient, logoAccent })— sowohl die Basisfarbe als auch eine lebendige Akzentfarbe für die Markentönung.
Der Emit wird unterdrückt, wenn die neue Farbe der zuletzt emittierten entspricht (Deduplikation) und wird ansonsten entweder (a) kontinuierlich während des Ziehens emittiert, durch lineare Interpolation zwischen den Paletten der Von- und Zu-Seite, gekeyed auf den Offset-Anteil des Pagers, oder (b) einmal emittiert, wenn ein Settle innerhalb component.heroCarousel.colorEmitThresholdMs = 300ms nach dem Seitenwechsel abgeschlossen ist. Dieses Fenster verhindert, dass veraltete Lesungen die Farbe des fokussierten Slides überschreiben.
Der Page-Indicator ist an den echten Seitenindex virtualPage modulo realPageCount gebunden. Es gibt keinen separaten Animationszustand — die eigene Punktbreiten-Animation des Indicators übernimmt den visuellen Übergang (siehe PageIndicator).
Das Bild jedes Slides wird mit aktiviertem Crossfade angefordert. Bis das Bild aufgelöst ist, rendert der Slide die Kartenhülle mit der vordefinierten gradientColor des Slides (falls vorhanden) oder der Marken-Primärfarbe als Hintergrund. Kein Skeleton wird innerhalb der Karte angezeigt.
Bei einem Konfigurationswechsel zwischen Hochformat/Mobil und Querformat/Tablet werden Seitenverhältnis und Slide-Variante neu bewertet. Der aktuell fokussierte echte Seitenindex bleibt erhalten. Der Color-Emit wird für die fokussierte Seite erneut ausgelöst, sobald sich die Recomposition stabilisiert.
Das erneute Betreten des Home-Tabs setzt das "Nutzer hat interagiert"-Flag nicht zurück — Auto-Advance bleibt gestoppt, wenn der Nutzer zuvor gewischt hat. Der Pager behält seine zuletzt fokussierte Seite.
Bewegung
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Auto-Advance zur nächsten Seite | Pager-Scroll-Position | 600ms (component.heroCarousel.autoAdvanceAnimationDurationMs) | ease-in-out (FastOutSlowIn) |
| Nutzer-Drag | Pager-Scroll-Position | kontinuierlich (1:1 mit Finger) | linear |
| Drag-Loslassen / Settle | Pager-Scroll-Position | Plattform-Standard-Fling-Decay | Plattform-Standard |
| Seitenwechsel (fokussiert) | emittierte Hintergrund-/Akzentfarben | kontinuierlich während Drag (lineare Interpolation zwischen Von-/Zu-Farben, gekeyed auf Offset-Anteil) | linear |
| Color-Settle nach Seitenwechsel | emittierte Farbe | Step (einmaliger Emit) innerhalb 300ms (colorEmitThresholdMs) nach Seitenwechsel | n/a |
| Page-Indicator | siehe PageIndicator | — | — |
Interaktion & UX
- Horizontales Wischen — primäre Eingabe. Ein Wisch geht eine Seite vor/zurück. Geschwindigkeitsbasierter Fling ist erlaubt, landet aber auf der nächstgelegenen Seite.
- Tap auf Seite — ruft die Primäraktion des Slides auf (
onSlideClick). Tap wird per Standard-Touch-Slop-Schwellenwert der Plattform vom Drag unterschieden. - Vertikales Scrollen — wird an den Scroller des Home-Bildschirms durchgereicht; das Karussell fängt keine vertikalen Drags ab.
- Long-Press, Doppeltipp, Rechtsklick — kein definiertes Verhalten in Iteration 1.
- Kantenverhalten — es gibt keine sichtbaren Kanten. Die virtuelle Seitenanzahl lässt den Pager in beide Richtungen unbegrenzt zyklisch wirken.
- Auto-Advance ist eine einmalige Affordance — sobald durch Nutzereingabe gestoppt, startet er nicht neu. In Iteration 1 keine "Autoplay fortsetzen"-Steuerung anbieten.
Barrierefreiheit
- Rolle — der Pager exponiert eine Carousel-/List-Semantik mit einem Kind pro echtem Slide (nicht der virtuellen Anzahl). Position ankündigen als "Slide N von M".
- Labels — jede Seite erbt das Barrierefreiheits-Label ihrer Variante (z.B. "Don Matteo · Geister der Vergangenheit · Video"). Das Karussell selbst ist mit "Empfohlene Inhalte" (Deutsch) gelabelt.
- Fokus — Tastatur-/D-Pad-Fokus folgt der fokussierten Seite. Links/Rechts wechseln zwischen Seiten und lösen dieselbe Scroll-Animation wie ein Wisch aus. Der Page-Indicator erhält keinen Fokus.
- Trefferfläche — die gesamte sichtbare Seite ist antippbar; weit über 48dp auf jedem unterstützten Gerät.
- Reduzierte Bewegung — wenn das OS reduzierte Bewegung meldet, muss Auto-Advance beim Mount deaktiviert werden und Seitenwechsel verwenden einen Step-Übergang (keine Scroll-Animation). Color-Emit läuft weiter, sodass der Gradient-Bleed beim manuellen Wischen weiter aktualisiert.
- Screen-Reader-Virtualisierung — die virtuelle Seitenanzahl darf Hilfstechnologien nicht exponiert werden; nur der echte Seitenindex und die echte Seitenanzahl werden angekündigt.
Verwendete Tokens
| Pfad | Ebene |
|---|---|
component.heroCarousel.aspectRatioMobile | component |
component.heroCarousel.aspectRatioTablet | component |
component.heroCarousel.contentPadding | component |
component.heroCarousel.pageSpacing | component |
component.heroCarousel.autoAdvanceIntervalMs | component |
component.heroCarousel.autoAdvanceAnimationDurationMs | component |
component.heroCarousel.colorEmitThresholdMs | component |
semantic.spacing.{bp}.gap2xs (Indicator vertikales Padding) | primitive |
Pro-Karte-Chrome-Tokens (Eckenradius, Rahmen, Schatten) werden über HeroCardShell konsumiert; varianten-spezifische Typografie- und CTA-Tokens werden über die einzelnen Hero-Karten-Komponenten konsumiert.
Referenz




Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/hero/HeroCarousel.kt. Die virtuelle Seitenanzahl (realPageCount × 1000) implementiert das zyklische Gefühl ohne Grenzlogik. Farbwerte werden pro Slide von der Composition-API geliefert (der Slide-Payload enthält ein `preExtractedColors`-Feld mit Hell- und Dunkel-Paletten). Geschwister-Layouts: HeroMobileLayout.kt (Hochformat, 3:4) und HeroTabletLayout.kt (Querformat, ~2.54:1) werden von HeroSlideRenderer basierend auf LocalDeviceMode und Orientierung ausgewählt.
Composition-API-Kontrakt
HeroCarousel rendert die SLIDER_HERO-Komponente aus dem Composition-Payload. Jeder Slide ist ein CompositionCard; der Renderer verteilt anhand card.type (card_hero_media → HeroVideoCard; card_hero + objectType=verse → HeroBibleVerseCard; andere Hero-Typen sind außerhalb des Iteration-1-Umfangs).
Feld auf CompositionComponent | Typ (gemäß CompositionModels.kt) | Hinweise |
|---|---|---|
type |
ComponentType.SLIDER_HERO → Wire-Wert "slider_hero" |
Diskriminator, der diese Payload an das HeroCarousel weiterleitet. CompositionModels.kt:59. |
id |
String |
Stabile Komponenten-Kennung; erscheint in Tracking-Events. |
headlineText |
String? |
Optionaler Abschnittstitel über dem Karussell. Die meisten Home-Payloads lassen dies für Hero weg (das Karussell ist das erste Element auf der Seite). CompositionModels.kt:38. |
cards |
List<CompositionCard> |
Ein Slide pro Element. Die Reihenfolge entspricht der Render-Reihenfolge. Jede Karte trägt ihre eigene Slide-spezifische Palette (colors.{light,dark}), Badge, Button, Inhalt und Tracking — siehe die Abschnitte HeroCard_Video und (folgend) HeroBibleVerseCard für den Feld-Vertrag pro Karte. |
tracking |
ComponentTracking { impression, click } |
Events auf Karussell-Ebene: Impression wird ausgelöst, wenn das Karussell in den Viewport gelangt (einmal pro Sitzung); Click wird beim Tippen auf einen Slide ausgelöst (zusammengefasst unter der Karussell-ID, getrennt vom kartenspezifischen Click-Tracking). CompositionModels.kt:166–190. |
buttons |
List<CompositionButtonGroupItem> = emptyList() |
Auf dem Wire-Modell für alle Komponenten vorhanden, aber von SLIDER_HERO nicht genutzt. Hero führt nie eine Button-Liste — Buttons befinden sich stattdessen auf den einzelnen Karten. |
Color-Emit-Vertrag: Wenn sich die fokussierte Seite ändert, liest das Karussell card.colors.{light,dark}.base und .accent, wählt das aktive Theme und gibt die Werte über onBackgroundColorChange + onColorsChange nach oben weiter. Während des Drag-Vorgangs werden die Werte linear zwischen benachbarten Slides interpoliert. Diese Werte stammen aus der Composition-Payload — keine Laufzeit-Palettenextraktion auf Home. HeroCarousel.kt:118–199.
HeroVideoCard
Umfang Iteration 1 Hero-Slide-Variante BildgeführtEin bildgeführter Hero-Slide, der innerhalb der HeroCardShell gerendert wird. Stellt einen einzelnen redaktionellen Videoinhalt (oder eine Playlist) dar mit Cover-Bild, optionalem Kategorie-Badge, Eyebrow, Titel, optionaler Beschreibung, primärem CTA ("Jetzt ansehen") und einem optionalen sekundären CTA. Der Slide passt sich zwischen einem Portrait-Layout für Mobile (Bild füllt die Karte; Text liegt über einem vertikalen Verlauf am unteren Rand) und einem Landscape-Layout für Tablet (Bild rechts; Text-und-CTA-Spalte links, getrennt durch einen horizontalen Verlauf, der das Bild in eine durch die Komposition gelieferte Basisfarbe überblendet) an.
Visuelle Spezifikation
Aufbau
- 1. Shell — bereitgestellt durch HeroCardShell. Abgerundete Ecken, Rahmen, durch die Komposition gelieferte Basisfarbe für den Verlauf, optionales
onClick-Press-Feedback. Alle nachfolgenden Kinder werden innerhalb der Shell gestapelt. - 2. Cover-Bildebene — füllt die Shell auf Mobile (full bleed); füllt den rechten Anteil
component.heroCarousel.tabletImageWidthFractionauf Tablet. Zentriert beschnitten auf das Portrait- oder Landscape-Seitenverhältnis des Slides. Quelle: über imgix bereitgestellte URL oder Plattform-Bildressource. Lädt mit Cross-Fade-In (siehe Motion). - 3. Gradient-Overlay — liegt über dem Bild. Mobile: vertikaler Verlauf am unteren Rand verankert, geht von transparent oben in die durch die Komposition gelieferte Basisfarbe unten über. Tablet: horizontaler Verlauf am linken Rand verankert, geht von der Basisfarbe an der führenden Kante zu transparent an der nachfolgenden Kante über.
- 4. BtvBadge — API-getriebene Kategorie-Pille — optional. Oben links. Pille, bei der sowohl Farbe als auch Text aus der Composition-Payload stammen (
slide.category) — kein festes Enum. Die Beispiele "Video / Serie / Live" im Dokument sind illustrative Payloads, keine erlaubten Werte. Versteckt sich selbst, wenn der Text null/leer ist. Tokens auscomponent.badge.*; oberhalb des Verlaufs gerendert. Padding innerhalb der Shell:semantic.spacing.{bp}.paddingDefault= 24dp. - 5. Content-Stack — vertikaler Stack innerhalb der Verlaufsregion.
- 5a. Optionaler Eyebrow (Untertitel), einzeilig, sekundäre Textfarbe.
- 5b. Titel, primäre Textfarbe, bis zu N Zeilen (Token
tabletTitleMaxLines) mit Ellipsis. - 5c. Optionale Beschreibung (oder EPG-Kurztext, falls verfügbar), sekundäre Textfarbe, bis zu N Zeilen mit Ellipsis.
- 5d. Primärer CTA —
MediathekButton, TypPrimary, GrößeSmall, vorangestelltes Iconplay_arrow. - 5e. Optionaler sekundärer CTA —
MediathekButton, TypSecondary, GrößeSmall.
Abmessungen
| Eigenschaft | Mobile | Tablet | Token |
|---|---|---|---|
| Karten-Seitenverhältnis | 0.75 (3:4 Portrait, füllt Shell) | 0.394 (≈2.54:1 Landscape) | component.heroCarousel.aspectRatioMobile / aspectRatioTablet |
| Seitenverhältnis Cover-Bild | 15:16 (imgix-Anfrage) | 16:9 (imgix-Anfrage) | — |
| Breitenanteil Cover-Bild | 1.0 | tabletImageWidthFraction | component.heroCarousel.tabletImageWidthFraction |
| Breitenanteil Content-Spalte | 1.0 | tabletContentWidthFraction | component.heroCarousel.tabletContentWidthFraction |
| Breitenanteil Verlaufsregion | 1.0 (vertikal) | tabletGradientWidthFraction (horizontal) | component.heroCarousel.tabletGradientWidthFraction |
| Content Top-Padding (oberhalb der Verlauf-Textregion) | 140dp | — | component.{mode}.heroVideo.contentTopPadding |
| Content seitliches Padding | 16dp | 20dp | semantic.spacing.{bp}.paddingDefault (mobile 16 · tablet 20 · desktop 24) |
| Content unteres Padding | 16dp | 20dp | semantic.spacing.{bp}.paddingDefault (mobile 16 · tablet 20 · desktop 24) |
| Eyebrow Schriftgröße | 12sp | 14sp | component.{mode}.heroVideo.mobileDescFontSize / tabletDescFontSize |
| Titel Schriftgröße | 16sp | 26sp | component.{mode}.heroVideo.mobileTitleFontSize / tabletTitleFontSize |
| Titel Zeilenhöhe | 1.15× Schriftgröße (Standard) | 28sp | component.{mode}.heroVideo.tabletTitleLineHeight |
| Titel Schriftstärke | SemiBold (Mobile) | Medium (Tablet) | component.{mode}.heroVideo.mobileTitleFontWeight / tabletTitleFontWeight |
| Beschreibung Schriftgröße | 12sp | 14sp | component.{mode}.heroVideo.mobileDescFontSize / tabletDescFontSize |
| Beschreibung Zeilenhöhe | — | 18sp | component.{mode}.heroVideo.tabletDescLineHeight |
| Beschreibung Schriftstärke | Normal | Medium | component.{mode}.heroVideo.mobileDescFontWeight / tabletDescFontWeight |
| CTA primärer Icon-Größe | 14dp | 14dp | component.{mode}.heroVideo.iconSize |
| Abstand Eyebrow → Titel | 4dp | 4dp | semantic.spacing.{bp}.gap4xs / gap3xs |
| Abstand Titel → Beschreibung | 4dp | 4dp | semantic.spacing.{bp}.gap4xs / gap2xs |
| Abstand Content → primärer CTA | 12dp | — | semantic.spacing.{bp}.gapSm |
| Abstand primärer → sekundärer CTA | 8dp | 8dp | semantic.spacing.{bp}.gap2xs |
Mobile Gradient-Overlay (vertikal, am unteren Rand verankert)
Acht Stops im Dark Mode (gerendert wenn isLightMode = false); sieben Stops im Light Mode. Die Basisfarbe C ist die durch die Composition-API in der Slide-Payload gelieferte Verlaufsbasis.
| Position | Dark Mode (8 Stops) | Light Mode (7 Stops) |
|---|---|---|
| 0% | transparent | transparent |
| 8% | C @ alpha 0.15 primitives.opacity.scale15 | — |
| 15% | — | C @ alpha 0.05 primitives.opacity.scale5 |
| 22% | C @ alpha 0.45 primitives.opacity.scale45 | — |
| 35% | — | C @ alpha 0.50 = primitives.opacity.scale50 |
| 38% | C @ alpha 0.72 primitives.opacity.scale70 (gerundet) | — |
| 50% | — | C @ alpha 0.82 primitives.opacity.scale80 (gerundet) |
| 52% | C @ alpha 0.90 primitives.opacity.scale90 | — |
| 60% | — | C @ alpha 0.95 primitives.opacity.scale95 |
| 62% | C @ alpha 0.97 primitives.opacity.scale95 (gerundet) | — |
| 68% | — | C (deckend) |
| 70% | C (deckend) | — |
| 100% | C (deckend) | C (deckend) |
Jede Zeile mit Alpha < 1.0 ist die durch die Komposition gelieferte Basisfarbe multipliziert mit der angegebenen Opazität. Die Opazitätswerte sind auf die primitives.opacity.*-Skala gemappt.
Tablet Gradient-Overlay (horizontal, am führenden Rand verankert, 4 effektive Stops + Wiederholungen)
| Position | Farbe |
|---|---|
| 0% | C (deckend) |
| 60% | C (deckend, Plateau) |
| 70% | C @ alpha 0.75 primitives.opacity.scale75 |
| 80% | C @ alpha 0.50 = primitives.opacity.scale50 |
| 90% | C @ alpha 0.25 = primitives.opacity.scale25 |
| 100% | transparent |
Hit-Targets
Die gesamte Shell ist ein Hit-Target (vollständiges Slide-Tippen = onClick). Jedes interaktive Kind darin (primärer CTA, sekundärer CTA, Kategorie-Badge sofern mit Aktion belegt) muss mindestens 48dp × 48dp erreichen. Polstern Sie kleinere Steuerelemente transparent auf, um das Minimum zu erreichen.
Aufbau
Wireframe-Darstellung der HeroVideoCard in den tatsächlichen Proportionen. Mobile (3:4 Portrait) oben, Tablet (~2.54:1 Landscape, Split-Panel-Layout) unten. Die Nummern verweisen auf die Legende rechts; jeder genannte Wert hat eine file:line-Referenz auf den Android-Quellcode.
Mobile Callout-Legende
- Äußerer Container — Karten-Chrome
- Bildebene — Full-bleed-Cover
- BtvBadge — API-getriebene Kategorie-Pille
- Gradient-Overlay — am unteren Rand verankert, mehrstufig vertikal
- Eyebrow — Serie / Sendungsname
- Titel — bis zu 2 Zeilen (Mobile)
- Beschreibung — bis zu 3 Zeilen (Mobile)
- Primärer CTA "Jetzt ansehen"
Tablet Callout-Legende
- Äußerer Container — Karten-Chrome
- Bildebene — am nachfolgenden Rand verankert
- Gradient-Overlay — horizontal, blendet Bild in Canvas-Farbe über
- Content-Panel — am führenden Rand verankert über Canvas-Color-Bleed
- BtvBadge — API-getriebene Kategorie-Pille
- Eyebrow — Serie / Sendungsname
- Titel — dynamische maximale Zeilenanzahl (Tablet)
- Beschreibung — dynamische maximale Zeilenanzahl (Tablet)
- Primärer CTA "Jetzt ansehen"
Zwei adaptive Regeln steuern den Text der HeroVideoCard. Beide sind für das Redesign erforderlich — der aktuelle Android-Quellcode ist regrediert und muss aus dem kanonischen Commit wiederhergestellt werden.
1. Titel-Schriftgröße — abhängig von der Titel-Zeichenlänge
| Titellänge (Zeichen) | Schriftgröße | Zeilenhöhe |
|---|---|---|
| < 20 | 28sp | 30sp |
| < 30 | 24sp | 26sp |
| < 40 | 22sp | 24sp |
| < 50 | 20sp | 22sp |
| ≥ 50 | 18sp | 20sp |
Formel: lineHeight = fontSize + 2 immer. fontSize wird pro Slide via remember(variant.title) { … } neu berechnet, sodass sie für einen gegebenen Titel stabil ist.
2. Maximale Zeilenanzahl — abhängig von Breakpoint, Ausrichtung und "crowded top"
| Modus | hasCrowdedTop | Titel max. Zeilen | Beschreibung max. Zeilen |
|---|---|---|---|
| Mobile | (irrelevant) | 2 | 3 |
| Tablet Portrait | nein | 2 | 3 |
| Tablet Portrait | ja | 2 | 2 |
| Tablet Landscape | (irrelevant) | 3 | 5 |
hasCrowdedTop = slide.category != null && !slide.subtitle.isNullOrEmpty()
Tablet Portrait wurde 2026-06-10 reduziert (vorher 3/4 bzw. crowded 2/3): die schmale Inhaltsspalte plus größere Buttons machten die Karte zu textlastig. Wenn die Karte sowohl eine Kategorie-Pille ALS AUCH eine Eyebrow-Zeile hat (crowded), verliert die Beschreibung eine weitere Zeile. Werte: component.heroVideo.maxLines.* (API-übersteuerbar).
HeroVideoCard hat keinen expliziten Fallback bei Bildfehler: Wenn das Bild leer ist oder fehlschlägt, rendert das AsyncImage nichts und Verlaufsregion + Content-Stack werden weiterhin über die Hintergrundfarbe der Karte gerendert. Das Ergebnis ist eine "Karte nur mit Verlauf und Text", visuell ähnlich zur Gradient-only-Variante der HeroBibleVerseCard (außerhalb des Umfangs von Iteration 1), aber ohne expliziten Akzentfüllung. Wenn ein korrekter Poster-Karten-Fallback (Verlauf mit zentriertem Titel) auf dem Hero gewünscht ist, fragen Sie nach, bevor Sie ihn implementieren — er ist nicht Teil des aktuellen Vertrags.
HeroVideoCard.kt:94–111 — kein onError-Handler ist am AsyncImage angehängt.
Zustände
- Default
- Cover-Bild sichtbar, Verlauf + Content-Stack zusammengesetzt. Primärer CTA in voller Deckkraft.
- Bild wird geladen
- Bildebene wird mit Alpha 0 gerendert; sobald dekodiert, blendet sie über die Cross-Fade-Dauer auf 1 über. Die Basisfarbe des Verlaufs der Shell ist unter dem Alpha-0-Bild sichtbar, sodass der Slide nie "leer" ist — es ist die durch die Komposition gelieferte Basisfarbe, mit dem Content-Stack bereits lesbar darüber.
- Bildfehler / fehlende URL
- Bildebene bleibt bei Alpha 0; Verlaufsbasisfarbe füllt die Shell; Content-Stack und Kategorie-Badge bleiben lesbar. Keine Anzeige eines defekten Bildes.
- Pressed (gesamter Slide)
- Press-Feedback-Overlay der Shell — siehe HeroCardShell.
- Pressed (CTA)
- Gemäß MediathekButton — typischerweise ein tonales Overlay bei
primitives.opacity.scale15. - Disabled CTA
- In Iteration 1 nicht verwendet — der primäre CTA ist immer aktiviert, wenn vorhanden.
- Focus (Tastatur / D-Pad)
- Äußerer Fokusring von der Shell gerendert bei
semantic.focusRing.{bp}.*. CTAs erhalten ihren eigenen inneren Fokusring, wenn sie über die Tastatur erreicht werden.
Verhalten
Das Layout wechselt basierend auf dem aktiven Device-Mode-Signal (LocalDeviceMode). Mobile rendert HeroVideoCardMobile (Bild full-bleed, vertikaler Verlauf unten). Tablet rendert HeroVideoCardTablet (Bild rechts, horizontaler Verlauf, Content-Spalte links). Der Breakpoint wird zentral gesetzt — siehe Breakpoints.
1) Beim ersten Composition: Erstellen einer imgix-Anfrage für die thumbnail-URL des Slides mit dem Modus-spezifischen Seitenverhältnis (Mobile 15:16, Breite 600; Tablet 16:9, Breite 1200) und Fokuspunkt Y = 0.5. 2) Beginn des asynchronen Dekodierens. 3) Bei Erfolg: Cross-Fade-Einblenden der Bildebene. 4) Bei Fehler: Ebene transparent lassen; Verlaufsbasisfarbe scheint durch. Wenn der Slide eine integrierte Bildressource trägt (imageResId), wird sie direkt ohne imgix-Transformation gezeichnet.
Die Basisfarbe C, die von beiden Gradient-Overlays verwendet wird, wird in dieser Prioritätsreihenfolge aufgelöst (HeroColorResolver): (1) vorab durch die Komposition gelieferte Basis aus der preExtractedColors-Variante (Light oder Dark) der Slide-Payload; (2) wahrnehmungsbasierte vorgelieferte Palette aus dem Live-Bild (URL oder Ressource); (3) der statische gradientColor-Hex des Slides; (4) die Hintergrundfarbe der Seite als letzter Fallback. Der Resolver veröffentlicht außerdem eine Logo-Akzentfarbe nach oben über onColorsExtracted(backgroundGradient, logoAccent), sodass die übergeordnete Bildschirmebene das TopBar-Logo passend einfärben kann.
Eyebrow ist der Untertitel des Slides. Title ist der Titel des Slides. Die Beschreibung bevorzugt epgKurz (kurze EPG-Zeile), wenn verfügbar, andernfalls fällt sie auf die description des Slides zurück. Jedes Element wird ausgelassen, wenn null/leer. Alle drei respektieren ihre Token-maximalen Zeilen und verwenden Ellipsis-Overflow.
Tipp auf den primären CTA ruft die ctaAction des Slides auf. Tipp auf den sekundären CTA ruft secondaryCtaAction auf. Tipp auf den ganzen Slide ruft das optionale onClick auf, das vom übergeordneten Karussell registriert wurde; dies spiegelt im Allgemeinen die primäre CTA-Aktion für Slides ohne ein separates "Zur Detailseite navigieren"-Ziel.
Der Slide liest isLightMode vom Karussell. Textfarben, Gradient-Stop-Schema und der visuelle Stil des CTA wechseln alle anhand dieses Flags. Beide Modi verwenden dieselbe durch die Komposition gelieferte Basisfarbe C, aber der Dark-Mode-Verlauf rampt früher hoch (8 Stops), um den Text gegen typischerweise hellere Cover-Fotografie lesbar zu halten; Light-Mode verwendet 7 Stops mit einer späteren, aggressiveren Rampe.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Bild dekodiert | Alpha der Bildebene 0 → 1 | 200ms (Plattform-Standard-Cross-Fade) | ease-out |
| Slide-Druck | Press-Feedback der Shell | siehe HeroCardShell | siehe HeroCardShell |
| CTA-Druck | Opazität des tonalen Overlays | 120ms ein / 200ms aus | ease-out |
| Karussell Auto-Advance | siehe HeroCarousel | — | — |
| Modus-Wechsel (Light/Dark) | Verlauf-Stops, Textfarben | sofort (Re-Composition, kein Tween) | — |
Interaktion & UX
- Tipp auf den ganzen Slide ist die primäre Affordance auf Mobile; auf Tablet ist der sichtbare primäre CTA das prominentere Ziel, aber Tipp auf den ganzen Slide funktioniert weiterhin.
- Long-Press, Doppel-Tipp, Rechtsklick: kein definiertes Verhalten in Iteration 1.
- Während das Karussell automatisch weiterläuft, pausiert eine aktive Berührung (laufender Drag) auf diesem Slide das Auto-Advance — siehe HeroCarousel.
- Das Kategorie-Badge ist in Iteration 1 dekorativ (nicht interaktiv), muss aber dennoch im Accessibility-Tree vorhanden sein.
- Beschreibungstext ist rein informativ — niemals der alleinige Träger einer Aktion.
Barrierefreiheit
- Slide-Container: role button; zugänglicher Name = Titel; zugängliche Beschreibung = "{eyebrow}. {description}", wenn diese existieren.
- Cover-Bild: role image; Alt-Text = Titel (dekorative Rolle wird in den übergeordneten Button zusammengeführt).
- Kategorie-Badge: role text; wird als Teil des zugänglichen Namens des Slides angekündigt, wenn vorhanden (z. B. "Video. Titel. Beschreibung.").
- Primärer CTA: role button; Label aus
ctaLabel. Sekundärer CTA: dasselbe, Label aussecondaryCtaLabel. - Fokus-Reihenfolge innerhalb des Slides: Slide → primärer CTA → sekundärer CTA. Der primäre CTA muss über Tastatur / D-Pad erreichbar bleiben, auch wenn der ganze Slide tippbar ist.
- Reduzierte Bewegung: Bild-Cross-Fade überspringen — Bild sofort beim Dekodieren mit Alpha 1 rendern.
- Textfarben werden aus der Palette abgeleitet, sodass Kontrast nicht statisch erzwungen wird; der Resolver tendiert zu
semantic.{mode}.text.onColorfür primären Text und einer mitscale75abgeschwächten Kopie für sekundären Text. Implementierungen müssen pro Slide verifizieren, dass der aufgelöste primäre Text gegenüber dem End-Stop des Verlaufs WCAG AA (≥4.5:1) erfüllt.
Genutzte Tokens
| Pfad | Tier |
|---|---|
semantic.{mode}.background.canvas | semantic |
semantic.{mode}.text.primary | semantic |
semantic.{mode}.text.onColor | semantic |
semantic.{mode}.brand.primary | semantic |
semantic.{mode}.brand.accent | semantic |
semantic.{mode}.border.subtle | semantic |
semantic.spacing.{bp}.paddingDefault | semantic |
semantic.spacing.{bp}.gap2xs | semantic |
semantic.spacing.{bp}.gap3xs | semantic |
semantic.spacing.{bp}.gap4xs | semantic |
semantic.spacing.{bp}.gapSm | semantic |
semantic.focusRing.{bp}.* | semantic |
component.{mode}.heroCarousel.aspectRatioMobile | component |
component.{mode}.heroCarousel.aspectRatioTablet | component |
component.{mode}.heroCarousel.tabletImageWidthFraction | component |
component.{mode}.heroCarousel.tabletGradientWidthFraction | component |
component.{mode}.heroCarousel.tabletContentWidthFraction | component |
component.{mode}.heroCarousel.cornerRadius | component |
component.{mode}.heroCarousel.borderColor | component |
component.{mode}.heroCarousel.borderWidth | component |
component.{mode}.heroCarousel.bodyFontFamily | component |
component.{mode}.heroCarousel.displayFontFamily | component |
component.{mode}.heroCarousel.eyebrowMaxLines | component |
component.{mode}.heroCarousel.titleMaxLines | component |
component.{mode}.heroCarousel.descriptionMaxLines | component |
component.{mode}.heroVideo.contentTopPadding | component |
component.{mode}.heroVideo.mobileTitleFontSize | component |
component.{mode}.heroVideo.mobileTitleFontWeight | component |
component.{mode}.heroVideo.mobileDescFontSize | component |
component.{mode}.heroVideo.mobileDescFontWeight | component |
component.{mode}.heroVideo.tabletTitleFontSize | component |
component.{mode}.heroVideo.tabletTitleLineHeight | component |
component.{mode}.heroVideo.tabletTitleFontWeight | component |
component.{mode}.heroVideo.tabletTitleMaxLines | component |
component.{mode}.heroVideo.tabletDescFontSize | component |
component.{mode}.heroVideo.tabletDescLineHeight | component |
component.{mode}.heroVideo.tabletDescFontWeight | component |
component.{mode}.heroVideo.tabletDescMaxLines | component |
component.{mode}.heroVideo.eyebrowMaxLines | component |
component.{mode}.heroVideo.iconSize | component |
component.{mode}.heroVideo.displayFontFamily | component |
component.{mode}.heroVideo.bodyFontFamily | component |
primitives.opacity.scale25 | primitive |
primitives.opacity.scale50 | primitive |
primitives.opacity.scale75 | primitive |
primitives.opacity.scale15 | primitive |
Referenz




Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/hero/cards/HeroVideoCard.kt. Mobile-Layout in HeroVideoCardMobile; Tablet-Layout in HeroVideoCardTablet. Gemeinsamer Inhalt wird über RegularHeroContent in HeroSharedContent.kt gerendert. Vorgelieferte Palette ist über rememberHeroColors in HeroColorResolver.kt verdrahtet; Bildtransformationen über ImgixUtils.getHeroImageUrl. CTA-Buttons verwenden MediathekButton (type = Primary/Secondary, size = Small).
Composition-API-Vertrag
Annotationen verfasst vom API-Engineer. Jedes Feld der HeroCard_Video / HeroCarouselContainer-Payload (badge, sponsor, eyebrow, headline, description, button, image, color) ist im darunterliegenden Source-of-Truth-Diagramm dokumentiert. Click-Ziel wird stets aus objectType + objectId aufgelöst; Farben stammen aus dem Design-Tokens-System je Theme.
objectType + objectId; Button-Farben über Design Tokens. Quelle: Pencil-Board des API-Engineers.API-Felder (1:1 zu CompositionModels.kt)
Diese Tabelle ist die Vereinigung der Annotationen des API-Engineers und des tatsächlich bereits in packages/android/app/src/main/java/com/protobible/android/data/models/CompositionModels.kt implementierten Wire-Vertrags. Feldtypen und Required-/Optional-Flags spiegeln das Modell wider — die Agentur soll dies exakt abbilden. Wo sich die verbale Beschreibung des API-Engineers und das implementierte Modell unterscheiden, wird die Differenz explizit benannt.
Feld in CompositionCard | Typ (gemäß CompositionModels.kt) | Hinweise |
|---|---|---|
type |
CardType-Enum → Wire-Wert "card_hero_media" |
"Grundsätzlich type: 'card_hero_media', unabhängig vom Inhalt (ob Video oder Serie)." Ein API-Typ für jede Hero-Medien-Karte, unabhängig davon, ob der zugrundeliegende Inhalt ein Video oder eine Serie ist. Definiert bei CompositionModels.kt:95 (@SerialName("card_hero_media") CARD_HERO_MEDIA). |
objectType + objectId |
ObjectType-Enum (video · series · verse · playlist · generic) + String? |
Löst das Tap-Ziel für die gesamte Karte UND den Button auf. CompositionModels.kt:72–73, 102–108. |
badge |
CompositionBadge? { displayText: String, backgroundColor: String, textColor: String } |
Wird nur gerendert, wenn das gesamte badge-Objekt vorhanden ist. Wenn die API auf dieser Karte kein Badge möchte, wird die Eigenschaft vollständig weggelassen (null); wenn sie gesendet wird, sind alle drei Felder erforderlich.
" displayText für den Text, der angezeigt werden soll. Ein etwaiges permanentes Uppercasing hat Clientseitig zu erfolgen. backgroundColor für die Hintergrundfarbe mit AlphaKanal optional. textColor für die Textfarbe."
Client führt Uppercasing aus (Android erledigt dies bei BtvBadge.kt:119 über text.uppercase()). Hex-Strings mit optionalem Alphakanal — geparst über android.graphics.Color.parseColor (CompositionModels.kt:159–160). Modelldefinition bei CompositionModels.kt:114–119.
|
imageUrl |
String? |
"Falls keine imageUrl angegeben, dann vollflächig über gesamte Kartenhöhe." Wenn nicht vorhanden, deckt der farbige/verlaufende Hintergrund die volle Kartenhöhe ab (kein Bildbereich). CompositionModels.kt:83. |
colors |
CompositionColorPalette { light: CompositionColorScheme, dark: CompositionColorScheme }, wobei jedes Schema = { base: String, accent: String, textPrimary: String?, textSecondary: String?, backgroundGradient: Map<String,String> }
|
Pro Theme — separate light- und dark-Paletten auf jeder Karte. backgroundGradient ist eine Map mit Stop-Prozent-Strings ("0", "25", "50", "75", "100") als Schlüssel → Hex-Farbe; toGradientStops() sortiert und konvertiert zu Compose-Farben (CompositionModels.kt:149–155). CompositionAdapter.kt:126–132 liest genau diese fünf Stops.
"Hat im Hero-Slider Einfluss auf die allgemeine Background Color der App über; Farbverlauf aus colors.base von Alpha 0% (oben) auf Alpha 100% (unten)."
|
eyebrowText |
String? |
Serie / Sendungsname über dem Titel. Einzeilig. CompositionModels.kt:76. |
headlineText |
String? |
Der Titel. Im Modell nullable, auch wenn der API-Engineer ihn als Headline beschreibt. CompositionModels.kt:75. |
descriptionText |
String? |
Fließtext unter dem Titel. CompositionModels.kt:77. |
user.progressPercent |
CardUser { progressPercent: Int? }, gesamtes user nullable |
"Unten ist noch eine optionale ProgressBar aus user.progressPercent." Gleicher Vertrag wie CardPoster: Der Balken wird ausgeblendet, wenn user == null, wenn der Wert 0 ist oder wenn der Wert 100 ist. Wird andernfalls am unteren Rand der Karte gerendert. CompositionModels.kt:89, 128–131. Adapter konvertiert zu Bruchteil bei CompositionAdapter.kt:80 (progressPercent / 100f). |
button |
CompositionButton { displayText: String } — nur displayText wird hier konsumiert |
"displayText für den Text, der angezeigt werden soll. Das Ziel des Buttons ergibt sich aus objectType und objectId."
Nutzungsregel: HeroCard_Video komponiert den Design-System- ProtoBibleButton / MediathekButton und zieht alle Button-Farben aus Design Tokens. Das Modell trägt technisch zwar auch optionale backgroundColor / textColor-Felder auf CompositionButton (CompositionModels.kt:122–126), aber diese Komponente ignoriert sie — Tokens sind die Source of Truth.
|
shareUrl |
String? |
Ziel für das Share-Sheet. Außerhalb des Umfangs von Iteration 1 Home, aber im Modell vorhanden (CompositionModels.kt:84). Implementierer kann dies für diese Iteration ignorieren. |
tracking |
CardTracking { impression: CardImpression, click: CardClick } |
Erforderliche Impression- und Click-Events auf jeder Karte (Event-Name, cardId, cardType, objectType, objectId). Impression auslösen, wenn die Karte in den Viewport gelangt; Click, wenn der Benutzer auf die Karte oder ihren primären Button tippt. CompositionModels.kt:193–214. |
durationSeconds · audioUrl · displayText |
Alle String? / Int? |
Gehören zu Geschwister-Kartentypen (card_hero Vers- / Video-Metadaten). Werden vom card_hero_media-Rendering auf Home Iteration 1 nicht verwendet. CompositionModels.kt:78–81. |
Hinweise zum Abgleich für Implementierer: (1) Das Uppercasing des Badge erfolgt clientseitig bei BtvBadge.kt:119. (2) Der Progress-Bar-Vertrag lautet ausblenden, wenn user == null, wenn progressPercent == 0 oder wenn progressPercent == 100 — nur für laufende Elemente rendern. Der aktuelle Android-Quellcode (CardPoster.kt:133 + Äquivalent in HeroVideoCard.kt) blendet bei 0/null aus, rendert aber bei 100; mit dem Vertrag in Einklang bringen.
Composition-API-Kontrakt
Im Pages-Payload (/api/v1/pages/<page>) ist jeder Video-Slide ein VideoHeroItem (Diskriminator type="video") innerhalb einer hero_carousel-Sektion. Modell: PlaylistCollectionModels.kt:140.
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung. |
type | "video" | Diskriminator → HeroVideoCard. |
title | String | Titel des Inhalts; max. 2 Zeilen im Split-Layout. |
subtitle | String? | Eyebrow-/Serienzeile über dem Titel. |
seriesTitle | String? | Serienname (im Showcase nicht gesetzt). |
description | String? | Langbeschreibung; epgInfo hat Vorrang, wenn beide gesetzt sind. |
contentType | String? | Kategorie-Badge ("Serie", "Drama", …). |
duration | String? | Längen-Label ("44 Min."). |
epgInfo | String? | EPG-Kurzinfo; verdrängt description in der Beschreibungszeile. |
imageUrl | String? | Hero-Bild; Layout-spezifisch zugeschnitten (Hochformat mobil, ~2.54:1 Tablet). |
colors | CollectionExtractedColors? | Vorab extrahierte Palette {light,dark}.{base,accent} — treibt Gradient-Scrim + Badge-Farben. |
deepLink | String? | Navigationsziel beim Tippen (z. B. video/19225). |
HeroBibleVerseCard
Auf nächste Iteration verschoben Hero-VarianteVariante der Hero-Karte, die einen Bibelvers (statt Video oder Serie) im Karussell zeigt. Im aktuellen Android-Quellcode unter packages/android/app/src/main/java/com/protobible/android/ui/components/hero/cards/HeroBibleVerseCard.kt implementiert; im Composition-API-Payload getriggert durch card.type = "card_hero" kombiniert mit card.objectType = "verse".
Iteration 1 — bewusste Lücke. Diese Spezifikation wurde noch nicht vollständig ausgearbeitet (kein detailliertes Anatomy-, Tokens-, States- oder Behavior-Layout). Iteration 1 war auf die HeroVideoCard-Variante zugespitzt; HeroBibleVerseCard erscheint in der Karussell-Rotation des Composition-Payloads, aber die Hand-off-Detaillierung für iOS/Web folgt in einer späteren Iteration.
Bekannt aus dem Code (kein vollständiger Vertrag):
- Karten-Diskriminator:
CardType.CARD_HERO(wire"card_hero") +ObjectType.VERSE(wire"verse").CompositionModels.kt:94, 105. - Liest
displayText(der Versinhalt, nichtheadlineText) und optionalaudioUrlfür eine Vorlese-Funktion.CompositionModels.kt:80–81. - Teilt
colors.{light,dark}-Behandlung mit HeroVideoCard — die Karte trägt eine eigene Palette pro Slide. - Hat im Allgemeinen kein
imageUrl(Verse zeigen Typografie + Gradient statt eines Posters).
Was die Agentur in Iteration 1 tun sollte: die Hero-Karussell-Rotation für diese Karte als Platzhalter behandeln — das Karussell sollte den Slide weiterhin paginieren und farblich wechseln (auf Basis von colors), aber der interne Render kann ein neutrales Placeholder-Layout sein, bis die volle Spezifikation nachgeliefert wird.
Composition-API-Kontrakt
Ein Vers-Slide ist ein BibleVerseHeroItem (Diskriminator type="bible_verse"). Modell: PlaylistCollectionModels.kt:119. Im Leseplan-Showcase nicht enthalten (Komponente ist iteration-2-eingefroren).
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung. |
type | "bible_verse" | Diskriminator → HeroBibleVerseCard. |
verseText | String | Verstext; dynamische Schriftgröße nach Textlänge. |
verseReference | String | Versangabe ("Johannes 3,16"); zugleich Label des Sekundär-CTA. |
imageUrl | String? | Hintergrundbild des Vers-Slides. |
audioUrl | String? | Vorgesehen für Vorlese-Audio; vom Client aktuell nicht konsumiert. |
gradientColor | String? | Hex-Farbe für den Gradient-Hintergrund. |
accentColor | String? | Hex-Akzentfarbe der Vers-Typografie. |
colors | CollectionExtractedColors? | Palette wie bei allen Hero-Slides. |
deepLink | String? | Navigationsziel (optional). |
HeroPosterCard — image-only Hero
Iteration 2 scope Hero variantEine bild-only Hero-Variante (Composition-Hero-Typ poster): ein full-bleed-Bild ohne Gradient-Scrim und ohne Text-Overlay. Der Titel erscheint nur als Fallback, wenn das Bild fehlt oder nicht geladen werden kann (analog zur Poster-Karte). Es trägt zwei Asset-URLs, damit der Renderer pro Formfaktor den passenden Zuschnitt wählt.
Aufbau
Innerhalb der gemeinsamen HeroCardShell (Eckenradius, Border, Schatten, Farb-Emission) liegt genau ein full-bleed-Bild, ContentScale.Crop. Es gibt keinen Gradient und keinen Text im Normalzustand. Bei imageLoadFailed || imageUrl.isEmpty() wird der Titel zentriert gezeichnet (im hero-eigenen Fallback-Font). Der Renderer wählt das Asset anhand des Formfaktors:
- Mobile (Portrait):
posterImageUrl(FallbackimageUrl), Imgixar=2:3,w=600. - Tablet / breit (Landscape):
coverImageUrl(FallbackimageUrl), Imgixar=16:9,w=1200.
HeroPosterCard.kt:41–88 · Dispatch: HeroCarousel.kt:332 (ContentType.POSTER)
Callout-Legende
- Bild — formfaktor-abhängiger Zuschnitt
- Fallback-Titel
Composition-API-Vertrag
| Feld | Typ | Hinweise |
|---|---|---|
type | "poster" | Diskriminator (kotlinx @SerialName("poster")). |
imagePosterUrl | String? | Portrait-Zuschnitt (2:3) für Mobile. |
imageCoverUrl | String? | Landscape-/Cover-Zuschnitt (16:9) für Tablet. |
imageUrl | String? | Fallback-Asset für beide Formfaktoren (Single-Asset-Rückwärtskompatibilität). |
title | String | Nur als Fallback-Text gerendert. |
deepLink | String? | Tap-Ziel. |
Zustände
- Default (Bild vorhanden)
- Reines Bild, formfaktor-korrekter Zuschnitt. Kein Text, kein Gradient.
- Bildfehler / leere URL
- Zentrierter Titel im
cardHero.fallbackTitle*-Font.
Referenz

Implementierungsreferenz
HeroPosterCard.kt · PosterHeroItem (PlaylistCollectionModels.kt) · cardHero.fallbackTitle* in MediathekTokenModels.kt
Composition-API-Kontrakt
Ein Poster-Slide ist ein PosterHeroItem (Diskriminator type="poster"). Modell: PlaylistCollectionModels.kt:157.
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung. |
type | "poster" | Diskriminator → HeroPosterCard (bild-only, kein Gradient/Text-Overlay). |
title | String | Anzeigetitel (Accessibility/Tracking; nicht im Bild gerendert). |
imageUrl | String? | Einzel-Asset-Fallback — aktuell das einzige Bildfeld, das der Android-Client liest. |
imagePosterUrl | String? (nur BFF) | Portrait-Asset (3:4) für Mobile. Wird vom BFF geliefert, vom Android-Modell aber noch nicht ingestiert — siehe Gap-Hinweis. |
imageCoverUrl | String? (nur BFF) | Landscape-Asset (16:9) für Tablet. Gleiche Einschränkung. |
colors | CollectionExtractedColors? | Palette (im Showcase nicht gesetzt). |
deepLink | String? | Navigationsziel beim Tippen. |
Bekannter Gap: Der Zwei-Asset-Vertrag (imagePosterUrl + imageCoverUrl, FR-006) ist BFF-seitig ausgeliefert; PosterHeroItem (PlaylistCollectionModels.kt:157) liest derzeit nur imageUrl. Vor Iteration-2-Abschluss muss der Client beide Felder ingestieren und orientierungsabhängig wählen.
HeroGenericCard
Iteration 2 scope Hero variantEine Hero-Variante (Composition-Hero-Typ generic) mit identischem Look wie die HeroVideoCard — full-bleed-Bild, token-definierter Gradient-Scrim, Eyebrow / Titel / Beschreibung, optionales Badge, Primary-CTA — deren CTA jedoch eine beliebige URL öffnet statt ein Video abzuspielen. Geplanter Einsatz: Bewerbung externer Ziele, z. B. eines WhatsApp-Kanals.
Aufbau
Visuell deckungsgleich mit der HeroVideoCard (Mobile Portrait + Tablet 3-Schichten-Landscape) — die Implementierung delegiert an HeroVideoCard und überschreibt nur das CTA-Leading-Icon. Zwei semantische Unterschiede:
- CTA-Icon: Pfeil-nach-rechts (
ArrowForward) statt Play-Glyphe — das Ziel ist ein Link, kein Video. - Ziel: die CTA / der Karten-Tap führt zur
deepLink-URL. Ist diese einehttp(s)-URL, öffnet der Client sie extern (System-Handler / Browser); In-App-Deeplinks (video/…,series/…,playlist/…) routen wie gewohnt.
HeroGenericCard.kt · HeroVideoCard.kt (ctaLeadingIcon-Parameter) · Externes Routing: CollectionRenderer.kt (onSlideClick) · Dispatch: HeroMobileLayout.kt / HeroTabletLayout.kt (ContentType.GENERIC)
Callout-Legende
- Bild + Gradient-Scrim
- Badge (optional)
- Eyebrow
- Titel + Beschreibung
- Primary-CTA — [arrow_forward], nicht [play_arrow]
Composition-API-Vertrag
| Feld | Typ | Hinweise |
|---|---|---|
type | "generic" | Diskriminator (kotlinx @SerialName("generic")). |
title | String | Headline. |
subtitle | String? | Eyebrow über der Headline. |
description | String? | Beschreibungstext. |
category | String? | Badge (z. B. „Anzeige"). |
ctaLabel | String? | Button-Beschriftung (z. B. „WhatsApp-Kanal beitreten"). |
deepLink | String? | Die Ziel-URL. http(s) → öffnet extern. |
imageUrl | String? | Hintergrundbild. |
Zustände
- Default
- Wie HeroVideoCard: Bild + Gradient-Scrim + Eyebrow/Titel/Beschreibung + Primary-CTA (mit ArrowForward-Icon) + optionalem Badge.
- Gedrückt (CTA / Karte)
- Öffnet
deepLink; beihttp(s)extern über den System-Handler.
Referenz

Implementierungsreferenz
HeroGenericCard.kt · HeroVideoCard.kt · CollectionRenderer.kt (openExternalUrl) · GenericHeroItem (PlaylistCollectionModels.kt) · ContentType.GENERIC (VisualParityEnums.kt)
Composition-API-Kontrakt
Ein Generic-Slide ist ein GenericHeroItem (Diskriminator type="generic"). Modell: PlaylistCollectionModels.kt:203.
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung. |
type | "generic" | Diskriminator → HeroGenericCard (Video-Look, beliebiges CTA-Ziel). |
title | String | Titel ("Bibel TV jetzt auf WhatsApp"). |
subtitle | String? | Sekundäre Headline. |
description | String? | Beschreibungstext unter dem Titel. |
category | String? | Badge-Label ("Anzeige"). |
ctaLabel | String? | Text des Primär-CTA ("WhatsApp-Kanal beitreten"); Icon: Pfeil-nach-rechts statt Play. |
imageUrl | String? | Hero-Bild. |
colors | CollectionExtractedColors? | Palette (optional). |
deepLink | String? | CTA-Ziel; http(s)-URLs öffnen extern (Browser/App), alles andere navigiert intern. |
RowSection
Iteration 1 scope Section RepeatingDie primäre Inhaltseinheit des Home-Bildschirms. Ein vertikaler Block, der aus einem optionalen Titel und einem horizontal scrollenden Track aus Karten besteht. Jede Instanz ist eine Playlist-Reihe; der Home-Bildschirm rendert eine unbestimmte, servergesteuerte Sequenz von ihnen zwischen der ButtonGroupSection und der BottomNavigation. Die Kartenvariante für die Items jeder Reihe (CardPoster · CardLandscape · OVERLAY · SPLIT · CardAvatar) wird ebenfalls über die Payload gesteuert — RowSection ist ein schlanker, slot-basierter Dispatcher.
Visuelle Spezifikation
Aufbau
Horizontal scrollender Reihen-Abschnitt. Trägt einen optionalen Titel über einer LazyRow aus Karten. In Iteration 1 ist die einzige im Scope befindliche Kartenvariante CardPoster; die Reihe kann auch andere Typen hosten (Landscape, Top10, Avatar, Split, Overlay), diese sind jedoch nicht im Umfang dieses Builds enthalten.
Callout-Legende
- Abschnittstitel (optional)
- Reihen-Container — LazyRow
- Karten-Slot — servergesteuerter Typ
- Halb-Peek der nächsten Karte (Scroll-Hinweis)
- Abstand zwischen Karten
- Reihen-Randpadding
Kartenbreiten-Formel (POSTER_PORTRAIT, nur im Scope)
| Modus | Kartenbreite | Formel / Quelle |
|---|---|---|
| Mobile (Smartphone) | dynamisch berechnet, damit ~1,5 Karten sichtbar sind | (screenWidthDp − 2 × rowHorizontalPadding − 0.5 × rowItemSpacing) / 1.5RowSection.kt:108–128 |
| Tablet — Stufe Klein (S) | cardPoster.widthSmall (Token) | Statischer Tokenwert aus component.{mode}.cardPoster.widthSmall |
| Tablet — Stufe Mittel (M) | cardPoster.widthMedium (Standard) | Statischer Tokenwert |
| Tablet — Stufe Groß (L) | cardPoster.widthLarge | Statischer Tokenwert |
| MATCHED-Größenvariante (höhengetrieben) | Höhe 300dp × aspectRatio | MATCHED_HEIGHT × cardPoster.aspectRatio = 300 × 0.5625 = 168.75dpRowSection.kt:97–106 |
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Titel-Schriftgröße | Token | component.{mode}.playlist.rowTitleFontSizeSp | MediathekTokenModels.kt PlaylistTokens |
| Titel-Schriftstärke | Medium | component.playlist.rowTitleFontWeight | MediathekTokenModels.kt PlaylistTokens |
| Abstand Titel zu Reihe | Token | component.{mode}.playlist.rowTitleBottomSpacing | MediathekTokenModels.kt |
| Horizontales Padding der Reihe | Token | component.{mode}.playlist.rowHorizontalPadding | MediathekTokenModels.kt |
| Abstand zwischen Karten | Token | component.{mode}.playlist.rowItemSpacing (auch views.home.layout.rowCardSpacing{Mobile,Tablet}) | MediathekTokenModels.kt · views/home.json |
Zustände
- Idle (geladen)
- Titel sichtbar (falls vorhanden), Karten ausgelegt, ~1,5 Karten lugen am Smartphone hervor.
- Scrolling (Benutzer-Drag)
- Reihe folgt dem Finger horizontal · Gesten-Priorität beansprucht horizontale Swipes, bevor der übergeordnete vertikale Scroll sie konsumiert · kein Snap, kein Inertia-Overshoot.
- Loading (Items noch nicht aufgelöst)
- Titel wird gerendert. Item-Bereich rendert nichts (oder Skelett-Karten, falls der Parent welche liefert) — RowSection selbst zeigt kein Skelett; der Parent-Bildschirm entscheidet.
- Leer
- Wenn
displayItemsleer ist, rendert die Reihe nur den Titel (oder nichts, falls kein Titel). Keine "Leerzustand"-Meldung — der Parent-Bildschirm filtert leere Abschnitte bereits auf Composition-Ebene heraus.
Verhalten
- Item-Reihenfolge — wenn
section.shuffled = true, werden Items partitioniert: solche mitfixedPositionwerden an ihrem Index fixiert, die übrigen werden gemischt und anschließend an den fixierten Indizes wieder eingefügt. Memoisiert viaremember(section.items, section.shuffled). - Karten-Dispatch —
PlaylistCardDispatchwählt den Renderer basierend aufcardTypeaus. Iteration 1 leitet nur an CardPoster (POSTER_PORTRAIT) weiter. - Gesten-Priorität — Composes
LazyRowbeansprucht horizontale Gesten beim Gestenstart; der vertikale Scroll konsumiert weiterhin vertikale Drags. Das bedeutet: Wenn ein Benutzer in der Nähe einer Reihe wischt, scrollt diese Reihe, nicht die Seite.
Barrierefreiheit
- Die Reihe ist eine logische Gruppe; auf Screenreadern leitet der Titel (falls vorhanden) die Items ein.
- Jede Karte ist unabhängig fokussierbar (ein Stopp pro Karte). Die Fokus-Reihenfolge folgt der visuellen Reihenfolge der Reihe.
- Der horizontale Scroll ist per Tastatur-Pfeiltasten erreichbar (Compose-Standard).
Audit-Notizen
Keine Regressionen erkannt. Alle Abmessungen sind Token-gesteuert. Die "1,5 Karten sichtbar"-Formel ist eine bewusste Laufzeitberechnung gegen screenWidthDp, keine magische Zahl.
Hinweis für Implementierer: Die Konstante MATCHED_HEIGHT (300dp) innerhalb von getCardWidth ist ein hartcodiertes Literal im Quellcode — sie wäre ein Kandidat für Tokenisierung (component.playlist.matchedRowHeight), falls die MATCHED-Größenvariante in einer zukünftigen Iteration in den Scope kommt.RowSection.kt MATCHED_HEIGHT Konstante
Zustände
- Default
- Track bei Scroll-Offset 0 — erste Karte bündig mit dem führenden Inset, die letzte Karte kann außerhalb des Bildschirms liegen mit einem halb sichtbaren Nachbarn als Hinweis auf weiteren Inhalt.
- Scrolling
- Track verschiebt sich horizontal unter Finger-/Scrollrad-/Pfeiltasten-Eingabe. Vertikaler Seiten-Scroll wird während einer aktiven horizontalen Geste unterdrückt (siehe Interaktion).
- Karte gedrückt
- Jede Karte behandelt ihr eigenes Druck-Feedback (siehe CardPoster u. a.). RowSection selbst zeichnet keinen Druck-Zustand.
- Karte fokussiert (Tastatur / D-Pad)
- Die fokussierte Karte hebt ihren eigenen Fokusring hervor; der Track scrollt gerade so weit, dass die fokussierte Karte vollständig sichtbar ist (kein Über-Scrollen).
- Loading
- Wenn der Abschnitt vor dem Auflösen seiner Items gemountet wird, rendern Sie den Titel und eine Skelett-Reihe von kartenförmigen Platzhaltern in der konfigurierten Breite — Anzahl = bis zu
ceil(viewport / cardWidth) + 1. Skelett-Linien pulsen im globalen Skelett-Takt. - Leer
- Wenn die aufgelöste
items-Liste nach Filterung leer ist (z. B. allefixedPosition-Platzierungen entfernt), rendert der Abschnitt nichts — der übergeordnete Home-Bildschirm lässt die Lücke aus.
Verhalten
Jede PlaylistRowSection trägt ein shuffled: boolean-Flag und eine Liste von Items, jeweils optional annotiert mit fixedPosition: number. Wenn shuffled = true: Mischen Sie die nicht fixierten Items und fügen Sie dann jedes fixierte Item an seiner fixedPosition wieder ein (geklammert auf [0, list.length]). Wenn false: Rendern in Payload-Reihenfolge. Die Misch-Stabilität gilt pro Mount — ein Re-Mount kann erneut mischen.
Für jedes Item wird effectiveCardType = item.cardTypeOverride ?? section.cardType berechnet. Rendern Sie die entsprechende Karten-Komponente in der abgeleiteten Breite:
POSTER_PORTRAIT→ CardPosterIMAGE_ONLY_LANDSCAPE→ CardLandscapeOVERLAY→ MediaCard mit style = OVERLAY (Bild + am unteren Rand ausgerichtete Overlay-Metadaten)SPLIT→ MediaCard mit style = SPLIT (Bild links, Metadaten rechts; Mobile = horizontaler Split)AVATAR→ CardAvatar (kreisförmig)
Für OVERLAY/SPLIT werden zusätzliche Payload-Felder (tagVariant, tag, contentType, duration, description, series, subtitle, metadata) unverändert durchgereicht. preExtractedColors aus der Payload umgehen, wenn vorhanden, die Farb-Auflösung auf dem Gerät aus der Composition-API.
Der Track scrollt horizontal mit dem nativen Momentum/Inertia der Plattform. Es gibt in Iteration 1 kein Snap-to-Card — der Track scrollt frei und bleibt dort stehen, wo die Geste ihn verlässt. Overflow wird auf zwei Arten signalisiert: (1) durch die halb sichtbare zweite Karte bei Scroll-Offset 0 (Smartphone) und (2) durch die rechtsstehende Karte, die am Inset-Rand beschnitten ist. Es wird kein Scrollbar gerendert. Es werden keine Edge-Fades oder Fade-Out-Verläufe als Overlays angewendet — der einzige Rand-Hinweis ist der Inset selbst.
Ein horizontaler Swipe, dessen dominante Achse beim Gestenstart X ist, wird vom Track dieser Reihe konsumiert und NICHT an den übergeordneten vertikalen Scroller weitergegeben. Ein vertikaler Swipe (dominantes Y) umgeht diese Reihe und scrollt die Home-Seite. Die Achse wird beim Gestenstart festgelegt — sobald erfasst, behält der Track die Geste bis zur Freigabe. Zeiger-/Mausrad-Ereignisse: Eine horizontale Radeingabe (Zwei-Finger-Swipe am Touchpad, Shift+Rad) steuert den Track; eine vertikale Radeingabe umgeht den Track und steuert den Seiten-Scroll, selbst wenn der Zeiger über dem Track schwebt.
Ein Tap auf eine Karte ruft onNavigate(item.deepLink) auf. Die Deep-Link-Zeichenkette ist für RowSection undurchsichtig — die Auflösung des Ziels liegt in der Verantwortung des Host-Bildschirms.
Tracks müssen ihren Inhalt virtualisieren: Nur Karten innerhalb (oder knapp außerhalb) des Viewports werden gemessen/gezeichnet beibehalten. Bild-Ladevorgänge von Karten außerhalb des Bildschirms werden pausiert oder abgebrochen. Wenn eine Karte in den Viewport zurückkehrt, muss ihr Bild ohne Flackern wieder erscheinen — URL-basierte Caches sind verpflichtend. Die Android-Referenzimplementierung verwendet eine Lazy Horizontal List, die dies kostenlos bereitstellt; iOS und Web müssen dies replizieren.
Bei einer Änderung der Viewport-Breite (Rotation, Split-Screen, Foldable, Browser-Resize) wird die Breite pro Karte erneut aus der obigen Formel abgeleitet. Der Track springt — ohne Animation — auf die äquivalente erste-vollständig-sichtbare Karte, um die Position des Benutzers zu erhalten.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Freier Scroll des Tracks | track translateX | kontinuierlich (1:1 zur Eingabe) | linear (Geste) / Plattform-Inertia (Freigabe) |
| Inertia-Abklingen | track translateX | Plattform-Standard (≈ 350–600ms) | cubic-bezier(0.0, 0.0, 0.2, 1) |
| Fokuskarte in Sicht scrollen | track translateX | 250ms | cubic-bezier(0.4, 0.0, 0.2, 1) |
| Re-Layout bei Resize | track translateX | 0ms (sofortiger Snap) | — |
| Skelett-Puls | opacity | 1200ms Schleife | cubic-bezier(0.4, 0.0, 0.2, 1) |
| Karten-Druck | delegiert an die Karten-Komponente | — | — |
Interaktion & UX
- Touch-Benutzer erkennen die Scrollbarkeit durch das Halb-Karten-Peek — zentrieren Sie die Karten nicht und rendern Sie sie nicht randlos ohne Overflow.
- Mausrad-Benutzer im Web erhalten das Verhalten "Vertikales Rad = Seiten-Scroll"; dies ist beabsichtigt. Stellen Sie eine Alternative (Drag, Fokus, Pfeiltasten) für die horizontale Navigation bereit.
- Tastatur / D-Pad: Links/Rechts bewegen den Fokus auf die vorherige/nächste Karte im Track; Hoch/Runter verlassen die Reihe vollständig (Bewegung zum vorherigen/nächsten fokussierbaren Element auf der Seite).
- Zeichnen Sie keinen horizontalen Scrollbar. Die Track-Höhe wird durch die höchste Kind-Karte festgelegt; lassen Sie die Reihe niemals darunter kollabieren.
- Reihen mit gemischten Größen (Small/Medium/Large-Poster in derselben Reihen-Payload) werden in Iteration 1 nicht verwendet —
section.cardSizegilt für die gesamte Reihe.
Barrierefreiheit
- Section-Landmark: role = region · zugänglicher Name = der Reihentitel (Deutsch, z. B. "Empfehlungen"); Fallback auf "Untitled row" nur als Debugging-Ausweichmöglichkeit — Produktions-Payloads tragen für Nicht-Top10-Reihen immer einen Titel.
- Track: role = list; Karten haben role = listitem. Der Track legt eine horizontal-scrollbare Beschreibung offen, damit Screenreader den Hinweis ansagen.
- Karten: Jede Karte ist ein einziges fokussierbares, einzeln drückbares Element mit einem eigenen zugänglichen Namen (delegiert an die Karten-Komponente — Titel + optionales Tag + Dauer).
- Fokus-Reihenfolge: Eintritt in die Reihe bei der ersten Karte; Links/Rechts durchqueren die Karten; Tab verlässt die Reihe an der letzten sichtbaren Karte und setzt im nächsten Abschnitt fort.
- Der Halb-Karten-Peek bleibt erhalten, wenn unterstützendes Scrollen eine Karte in den Sichtbereich bringt — die Implementierung muss gerade so weit scrollen, dass die fokussierte Karte vollständig sichtbar ist, und den nachfolgenden Peek nach Möglichkeit erhalten.
- Reduced-Motion: Fokus-in-Sicht-Animationen werden auf sofortigen Snap (0ms) reduziert. Inertia-Abklingen wird auf ≤120ms verkürzt.
- Das Mischverhalten beim Mounten wird als Eigenschaft der Reihe angesagt, nicht als Inhaltsänderung — feuern Sie keine Live-Region-Ansagen ab, wenn sich die Misch-Reihenfolge ändert.
Verbrauchte Tokens
| Pfad | Tier |
|---|---|
component.{mode}.section.verticalSpacing | component |
component.{mode}.section.horizontalPadding | component |
component.{mode}.playlist.rowHorizontalPadding | component |
component.{mode}.playlist.rowItemSpacing | component |
component.{mode}.playlist.rowTitleFontSize | component |
component.{mode}.playlist.rowTitleFontWeight | component |
component.{mode}.playlist.rowTitleBottomSpacing | component |
component.{mode}.rowSection.cardAspectRatio | component |
component.{mode}.cardPoster.aspectRatio | component |
component.{mode}.cardPoster.widthSmall / widthMedium / widthLarge | component |
component.{mode}.cardImageOnly.widthSmall / widthMedium / widthLarge | component |
semantic.{mode}.text.primary | semantic |
semantic.{mode}.background.canvas | semantic |
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/sections/RowSection.kt (Renderer + Dispatch) · packages/android/app/src/main/java/com/protobible/android/ui/components/sections/CollectionRenderer.kt (Host) · packages/android/app/src/main/java/com/protobible/android/ui/components/sections/Top10Section.kt (gerankte Variante). Der Track ist eine Lazy Horizontal List mit dem Gesten-Achsen-Lock, der von den verschachtelten Scroll-Primitiven der Plattform bereitgestellt wird.
ScrollView(.horizontal), der einen LazyHStack mit expliziten Breiten-Modifikatoren pro Karte hostet; Gesten-Achsen-Priorität wird über simultaneousGesture + ein DragGesture abgefangen, das beim ersten Delta in onChanged auf der dominanten Achse einrastet.
overflow-x: auto-Containers; der Halb-Karten-Peek wird durch Setzen der Kartenbreite über die obige Formel erreicht, nicht durch scroll-snap. Unterdrücken Sie den horizontalen Scrollbar mit scrollbar-width: none. Verwenden Sie eine Virtualisierungs-Bibliothek (oder einen IntersectionObserver), um Karten außerhalb des Bildschirms zu recyceln.
Composition-API-Vertrag
RowSection rendert die Komponente SLIDER. Der Renderer liest den type der ersten Karte, um den Renderer pro Karte auszuwählen (Iteration 1: Nur card_poster_* wird an CardPoster weitergeleitet) und die entsprechende Kartengröße.
Feld an CompositionComponent | Typ (laut CompositionModels.kt) | Hinweise |
|---|---|---|
type |
ComponentType.SLIDER → Wire-Wert "slider" |
Diskriminator, der diese Payload an die RowSection weiterleitet. CompositionModels.kt:58. |
headlineText |
String? |
Abschnittstitel, der bei Vorhandensein über der Reihe gerendert wird. Der Adapter unter CompositionAdapter.kt:69 fällt bei null auf einen leeren String zurück — der Renderer lässt den Titelknoten dann vollständig weg. |
cards |
List<CompositionCard> |
Reihen-Items. Leere Liste → Abschnitt wird übersprungen (CompositionAdapter.kt:67). Der type der ersten Karte bestimmt den Renderer und die Kartengröße für die gesamte Reihe (siehe Kartentyp-Dispatch unten). Auf Item-Ebene verwendete Felder: id, imageUrl, objectType + objectId (Deep-Link), headlineText, eyebrowText, descriptionText, durationSeconds, user.progressPercent, badge.displayText, colors. |
| Kartentyp-Dispatch | CompositionCard.type |
Adapter unter CompositionAdapter.kt:105–110:
|
tracking |
ComponentTracking? |
Impression + Click auf Komponentenebene. Impressions/Clicks pro Karte werden vom Karten-Renderer über CardTracking emittiert. |
buttons |
List<CompositionButtonGroupItem> = emptyList() |
Im Wire-Modell für alle Komponenten vorhanden, von SLIDER jedoch nicht verwendet. |
Hinweis für Implementierer: Gemischte Kartengrößen innerhalb einer einzelnen Reihe werden nicht unterstützt — die Größe wird einmalig aus der ersten Karte abgeleitet. Wenn die Payload Karten mit unterschiedlichen type-Werten enthält, gewinnt nur der Typ der ersten Karte. Die API sollte Homogenität pro Reihe erzwingen.
Composition-API-Kontrakt
Sektion mit Diskriminator type="playlist_row". Modelle: PlaylistRowSection (PlaylistCollectionModels.kt:49), PlaylistItem (PlaylistCollectionModels.kt:226), RowContextAction (PlaylistCollectionModels.kt:64); Enums PlaylistCardType (PlaylistCollectionModels.kt:356), CardSize (PlaylistCollectionModels.kt:365), MetadataVariant (PlaylistCollectionModels.kt:373).
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Zeilen-Kennung. |
type | "playlist_row" | Diskriminator → RowSection. |
title | String? | Zeilen-Headline über den Karten. |
items | List<PlaylistItem> | Karten der Zeile; Reihenfolge = Render-Reihenfolge (außer shuffled). |
cardType | PlaylistCardType | OVERLAY / SPLIT / POSTER_PORTRAIT / IMAGE_ONLY_LANDSCAPE / AVATAR — wählt das Karten-Composable. |
cardSize | CardSize? | SMALL / MEDIUM (Default) / LARGE / MATCHED — Kartenbreite über Komponenten-Tokens (widthSmall/Medium/Large). |
metadataVariant | MetadataVariant? | FULL (Default) / MINIMAL; wirkt auf die Metadaten-Zeile der SPLIT-Karte. |
shuffled | Boolean | Client mischt die Items; fixedPosition pro Item bleibt respektiert. |
contextActions | List<RowContextAction>? | Long-Press-Kontextmenü pro Karte der Zeile. Felder: label (Menütext), kind (Aktions-Diskriminator, z. B. "remove" — Handler derzeit No-Op, wartet auf Watchlist-Backend). |
Felder pro PlaylistItem (PlaylistCollectionModels.kt:226) und welche Kartentypen sie konsumieren:
| PlaylistItem-Feld | OVERLAY | SPLIT | POSTER_PORTRAIT | IMAGE_ONLY_LANDSCAPE | AVATAR |
|---|---|---|---|---|---|
title | ✓ | ✓ | ✓ | ✓ | ✓ |
subtitle | ✓ | — | — | — | — |
series | ✓ (seriesName) | ✓ (Eyebrow) | — | — | — |
metadata | duration | ✓ | ✓ (metadata vor duration) | — | — | — |
contentType | ✓ (wenn kein tag) | ✓ (wenn kein tag) | — | — | — |
imageUrl | ✓ (16:9) | ✓ (16:9) | ✓ (Hochformat) | ✓ (16:9) | ✓ (rund, Face-Crop) |
progress | ✓ | ✓ | ✓ | ✓ | — |
tag / tagVariant | ✓ | ✓ | — | ✓ | — |
colors | ✓ | — | — | — | — |
deepLink | ✓ | ✓ | ✓ | ✓ | ✓ |
| contextActions (Zeile) | ✓ | ✓ | — | — | — |
Wire-Felder ohne Renderer: description (definiert, von keiner Karte gerendert). Client-only-Felder (nicht im BFF-Payload): fixedPosition (Pinning beim Shuffle), cardTypeOverride (Kartentyp pro Item überschreiben). tagVariant-Werte: new, live, expiring, login_required, upcoming — steuern die Pill-Variante; tag ist der Pill-Text und verdrängt das contentType-Badge.
CardPoster
Iteration 1 scope Row itemEine vertikale 2:3- (9:16-) Posterkarte, die als dominantes Item-Typ in horizontalen Reihen-Abschnitten auf dem Home-Bildschirm verwendet wird ("Empfehlungen"-artige Reihen, gerankte Reihen, Suchergebnisse). Die gesamte Karte ist ein einziges Tap-Ziel, das den entsprechenden Detail-Bildschirm öffnet. Sie wird in drei Breiten-Stufen (S / M / L) gerendert und unterstützt einen optionalen Fortschrittsbalken sowie einen Fallback bei Bildfehlern.
Visuelle Spezifikation
Aufbau
Vertikale 9:16-Posterkarte, die als Reihen-Item innerhalb horizontaler Reihen-Abschnitte auf dem Home-Bildschirm verwendet wird. Die gesamte Karte ist ein einziges Tap-Ziel, das den entsprechenden Detail-Bildschirm öffnet. Wird in drei Breiten-Stufen (S / M / L) gerendert; unterstützt einen optionalen Fortschrittsbalken (Resume-Watching) und einen Verlauf + zentrierten Titel als Fallback, wenn das Bild fehlt oder nicht geladen werden kann.
Callout-Legende für den Default-Zustand
- Container — Karten-Chrome
- Bild-Layer — Full-Bleed-Cover
- Schlagschatten
- Fortschrittsbalken (optional)
Callout-Legende für den Fallback-Zustand
- Verlaufsfüllung — 3-Stopp-Linearverlauf
- Titel — zentriert
- Fortschrittsbalken (wird ggf. weiterhin gerendert)
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Seitenverhältnis | 9:16 (≈ 0.5625) | component.cardPoster.aspectRatio | MediathekTokenModels.kt |
| Breite — Klein | Token | component.{mode}.cardPoster.widthSmall | MediathekTokenModels.kt |
| Breite — Mittel (Standard) | Token | component.{mode}.cardPoster.widthMedium | MediathekTokenModels.kt |
| Breite — Groß | Token | component.{mode}.cardPoster.widthLarge | MediathekTokenModels.kt |
| Eckenradius | Token | component.{mode}.cardPoster.cornerRadius | MediathekTokenModels.kt |
| Rahmen | 1dp bei #26FFFFFF (Weiß α 0.15) | component.cardPoster.borderColor + borderWidth | MediathekTokenModels.kt |
| Schatten-Elevation | 8dp | component.cardPoster.shadowElevation | MediathekTokenModels.kt |
| Schattenfarbe | Schwarz α 0.3 | component.cardPoster.shadowColor | MediathekTokenModels.kt |
| Hintergrund (Loading + Fallback-Start) | #141B1F | component.cardPoster.bgColor | MediathekTokenModels.kt |
| Fallback-Textfarbe | #EBF0F2 | component.cardPoster.fallbackTextColor | MediathekTokenModels.kt |
| Hit-Target | gesamte Karte (≥ 120 × 213 dp in der kleinsten Stufe — deutlich über dem 48dp-Minimum) | — | a11y baseline |
| Abstand zwischen Karten (in Reihe) | 16dp | views.home.layout.rowCardSpacing{Mobile,Tablet} | views/home.json (Spec 083) |
Zustände
- Loading (Skelett)
- Container wird ausschließlich in
cardPoster.bgColorgezeichnet. Rahmen + Schatten werden gerendert. Bild-Bereich leer. - Geladen
- Bild blendet über 200ms ein (Coil
crossfade(true)). Rahmen + Schatten bleiben oben. - Bildfehler / leere URL
- Fällt permanent auf die Verlauf-+-zentrierter-Titel-Schicht zurück. Innerhalb der Lebensdauer der Karte erfolgt kein Retry.
- Gedrückt
- Material-3-Ripple innerhalb des geclippten Eckenradius · Tint
semantic.{mode}.text.primarymitprimitives.opacity.scale15. - Mit Fortschritt
- Wird nur gerendert, wenn
user != nullUND0 < user.progressPercent < 100. Fortschrittsbalken am unteren Rand verankert über die volle Innenbreite; Füllbreite =(progressPercent / 100) × Innenbreite. Ausgeblendet bei 0 und 100 gemäß API-Vertrag.
Verhalten
- Tap — ruft
onClickauf. Öffnet das Detail (Video / Serie / Playlist / Live-Programm je nach zugrunde liegendem Medientyp). - Bild-Fetch — wird einmal ausgelöst, wenn die Karte in den Viewport eintritt. URL über Imgix mit
9:16-Seitenverhältnis undw=400umgeschrieben. Innerhalb der Karte selbst kein Prefetch. - Bildfehler — schaltet den
imageLoadFailed-State über denonError-Listener von Coil um; nachfolgende Recompositions rendern die Fallback-Schicht. - Long-Press / Rechtsklick — kein definiertes Verhalten in Iteration 1.
Barrierefreiheit
- Rolle button am Container (oder link im Web).
- Zugängliches Label = der Titel des Mediums. Das Bild ist innerhalb der Tap-Fläche dekorativ — sein Alt-Text ist der Titel (bereits durch das Container-Label bereitgestellt).
- Der Fortschrittsbalken legt den Prozentsatz über accessibilityValue offen: "Bereits angesehen: NN %".
- Die kleinste Stufe (120dp × 213dp) überschreitet das 48dp-Minimum bereits auf jeder Achse.
- Fokus-Reihenfolge: Ein Stopp pro Karte; keine verschachtelten Fokus-Stopps innerhalb der Karte.
Audit-Notizen
Ein Regressions-Kandidat: Der mittlere Stopp des Fallback-Verlaufs verwendet bgColor.copy(alpha = 0.8f) — eine hartcodierte Zahl. Der Token primitives.opacity.scale80 = 0.8 existiert; der Quellcode trägt eine Annotation // EXCEPTION: derived contrast. Ersetzen Sie entweder das Literal durch tokens.opacity.scale80 oder akzeptieren Sie die Ausnahme. Niedrige Priorität.CardPoster.kt:98
Die Schrift des "Titels" im Fallback verwendet cardSplit.titleFontSize etc., also die Schriftkonfiguration der CardSplit- (image-only-landscape) Komponente. Die komponentenübergreifende Token-Wiederverwendung ist beabsichtigt — diese Tokens wurden aufgrund der visuellen Rhythmik gewählt — der Querverweis ist jedoch nicht offensichtlich. Erwägen Sie, sie in einer zukünftigen Iteration in einen gemeinsamen component.card.fallbackTitle.*-Namespace zu überführen.
Zustände
- Loading (Skelett)
- Container in
cardPoster.bgColorgezeichnet. Kein Spinner. Rahmen und Schatten sind bereits gerendert. - Geladen
- Bild blendet über 200ms ein. Rahmen, Schatten und etwaige Fortschrittsbalken werden darüber gerendert.
- Bildfehler
- Fällt auf einen Verlaufshintergrund + zentrierten Display-Titel zurück (siehe Aufbau 6). Permanent für die Lebensdauer der Karten-Instanz — kein automatischer Retry.
- Gedrückt
- Standard-Plattform-Druckfeedback (Ripple/Highlight) innerhalb des geclippten Eckenradius, getönt mit
semantic.{mode}.text.primarybeiopacity.scale15. - Fokussiert (Tastatur / D-Pad)
- Äußerer Fokusring bei
semantic.focusRing.{bp}.width, versetzt umsemantic.focusRing.{bp}.offset, Farbesemantic.{mode}.focusRing.default. Wird außerhalb des kartenseitigen Rahmens gezeichnet, sodass sich beide nie überlappen. - Mit Fortschritt
- Fortschrittsbalken am unteren Rand verankert. Track über die volle Innenbreite sichtbar; Füllbreite =
watchProgress× Innenbreite. - Deaktiviert
- In Iteration 1 nicht verwendet — jede gerenderte Karte ist tappbar.
Verhalten
- Tap — ruft
onClickder Karte auf. Öffnet den Detail-Bildschirm für das zugrunde liegende Medium (Video, Serie, Playlist oder geplantes Programm). - Bild-Fetch — Die Anfrage wird einmal ausgelöst, wenn die Karte in den Viewport eintritt. Die CDN-URL wird synchron aus der Quell-URL erzeugt; es wird kein separater Prefetch durch die Karte selbst durchgeführt.
- Fehlerbehandlung — Ein Fehlschlag beim Bild-Fetch versetzt die Karte für den Rest ihrer Lebensdauer in die Fallback-Schicht; der Fehler wird nicht an die Reihe oder die Seite weitergegeben.
- Long-Press / Rechtsklick — kein definiertes Verhalten in Iteration 1.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Bild aufgelöst | image opacity 0 → 1 | 200ms | ease-out |
| Druck | Press-Feedback-Opazität | 120ms ein / 200ms aus | ease-out |
| Fokus rein/raus | Fokusring-Opazität | 100ms | linear |
| Fortschritts-Füllung | Füllbreite | innerhalb der Karte nicht animiert | — |
Interaktion & UX
- Karten leben innerhalb einer horizontal scrollenden Reihe. Touch-Targets nahe den Reihenrändern sind weiterhin voll tappbar — das Edge-Padding der Reihe beschneidet das Druck-Feedback nicht.
- Die Karte zeigt in Iteration 1 niemals Text über dem Bild an. Titel und Metadaten erscheinen nur in der Fallback-Schicht; das Live-Bild trägt den Titel visuell über das Artwork.
- Der Fortschrittsbalken ist still, wenn
watchProgress0, null oder nicht vorhanden ist — die Karte reserviert keinen Platz dafür. - Die Schattenfarbe kann auf Parent-Ebene mit einer Palettenfarbe getönt werden, die in der Composition-API-Payload der Karte ankommt. Die Tönung ist eine Dekoration auf Bildschirmebene, nicht Teil des Kartenvertrags; der kartenseitige Schatten-Token definiert den neutralen Standard-Schatten.
Barrierefreiheit
- Rolle button (oder link im Web) am Container.
- Zugängliches Label = der Titel des Mediums (z. B. "Bibel Verse Plan"). In Iteration 1 wird kein Untertitel gerendert, daher ist keine Konkatenation erforderlich.
- Das Bild ist innerhalb der Tap-Fläche dekorativ — sein Alt-Text ist der Titel (bereits durch das Container-Label bereitgestellt); kein separater Bild-Zugänglichkeitsknoten.
- Der Fortschrittsbalken legt seinen Prozentsatz über einen accessibilityValue offen: "Bereits angesehen: NN %".
- Fokus-Reihenfolge: Reihenrichtung (LTR) — ein Stopp pro Karte. Keine verschachtelten Stopps.
- Verwenden Sie den plattform-Standard-Fokusring (
semantic.focusRing.{bp}.*) bei Tastatur- / D-Pad-Fokus. - Minimales Hit-Target in allen Größen erfüllt (≥ 120dp × 213dp).
Verbrauchte Tokens
| Pfad | Tier |
|---|---|
component.{mode}.cardPoster.cornerRadius | component |
component.{mode}.cardPoster.widthSmall | component |
component.{mode}.cardPoster.widthMedium | component |
component.{mode}.cardPoster.widthLarge | component |
component.{mode}.cardPoster.aspectRatio | component |
component.{mode}.cardPoster.borderColor | component |
component.{mode}.cardPoster.borderWidth | component |
component.{mode}.cardPoster.shadowElevation | component |
component.{mode}.cardPoster.shadowColor | component |
component.{mode}.cardPoster.bgColor | component |
component.{mode}.cardPoster.fallbackGradientEnd | component |
component.{mode}.cardPoster.fallbackTextColor | component |
component.{mode}.cardSplit.titleFontSize | component (Fallback-Titel) |
component.{mode}.cardSplit.titleFontWeight | component (Fallback-Titel) |
component.{mode}.cardSplit.titleLineHeight | component (Fallback-Titel) |
component.{mode}.progress.barHeight | component |
component.{mode}.progress.barCornerRadius | component |
component.{mode}.progress.barColor | component |
component.{mode}.progress.barTrackColor | component |
semantic.{mode}.background.canvas | semantic (Fallback-Verlauf) |
semantic.{mode}.text.primary | semantic (Fallback-Text + Druck-Feedback) |
semantic.focusRing.{bp}.width | semantic |
semantic.focusRing.{bp}.offset | semantic |
primitives.opacity.scale15 | primitive (Druck-Feedback) |
Referenz


Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/cards/CardPoster.kt. Der Bild-Fetch erfolgt über ImgixUtils.getCardImageUrl(url, aspectRatio = "9:16", width = 400). Der optionale Fortschrittsbalken wird über das gemeinsam genutzte ProgressBar-Primitiv gerendert. Karten werden von RowSection.kt innerhalb einer LazyRow ausgelegt.
Composition-API-Vertrag
Annotationen vom API-Engineer verfasst. CardPoster wird in drei Größenvarianten über das type-Feld ausgeliefert: card_poster_small, card_poster_medium, card_poster_large — serverseitig ausgewählt. Der Renderer zeigt das Bild im Vollformat (imageUrl) und fällt auf einen generierten / gebrandeten Platzhalter zurück, wenn kein Bild bereitgestellt wird oder das Bild nicht geladen werden kann. user.progressPercent ist eine 0–100 Ganzzahl, die den unteren Fortschrittsbalken steuert (nur für laufende Items gerendert — siehe Vertrag unten). colors.{light,dark}.base liefert den Hintergrund pro Theme. Die Farbe von headlineText stammt aus den Design Tokens. Das Klickziel wird über objectType + objectId aufgelöst.
user.progressPercent null → kein Fortschrittsbalken; colors.base pro Theme; Klickziel über objectType + objectId. Quelle: Pencil-Board des API-Engineers.API-Felder (1:1 zu CompositionModels.kt)
CardPoster teilt sich die CompositionCard-Payload mit den Hero-Karten — es unterscheiden sich nur das type-Enum und die Felder, die der Renderer tatsächlich verbraucht. Die Agentur sollte dies exakt abbilden.
Feld an CompositionCard | Typ (laut CompositionModels.kt) | Hinweise |
|---|---|---|
type |
CardType-Enum → Wire-Werte "card_poster_small" · "card_poster_medium" · "card_poster_large" |
Ein API-Typ pro Größenstufe (S / M / L). Dies sind die einzigen drei Postergrößen, die die API liefern wird — es gibt keine legacy-Variante in der Spezifikation. Definiert in CompositionModels.kt:96–98. |
objectType + objectId |
ObjectType-Enum + String? |
Klickziel der Karte. CompositionModels.kt:72–73. |
imageUrl |
String? |
URL des Poster-Artworks. Wenn nicht vorhanden, wird der Fallback mit Verlauf + zentriertem Titel gerendert (siehe "Zustände · Bildfehler / fehlend" oben). |
headlineText |
String? |
Sendungstitel. Farbe aus Design Tokens (semantic.{mode}.text.primary). CompositionModels.kt:75. |
colors |
CompositionColorPalette { light, dark } — jeweils mit base, accent, backgroundGradient, optional textPrimary/textSecondary |
Palette pro Theme. colors.{mode}.base ist der Kartenhintergrund-Fallback (und die "Hintergrundfarbe je nach Theme", die der API-Engineer angesprochen hat). |
badge |
CompositionBadge? |
Optional. Gleiche Form wie Hero — displayText, backgroundColor, textColor. Clientseitige Großschreibung. |
user.progressPercent |
CardUser { progressPercent: Int? } (gesamtes user nullable) |
Laut API-Engineer (wortwörtlich):
"Falls User nicht eingeloggt / keine Infos zu diesem User vorliegen, wird das Property
CompositionModels.kt:128–131. Der Adapter unter CompositionAdapter.kt:80 wandelt dies in einen 0–1-Bruchteil um.
|
tracking |
CardTracking { impression: CardImpression, click: CardClick } |
Gleiche Form wie Hero: Impression feuert beim Eintritt in den Viewport, Click beim Tap. CompositionModels.kt:193–214. |
button |
CompositionButton? |
Im gemeinsamen Modell vorhanden, von CardPoster aber nicht gerendert — die gesamte Karte ist das einzige Tap-Ziel. Für das Poster-Rendering ignorieren. |
Composition-API-Kontrakt
CardPoster wird von playlist_row-Zeilen mit cardType=POSTER_PORTRAIT instanziiert. Konsumierte PlaylistItem-Felder (PlaylistCollectionModels.kt:226):
| Feld | Typ | Hinweise |
|---|---|---|
imageUrl | String | Hochformat-Poster (3:4-Zuschnitt). |
title | String? | Titel; Fallback-Darstellung, wenn das Bild fehlt. |
tag / tagVariant | String? | Status-Badge oben links (tagVariant → VideoState-Pille); Fallback ohne tag: Kategorie-Badge aus contentType; ohne beides entfällt es. Seit 2026-06-11 unterstützen alle Kartenvarianten Badge + Fortschrittsbalken. |
progress | Float? | Fortschrittsbalken 0.0–1.0; entfällt bei null. |
deepLink | String | Navigationsziel beim Tippen. |
Breite über cardSize der Zeile → Tokens cardPoster.widthSmall/Medium/Large. Alle übrigen PlaylistItem-Felder werden von dieser Karte nicht konsumiert (Matrix in RowSection).
CardLandscape
Iteration 2 scope Row itemEine bild-only 16:9-Querformatkarte, verwendet als Reihen-Item in horizontalen Reihen-Abschnitten (Composition-Kartentyp IMAGE_ONLY_LANDSCAPE). Die gesamte Karte ist ein einziges Tap-Ziel. Sie wird in drei Breiten-Stufen (S / M / L) gerendert, zeigt einen optionalen Fortschrittsbalken und fällt bei Bildfehlern auf einen Verlauf + zentrierten Titel zurück.
Aufbau
Box mit fester 16:9-Ratio, auf den Eckenradius geclippt, mit gemeinsamer Karten-Border und (im Dunkelmodus) Schatten. Darin: das full-bleed-Cover-Bild (Coil crossfade), unten-links optional ein ProgressBar. Bei imageLoadFailed || imageUrl.isEmpty() wird stattdessen ein 3-Stopp-Linearverlauf mit zentriertem Titel gezeichnet. Die drei Breiten werden nicht in der Komponente, sondern beim Aufruf über getCardWidth(IMAGE_ONLY_LANDSCAPE, cardSize) aus den cardImageOnly.width*-Tokens bestimmt.CardLandscape.kt · RowSection.kt:149–154
Callout-Legende
- Container — Karten-Chrome
- Bild — Full-Bleed-Cover (16:9)
- Fortschrittsbalken (optional)
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Seitenverhältnis | 16:9 (≈ 1.7778) | component.cardLandscape.aspectRatio | MediathekTokenModels.kt |
| Breite — Klein | Token | component.{mode}.cardImageOnly.widthSmall | RowSection.kt:150 |
| Breite — Mittel (Standard) | Token | component.{mode}.cardImageOnly.widthMedium | RowSection.kt:151 |
| Breite — Groß | Token | component.{mode}.cardImageOnly.widthLarge | RowSection.kt:152 |
| Eckenradius | Token (= radius.lg, 16dp) | component.{mode}.cardLandscape.cornerRadius | MediathekTokenModels.kt |
| Rahmen | 0.5dp bei #26FFFFFF (gemeinsame Karten-Border) | component.cardLandscape.borderColor + borderWidth | CardLandscape.kt:52,57 |
| Schatten-Elevation (nur Dunkel) | 8dp | component.cardLandscape.shadowElevation | CardLandscape.kt:81 |
| Hintergrund (Loading) | #141B1F | component.cardImageOnly.bgColor | MediathekTokenModels.kt |
| Fallback-Titel-Font | 14sp · SemiBold · LH 15 | component.cardLandscape.fallbackTitleFontSize/Weight/LineHeight | CardLandscape.kt:109–111 |
Zustände
- Loading (Skelett)
- Box wird in
cardImageOnly.bgColorgezeichnet; Bild blendet über 300ms ein (crossfade). - Geladen
- Full-bleed-Cover-Bild, auf den Eckenradius geclippt. Border + (Dunkel-)Schatten bleiben oben.
- Bildfehler / leere URL
- Permanenter Fallback: 3-Stopp-Linearverlauf (
bgColor → bgColor α 0.8 → canvas) + zentrierter Titel imcardLandscape.fallbackTitle*-Font. - Mit Fortschritt
- Nur gerendert, wenn
watchProgress != null && watchProgress > 0f.ProgressBarunten-links verankert. - Gedrückt
- Material-3-Ripple innerhalb des geclippten Eckenradius.
Referenz

Implementierungsreferenz
CardLandscape.kt · RowSection.kt (PlaylistCardType.IMAGE_ONLY_LANDSCAPE, getCardWidth) · CardLandscapeTokens / CardImageOnlyTokens in MediathekTokenModels.kt
Composition-API-Kontrakt
CardLandscape wird von playlist_row-Zeilen mit cardType=IMAGE_ONLY_LANDSCAPE instanziiert. Konsumierte PlaylistItem-Felder (PlaylistCollectionModels.kt:226):
| Feld | Typ | Hinweise |
|---|---|---|
imageUrl | String | 16:9-Landscape-Bild. |
title | String? | Titel; zentrierter Fallback-Text, wenn das Bild fehlt. |
tag / tagVariant | String? | Status-Pill ("Neu", "Live", …). |
progress | Float? | Fortschrittsbalken 0.0–1.0; entfällt bei null. |
deepLink | String | Navigationsziel beim Tippen. |
Breite über cardSize der Zeile → Tokens cardImageOnly.widthSmall/Medium/Large (im Showcase in allen drei Größen exerziert).
CardAvatar
Iteration 2 scope Row itemEine kreisförmige Avatar-Karte für Personen / Sendereihen (Composition-Kartentyp AVATAR): ein rundes, gesichtsfokussiert zugeschnittenes Bild mit einem Titel-Label darunter. Bei Bildfehlern werden die Initialen des Titels als Fallback gezeigt. Die gesamte Karte ist ein einziges Tap-Ziel.
Aufbau
Eine zentrierte Spalte: oben ein kreisförmiger Container (CircleShape-Clip), darunter mit kleinem Abstand das Titel-Label. Das Bild wird über eine Face-Crop-URL geladen. Der kreisförmige Container trägt die gemeinsame Karten-Border (dieselben cardLandscape-Border-Tokens wie Split-/Landscape-/Poster-/Hero-Karten) und im Dunkelmodus einen Schatten. Bei Bildfehlern füllt eine Initialen-Schicht (erste Buchstaben des Titels) den Kreis.CardAvatar.kt:52–59,71–114
Callout-Legende
- Kreisbild — Face-Crop
- Border — gemeinsame Karten-Border
- Titel-Label
- Initialen-Fallback
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Durchmesser (Standard) | 160dp | component.cardAvatar.defaultSize | MediathekTokenModels.kt:474 |
| Form | Kreis | — | CardAvatar.kt (CircleShape) |
| Rahmen | 1dp bei #26FFFFFF (gemeinsame Karten-Border) | component.cardLandscape.borderColor + borderWidth | CardAvatar.kt:55,59 |
| Schatten-Elevation (nur Dunkel) | 8dp | component.cardAvatar.shadowElevation | CardAvatar.kt:82–86 |
| Label-Font | 12sp · Medium · DiagrammFontFamily | component.cardAvatar.labelFontSize + labelFontWeight | CardAvatar.kt:135–138 |
| Label max. Zeilen | 2 · Ellipsis | component.cardAvatar.labelMaxLines | CardAvatar.kt:141 |
| Initialen (Fallback) | 2 Zeichen · Bold · 0.3 × Durchmesser | cardAvatar.initialsCount / initialsFontSizeRatio / initialsFontWeight | CardAvatar.kt:106–110 |
Zustände
- Loading
- Kreis-Container ohne Bild; Border + (Dunkel-)Schatten sichtbar.
- Geladen
- Face-Crop-Bild füllt den Kreis (
ContentScale.Crop). - Bildfehler / leere URL
- Initialen-Fallback: die ersten Zeichen des Titels, zentriert im Kreis.
- Gedrückt
- Material-3-Ripple innerhalb des Kreis-Clips.
Referenz

Implementierungsreferenz
CardAvatar.kt · RowSection.kt (PlaylistCardType.AVATAR) · CardAvatarTokens in MediathekTokenModels.kt
Composition-API-Kontrakt
CardAvatar wird von playlist_row-Zeilen mit cardType=AVATAR instanziiert. Konsumierte PlaylistItem-Felder (PlaylistCollectionModels.kt:226):
| Feld | Typ | Hinweise |
|---|---|---|
imageUrl | String | Rundes Avatar-Bild; gesichtszentrierter Zuschnitt über Imgix-Face-Crop. |
title | String? | Label unter dem Avatar (Person / Sendereihe). |
deepLink | String | Navigationsziel beim Tippen. |
Kein Fortschritt, keine Pills, keine Metadaten — bewusst minimaler Vertrag (Matrix in RowSection).
Motion-Sprache
Cross-cutting-Vertrag ErforderlichMotion ist Bestandteil der Spezifikation, nicht Dekoration. Jeder animierte Übergang im Produkt muss eine Dauer aus der Dauer-Skala und ein Easing aus der nachstehenden Easing-Skala wählen — beliebige Werte sind nicht zulässig. Motion drückt Hierarchie aus: kleines UI-Feedback ist schnell und knapp; Surface-Wechsel (Drawer, Modals, Carousels) sind mittelschnell und nutzen Anticipation; Umgebungswechsel (Theme-Crossfade, Vollbild-Route) sind lang und symmetrisch.
Dauer-Skala
Drei benannte Ebenen. Wählen Sie nach Größe des bewegten Elements und Wichtigkeit des Wechsels.
| Token | Wert | Verwendung |
|---|---|---|
motion.duration.short | 120ms | Press-Feedback rein, Hover-/Focus-Statuswechsel, Tooltip-Erscheinen, Icon-Tausch, Beginn eines Ripples. |
motion.duration.medium | 220ms | Press-Feedback raus, Enter/Exit von Listenelementen, Page-Indicator-Dot-Übergänge, kleine Surface-Fades, Tab-Indicator-Slide. |
motion.duration.long | 340ms | Drawer Öffnen/Schließen, Modal-Scrim-Fade, Crossfade-Body eines Hero-Slides, Theme-Wechsel, Banner-Overlay-Reveal. |
motion.duration.carousel | 600ms | Nur für den Seitenübergang beim Auto-Advance des Hero-Carousels. Lang genug, dass der Nutzer den Wechsel als bewusst wahrnimmt, ohne abgelenkt zu sein. |
Alles, was länger als 600ms ist, muss eine kontinuierliche, scroll- oder mediengesteuerte Animation sein (z. B. audio-reaktiver Puls, Video-Timeline). Es ist kein Statuswechsel.
Easing-Skala
Fünf benannte Easings. Verwenden Sie in Specs den symbolischen Namen; die Plattformen mappen auf die nachstehende Kurve.
| Token | Kurve | Verwendung |
|---|---|---|
motion.easing.linear | linear | Nur für kontinuierliches Tracking — scroll-getriebene Opacity, Drag-Tracking-Transforms, Scrubber. Niemals für Statuswechsel. |
motion.easing.standardOut (Entrance) | cubic-bezier(0, 0, 0.2, 1) | Entrances, Expansions, Fade-ins, Drawer-Öffnen. Das Element verlangsamt in den Ruhezustand. |
motion.easing.standardIn (Exit) | cubic-bezier(0.4, 0, 1, 1) | Exits, Collapses, Fade-outs, Drawer-Schließen. Das Element beschleunigt beim Verlassen. |
motion.easing.standard (symmetrisch) | cubic-bezier(0.4, 0, 0.2, 0.8) | Symmetrische, in beide Richtungen verlaufende Wechsel: Skalen-Toggles, Farb-Crossfades, Indicator-Slides, Hero-Auto-Advance. |
motion.easing.spring.gentle | spring(stiffness=MediumLow, damping=Medium) | Surface-Zustände, die sich physisch anfühlen sollen: Scale-in des Notification-Scrims, Einrasten des Tablet-Menü-Drawers. Sparsam einsetzen. |
motion.easing.spring.snappy | spring(stiffness=Medium, damping=High) | Schließen derselben Surfaces. Kehrt ohne Overshoot in den Ruhezustand zurück. In Paarung mit spring.gentle für ein abgestimmtes In/Out. |
Der Auto-Advance des Hero-Carousels nutzt motion.easing.standard bei motion.duration.carousel — siehe HeroCarousel. Das Tablet-Notification-Overlay verwendet gepaarte Springs — siehe Notification-Surface der BottomNavigation.
Choreografie-Regeln
Wenn zwei Elemente wechseln (z. B. Hero-Slide A → B, Drawer geschlossen → offen), startet das eingehende Element seinen Enter bei t=0 und das ausgehende Element beendet seinen Exit spätestens bei t=Dauer. Keine Pause dazwischen. Gleichzeitigkeit, keine Sequenz — es sei denn, das ausgehende Element verdeckt das eingehende; in diesem Fall den Enter um 50 % der Exit-Dauer versetzen.
Wenn ≥ 3 Geschwister-Elemente gemeinsam erscheinen (Reveal von Row-Section-Inhalt, Menü-Einträge), staffeln Sie um 30ms pro Element, gedeckelt auf 6 Elemente (180ms Gesamtstaffelung). Elemente ab Nummer 7 erscheinen gemeinsam mit Element 6.
Anticipation (eine kleine Gegenbewegung vor der Hauptbewegung) ist ausschließlich Spring-getriebenen Surfaces vorbehalten. Keine Anticipation bei Fades oder scroll-getriebener Motion einsetzen. Follow-Through (Overshoot und dann Einschwingen) gehört nur zu motion.easing.spring.gentle.
Eine Surface, die von rechts öffnet (MenuDrawer), schließt nach rechts. Ein Bottom Sheet erscheint von unten und verschwindet nach unten. Niemals die Richtung zwischen Enter und Exit umkehren — das bricht das räumliche Modell.
Zu jedem Zeitpunkt darf höchstens ein Element, das mehr als 50 % der Viewport-Fläche einnimmt, animiert werden. Während ein Drawer öffnet, darf das darunterliegende Hero nicht ebenfalls animiert werden. Pausieren Sie den Auto-Advance während Overlays — siehe Reduced Motion unten.
Touch- & Press-Feedback
Press-Feedback ist die einzige Bewegung, die Dauern mischt: Es dehnt sich schnell aus, um den Kontakt zu bestätigen, und zieht sich langsam zusammen, um nachgiebig zu wirken.
| Phase | Dauer | Easing | Eigenschaft |
|---|---|---|---|
| Press rein (Finger drückt) | motion.duration.short (120ms) | motion.easing.standardOut | Ripple-Opacity 0 → token-definierter Press-Alpha; Ripple-Radius 0 → Max. |
| Press raus (Finger löst) | 200ms (custom — Ausnahme von der Skala) | motion.easing.standardIn | Ripple-Opacity → 0; Radius bleibt auf Max. |
200ms ist die einzige zulässige Dauer abseits der Skala. Sie existiert, weil sich 220ms beim Lösen unter dem Finger träge und 120ms ruckartig anfühlen.
Reduced Motion
Meldet die Plattform, dass der Nutzer reduzierte Bewegung bevorzugt (prefers-reduced-motion: reduce im Web, UIAccessibility.isReduceMotionEnabled auf iOS, Settings.Global.ANIMATOR_DURATION_SCALE = 0 oder "Animationen entfernen" auf Android), muss das Produkt:
- Alle
motion.duration.*-Werte auf 0ms reduzieren — bzw. auf 50ms bei Übergängen, bei denen ein sofortiges Umschalten die Zustandskontinuität verlöre (z. B. Drawer öffnen → ein sofortiges Teleportieren wirkt kaputt; ein 50ms-Fade nicht). - Alle automatisch fortschreitenden Medien anhalten: der Auto-Advance des Hero-Carousels pausiert unbefristet, scroll-getriebene Loop-Animationen stoppen, audio-reaktive Visualisierungen frieren auf dem letzten Frame ein.
- Springs durch harte Snaps ersetzen.
motion.easing.spring.*wird zu einemmotion.easing.standardmit 0ms oder 50ms. - Alle Statuswechsel erhalten — nur die Animation zwischen den Zuständen entfällt. Die Funktionalität ist identisch.
- Die Einstellung bei jedem Lesen respektieren; den Wert nicht über eine App-Sitzung hinweg cachen — Nutzer können sie aus den Systemeinstellungen umschalten, ohne die App in den Hintergrund zu schicken.
Seitenübergänge
Referenz
- HeroCarousel — Auto-Advance:
motion.duration.carousel+motion.easing.standard; Verweildauer von 3000ms zwischen Wechseln. - MenuDrawer — Slide-in horizontal + Fade:
motion.duration.long+motion.easing.standardOutbeim Öffnen,standardInbeim Schließen. - Notification-Surface der BottomNavigation (Tablet) — gepaartes
motion.easing.spring.gentlerein /spring.snappyraus. - Scroll-Fade der TopBar —
motion.easing.linear, scroll-getrieben (keine Dauer).
Barrierefreiheits-Baseline
Cross-cutting-Vertrag WCAG 2.2 AADieser Abschnitt ist die Mindestbasis. Jede Komponente in diesem Dokument erbt diese Anforderungen; die komponentenspezifischen Accessibility-Blöcke ergänzen lediglich Detailangaben (Labels, Fokus-Reihenfolge, Rolle). Eine Komponente, die eine Baseline-Regel verletzt, ist unabhängig von ihrer eigenen Spezifikation nicht konform.
Hit-Targets
- Jedes interaktive Element muss eine Hit-Area von mindestens 48dp × 48dp exponieren (Android-Material-Guidance; das iOS-HIG-Minimum von 44pt ist für unsere visuelle Dichte zu klein — wir runden auf).
- Ist das visuelle Element kleiner, wird transparent nach außen gepolstert, um das Minimum zu erreichen. Das Visual nicht vergrößern.
- Benachbarte Hit-Areas dürfen in den visuellen Gutter überlappen, ein Tap muss aber stets auf genau ein Target auflösen. Mehrdeutigkeiten werden über das nächstgelegene Zentrum aufgelöst.
- Long-Press, Swipe und andere zusammengesetzte Gesten müssen eine alternative Single-Tap- oder Button-Affordance für Nutzer bieten, die diese Gesten nicht ausführen können.
Tastatur & Fokus
- Jedes aktionsfähige Element muss per Tastatur / D-Pad / externem Switch erreichbar sein. Keine Ausnahmen.
- Fokus-Indikator: 2dp-Ring in
semantic.focusRing.{bp}.colormit 2dp Offset außerhalb der Element-Bounds. Niemals nur auf Farbwechsel verlassen. - Der Fokus ist sichtbar, sobald ein nicht-zeigendes Eingabegerät verwendet wird; er darf unterdrückt (aber nicht entfernt) werden, wenn die letzte Eingabe ein Touch oder Mausklick war. Web:
:focus-visible-Semantik; andernorts äquivalentes Plattformverhalten. - Die Tab-Reihenfolge entspricht der visuellen Leseordnung (von oben nach unten, von Anfang nach Ende in der Leserichtung des Dokuments). Komponenten definieren ihre interne Fokus-Reihenfolge in ihrem Accessibility-Abschnitt.
- Skip-Links: in Iteration 1 nicht erforderlich (die Home-Ansicht ist flach), aber die BottomNavigation muss von der TopBar aus in ≤ N+2 Tabs erreichbar sein, wobei N die Anzahl fokussierbarer Elemente dazwischen ist.
Labels
- Jedes interaktive Element hat ein Label. Sichtbarer Text genügt; bei reinen Icon-Elementen
aria-label(Web),accessibilityLabel(iOS),contentDescription(Android) bereitstellen. - Labels sind lokalisiert. Iteration 1 liefert deutsche Strings; Englisch ist eine zusätzliche Locale. Lokalisierte Strings niemals zusammensetzen — ganze Sätze durch ICU schleusen.
- Labels beschreiben die Aktion, nicht das Icon. "Menü öffnen", nicht "Drei Linien".
- Zustände müssen bei Änderung angesagt werden (toggled, aufgeklappt/zugeklappt, ausgewählt). Die State-APIs der Plattform verwenden, nicht ein Label-Suffix wie "(aktiv)".
Farbkontrast
- Body-Text und Icons auf Hintergrund: ≥ 4,5:1 Kontrast (WCAG AA). Gegen beide Token-Sätze (Dark und Light) prüfen.
- Großer Text (≥ 24sp Regular, ≥ 18,66sp Bold) und UI-Elemente (Fokus-Ringe, bedeutungstragende Icons, Borders, die Hit-Areas abgrenzen): ≥ 3:1.
- Rein dekorative Elemente (Gradient-Overlays, Scrims auf Hero-Cards) sind vom Kontrasterfordernis befreit, dürfen aber nicht alleiniger Bedeutungsträger sein.
- Hero-Cards dürfen Text über Fotografie rendern; ein dunkler Gradient-Scrim ist erforderlich, wenn das darunterliegende Bild heller ist, als die Kontrastschwelle es zulässt. Siehe HeroCardShell.
Dynamic Type
- Die Schriftskalierung des Nutzers wird bis 200% ohne Beschneidung oder Kürzung respektiert. Die Skalierung wird auf allen Text via
sp/Dynamic-Type-Pipeline angewendet; reine Display-Strings (Brand-Wordmark) dürfen sich ausklinken. - Layouts müssen umfließen: Karten dürfen vertikal wachsen, Zeilen dürfen umbrechen, Bars mit fester Höhe (TopBar, BottomNavigation) dürfen im Rahmen wachsen.
- Kürzung ist nur bei Metadaten-Strings zulässig, die in der Komponenten-Spec explizit als kürzbar markiert sind — niemals bei primären Titeln, Button-Labels oder Alerts.
- Vor dem "ausgeliefert"-Status bei 100%, 130%, 175% und 200% Skalierung testen.
Reduced Motion
Siehe Motion-Sprache § Reduced Motion. Der Vertrag ist für Barrierefreiheit verbindlich, nicht nur Vorliebe: Animationen, die blinken, pulsieren oder automatisch fortschreiten, sind in voller Stärke vestibuläre und epileptische Risiken.
Screenreader-Navigation
- Die Traversierungsreihenfolge entspricht der visuellen Leseordnung. Komponenten, die Kinder aus visuellen Gründen umordnen (z. B. absolut positionierte Badges), müssen die Reihenfolge im Accessibility-Baum explizit korrigieren.
- Dekorative Elemente werden mit
aria-hidden/accessibilityElementsHidden/importantForAccessibility="no"markiert. Dazu gehören: Gradient-Overlays, Badges, die visuell bereits ausgesagten Text duplizieren (z. B. eine "LIVE"-Pille, wenn der Titel bereits "LIVE" enthält), Hintergrundbilder auf Hero-Cards, Page-Indicator-Dots, wenn das Carousel eine Ansage "Seite X von Y" exponiert. - Verwandte Elemente gruppieren: Eine Karte mit Bild + Titel + Metadaten wird als ein Fokus-Stopp mit kombiniertem Label angesagt, nicht als drei.
- Heading-Level sind im Accessibility-Baum korrekt, auch wenn das visuelle Styling vom semantischen Level abweicht.
Modal-Semantik
- Drawer, Dialoge und Banner-Overlays deklarieren Modal-/Dialog-Rollen. Solange geöffnet: der Fokus ist im Modal gefangen, der dahinterliegende Inhalt ist inert (nicht fokussierbar, nicht angesagt), und die Back-Geste / Escape-Taste schließt.
- Das Öffnen eines Modals führt den Fokus auf das erste fokussierbare Element darin (oder auf den Modal-Container, falls fokussierbar). Beim Schließen kehrt der Fokus zum öffnenden Element zurück.
- Der Scrim ist
aria-hidden, erhält jedoch einen Tap-to-Dismiss-Handler. Der Scrim ist kein fokussierbares Element. - Gilt für: MenuDrawer, das Tablet-Notification-Overlay (BottomNavigation) sowie alle künftigen Bottom Sheets.
Live-Regionen
- Dynamische Inhalte, die sich ohne Nutzeraktion ändern (Programmwechsel des LIVE-Badges, Erscheinen eines Banner-Overlays, Ersetzungen im Fehlerzustand), werden standardmäßig über eine polite Live-Region angesagt.
- assertive nur für Inhalte verwenden, die sofortige Aufmerksamkeit erfordern — Verbindung verloren, Wiedergabefehler mitten im Stream. Iteration 1 hat auf dem Home-Screen keine assertive Fälle.
- Aktualisierungen sind entprellt: aus derselben Region nicht mehr als eine Änderung pro ~2s ansagen.
Formular-Verträge
aria-describedby mit Eingaben verknüpft sind, sowie Tastatur-Submit. Hier festgehalten, damit Reviewer die Lücke als beabsichtigt bestätigen können.
Plattform-Mappings
| Konzept | Android | iOS | Web |
|---|---|---|---|
| Label | contentDescription | accessibilityLabel | aria-label / sichtbarer Text |
| Rolle | Modifier.semantics { role = ... } | accessibilityTraits | role-Attribut |
| Versteckt | importantForAccessibility="no" | accessibilityElementsHidden | aria-hidden="true" |
| Live-Region | liveRegion in semantics | UIAccessibility.post(notification:) | aria-live="polite" |
| Reduced Motion | Settings.Global.ANIMATOR_DURATION_SCALE | UIAccessibility.isReduceMotionEnabled | prefers-reduced-motion |
| Fokus-Ring | Compose indication + FocusRing-Modifier | UIFocusEffect | :focus-visible-Outline |
Breakpoints
Cross-cutting-Vertrag 2 aktiv · 2 vorgerüstetDas Produkt hat einen einzigen, im Root berechneten Layout-Modus. Es gibt keine komponenten-spezifische Media-Query: das Root löst den Breakpoint des Geräts einmal auf, propagiert ihn nach unten, und Komponenten verzweigen über einen booleschen isTablet-Hinweis, sofern ihr Layout abweicht. Token sind per Breakpoint geschlüsselt (semantic.mobile.*, semantic.tablet.*, semantic.desktop.*, semantic.tv.*), und Komponenten wählen den passenden Schlüssel basierend auf dem aufgelösten Modus.
Definierte Breakpoints
| Modus | Token-Schlüssel | Trigger | Status in Iteration 1 |
|---|---|---|---|
| mobile | semantic.mobile.* | Kürzeste Dimension < 600dp und Hochformat. | Aktiv. Referenz-Designfläche. |
| tablet | semantic.tablet.* | Kürzeste Dimension ≥ 600dp und Seitenverhältnis erfüllt die Hysterese-Schwelle (siehe unten). Oder: jedes Gerät im Querformat, das die Mobile-Regel nicht erfüllt. | Aktiv. Referenz-Designfläche. |
| desktop | semantic.desktop.* | Kürzeste Dimension ≥ 900dp und Tablet-Regel ist erfüllt. Reserviert für Web/Foldable. | Vorgerüstet — Token existieren; keine Layouts in Iteration 1. |
| tv | semantic.tv.* | Plattform meldet TV-UI-Modus (Android UI_MODE_TYPE_TELEVISION, tvOS, Web-10-Foot-Media-Query). | Vorgerüstet — Token existieren; keine Layouts in Iteration 1. |
Auflösungsregel (verbindlich)
Der Breakpoint wird einmal pro Layout-Pass im Application-Root aufgelöst und in einem Kontextwert gespeichert (Android LocalDeviceMode; iOS Environment-Wert; Web React-Context). Komponenten messen das Fenster niemals selbst.
- Meldet die Plattform TV-UI-Modus → tv.
- Andernfalls
shortSide = min(widthDp, heightDp)undratio = widthDp / heightDpberechnen. - Wenn
shortSide ≥ 900dpund die Tablet-Regel (Schritt 4) zutrifft → desktop. - Wenn
shortSide ≥ 600dpundratiodie Hysterese-Schwelle erfüllt → tablet. - Andernfalls, wenn das Gerät im Querformat ist → tablet. (Telefone im Querformat übernehmen das Tablet-Layout.)
- Andernfalls → mobile.
Hysterese
Um Flackern beim Größenwechsel über die Grenze hinweg (Split-Screen, Foldables, Browser-Drag) zu verhindern, verwendet der Übergang Tablet ↔ Mobile zwei Schwellen:
- Aktuell Mobile, Wechsel zu Tablet: erfordert
ratio ≥ 0.62(Wiedereintrittsschwelle). - Aktuell Tablet, Wechsel hinaus: bleibt Tablet, bis
ratio < 0.60(Austrittsschwelle). - Die Erstbestimmung beim Kaltstart nutzt die Wiedereintrittsschwelle (
0.62).
Folgt der Android-Referenz unter packages/android/app/src/main/java/com/protobible/android/ui/utils/ScreenUtils.kt (isTabletLayout). Andere Plattformen müssen dasselbe Hysterese-Verhalten implementieren — nicht nur dieselben primären Schwellen.
Orientierungswechsel
- Ein Orientierungswechsel evaluiert den Breakpoint neu. Der neue Modus wird im nächsten Layout-Pass den Baum hinunter propagiert.
- Komponenten müssen sich beim Breakpoint-Wechsel neu komponieren (bzw. neu rendern), ohne Zustand zu verlieren. Scroll-Position, Carousel-Index, Drawer-Offen-Zustand und Formulareingaben überleben den Wechsel.
- Laufende Animationen werden abgebrochen und auf die Ruhewerte des neuen Breakpoints gesnappt; nicht zwischen Breakpoints animieren.
Was sich zwischen Mobile und Tablet ändert
Komponenten verzweigen auf isTablet auf drei Arten:
- Token-Auswahl — derselbe logische Token löst zu einem anderen Wert auf:
component.heroVideo.titleFontSizeliest auf Tablet auscomponent.heroVideo.tabletTitleFontSize. - Seitenverhältnis — Hero-Cards sind auf Mobile 3:4 Hochformat und auf Tablet 2,54:1 Querformat. Siehe HeroCarousel.
- Surface-Behandlung — Modals, die auf Mobile Vollbild gehen, werden auf Tablet zu Seitenpanels oder zentrierten Dialogen (z. B. rechtes Seitenpanel beim MenuDrawer, zentriertes Tablet-Notification-Overlay mit Scrim).
Die strukturelle Anatomie jeder Komponente ist über alle Breakpoints hinweg identisch — dieselben Ebenen in derselben Reihenfolge. Nur Dimensionen, Schriftgrößen und Surface-Behandlungen ändern sich.
Was sich nicht ändert
- Die Token-Ebenen-Hierarchie, die Page-Composition-Payload oder die von der API gelieferten Daten.
- Die Komponenten-Identität: ein CardPoster auf Mobile ist auch auf Tablet ein CardPoster, nur größer.
- Die Navigationsstruktur: die BottomNavigation hat auf Mobile und Tablet dieselben fünf Tabs.
Authoring-Regel für Komponenten-Specs
Jede Dimensionstabelle in diesem Dokument hat eine Mobile-Spalte und eine Tablet-Spalte. Falls ein Wert zwischen beiden identisch ist, wiederholen Sie ihn — die Spalte nicht zusammenfassen. So bleibt der Vertrag auditierbar und versehentliches Drift wird verhindert, wenn ein Breakpoint aktualisiert wird.
Referenz
- Android:
packages/android/app/src/main/java/com/protobible/android/ui/theme/DeviceMode.kt— Enum + Root-Resolver. - Android:
packages/android/app/src/main/java/com/protobible/android/ui/utils/ScreenUtils.kt— Pure-Function-Hysterese-Regel,rememberIsTabletLayout(). - Token-Form:
packages/design-tokens-api/tokens.jsonuntersemantic.{mobile,tablet,desktop,tv}.
Token-Referenz
GeneriertDie vollständigen, automatisch generierten Token-Tabellen (Primitives · Semantic · Component · Views) befinden sich im benachbarten Referenz-Dokument index.html. Die kanonisch generierten Tabellen an einem Ort zu halten verhindert Drift und stellt sicher, dass jeder Implementierer dieselben Werte sieht.
API-Endpunkt
Implementierungen holen sich zur Laufzeit den vollständigen, DTCG-formatierten Token-Payload von der Design-Tokens-API. Ein Endpunkt, ein JSON-Bundle — die vier Tier-Ebenen werden zusammen ausgeliefert. Die Android-Referenzimplementierung tut dies beim App-Start und überlagert die Antwort über die mitgelieferten Defaults.
GET https://<tokens-host>/api/v1/tokens
Accept: application/json
If-None-Match: "<cached-etag>" ← optional, für ETag-Caching
200 OK
ETag: "<sha-256 of payload>"
{
"version": "<sha-256 of source>",
"timestamp": "2026-05-10T...",
"layers": {
"primitives": { "colors": {...}, "spacing": {...}, "fontWeights": {...}, "opacity": {...} },
"semantic": { "dark": {...}, "light": {...}, "typography": {...}, "spacing": {...}, "elevation": {...}, "effect": {...} },
"component": { "dark": {...}, "light": {...} },
"views": { "home": {...} }
}
}
304 Not Modified ← wenn ETag übereinstimmt — Client behält bestehende Tokens
Token-Sync-Verhalten (Client-Vertrag)
Alle Plattformen sollten dasselbe Sync-Verhalten implementieren, damit Token-Änderungen via Coolify-Redeploy live in der App landen, ohne die App neu zu installieren. Die Android-Referenz folgt diesem Muster:
| Phase | Verhalten | Android-Quelle |
|---|---|---|
| 1. Bundled defaults | App-Build enthält bundled Default-Token-Werte (in Kotlin: MediathekTokenModels.kt + SemanticColors.kt). Damit funktioniert die App auch bei Erstinstallation OHNE Netzwerk. |
MediathekTokenModels.kt · SemanticColors.kt |
| 2. Cold-Start: Persistenten Cache laden | Beim App-Start: aus lokalem Key-Value-Store den zuletzt synchronisierten Payload und seinen ETag laden (Android DataStore mit Key token_json / token_etag). Wenn vorhanden, wird er direkt in die StateFlow/Compose-Schicht emittiert und überlagert die bundled Defaults — offline-first. |
DesignTokenRepository.kt:54–69 (initialize) · 169–186 (loadPersistedTokens) |
| 3. Hot-Sync: Hintergrund-Refresh | Direkt nach Cold-Start (und periodisch via WorkManager): GET /api/v1/tokens mit If-None-Match: <cached-etag>. Antworten:
|
DesignTokenRepository.kt:76–102 (syncWithApi) · TokenSyncWorker.kt (WorkManager periodic) |
| 4. Force-Refresh (debug/dev) | forceSync() löscht den ETag-Cache und re-downloaded den vollen Payload. Für Entwickler-/QA-Builds gedacht, nicht für Produktionsnutzer. |
DesignTokenRepository.kt:108–112 |
| 5. Reaktive UI-Aktualisierung | UI-Schicht beobachtet einen Token-Stream (Android: StateFlow<TokenPayloadResponse?>) und rekomponiert auf Updates. Theme-Provider mergt den Remote-Payload über die bundled Defaults Layer-für-Layer (primitives → semantic → component → views). |
Theme.kt:75 (collectAsState) · 126–145 (resolveSemanticColors + mergeFromRemote) |
iOS / Web Implementierungshinweis: die plattform-äquivalenten Patterns sind: iOS = UserDefaults oder FileManager für Persistenz + URLSession mit If-None-Match + BGAppRefreshTask für Hintergrund-Refresh + @Published Combine-Stream. Web = localStorage oder IndexedDB + fetch mit If-None-Match + Service Worker für Background-Sync + Observable/Signal-Stream. Plattform-Idiome unterscheiden sich; der Vertrag (ETag-Caching, Offline-First, niemals crashen) ist identisch.
Authoring-Quelle
Tokens werden als W3C-DTCG-formatierte JSON-Dateien unter tokens/{primitive,semantic,component,view}/*.json im Repository gepflegt. Die API liefert einen aus diesen Dateien abgeleiteten, konsolidierten Snapshot aus.
| Ebene | Quellpfad | Dateien |
|---|---|---|
| Primitive | tokens/primitive/ | colors.json, opacity.json |
| Semantic | tokens/semantic/ | colors.json, spacing.json, typography.json |
| Component | tokens/component/ | badge.json, brand.json, button.json, card.json, detail.json, epg.json, hero.json, navigation.json, player.json, screen.json, switch.json |
| View | tokens/view/ | home.json |
Governance
Zwei CI-Gates schützen den Token-Vertrag:
node packages/design-tokens-api/scripts/audit-kotlin-defaults.mjs— jeder Default eines Kotlin-Token-Model-Felds muss auf einen DTCG-Eintrag auflösen oder eine@TokenExempt-Annotation tragen. Aktuell: 0 Verstöße · 328 Felder · 193 angehoben · 21 ausgenommen · 114 ohne Default.node tools/audit/kotlin-inline-literal-lint.mjs— keine Inline-Farb-, Alpha- oder dp-Literale in Composables (mit allowlisteten Ausnahme-Präfixen). Aktuell: 0 Verstöße / 112 Dateien gescannt.
Beide Gates laufen in .github/workflows/tokens-governance.yml. Pull Requests, die untokenisierte Werte einführen, werden blockiert.
Vollständige Token-Tabellen
Automatisch generiert aus packages/design-tokens-api/tokens.json durch specs/080-create-a-plan/docs/scripts/generate-tokens.mjs. Jede Ebene klappt zu ihrer vollständigen Tabelle auf. Diese Tabellen sind die Source of Truth bei der Implementierung — jeder Wert entspricht exakt dem, was die API zur Laufzeit ausliefert.
Primitives — Farben, Spacing, Font Weights, Opacity-Skala
Colors — 11 scales, 99 tokens
base
| Path | Value |
|---|---|
colors.base.white | #FFFFFF |
colors.base.black | #141B1F |
neutral
| Path | Value |
|---|---|
colors.neutral.100 | #F8F9FA |
colors.neutral.150 | #F3F5F7 |
colors.neutral.200 | #EBF0F2 |
colors.neutral.300 | #DCE4E8 |
colors.neutral.400 | #CFD9DD |
colors.neutral.500 | #A8B5BC |
colors.neutral.600 | #7A8B94 |
colors.neutral.700 | #556570 |
colors.neutral.800 | #3A474F |
colors.neutral.900 | #252F35 |
colors.neutral.950 | #141B1F |
colors.neutral.975 | #121212 |
colors.neutral.999 | #0A0A0A |
warm
| Path | Value |
|---|---|
colors.warm.100 | #FFF7E8 |
colors.warm.150 | #FAF0E1 |
colors.warm.200 | #F5EBDB |
colors.warm.300 | #EADED2 |
colors.warm.400 | #DBCDB9 |
colors.warm.500 | #C7B3A0 |
colors.warm.600 | #A99178 |
colors.warm.700 | #826B52 |
colors.warm.800 | #4A3A2D |
colors.warm.900 | #2B211A |
blue
| Path | Value |
|---|---|
colors.blue.100 | #EEF3F5 |
colors.blue.150 | #DEECF2 |
colors.blue.200 | #B1D0E1 |
colors.blue.300 | #66B0D9 |
colors.blue.400 | #1B8FD2 |
colors.blue.500 | #055F96 |
colors.blue.600 | #0D477B |
colors.blue.700 | #143764 |
colors.blue.800 | #10223A |
colors.blue.900 | #080D16 |
red
| Path | Value |
|---|---|
colors.red.100 | #FBEAEA |
colors.red.150 | #F8D8D8 |
colors.red.200 | #F4CACA |
colors.red.300 | #EAA3A3 |
colors.red.400 | #DE7979 |
colors.red.500 | #D25151 |
colors.red.600 | #C14545 |
colors.red.700 | #B03939 |
colors.red.800 | #8F2E2E |
colors.red.900 | #4D1818 |
green
| Path | Value |
|---|---|
colors.green.100 | #E9F3E6 |
colors.green.150 | #DEEDD9 |
colors.green.200 | #C5E0BC |
colors.green.300 | #9CCB8E |
colors.green.400 | #6DB258 |
colors.green.500 | #4A9A36 |
colors.green.600 | #3D8C29 |
colors.green.700 | #347A22 |
colors.green.800 | #2A641B |
colors.green.900 | #14300D |
yellow
| Path | Value |
|---|---|
colors.yellow.100 | #FFF9E5 |
colors.yellow.150 | #FFF4D1 |
colors.yellow.200 | #FFF0B8 |
colors.yellow.300 | #FFE066 |
colors.yellow.400 | #FABE00 |
colors.yellow.500 | #E0A800 |
colors.yellow.600 | #B8860B |
colors.yellow.700 | #8B6914 |
colors.yellow.800 | #5C4510 |
colors.yellow.900 | #3D2E0A |
purple
| Path | Value |
|---|---|
colors.purple.100 | #F0EDF6 |
colors.purple.150 | #E5E1EF |
colors.purple.200 | #D9D2E8 |
colors.purple.300 | #BEB2D6 |
colors.purple.400 | #9F8FC2 |
colors.purple.500 | #7C6CAD |
colors.purple.600 | #6A5A9A |
colors.purple.700 | #5E4D89 |
colors.purple.800 | #4D3F71 |
colors.purple.900 | #2B2341 |
orange
| Path | Value |
|---|---|
colors.orange.100 | #FFF3E5 |
colors.orange.150 | #FFEAD1 |
colors.orange.200 | #FFE0B8 |
colors.orange.300 | #FFC980 |
colors.orange.400 | #FFB24D |
colors.orange.500 | #FFA014 |
colors.orange.600 | #E68C00 |
colors.orange.700 | #CC7A00 |
colors.orange.800 | #A36200 |
colors.orange.900 | #523100 |
marianBlue
| Path | Value |
|---|---|
colors.marianBlue.100 | #E0F7FA |
colors.marianBlue.150 | #D3F3F8 |
colors.marianBlue.200 | #B2EBF2 |
colors.marianBlue.300 | #80DEEA |
colors.marianBlue.400 | #4DD0E1 |
colors.marianBlue.500 | #26C6DA |
colors.marianBlue.600 | #00ACC1 |
colors.marianBlue.700 | #0097A7 |
colors.marianBlue.800 | #00838F |
colors.marianBlue.900 | #006064 |
brand
| Path | Value |
|---|---|
colors.brand.dunkelblau | #143764 |
colors.brand.orange | #FFA014 |
colors.brand.gelb | #FABE00 |
colors.brand.creme | #FAFAEB |
Spacing — 37 tokens
| Path | Value |
|---|---|
spacing.Space0 | 0 |
spacing.Space0_5 | 2 |
spacing.Space1 | 4 |
spacing.Space1_5 | 6 |
spacing.Space2 | 8 |
spacing.Space2_25 | 9 |
spacing.Space2_5 | 10 |
spacing.Space2_75 | 11 |
spacing.Space3 | 12 |
spacing.Space3_25 | 13 |
spacing.Space3_5 | 14 |
spacing.Space3_75 | 15 |
spacing.Space4 | 16 |
spacing.Space4_25 | 17 |
spacing.Space4_5 | 18 |
spacing.Space5 | 20 |
spacing.Space5_5 | 22 |
spacing.Space6 | 24 |
spacing.Space7 | 28 |
spacing.Space8 | 32 |
spacing.Space9 | 36 |
spacing.Space10 | 40 |
spacing.Space11 | 44 |
spacing.Space12 | 48 |
spacing.Space13 | 52 |
spacing.Space14 | 56 |
spacing.Space14_25 | 57 |
spacing.Space15 | 60 |
spacing.Space16 | 64 |
spacing.Space18 | 72 |
spacing.Space20 | 80 |
spacing.Space22 | 88 |
spacing.Space24 | 96 |
spacing.Space28 | 112 |
spacing.Space32 | 128 |
spacing.Space40 | 160 |
spacing.Space48 | 192 |
Font Weights — 4 tokens
| Path | Value |
|---|---|
fontWeights.Normal | 400 |
fontWeights.Medium | 500 |
fontWeights.SemiBold | 600 |
fontWeights.Bold | 700 |
Opacity — 14 rungs
| Path | Value |
|---|---|
opacity.scale0 | 0 |
opacity.scale10 | 0.1 |
opacity.scale15 | 0.15 |
opacity.scale25 | 0.25 |
opacity.scale40 | 0.4 |
opacity.scale45 | 0.45 |
opacity.scale50 | 0.5 |
opacity.scale60 | 0.6 |
opacity.scale70 | 0.7 |
opacity.scale75 | 0.75 |
opacity.scale80 | 0.8 |
opacity.scale90 | 0.9 |
opacity.scale95 | 0.95 |
opacity.scale100 | 1 |
Semantic — theme-aware (Dark/Light) + breakpoint-aware
Theme-aware (dark / light)
background — 6 tokens
| Path | Dark | Light |
|---|---|---|
background.canvas | #080D16 | #F8F9FA |
background.surface | #3A474F | #F5EBDB |
background.surfaceRaised | #252F35 | #FFFFFF |
background.surfaceSunken | #141B1F | #EBF0F2 |
background.inverse | #FFFFFF | #080D16 |
background.elevated | #141B1F | #FFFFFF |
text — 12 tokens
| Path | Dark | Light |
|---|---|---|
text.display | #FFFFFF | #080D16 |
text.heading | #F8F9FA | #10223A |
text.primary | #EBF0F2 | #252F35 |
text.secondary | #CFD9DD | #556570 |
text.tertiary | #A8B5BC | #7A8B94 |
text.disabled | #7A8B94 | #A8B5BC |
text.disabledOnPrimary | #7A8B94 | #EBF0F2 |
text.inverse | #080D16 | #FFFFFF |
text.onColor | #FFFFFF | #FFFFFF |
text.link | #66B0D9 | #0D477B |
text.linkHover | #1B8FD2 | #1B8FD2 |
text.linkVisited | #9F8FC2 | #5E4D89 |
border — 6 tokens
| Path | Dark | Light |
|---|---|---|
border.default | #3A474F | #CFD9DD |
border.subtle | #252F35 | #DCE4E8 |
border.strong | #556570 | #A8B5BC |
border.focus | #055F96 | #1B8FD2 |
border.focusStrong | #1B8FD2 | #0D477B |
border.disabled | #3A474F | #DCE4E8 |
primary — 9 tokens
| Path | Dark | Light |
|---|---|---|
primary.background.default | #055F96 | #055F96 |
primary.background.hover | #1B8FD2 | #1B8FD2 |
primary.background.pressed | #0D477B | #0D477B |
primary.background.disabled | #10223A | #B1D0E1 |
primary.subtle | #080D16 | #EEF3F5 |
primary.text.default | #FFFFFF | #FFFFFF |
primary.text.disabled | #1B8FD2 | #FFFFFF |
primary.text.secondary | #B1D0E1 | #66B0D9 |
primary.text.secondaryDisabled | #055F96 | #EEF3F5 |
secondary — 9 tokens
| Path | Dark | Light |
|---|---|---|
secondary.background.default | #3A474F | #EBF0F2 |
secondary.background.hover | #556570 | #DCE4E8 |
secondary.background.pressed | #3A474F | #CFD9DD |
secondary.background.disabled | #252F35 | #F8F9FA |
secondary.border | #556570 | #CFD9DD |
secondary.text.default | #EBF0F2 | #252F35 |
secondary.text.disabled | #7A8B94 | #A8B5BC |
secondary.text.secondary | #DCE4E8 | #556570 |
secondary.text.secondaryDisabled | #3A474F | #CFD9DD |
tertiary — 6 tokens
| Path | Dark | Light |
|---|---|---|
tertiary.hover | #3A474F | #EBF0F2 |
tertiary.pressed | #252F35 | #DCE4E8 |
tertiary.text.default | #EBF0F2 | #252F35 |
tertiary.text.disabled | #7A8B94 | #A8B5BC |
tertiary.text.secondary | #DCE4E8 | #556570 |
tertiary.text.secondaryDisabled | #556570 | #CFD9DD |
destructive — 19 tokens
| Path | Dark | Light |
|---|---|---|
destructive.background.default | #D25151 | #B03939 |
destructive.background.hover | #DE7979 | #C14545 |
destructive.background.pressed | #C14545 | #8F2E2E |
destructive.background.disabled | #10223A | #EAA3A3 |
destructive.subtle | #4D1818 | #FBEAEA |
destructive.text | #DE7979 | #8F2E2E |
destructive.border | #D25151 | #B03939 |
destructive.secondary.default | #4D1818 | #FBEAEA |
destructive.secondary.hover | #8F2E2E | #F4CACA |
destructive.secondary.pressed | #B03939 | #EAA3A3 |
destructive.secondary.disabled | #4D1818 | #FBEAEA |
destructive.textColors.default | #DE7979 | #8F2E2E |
destructive.textColors.disabled | #10223A | #EAA3A3 |
destructive.textColors.secondary | #D25151 | #B03939 |
destructive.textColors.secondaryDisabled | #8F2E2E | #DE7979 |
destructive.textOnBackground.default | #141B1F | #FFFFFF |
destructive.textOnBackground.disabled | #DE7979 | #D25151 |
destructive.textOnBackground.secondary | #4D1818 | #F4CACA |
destructive.textOnBackground.secondaryDisabled | #D25151 | #DE7979 |
constructive — 19 tokens
| Path | Dark | Light |
|---|---|---|
constructive.background.default | #4A9A36 | #347A22 |
constructive.background.hover | #6DB258 | #3D8C29 |
constructive.background.pressed | #3D8C29 | #2A641B |
constructive.background.disabled | #2A641B | #9CCB8E |
constructive.subtle | #14300D | #E9F3E6 |
constructive.text | #6DB258 | #2A641B |
constructive.border | #4A9A36 | #347A22 |
constructive.secondary.default | #14300D | #E9F3E6 |
constructive.secondary.hover | #2A641B | #C5E0BC |
constructive.secondary.pressed | #347A22 | #9CCB8E |
constructive.secondary.disabled | #14300D | #E9F3E6 |
constructive.textColors.default | #6DB258 | #2A641B |
constructive.textColors.disabled | #2A641B | #9CCB8E |
constructive.textColors.secondary | #4A9A36 | #347A22 |
constructive.textColors.secondaryDisabled | #14300D | #6DB258 |
constructive.textOnBackground.default | #141B1F | #FFFFFF |
constructive.textOnBackground.disabled | #6DB258 | #4A9A36 |
constructive.textOnBackground.secondary | #14300D | #C5E0BC |
constructive.textOnBackground.secondaryDisabled | #4A9A36 | #6DB258 |
ai — 32 tokens
| Path | Dark | Light |
|---|---|---|
ai.background.default | #7C6CAD | #5E4D89 |
ai.background.hover | #9F8FC2 | #6A5A9A |
ai.background.pressed | #6A5A9A | #4D3F71 |
ai.background.disabled | #4D3F71 | #BEB2D6 |
ai.subtle | #2B2341 | #F0EDF6 |
ai.text | #9F8FC2 | #4D3F71 |
ai.border | #7C6CAD | #5E4D89 |
ai.textColors.default | #B1D0E1 | #4D3F71 |
ai.textColors.disabled | #4D3F71 | #BEB2D6 |
ai.textColors.secondary | #7C6CAD | #5E4D89 |
ai.textColors.secondaryDisabled | #2B2341 | #9F8FC2 |
ai.textOnBackground.default | #FFFFFF | #FFFFFF |
ai.textOnBackground.disabled | #9F8FC2 | #FFFFFF |
ai.textOnBackground.secondary | #F0EDF6 | #FFFFFF |
ai.textOnBackground.secondaryDisabled | #BEB2D6 | #FFFFFF |
ai.gradient.defaultStart | #0097A7 | #00ACC1 |
ai.gradient.defaultEnd | #4D3F71 | #5E4D89 |
ai.gradient.hoverStart | #00ACC1 | #26C6DA |
ai.gradient.hoverEnd | #5E4D89 | #6A5A9A |
ai.gradient.pressedStart | #00838F | #0097A7 |
ai.gradient.pressedEnd | #2B2341 | #4D3F71 |
ai.gradient.disabledStart | #00838F | #B2EBF2 |
ai.gradient.disabledEnd | #6A5A9A | #9F8FC2 |
ai.gradient.angleDeg | 135 | 135 |
ai.gradientSecondary.defaultStart | #006064 | #B2EBF2 |
ai.gradientSecondary.defaultEnd | #2B2341 | #D9D2E8 |
ai.gradientSecondary.hoverStart | #00838F | #E0F7FA |
ai.gradientSecondary.hoverEnd | #4D3F71 | #F0EDF6 |
ai.gradientSecondary.pressedStart | #006064 | #80DEEA |
ai.gradientSecondary.pressedEnd | #2B2341 | #BEB2D6 |
ai.gradientSecondary.disabledStart | #006064 | #E0F7FA |
ai.gradientSecondary.disabledEnd | #2B2341 | #F0EDF6 |
warning — 8 tokens
| Path | Dark | Light |
|---|---|---|
warning.default | #FABE00 | #FABE00 |
warning.hover | #FFE066 | #E0A800 |
warning.pressed | #E0A800 | #B8860B |
warning.disabled | #5C4510 | #FFF4D1 |
warning.subtle | #3D2E0A | #FFF9E5 |
warning.text | #FFE066 | #5C4510 |
warning.border | #E0A800 | #E0A800 |
warning.textOnBackground | #141B1F | #141B1F |
accent — 7 tokens
| Path | Dark | Light |
|---|---|---|
accent.default | #6A5A9A | #7C6CAD |
accent.hover | #5E4D89 | #5E4D89 |
accent.pressed | #4D3F71 | #4D3F71 |
accent.disabled | #4D3F71 | #BEB2D6 |
accent.subtle | #2B2341 | #F0EDF6 |
accent.text | #9F8FC2 | #5E4D89 |
accent.textOnBackground | #FFFFFF | #FFFFFF |
input — 8 tokens
| Path | Dark | Light |
|---|---|---|
input.background | #3A474F | #FFFFFF |
input.backgroundDisabled | #252F35 | #EBF0F2 |
input.border | #556570 | #CFD9DD |
input.borderHover | #7A8B94 | #A8B5BC |
input.borderFocus | #055F96 | #1B8FD2 |
input.borderError | #D25151 | #C14545 |
input.borderSuccess | #4A9A36 | #347A22 |
input.placeholder | #A8B5BC | #7A8B94 |
brand — 8 tokens
| Path | Dark | Light |
|---|---|---|
brand.dunkelblau | #143764 | #143764 |
brand.orange | #FFA014 | #FFA014 |
brand.gelb | #FABE00 | #FABE00 |
brand.creme | #FAFAEB | #FAFAEB |
brand.primary | #FAFAEB | #143764 |
brand.accent | #FABE00 | #FFA014 |
brand.text | #FFFFFF | #071322 |
brand.background | #143764 | #FFFFFF |
overlay — 4 tokens
| Path | Dark | Light |
|---|---|---|
overlay.light | {"color":"#000000","alpha":0.2} | {"color":"#000000","alpha":0.2} |
overlay.medium | {"color":"#000000","alpha":0.5} | {"color":"#000000","alpha":0.5} |
overlay.heavy | {"color":"#000000","alpha":0.7} | {"color":"#000000","alpha":0.7} |
overlay.strong | {"color":"#000000","alpha":0.8} | {"color":"#000000","alpha":0.8} |
Responsive (mobile / tablet / desktop / tv)
typography — 37 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
typography.display | 57 | 57 | 80 | 128 |
typography.h1 | 32 | 32 | 48 | 96 |
typography.h2 | 28 | 28 | 36 | 80 |
typography.h3 | 24 | 24 | 28 | 64 |
typography.h4 | 22 | 22 | 24 | 56 |
typography.bodyLarge | 18 | 18 | 18 | 52 |
typography.body | 16 | 16 | 16 | 48 |
typography.bodySmall | 14 | 14 | 14 | 40 |
typography.label | 12 | 12 | 12 | 36 |
typography.caption | 11 | 11 | 11 | 32 |
typography.overline | 10 | 10 | 10 | 28 |
typography.finePrint | 9 | 9 | 9 | 24 |
typography.micro | 8 | 8 | 8 | 0 |
typography.xs | 12 | 12 | 12 | 24 |
typography.sm | 14 | 14 | 14 | 32 |
typography.base | 16 | 16 | 16 | 40 |
typography.md | 16 | 16 | 18 | 44 |
typography.lg | 18 | 18 | 20 | 48 |
typography.xl | 20 | 20 | 24 | 56 |
typography.xxl | 24 | 24 | 28 | 64 |
typography.xxxl | 32 | 32 | 36 | 80 |
typography.xxxxl | 40 | 40 | 48 | 96 |
typography.heroTitle | 26 | 32 | 40 | 64 |
typography.heroSubtitle | 14 | 16 | 18 | 28 |
typography.screenTitle | 32 | 40 | 48 | 80 |
typography.playerTitle | 18 | 18 | 18 | 18 |
typography.playerTime | 12 | 12 | 12 | 12 |
typography.epgTime | 10 | 10 | 10 | 10 |
typography.epgTitle | 14 | 14 | 14 | 14 |
typography.epgDetail | 11 | 11 | 11 | 11 |
typography.bodyMedium | 15 | 15 | 15 | 45 |
typography.labelMedium | 13 | 13 | 13 | 39 |
typography.lineHeightNotificationTitle | 28 | 28 | 28 | 84 |
typography.lineHeightNotificationBody | 24 | 24 | 24 | 72 |
typography.lineHeightHeroTitle | 28 | 34 | 43 | 68 |
typography.lineHeightHeroSub | 19 | 22 | 24 | 38 |
typography.letterSpacingOverline | 1 | 1 | 1 | 3 |
spacing — 24 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
spacing.gap4xs | 2 | 2 | 2 | 4 |
spacing.gap3xs | 4 | 4 | 4 | 8 |
spacing.gap2_5xs | 6 | 6 | 6 | 12 |
spacing.gap2xs | 8 | 8 | 8 | 16 |
spacing.gapXs | 10 | 10 | 10 | 20 |
spacing.gapSm | 12 | 12 | 12 | 24 |
spacing.gapDefault | 16 | 16 | 16 | 32 |
spacing.gapM | 20 | 20 | 20 | 40 |
spacing.gapL | 24 | 24 | 24 | 48 |
spacing.gapXl | 28 | 28 | 28 | 56 |
spacing.gap2xl | 32 | 32 | 32 | 64 |
spacing.gap3xl | 36 | 36 | 36 | 72 |
spacing.gap4xl | 40 | 40 | 40 | 80 |
spacing.gap8xl | 96 | 96 | 96 | 192 |
spacing.paddingSm | 12 | 14 | 16 | 24 |
spacing.paddingDefault | 16 | 20 | 24 | 40 |
spacing.paddingLg | 24 | 32 | 48 | 64 |
spacing.sectionGap | 32 | 80 | 48 | 80 |
spacing.sectionMargin | 16 | 48 | 24 | 48 |
spacing.elevationCard | 8 | 8 | 8 | 8 |
spacing.elevationHero | 16 | 16 | 16 | 16 |
spacing.dividerHeight | 0.5 | 0.5 | 0.5 | 0.5 |
spacing.gap5xl | 48 | 64 | 48 | 96 |
spacing.bottomScrollPadding | 100 | 120 | 100 | 200 |
radius — 11 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
radius.none | 0 | 0 | 0 | 0 |
radius.sm | 4 | 8 | 4 | 8 |
radius.default | 8 | 16 | 8 | 16 |
radius.md | 12 | 24 | 12 | 24 |
radius.lg | 16 | 32 | 16 | 32 |
radius.xl | 20 | 40 | 20 | 40 |
radius.xxl | 24 | 48 | 24 | 48 |
radius.xxxl | 28 | 0 | 0 | 0 |
radius.xxxxl | 32 | 0 | 0 | 0 |
radius.xxxxxl | 36 | 0 | 0 | 0 |
radius.full | 9999 | 9999 | 9999 | 9999 |
icons — 9 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
icons.xs | 16 | 16 | 16 | 32 |
icons.sm | 20 | 20 | 20 | 40 |
icons.md | 24 | 24 | 24 | 48 |
icons.lg | 32 | 32 | 32 | 64 |
icons.xl | 36 | 36 | 36 | 72 |
icons.xxl | 40 | 40 | 40 | 80 |
icons.xxxl | 56 | 56 | 56 | 112 |
icons.xxxxl | 80 | 80 | 80 | 160 |
icons.xxxxxl | 96 | 96 | 96 | 192 |
buttons — 17 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
buttons.xsmall.height | 28 | 36 | 28 | 48 |
buttons.xsmall.paddingX | 12 | 16 | 12 | 24 |
buttons.xsmall.paddingY | 6 | 8 | 6 | 12 |
buttons.xsmall.fontSize | 12 | 14 | 12 | 24 |
buttons.small.height | 32 | 44 | 32 | 56 |
buttons.small.paddingX | 16 | 24 | 16 | 32 |
buttons.small.paddingY | 8 | 12 | 8 | 16 |
buttons.small.fontSize | 14 | 16 | 14 | 28 |
buttons.medium.height | 40 | 56 | 40 | 72 |
buttons.medium.paddingX | 20 | 32 | 20 | 48 |
buttons.medium.paddingY | 10 | 16 | 10 | 24 |
buttons.medium.fontSize | 16 | 18 | 16 | 32 |
buttons.radius | 8 | 16 | 8 | 16 |
buttons.height | 44 | 64 | 40 | 64 |
buttons.paddingX | 24 | 40 | 24 | 40 |
buttons.paddingY | 12 | 20 | 12 | 20 |
buttons.fontSize | 16 | 16 | 16 | 32 |
input — 4 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
input.height | 44 | 64 | 40 | 64 |
input.paddingX | 16 | 32 | 16 | 32 |
input.radius | 8 | 16 | 8 | 16 |
input.fontSize | 16 | 16 | 16 | 32 |
card — 4 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
card.padding | 16 | 20 | 24 | 40 |
card.gap | 16 | 16 | 16 | 32 |
card.radius | 16 | 16 | 16 | 32 |
card.nestedRadius | 0 | 0 | 0 | 0 |
videoCard — 3 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
videoCard.padding | 12 | 14 | 16 | 32 |
videoCard.radius | 16 | 16 | 16 | 32 |
videoCard.thumbnailRadius | 4 | 4 | 4 | 8 |
navigation — 2 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
navigation.navBarHeight | 56 | 80 | 64 | 80 |
navigation.tabBarHeight | 48 | 80 | 56 | 80 |
focusRing — 2 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
focusRing.width | 2 | 2 | 2 | 4 |
focusRing.offset | 4 | 4 | 4 | 8 |
safeArea — 4 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|---|---|---|---|
safeArea.top | 44 | 32 | 0 | 32 |
safeArea.bottom | 32 | 32 | 0 | 32 |
safeArea.left | 0 | 48 | 0 | 48 |
safeArea.right | 0 | 48 | 0 | 48 |
effect — 0 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|
elevation — 0 tokens
| Path | Mobile | Tablet | Desktop | TV |
|---|
Component — Komposition pro Komponente
hero — 21 tokens
| Path | Dark | Light |
|---|---|---|
hero.titleMaxFontSize | 26 | 26 |
hero.titleMinFontSize | 16 | 16 |
hero.autoAdvanceIntervalMs | 3000 | 3000 |
hero.aspectRatioMobile | 0.75 | 0.75 |
hero.aspectRatioTablet | 0.394 | 0.394 |
hero.indicatorDotSize | 6 | 6 |
hero.indicatorDotSelectedWidth | 20 | 20 |
hero.indicatorDotSpacing | 3 | 3 |
hero.cornerRadius | 24 | 24 |
hero.contentPadding | 24 | 24 |
hero.pageSpacing | 16 | 16 |
hero.tabletTitleFontSize | 26 | 26 |
hero.tabletTitleLineHeight | 28 | 28 |
hero.tabletTitleFontWeight | Medium | Medium |
hero.tabletDescFontSize | 14 | 14 |
hero.tabletDescLineHeight | 18 | 18 |
hero.tabletDescFontWeight | Medium | Medium |
hero.mobileTitleFontSize | 16 | 16 |
hero.mobileTitleFontWeight | SemiBold | SemiBold |
hero.mobileDescFontSize | 12 | 12 |
hero.mobileDescFontWeight | Normal | Normal |
cardOverlay — 15 tokens
| Path | Dark | Light |
|---|---|---|
cardOverlay.gradientStartOpacity | 0 | 0 |
cardOverlay.gradientEndOpacity | 1 | 0.85 |
cardOverlay.cornerRadius | 16 | 16 |
cardOverlay.textPadding | 16 | 16 |
cardOverlay.titleFontSize | 14 | 14 |
cardOverlay.titleFontWeight | SemiBold | SemiBold |
cardOverlay.titleLineHeight | 15 | 15 |
cardOverlay.subtitleFontSize | 11 | 11 |
cardOverlay.subtitleFontWeight | Normal | Normal |
cardOverlay.subtitleLineHeight | 13 | 13 |
cardOverlay.metadataFontSize | 10 | 10 |
cardOverlay.metadataFontWeight | Normal | Normal |
cardOverlay.metadataLineHeight | 13 | 13 |
cardOverlay.eyebrowTitleSpacing | 2 | 2 |
cardOverlay.actionInset | 48 | 48 |
cardSplit — 15 tokens
| Path | Dark | Light |
|---|---|---|
cardSplit.infoAreaPaddingHorizontal | 10 | 10 |
cardSplit.infoAreaPaddingVertical | 10 | 10 |
cardSplit.cornerRadius | 16 | 16 |
cardSplit.titleFontSize | 14 | 14 |
cardSplit.titleFontWeight | SemiBold | SemiBold |
cardSplit.titleLineHeight | 15 | 15 |
cardSplit.subtitleFontSize | 11 | 11 |
cardSplit.subtitleFontWeight | Normal | Normal |
cardSplit.subtitleLineHeight | 13 | 13 |
cardSplit.metadataFontSize | 10 | 10 |
cardSplit.metadataFontWeight | Normal | Normal |
cardSplit.metadataLineHeight | 13 | 13 |
cardSplit.titleFooterSpacing | 16 | 16 |
cardSplit.eyebrowTitleSpacing | 2 | 2 |
cardSplit.actionInset | 48 | 48 |
cardImageOnly — 5 tokens
| Path | Dark | Light |
|---|---|---|
cardImageOnly.cornerRadius | 16 | 16 |
cardImageOnly.widthSmall | 200 | 200 |
cardImageOnly.widthMedium | 280 | 280 |
cardImageOnly.widthLarge | 360 | 360 |
cardImageOnly.bgColor | #141B1F | #FFFFFF |
cardPoster — 12 tokens
| Path | Dark | Light |
|---|---|---|
cardPoster.cornerRadius | 16 | 16 |
cardPoster.widthSmall | 120 | 120 |
cardPoster.widthMedium | 160 | 160 |
cardPoster.widthLarge | 200 | 200 |
cardPoster.aspectRatio | 0.5625 | 0.5625 |
cardPoster.borderColor | rgba(255, 255, 255, 0.15) | rgba(26, 26, 26, 0.15) |
cardPoster.borderWidth | 1 | 1 |
cardPoster.shadowElevation | 8 | 8 |
cardPoster.shadowColor | rgba(0, 0, 0, 0.3) | rgba(0, 0, 0, 0.3) |
cardPoster.bgColor | #141B1F | #FFFFFF |
cardPoster.fallbackGradientEnd | #080D16 | #FFFFFF |
cardPoster.fallbackTextColor | #EBF0F2 | #252F35 |
cardTop10 — 10 tokens
| Path | Dark | Light |
|---|---|---|
cardTop10.rankFontSize | 64 | 64 |
cardTop10.cardAspectRatio | 0.5625 | 0.5625 |
cardTop10.gradientColors | [ #7A8B94 #252F35 ] | [ #F8F9FA #CFD9DD ] |
cardTop10.posterWidth | 160 | 160 |
cardTop10.rankNumFontSize | 200 | 200 |
cardTop10.rankNumStrokeWidth | 3 | 3 |
cardTop10.rankNumLetterOverlapFactor | -0.2 | -0.2 |
cardTop10.rankNumOuterOverlap | 100 | 100 |
cardTop10.rankNumBorderColorDark | rgba(255, 255, 255, 0.25) | rgba(255, 255, 255, 0.25) |
cardTop10.rankNumBorderColorLight | rgba(26, 26, 26, 0.25) | rgba(26, 26, 26, 0.25) |
playlist — 5 tokens
| Path | Dark | Light |
|---|---|---|
playlist.rowHorizontalPadding | 16 | 16 |
playlist.rowItemSpacing | 16 | 16 |
playlist.rowTitleFontSize | 16 | 16 |
playlist.rowTitleFontWeight | Medium | Medium |
playlist.rowTitleBottomSpacing | 8 | 8 |
section — 2 tokens
| Path | Dark | Light |
|---|---|---|
section.verticalSpacing | 24 | 24 |
section.horizontalPadding | 16 | 16 |
buttonGroup — 3 tokens
| Path | Dark | Light |
|---|---|---|
buttonGroup.spacing | 8 | 8 |
buttonGroup.cornerRadius | 50 | 50 |
buttonGroup.height | 32 | 32 |
badge — 9 tokens
| Path | Dark | Light |
|---|---|---|
badge.height | 18 | 18 |
badge.cornerRadius | 34 | 34 |
badge.horizontalPadding | 8 | 8 |
badge.fontSize | 10 | 10 |
badge.fontWeight | Medium | Medium |
badge.letterSpacing | 0.5 | 0.5 |
badge.defaultBackgroundColor | rgba(0, 0, 0, 0.6) | rgba(0, 0, 0, 0.6) |
badge.textColor | #FFFFFF | #FFFFFF |
badge.textColorOnLight | #1A1A1A | #1A1A1A |
cardAvatar — 9 tokens
| Path | Dark | Light |
|---|---|---|
cardAvatar.initialsFontWeight | Bold | Bold |
cardAvatar.labelFontSize | 12 | 12 |
cardAvatar.labelFontWeight | Medium | Medium |
cardAvatar.defaultSize | 128 | 128 |
cardAvatar.borderColor | rgba(255, 255, 255, 0.15) | rgba(26, 26, 26, 0.15) |
cardAvatar.borderWidth | 1 | 1 |
cardAvatar.shadowElevation | 8 | 8 |
cardAvatar.shadowColor | rgba(0, 0, 0, 0.3) | rgba(0, 0, 0, 0.3) |
cardAvatar.bgColor | #141B1F | #FFFFFF |
liveTv — 3 tokens
| Path | Dark | Light |
|---|---|---|
liveTv.progressBarColor | #FFA014 | #FFA014 |
liveTv.livePillBackgroundColor | #FF0000 | #FF0000 |
liveTv.channelLogoSize | 48 | 48 |
progress — 4 tokens
| Path | Dark | Light |
|---|---|---|
progress.barHeight | 4 | 4 |
progress.barCornerRadius | 2 | 2 |
progress.barColor | #FFFFFF | #141B1F |
progress.barTrackColor | rgba(255, 255, 255, 0.15) | rgba(0, 0, 0, 0.15) |
heroCarousel — 17 tokens
| Path | Dark | Light |
|---|---|---|
heroCarousel.autoAdvanceIntervalMs | 3000 | 3000 |
heroCarousel.aspectRatioMobile | 0.75 | 0.75 |
heroCarousel.aspectRatioTablet | 0.394 | 0.394 |
heroCarousel.indicatorDotSize | 6 | 6 |
heroCarousel.indicatorDotSelectedWidth | 20 | 20 |
heroCarousel.indicatorDotSpacing | 3 | 3 |
heroCarousel.indicatorDotCornerRadius | 4 | 4 |
heroCarousel.contentPadding | 24 | 24 |
heroCarousel.pageSpacing | 16 | 16 |
heroCarousel.cornerRadius | 24 | 24 |
heroCarousel.borderColor | rgba(255, 255, 255, 0.15) | rgba(26, 26, 26, 0.15) |
heroCarousel.borderWidth | 1 | 1 |
heroCarousel.elevation | 16 | 16 |
heroCarousel.shadowColor | #000000 | #000000 |
heroCarousel.tabletImageWidthFraction | 0.7 | 0.7 |
heroCarousel.tabletGradientWidthFraction | 0.5 | 0.6 |
heroCarousel.tabletContentWidthFraction | 0.4 | 0.4 |
heroVideo — 35 tokens
| Path | Dark | Light |
|---|---|---|
heroVideo.titleMaxFontSize | 26 | 26 |
heroVideo.titleMinFontSize | 16 | 16 |
heroVideo.tabletTitleFontSize | 26 | 26 |
heroVideo.tabletTitleLineHeight | 28 | 28 |
heroVideo.tabletTitleFontWeight | Medium | Medium |
heroVideo.tabletDescFontSize | 14 | 14 |
heroVideo.tabletDescLineHeight | 18 | 18 |
heroVideo.tabletDescFontWeight | Medium | Medium |
heroVideo.mobileTitleFontSize | 16 | 16 |
heroVideo.mobileTitleFontWeight | SemiBold | SemiBold |
heroVideo.mobileDescFontSize | 12 | 12 |
heroVideo.mobileDescFontWeight | Normal | Normal |
heroVideo.contentTopPadding | 140 | 140 |
heroVideo.iconSize | 14 | 14 |
heroVideo.maxLines.mobileTitle | 2 | 2 |
heroVideo.maxLines.mobileDescription | 3 | 3 |
heroVideo.maxLines.tabletPortraitTitle | 2 | 2 |
heroVideo.maxLines.tabletPortraitDescription | 3 | 3 |
heroVideo.maxLines.tabletPortraitCrowdedTitle | 2 | 2 |
heroVideo.maxLines.tabletPortraitCrowdedDescription | 2 | 2 |
heroVideo.maxLines.tabletLandscapeTitle | 3 | 3 |
heroVideo.maxLines.tabletLandscapeDescription | 5 | 5 |
heroVideo.titleFontSize.thresholdShortChars | 20 | 20 |
heroVideo.titleFontSize.thresholdMediumChars | 30 | 30 |
heroVideo.titleFontSize.thresholdLongChars | 40 | 40 |
heroVideo.titleFontSize.thresholdVeryLongChars | 50 | 50 |
heroVideo.titleFontSize.spShort | 28 | 28 |
heroVideo.titleFontSize.spMedium | 24 | 24 |
heroVideo.titleFontSize.spLong | 22 | 22 |
heroVideo.titleFontSize.spVeryLong | 20 | 20 |
heroVideo.titleFontSize.spXVeryLong | 18 | 18 |
heroVideo.titleFontSize.lineHeightDelta | 2 | 2 |
heroVideo.gradient.stops | [ [object Object] [object Object] [object Object] [object Object] [object Object] [object Object] [object Object] ] | [ [object Object] [object Object] [object Object] [object Object] [object Object] [object Object] ] |
heroVideo.scrimFadeShift | 52 | 52 |
heroVideo.scrimFadeHeight | 240 | 240 |
heroBibleVerse — 9 tokens
| Path | Dark | Light |
|---|---|---|
heroBibleVerse.tabletTitleFontSize | 26 | 26 |
heroBibleVerse.tabletTitleLineHeight | 28 | 28 |
heroBibleVerse.tabletTitleFontWeight | Medium | Medium |
heroBibleVerse.tabletDescFontSize | 14 | 14 |
heroBibleVerse.tabletDescLineHeight | 18 | 18 |
heroBibleVerse.tabletDescFontWeight | Medium | Medium |
heroBibleVerse.mobileVerseFontSizeMax | 36 | 36 |
heroBibleVerse.mobileVerseFontSizeMin | 16 | 16 |
heroBibleVerse.contentTopPadding | 140 | 140 |
heroFeatured — 18 tokens
| Path | Dark | Light |
|---|---|---|
heroFeatured.tabletTitleFontSize | 26 | 26 |
heroFeatured.tabletTitleLineHeight | 28 | 28 |
heroFeatured.tabletTitleFontWeight | Medium | Medium |
heroFeatured.tabletDescFontSize | 14 | 14 |
heroFeatured.tabletDescLineHeight | 18 | 18 |
heroFeatured.tabletDescFontWeight | Medium | Medium |
heroFeatured.mobileTitleFontSize | 16 | 16 |
heroFeatured.mobileTitleFontWeight | SemiBold | SemiBold |
heroFeatured.mobileDescFontSize | 12 | 12 |
heroFeatured.mobileDescFontWeight | Normal | Normal |
heroFeatured.contentTopPadding | 140 | 140 |
heroFeatured.scrimFadeShift | 52 | 52 |
heroFeatured.scrimFadeHeight | 240 | 240 |
heroFeatured.tabletPortraitTitleMaxLines | 2 | 2 |
heroFeatured.tabletPortraitDescMaxLines | 3 | 3 |
heroFeatured.tabletImageWidthFraction | 0.7 | 0.7 |
heroFeatured.tabletGradientWidthFraction | 0.5 | 0.6 |
heroFeatured.tabletContentWidthFraction | 0.4 | 0.4 |
heroLive — 1 tokens
| Path | Dark | Light |
|---|---|---|
heroLive.cornerRadius | 24 | 24 |
cardLandscape — 10 tokens
| Path | Dark | Light |
|---|---|---|
cardLandscape.aspectRatio | 1.7778 | 1.7778 |
cardLandscape.cornerRadius | 12 | 12 |
cardLandscape.borderColor | rgba(255, 255, 255, 0.15) | rgba(26, 26, 26, 0.15) |
cardLandscape.borderWidth | 1 | 1 |
cardLandscape.shadowElevation | 8 | 8 |
cardLandscape.shadowColor | rgba(0, 0, 0, 0.3) | rgba(0, 0, 0, 0.3) |
cardLandscape.bgColor | #141B1F | #FFFFFF |
cardLandscape.fallbackTitleFontSize | 14 | 14 |
cardLandscape.fallbackTitleFontWeight | SemiBold | SemiBold |
cardLandscape.fallbackTitleLineHeight | 15 | 15 |
cardNotification — 1 tokens
| Path | Dark | Light |
|---|---|---|
cardNotification.aspectRatio | 1.7778 | 1.7778 |
mediaCard — 11 tokens
| Path | Dark | Light |
|---|---|---|
mediaCard.defaultMaxWidth | 280 | 280 |
mediaCard.defaultHeight | 256 | 256 |
mediaCard.metadataSpacing | 3 | 3 |
mediaCard.overlayLinearAlphaMiddle | 0.2 | 0.2 |
mediaCard.overlayLinearAlphaEnd | 0.6 | 0.6 |
mediaCard.overlayRadialAlphaMiddle | 0.3 | 0.3 |
mediaCard.overlayRadialAlphaEnd | 0.7 | 0.7 |
mediaCard.overlayAngledAlphaFirst | 0.2 | 0.2 |
mediaCard.overlayAngledAlphaMid | 0.5 | 0.5 |
mediaCard.overlayAngledAlphaEnd | 0.8 | 0.8 |
mediaCard.overlayFallbackAlphaEnd | 0.4 | 0.4 |
programGuideCard — 6 tokens
| Path | Dark | Light |
|---|---|---|
programGuideCard.borderWidth | 1 | 1 |
programGuideCard.cornerRadius | 34 | 34 |
programGuideCard.dotSize | 6 | 6 |
programGuideCard.progressHeight | 3 | 3 |
programGuideCard.progressCornerRadius | 1.5 | 1.5 |
programGuideCard.liveBadgeBackgroundAlpha | 0.4 | 0.4 |
btvTopBar — 1 tokens
| Path | Dark | Light |
|---|---|---|
btvTopBar.height | 42 | 42 |
btvDropdown — 1 tokens
| Path | Dark | Light |
|---|---|---|
btvDropdown.minWidth | 250 | 250 |
loadingSpinner — 1 tokens
| Path | Dark | Light |
|---|---|---|
loadingSpinner.strokeWidth | 3 | 3 |
playlistVideoListItem — 3 tokens
| Path | Dark | Light |
|---|---|---|
playlistVideoListItem.thumbnailWidth | 120 | 120 |
playlistVideoListItem.thumbnailCornerRadius | 2 | 2 |
playlistVideoListItem.thumbnailVerticalPadding | 2 | 2 |
episodeListItem — 1 tokens
| Path | Dark | Light |
|---|---|---|
episodeListItem.aspectRatio | 1.7778 | 1.7778 |
detailHeroMobile — 2 tokens
| Path | Dark | Light |
|---|---|---|
detailHeroMobile.aspectRatio | 1.7778 | 1.7778 |
detailHeroMobile.spacerWidth | 2 | 2 |
protoBibleButton — 4 tokens
| Path | Dark | Light |
|---|---|---|
protoBibleButton.borderWidth | 1 | 1 |
protoBibleButton.pressedAlpha | 0.12 | 0.12 |
protoBibleButton.disabledAlpha | 0.38 | 0.38 |
protoBibleButton.hoverAlpha | 0.08 | 0.08 |
btvLogo — 4 tokens
| Path | Dark | Light |
|---|---|---|
btvLogo.starBibelGap | 5 | 5 |
btvLogo.bibelAppGap | 3 | 3 |
btvLogo.bibelWidth | 52 | 52 |
btvLogo.appWidth | 28 | 28 |
rowSection — 1 tokens
| Path | Dark | Light |
|---|---|---|
rowSection.cardAspectRatio | 0.5625 | 0.5625 |
cardHero — 22 tokens
| Path | Dark | Light |
|---|---|---|
cardHero.cornerRadius | 16 | 16 |
cardHero.minHeight | 480 | 480 |
cardHero.badgePaddingHorizontal | 10 | 10 |
cardHero.badgePaddingVertical | 5 | 5 |
cardHero.badgeCornerRadius | 6 | 6 |
cardHero.badgeFontSize | 11 | 11 |
cardHero.eyebrowFontSize | 12 | 12 |
cardHero.titleFontSize | 22 | 22 |
cardHero.descriptionFontSize | 14 | 14 |
cardHero.buttonHeight | 44 | 44 |
cardHero.buttonFontSize | 14 | 14 |
cardHero.buttonHorizontalPadding | 20 | 20 |
cardHero.gradientStartFraction | 0.3 | 0.3 |
cardHero.contentBottomPadding | 24 | 24 |
cardHero.contentHorizontalPadding | 20 | 20 |
cardHero.eyebrowTopSpacing | 4 | 4 |
cardHero.descriptionTopSpacing | 6 | 6 |
cardHero.buttonTopSpacing | 12 | 12 |
cardHero.progressBarHeight | 4 | 4 |
cardHero.fallbackTitleFontSize | 14 | 14 |
cardHero.fallbackTitleFontWeight | SemiBold | SemiBold |
cardHero.fallbackTitleLineHeight | 15 | 15 |
slider — 4 tokens
| Path | Dark | Light |
|---|---|---|
slider.titleFontSize | 18 | 18 |
slider.titleBottomPadding | 12 | 12 |
slider.horizontalPadding | 16 | 16 |
slider.itemSpacing | 12 | 12 |
sliderHero — 4 tokens
| Path | Dark | Light |
|---|---|---|
sliderHero.heightMobile | 520 | 520 |
sliderHero.heightTablet | 600 | 600 |
sliderHero.autoAdvanceIntervalMs | 5000 | 5000 |
sliderHero.indicatorBottomPadding | 16 | 16 |
bannerOverlay — 2 tokens
| Path | Dark | Light |
|---|---|---|
bannerOverlay.scrimAlpha | 0.6 | 0.6 |
bannerOverlay.closeButtonBackgroundAlpha | 0.5 | 0.5 |
menuDrawer — 2 tokens
| Path | Dark | Light |
|---|---|---|
menuDrawer.rowBgColor | #3A474F | #EBF0F2 |
menuDrawer.overlayBgAlpha | 0.95 | 0.95 |
button — 40 tokens
| Path | Dark | Light |
|---|---|---|
button.primary.bg | rgba(235, 240, 242, 0.75) | #252F35 |
button.primary.bg-pressed | rgba(235, 240, 242, 0.85) | rgba(37, 47, 53, 0.8) |
button.primary.bg-disabled | rgba(235, 240, 242, 0.3) | rgba(37, 47, 53, 0.3) |
button.primary.text | #252F35 | #EBF0F2 |
button.primary.text-disabled | rgba(37, 47, 53, 0.4) | rgba(235, 240, 242, 0.5) |
button.primary.secondary-text | rgba(37, 47, 53, 0.7) | rgba(235, 240, 242, 0.7) |
button.primary.secondary-text-disabled | rgba(37, 47, 53, 0.4) | rgba(235, 240, 242, 0.4) |
button.primary.border | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.secondary.bg | rgba(235, 240, 242, 0.15) | rgba(37, 47, 53, 0.1) |
button.secondary.bg-pressed | rgba(235, 240, 242, 0.25) | rgba(37, 47, 53, 0.15) |
button.secondary.bg-disabled | rgba(235, 240, 242, 0.08) | rgba(37, 47, 53, 0.05) |
button.secondary.text | #EBF0F2 | #252F35 |
button.secondary.text-disabled | rgba(235, 240, 242, 0.4) | rgba(37, 47, 53, 0.4) |
button.secondary.secondary-text | rgba(235, 240, 242, 0.7) | rgba(37, 47, 53, 0.7) |
button.secondary.secondary-text-disabled | rgba(235, 240, 242, 0.4) | rgba(37, 47, 53, 0.3) |
button.secondary.border | rgba(235, 240, 242, 0.3) | rgba(37, 47, 53, 0.15) |
button.tertiary.bg | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.tertiary.bg-pressed | rgba(235, 240, 242, 0.1) | rgba(37, 47, 53, 0.1) |
button.tertiary.bg-disabled | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.tertiary.text | #EBF0F2 | #252F35 |
button.tertiary.text-disabled | rgba(235, 240, 242, 0.4) | rgba(37, 47, 53, 0.4) |
button.tertiary.secondary-text | rgba(235, 240, 242, 0.7) | rgba(37, 47, 53, 0.7) |
button.tertiary.secondary-text-disabled | rgba(235, 240, 242, 0.4) | rgba(37, 47, 53, 0.3) |
button.tertiary.border | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.overlay.bg | rgba(37, 47, 53, 0.5) | rgba(37, 47, 53, 0.5) |
button.overlay.bg-pressed | rgba(37, 47, 53, 0.65) | rgba(37, 47, 53, 0.65) |
button.overlay.bg-disabled | rgba(37, 47, 53, 0.25) | rgba(37, 47, 53, 0.25) |
button.overlay.text | #EBF0F2 | #EBF0F2 |
button.overlay.text-disabled | rgba(235, 240, 242, 0.4) | rgba(235, 240, 242, 0.4) |
button.overlay.secondary-text | rgba(235, 240, 242, 0.7) | rgba(235, 240, 242, 0.7) |
button.overlay.secondary-text-disabled | rgba(235, 240, 242, 0.3) | rgba(235, 240, 242, 0.3) |
button.overlay.border | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.tertiary-dark.bg | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.tertiary-dark.bg-pressed | rgba(37, 47, 53, 0.1) | rgba(37, 47, 53, 0.1) |
button.tertiary-dark.bg-disabled | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
button.tertiary-dark.text | #252F35 | #252F35 |
button.tertiary-dark.text-disabled | rgba(37, 47, 53, 0.4) | rgba(37, 47, 53, 0.4) |
button.tertiary-dark.secondary-text | rgba(37, 47, 53, 0.7) | rgba(37, 47, 53, 0.7) |
button.tertiary-dark.secondary-text-disabled | rgba(37, 47, 53, 0.3) | rgba(37, 47, 53, 0.3) |
button.tertiary-dark.border | rgba(0, 0, 0, 0) | rgba(0, 0, 0, 0) |
Views (4. Ebene) — Komposition pro Screen (Home)
home — 14 tokens
| Path | Value |
|---|---|
views.home.navBar.borderAlpha | 0.15 |
views.home.videoSurface.background | #000000 |
views.home.modalScrim.base | #000000 |
views.home.modalScrim.alpha | 0.8 |
views.home.layout.contentPaddingHorizontalMobile | 16 |
views.home.layout.contentPaddingHorizontalTablet | 32 |
views.home.layout.sectionGapMobile | 24 |
views.home.layout.sectionGapTablet | 32 |
views.home.layout.contentPaddingTopMobile | 8 |
views.home.layout.contentPaddingTopTablet | 16 |
views.home.layout.contentPaddingBottomMobile | 24 |
views.home.layout.contentPaddingBottomTablet | 32 |
views.home.layout.rowCardSpacingMobile | 16 |
views.home.layout.rowCardSpacingTablet | 16 |
Coverage der in dieser Spec zitierten Pfade
Coverage-Übersicht
| Status | Anzahl | Bedeutung |
|---|---|---|
| ⚙️ API | 168 | Pfad löst aktuell über die Design-Tokens-API auf. Implementierer holen den Wert zur Laufzeit von /api/v1/tokens. |
| 🔧 Kotlin | 76 | Pfad entspricht einem Feld in der Android-Kotlin-Token-Model-Datenklasse (mit Default-Wert), ist aber noch nicht in die API überführt. Implementierer können den Wert bis zur Überführung aus dem Android-Quellcode als kanonischen Default lesen. |
| 📝 Spec-only | 24 | Reiner Spec-Pfad — noch kein Token in irgendeiner Form. Als logischer Platzhalter behandeln; der Implementierer sollte beim Adaptieren der Spec einen echten Token vorschlagen. |
Detaillierte Liste (268 Pfade)
| Pfad | Status |
|---|---|
component.btvLogo.heightMobile | 🔧 Kotlin |
component.btvLogo.heightTablet | 🔧 Kotlin |
component.btvTopBar.actionSpacing | 📝 Spec-only |
component.btvTopBar.horizontalPaddingMobile | 📝 Spec-only |
component.btvTopBar.horizontalPaddingTablet | 📝 Spec-only |
component.btvTopBar.scrollFadeDistanceDp | 📝 Spec-only |
component.btvTopBar.verticalPaddingMobile | 📝 Spec-only |
component.btvTopBar.verticalPaddingTablet | 📝 Spec-only |
component.cardLandscape.aspectRatio | ⚙️ API |
component.heroBibleVerse.tabletContentWidthFraction | 🔧 Kotlin |
component.heroBibleVerse.tabletGradientOnlyContentWidthFraction | 🔧 Kotlin |
component.heroBibleVerse.tabletGradientWidthFraction | 🔧 Kotlin |
component.heroBibleVerse.tabletImageWidthFraction | 🔧 Kotlin |
component.heroCarousel.aspectRatioMobile | ⚙️ API |
component.heroCarousel.aspectRatioTablet | ⚙️ API |
component.heroCarousel.autoAdvanceAnimationDurationMs | 🔧 Kotlin |
component.heroCarousel.autoAdvanceIntervalMs | ⚙️ API |
component.heroCarousel.beyondViewportPageCount | 📝 Spec-only |
component.heroCarousel.borderColor | ⚙️ API |
component.heroCarousel.borderWidth | ⚙️ API |
component.heroCarousel.colorEmitThresholdMs | 🔧 Kotlin |
component.heroCarousel.contentPadding | ⚙️ API |
component.heroCarousel.cornerRadius | ⚙️ API |
component.heroCarousel.elevation | ⚙️ API |
component.heroCarousel.indicatorDotCornerRadius | ⚙️ API |
component.heroCarousel.indicatorDotSelectedWidth | ⚙️ API |
component.heroCarousel.indicatorDotSize | ⚙️ API |
component.heroCarousel.indicatorDotSpacing | ⚙️ API |
component.heroCarousel.pageIndicatorAnimationDurationMs | 🔧 Kotlin |
component.heroCarousel.pageIndicatorInactiveAlpha | 🔧 Kotlin |
component.heroCarousel.pageSpacing | ⚙️ API |
component.heroCarousel.shadowAmbientAlpha | 🔧 Kotlin |
component.heroCarousel.shadowColor | ⚙️ API |
component.heroCarousel.tabletContentWidthFraction | 🔧 Kotlin |
component.heroCarousel.tabletGradientWidthFraction | 🔧 Kotlin |
component.heroCarousel.tabletImageWidthFraction | 🔧 Kotlin |
component.heroFeatured.bodyFontFamily | 🔧 Kotlin |
component.heroFeatured.contentTopPadding | ⚙️ API |
component.heroFeatured.displayFontFamily | 🔧 Kotlin |
component.heroFeatured.mobileDescFontSize | ⚙️ API |
component.heroFeatured.mobileDescFontWeight | ⚙️ API |
component.heroFeatured.mobileTitleFontSize | ⚙️ API |
component.heroFeatured.mobileTitleFontWeight | ⚙️ API |
component.heroFeatured.tabletContentWidthFraction | 🔧 Kotlin |
component.heroFeatured.tabletDescFontSize | ⚙️ API |
component.heroFeatured.tabletDescFontWeight | ⚙️ API |
component.heroFeatured.tabletDescLineHeight | ⚙️ API |
component.heroFeatured.tabletDescMaxLines | 🔧 Kotlin |
component.heroFeatured.tabletGradientWidthFraction | 🔧 Kotlin |
component.heroFeatured.tabletImageWidthFraction | 🔧 Kotlin |
component.heroFeatured.tabletTitleFontSize | ⚙️ API |
component.heroFeatured.tabletTitleFontWeight | ⚙️ API |
component.heroFeatured.tabletTitleLineHeight | ⚙️ API |
component.heroFeatured.tabletTitleMaxLines | 🔧 Kotlin |
component.heroLive.bodyFontFamily | 🔧 Kotlin |
component.heroLive.borderColorAlpha | 🔧 Kotlin |
component.heroLive.channelNameMaxLines | 🔧 Kotlin |
component.heroLive.cornerRadius | ⚙️ API |
component.heroLive.displayFontFamily | 🔧 Kotlin |
component.heroLive.epgRefreshIntervalMs | 🔧 Kotlin |
component.heroLive.miniTimelineTitleMaxLines | 🔧 Kotlin |
component.heroLive.programTitleMaxLines | 🔧 Kotlin |
component.heroLive.progressUpdateIntervalMs | 🔧 Kotlin |
component.heroLive.tabletInfoWidthFraction | 🔧 Kotlin |
component.heroVideo.tabletTitleFontSize | ⚙️ API |
component.heroVideo.titleFontSize | 🔧 Kotlin |
component.programGuideCard.progressCornerRadius | ⚙️ API |
component.programGuideCard.progressHeight | ⚙️ API |
component.{mode}.btvDropdown.minWidth | ⚙️ API |
component.{mode}.button.medium.height | 🔧 Kotlin |
component.{mode}.button.primary.bg | 🔧 Kotlin |
component.{mode}.button.primary.bgDisabled | 📝 Spec-only |
component.{mode}.button.primary.bgPressed | 📝 Spec-only |
component.{mode}.button.primary.text | 🔧 Kotlin |
component.{mode}.button.primary.textDisabled | 📝 Spec-only |
component.{mode}.button.secondary.bg | 🔧 Kotlin |
component.{mode}.button.secondary.bgDisabled | 📝 Spec-only |
component.{mode}.button.secondary.bgPressed | 📝 Spec-only |
component.{mode}.button.secondary.border | 🔧 Kotlin |
component.{mode}.button.secondary.text | 🔧 Kotlin |
component.{mode}.button.secondary.textDisabled | 📝 Spec-only |
component.{mode}.buttonGroup.* | 🔧 Kotlin |
component.{mode}.buttonGroup.cornerRadius | ⚙️ API |
component.{mode}.buttonGroup.height | ⚙️ API |
component.{mode}.buttonGroup.spacing | ⚙️ API |
component.{mode}.cardImageOnly.widthMedium | ⚙️ API |
component.{mode}.cardLandscape.aspectRatio | ⚙️ API |
component.{mode}.cardLandscape.bgColor | ⚙️ API |
component.{mode}.cardLandscape.borderColor | ⚙️ API |
component.{mode}.cardLandscape.borderWidth | ⚙️ API |
component.{mode}.cardLandscape.cornerRadius | ⚙️ API |
component.{mode}.cardLandscape.shadowColor | ⚙️ API |
component.{mode}.cardLandscape.shadowElevation | ⚙️ API |
component.{mode}.cardPoster.* | 🔧 Kotlin |
component.{mode}.cardPoster.aspectRatio | ⚙️ API |
component.{mode}.cardPoster.bgColor | ⚙️ API |
component.{mode}.cardPoster.borderColor | ⚙️ API |
component.{mode}.cardPoster.borderWidth | ⚙️ API |
component.{mode}.cardPoster.cornerRadius | ⚙️ API |
component.{mode}.cardPoster.fallbackGradientEnd | ⚙️ API |
component.{mode}.cardPoster.fallbackTextColor | ⚙️ API |
component.{mode}.cardPoster.shadowColor | ⚙️ API |
component.{mode}.cardPoster.shadowElevation | ⚙️ API |
component.{mode}.cardPoster.widthLarge | ⚙️ API |
component.{mode}.cardPoster.widthMedium | ⚙️ API |
component.{mode}.cardPoster.widthSmall | ⚙️ API |
component.{mode}.cardPoster.widthSmall|Medium|Large | 📝 Spec-only |
component.{mode}.cardSplit.titleFontSize | ⚙️ API |
component.{mode}.cardSplit.titleFontWeight | ⚙️ API |
component.{mode}.cardSplit.titleLineHeight | ⚙️ API |
component.{mode}.cardTop10.cardAspectRatio | ⚙️ API |
component.{mode}.cardTop10.posterWidth | ⚙️ API |
component.{mode}.cardTop10.rankNumBorderColorDark | ⚙️ API |
component.{mode}.cardTop10.rankNumBorderColorLight | ⚙️ API |
component.{mode}.cardTop10.rankNumFontSize | ⚙️ API |
component.{mode}.cardTop10.rankNumLetterOverlapFactor | ⚙️ API |
component.{mode}.cardTop10.rankNumOuterOverlap | ⚙️ API |
component.{mode}.cardTop10.rankNumStrokeWidth | ⚙️ API |
component.{mode}.heroBibleVerse.contentTopPadding | ⚙️ API |
component.{mode}.heroBibleVerse.displayFontFamily | 🔧 Kotlin |
component.{mode}.heroBibleVerse.mobileVerseFontSizeMax | ⚙️ API |
component.{mode}.heroBibleVerse.mobileVerseFontSizeMin | ⚙️ API |
component.{mode}.heroBibleVerse.tabletContentWidthFraction | 🔧 Kotlin |
component.{mode}.heroBibleVerse.tabletGradientOnlyContentWidthFraction | 🔧 Kotlin |
component.{mode}.heroBibleVerse.tabletGradientWidthFraction | 🔧 Kotlin |
component.{mode}.heroBibleVerse.tabletImageWidthFraction | 🔧 Kotlin |
component.{mode}.heroBibleVerse.verseBaseSizeMobile | 🔧 Kotlin |
component.{mode}.heroCarousel.aspectRatioMobile | ⚙️ API |
component.{mode}.heroCarousel.aspectRatioTablet | ⚙️ API |
component.{mode}.heroCarousel.bodyFontFamily | 🔧 Kotlin |
component.{mode}.heroCarousel.borderColor | ⚙️ API |
component.{mode}.heroCarousel.borderWidth | ⚙️ API |
component.{mode}.heroCarousel.cornerRadius | ⚙️ API |
component.{mode}.heroCarousel.descriptionMaxLines | 🔧 Kotlin |
component.{mode}.heroCarousel.displayFontFamily | 🔧 Kotlin |
component.{mode}.heroCarousel.eyebrowMaxLines | 🔧 Kotlin |
component.{mode}.heroCarousel.tabletContentWidthFraction | 🔧 Kotlin |
component.{mode}.heroCarousel.tabletGradientWidthFraction | 🔧 Kotlin |
component.{mode}.heroCarousel.tabletImageWidthFraction | 🔧 Kotlin |
component.{mode}.heroCarousel.titleMaxLines | 🔧 Kotlin |
component.{mode}.heroVideo.bodyFontFamily | 🔧 Kotlin |
component.{mode}.heroVideo.contentTopPadding | ⚙️ API |
component.{mode}.heroVideo.displayFontFamily | 🔧 Kotlin |
component.{mode}.heroVideo.eyebrowMaxLines | 🔧 Kotlin |
component.{mode}.heroVideo.iconSize | ⚙️ API |
component.{mode}.heroVideo.mobileDescFontSize | ⚙️ API |
component.{mode}.heroVideo.mobileDescFontWeight | ⚙️ API |
component.{mode}.heroVideo.mobileTitleFontSize | ⚙️ API |
component.{mode}.heroVideo.mobileTitleFontWeight | ⚙️ API |
component.{mode}.heroVideo.tabletDescFontSize | ⚙️ API |
component.{mode}.heroVideo.tabletDescFontWeight | ⚙️ API |
component.{mode}.heroVideo.tabletDescLineHeight | ⚙️ API |
component.{mode}.heroVideo.tabletDescMaxLines | 🔧 Kotlin |
component.{mode}.heroVideo.tabletTitleFontSize | ⚙️ API |
component.{mode}.heroVideo.tabletTitleFontWeight | ⚙️ API |
component.{mode}.heroVideo.tabletTitleLineHeight | ⚙️ API |
component.{mode}.heroVideo.tabletTitleMaxLines | 🔧 Kotlin |
component.{mode}.menuDrawer.dividerAlpha | 🔧 Kotlin |
component.{mode}.menuDrawer.overlayBgAlpha | ⚙️ API |
component.{mode}.menuDrawer.rowBgColor | ⚙️ API |
component.{mode}.menuDrawer.sectionLabelAlpha | 🔧 Kotlin |
component.{mode}.menuDrawer.swipeDismissThresholdDp | 🔧 Kotlin |
component.{mode}.menuDrawer.tabletDrawerWidth | 🔧 Kotlin |
component.{mode}.menuDrawer.tabletScrimAlpha | 🔧 Kotlin |
component.{mode}.playlist.rowHorizontalPadding | ⚙️ API |
component.{mode}.playlist.rowItemSpacing | ⚙️ API |
component.{mode}.playlist.rowTitleBottomSpacing | ⚙️ API |
component.{mode}.playlist.rowTitleFontSize | ⚙️ API |
component.{mode}.playlist.rowTitleFontWeight | ⚙️ API |
component.{mode}.progress.barColor | ⚙️ API |
component.{mode}.progress.barCornerRadius | ⚙️ API |
component.{mode}.progress.barHeight | ⚙️ API |
component.{mode}.progress.barTrackColor | ⚙️ API |
component.{mode}.protoBibleButton.* | 🔧 Kotlin |
component.{mode}.protoBibleButton.borderWidth | ⚙️ API |
component.{mode}.protoBibleButton.disabledAlpha | ⚙️ API |
component.{mode}.protoBibleButton.hoverAlpha | ⚙️ API |
component.{mode}.protoBibleButton.pressedAlpha | ⚙️ API |
component.{mode}.rowSection.* | 🔧 Kotlin |
component.{mode}.rowSection.cardAspectRatio | ⚙️ API |
component.{mode}.section.horizontalPadding | ⚙️ API |
component.{mode}.section.verticalSpacing | ⚙️ API |
primitives.colors.blue.150 | ⚙️ API |
primitives.colors.blue.300 | ⚙️ API |
primitives.colors.blue.600 | ⚙️ API |
primitives.colors.blue.800 | ⚙️ API |
primitives.fontWeights.Normal | ⚙️ API |
primitives.opacity.* | 🔧 Kotlin |
primitives.opacity.scale10 | ⚙️ API |
primitives.opacity.scale15 | ⚙️ API |
primitives.opacity.scale25 | ⚙️ API |
primitives.opacity.scale40 | ⚙️ API |
primitives.opacity.scale45 | ⚙️ API |
primitives.opacity.scale5 | 🔧 Kotlin |
primitives.opacity.scale50 | ⚙️ API |
primitives.opacity.scale70 | ⚙️ API |
primitives.opacity.scale75 | ⚙️ API |
primitives.opacity.scale80 | ⚙️ API |
primitives.opacity.scale90 | ⚙️ API |
primitives.opacity.scale95 | ⚙️ API |
primitives.opacity.scaleNN | 📝 Spec-only |
primitives.radius.sm | 📝 Spec-only |
semantic.color.accent.genres | 📝 Spec-only |
semantic.color.broadcast.live | 🔧 Kotlin |
semantic.dark.text.onColor | ⚙️ API |
semantic.dark.text.primary | ⚙️ API |
semantic.desktop.* | 🔧 Kotlin |
semantic.focusRing.{bp}.* | 🔧 Kotlin |
semantic.focusRing.{bp}.color | 🔧 Kotlin |
semantic.focusRing.{bp}.offset | ⚙️ API |
semantic.focusRing.{bp}.width | ⚙️ API |
semantic.icons.{bp}.md | ⚙️ API |
semantic.light.text.primary | ⚙️ API |
semantic.mobile.* | 🔧 Kotlin |
semantic.navigation.{bp}.iconLabelGap | 📝 Spec-only |
semantic.navigation.{bp}.navBarHeight | ⚙️ API |
semantic.radius.{bp}.sm | ⚙️ API |
semantic.spacing.{bp}.gap2_5xs | ⚙️ API |
semantic.spacing.{bp}.gap2xs | ⚙️ API |
semantic.spacing.{bp}.gap3xs | ⚙️ API |
semantic.spacing.{bp}.gap4xs | ⚙️ API |
semantic.spacing.{bp}.gapDefault | ⚙️ API |
semantic.spacing.{bp}.gapSm | ⚙️ API |
semantic.spacing.{bp}.gapXl | ⚙️ API |
semantic.spacing.{bp}.paddingDefault | ⚙️ API |
semantic.tablet.* | 🔧 Kotlin |
semantic.tv.* | 🔧 Kotlin |
semantic.typography.body.fontFamily | 📝 Spec-only |
semantic.typography.display.fontFamily | 📝 Spec-only |
semantic.typography.{bp}.label | ⚙️ API |
semantic.{mobile,tablet,desktop,tv} | 📝 Spec-only |
semantic.{mode}.ai.gradient.* | 🔧 Kotlin |
semantic.{mode}.ai.gradient.defaultEnd | ⚙️ API |
semantic.{mode}.ai.gradient.defaultStart | ⚙️ API |
semantic.{mode}.ai.gradient.disabledEnd | ⚙️ API |
semantic.{mode}.ai.gradient.disabledStart | ⚙️ API |
semantic.{mode}.ai.textOnBackground.default | ⚙️ API |
semantic.{mode}.ai.textOnBackground.disabled | ⚙️ API |
semantic.{mode}.background.canvas | ⚙️ API |
semantic.{mode}.background.elevated | 📝 Spec-only |
semantic.{mode}.background.surface | ⚙️ API |
semantic.{mode}.background.surfaceSunken | ⚙️ API |
semantic.{mode}.border.default | ⚙️ API |
semantic.{mode}.border.strong | ⚙️ API |
semantic.{mode}.border.subtle | ⚙️ API |
semantic.{mode}.border.{strong|default} | 📝 Spec-only |
semantic.{mode}.brand.accent | ⚙️ API |
semantic.{mode}.brand.orange | ⚙️ API |
semantic.{mode}.brand.primary | ⚙️ API |
semantic.{mode}.focusRing.default | 🔧 Kotlin |
semantic.{mode}.scrim.medium | 📝 Spec-only |
semantic.{mode}.text.onColor | ⚙️ API |
semantic.{mode}.text.primary | ⚙️ API |
semantic.{mode}.text.secondary | ⚙️ API |
views.home.layout.contentPaddingBottomMobile | ⚙️ API |
views.home.layout.contentPaddingBottomTablet | ⚙️ API |
views.home.layout.contentPaddingHorizontalMobile | ⚙️ API |
views.home.layout.contentPaddingHorizontalTablet | ⚙️ API |
views.home.layout.contentPaddingTopMobile | ⚙️ API |
views.home.layout.contentPaddingTopTablet | ⚙️ API |
views.home.layout.rowCardSpacingMobile | ⚙️ API |
views.home.layout.rowCardSpacingTablet | ⚙️ API |
views.home.layout.sectionGapMobile | ⚙️ API |
views.home.layout.sectionGapTablet | ⚙️ API |
views.home.modalScrim.alpha | ⚙️ API |
views.home.modalScrim.base | ⚙️ API |
views.home.navBar.borderAlpha | ⚙️ API |
views.home.videoSurface.background | ⚙️ API |
Bei der Implementierung auf einer neuen Plattform: zuerst Pfade mit der Markierung ⚙️ API bevorzugen (Werte von der API holen); auf 🔧 Kotlin-Defaults zurückfallen (aus packages/android/.../MediathekTokenModels.kt lesen), bis diese Tokens überführt sind; 📝 Spec-only-Einträge als kleines Lift-Backlog behandeln und während der Implementierung als DTCG-Ergänzungen vorschlagen.
Anhang — Pflege dieses Dokuments
Dieses Dokument besteht aus handgeschriebener Prosa mit zwei automatisierten Komponenten: den Token-Referenz-Tabellen (im benachbarten index.html) und dem Token-Coverage-Anhang (in diesem Dokument, von einem Skript erzeugt). Alles Übrige — Anatomie, Motion, Verhalten, Barrierefreiheit — ist handgeschrieben.
Wann aktualisieren
- Komponenten-Vertrag geändert (visuell, Verhalten, Motion, a11y) → den entsprechenden Abschnitt von Hand aktualisieren. Bei visuellen Änderungen neuen Screenshot erstellen und die Datei unter
screenshots/ersetzen. - Token hinzugefügt / umbenannt / entfernt →
node specs/080-create-a-plan/docs/scripts/generate-tokens.mjsausführen (regeneriert die von index.html konsumiertenpartials/tokens-*.html) undnode specs/080-create-a-plan/docs/scripts/gen-token-coverage.mjs(frischt die oben eingebettete Coverage-Tabelle auf). Beide Skripte sind idempotent. - Neue Iteration beginnt → entweder neue
<section>-Blöcke nach dem etablierten Template hinzufügen oder diese Datei zuhandover-v2.htmlforken, falls sich der Vertrag radikal ändert.
Authoring-Konventionen
- Jede Komponenten-
<section>muss alle Unterabschnitte in dieser Reihenfolge enthalten: Überblick · Visuelle Spezifikation (Anatomie + Dimensionen + Hit-Targets) · Zustände · Verhalten · Motion · Interaktion & UX · Barrierefreiheit · Verwendete Token · Referenz · Implementierungsreferenz. - Plattform-agnostisches Vokabular verwenden: "Container", "Stack", "Press-Feedback", niemals "Box / Modifier / Composable / View / UIView / div".
- Größen in
dpodersp, niemals inpxoderpt. Zeit inms. Easings alscubic-bezier(...)oder symbolischer Name (ease-out,linear, …). - DTCG-Token-Pfade innerhalb von
<code>-Tags referenzieren. Das Coverage-Skript liest diese, um den obigen Anhang zu berechnen; konsistente Pfadbenennung ist es, was den Anhang korrekt hält. - Jedes interaktive Element erhält ein dokumentiertes Hit-Target ≥ 48dp.
- Jeder Statuswechsel erhält einen dokumentierten Motion-Eintrag (oder einen expliziten "instant"-Hinweis).
Validatoren (vor dem Mergen von Änderungen an diesem Dokument ausführen)
node specs/080-create-a-plan/docs/scripts/gen-token-coverage.mjs— frischt die Coverage auf. Wächst die Spec-only-Anzahl unerwartet, haben Sie einen Token zitiert, der nirgends existiert — Pfad korrigieren.node packages/design-tokens-api/scripts/audit-kotlin-defaults.mjs— jedes Kotlin-Feld mit Default löst weiterhin über DTCG auf. Muss mit Exit-Code 0 enden.node tools/audit/kotlin-inline-literal-lint.mjs— keine Inline-Farb-, Alpha- oder dp-Literale in Composables (nur allowlistete Ausnahmen). Muss mit Exit-Code 0 enden.
Provenienz
v1 verfasst am 10.05.2026 auf Basis der Android-Referenzimplementierung. Die initialen Komponentenabschnitte wurden in 7 parallelen agentischen Durchgängen gegen den Kotlin-Quellcode + die DTCG-Tokens + das Engineering-Referenz-Dokument (index.html) erstellt und anschließend integriert, poliert und validiert. Mehr als 30 Commits liefern Codebasis, Token-API, Token-System und dieses Dokument gemeinsam aus. Die vollständige Historie findet sich über git log --grep="084\|spec(083)\|083\|handover" im Projekt-Root.
Browser-Unterstützung
Dieses Dokument benötigt einen modernen Evergreen-Browser (Chrome / Edge / Firefox / Safari, in den letzten 2 Jahren veröffentlicht). Es verwendet CSS Grid, position: sticky, scroll-margin-top, IntersectionObserver, prefers-color-scheme, backdrop-filter und ES6+-JavaScript. Getestet in Chrome 130+, Safari 17+, Firefox 130+. Das Dokument ist auch ohne JavaScript vollständig nutzbar (TOC-Scroll-Spy, Theme-Persistenz, ⌘K-Filter und Scroll-to-Top sind Progressive Enhancements).




