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. - Icons stammen ausnahmslos aus dem Material Symbols Rounded-Set und werden per Symbolname referenziert (z. B.
[play_arrow],[auto_awesome]), nie als Unicode-Glyph oder Emoji. Android:androidx.compose.material.icons.Icons.Rounded.*überMaterialSymbols.kt; andere Plattformen bilden auf visuell äquivalente Rounded-Icons ab (iOS importiert keine Material-Ikonografie — siehe Plattform-Hinweise). Implementierungslücke (Produktion): die Produktions-App verwendet Material Symbols, aber nicht durchgängig die Rounded-Variante — Implementierungen müssen auf Rounded vereinheitlichen. - 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).
Inzwischen geliefert und daher aus der Liste entfernt: CardOverlay und CardSplit (eigene Komponenten-Abschnitte seit Iteration 3) · 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-07-01
- Globale Icon-Regel ergänzt (Konventionen, Aufbau dieses Dokuments): Alle Icons stammen aus dem Material Symbols Rounded-Set, per Symbolname referenziert (nie als Glyph/Emoji); Android
Icons.Rounded.*überMaterialSymbols.kt. Implementierungslücke (Produktion): die Produktions-App nutzt Material Symbols, aber nicht durchgängig die Rounded-Variante — auf Rounded vereinheitlichen. - HeroVideoCard: veraltete „REGRESSION"-Notizen korrigiert (#hero-video). Die Zeilenlimits sind orientierungsabhängig und im aktuellen Code umgesetzt (
rememberHeroVideoDynamics, konsumiert im Mobile- und Tablet-Pfad); die früheren Hinweise „statisch … — wiederherstellen" trafen nicht mehr zu und wurden entfernt. Werte Titel/Beschreibung: Mobile 2/3 · Tablet Portrait 2/3 (crowded 2/2) · Tablet Landscape 3/5.
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 im aktuellen Code implementiert (rememberHeroVideoDynamics, konsumiert im Mobile- und Tablet-Pfad).
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
Iteration 3 scope Hero-VarianteEine Hero-Variante, die einen Bibelvers (statt Video oder Serie) im Karussell zeigt: typografiegeführt mit dynamischer Schriftgröße nach Textlänge, einer Vorlese-Funktion ("Anhören") mit eigener Audio-Zustandsmaschine inklusive Fortschrittsbalken sowie Buttons für Versangabe und Teilen. Es existieren zwei Varianten, die zur Laufzeit anhand des Payloads gewählt werden: Gradient-only (kein Bild — Typografie über einem zweifarbigen Verlauf) und Bildhintergrund (neu in Iteration 3 — Cover-Bild mit demselben Gradient-Scrim-Schema wie die HeroVideoCard). Die Auswahl trifft isGradientOnlyVerse = isBibleVerse && imageResId == null && thumbnail.isEmpty(). HeroColorResolver.kt:55 · HeroBibleVerseCard.kt:85, 236
Aufbau
Vier Wireframes — beide Varianten in beiden Formfaktoren. Mobile rendert HeroBibleVerseCardMobile, Tablet HeroBibleVerseCardTablet; der Wechsel folgt dem zentralen Device-Mode-Signal. HeroBibleVerseCard.kt:65–68 Beide Varianten leben innerhalb der gemeinsamen HeroCardShell (Eckenradius, Rahmen, Schatten, Press-Feedback, Farb-Emission). Das Kategorie-Badge ist konditional (slide.category != null) und wird in den Default-Replikas nicht gezeigt — das Pages-Payload-Mapping setzt für Vers-Slides derzeit keine Kategorie. HeroBibleVerseCard.kt:120–124, 169–173 · CollectionRenderer.kt:161–176
Callout-Legende
- Shell — Karten-Chrome
- Vertikaler Verlauf — volle Kartenfläche
- Verstext — vertikal zentriert, dynamische Größe
- Button-Zeile — [Versangabe] ←→ [Teilen][Anhören]
- Kollaps-Verhalten bei Audio aktiv
Callout-Legende
- Shell — Landscape-Seitenverhältnis
- Horizontaler Verlauf — volle Kartenfläche
- Content-Spalte — 50% Kartenbreite
- Verstext — orientierungsabhängige Größenbereiche
- Button-Zeile — drei Buttons linksbündig, niemals umbrechend
Callout-Legende
- Bildebene — full-bleed
- Gradient-Overlay — am unteren Rand verankert
- Verstext in der Verlaufsregion
- Button-Zeile — identisch zu Variante A
Callout-Legende
- Shell — Landscape-Seitenverhältnis
- Bildebene — am nachfolgenden Rand verankert
- Gradient-Overlay — horizontal, am führenden Rand
- Badge (konditional)
- Content-Spalte — Verstext vertikal zentriert
- Button-Zeile — Einzeilen-Regel (FR-006)
Composition-API-Kontrakt
Im Pages-Payload (/api/v1/pages/<page>) ist ein Vers-Slide ein BibleVerseHeroItem (Diskriminator type="bible_verse") innerhalb einer hero_carousel-Sektion. Modell: PlaylistCollectionModels.kt:169–188; Basisfelder aus HeroItem (PlaylistCollectionModels.kt:161–167). Das Mapping zum Renderer setzt clientseitig ctaLabel = "Anhören" und secondaryCtaLabel = verseReference. CollectionRenderer.kt:159–176
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung. PlaylistCollectionModels.kt:173. |
type | "bible_verse" | Diskriminator → HeroBibleVerseCard. PlaylistCollectionModels.kt:171. |
verseText | String | Der Verstext (wird als Slide-Titel gemappt); dynamische Schriftgröße nach Textlänge. Validierung: 15–180 Wörter (validateWordCount(), PlaylistCollectionModels.kt:184–187). |
verseReference | String | Versangabe ("Johannes 3,16"); zugleich Label des Versangabe-Buttons (Sekundär-CTA). PlaylistCollectionModels.kt:178 · CollectionRenderer.kt:169. |
imageUrl | String? | Varianten-Schalter: gesetzt → Bildhintergrund-Variante; null/leer (und kein imageResId) → Gradient-only. PlaylistCollectionModels.kt:174 · HeroColorResolver.kt:55. |
audioUrl | String? | Vorgesehen für das Vorlese-Audio. Implementierungslücke: Das Mapping reicht audioUrl derzeit nicht in den Slide durch (ctaAction = {}); die Audio-Zustandsmaschine läuft mit simulierten Fortschrittswerten. Der Vertrag ist: Anhören startet die Wiedergabe von audioUrl. PlaylistCollectionModels.kt:179 · CollectionRenderer.kt:167–168. |
imageResId | Int? | Nur für integrierte Demo-Assets; nicht Teil des Wire-Vertrags. PlaylistCollectionModels.kt:180. |
gradientColor | String? | Hex-Fallback für die Verlaufsbasis (Auflösungsstufe 3 im Farb-Resolver). PlaylistCollectionModels.kt:181. |
accentColor | String? | Hex-Akzentfarbe (Logo-/Akzent-Emission; auch im Share-Bild verwendet). PlaylistCollectionModels.kt:182. |
colors | CollectionExtractedColors? | Vorab extrahierte Palette {light,dark}.{base,accent} — höchste Priorität im Farb-Resolver; treibt Gradient-Scrim der Bild-Variante. PlaylistCollectionModels.kt:175. |
deepLink | String? | Navigationsziel beim Tippen auf die Karte (optional). PlaylistCollectionModels.kt:176. |
Abmessungen
Vollständige heroBibleVerse.*-Token-Tabelle. Quelle der Kotlin-Defaults: MediathekTokenModels.kt (HeroBibleVerseTokens). Seit 2026-07 ist der gesamte Struct in packages/design-tokens/tokens.source.json (layers.component.{dark,light}.heroBibleVerse) serialisiert und damit per Payload übersteuerbar (Merge: Theme.kt, heroBibleVerse-Block).
| Token | Wert | DTCG | Verwendung |
|---|---|---|---|
tabletTitleFontSize | 26sp | ja | Reserviert (Titel-Stil-Paar mit heroVideo); im Vers-Rendering nicht gelesen. |
tabletTitleLineHeight | 28sp | ja | dito |
tabletTitleFontWeight | Medium | ja | dito |
tabletDescFontSize | 14sp | ja | dito |
tabletDescLineHeight | 18sp | ja | dito |
tabletDescFontWeight | Medium | ja | dito |
scrimMobileLightAlpha1–4 | 0.05/0.50/0.82/0.95 | ja | Alpha-Rampe des Bild-Scrims (Light, Mobile). Stop-POSITIONEN bleiben strukturelle Code-Konstanten (Alpha-only-Tuning-Regel). HeroBibleVerseCard.kt |
scrimMobileDarkAlpha1–5 | 0.15/0.45/0.72/0.90/0.97 | ja | Alpha-Rampe des Bild-Scrims (Dark, Mobile). |
scrimTabletAlpha1–3 | 0.75/0.5/0.25 | ja | Alpha-Rampe des horizontalen Tablet-Scrims. |
imageAspectRatioMobile / imageWidthMobile | 3:4 / 600 | ja | Imgix-Fetch-Parameter (Mobile). |
imageAspectRatioTablet / imageWidthTablet | 16:9 / 1200 | ja | Imgix-Fetch-Parameter (Tablet/Wide). |
imageFocalPointY | 0.35 | ja | Fokuspunkt beider Bild-Requests. |
verseMaxLinesMobile | 8 | ja | Font-Scale-Sicherheitsnetz: ungebundene Vers-Layouts ellipsieren nach dieser Zeilenzahl. |
contentTopPadding | 140dp | ja | Oberer Innenabstand der Verlaufs-Textregion (Bild-Variante Mobile). HeroBibleVerseCard.kt:203 |
verseBaseSizeMobile | 36 | ja | Parameter der dynamischen Schriftgröße Mobile. HeroSharedContent.kt:127–129, 288 |
verseMinSizeMobile | 16 | ja | Untergrenze Mobile (sp). |
verseMaxSizeMobile | 48 | ja | Obergrenze Mobile (sp). |
verseBaseSizeTabletLandscape | 22 | ja | Tablet Landscape. HeroSharedContent.kt:276–281 |
verseMinSizeTabletLandscape | 14 | ja | Untergrenze Tablet Landscape (sp). |
verseMaxSizeTabletLandscape | 26 | ja | Obergrenze Tablet Landscape (sp). |
verseBaseSizeTabletPortrait | 16 | ja | Tablet Portrait. HeroSharedContent.kt:282–287 |
verseMinSizeTabletPortrait | 11 | ja | Untergrenze Tablet Portrait (sp). |
verseMaxSizeTabletPortrait | 18 | ja | Obergrenze Tablet Portrait (sp). |
textLengthMin | 20 | ja | Untere Klemmgrenze der Zeichenlänge; wird an calculateDynamicFontSize durchgereicht. |
textLengthMax | 250 | ja | Obere Klemmgrenze — dito. |
lineMinHeight | 115 (%) | ja | Zeilenhöhe bei kleinster Schriftgröße; wird an calculateDynamicLineHeight durchgereicht. |
lineMaxHeight | 90 (%) | ja | Zeilenhöhe bei größter Schriftgröße — dito. |
displayFontFamily | Diagramm | ja | Verstext-Schriftfamilie. HeroSharedContent.kt:166, 300 |
bodyFontFamily | Inter | ja | Reserviert für Sekundärtext. |
tabletImageWidthFraction | 0.7 | ja | Bildbreite Tablet (Bild-Variante). HeroBibleVerseCard.kt:320 |
tabletGradientWidthFraction | 0.5 | ja | Verlaufsbreite Tablet (Bild-Variante). HeroBibleVerseCard.kt:346 |
tabletContentWidthFraction | 0.4 | ja | Content-Spalte Tablet (Bild-Variante). HeroBibleVerseCard.kt:375 |
tabletGradientOnlyContentWidthFraction | 0.5 | ja | Content-Spalte Tablet (Gradient-only). HeroBibleVerseCard.kt:289 |
verseTextAlpha | 0.9 | ja | Nur im Legacy-Light-Pfad des Karussells verwendet. HeroCarousel.kt:596 |
buttonIconSize | 14dp | ja | Icon-Größe der Kollaps-Pille (CollapsingPillButton). |
buttonCornerRadiusPercent | 50 | ja | Pillenform der Buttons (voll gerundet). |
Geteilte Abstände: Karten-Innenabstand gapDefault = 16dp; Abstand zwischen Buttons gap2xs = 8dp; Abstand Verstext → Buttonzeile gap3xs = 4dp (zweifach: Spaltenabstand + Spacer). DesignTokens.kt:284–289 · HeroSharedContent.kt:160–161, 175
Dynamische Verstext-Schriftgröße — abhängig von der Textlänge
Die Schriftgröße wird logarithmisch über die Zeichenlänge interpoliert (nicht in Stufen wie beim Video-Hero-Titel): Die Länge wird auf 20–250 Zeichen geklemmt, dann size = max − ((ln(len) − ln(20)) / (ln(250) − ln(20))) × (max − min), gerundet. Kurze Verse erhalten die Maximalgröße, lange die Minimalgröße. Berechnung via calculateDynamicFontSize(), gecacht pro Verstext. HeroSharedContent.kt:62–82, 127–129, 274–290
Die Zeilenhöhe läuft gegenläufig über calculateDynamicLineHeight(): 115% der Schriftgröße an der Untergrenze, linear fallend auf 90% an der Obergrenze — große Schriften sitzen enger. HeroSharedContent.kt:93–104, 130–133
| Formfaktor | Min | Max |
|---|---|---|
| Mobile (beide Varianten) | 16sp | 48sp |
| Tablet Portrait | 11sp | 18sp |
| Tablet Landscape | 14sp | 26sp |
Zustände
Der Anhören-Button trägt eine Audio-Zustandsmaschine (AudioPlaybackState: Idle · Loading · Playing(progress, remainingTime) · Paused(progress, remainingTime) · Error(message)). ProtoBibleButton.kt:142–154
- Default (Variante A)
- Zweifarbiger Markenverlauf füllt die Karte; Verstext vertikal zentriert; Buttonzeile unten. Audio-Zustand
Idle. - Default (Variante B)
- Cover-Bild + Gradient-Scrim; Verstext in der Verlaufsregion (Mobile) bzw. Content-Spalte (Tablet). Audio-Zustand
Idle. - Audio Idle
- Button-Label =
ctaLabel("Anhören"), Icon[play_arrow], kein Zusatzinhalt. HeroSharedContent.kt:135–140 · ProtoBibleButton.kt:632–635, 727–729 - Audio Loading
- Label bleibt "Anhören"; rechts erscheint ein kreisförmiger Lade-Indikator (¾ der Icon-Größe). Tippen wird ignoriert, solange geladen wird. ProtoBibleButton.kt:684–693 · HeroSharedContent.kt:156
- Audio Playing
- Label = "Pausieren", Icon
[pause]; rechts Fortschrittsbalken (Breite 40dp, ~3dp hoch, abgerundete Enden, Track bei α0.15 der Inhaltsfarbe) + Restzeit-Text. Auf Mobile kollabieren Versangabe- und Teilen-Pille zu Icon-Kreisen. HeroSharedContent.kt:136, 216–218 · ProtoBibleButton.kt:694–716 - Audio Paused
- Label = "Fortsetzen", Icon
[play_arrow]; Fortschrittsbalken + Restzeit bleiben sichtbar (eingefroren). Pillen bleiben kollabiert. HeroSharedContent.kt:137, 398–399 - Audio Error
- Fehlertext (Fallback "Fehler") in destruktiver Textfarbe rechts neben dem Label. Nächster Tipp startet die Wiedergabe neu (wie Idle). ProtoBibleButton.kt:718–726 · HeroSharedContent.kt:144
- Pressed (gesamter Slide)
- Press-Feedback der Shell — siehe HeroCardShell.
- Focus (Tastatur / D-Pad)
- Fokusring der Shell; jeder der drei Buttons erhält seinen eigenen inneren Fokusring.
- Bild wird geladen (nur Variante B)
- Cross-Fade beim Dekodieren; darunter ist die Verlaufsbasisfarbe der Shell sichtbar.
Verhalten
Der Farb-Resolver bestimmt pro Slide isGradientOnlyVerse: wahr, wenn der Inhaltstyp Bibelvers ist UND weder Bild-URL noch integrierte Bildressource vorliegt. Gradient-only verwendet die Komponenten-Token-Verlaufsfarben (Light: gradientTopLight/gradientBottomLight = Blue150/Blue300; Dark: gradientTopDark/gradientBottomDark = Blue800/Blue600, farbmodus-transformiert) — unabhängig von der Slide-Palette. Die Bild-Variante verwendet die aufgelöste Palettenbasis C für ihr Scrim, mit derselben Prioritätskette wie die HeroVideoCard (preExtractedColors → Live-Palette → gradientColor → Seitenhintergrund). HeroColorResolver.kt:55, 73–104 · HeroBibleVerseCard.kt:110–123, 268–276 · MediathekTokenModels.kt:227–230
Tipp auf den Audio-Button schaltet: Idle/Error → Playing (löst zusätzlich die CTA-Aktion des Slides aus) · Playing → Paused (Fortschritt bleibt erhalten) · Paused → Playing · Loading → keine Reaktion. Das Button-Label folgt dem Zustand: Idle/Loading → "Anhören" (bzw. ctaLabel), Playing → "Pausieren", Paused → "Fortsetzen". Der Fortschrittsbalken animiert Wertänderungen über 300ms. Implementierungslücke: Der Zustand ist derzeit lokaler UI-State mit simulierten Werten (progress = 0.3f, remainingTime = "0:45") — eine echte Player-Anbindung an audioUrl existiert noch nicht; der dokumentierte Vertrag ist das Zielverhalten mit echten Werten. HeroSharedContent.kt:124, 135–158, 395–420 · ProtoBibleButton.kt:650–655
Mobile-Anordnung: [Versangabe] links, [Teilen][Anhören] rechts (SpaceBetween). Sobald der Audio-Zustand Playing oder Paused ist, gelten Versangabe- und Teilen-Pille als "kollabiert": Der Text verschwindet sofort, das horizontale Padding federt von 12dp auf den Kreiswert, sodass eine perfekte Icon-Kreisform entsteht; die gemessene Ausgangshöhe wird eingefroren, damit sich die Zeilenhöhe nie ändert. Der Anhören-Button gewinnt dadurch Platz für Fortschrittsbalken + Restzeit. HeroSharedContent.kt:215–258, 311–371, 467–510
Auf Tablet (alle drei Buttons linksbündig: [Versangabe] [Anhören] [Teilen]) dürfen Button-Labels niemals auf zwei Zeilen umbrechen — kritisch im Tablet-Portrait, wo die Content-Spalte schmal ist. Umsetzung: Die Karte misst ihre Innenbreite und reicht maxRowWidth = Kartenbreite − 2 × gapDefault an die Buttonzeile durch; die Zeile layoutet ungebunden über die Spaltenbreite hinaus und wird erst an maxRowWidth gekappt. Gilt für beide Varianten. Dasselbe Muster wie bei den Video-Hero-CTAs. HeroBibleVerseCard.kt:257–260, 303–314, 390–401 · HeroSharedContent.kt:384–435 · Commit e28327bf (094 T027)
Der Versangabe-Button (Label = secondaryCtaLabel ?: reference, Icon [menu_book]) ruft die Sekundär-Aktion des Slides auf — vorgesehen als Sprung in den Bibeltext. Teilen erzeugt asynchron ein Share-Bild des Verses (Verstext + Versangabe + ggf. Hintergrundbild/Akzentfarbe) und übergibt es der umgebenden Bildschirmebene. HeroSharedContent.kt:224–231, 436–444 · HeroBibleVerseCard.kt:409–438
Eine Hilfsfunktion ersetzt das letzte Leerzeichen des Verstextes durch ein geschütztes Leerzeichen, damit kein einzelnes Wort allein in der letzten Zeile steht. HeroSharedContent.kt:84–91
Barrierefreiheit
- Slide-Container: role button (wenn
onClickgesetzt); zugänglicher Name = Verstext + Versangabe. - Cover-Bild (Variante B):
contentDescription= Verstext. HeroBibleVerseCard.kt:153, 163 - Audio-Button: role button; Label wechselt mit dem Zustand ("Anhören" / "Pausieren" / "Fortsetzen") und muss bei Zustandswechsel neu angekündigt werden. Fortschrittsbalken + Restzeit als ergänzende Wertbeschreibung exponieren.
- Kollabierte Pillen (Mobile, Audio aktiv): Die Labels "Versangabe" / "Teilen" müssen als zugängliche Namen erhalten bleiben, auch wenn der sichtbare Text ausgeblendet ist (Icon-only-Kreis).
- Alle drei Buttons: Hit-Target ≥ 48 × 48 dp durch transparente Aufpolsterung.
- Reduzierte Bewegung: Pillen-Kollaps und Fortschritts-Tween ohne Animation schalten (Zustände sofort anwenden).
- Kontrast: Verstext gegen Verlauf/Scrim muss WCAG AA (≥ 4.5:1) erfüllen; bei Variante A sind die Verlaufenden festen Markenfarben, bei Variante B ist die Basis dynamisch — pro Slide verifizieren.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Bild dekodiert (Variante B) | Alpha der Bildebene 0 → 1 | Plattform-Standard-Cross-Fade | ease-out |
| Audio-Fortschritt ändert sich | Füllstand des Fortschrittsbalkens | 300ms | tween ProtoBibleButton.kt:650–655 |
| Audio-Start / -Stopp (Mobile) | Horizontales Padding der Pillen (Pille ↔ Kreis) | Feder (mittel-federnd, mittel-niedrige Steifigkeit) | spring HeroSharedContent.kt:342–346 |
| Slide-Druck | Press-Feedback der Shell | siehe HeroCardShell | |
| Modus-Wechsel (Light/Dark) | Verlaufs-/Textfarben | sofort (Re-Composition, kein Tween) | — |
Audit-Notizen
- Screenshots ausstehend. Für HeroBibleVerseCard existieren noch keine Geräte-Screenshots unter
docs/screenshots/— die Aufnahme ist Teil von Aufgabe 094 T027 ("Device screenshots PENDING", Commite28327bf). Wireframes oben sind aus dem Quellcode abgeleitet; bei Verfügbarkeit der Screenshots gegenprüfen. - Implementierungslücke — Audio:
audioUrlwird vom Pages-Mapping nicht durchgereicht (ctaAction = {},CollectionRenderer.kt:167–168); die Zustandsmaschine läuft mit Mock-Werten (progress = 0.3f,remainingTime = "0:45",HeroSharedContent.kt:145, 407). UI-Vertrag (Zustände, Labels, Fortschrittsbalken) ist final; die Player-Anbindung muss folgen. - AUFGELÖST (2026-07-03) — ehemalige Token-Drifts: (a) das redundante Paar
mobileVerseFontSizeMax/Minwurde ENTFERNT — der Renderer liest die (jetzt vollständig serialisierte)verse{Base,Min,Max}Size*-Familie; (b)textLengthMin/MaxundlineMinHeight/lineMaxHeightwerden jetzt aus den Tokens ancalculateDynamicFontSize/calculateDynamicLineHeightdurchgereicht (keine Hardcode-Duplikate mehr); (c) die Kollaps-Pille konsumiertbuttonIconSizeundbuttonCornerRadiusPercent. Weiterhin wahr:calculateDynamicFontSizenimmt einenbaseSize-Parameter an, der im Funktionskörper nie verwendet wird — dieverseBaseSize*-Tokens sind damit inert (offener Punkt: entfernen oder Kurve darauf umstellen). - Font-Scale-Verhalten (neu, 2026-07-03): Die berechnete Versgröße wird durch die System-Font-Scale geteilt (
fontScaleAdjustedSize), sodass die GERENDERTE Größe in der festen Kartenproportion konstant bleibt; unter der Mindestgröße greift das Sicherheitsnetz (maxLinesaus der Boxhöhe bzw.verseMaxLinesMobile,TextOverflow.Ellipsis). Die Scrim-Alphas sind alsscrim*Alpha*-Tokens übersteuerbar; Stop-Positionen bleiben Code-Konstanten (Alpha-only-Regel). - Legacy-Pfad: Im Karussell existiert ein älterer Light-Mode-Renderpfad (
BibleVerseContentLight,HeroCarousel.kt:506, 596), derverseTextAlpha = 0.9anwendet. Der hier dokumentierte Vertrag ist der HeroBibleVerseCard-Pfad; den Legacy-Pfad nicht nachbauen. - Verifiziert: Variantenwahl, beide Layout-Geometrien (Mobile/Tablet × Gradient-only/Bild), Gradient-Stop-Tabellen, Token-Tabelle 1:1 gegen
HeroBibleVerseTokens, Audio-Zustände/Labels, Einzeilen-Regel (FR-006/T027), Composition-Felder 1:1 gegenBibleVerseHeroItem.
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/hero/cards/HeroBibleVerseCard.kt. Mobile-Layout in HeroBibleVerseCardMobile, Tablet-Layout in HeroBibleVerseCardTablet (Dispatch: HeroMobileLayout.kt:36–37 / HeroTabletLayout.kt:36–37). Verstext + Buttonzeilen in HeroSharedContent.kt (BibleVerseContent, BibleVerseTextCentered, BibleVerseButtons, CollapsingPillButton). Audio-Zustände + Button-Rendering: AudioPlaybackState / AudioButtonContent in ProtoBibleButton.kt:142–154, 615–732 (konsumiert über MediathekButton, ProtoBibleButton.kt:1080). Farb-Auflösung: rememberHeroColors in HeroColorResolver.kt. Share-Bild: ShareImageGenerator. Tokens: HeroBibleVerseTokens in MediathekTokenModels.kt:181–213; Payload-Merge in Theme.kt:702–713. Wire-Modell: BibleVerseHeroItem in PlaylistCollectionModels.kt:169–188; Mapping in CollectionRenderer.kt:159–176.
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. |
Featured-Sektion
Iteration 3 scope SektionEin Sektionstyp der Composition-API (Diskriminator type="featured"): eine horizontal blätterbare Hero-Karten-Gruppe, die — anders als das Seiten-Hero-Karussell — als eigenständige Sektion mitten in der Seite gerendert wird und ihren eigenen Verlaufs-Hintergrund mitbringt. Der Hintergrund entsteht aus der pro Slide extrahierten Palettenfarbe, die mit derselben Engine-Paritäts-Konstante über die Canvas-Farbe geblendet wird wie der Seitenverlauf — beim Blättern wird die Farbe synchron zur Wischgeste zwischen den Slides interpoliert. Optional trägt die Sektion einen Titel im Playlist-Zeilen-Stil; unterhalb der Karten sitzt der Seitenindikator. Die Slides selbst sind reguläre Hero-Karten (Auswahl nach Inhaltstyp); die Variante HeroFeaturedCard für redaktionelle Feature-Inhalte ist unten mitdokumentiert. FeaturedSectionRenderer.kt:52–212 · CollectionRenderer.kt:121–129
Aufbau
Zwei Wireframes — die Sektion in beiden Formfaktoren. Der Layout-Schalter ist NICHT nur der Geräte-Modus: useWideLayout = Tablet ∨ Desktop ∨ Landscape — ein Telefon im Querformat erhält ebenfalls das breite Karten-Seitenverhältnis. FeaturedSectionRenderer.kt:63–65 Im schmalen Layout sind die Karten 3:4-Portrait (aspectRatioMobile = 0.75), im breiten ≈2.54:1-Landscape (1f / aspectRatioTablet, 0.394). Die innere Karte ist hier als HeroFeaturedCard gezeigt (Bild + Scrim + Titel/Beschreibung + CTA); je nach Inhaltstyp des Slides rendert an derselben Stelle stattdessen die HeroVideoCard, HeroBibleVerseCard oder Live-Variante. HeroMobileLayout.kt:46–54 · HeroTabletLayout.kt:47
Callout-Legende
- Sektions-Verlauf — extrahierte Farbe über Canvas geblendet
- Sektionstitel (optional)
- Pager + Karten-Chrome
- Bildebene — full-bleed (HeroFeaturedCard)
- Badge — konditional
- Karten-Scrim — entkoppelte Rampe
- Titel + Beschreibung
- Primärer CTA
- Seitenindikator
- Indikator-Position im Verlauf
Callout-Legende
- Sektions-Verlauf — identisch zu Mobile
- Karten-Chrome — breites Seitenverhältnis
- Bildebene — am nachfolgenden Rand verankert
- Karten-Verlauf — horizontal, am führenden Rand
- Content-Spalte — Text vertikal zentriert, CTA unten
- CTA — Einzeilen-Regel
- Seitenindikator + Abschluss-Abstand
Composition-API-Kontrakt
Im Pages-Payload (/api/v1/pages/<page>) ist die Sektion ein FeaturedSection-Objekt (Diskriminator type="featured"). API-Typ: playlist.ts:96–102; Android-Modell: PlaylistCollectionModels.kt:71–81. Reale Instanzen: home.json Sektion featured-highlights ("Highlights", 5 Video-Items, autoAdvanceIntervalMs: 8000, Zeilen 736–801) und mediathek.json Sektion mediathek-featured ("Empfehlungen", Zeilen 6–70).
| Feld | Typ | Hinweise |
|---|---|---|
type | "featured" | Diskriminator → FeaturedSectionRenderer. playlist.ts:97 · PlaylistCollectionModels.kt:73 · CollectionRenderer.kt:121–129. |
id | String | Stabile Sektions-Kennung. PlaylistCollectionModels.kt:75. |
title | String? | Sektionstitel. Rendering-Bedingung: nur wenn nicht null/leer; sonst Abstandshalter statt Titelzeile. PlaylistCollectionModels.kt:76 · FeaturedSectionRenderer.kt:159–172. |
items | List<HeroItem> | Polymorphe Hero-Items (Union: bible_verse · video · poster · live_tv · ad · generic, playlist.ts:119–125). Jedes Item wird clientseitig in einen Hero-Slide gemappt; die Kartenvariante folgt dem Inhaltstyp (Ad/Generic → HeroFeaturedCard, Video/Poster → HeroVideoCard, Bibelvers → HeroBibleVerseCard, Live → Live-Variante). Validierung: Liste darf nicht leer sein. PlaylistCollectionModels.kt:77, 80 · CollectionRenderer.kt:122, 159–241 · HeroMobileLayout.kt:46–54. |
autoAdvanceIntervalMs | Long (Default 10000) | Intervall des automatischen Weiterblätterns in Millisekunden — der Payload-Wert steuert die Sektion direkt (der heroCarousel-Token autoAdvanceIntervalMs = 3000 wird hier nicht gelesen). Reale Payloads verwenden 8000 (Highlights/Empfehlungen) und 10000 (Spotlight). playlist.ts:101 · PlaylistCollectionModels.kt:78 · FeaturedSectionRenderer.kt:109 · home.json:801. |
Abmessungen
Vollständige heroFeatured.*-Token-Tabelle (23 Schlüssel — Stand 2026-06-12 inklusive der Lifts tabletTitleMaxLines, tabletDescMaxLines, imageFocalPointY). Drei Ebenen, alle zitiert: DTCG-Quelle tokens.source.json (layers.component.{dark,light}.heroFeatured, Zeilen 1557 / 2021) → Kotlin-Wrapper HeroFeaturedTokens (MediathekTokenModels.kt:216–240, Dark-Instanz :742–753, Light-Override :898–900) → Payload-Merge (Theme.kt:423, 715–739). Jeder der 23 Schlüssel ist in der DTCG-Quelle serialisiert und per Payload übersteuerbar.
| Token | Wert (Dark) | Wert (Light) | Verwendung |
|---|---|---|---|
tabletTitleFontSize | 26sp | 26sp | Titel — Tablet-Spalte UND mobiler Content-Stack (siehe Audit). HeroFeaturedCard.kt:258 · HeroSharedContent.kt:634 |
tabletTitleLineHeight | 28sp | 28sp | Titel-Zeilenhöhe. HeroFeaturedCard.kt:260 |
tabletTitleFontWeight | Medium | Medium | Titel-Schriftstärke. HeroFeaturedCard.kt:259 |
tabletDescFontSize | 14sp | 14sp | Beschreibung. HeroFeaturedCard.kt:274 |
tabletDescLineHeight | 18sp | 18sp | Beschreibungs-Zeilenhöhe. HeroFeaturedCard.kt:276 |
tabletDescFontWeight | Medium | Medium | Beschreibungs-Schriftstärke. HeroFeaturedCard.kt:275 |
mobileTitleFontSize | 16sp | 16sp | Deklariert + geparst, aber vom mobilen Renderer derzeit nicht gelesen — siehe Audit-Notizen. Theme.kt:724 |
mobileTitleFontWeight | SemiBold | SemiBold | dito Theme.kt:725 |
mobileDescFontSize | 12sp | 12sp | dito Theme.kt:726 |
mobileDescFontWeight | Normal | Normal | dito Theme.kt:727 |
contentTopPadding | 140dp | 140dp | Oberer Innenabstand der Scrim-Textregion (Mobile). HeroFeaturedCard.kt:130, 133 |
scrimFadeShift | 52dp | 52dp | Verschiebt den Volldeckungs-Anker unter das Content-Padding (Anker = 140 + 52 = 192dp). HeroFeaturedCard.kt:130 · MediathekTokenModels.kt:228 |
scrimFadeHeight | 240dp | 240dp | Länge der Scrim-Rampe nach oben vom Anker. HeroFeaturedCard.kt:131 |
tabletTitleMaxLines | 3 | 3 | Titel-Zeilen Tablet-Landscape — Lift 2026-06-12 (vorher kt-only Default). HeroFeaturedCard.kt:237 · Theme.kt:736 |
tabletDescMaxLines | 4 | 4 | Beschreibungs-Zeilen Tablet-Landscape — Lift 2026-06-12. Auch vom mobilen Content-Stack gelesen. HeroFeaturedCard.kt:238 · HeroSharedContent.kt:655 · Theme.kt:737 |
tabletPortraitTitleMaxLines | 2 | 2 | Titel-Zeilen Tablet-Portrait. HeroFeaturedCard.kt:237 · Theme.kt:734 |
tabletPortraitDescMaxLines | 3 | 3 | Beschreibungs-Zeilen Tablet-Portrait. HeroFeaturedCard.kt:238 · Theme.kt:735 |
displayFontFamily | Diagramm | Diagramm | Titel-Schriftfamilie. HeroFeaturedCard.kt:257 |
bodyFontFamily | Inter | Inter | Beschreibungs-Schriftfamilie. HeroFeaturedCard.kt:273 |
tabletImageWidthFraction | 0.7 | 0.7 | Bildbreite Tablet. HeroFeaturedCard.kt:180 |
tabletGradientWidthFraction | 0.5 | 0.6 | Verlaufsbreite Tablet — einziger Light/Dark-Unterschied der Token-Familie. HeroFeaturedCard.kt:206 · MediathekTokenModels.kt:898–900 |
tabletContentWidthFraction | 0.4 | 0.4 | Content-Spalte Tablet. HeroFeaturedCard.kt:242 |
imageFocalPointY | 0.35 | 0.35 | imgix-Fokuspunkt (vertikal) beider Formfaktoren — Lift 2026-06-12 (vorher Hardcode). HeroFeaturedCard.kt:86, 163 · Theme.kt:738 |
Sektions-Geometrie (geteilte Tokens): seitlicher Pager-Abstand heroCarousel.contentPadding = 24dp · Kartenabstand pageSpacing = 16dp · Karten-Seitenverhältnisse aspectRatioMobile = 0.75 / aspectRatioTablet = 0.394 (angewendet als 1f / 0.394) · Titel-Einrückung paddingLg = 24dp · Titel-Unterabstand gap2_5xs = 6dp · vertikaler Sektionsabstand oben/unten section.verticalSpacing = 24dp · interne Kartenpolster der Höhenrechnung gap2xs = 8dp. FeaturedSectionRenderer.kt:65, 167–168, 175–180, 187–188 · MediathekTokenModels.kt:706–717, 828–831 · DesignTokens.kt:285–286, 298–299
Zustände
- Default (Dark)
- Sektions-Verlauf aus extrahierter Slide-Farbe über
Blue900 #080D16; Fallback-Farbe vor Abschluss der Extraktion:Blue800 #10223A. FeaturedSectionRenderer.kt:67–68, 86–87 - Default (Light)
- Gleiche Rampe über
Neutral150 #F3F5F7; auf der Tablet-Karte verbreitert sich der horizontale Verlauf auf 0.6. FeaturedSectionRenderer.kt:68 · MediathekTokenModels.kt:898–900 - Auto-Advance aktiv
- Bei > 1 Slide blättert die Sektion alle
autoAdvanceIntervalMseine Seite weiter (600ms-Animation), bis der letzte Slide erreicht ist — kein Umlauf. FeaturedSectionRenderer.kt:105–124 - Nach Nutzer-Interaktion
- Sobald die Person selbst scrollt, stoppt das Auto-Advance dauerhaft für diese Sektion (kein Neustart). Programmatisches Blättern setzt das Flag nicht. FeaturedSectionRenderer.kt:94–110
- Wird gewischt
- Während der Geste interpoliert die Hintergrundfarbe linear zwischen den extrahierten Farben des aktuellen und des Ziel-Slides, proportional zum Seiten-Offset. FeaturedSectionRenderer.kt:77–92
- Einzelner Slide
- Kein Auto-Advance, kein Seitenindikator (Indikator rendert erst ab 2 Seiten); der Verlaufs-Hintergrund bleibt. FeaturedSectionRenderer.kt:105 · PageIndicator.kt:29
- Ohne Titel
titlenull/leer → Abstandshalter (section.verticalSpacing = 24dp) statt Titelzeile. FeaturedSectionRenderer.kt:170–172- Pressed (Slide)
- Press-Feedback der Shell; Tippen navigiert zum
deepLinkdes Slides (falls vorhanden). FeaturedSectionRenderer.kt:195 - Bild wird geladen
- Cross-Fade beim Dekodieren; darunter die Verlaufsbasisfarbe der Shell. HeroFeaturedCard.kt:103–111
Verhalten
Der Hintergrund wird hinter den gesamten Sektionsinhalt gezeichnet: vier Stops, jeweils die aktuelle Slide-Farbe C über die Canvas-Farbe geblendet mit PAGE_BLEND_ALPHA = 0.8 (0%) und den Faktoren 0.85 (30%), 0.6 (50%), 0.3 (100%). PAGE_BLEND_ALPHA ist laut Quellcode-Kommentar eine Engine-Paritäts-Konstante: sie muss der 0.8-Tönung der Color-Lab-Extraktions-API (computeGradientStops in deren Server) entsprechen und ist bewusst kein Design-Token — eine Änderung erfordert den Gleichschritt mit der Engine. Die Stop-Rampe spiegelt pageGradientStops (0.85/0.6/0.3), endet aber NICHT auf der Canvas-Farbe, damit die Sektion am unteren Rand getönt bleibt und nahtlos mit dem HeroCarousel-Seitenverlauf verschmilzt. FeaturedSectionRenderer.kt:129–156 · ColorTransformations.kt:13–18, 42–56
Jeder Slide meldet seine extrahierte Hintergrundfarbe in eine Sektions-Map (Seitenindex → Farbe). Während einer Wischgeste wird aus dem Seiten-Offset die Zielseite bestimmt und die Hintergrundfarbe linear zwischen Ausgangs- und Zielfarbe interpoliert (lerp(from, to, |offset|)) — der Verlauf folgt dem Finger in Echtzeit statt nach Abschluss umzuschalten. Slides ohne gemeldete Farbe verwenden den Fallback Blue800. FeaturedSectionRenderer.kt:75–92, 196–198
Bei mehr als einem Slide blättert die Sektion automatisch im Payload-Intervall (autoAdvanceIntervalMs; Modell-Default 10000ms, reale Payloads 8000ms) eine Seite weiter — Animation 600ms mit Beschleunigen/Abbremsen-Kurve. Es gibt keinen Umlauf: am letzten Slide stoppt das Weiterblättern. Erkennt die Sektion eine echte Nutzer-Scrollgeste (programmatisches Blättern ist per Flag ausgenommen), wird das Auto-Advance dauerhaft beendet. Läuft gerade eine Scrollbewegung, wird der Tick übersprungen. FeaturedSectionRenderer.kt:94–124 · PlaylistCollectionModels.kt:78 · home.json:801
Das Karten-Seitenverhältnis wechselt über useWideLayout = Tablet ∨ Desktop ∨ Landscape zwischen aspectRatioMobile = 0.75 (3:4 Portrait) und 1f / aspectRatioTablet ≈ 2.54:1 — also auch auf einem Telefon im Querformat. Die Pager-Höhe wird vorab aus der verfügbaren Breite errechnet (Sektionsbreite − 2 × contentPadding − 2 × gap2xs), damit alle Seiten dieselbe Höhe haben. Innerhalb der Karte trifft die HeroFeaturedCard ihren eigenen Mobile/Tablet-Entscheid über den Geräte-Modus. FeaturedSectionRenderer.kt:63–65, 174–190 · HeroFeaturedCard.kt:64–69
Die Sektion rendert pro Item den geteilten Hero-Slide-Renderer; dieser wählt die Kartenvariante nach dem Inhaltstyp des gemappten Slides: Ad/Artikel → HeroFeaturedCard, Bibelvers → HeroBibleVerseCard, Live (mit Kanalinfo) → Live-Variante, alles andere (Video, Poster/Serie) → HeroVideoCard. Im Mapping erhalten ad- und generic-Items den Inhaltstyp Ad — die HeroFeaturedCard ist also die Karte für redaktionelle Feature-/Kampagnen-Inhalte mit eigenem CTA-Label aus dem Payload. FeaturedSectionRenderer.kt:191–199 · HeroMobileLayout.kt:25–64 · HeroTabletLayout.kt:47 · CollectionRenderer.kt:159–241
Auf Mobile sitzt der Content-Stack (Titel, Beschreibung, CTA) am unteren Kartenrand über einem Scrim, dessen Volldeckungs-Anker bei contentTopPadding + scrimFadeShift = 192dp von der Box-Oberkante liegt und dessen Rampe scrimFadeHeight = 240dp nach oben läuft — die Rampe darf oberhalb der Box beginnen, sodass der Übergang unabhängig von der Texthöhe lang und weich bleibt. Stop-Schema identisch zur HeroVideoCard (Dark 7 / Light 6 Zwischenstops, EXCEPTION-annotierte abgeleitete Kontrastwerte über der Laufzeit-Palettenbasis). HeroFeaturedCard.kt:123–133 · HeroContentScrim.kt:17–39, 48–85
Der CTA-Button darf über die 40%-Content-Spalte hinauswachsen, bis maximal zur Karten-Innenbreite (Kartenbreite − 2 × paddingDefault); das Label bricht niemals um, Ellipsis nur als letzter Ausweg. Die maximalen Zeilen von Titel und Beschreibung folgen der Ausrichtung (Landscape 3/4, Portrait 2/3). HeroFeaturedCard.kt:174–177, 236–238, 286–296
Barrierefreiheit
- Sektionstitel: als Überschrift der Gruppe exponieren; die Hero-Karten sind Listenelemente einer horizontal blätterbaren Gruppe ("Seite x von y").
- Jeder Slide: role button (Tippen navigiert zum
deepLink); zugänglicher Name = Titel + Beschreibung. Cover-Bild:contentDescription= Slide-Titel. HeroFeaturedCard.kt:98, 108 - Auto-Advance: automatisch wechselnde Inhalte müssen pausierbar sein — die dauerhafte Deaktivierung bei Nutzer-Interaktion erfüllt das; zusätzlich sollte Auto-Advance ruhen, solange Fokus/Screenreader auf der Sektion steht.
- Reduzierte Bewegung: Auto-Advance und Farb-Interpolation ohne Animation schalten (Seiten- und Farbwechsel sofort anwenden).
- Seitenindikator: dekorativ, vor Screenreadern verbergen — die Seitenposition kommt aus der Gruppen-Semantik.
- CTA und Slide: Hit-Target ≥ 48 × 48 dp durch transparente Aufpolsterung.
- Kontrast: Sektionstitel (primäre Textfarbe) liegt auf der dynamisch getönten Verlaufsfläche — die 0.8-Blendung über die Canvas-Farbe hält den Untergrund nah am Seitenhintergrund; WCAG AA (≥ 4.5:1) pro Extraktionsfarbe verifizieren. Kartentext: siehe Scrim-/Verlaufs-Schemata.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Wischgeste läuft | Hintergrundfarbe (lerp zwischen Slide-Farben) | gestengekoppelt (pro Frame, kein Tween) | linear zum Seiten-Offset FeaturedSectionRenderer.kt:77–92 |
| Auto-Advance-Tick | Seitenposition (eine Seite vor) | 600ms | Beschleunigen/Abbremsen (FastOutSlowIn) FeaturedSectionRenderer.kt:113–119 |
| Seitenwechsel | Breite des aktiven Indikator-Punkts (6dp ↔ 20dp) | 200ms | tween PageIndicator.kt:49–52 · MediathekTokenModels.kt:108 |
| Bild dekodiert | Alpha der Bildebene 0 → 1 | Plattform-Standard-Cross-Fade | ease-out HeroFeaturedCard.kt:106 |
| Slide-Druck | Press-Feedback der Shell | siehe HeroCardShell | |
| Modus-Wechsel (Light/Dark) | Canvas-/Textfarben | sofort (Re-Komposition, kein Tween) | — |
Audit-Notizen
- Screenshot ausstehend. Unter
docs/screenshots/existiert noch keine Aufnahme der Featured-Sektion (weder Sektion noch HeroFeaturedCard). Wireframes oben sind vollständig aus dem Quellcode abgeleitet; bei Verfügbarkeit gegenprüfen. - Kein Token (bewusst):
PAGE_BLEND_ALPHA = 0.8ist als Engine-Paritäts-Konstante kommentiert (ColorTransformations.kt:13–18) — sie spiegelt die 0.8-Tönung der Color-Lab-Extraktions-API und darf nur im Gleichschritt mit der Engine geändert werden. Nicht intokens.source.jsonaufnehmen. - Drift — ungenutzte Mobile-Typo-Tokens: Der mobile Content-Stack der HeroFeaturedCard (
AdArticleContent) liest dietablet*-Token (26sp Titel, 14sp Beschreibung,tabletTitleMaxLines/tabletDescMaxLines) — die vier deklarierten und geparstenmobile*-Token (16sp SemiBold / 12sp Normal) werden nirgends gelesen (HeroSharedContent.kt:630–656 · Theme.kt:724–727). Auflösen: entweder den mobilen Pfad auf diemobile*-Token umstellen oder die Token entfernen. - Usage rule — Karteninhalt: Die aktuellen Payloads (
home.json,mediathek.json) befüllen Featured-Sektionen ausschließlich mitvideo-Items — dort rendert also die HeroVideoCard innerhalb des Featured-Verlaufs. Die HeroFeaturedCard erscheint erst beiad-/generic-Items. Beide Kombinationen sind vertragskonform; die Sektion ist karten-agnostisch. - Usage rule — Auto-Advance-Quelle: Die Sektion liest das Intervall aus dem Payload (
section.autoAdvanceIntervalMs), nicht aus dem TokenheroCarousel.autoAdvanceIntervalMs = 3000(FeaturedSectionRenderer.kt:109 · MediathekTokenModels.kt:707). Auch Animationsdauer (600ms) und Easing sind hier lokale Literale, während das Seiten-Karussell den TokenautoAdvanceAnimationDurationMskennt — Vereinheitlichung prüfen. - Hinweis — Fallback-Farben:
Blue900/Blue800/Neutral150sind direkt referenzierte Farbprimitive (FeaturedSectionRenderer.kt:67–68, 86–87) statt semantischerbackground.canvas-Rollen; die Werte stimmen mit dem Dark-Canvas (#080D16) überein, der Light-WertNeutral150 #F3F5F7weicht vom Seiten-Canvas ab — mit Workstream Farb-Semantik abgleichen. - Verifiziert: Stop-Rampe (0.8 × 1/0.85/0.6/0.3, getöntes Ende), lerp-Farbinterpolation, Auto-Advance-Logik (kein Umlauf, dauerhafter Stopp), Layout-Schalter
useWideLayout, Token-Tabelle 1:1 gegenHeroFeaturedTokens(23 Schlüssel, Light-Override 0.6) und gegentokens.source.json(Zeilen 1557/2021), Theme-Merge aller 23 Schlüssel (Theme.kt:715–739), Composition-Felder 1:1 gegenFeaturedSection(TS + Kotlin), reale Payload-Instanzen.
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/sections/FeaturedSectionRenderer.kt (Verlaufs-Hintergrund, Auto-Advance, Farb-Interpolation, Pager + PageIndicator.kt). Hero-Karte: ui/components/hero/cards/HeroFeaturedCard.kt (HeroFeaturedCardMobile / HeroFeaturedCardTablet; mobiler Content-Stack AdArticleContent in HeroSharedContent.kt:615–670; Scrim in HeroContentScrim.kt). Karten-Dispatch: HeroSlideRenderer in HeroCarousel.kt:316–357 → HeroMobileLayout.kt / HeroTabletLayout.kt. Blend-Mathematik: util/ColorTransformations.kt (PAGE_BLEND_ALPHA, blendOver). Tokens: HeroFeaturedTokens in MediathekTokenModels.kt:216–240 (Defaults :742–753, Light :898–900); Payload-Merge in Theme.kt:423, 715–739; DTCG-Quelle packages/design-tokens/tokens.source.json:1557, 2021. Wire-Modell: FeaturedSection in PlaylistCollectionModels.kt:71–81; API-Typ packages/playlist-api/src/types/playlist.ts:96–102; Mapping in CollectionRenderer.kt:121–129, 159–241.
HeroLiveStreamSlide
Iteration 3 scope Hero-VarianteEin Hero-Slide für den Live-TV-Kanal innerhalb des Hero-Karussells. Zeigt eine animierte Stream-Vorschau (bewegtes WebP), die per Tipp in den echten Live-Player umschaltet, dazu ein Live-Badge, Sendername plus laufende Sendung aus dem EPG, einen sekündlich aktualisierten Fortschrittsbalken und eine Beschreibung der laufenden Sendung. Die EPG-Daten aktualisieren sich selbstständig per Polling. Mobile stapelt Video oben / Inhalt unten; Tablet teilt die Karte horizontal in Info-Spalte links und Video rechts. Anders als die übrigen Hero-Slides färbt dieser Slide den Seitenhintergrund nicht mit extrahierten Bildfarben ein.
Visuelle Spezifikation
Aufbau
- 1. Karten-Chrome — eigener Container (nicht die HeroCardShell): Eckenradius
component.{mode}.heroLive.cornerRadius= 24dp, Hintergrund elevierte Fläche (Dark:#1A1A1A, Light:#FFFFFF), 0.5dp-Rahmen in der primären Textfarbe bei AlphaheroLive.borderColorAlpha= 0.15, Schlagschatten nur im Dark Mode. HeroLiveStreamSlide.kt:155–183 · MediathekTokenModels.kt:243, 254 - 2. Stream-Vorschau / Live-Player — animierte WebP-Vorschau des Live-Streams (Kanal-
previewUrl, hochauflösende Variante). Tipp auf die Vorschau oder den Play-Button aktiviert den eingebetteten Live-Player (Autoplay, mit Steuerung, ohne eigenes Live-Badge). HeroLiveStreamSlide.kt:95–114, 225–250, 264–289 - 3. Play-Button-Overlay — zentrierter Kreis (56dp,
semantic.iconSize.{bp}.xxxl) mit halbtransparentem Overlay-Hintergrund (#80000000) und Icon[play_arrow](32dp,iconSize.lg), Beschriftung "Live ansehen". HeroLiveStreamSlide.kt:326–346 - 4. Live-Badge — wiederverwendetes BtvBadge-Primitive mit semantischem Farbschlüssel
"semantic.color.broadcast.live"und Text "Live". Die Farbe wird zur Laufzeit über den semantischen Tokensemantic.{mode}.broadcast.liveaufgelöst (siehe Token-Abschnitt). Mobile: oben links über dem Video, eingerückt umpaddingDefault. Tablet: oben in der Info-Spalte. HeroLiveStreamSlide.kt:195–198, 291–301 · BtvBadge.kt:45–51, 73–74 - 5. Content-Stack — vertikaler Stack:
- 5a. Sendername + ggf. Serienname ("Bibel TV · {Serie}"), einzeilig. HeroLiveStreamSlide.kt:377–385
- 5b. Titel der laufenden Sendung — Episodentitel falls vorhanden, sonst Sendungstitel, max.
heroLive.programTitleMaxLines= 2 Zeilen mit Ellipsis. HeroLiveStreamSlide.kt:366–374, 390–399 - 5c. Fortschrittszeile — Balken (Track + Akzent-Füllung) plus Zeitspanne "HH:mm – HH:mm". HeroLiveStreamSlide.kt:405–433
- 5d. Beschreibung — Episodentext falls vorhanden, sonst der längste EPG-Texteintrag; mit Ellipsis, füllt den Restraum. HeroLiveStreamSlide.kt:147–153, 436–447
Mobile Callout-Legende
- Äußerer Container — Karten-Chrome
- Stream-Vorschau — 16:9, animiert
- Play-Button-Overlay
- Live-Badge — BtvBadge mit broadcast.live
- Sendername + Serie
- Titel der laufenden Sendung
- Fortschrittszeile — Balken + Zeitspanne
- Beschreibung
Tablet Callout-Legende
- Äußerer Container
- Info-Spalte — führende 30 %
- Live-Badge
- Video-Bereich — nachfolgende 70 %
- Play-Button-Overlay
Die Datei definiert ein Mini-Timeline-Element für die EPG-Vorschau (Startzeit-Label, schmaler Fortschrittsbalken, Programmtitel — der Balken füllt sich nur beim aktuell laufenden Eintrag, vergangene/kommende Einträge sind abgedimmt). Es existiert derzeit keine Aufrufstelle — weder im Slide selbst noch anderswo; auch der schedule-Parameter des Slides wird nicht konsumiert. Vor einer Implementierung auf anderen Plattformen klären, ob die Mini-Timeline Teil des Iteration-3-Umfangs ist (siehe Audit-Notizen).
| Element | Wert | Quelle |
|---|---|---|
| Startzeit-Label | typography.overline = 10sp · Medium · heroLive.displayFontFamily (Diagramm) · aktuell: volle Textfarbe, sonst @ opacity.scale50 | HeroLiveStreamSlide.kt:481–487 |
| Fortschrittsbalken | Höhe programGuideCard.progressHeight = 3dp · Radius programGuideCard.progressCornerRadius = 1.5dp · Track @ opacity.scale10/15 (Light/Dark) · Füllung nur bei isCurrent in Akzentfarbe | HeroLiveStreamSlide.kt:474–506 |
| Programmtitel | typography.caption = 11sp · SemiBold wenn aktuell, sonst Normal · max. heroLive.miniTimelineTitleMaxLines = 2, Ellipsis | HeroLiveStreamSlide.kt:510–519 · MediathekTokenModels.kt:247 |
Composition-API-Kontrakt
Im Pages-Payload (/api/v1/pages/<page>) ist der Live-Slide ein LiveTVHeroItem (Diskriminator type="live_tv") innerhalb einer hero_carousel-Sektion. Android-Modell: PlaylistCollectionModels.kt:218–234; BFF-Typ: packages/playlist-api/src/types/playlist.ts:173–185; Beispiel-Payload: packages/playlist-api/data/pages/search.json (Item live-tv-hero in der Sektion search-hero). Der Adapter mappt das Item auf einen Hero-Slide mit contentType = LIVE, category = "Live" und ctaLabel = "Live ansehen" (CollectionRenderer.kt:201–215); die Layout-Ebene rendert diesen Slide nur dann als HeroLiveStreamSlide, wenn channel vorhanden ist — andernfalls fällt sie auf die Standard-Videokarte zurück (HeroMobileLayout.kt:24–35, HeroTabletLayout.kt:26).
| Feld | Typ | Hinweise |
|---|---|---|
id | String | Stabile Slide-Kennung (Beispiel: "live-tv-hero"). |
type | "live_tv" | Diskriminator → HeroLiveStreamSlide. PlaylistCollectionModels.kt:220 · playlist.ts:174 |
channelName | String | Anzeigename des Senders ("Bibel TV"). Erscheint in der Sendername-Zeile. |
channel | ChannelInfo? = { id, name, shortName, streamUrl, previewUrl?, description?, hasEpg } | Rendering-Bedingung: ohne channel kein Live-Slide — die Layout-Ebene fällt auf die Videokarte zurück. channel.id steuert das EPG-Polling, channel.previewUrl die animierte Vorschau, channel.streamUrl den Player. EPGApi.kt:144–152 · playlist.ts:288–296 · HeroMobileLayout.kt:25 |
streamUrl | String? | HLS-Stream-URL auf Item-Ebene; das Rendering nutzt channel.streamUrl. |
imageUrl | String? | Stream-Vorschaubild (WebP). Fallback-Kette im Adapter: imageUrl ?: channelLogoUrl ?: "". CollectionRenderer.kt:206 |
channelLogoUrl | String? | Sender-Logo als Bild-Fallback. |
currentProgram / nextProgram | EPGProgram? = { title, startTime, endTime, category? } | Optionale Seed-Daten für Titelzeile, bevor das erste Polling-Ergebnis eintrifft. Die laufenden EPG-Inhalte holt der Slide danach selbst (siehe Verhalten). PlaylistCollectionModels.kt:270–275 · playlist.ts:249–254 |
colors | CollectionExtractedColors? | Nutzungsregel: Der Live-Slide trägt nicht zur Seitenhintergrund-Färbung über extrahierte Bildfarben bei — er veröffentlicht stattdessen die Flächenfarbe + Genre-Akzent. HeroLiveStreamSlide.kt:74–93 |
deepLink | String? | Navigationsziel (Beispiel: "live/13"). |
epgItem / nextEpgItem | EPGProgramItem? (nur Android-Modell) | Drift: existieren im Android-Wire-Modell (PlaylistCollectionModels.kt:232–233), aber nicht im BFF-Typ LiveTVHeroItem (playlist.ts:173–185) — der BFF sendet sie nie. Vor Vertrags-Freeze mit dem API-Engineer klären (entfernen oder im BFF nachziehen). |
Abmessungen
| Eigenschaft | Mobile | Tablet | Token | Quelle |
|---|---|---|---|---|
| Eckenradius Karte | 24dp | 24dp | component.{mode}.heroLive.cornerRadius | tokens.source.json · HeroLiveStreamSlide.kt:166, 176 |
| Rahmen | 0.5dp · primäre Textfarbe @ α 0.15 | semantic.spacing.{bp}.dividerHeight + heroLive.borderColorAlpha | HeroLiveStreamSlide.kt:65–71, 178–182 · MediathekTokenModels.kt:254 | |
| Schatten (nur Dark) | Elevation 16dp · Overlay-Farbe #80000000 | semantic.spacing.{bp}.elevationHero | HeroLiveStreamSlide.kt:159–171 | |
| Außenabstand Slide | 8dp horizontal + vertikal | semantic.spacing.{bp}.gap2xs | HeroLiveStreamSlide.kt:157 | |
| Video-Seitenverhältnis | 16:9 (1.7778) | — (füllt 70 % × volle Höhe) | component.{mode}.cardLandscape.aspectRatio | HeroLiveStreamSlide.kt:261 |
| Breitenanteil Info-Spalte | 1.0 (gestapelt) | 0.3 | heroLive.tabletInfoWidthFraction (Kotlin-Default, nicht in tokens.json) | MediathekTokenModels.kt:244 · HeroLiveStreamSlide.kt:190 |
| Innenabstand Content | 16dp | 20dp | semantic.spacing.{bp}.paddingDefault | HeroLiveStreamSlide.kt:193, 295, 317 |
| Play-Overlay Kreis / Icon | 56dp / 32dp | semantic.iconSize.{bp}.xxxl / .lg | HeroLiveStreamSlide.kt:333, 343 | |
| Sendername Schriftgröße | 14sp · Medium · Inter | semantic.typography.{bp}.bodySmall + heroLive.bodyFontFamily | HeroLiveStreamSlide.kt:379–381 | |
| Titel Schriftgröße / Zeilenhöhe | 26sp / 28sp | 32sp / 34sp | semantic.typography.{bp}.heroTitle / .lineHeightHeroTitle + heroLive.displayFontFamily | HeroLiveStreamSlide.kt:392–398 |
| Titel max. Zeilen | 2 | heroLive.programTitleMaxLines | MediathekTokenModels.kt:246 | |
| Fortschrittsbalken Höhe | 4dp · Radius radius.sm | semantic.spacing.{bp}.gap3xs | HeroLiveStreamSlide.kt:412–413 | |
| Zeitlabel Schriftgröße | 12sp · Medium | semantic.typography.{bp}.label | HeroLiveStreamSlide.kt:428 | |
| Beschreibung Schriftgröße / Zeilenhöhe | 14sp / 19sp | 14sp / 22sp | semantic.typography.{bp}.bodySmall / .lineHeightHeroSub | HeroLiveStreamSlide.kt:440, 445 |
| Vertikale Abstände im Stack | Name→Titel 2dp · Titel→Fortschritt 8dp · Fortschritt→Beschreibung 6dp | gap4xs / gap2xs / gap2_5xs | HeroLiveStreamSlide.kt:387, 402, 437 | |
Farb-Token broadcast.live (neu — Spec 094 T012)
| Token | Dark | Light | Quelle |
|---|---|---|---|
semantic.{mode}.broadcast.live | #E3000B | #E3000B | tokens.source.json (layers.semantic.dark:421 / light:658 → primitives.colors.brand.liveRed) · SemanticColors.kt:192, 432, 653 |
Vor T012 war #E3000B an den Live-Verwendungsstellen hartkodiert. Jetzt ist der Wert ein semantischer Token; das Badge referenziert ihn über den Payload-/Komponenten-Schlüssel "semantic.color.broadcast.live", der zur Laufzeit gegen die aktive Semantik-Palette aufgelöst wird BtvBadge.kt:45–51, 73–74.
Zustände
- Default (Vorschau)
- Animierte WebP-Vorschau läuft, Play-Overlay zentriert, Live-Badge sichtbar, EPG-Inhalte gefüllt, Fortschrittsbalken aktualisiert sich sekündlich.
- Stream aktiviert
- Nach Tipp auf Vorschau oder Play-Overlay ersetzt der eingebettete Live-Player die Vorschau (Autoplay, Steuerung sichtbar, kein zusätzliches Player-Badge — das Karten-Badge bleibt). Zustand ist lokal pro Slide und nicht persistiert. HeroLiveStreamSlide.kt:95, 225–233, 264–272
- EPG nicht verfügbar
- Titel zeigt den Fallback "Jetzt live"; Fortschrittszeile und Beschreibung entfallen (sie rendern nur bei vorhandenem Programm). HeroLiveStreamSlide.kt:368–370, 401, 451–452
- EPG-Fehler beim Polling
- Fehler werden abgefangen und protokolliert; die zuletzt bekannten Programmdaten bleiben stehen, das Polling läuft weiter. HeroLiveStreamSlide.kt:131–133
- Light / Dark
- Dark: elevierte Fläche
#1A1A1A, Rahmen Weiß @ α 0.15, Schatten. Light: Fläche#FFFFFF, Rahmen#1A1A1A@ α 0.15, kein Schatten. Badge-Farbebroadcast.liveist in beiden Modi identisch. HeroLiveStreamSlide.kt:65–72, 159–171 - Nicht selektierter Slide
isSelected = falsepausiert die Sichtbarkeit des aktivierten Players (kein Audio im Hintergrund), die Vorschau bleibt. HeroLiveStreamSlide.kt:231, 270
Verhalten
Beim Eintritt in die Komposition startet pro Kanal eine Endlosschleife: aktuelle + nächste Sendung werden vom EPG-Dienst geholt (fetchCurrentAndNextProgram(channel.id), EPGApi.kt:253), danach wartet die Schleife heroLive.epgRefreshIntervalMs = 30 000 ms und wiederholt. Erfolgreiche Antworten ersetzen die angezeigten Programmdaten; null-Antworten und Fehler lassen den letzten Stand stehen. Der Intervall-Wert ist ein verhaltensbezogener Token (@TokenExempt behavioral — Laufzeit-, kein visueller Wert). HeroLiveStreamSlide.kt:116–136 · MediathekTokenModels.kt:250–251
Der Fortschritt wird clientseitig aus Start-/Endzeit der laufenden Sendung berechnet ((jetzt − start) / (ende − start), auf 0…1 begrenzt, Zeitzone Europe/Berlin; EPGApi.kt:80–87, 114–118) und alle heroLive.progressUpdateIntervalMs = 1 000 ms neu gesetzt. HeroLiveStreamSlide.kt:138–145 · MediathekTokenModels.kt:252–253
Tipp auf die Vorschau oder das Play-Overlay setzt streamActivated = true; der Live-Player ersetzt die Vorschau an Ort und Stelle (kein Navigationswechsel). Es gibt keinen In-Card-Weg zurück zur Vorschau. HeroLiveStreamSlide.kt:95, 242, 247, 281, 286
Statt extrahierter Bildfarben veröffentlicht der Slide die Flächen-/Canvas-Farbe als Hintergrund und den Genre-Akzent (#FFA014, durch den aktiven Farbmodus transformiert) nach oben — das Seiten-Gradient bleibt dadurch neutral, während andere Hero-Slides es einfärben. HeroLiveStreamSlide.kt:74–93
Hat die laufende Sendung einen Episodentitel, wird er zum Titel und der Sendungstitel wandert als Serie in die Sendername-Zeile ("{Kanal} · {Serie}"); sonst ist der Sendungstitel der Titel. Die Beschreibung bevorzugt den Episodentext, sonst den längsten nicht-leeren EPG-Texteintrag. HeroLiveStreamSlide.kt:147–153, 366–374
Barrierefreiheit
- Vorschau-Bild: zugänglicher Name = Kanalname (
contentDescription = channel.name). HeroLiveStreamSlide.kt:238, 277 - Play-Overlay: role button; Label "Live ansehen". HeroLiveStreamSlide.kt:341
- Live-Badge: role text; als Teil des Slide-Namens ankündigen ("Live. {Titel}.").
- Der Fortschrittsbalken ist dekorativ; der Zeitbereich ("HH:mm – HH:mm") trägt die Information textuell und bleibt für Screenreader lesbar.
- Hit-Targets: Play-Overlay 56dp ≥ 48dp-Minimum; die gesamte Vorschaufläche ist zusätzlich tippbar.
- Reduzierte Bewegung: animierte WebP-Vorschau durch ein Standbild ersetzen; kein Autoplay des Players ohne Nutzeraktion.
- Kontrast: Badge-Hintergrund
broadcast.live #E3000Bmit weißem Text erreicht ≈ 4.9:1 (WCAG AA für normalen Text erfüllt). Den Live-Status dennoch nicht allein über Farbe transportieren — zusätzlich im zugänglichen Namen ankündigen.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Stream-Vorschau | animiertes WebP (kontinuierlich) | — | — |
| Fortschritts-Tick | Balkenbreite | sofort, 1s-Takt (kein Tween) | — |
| EPG-Refresh | Textinhalte | sofort (Re-Composition) | — |
| Stream-Aktivierung | Vorschau → Player | sofortiger Austausch | — |
Audit-Notizen
- Screenshot ausstehend — in
docs/screenshots/existiert keine Aufnahme des Live-Slides (Mobile/Tablet, Light/Dark). Aus der laufenden App nachliefern. - Tablet-Teilung ist 30/70, nicht 40/60 —
tabletInfoWidthFraction = 0.3ist der Kotlin-Default (MediathekTokenModels.kt:244) und wird weder intokens.source.jsonnoch im Live-Payload übersteuert. Falls 40/60 die Design-Absicht ist: Token intokens.jsonaufnehmen und Wert ändern — nicht im Code hartkodieren. - Mini-Timeline ohne Aufrufstelle —
MiniTimelineItem(HeroLiveStreamSlide.kt:457–521) und derschedule-Parameter (HeroLiveStreamSlide.kt:55) werden derzeit nicht konsumiert. Umfang vor Vertrags-Freeze klären. - Implementierungslücke (Theming) — Kartenfläche, Rahmen- und Textfarben kommen aus den statischen Legacy-Farbkonstanten (
bgElevated #1A1A1A,textPrimary #FFFFFFusw., design/tokens/DesignTokens.kt:28–34) statt aussemantic.{mode}.background.*/text.*. Für andere Plattformen gelten die semantischen Tokens als Vertrag. - Drift (Wire-Modell) —
epgItem/nextEpgItemexistieren nur im Android-Modell, nicht im BFF-Typ (siehe Kontrakt-Tabelle). - Verifiziert —
broadcast.live-Token (T012), Font-Family-TokensheroLive.bodyFontFamily= Inter /displayFontFamily= Diagramm (T011, MediathekTokenModels.kt:248–249), Polling- und Tick-Intervalle, 16:9-Video auf Mobile, 0.3-Split auf Tablet.
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/hero/HeroLiveStreamSlide.kt. Dispatch über HeroMobileLayout.kt:24–35 / HeroTabletLayout.kt:26 (Bedingung contentType == LIVE && channelInfo != null); Payload-Adapter CollectionRenderer.kt:201–215. Geteilter Content-Stack: LiveStreamContent (HeroLiveStreamSlide.kt:348–455); Player: LiveVideoPlayer (ui/components/player/); Badge: BtvBadge (ui/components/primitives/BtvBadge.kt); EPG: EPGApiService.fetchCurrentAndNextProgram (data/api/EPGApi.kt:253). Animierte WebP-Dekodierung über einen lokalen Image-Loader (ImageDecoderDecoder ab API 28, sonst GifDecoder; HeroLiveStreamSlide.kt:102–114).
SearchFieldAI (KI-Suchfeld)
Iteration 3 scope Composition-gesteuert OverlayZweiteilige Suchkomponente des "Suche"-Tabs: ein ruhendes Suchfeld (abgerundetes Feld mit dem KI-Verlauf-Rahmen aus semantic.{mode}.ai.gradient) als Composition-Section im Seiteninhalt, und ein expandiertes Overlay (Chat-artige Vollbildansicht im Gemini-Stil), in das das Feld bei Antippen übergeht. Das Eingabefeld wandert dabei sichtbar von seiner Ruheposition an die Unterkante des Viewports, direkt über die aufsteigende Tastatur. Die Komponente ist in Iteration 3 ein reiner Shell-Prototyp: Eingaben werden lokal klassifiziert (Bibelstelle vs. KI-Anfrage), aber es wird niemals eine Suchanfrage an ein Backend gesendet.SearchFieldAI.kt:43–47 · SearchFieldExpandedOverlay.kt:58–67
Visuelle Spezifikation
Aufbau
Das ruhende Feld erscheint, weil die Composition-Payload der Seite eine search_field-Section enthält — niemals, weil ein Bildschirm es fest verdrahtet (FR-022). Beim Antippen übergibt das Feld seine aktuelle Bildschirmposition an den Host-Bildschirm, der das Overlay startet; das Feld selbst kennt weder Seite noch Navigation.SearchFieldAI.kt:31–40 · SearchFieldSectionRenderer.kt:18–36 · SearchScreen.kt:26–33
Callout-Legende — ruhendes Feld
- Feld-Shell
- KI-Verlauf-Rahmen
- Such-Icon
- Placeholder-Text
- Seitenrand-Padding
Callout-Legende — expandiertes Overlay
- Scrim — Canvas-Farbe, progress-gekoppelt
- Edge-Glow — animierter Rand
- Schließen-Button
- Hinweistext — zentriert
- Intent-Chip
- Wanderndes Eingabefeld — unten verankert
- Tastatur-Verankerung
Light- vs. Dark-Modus
Sämtliches Theming wird über semantische Tokens aufgelöst — die Komponente verzweigt nicht selbst nach Modus (der isLightMode-Parameter wird durchgereicht, alle Farben kommen aus LocalSemanticColors). Verlaufs-Stops unterscheiden sich pro Modus: Dark defaultStart = marianBlue.700 (#0097A7) → defaultEnd = purple.800 (#4D3F71); Light defaultStart = marianBlue.600 (#00ACC1) → defaultEnd = purple.700 (#5E4D89).tokens.source.json semantic.{dark,light}.ai.gradient
Composition-API-Vertrag
Sektion mit Diskriminator type="search_field" (FR-022): die Shell rendert das Feld, weil die Payload es anfordert; Placeholder-Text und angebotene Intents sind server-gesteuert, mit token-/code-seitigen Defaults als Fallback. Modelle: Android SearchFieldSection (PlaylistCollectionModels.kt:110–118), BFF SearchFieldSection (packages/playlist-api/src/types/playlist.ts:30–36), Beispiel-Payload packages/playlist-api/data/pages/search.json (Seite search, erste Section).
| Feld | Typ (laut PlaylistCollectionModels.kt) | Hinweise |
|---|---|---|
type | "search_field" (@SerialName-Diskriminator) | Dispatch an den SearchField-Renderer.PlaylistCollectionModels.kt:112 |
id | String | Sektions-Kennung (Beispiel-Payload: "search-ai"). |
title | String? | Optionaler Sektionstitel — vom Renderer derzeit nicht dargestellt. |
placeholder | String? | Placeholder-Text des Feldes (ruhend UND expandiert). Beispiel-Payload: "Frag die Bibel oder suche Sendungen …". Fällt client-seitig auf "Suchen …" zurück, wenn nicht gesetzt.SearchFieldSectionRenderer.kt:29 · SearchScreen.kt:74 |
intents | List<String>? | Angebotene Intent-Chips. BFF-seitig typisiert als Array<'BIBLE_REFERENCE' | 'AI_QUERY'> — die Werte sind GROSSGESCHRIEBEN, damit sie exakt zu den Kotlin-Konstanten passen. Ein Intent-Chip wird nur angeboten, wenn sein Bezeichner in dieser Liste steht. Fallback bei fehlendem Feld: beide Intents.playlist.ts:23–35 · SearchFieldExpandedOverlay.kt:242–253 · SearchScreen.kt:75 |
Abmessungen
Alle Komponentengrößen kommen aus component.{mode}.searchField.* (identische Werte in Dark und Light). Quelle der Wahrheit: packages/design-tokens/tokens.source.json (Dark-Block Zeile 1736–1749, Light-Block Zeile 2197); Android-Defaults: SearchFieldTokens (MediathekTokenModels.kt:561–574); Laufzeit-Parsing des API-Payloads: Theme.kt:828–843.
| Eigenschaft | Wert | Token | Verwendung |
|---|---|---|---|
| Rahmenbreite | 2dp | searchField.borderWidth | SearchFieldAI.kt:71 · SearchFieldExpandedOverlay.kt:219 |
| Ecken-Radius Feld | 28dp | searchField.cornerRadius | SearchFieldAI.kt:58 · SearchFieldExpandedOverlay.kt:167 |
| Feldhöhe | 56dp | searchField.fieldHeight | SearchFieldAI.kt:66 · SearchFieldExpandedOverlay.kt:215 |
| Glow-Alpha min | 0.25 | searchField.glowAlphaMin → Alias primitives.opacity.scale25 | EdgeGlow.kt:37 |
| Glow-Alpha max | 0.6 | searchField.glowAlphaMax → Alias primitives.opacity.scale60 | EdgeGlow.kt:38 |
| Glow-Randbreite | 24dp | searchField.glowEdgeWidth | EdgeGlow.kt:58 |
| Glow-Atmen-Dauer | 2400ms | searchField.glowBreatheDurationMs | EdgeGlow.kt:40 |
| Glow-Sweep-Dauer | 8000ms | searchField.glowSweepDurationMs | EdgeGlow.kt:49 |
| Expansions-Dauer | 450ms | searchField.transitionDurationMs | SearchFieldExpandedOverlay.kt:90 |
| Overlay-Padding horizontal | 16dp | searchField.expandedPaddingHorizontal | SearchFieldExpandedOverlay.kt:121, 145, 157, 205 |
| Overlay-Padding vertikal | 12dp | searchField.expandedPaddingVertical | SearchFieldExpandedOverlay.kt:158, 206, 228 |
| Ecken-Radius Intent-Chip | 16dp | searchField.chipCornerRadius | SearchFieldExpandedOverlay.kt:255 |
| Inhalts-Padding Feld (innen) | Token | spacing.gapDefault horizontal · spacing.gap2xs Icon↔Text | SearchFieldAI.kt:63, 79 |
| Icon-Größe | Token | semantic.icons.md | SearchFieldAI.kt:85 · SearchFieldExpandedOverlay.kt:187 |
Zustände — Interaktions-Zustandsmaschine
Der Lebenszyklus der Komponente ist als explizite Zustandsmaschine mit vier Zuständen modelliert; nur die unten gelisteten Übergänge sind legal. Die Übergangsregeln sind implementiert in SearchFieldState.kt:17–22 und testgesichert in SearchFieldStateTest.kt:10–37 (kompletter Vorwärtszyklus, Transitions-Abbruch, abgelehnte Abkürzungen, abgelehnte Selbst-Übergänge).
- Resting
- Das ruhende Feld ist als Section im Seiteninhalt sichtbar; das Overlay ist nicht gemountet. Anzeige-only — kein Cursor, keine Texteingabe. Antippen meldet die aktuelle Bildschirm-Y-Position des Feldes an den Host und startet die Expansion.SearchFieldAI.kt:60–78 · SearchScreen.kt:51–56
- Transitioning
- Initialer Zustand des Overlays beim Mounten. Tastatur-Fokus wird sofort angefordert (IME beginnt zu steigen), parallel läuft die Progress-Animation 0 → 1 über
transitionDurationMs = 450ms. Scrim-Alpha, Edge-Glow, Schließen-Button, Hinweistext und Chip blenden mit dem Progress ein; das Eingabefeld wandert von der Ruheposition zur Unterkante.SearchFieldExpandedOverlay.kt:83, 88–92 - Expanded
- Progress bei 1, Eingabefeld an der Unterkante über der Tastatur eingerastet. Texteingabe wird bei jedem Tastendruck lokal klassifiziert; der Intent-Chip aktualisiert sich live.SearchFieldExpandedOverlay.kt:91, 149–160
- Dismissing
- Erreicht über System-Back oder Schließen-Button (auch direkt aus Transitioning — Abbruch der laufenden Expansion ist legal). Der Host blendet das Overlay aus; danach ist wieder Resting aktiv. Weitere Dismiss-Anfragen während des Ausblendens werden durch die Übergangsregeln ignoriert.SearchFieldExpandedOverlay.kt:94–100 · SearchFieldState.kt:17–22 · SearchScreen.kt:67–87
Verhalten
Beim Antippen des ruhenden Feldes erfasst das Feld seine aktuelle vertikale Position im Viewport und übergibt sie dem Host-Bildschirm (SearchFieldAI.kt:67, 78). Der Host mountet das Overlay; dessen Eingabefeld misst zuerst seine natürliche, unten verankerte Zielposition und interpoliert dann seinen vertikalen Versatz von der Ruheposition zur Zielposition über den Transitions-Progress — das Feld reist sichtbar vom Seiteninhalt zur Tastatur (SearchFieldExpandedOverlay.kt:162–214). Scrim, Edge-Glow und alle übrigen Overlay-Elemente blenden mit demselben Progress ein. Hinweis zur Technik: Es handelt sich um eine Offset-Interpolation zweier getrennter Elemente, nicht um eine framework-eigene Shared-Element-Transition — das ruhende Feld lebt in einer virtualisierten Scrollliste, was eine geteilte Transitions-Umgebung über den Sektionsbaum hinweg ausschließt (Forschungsentscheid R7, dokumentiert im Quelltext, SearchFieldExpandedOverlay.kt:58–67).
Zwei Wege schließen das Overlay: (a) System-Back — das Overlay registriert einen Back-Handler, solange es gemountet ist (SearchFieldExpandedOverlay.kt:100); (b) Schließen-Button [close] oben rechts (SearchFieldExpandedOverlay.kt:123–129). Beide laufen durch dieselbe Guard-Funktion, die nur legale Übergänge in Dismissing zulässt (SearchFieldExpandedOverlay.kt:94–99). Der Host blendet das Overlay daraufhin per Fade aus (SearchScreen.kt:67–71). Ein Tap auf die Scrim-Fläche schließt das Overlay derzeit NICHT — die Scrim-Ebene hat keinen Klick-Handler (siehe Audit-Notizen). Es gibt keine Rück-Reise des Eingabefeldes zur Ruheposition; das Schließen ist ein reines Ausblenden.
Der Viewport-Rand des Overlays glüht in den KI-Verlaufsfarben: vier nach innen auslaufende Alpha-Rampen-Verläufe (oben/unten/links/rechts) mit Streifenbreite glowEdgeWidth = 24dp. Zwei endlose Animationen laufen parallel: das Atmen pendelt das Alpha linear zwischen glowAlphaMin = 0.25 und glowAlphaMax = 0.6 (Dauer glowBreatheDurationMs = 2400ms, Richtungsumkehr an den Endpunkten), und der Sweep wandert die Farbphase über glowSweepDurationMs = 8000ms als Dreieckswelle zwischen den beiden Verlaufs-Stops hin und her, sodass die Farbe nahtlos um den Rand zu kreisen scheint (gegenüberliegende Kanten zeigen jeweils die komplementäre Phase). Bewusst ohne GPU-Filtereffekte umgesetzt — reine Alpha-Rampen-Verläufe, damit auch die älteste unterstützte Plattformversion rendert.EdgeGlow.kt:23–29, 36–52, 57–99
Jede Eingabe wird lokal durch eine reine Funktion klassifiziert (SearchIntentClassifier.kt:18–25, testgesichert in SearchIntentClassifierTest.kt:13–64): ein Bibelstellen-Muster wie "Johannes 3,16", "Joh 3:16", "1. Mose 1,1", "Römer 8,1" oder "Psalm 23" (Buch — auch nummeriert/abgekürzt/mit Umlauten — plus Kapitel, optional Vers mit Komma- oder Doppelpunkt-Trenner, SearchIntentClassifier.kt:14–16) ergibt BibleReference; jeder andere nicht-leere Text ergibt AiQuery; leere/Whitespace-Eingabe ergibt Empty (kein Chip). Daraus entsteht maximal ein Chip: "„Buch Kapitel,Vers“ in der Bibel öffnen" (nur wenn BIBLE_REFERENCE in den angebotenen Intents) bzw. "Mit KI suchen" (nur wenn AI_QUERY angeboten) (SearchFieldExpandedOverlay.kt:242–253). Die Chip-Navigation ist ein reiner Prototyp-Stub ohne Backend: der Bibelstellen-Chip navigiert zur Bibel-Route, der KI-Chip tut nichts — die KI-Suche existiert noch nicht; es wird zu keinem Zeitpunkt eine Suchanfrage gesendet (SearchScreen.kt:76–83).
Placeholder-Text und angebotene Intents kommen aus der search_field-Section der Composition-Payload (siehe Composition-API-Vertrag). Der Renderer und der Host fallen auf Code-Defaults zurück, wenn Felder fehlen: Placeholder "Suchen …", Intents ["BIBLE_REFERENCE", "AI_QUERY"] (SearchFieldSectionRenderer.kt:29 · SearchScreen.kt:74–75). Fehlt die Section ganz, rendert die Seite schlicht kein Suchfeld; fehlt der Host-Kontext (Section auf einer Seite ohne Suchfeld-Host), rendert die Section nichts (SearchFieldSectionRenderer.kt:25).
Barrierefreiheit
- Ruhendes Feld · tappbare Fläche in voller Sektionsbreite × 56dp (≥ 48dp Hit-Target). Das Such-Icon ist dekorativ (kein Label); der zugängliche Name ergibt sich aus dem Placeholder-Text. Eine explizite Button-Rolle/Aktion ("Suche öffnen") ist im Quelltext nicht gesetzt — siehe Audit-Notizen.SearchFieldAI.kt:66, 78, 83
- Fokus-Übergabe · Beim Öffnen des Overlays wird der Eingabefokus sofort programmatisch auf das Texteingabefeld gesetzt; dadurch steigt die Bildschirmtastatur parallel zur Expansion auf.SearchFieldExpandedOverlay.kt:84, 88–89, 226
- IME-Verankerung · Das Layout weicht Statusleiste, Navigationsleiste und Tastatur über Insets aus; das Eingabefeld wird nie von der Tastatur verdeckt.SearchFieldExpandedOverlay.kt:109–115
- Schließen · Der Schließen-Button trägt das Label "Suche schließen"; System-Back schließt gleichwertig. Screenreader-Nutzer haben damit zwei unabhängige Dismiss-Pfade.SearchFieldExpandedOverlay.kt:100, 126
- Intent-Chip · Zugänglicher Name = sichtbares Chip-Label (enthält die erkannte Bibelstelle wörtlich). Die Chip-Höhe ergibt sich aus Text +
spacing.gap2xsvertikalem Padding und liegt unter 48dp — Hit-Target-Aufpolsterung beim Reimplementieren vorsehen.SearchFieldExpandedOverlay.kt:261–265 - TalkBack / Screenreader · Das Overlay setzt keine expliziten Modal-/Pane-Semantiken (
paneTitleo. ä.); der dahinterliegende Seiteninhalt ist während der Expansion visuell ausgeblendet, aber nicht programmatisch inert — für die Produktreife wie beim MenuDrawer nachrüsten (siehe Audit-Notizen). - Reduzierte Bewegung · Edge-Glow (Endlos-Animation) und Feld-Reise sollten bei OS-seitigem "Reduce Motion" auf statischen Glow bzw. sofortiges Einblenden zurückfallen; im Iteration-3-Prototyp ist diese Weiche noch nicht implementiert.
- Kontrast · Placeholder (
text.secondary) und Eingabetext (text.primary) liegen aufsurfaceRaised-Flächen; die semantischen Paarungen entsprechen den übrigen Formularflächen des Systems. Auf dem Gerät gegen WCAG AA verifizieren (T026 ausstehend).
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Expansion (Tap auf ruhendes Feld) | Eingabefeld-Versatz Ruheposition → unten verankert; Scrim-Alpha 0 → 1; Glow/Schließen/Hinweis/Chip-Alpha 0 → 1 (alle an denselben Progress gekoppelt) | 450ms = searchField.transitionDurationMs | Standard-Tween der Plattform (Android: FastOutSlowInEasing ≙ cubic-bezier(0.4, 0.0, 0.2, 1))SearchFieldExpandedOverlay.kt:90 |
| Dismiss (Back / Schließen) | Overlay-Opazität 1 → 0 (Fade des gesamten Overlays, keine Rück-Reise des Feldes) | Framework-Default (nicht token-gesteuert) | Framework-Default-FadeSearchScreen.kt:67–71 |
| Edge-Glow Atmen | Glow-Alpha 0.25 ↔ 0.6 | 2400ms = searchField.glowBreatheDurationMs · endlos, Richtungsumkehr | linearEdgeGlow.kt:36–44 |
| Edge-Glow Farb-Sweep | Farbphase zwischen ai.gradient.defaultStart und defaultEnd (Dreieckswelle, gegenläufig auf gegenüberliegenden Kanten) | 8000ms = searchField.glowSweepDurationMs · endlos, Neustart | linearEdgeGlow.kt:45–52, 61–63 |
| Intent-Chip erscheint/wechselt | Sofortiger Austausch bei jeder Neuklassifikation (keine eigene Animation in Iteration 3) | — | —SearchFieldExpandedOverlay.kt:149–160 |
Genutzte Tokens
| Pfad | Ebene |
|---|---|
component.{mode}.searchField.borderWidth / cornerRadius / fieldHeight | component |
component.{mode}.searchField.glowAlphaMin / glowAlphaMax / glowEdgeWidth | component |
component.{mode}.searchField.glowBreatheDurationMs / glowSweepDurationMs | component |
component.{mode}.searchField.transitionDurationMs | component |
component.{mode}.searchField.expandedPaddingHorizontal / expandedPaddingVertical | component |
component.{mode}.searchField.chipCornerRadius | component |
component.{mode}.playlist.rowHorizontalPadding (Section-Einzug) | component |
semantic.{mode}.ai.gradient.defaultStart / defaultEnd / angleDeg | semantic |
semantic.{mode}.background.canvas / surfaceRaised | semantic |
semantic.{mode}.text.primary / secondary | semantic |
semantic.{mode}.border.subtle (Chip-Rahmen) | semantic |
semantic.icons.{bp}.md | semantic |
spacing.gapDefault / gap2xs | primitive |
primitives.opacity.scale25 / scale60 (Alias-Ziele der Glow-Alphas) | primitive |
Audit-Notizen
Screenshots ausstehend (T026 Geräte-Verifikation). Für diese Komponente existieren noch keine Screenshots unter docs/screenshots/; die Wireframes oben sind aus dem Quelltext abgeleitet. Nach der Geräte-Verifikation Screenshots ergänzen (ruhend Dark/Light, expandiert mit Tastatur, Chip in beiden Intent-Varianten).
Nicht tokenisierte kt-Literale. Drei Hintergrund-Alphas sind als Kotlin-Literale gesetzt statt als Tokens: ruhendes Feld surfaceRaised α 0.6 (SearchFieldAI.kt:69), expandiertes Eingabefeld α 0.85 (SearchFieldExpandedOverlay.kt:217), Chip α 0.8 (SearchFieldExpandedOverlay.kt:259); außerdem der Chip-Rahmen mit fest codiertem 1dp (SearchFieldExpandedOverlay.kt:260). Nach Projektregel "jede Komponente vollständig tokenisiert" sollten diese vier Werte in component.{mode}.searchField.* nachgezogen werden.
Hinweistext ist nicht server-gesteuert. Der zentrierte Text "Stelle eine Frage oder gib eine Bibelstelle ein" ist ein kt-Literal (SearchFieldExpandedOverlay.kt:140), während Placeholder + Intents per FR-022 aus der Payload kommen. Vor Vertrags-Freeze klären, ob dieser Text ebenfalls in die search_field-Section gehört.
Kein Scrim-Tap-Dismiss, keine Rück-Transition. Das Overlay schließt nur über Back oder den Schließen-Button; ein Tap auf die freie Scrim-Fläche tut nichts, und das Schließen ist ein Fade ohne Rück-Reise des Feldes zur Ruheposition. Beides ist beobachteter Ist-Stand des Prototyps — als Soll-Verhalten für die Produktreife bestätigen oder nachziehen.
A11y-Lücken (Prototyp). Ruhendes Feld ohne explizite Button-Rolle/Aktionsbeschreibung; Overlay ohne Modal-/Pane-Semantik und ohne inerten Hintergrund; Chip-Hit-Target < 48dp; keine Reduce-Motion-Weiche für den Edge-Glow. Vor Übergabe an die Agentur als verbindliche Anforderungen ausformulieren (Muster: MenuDrawer-Barrierefreiheitsblock).
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/search/SearchFieldAI.kt (ruhendes Feld + SearchFieldHost-CompositionLocal) · SearchFieldExpandedOverlay.kt (Overlay, Feld-Reise, IntentChip) · EdgeGlow.kt (Rand-Glow, drawBehind ohne RenderEffect — rendert ab minSdk 24) · SearchFieldState.kt (Zustandsmaschine) · SearchIntentClassifier.kt (reine Klassifikation) · ui/components/sections/SearchFieldSectionRenderer.kt (Section-Dispatch) · ui/screens/SearchScreen.kt (Host: Expand-State, AnimatedVisibility, Intent-Stubs). Die Feld-Reise ist eine Animatable-Offset-Interpolation (Forschungsentscheid R7): das ruhende Feld liegt in einer LazyColumn, was SharedTransitionLayout über den Sektionsbaum hinweg ausschließt. Tests: packages/android/app/src/test/java/com/protobible/android/ui/components/search/SearchFieldStateTest.kt · SearchIntentClassifierTest.kt.
matchedGeometryEffect zwischen ruhendem Feld und Overlay-Feld (oder dieselbe Offset-Interpolation, falls das Feld in einer List/LazyVStack liegt), Tastatur-Verankerung über keyboardLayoutGuide bzw. safeAreaInset(edge: .bottom), Edge-Glow als vier Verlaufs-Overlays mit TimelineView/withAnimation(.linear.repeatForever).
transform: translateY() starten → auf 0 animieren, 450ms, cubic-bezier(0.4, 0, 0.2, 1)), Eingabefeld in einem position: fixed-Container, der via visualViewport-API über der Bildschirmtastatur bleibt; Edge-Glow als vier fixe Verlaufstreifen mit CSS-Keyframes (alternierende opacity 0.25↔0.6 über 2.4s, Farbphase über 8s).
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 · CardOverlay · CardSplit · 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 seit Iteration 3 die dedizierte Token-Gruppe cardPoster.fallbackTitleFontSize / fallbackTitleFontWeight / fallbackTitleLineHeight — die frühere komponentenübergreifende Wiederverwendung der cardSplit.title*-Tokens ist damit aufgelöst.CardPoster.kt:121–123 · tokens.json component.cardPoster.fallbackTitle*
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}.cardPoster.fallbackTitleFontSize | component (Fallback-Titel) |
component.{mode}.cardPoster.fallbackTitleFontWeight | component (Fallback-Titel) |
component.{mode}.cardPoster.fallbackTitleLineHeight | 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).
CardOverlay
Iteration 2 scope Row itemEine 16:9-Querformatkarte, bei der Text über dem Bild liegt (Composition-Kartentyp OVERLAY): Das Cover füllt die Karte, ein vertikaler Scrim-Verlauf in der extrahierten Grundfarbe des Artworks läuft von transparent zu deckend nach unten, und unten-links sitzen Eyebrow + Titel direkt auf dem Verlauf. Die Karte ist die einzige Reihen-Karte, deren Flächen- und Textfarben aus der per Payload mitgelieferten (bzw. on-device extrahierten) Bildpalette stammen statt aus festen Tokens. Die gesamte Karte ist ein einziges Tap-Ziel.
Aufbau
Eine Box mit fester 16:9-Ratio, auf den Eckenradius geclippt, mit Schatten und einer 0.5dp-Border. Die Grundfläche ist die extrahierte Basisfarbe (animiert über 300ms beim Farbwechsel); darüber das full-bleed-Cover-Bild (Einblendung über 300ms), darüber oben-links optional ein Status- oder Kategorie-Badge, darüber der Scrim-Verlauf mit dem Textblock (Eyebrow + Titel, unten verankert), zuunterst am Kartenrand optional ein Fortschrittsbalken. Es gibt keine separate Fallback-Schicht: Schlägt das Bild fehl, bleibt die Bild-Opazität bei 0 und die Grundfarbe + Scrim + Text tragen die Karte allein.MediaCard.kt:239–338
Callout-Legende
- Container — Karten-Chrome
- Bild — Full-Bleed-Cover
- Badge (optional)
- Scrim-Verlauf — extrahierte Grundfarbe
- Textblock — Eyebrow + Titel, unten verankert
- Fortschrittsbalken (optional)
Composition-API-Kontrakt
CardOverlay wird von playlist_row-Zeilen mit cardType=OVERLAY instanziiert (PlaylistCollectionModels.kt:50–62, 409–415 · RowSection.kt:210–231). Konsumierte PlaylistItem-Felder (PlaylistCollectionModels.kt:279):
| Feld | Typ | Hinweise |
|---|---|---|
imageUrl | String | 16:9-Cover (CDN-Zuschnitt, w=640). |
title | String? | Titel über dem Scrim, max 2 Zeilen. |
series | String? | Eyebrow-Zeile über dem Titel; entfällt bei null/leer. |
tag / tagVariant | String? | Status-Badge oben-links (tagVariant → VideoState-Pille); Fallback ohne tag: Kategorie-Badge aus contentType; ohne beides entfällt es. |
progress | Float? | Fortschrittsbalken 0.0–1.0; entfällt bei null. |
colors | CollectionExtractedColors? | Pro-Theme-Palette (palette.{light,dark}.base/textPrimary/textSecondary) für Grundfläche, Scrim und Textfarben. Fehlt sie, extrahiert der Client on-device; letzter Fallback sind die semantischen Tokens (background.surfaceRaised dunkel / background.canvas hell, text.primary/secondary). PlaylistCollectionModels.kt:350–396 · MediaCard.kt:117–167 |
deepLink | String | Navigationsziel beim Tippen. |
Nutzungsregeln: subtitle und duration werden zwar an die Karte durchgereicht, von der Overlay-Darstellung aber nicht gerendert — sichtbar sind nur Eyebrow (series) + TitelRowSection.kt:216,218 · MediaCard.kt:200–339. Die per Zeile definierten contextActions werden ebenfalls durchgereicht, aber im Overlay-Layout nicht gerendert (siehe Audit-Notizen). Breite: auf Telefonen dynamisch, sodass ~1,5 Karten sichtbar sind — (Bildschirmbreite − 2 × rowHorizontalPadding − 0.5 × rowItemSpacing) / 1.5 —, sonst 280dpRowSection.kt:130–134, 139–143, 161.
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Seitenverhältnis | 16:9 (Parameter-Default) | — (kein Token; Funktions-Default) | MediaCard.kt:106 |
| Breite — Telefon | dynamisch (~1,5 Karten sichtbar) | abgeleitet aus playlist.rowHorizontalPadding + rowItemSpacing | RowSection.kt:139–143, 161 |
| Breite — Tablet / MATCHED | 280dp (Literal; ≙ mediaCard.defaultMaxWidth = 280, aber nicht darüber gelesen) | component.{mode}.mediaCard.defaultMaxWidth | RowSection.kt:130–134, 161 · MediathekTokenModels.kt:474–476 |
| Eckenradius | 16 | component.{mode}.cardOverlay.cornerRadius | tokens.source.json (layers.component.*.cardOverlay) · MediaCard.kt:244 |
| Rahmen | 0.5dp in cardOverlay.borderColor | component.{mode}.cardOverlay.borderColor + spacing.dividerHeight | MediaCard.kt:248–252 · DesignTokens.kt:304 |
| Schatten | 8dp Elevation · Spot Schwarz α 0.3 | spacing.elevationCard + colors.overlayLight | MediaCard.kt:242–246 · DesignTokens.kt:302 · design/tokens/DesignTokens.kt:37 |
| Scrim-Deckkraft (Token-Soll) | Dunkel 0 → 1 · Hell 0 → 0.85 | component.{mode}.cardOverlay.gradientStartOpacity / gradientEndOpacity | tokens.source.json · MediathekTokenModels.kt:259–260 — von der Implementierung derzeit nicht gelesen, siehe Audit |
| Oberer Inhalts-Abstand | Dunkel 24dp · Hell 48dp | spacing.gapL / spacing.gap5xl | MediaCard.kt:284 · DesignTokens.kt:291,305 |
| Text-Innenabstand (Seiten + unten) | 8dp | spacing.gap2xs | MediaCard.kt:299 · DesignTokens.kt:286 |
| Titel-Font | 14sp · SemiBold · LH 15 · Diagramm · max 2 Zeilen | component.{mode}.cardOverlay.titleFontSize/Weight/LineHeight | tokens.source.json · MediaCard.kt:317–328 |
| Eyebrow-Font | 11sp · Normal · LH 13 · Inter · 1 Zeile | component.{mode}.cardOverlay.subtitleFontSize/Weight/LineHeight | tokens.source.json · MediaCard.kt:302–315 |
| Badge-Inset | 8dp | spacing.gap2xs | MediaCard.kt:272, 280 |
Zustände
- Loading
- Grundfläche in der extrahierten Basisfarbe (bzw. dem semantischen Fallback); Scrim + Textblock werden bereits gerendert. Bild-Opazität 0.
- Geladen
- Bild blendet über 300ms ein; Scrim, Badge, Text und Fortschrittsbalken liegen darüber.
- Bildfehler / leere URL
- Keine separate Fallback-Schicht: Die Bild-Opazität bleibt 0, die Karte zeigt dauerhaft Grundfarbe + Scrim + Eyebrow/Titel. Kein Retry.
- Mit Badge
- Status-Pille aus
tagVariant/tag; ohne Status fällt sie auf das Kategorie-Badge auscontentTypezurück. - Mit Fortschritt
- Nur gerendert, wenn
watchProgress != null && watchProgress > 0f; unten-links verankert. - Gedrückt
- Plattform-Druckfeedback (Ripple/Highlight) innerhalb des geclippten Eckenradius.
Verhalten
- Tap — ruft
onClickauf; navigiert zumdeepLinkdes Items.RowSection.kt:104 - Farbauflösung — Palette aus der Payload (
colors.palette.{mode}) hat Vorrang; fehlt sie, extrahiert der Client die Farben aus dem Bild; letzter Fallback sind semantische Tokens. Der Wechsel der Grundfarbe wird über 300ms animiert.MediaCard.kt:117–149 - Bild-Fetch — einmal beim Eintritt in den Viewport; CDN-URL mit Ratio-abhängiger Breite (640 bei ≥1:1, sonst 400).MediaCard.kt:221–230
- Hell/Dunkel — beide Modi rendern dieselbe Struktur; im Hellmodus wird die Light-Palette verwendet und der obere Inhalts-Abstand steigt von 24dp auf 48dp (flacherer Scrim-Anteil).MediaCard.kt:118–120, 284
- Long-Press / Rechtsklick — kein definiertes Verhalten.
Barrierefreiheit
- Rolle button (oder link im Web) am Container; die gesamte Karte ist ein Fokus-Stopp.
- Zugängliches Label = der Titel des Mediums; das Bild trägt den Titel als Alt-Text und ist innerhalb der Tap-Fläche dekorativ.MediaCard.kt:258
- Der Fortschrittsbalken legt den Prozentsatz über accessibilityValue offen: "Bereits angesehen: NN %".
- Kontrast: Die Textfarben stammen aus derselben Palette wie die Scrim-Grundfarbe (
textPrimary/textSecondarypro Theme); der zu α 1.0 auslaufende Scrim stellt den Untergrund. Die Kontrast-Verantwortung liegt damit beim Palette-Lieferanten (API bzw. Extraktions-Engine) — bei eigener Befüllung WCAG-Kontrast gegen die Basisfarbe sicherstellen.MediaCard.kt:152–167 - Hit-Target deutlich über dem 48dp-Minimum (Telefon-Breite ≥ Bildschirmbreite/1,5 bei 16:9).
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Bild aufgelöst | image opacity 0 → 1 | 300ms | tween (Standard) |
| Palette wechselt | Grundfarbe (Fläche + Scrim) | 300ms | tween (Standard) |
| Druck | Plattform-Druckfeedback | Plattform-Standard | — |
Audit-Notizen
Screenshot ausstehend. Unter docs/screenshots/ existiert noch keine CardOverlay-Aufnahme — bitte aus dem Lesepläne-Showcase nachliefern (Hell + Dunkel).
Scrim-Tokens definiert, aber nicht gelesen: cardOverlay.gradientStartOpacity/gradientEndOpacity (Dunkel 0→1, Hell 0→0.85) werden geparstTheme.kt:480–481, der Scrim verwendet jedoch hartcodierte Stopps 0 / 0 / 0.7 / 0.9 / 1.0 mit EXCEPTION: derived contrast-AnnotationMediaCard.kt:288–298. Entweder die Stopps aus den Tokens ableiten oder die Tokens als unbenutzt deklarieren. Dito cardOverlay.textPadding = 16 — der Code verwendet spacing.gap2xs = 8dpMediaCard.kt:299.
Border zur Laufzeit unsichtbar: Die Payload enthält kein cardOverlay.borderColor; der Kotlin-Default ist Color.TransparentMediathekTokenModels.kt:263 · Theme.kt:484 — die 0.5dp-Border wird also effektiv nicht gezeichnet. Falls die gemeinsame Karten-Border (Weiß α 0.15) gewollt ist, den Token in die Quelle aufnehmen.
Baked-Default-Drift: Kotlin-Default cornerRadius = 12dp und titleLineHeight = 17MediathekTokenModels.kt:759, 266 weichen vom Token-Soll (16 / 15) ab; die Live-Payload überschreibt beim Start, offline-first gilt aber kurz der Baked-Wert. Defaults nachziehen.
Kontextmenü-Lücke: contextMenuActions wird als Parameter akzeptiertMediaCard.kt:113, im Overlay-Layout aber — anders als bei CardSplit — nicht gerendert (kein [more_vert]-Trigger). Klären, ob das Overlay-Karten-Kontextmenü (Spec 086) folgen soll.
Unbenutzte Payload-Schlüssel: cardOverlay.metadataFontSize/Weight/LineHeight, eyebrowTitleSpacing und actionInset liegen in der Payload, werden aber nicht gerendert bzw. nicht geparst — die Karte hat keine Metadaten-Zeile. Nutzungsregel dokumentieren oder Tokens zurückbauen.
Referenz
Screenshot ausstehend — siehe Audit-Notizen. Die Komponente ist im Lesepläne-Showcase (Reihen mit cardType=OVERLAY) und in Figma (Seite i2 · Card · Overlay) sichtbar.
Implementierungsreferenz
MediaCard.kt (MediaCardStyle.OVERLAY → MediaCardOverlay, Zeilen 96–339) · RowSection.kt (PlaylistCardType.OVERLAY, Zeilen 130–134, 161, 210–231) · CardOverlayTokens / MediaCardTokens in MediathekTokenModels.kt:258–273, 474–476 · Payload-Parsing Theme.kt:479–493
CardSplit
Iteration 2 scope Row itemEine zweigeteilte Karte (Composition-Kartentyp SPLIT): oben ein 16:9-Bild, darunter ein Textblock auf statischem Inhaltsflächen-Hintergrund — Eyebrow, Titel (begrenzte Zeilenzahl) und eine unten verankerte Metadaten-Zeile. Anders als CardOverlay liest CardSplit ausschließlich die eigene cardSplit-Token-Gruppe und die modus-bewusste semantische Palette — keine extrahierten Bildfarben. Der Textblock reserviert ein festes Zeilenbudget, sodass alle Karten einer Reihe gleich hoch bleiben. Die gesamte Karte ist ein einziges Tap-Ziel; optional erscheint ein Kontextmenü-Trigger in der unteren Kartenecke.
Aufbau
Eine Spalte, auf den Eckenradius geclippt, mit Schatten und der Inhaltsflächen-Hintergrundfarbe. Oben das full-bleed-16:9-Bild (Einblendung über 300ms) mit optionalem Badge oben-links und optionalem Fortschrittsbalken an der Bild-Unterkante. Darunter der Textblock mit fester Höhe = Eyebrow-Zeile + titleMaxLines × Titel-Zeilenhöhe + Metadaten-Zeile + 2 × 4dp: Eyebrow + Titel sitzen oben (der Titel rückt nach oben, wenn kein Eyebrow vorhanden ist), die Metadaten-Zeile ist unten verankert; ein flexibler Zwischenraum absorbiert ungenutzte Titel-Zeilen. Die Metadaten-Zeile wird auch leer gerendert, damit ihre Höhe reserviert bleibt.CardSplit.kt:94–105, 107–231
Callout-Legende
- Container — Karten-Chrome
- Bild — Full-Bleed-Cover (16:9)
- Badge (optional)
- Fortschrittsbalken (optional)
- Textblock — Eyebrow + Titel
- Metadaten-Zeile — unten verankert
- Kontextmenü-Trigger (optional)
Composition-API-Kontrakt
CardSplit wird von playlist_row-Zeilen mit cardType=SPLIT instanziiert (PlaylistCollectionModels.kt:50–62, 409–415 · RowSection.kt:232–250). Konsumierte PlaylistItem-Felder (PlaylistCollectionModels.kt:279):
| Feld | Typ | Hinweise |
|---|---|---|
imageUrl | String | 16:9-Bild der oberen Kartenhälfte (CDN-Zuschnitt, w=520). |
title | String? | Titel, max cardSplit.titleMaxLines Zeilen. |
series | String? | Eyebrow-Zeile über dem Titel; entfällt bei null/leer (der Titel rückt nach oben).RowSection.kt:238 · CardSplit.kt:181–195 |
metadata / duration | String? | Metadaten-Zeile: metadata hat Vorrang, sonst duration.RowSection.kt:239 |
contentType | String? | Doppelrolle: Kategorie-Badge-Fallback (nur wenn tag == null) und letztes Segment der Metadaten-Zeile.RowSection.kt:240 · CardSplit.kt:104, 144–152 |
tag / tagVariant | String? | Status-Badge oben-links (tagVariant → VideoState-Pille). |
progress | Float? | Fortschrittsbalken 0.0–1.0 an der Bild-Unterkante; entfällt bei null. |
deepLink | String | Navigationsziel beim Tippen. |
Zeilen-Ebene: contextActions | List<RowContextAction>? | Pro Reihe definierte Aktionen → Kontextmenü-Einträge der Karte (Spec 086; Klick-Verdrahtung folgt mit dem Watchlist-Backend).PlaylistCollectionModels.kt:59, 66–69 · RowSection.kt:93–95 |
Nutzungsregeln: colors wird von CardSplit nicht konsumiert — der Inhaltsblock liegt auf dem statischen cardSplit.bgColor-Token, Textfarben kommen aus der semantischen Palette (FR-002)CardSplit.kt:76–79, 117. Die Parameter seasonNumber/episodeNumber existieren an der Komponente, werden von der Reihe aber nie befüllt — PlaylistItem hat keine Staffel-/Folgen-Felder; das "S/E"-Format ist über die Payload derzeit nur via vorformatiertem metadata-String erreichbar.CardSplit.kt:65–66, 97–103 · RowSection.kt:236–249 Breite: auf Telefonen dynamisch (~1,5 Karten sichtbar), sonst 280dpRowSection.kt:125–129, 162.
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Bild-Seitenverhältnis | 16:9 (1,7778) | cardSplit.imageAspectRatio (094-Lift: Quelle 1.7777778 + Kotlin-Default + Merge) | CardSplit.kt:128 · MediathekTokenModels.kt:300 · Theme.kt:533 · tokens.source.json (layers.component.*.cardSplit) |
| Breite — Telefon | dynamisch: (Bildschirmbreite − 2 × rowHorizontalPadding − 0.5 × rowItemSpacing) / 1.5 | abgeleitet aus playlist.rowHorizontalPadding + rowItemSpacing | RowSection.kt:125–128, 139–143, 162 |
| Breite — Tablet / MATCHED | 280dp (Literal) | — | RowSection.kt:129, 162 |
| Eckenradius | 16 | component.{mode}.cardSplit.cornerRadius | tokens.source.json (layers.component.*.cardSplit) · CardSplit.kt:113,116 |
| Hintergrund Textblock | Dunkel #141B1F · Hell #FFFFFF | component.{mode}.cardSplit.bgColor (Kotlin-Default; nicht in der Payload) | MediathekTokenModels.kt:294, 867–870 |
| Schatten | 8dp Elevation · Spot Schwarz α 0.3 · keine Border | spacing.elevationCard + colors.overlayLight | CardSplit.kt:111–115 · DesignTokens.kt:302 |
| Textblock-Innenabstand | 10dp horizontal · 10dp vertikal | component.{mode}.cardSplit.infoAreaPaddingHorizontal/Vertical | tokens.source.json · CardSplit.kt:175–178 |
| Textblock-Höhe | Eyebrow-LH + Titel-LH × titleMaxLines + Metadaten-LH + 2 × 4dp (spacing.gap3xs) | abgeleitet aus den cardSplit.*LineHeight-Tokens | CardSplit.kt:167–171 |
| Titel-Font | DTCG/Payload: 14sp · SemiBold · LH 15 — Kotlin-Default: 16sp · SemiBold · LH 19.2 (091 FR-006) · Diagramm · Drift, siehe Audit | component.{mode}.cardSplit.titleFontSize/Weight/LineHeight | tokens.source.json · MediathekTokenModels.kt:281–283 · CardSplit.kt:197–208 |
| Titel — max. Zeilen | 2 · Ellipsis | component.{mode}.cardSplit.titleMaxLines (T007-Lift; in Quelle + Payload-Parsing verifiziert) | tokens.source.json · MediathekTokenModels.kt:284 · Theme.kt:508 · CardSplit.kt:206 |
| Eyebrow-Font | 12sp (Kotlin-Default) · Gewicht Normal (Token) · LH 15 (Kotlin-Default) · Inter · 1 Zeile | component.{mode}.cardSplit.eyebrowFontWeight (T007-Lift); Größe/LH nur als Kotlin-Default, siehe Audit | tokens.source.json · MediathekTokenModels.kt:286–288 · Theme.kt:502–504 · CardSplit.kt:181–193 |
| Abstand Eyebrow → Titel | 4dp | spacing.gap3xs | CardSplit.kt:194 · DesignTokens.kt:284 |
| Metadaten-Font | Payload: 10sp · Normal · LH 13 (Kotlin-Default 11/14) · Inter · 1 Zeile | component.{mode}.cardSplit.metadataFontSize/Weight/LineHeight | tokens.source.json · MediathekTokenModels.kt:289–291 · CardSplit.kt:212–223 |
| Badge-Inset | 8dp | spacing.gap2xs | CardSplit.kt:142, 150 |
Zustände
- Loading
- Bildbereich in
cardSplit.bgColor(Bild-Opazität 0); der Textblock wird sofort vollständig gerendert. - Geladen
- Bild blendet über 300ms ein; Badge + Fortschrittsbalken liegen darüber.
- Bildfehler / leere URL
- Keine separate Fallback-Schicht: Die Bild-Opazität bleibt 0, der Bildbereich zeigt dauerhaft
cardSplit.bgColor; der Textblock bleibt unverändert lesbar. Kein Retry. - Ohne Eyebrow
seriesleer → Eyebrow-Zeile entfällt, der Titel rückt nach oben; die Blockhöhe bleibt durch das feste Zeilenbudget unverändert.- Mit Fortschritt
- Nur gerendert, wenn
watchProgress != null && watchProgress > 0f; an der Bild-Unterkante verankert. - Mit Kontextmenü
contextActionsvorhanden →[more_vert]-Trigger in der unteren End-Ecke; geöffnet: Dropdown mit Frosted-Glass-Hintergrund (Canvas × α 0.95).- Gedrückt
- Plattform-Druckfeedback (Ripple/Highlight) innerhalb des geclippten Eckenradius.
Verhalten
- Tap — ruft
onClickauf; navigiert zumdeepLinkdes Items.RowSection.kt:104 - Höhen-Stabilität — der Textblock reserviert immer das volle Zeilenbudget (Eyebrow +
titleMaxLines+ Metadaten); kürzere Titel werden durch einen flexiblen Zwischenraum ausgeglichen, sodass die Metadaten-Zeile aller Karten einer Reihe auf einer Linie liegt.CardSplit.kt:163–179, 210 - Metadaten-Aufbau — Dauer, dann Staffel/Folge, dann Kategorie; leere Teile werden übersprungen; Verbinder " • ". Die Zeile wird auch leer gerendert.CardSplit.kt:94–105
- Bild-Fetch — einmal beim Eintritt in den Viewport; CDN-URL mit
ar=16:9,w=520.CardSplit.kt:81–85 - Kontextmenü — öffnet/schließt per Tap auf den Trigger; ein Eintrag-Tap führt die Aktion aus und schließt das Menü. Aktionen sind derzeit visuelle Platzhalter (No-Op) bis das Watchlist-Backend verdrahtet ist.CardMenu.kt:57–85 · RowSection.kt:91–95
- Long-Press / Rechtsklick — kein definiertes Verhalten.
Barrierefreiheit
- Rolle button (oder link im Web) an der Kartenfläche; zugängliches Label = der Titel des Mediums (Bild-Alt-Text = Titel).CardSplit.kt:128
- Der Kontextmenü-Trigger ist ein eigener, verschachtelter Fokus-Stopp mit Label "Optionen" — anders als bei den rein bild-basierten Karten hat CardSplit also bis zu zwei Stopps.CardMenu.kt:50–56
- Eyebrow- und Metadaten-Text liegen als sichtbarer Text im Kartenkörper und sind für Screenreader ohne Sonderbehandlung erreichbar; Kontrast über
semantic.{mode}.text.primary/secondaryauf dem statischen Token-Hintergrund (kein extrahierter Untergrund). - Der Fortschrittsbalken legt den Prozentsatz über accessibilityValue offen: "Bereits angesehen: NN %".
- Hit-Target deutlich über dem 48dp-Minimum (Telefon-Breite ≥ Bildschirmbreite/1,5).
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Bild aufgelöst | image opacity 0 → 1 | 300ms | tween (Standard) |
| Kontextmenü öffnen/schließen | Dropdown-Sichtbarkeit | Plattform-Standard (Menü-Transition) | — |
| Druck | Plattform-Druckfeedback | Plattform-Standard | — |
Audit-Notizen
Screenshot ausstehend. Unter docs/screenshots/ existiert noch keine CardSplit-Aufnahme — bitte aus dem Lesepläne-Showcase nachliefern (Hell + Dunkel).
Titel-Font-Drift (offen): Die Token-Quelle + Payload liefern titleFontSize = 14 / titleLineHeight = 15; der Kotlin-Default (091 FR-006, Referenz-Implementierung) ist 16 / 19.2MediathekTokenModels.kt:280–283. Da die Live-Payload die Baked-Defaults überschreibt, rendert die App zur Laufzeit 14sp/15. Der Audit-Hinweis im Haupt-Dokument (design-system.html:365) behauptet, die API sei am 10.06. auf 16 korrigiert worden — die aktuelle tokens.source.json / design-tokens-api/tokens.json sagt weiterhin 14. Vor Vertrags-Freeze beim Token-Pipeline-Owner klären.
Eyebrow nur teilweise geliftet (T007): cardSplit.titleMaxLines = 2 und cardSplit.eyebrowFontWeight = Normal sind in der Quelle vorhanden und werden geparstTheme.kt:503, 508 — verifiziert. Die Payload führt Eyebrow-Größe/Zeilenhöhe aber noch unter den Legacy-Schlüsseln subtitleFontSize/subtitleLineHeight (11/13), während das Parsing eyebrowFontSize/eyebrowLineHeight erwartetTheme.kt:502, 504 — Größe/LH sind damit remote nicht steuerbar und laufen auf den Kotlin-Defaults 12sp/15MediathekTokenModels.kt:286–288. Schlüssel in der Quelle umbenennen.
Unbenutzte Payload-Schlüssel: cardSplit.titleFooterSpacing, eyebrowTitleSpacing und actionInset liegen in der Payload, werden aber nicht geparst — der Code verwendet spacing.gap3xs-Literale und einen flexiblen ZwischenraumCardSplit.kt:171, 194, 210. cardSplit.bgColor ist umgekehrt nur Kotlin-Default (nicht in der Quelle), wird aber geparst, falls die Payload ihn liefertTheme.kt:509.
Komponentenübergreifende Wiederverwendung — inzwischen aufgelöst: Der CardPoster-Abschnitt (design-system.html:3005, Callout 2950, Token-Tabelle 3073–3075) dokumentiert, dass der Poster-Fallback-Titel cardSplit.titleFontSize/Weight/LineHeight wiederverwendet. Der aktuelle Code tut das nicht mehr: CardPoster liest eigene cardPoster.fallbackTitle*-Defaults (16sp/Medium/20, Kotlin-only)CardPoster.kt:121–123 · MediathekTokenModels.kt:322–324 — genau die Namespace-Trennung, die der dortige Audit-Hinweis empfahl. Beim Einbau dieses Abschnitts den CardPoster-Abschnitt entsprechend nachziehen.
Referenz
Screenshot ausstehend — siehe Audit-Notizen. Die Komponente ist im Lesepläne-Showcase (Reihen mit cardType=SPLIT) und in Figma (Seite i2 · Card · Split) sichtbar.
Implementierungsreferenz
cards/CardSplit.kt · RowSection.kt (PlaylistCardType.SPLIT, Zeilen 125–129, 162, 232–250) · CardMenu.kt (CardContextMenuButton) · CardSplitTokens in MediathekTokenModels.kt:276–295 · Payload-Parsing Theme.kt:495–509
CardTop10
Iteration 3 scope Row itemEine Komposit-Karte für gerankte "Top 10"-Reihen: eine überdimensionale Rang-Ziffer (Outline + vertikaler Verlauf als Füllung), hinter deren rechte Flanke sich eine Inhaltskarte schiebt. CardTop10 rendert selbst keine Medieninhalte — der Karten-Slot wird je nach cardType der Sektion mit einer bestehenden Kartenvariante befüllt (CardPoster, CardLandscape, MediaCard OVERLAY oder CardSplit). Das einzige Tap-Ziel ist die innere Karte; die Ziffer ist nicht interaktiv und wird für TalkBack zu einem einzigen Rang-Label ("Platz N") zusammengefasst (siehe Barrierefreiheit).
Aufbau
Ein äußerer Container mit der Breite Kartenbreite + Rang-Überhang (rankNumOuterOverlap = 100dp). Links darin, vertikal zentriert, die Rang-Ziffer(n) in zwei übereinanderliegenden Text-Schichten pro Ziffer: zuerst die Kontur-Schicht (Stroke, 3dp), darüber die Füll-Schicht (vertikaler 2-Stopp-Linearverlauf). Die gesamte Ziffern-Gruppe wird nach rechts hin weich ausgeblendet (horizontale Maske: voll sichtbar bis 55 % der Gruppenbreite, dann Ausblendung bis 100 %), sodass die Ziffer optisch hinter der Karte verschwindet. Die Inhaltskarte ist rechtsbündig im äußeren Container verankert.CardTop10.kt:100–158
Callout-Legende
- Außencontainer — Maß-Box
- Rang-Ziffer — Kontur-Schicht (Stroke)
- Rang-Ziffer — Füll-Schicht (Verlauf) + Ausblend-Maske
- Karten-Slot — rechtsbündig
- Mehrstellige Ränge — negativer Überlapp
Composition-API-Kontrakt
CardTop10 wird ausschließlich von der Sektion type="top10" instanziiert — sie ist kein playlist_row-Kartentyp, sondern eine eigene Sektionsart mit eigener Item-Form (Top10Item = Reihen-Item + rank). Die Sektion validiert clientseitig: Items nicht leer, Ränge eindeutig, jeweils 1–10, aufsteigend sortiert.PlaylistCollectionModels.kt:92–108 · playlist.ts:111–117
Sektion top10 (1:1 zu PlaylistCollectionModels.kt)
| Feld | Typ (laut PlaylistCollectionModels.kt:95–99) | Hinweise |
|---|---|---|
type | Diskriminator, Wire-Wert "top10" | Wählt diese Sektionsart aus (@SerialName("top10")). PlaylistCollectionModels.kt:94 |
id | String | Eindeutige Sektions-ID (z. B. "top-10" auf der Home-Seite). packages/playlist-api/data/pages/home.json |
title | String? | Optionaler Reihen-Titel über der Reihe (z. B. „Top 10 diese Woche"). Entfällt bei null. Top10Section.kt:51–63 |
items | List<Top10Item> | Genau die gerankten Items; Reihen-Schlüssel ist rank. Top10Section.kt:69 |
cardType | PlaylistCardType (Default POSTER_PORTRAIT) | Bestimmt, welche Kartenvariante im Karten-Slot gerendert wird — siehe Dispatch-Matrix unten. Wire-Werte: POSTER_PORTRAIT · IMAGE_ONLY_LANDSCAPE · OVERLAY · SPLIT · AVATAR. PlaylistCollectionModels.kt:99,409–415 |
Item-Form Top10Item (1:1 zu PlaylistCollectionModels.kt:321–334)
| Feld | Typ | Hinweise |
|---|---|---|
rank | Int | 1–10, eindeutig, aufsteigend. Steuert die gezeichnete Ziffer und dient als Listen-Schlüssel. PlaylistCollectionModels.kt:101–107 |
imageUrl | String | Artwork; Zuschnitt übernimmt die innere Karte (Poster 9:16, Landscape 16:9 …). |
title | String | Titel; von allen inneren Kartenvarianten konsumiert (auch als Bild-Fallback). |
contentType | String? | Kategorie-Fallback für das Badge, wenn tag fehlt. Top10Section.kt:119 |
deepLink | String | Navigationsziel beim Tippen auf die innere Karte. Top10Section.kt:74 |
progress | Float? | 0.0–1.0; wird als Fortschrittsbalken an die innere Karte durchgereicht; entfällt bei null. Top10Section.kt:116 |
tag | String? | Status-Pill; tag == "Live" (case-insensitive) erzeugt den Live-Zustand der Pill. Dient zugleich als Badge-Text vor dem contentType-Fallback. Top10Section.kt:117–119 |
subtitle | String? | Nur von OVERLAY konsumiert. Top10Section.kt:148 |
series | String? | Nur von OVERLAY und SPLIT konsumiert. Top10Section.kt:149,165 |
description | String? | Auf dem Modell vorhanden, von keiner Top10-Dispatch-Variante konsumiert (Nutzungsregel). |
duration | String? | Von OVERLAY konsumiert; bei SPLIT Fallback, wenn metadata fehlt. Top10Section.kt:150,166 |
metadata | String? | Nur SPLIT: Metadaten-Zeile, hat Vorrang vor duration. Top10Section.kt:166 |
Drift-Hinweis: Das BFF-Modell Top10Item extends PlaylistItem (playlist.ts:220–239) trägt zusätzlich id, tagVariant und colors — diese Felder existieren im Android-Modell nicht und werden beim Parsen ignoriert. Zu klären, ob tagVariant (explizite Pill-Variante statt der "Live"-String-Heuristik) nachgezogen werden soll.
Dispatch-Matrix — cardType → innere Karte
cardType | Gerenderte Variante | Breite der inneren Karte | Quelle |
|---|---|---|---|
POSTER_PORTRAIT (Default) | CardTop10 — Convenience-Wrapper mit CardPoster im Slot | cardTop10.posterWidth = 160dp (fest) | Top10Section.kt:110–122 · CardTop10.kt:31–64 |
IMAGE_ONLY_LANDSCAPE | CardLandscape in CardTop10Layout | Phone: dynamisch (~1.2 sichtbare Karten, min. 180dp) · Tablet: cardImageOnly.widthMedium | Top10Section.kt:95,132–144 |
OVERLAY | MediaCard im Stil OVERLAY in CardTop10Layout | Phone: dynamisch (min. 180dp) · Tablet: 280dp (Literal, kein Token — siehe Audit) | Top10Section.kt:96,145–161 |
SPLIT | CardSplit in CardTop10Layout | Phone: dynamisch (min. 180dp) · Tablet: 280dp (Literal, kein Token — siehe Audit) | Top10Section.kt:97,162–176 |
AVATAR | Nicht unterstützt — der Slot bleibt leer (nur die Rang-Ziffer würde gerendert). Das BFF darf AVATAR für top10 nicht ausliefern. | — | Top10Section.kt:177 |
In allen Varianten reicht die Dispatch-Ebene Badge (tag / contentType) und Fortschritt (progress) unverändert an die innere Karte durch — CardTop10 legt kein eigenes Badge-/Progress-Overlay über den Slot. Beispiel-Payloads: home.json (Sektion top-10, POSTER_PORTRAIT) und search.json (Sektionen search-top10-poster und search-top10-split).Top10Section.kt:113–119,137–140 · packages/playlist-api/data/pages/home.json · search.json
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Außenbreite | Kartenbreite + 100dp | cardWidth + component.{mode}.cardTop10.rankNumOuterOverlap | CardTop10.kt:100 |
| Rang-Überhang (sichtbarer Ziffern-Anteil) | 100dp | component.{mode}.cardTop10.rankNumOuterOverlap | MediathekTokenModels.kt:375 |
| Ziffern-Schriftgröße | 200sp · Zeilenhöhe 200sp | component.{mode}.cardTop10.rankNumFontSize | CardTop10.kt:87,93,96 · MediathekTokenModels.kt:372 |
| Ziffern-Schrift | Inter · Black (900) · Laufweite 0em | component.{mode}.cardTop10.rankFontFamily + rankNumLetterSpacing | CardTop10.kt:91–95 · MediathekTokenModels.kt:378,380 · DesignTokens.kt:59 |
| Kontur-Strichstärke | 3dp | component.{mode}.cardTop10.rankNumStrokeWidth | CardTop10.kt:88 · MediathekTokenModels.kt:373 |
| Konturfarbe — Dunkel | Weiß α 0.22 (Kotlin-Default; Token sagt rgba(255,255,255,0.25) — siehe Audit) | component.dark.cardTop10.rankNumBorderColorDark | MediathekTokenModels.kt:376 · tokens.source.json cardTop10 |
| Konturfarbe — Hell | #141B1F α 0.25 (Kotlin-Default; Token sagt rgba(26,26,26,0.25) — siehe Audit) | component.light.cardTop10.rankNumBorderColorLight | MediathekTokenModels.kt:377 · tokens.source.json cardTop10 |
| Ziffern-Füllung — Dunkel | Vertikaler Verlauf border.strong (#7A8B94) → background.canvas (#080D16) | semantic.dark.border.strong + semantic.dark.background.canvas | CardTop10.kt:76–77 · SemanticColors.kt:439,462 |
| Ziffern-Füllung — Hell | Vertikaler Verlauf background.surfaceSunken (#EBF0F2) → border.strong (#A8B5BC) | semantic.light.background.surfaceSunken + semantic.light.border.strong | CardTop10.kt:76–77 · SemanticColors.kt:221,241 |
| Buchstaben-Überlapp (mehrstellig) | -0.2 × Schriftgröße | component.{mode}.cardTop10.rankNumLetterOverlapFactor | CardTop10.kt:89,144–156 · MediathekTokenModels.kt:374 |
| Ausblend-Maske der Ziffer | horizontal · sichtbar 0–55 %, Ausblendung 55–100 % | — (grafisches Primitiv, mit EXCEPTION-Kommentar im Quellcode) | CardTop10.kt:109–122 |
| Posterbreite (POSTER_PORTRAIT) | 160dp | component.{mode}.cardTop10.posterWidth | CardTop10.kt:43 · MediathekTokenModels.kt:371 |
| Abstand zwischen Items / Reihen-Einzug | Token | component.{mode}.playlist.rowItemSpacing + rowHorizontalPadding | Top10Section.kt:65–67 |
| Reihen-Titel | Token (geteilt mit allen Reihen-Sektionen) | component.{mode}.playlist.rowTitleFontSize / rowTitleFontWeight / rowTitleBottomSpacing | Top10Section.kt:51–63 |
Zustände
- Default
- Rang-Ziffer (Kontur + Verlauf, nach rechts ausgeblendet) + innere Karte im Default-Zustand der jeweiligen Variante.
- Loading / Geladen / Bildfehler
- Vollständig an die innere Karte delegiert (Skelett, Crossfade, Verlauf-Fallback — siehe CardPoster bzw. CardLandscape). Die Rang-Ziffer ist von Anfang an sichtbar; sie hat keinen Ladezustand.
- Gedrückt
- Druck-Feedback nur innerhalb der inneren Karte (deren geclippter Ripple). Die Ziffer reagiert nicht auf Berührung.
- Hell / Dunkel
- Konturfarbe und Verlaufs-Stopps der Ziffer wechseln pro Theme (siehe Abmessungen); die innere Karte folgt ihrem eigenen Theme-Verhalten.
- Mit Badge / Fortschritt
- Badge (
tag/contentType) und Fortschrittsbalken (progress) werden an die innere Karte durchgereicht und dort nach deren Regeln gerendert — kein zusätzliches Overlay auf Komposit-Ebene. - Zweistelliger Rang (10)
- Beide Ziffern als eigene Schichten-Paare, horizontal mit -0.2 × Schriftgröße überlappend platziert; die Außenbreite ändert sich nicht — der zusätzliche Ziffernanteil verschwindet hinter der Karte.
Verhalten
- Tap — die innere Karte ruft den Klick-Callback auf; das Ziel ist
item.deepLink. Die Rang-Ziffer selbst ist kein Tap-Ziel.Top10Section.kt:74 - Reihen-Layout — die Sektion rendert optionalen Titel + horizontal scrollende Reihe mit den geteilten
playlist-Reihen-Tokens; Listen-Schlüssel istrank.Top10Section.kt:50–78 - Rang-Validierung — die Sektion gilt nur als gültig, wenn Items vorhanden sind, alle Ränge eindeutig im Bereich 1–10 liegen und aufsteigend sortiert ankommen.PlaylistCollectionModels.kt:101–107
- Live-Heuristik —
tag == "Live"(Groß-/Kleinschreibung egal) versetzt die Status-Pill der inneren Karte in den Live-Zustand.Top10Section.kt:117 - Dynamische Breite (Phone, Nicht-Poster-Varianten) — die innere Kartenbreite wird so berechnet, dass ~1.2 Karten sichtbar sind (Bildschirmbreite abzüglich Reihen-Einzug und halbem Item-Abstand, geteilt durch 1.2, minus 100 für den Rang-Überhang; Minimum 180). Auf Tablet gelten feste Breiten je Variante.Top10Section.kt:82–100
Barrierefreiheit
- Tap-Stopp pro Item: die innere Karte (
CardPostermitrole=Button, Label = Titel).CardTop10.kt:50–62 - Die Rang-Ziffer rendert pro Stelle eigene Text-Knoten (Kontur + Verlauf). Damit TalkBack sie nicht ziffernweise vorliest ("eins, null" bei Rang 10), fasst
clearAndSetSemantics { contentDescription = "Platz $rank" }die Gruppe zu einem einzigen Knoten "Platz N" zusammen — der Rang ist die zentrale Information dieser Reihe und bleibt so hörbar (094 a11y-Verfeinerung). Er steht als eigener Knoten neben der Karte; alternativ ließe sich "Platz N: Titel" direkt in das Karten-Label komponieren.CardTop10.kt:112 - Die Ziffer ist bewusst niedrig-kontrastig (Kontur α ≈ 0.22–0.25, Verlauf in Flächenfarben) — der Rang wird über das Label "Platz N" transportiert, nicht über den Ziffern-Kontrast.
- Fokus-Reihenfolge: Reihenrichtung (LTR), Rang 1 zuerst — durch die Sortier-Validierung garantiert.
- Hit-Target = innere Karte (≥ 160 × 284dp bei Poster) — deutlich über dem 48dp-Minimum.
Motion
Die Komposit-Ebene ist statisch — keine eigenen Animationen. Crossfade und Druck-Feedback kommen aus der inneren Kartenvariante.
Audit-Notizen
Screenshot ausstehend (T026/T040 Geräte-Verifikation). Es liegt noch keine Bildschirmaufnahme der Top10-Reihe unter docs/screenshots/ — Referenzfigur nachreichen, sobald die Geräte-Verifikation gelaufen ist.
Verlaufs-Quelle der Ziffern — Laufzeit-Semantik ist maßgeblich, nicht der gradientColors-Token. Der Token cardTop10.gradientColors (Dunkel [#7A8B94, #252F35], Hell [#F8F9FA, #CFD9DD]) wird zwar aus der Token-Payload geparst (Theme.kt:560), von der Komponente aber nicht gerendert — der tatsächliche Verlauf wird zur Laufzeit aus den semantischen Farben abgeleitet (Dunkel: border.strong → background.canvas = #7A8B94 → #080D16; Hell: background.surfaceSunken → border.strong = #EBF0F2 → #A8B5BC).CardTop10.kt:76–84 Drift: Token-Werte und Laufzeit-Ableitung stimmen nicht überein — entweder den Token konsumieren oder ihn entfernen / als Alias auf die semantischen Farben dokumentieren.
Konturfarben-Tokens werden nicht aus der Payload übernommen (Implementierungslücke). rankNumBorderColorDark/Light existieren in tokens.source.json (rgba(255,255,255,0.25) / rgba(26,26,26,0.25)), das Remote-Merge in Theme.kt:556–566 parst sie aber nicht — es gewinnen die Kotlin-Defaults (Weiß α 0.22 / #141B1F α 0.25), die zudem leicht von den Token-Werten abweichen. Parser ergänzen und Defaults angleichen.
Toter Token rankFontSize = 64. Wird geparst, aber von keiner Stelle der Komponente gelesen (nur rankNumFontSize = 200 wird verwendet) — Altlast aus einer früheren Darstellung; Kandidat für Entfernung.MediathekTokenModels.kt:368 · Theme.kt:558
Literale in der Breitenberechnung. getInnerCardWidth enthält die Literale 280f (Tablet-Breite für OVERLAY/SPLIT), 180f (Phone-Minimum), 100f (Rang-Überhang, dupliziert den Token rankNumOuterOverlap) und 1.2f (Sichtbarkeitsfaktor) — keiner davon ist tokenisiert. Tokenisieren oder mit EXCEPTION-Kommentar begründen.Top10Section.kt:91–97
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/cards/CardTop10.kt — CardTop10 (Poster-Convenience) und CardTop10Layout (generischer Rang-Rahmen mit Karten-Slot als BoxScope-Content). Sektions-Rendering + Varianten-Dispatch in ui/components/sections/Top10Section.kt (Top10Section, Top10CardDispatch, getInnerCardWidth). Tokens: CardTop10Tokens in data/models/MediathekTokenModels.kt:367–381, Remote-Merge in ui/theme/Theme.kt:556–566. BFF-Vertrag: packages/playlist-api/src/types/playlist.ts (Top10Section, Top10Item), Beispiel-Payloads in data/pages/home.json + search.json.
CardNotification
Iteration 3 scope Section-Karte DismissibleEine In-Page-Hinweiskarte mit Titel, Markdown-Body (Text + optionale Bilder), einem Call-to-Action in voller Breite und einem optionalen Schließen-Icon. Die Komponente stammt aus dem Benachrichtigungs-Feature (Spec 054) und wird in Iteration 3 zusätzlich server-getrieben: Der neue Composition-Section-Typ notification rendert sie als reguläre Seitensektion (erstmals auf dem Suche-Tab). Die Karte erscheint inline im Seitenfluss — sie ist kein Overlay und blockiert keinen Inhalt.CardNotification.kt:84–268 · NotificationSectionRenderer.kt:22–47
Aufbau
Ein abgerundeter Container in voller Verfügbarkeitsbreite. Oben rechts (nur wenn schließbar) ein Schließen-Icon. Darunter eine vertikale Spalte: Titel, dann die geparsten Markdown-Elemente des Bodys in Dokumentreihenfolge (Textabsätze und 16:9-Bilder im Wechsel), dann der CTA-Button in voller Innenbreite. Der Body wird mit einem minimalen Markdown-Parser zerlegt, der ausschließlich die Bild-Syntax  erkennt — aller übrige Text wird unverändert als Fließtext gerendert (kein Fett, keine Links).CardNotification.kt:51–81 (parseMarkdown) · 191–264 (Spalte)
Callout-Legende Default
- Container — Karten-Chrome
- Schließen-Icon — oben rechts, nur wenn schließbar
- Titel
- Body — geparste Markdown-Elemente
- CTA-Button — volle Innenbreite
Callout-Legende Variante
- Markdown-Bild — optional, 16:9
- Nicht schließbar (
requiresInteraction = true)
Composition-API-Kontrakt
Neu in Iteration 3: Der Section-Typ notification liefert CardNotification als Seitensektion aus. Der Dispatch läuft über den Sektions-Renderer der Composition-Seite; die Sektion validiert, dass body nicht leer ist. Erstes Live-Beispiel: das KI-Suche-Intro auf der Suche-Seite.CollectionRenderer.kt:131–137 · PlaylistCollectionModels.kt:133 · packages/playlist-api/data/pages/search.json:193–203
API-Felder (1:1 zu PlaylistCollectionModels.kt:120–134 · BFF: packages/playlist-api/src/types/playlist.ts:38–49)
| Feld | Typ (Android · BFF) | Hinweise |
|---|---|---|
type |
Diskriminator "notification" (@SerialName("notification") · type: 'notification') |
Wählt den Notification-Renderer im Sektions-Dispatch aus. PlaylistCollectionModels.kt:122 · playlist.ts:40 |
id |
String · string |
Sektions-ID innerhalb der Seite (z. B. "search-notification"). PlaylistCollectionModels.kt:124 |
title |
String? · string | undefined |
Kartentitel. Bei null rendert der Client einen leeren Titel-String. PlaylistCollectionModels.kt:125 · NotificationSectionRenderer.kt:34 |
notificationId |
String · string |
Stabile Kennung der Mitteilung (z. B. "it3-ki-suche-intro"). Der Client nutzt sie als Schlüssel für den sitzungslokalen Dismissed-Zustand — bei neuer ID erscheint die Karte erneut. PlaylistCollectionModels.kt:126 · NotificationSectionRenderer.kt:28 |
body |
String · string |
Markdown-Body. Unterstützte Syntax: reiner Text + -Bilder. Pflichtfeld — validate() verwirft Sektionen mit leerem Body. PlaylistCollectionModels.kt:127,133 · CardNotification.kt:51–81 |
imageUrl |
String? · string | null | undefined |
Drift-Hinweis: Feld existiert auf dem Wire (Beispiel-Payload sendet null), wird vom Renderer aber nicht konsumiert — Bilder laufen ausschließlich über die Markdown-Syntax im body. Vor Contract-Freeze mit dem API-Engineer klären: Feld streichen oder Renderer erweitern. PlaylistCollectionModels.kt:128 · playlist.ts:45 · search.json:199 · NotificationSectionRenderer.kt:33–39 (kein Mapping) |
ctaLabel |
String? · string | undefined |
Button-Beschriftung (z. B. "Jetzt ausprobieren"). Siehe Audit-Hinweis: Der Button wird derzeit auch ohne Label gerendert. PlaylistCollectionModels.kt:129 · NotificationSectionRenderer.kt:36 |
ctaDeepLink |
String? · string | undefined |
Navigationsziel des CTA (z. B. "protobible://search"). Tap navigiert und schließt die Karte. PlaylistCollectionModels.kt:130 · NotificationSectionRenderer.kt:42 · CardNotification.kt:241–245 |
dismissible |
Boolean? (Default true) · boolean | undefined |
false → Karte ist nicht wegklickbar (requiresInteraction = true: kein Schließen-Icon, Dismiss nur über CTA). true / fehlend → schließbar. PlaylistCollectionModels.kt:131 · NotificationSectionRenderer.kt:38,41 |
Beispiel-Payload: packages/playlist-api/data/pages/search.json:193–203 ("Neu: Die KI-Suche"). Die Felder backgroundColor / textColor der Komponenten-API (NotificationCardData) sind nicht Teil des Section-Kontrakts — der server-getriebene Pfad nutzt immer die Token-Standardfarben.CardNotification.kt:35–44 · NotificationSectionRenderer.kt:33–39
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Breite | volle Verfügbarkeitsbreite · im Section-Kontext mit horizontalem Seitenabstand playlist.rowHorizontalPadding | — | NotificationSectionRenderer.kt:43–45 |
| Eckenradius Karte | 16dp Mobile / 32dp Tablet | radius.lg | CardNotification.kt:174 · DesignTokens.kt:313,471 |
| Innenabstand horizontal | 24dp Mobile / 32dp Tablet | spacing.paddingLg | CardNotification.kt:113,176 · DesignTokens.kt:299,457 |
| Innenabstand vertikal | 32dp Mobile / 80dp Tablet | spacing.sectionGap | CardNotification.kt:114,176 · DesignTokens.kt:300,458 |
| Element-Abstand (Spalte) | 10dp | spacing.gapXs | CardNotification.kt:115,195 · DesignTokens.kt:287 |
| Markdown-Bild — Seitenverhältnis | 16:9 (1.7778) | component.{mode}.cardNotification.aspectRatio | CardNotification.kt:218 · MediathekTokenModels.kt:469–471 · tokens.source.json:1596–1598 / 2057–2059 · Theme.kt:754–755 (Payload-Merge) |
| Markdown-Bild — Radius | 8dp Mobile | radius.default | CardNotification.kt:219 · DesignTokens.kt:311 |
| Markdown-Bild — Außenabstand | 4dp oben + unten | spacing.gap3xs | CardNotification.kt:212,222 · DesignTokens.kt:284 |
| Titel-Font | 22sp · SemiBold · LH 28sp · Diagramm | typography.h4 + lineHeightNotificationTitle | CardNotification.kt:197–204 · DesignTokens.kt:248,267 |
| Body-Font | 16sp · Normal · LH 24sp · Inter | typography.body + lineHeightNotificationBody | CardNotification.kt:224–232 · DesignTokens.kt:250,268 |
| CTA — Höhe | 48dp Mobile / 64dp Tablet | spacing.gap5xl | CardNotification.kt:249 · DesignTokens.kt:305,463 |
| CTA — Radius | 24dp Mobile / 48dp Tablet (Pill) | radius.xxl | CardNotification.kt:250 · DesignTokens.kt:315,473 |
| CTA — Font | 16sp · Medium · Inter | typography.body | CardNotification.kt:256–263 |
| Schließen-Icon | 20dp · [close] | icon.sm | CardNotification.kt:180,185 · DesignTokens.kt:323 |
| Hintergrund Hell (Standard) | semantic.light.background.canvas | semantic | CardNotification.kt:118 |
| Hintergrund Dunkel (Standard) | #000000 × α 0.3 (Colors.overlayLight) | component (Kotlin-Default) | CardNotification.kt:118 · design/tokens/DesignTokens.kt:37 |
| Body-Textfarbe | Textfarbe × α 0.8 | abgeleitet (// EXCEPTION: derived contrast) | CardNotification.kt:124 |
Zustände
- Einblendend
- Karte skaliert von 0 auf 1 (400ms, Overshoot-Easing), danach blendet der Inhalt ein (300ms). Siehe Motion.
- Offen (Standard)
- Hell-Modus: Canvas-Hintergrund, Textfarbe
semantic.light.text.primary, CTA invertiert (dunkler Button, helle Schrift). Dunkel-Modus: Hintergrund #000000 × α 0.3 über der Seitenfläche, CTA hell mit dunkler Schrift. Beide Modi sind dokumentiert und müssen gerendert werden.CardNotification.kt:117–145 - Mit eigener Hintergrundfarbe
- Nur Komponenten-API (nicht Section-Payload): Bei gesetztem
backgroundColorbestimmt eine Luminanz-Prüfung (Rec. 601-Gewichtung, Schwelle 0.5) automatisch kontrastierende CTA- und Icon-Farben.CardNotification.kt:131–138 - Nicht schließbar
requiresInteraction = true(Payload:dismissible = false): kein Schließen-Icon, Titel ohne End-Padding, Dismiss nur über den CTA.CardNotification.kt:107–111,178,206- Wird geschlossen
- Inhalt blendet aus (200ms) und die Karte kollabiert nach oben (300ms). Nach Abschluss der Animation feuert der
onDismiss-Callback.CardNotification.kt:149–165 - Geschlossen
- Im Section-Kontext: sitzungslokaler Zustand, geschlüsselt auf
notificationId. Die Sektion rendert nichts mehr; nach App-Neustart (oder bei neuernotificationId) erscheint die Karte erneut. Keine persistente Speicherung — siehe Audit-Hinweise.NotificationSectionRenderer.kt:28–29
Verhalten
Der Body wird einmalig pro Inhalt geparst (memoisiert auf den Body-String). Der Parser extrahiert ausschließlich Bild-Ausdrücke  und teilt den Rest in Textsegmente; Segmente werden in Dokumentreihenfolge gerendert. Andere Markdown-Konstrukte (Fett, Kursiv, Links, Listen) werden nicht interpretiert, sondern erscheinen als Rohtext — Redakteure dürfen im Body nur Klartext + Bild-Syntax verwenden.CardNotification.kt:51–81,147,209–237
Reihenfolge: (1) optionaler Daten-Callback, (2) onButtonClick des Aufrufers — im Section-Kontext die Navigation zum ctaDeepLink —, (3) die Karte schließt sich selbst (Exit-Animation, dann onDismiss). Der CTA schließt die Karte auch bei requiresInteraction = true — er ist dort der einzige Ausweg.CardNotification.kt:241–246 · NotificationSectionRenderer.kt:42
Tap auf das [close]-Icon startet die Exit-Animation; ist die Karte nicht schließbar, ist der Handler ein No-op (das Icon wird ohnehin nicht gerendert). Im Section-Kontext setzt onDismiss den sitzungslokalen Dismissed-Zustand — geschützt durch eine zweite dismissible-Prüfung im Renderer.CardNotification.kt:107–111,187 · NotificationSectionRenderer.kt:41
Als Composition-Sektion wird die Karte vom Seiten-Renderer an der vom Server bestimmten Position zwischen anderen Sektionen gerendert, mit dem Standard-Seitenabstand der Reihen. Sie scrollt mit dem Inhalt — kein Sticky-, kein Overlay-Verhalten. (Daneben existiert ein älterer, client-seitiger Einsatz auf dem Home-Tab, gespeist aus einer gebündelten JSON-Ressource — siehe Implementierungsreferenz.)CollectionRenderer.kt:131–137 · NotificationSectionRenderer.kt:43–45 · HomeScreen.kt:57–76,90–110
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Erscheinen — Schritt 1 | Karten-Scale 0 → 1 | 400ms | EaseOutBack (Overshoot) |
| Erscheinen — Schritt 2 (sequenziell danach) | Inhalts-Opacity 0 → 1 | 300ms | Standard-tween |
| Schließen | Opacity 1 → 0 | 200ms | Standard-tween |
| Schließen (parallel) | Höhe kollabiert nach oben | 300ms | EaseInOut |
Die beiden Erscheinen-Schritte laufen sequenziell (erst Scale, dann Inhalts-Fade), die beiden Schließen-Animationen parallel.CardNotification.kt:96–105,149–156
Barrierefreiheit
- Das Schließen-Icon trägt das Label "Schließen"; Hit-Fläche derzeit nur 20dp — siehe Audit-Hinweise.CardNotification.kt:181,185
- Der CTA ist ein Standard-Button (Plattform-Rolle button, Label =
ctaLabel). - Markdown-Bilder übernehmen den Alt-Text aus der Markdown-Syntax als Bildbeschreibung.CardNotification.kt:215
- Body-Kontrast: Die α-0.8-Ableitung der Textfarbe muss in beiden Themes WCAG AA (4,5:1) gegen den Kartenhintergrund erfüllen; bei server-gelieferten Eigenfarben ist die Luminanz-Logik die Schutzschicht.
- Reduced Motion: Scale-/Fade-/Collapse-Animationen durch sofortiges Ein-/Ausblenden ersetzen.
- Fokus-Reihenfolge: Titel/Body als Text-Knoten, dann CTA, dann Schließen-Icon (bzw. nach Plattformkonvention); nach dem Schließen wandert der Fokus zum nächsten Inhalt der Seite.
Audit-Hinweise
Screenshot ausstehend. In docs/screenshots/ existiert noch keine Aufnahme von CardNotification (weder Home-054-Einsatz noch Suche-Sektion). Vor Abnahme aus der laufenden App nachliefern (hell + dunkel).
Dismissal ist nicht persistent. Der Section-Renderer hält den Dismissed-Zustand nur sitzungslokal im UI-Zustand (remember, geschlüsselt auf notificationId) — kein DataStore, keine Wiederherstellung über Prozess-Neustarts. Laut Renderer-KDoc ist das beabsichtigt ("Dismissal is session-local component state"). Falls das Produkt "einmal weggeklickt = dauerhaft weg" verlangt, ist eine Persistenz auf notificationId nachzurüsten.NotificationSectionRenderer.kt:16–20,28
Implementierungslücke — CTA ohne Label: Der Button wird bedingungslos gerendert; ein Payload ohne ctaLabel erzeugt einen leeren Pill-Button. Entweder den Button bei fehlendem Label/Deep-Link unterdrücken oder ctaLabel im Kontrakt zum Pflichtfeld machen.CardNotification.kt:241–264 · NotificationSectionRenderer.kt:36
Drift — imageUrl: Auf dem Wire vorhanden (Android-Modell, BFF-Typ, Beispiel-Payload), vom Renderer nicht konsumiert. Mit dem API-Engineer vor Contract-Freeze auflösen.PlaylistCollectionModels.kt:128 · playlist.ts:45 · search.json:199
Hit-Target Schließen-Icon: Die klickbare Fläche entspricht der Icon-Größe (20dp) — unter dem 48dp-Minimum. Auf allen Plattformen die Hit-Fläche auf ≥ 48dp aufweiten, ohne das Icon zu vergrößern.CardNotification.kt:183–187
Tablet-Innenabstand: Das vertikale Padding bezieht spacing.sectionGap, das auf dem Tablet 80dp beträgt — die Karte wird dort sehr hoch. Verifizieren, ob das die Designabsicht ist oder ein eigener cardNotification.paddingVertical-Token gebraucht wird.CardNotification.kt:114 · DesignTokens.kt:458
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/cards/CardNotification.kt (Komponente, seit Spec 054) · ui/components/sections/NotificationSectionRenderer.kt (Iteration-3-Dispatch des notification-Section-Typs, 094) · ui/components/sections/CollectionRenderer.kt:131–137 (Seiten-Dispatch) · Modell data/models/PlaylistCollectionModels.kt:120–134. Älterer client-seitiger Einsatz: ui/screens/HomeScreen.kt:31–110 liest res/raw/notification_card (JSON) und rendert die Karte als Pre-Content der Home-Liste (nur Mobile). Token-Merge der Remote-Payload: ui/theme/Theme.kt:441,754–755.
packages/playlist-api/src/types/playlist.ts:38–49 (NotificationSection-Interface) · Beispielseite packages/playlist-api/data/pages/search.json:193–203.
ProgramGuideCard
Iteration 3 scopeEine horizontale Programmführer-Karte (EPG-Zeile): quadratisches Vorschaubild links, daneben Zeitspanne, Titel, optionaler Untertitel und eine Meta-Zeile aus Genre und Dauer. Läuft die Sendung gerade, erscheint ein "LIVE"-Badge mit pulsfreiem Punkt über dem Vorschaubild und ein sekündlich aktualisierter Fortschrittsbalken am unteren Kartenrand — beide in der semantischen Live-Farbe broadcast.live. Die gesamte Karte ist ein einziges Tap-Ziel. Karten werden vertikal gestapelt durch die server-getriebene Sektion program_guide ausgeliefert.
Visuelle Spezifikation
Aufbau
- 1. Karten-Container — volle Breite, feste Höhe 160dp, Eckenradius
radius.lg, Hintergrundsemantic.{mode}.background.surfaceRaised, 1dp-Rahmen inborder.subtle, ganzflächig tippbar. ProgramGuideCard.kt:63–79 - 2. Vorschaubild — 160 × 160dp, führende Ecken auf den Kartenradius geclippt, center-crop, CDN-Anfrage mit Breite 400. ProgramGuideCard.kt:82–93 · EPGApi.kt:57–64
- 3. LIVE-Badge (nur live) — Pille oben links über dem Bild: 18dp hoch, Hintergrund Schwarz @
programGuideCard.liveBadgeBackgroundAlpha= 0.4, runder Punkt (6dp) insemantic.{mode}.broadcast.live, Text "LIVE". ProgramGuideCard.kt:96–127 - 4. Content-Spalte — Zeitspanne, Titel (max. 2 Zeilen), optionaler Untertitel (max. 2 Zeilen), unten Meta-Zeile "Genre • Dauer". ProgramGuideCard.kt:132–204
- 5. Fortschrittsbalken (nur live) — 3dp hoch, volle Kartenbreite am unteren Rand; Track
border.subtle, Füllungbroadcast.live, Breite = Sendungsfortschritt. ProgramGuideCard.kt:208–223
Callout-Legende (Live)
- Karten-Container
- Vorschaubild — 160 × 160dp
- LIVE-Badge — gebunden an
broadcast.live(T012) - Content-Spalte
- Fortschrittsbalken (nur live)
Unterschiede zum Live-Zustand
- Kein LIVE-Badge, kein Fortschrittsbalken
- Zeitlabel abgedimmt
Composition-API-Kontrakt
Server-getriebene Sektion mit Diskriminator type="program_guide" (neu in Iteration 3). Android-Modelle: ProgramGuideSection (@SerialName("program_guide"), PlaylistCollectionModels.kt:136–145; Validierung: items darf nicht leer sein, Zeile 144) und ProgramGuideItem (PlaylistCollectionModels.kt:147–159). BFF-Typen: ProgramGuideSection / ProgramGuideItem (packages/playlist-api/src/types/playlist.ts:51–69). Beispiel-Payload: Sektion search-epg ("Jetzt im TV") in packages/playlist-api/data/pages/search.json. Der Sektions-Renderer stapelt die Karten vertikal, rendert den optionalen Sektionstitel und adaptiert jedes Item in das EPG-Modell, das die Karte konsumiert (ProgramGuideSectionRenderer.kt:27–61, 64–76).
Sektion program_guide
| Feld | Typ | Hinweise |
|---|---|---|
type | "program_guide" | Diskriminator → vertikale Liste aus ProgramGuideCards. PlaylistCollectionModels.kt:138 · playlist.ts:53 |
id | String | Stabile Sektions-Kennung (Beispiel: "search-epg"). |
title | String? | Optionaler Sektionstitel (Beispiel: "Jetzt im TV"); gerendert mit den Reihen-Titel-Tokens (playlist.rowTitleFontSizeSp usw.). ProgramGuideSectionRenderer.kt:40–51 |
items | ProgramGuideItem[] | Muss mindestens ein Item enthalten. PlaylistCollectionModels.kt:144 |
Item ProgramGuideItem
| Feld | Typ (Android / BFF) | Hinweise |
|---|---|---|
programId | String / string | Kennung der Sendung; Tipp auf die Karte navigiert zu program/{programId}. ProgramGuideSectionRenderer.kt:56 |
thumbnailUrl | String? / string | null | Vorschaubild; das CDN-Rewrite (?w=400) erfolgt clientseitig. |
title | String / string | Sendungstitel, max. 2 Zeilen. |
subtitle | String? / string | null | Untertitel; Rendering-Bedingung: nur wenn nicht leer und ungleich title. ProgramGuideCard.kt:163 |
genre | String? / string | null | Meta-Zeile; der Trennpunkt "•" erscheint nur, wenn ein Genre vorhanden ist. ProgramGuideCard.kt:182–197 |
durationMin | Int? / number | Nutzungsregel: wird vom Android-Adapter verworfen — die angezeigte Dauer wird aus startsAt/endsAt neu berechnet ("45 Min." / "1 Std. 55 Min."). ProgramGuideSectionRenderer.kt:64–76 · EPGApi.kt:95–105 |
startsAt / endsAt | String? (Default null) / string (erforderlich) | ISO-8601-Zeitstempel; treiben Zeitlabel, Live-Erkennung und Fortschritt (Zeitzone Europe/Berlin). Drift: im BFF erforderlich, im Android-Modell optional mit Leerstring-Fallback — bei fehlenden Zeiten gilt die Sendung nie als live. PlaylistCollectionModels.kt:156–157 · playlist.ts:66–67 · EPGApi.kt:73–87, 114–118 |
isLive | Boolean? / boolean | Drift: Das Flag steht auf dem Draht, wird vom Android-Adapter aber nicht übernommen — der Live-Zustand wird ausschließlich clientseitig aus startsAt/endsAt gegen die Geräteuhr abgeleitet. Vor Vertrags-Freeze klären, ob das Server-Flag maßgeblich sein soll (z. B. bei Uhrzeit-Abweichungen). ProgramGuideSectionRenderer.kt:64–76 · ProgramGuideCard.kt:37 |
Abmessungen
| Eigenschaft | Wert | Token | Quelle |
|---|---|---|---|
| Höhe Inhaltszeile | 160dp (+ 3dp Fortschrittsbalken bei live) | primitives.spacing.Space40 | ProgramGuideCard.kt:79 · DesignTokens.kt:49 |
| Vorschaubild | 160 × 160dp | primitives.spacing.Space40 | ProgramGuideCard.kt:84 |
| Eckenradius Karte | 16dp mobile / 32dp tablet | semantic.radius.{bp}.lg | ProgramGuideCard.kt:66, 71, 86 |
| Rahmen | 1dp in border.subtle | component.{mode}.programGuideCard.borderWidth | MediathekTokenModels.kt:499 |
| Hintergrund | Dark: neutral.900 · Light: Weiß | semantic.{mode}.background.surfaceRaised | ProgramGuideCard.kt:55 |
| LIVE-Badge Höhe | 18dp | primitives.spacing.Space4_5 | ProgramGuideCard.kt:104 · DesignTokens.kt:28 |
| LIVE-Badge Hintergrund | Schwarz @ α 0.4 · Pillenform 34dp | programGuideCard.liveBadgeBackgroundAlpha + .cornerRadius | MediathekTokenModels.kt:500, 504 |
| LIVE-Punkt | 6dp · #E3000B | programGuideCard.dotSize + semantic.{mode}.broadcast.live | MediathekTokenModels.kt:501 · ProgramGuideCard.kt:113–117 |
| "LIVE"-Text | 10sp · Medium · Letter-Spacing 1 | semantic.typography.{bp}.epgTime + .letterSpacingOverline | ProgramGuideCard.kt:118–124 |
| Innenabstand Content | 12dp mobile / 14dp tablet | semantic.spacing.{bp}.paddingSm | ProgramGuideCard.kt:136 |
| Zeitlabel | 11sp · Diagramm | semantic.typography.{bp}.epgDetail | ProgramGuideCard.kt:141–146 |
| Titel | 14sp · SemiBold · Zeilenhöhe 18sp · max. 2 Zeilen | semantic.typography.{bp}.epgTitle / .bodyLarge | ProgramGuideCard.kt:151–160 |
| Untertitel | 12sp · Zeilenhöhe 16sp · max. 2 Zeilen | semantic.typography.{bp}.label / .body | ProgramGuideCard.kt:165–173 |
| Meta-Zeile | 11sp in text.tertiary · Abstände gap2xs = 8dp | semantic.typography.{bp}.epgDetail | ProgramGuideCard.kt:178–204 |
| Vertikale Text-Abstände | Zeit→Titel 2dp · Titel→Untertitel 2dp | semantic.spacing.{bp}.gap4xs | ProgramGuideCard.kt:148, 164 |
| Fortschrittsbalken | 3dp hoch · volle Breite | component.{mode}.programGuideCard.progressHeight | MediathekTokenModels.kt:502 · ProgramGuideCard.kt:213 |
| Abstand zwischen Karten (Sektion) | — | playlist.rowItemSpacing + horizontales Padding playlist.rowHorizontalPadding | ProgramGuideSectionRenderer.kt:38, 58 |
| Hit-Target | gesamte Karte (volle Breite × 160dp — weit über dem 48dp-Minimum) | — | ProgramGuideCard.kt:73 |
Zustände
- Live
- Sendung läuft laut Start-/Endzeit gerade: LIVE-Badge über dem Bild, Fortschrittsbalken am unteren Rand (sekündlich aktualisiert), Zeitlabel in
text.primary. ProgramGuideCard.kt:37–48, 60, 96, 209 - Kommend
- Sendung liegt in der Zukunft: kein Badge, kein Balken, Zeitlabel in
text.secondary. - Vergangen
- Kein eigener Zustand — vergangene Sendungen rendern identisch zu "Kommend" (die Live-Prüfung ist für beide falsch). Ob vergangene Einträge zusätzlich abgedimmt werden sollen, ist nicht spezifiziert; bei Bedarf vor Implementierung klären. EPGApi.kt:73–78
- Bild fehlt / Ladefehler
- Kein expliziter Fallback — die Bildfläche bleibt in der Kartenhintergrundfarbe stehen; es wird kein defektes Bild angezeigt. ProgramGuideCard.kt:88–93 (kein onError-Handler)
- Light / Dark
- Alle Farben sind semantisch gebunden (
background.surfaceRaised,border.subtle,text.primary/secondary/tertiary/onColor,broadcast.live) und wechseln automatisch mit der aktiven Palette.broadcast.liveist in beiden Modi#E3000B. ProgramGuideCard.kt:50–61 - Pressed
- Plattform-Standard-Press-Feedback auf der ganzen Karte (Ripple auf der Referenzplattform).
Verhalten
Der Live-Zustand wird clientseitig bestimmt: jetzt ∈ [startsAt, endsAt) gegen die Geräteuhr (Zeitzone Europe/Berlin). Ist die Sendung live, wird der Fortschritt (jetzt − start) / (ende − start) (begrenzt auf 0…1) jede Sekunde neu berechnet. Der Tick-Intervall ist aktuell als Literal implementiert (siehe Audit-Notizen). ProgramGuideCard.kt:37–48 · EPGApi.kt:73–87, 114–118
Die gesamte Karte ruft onClick auf; in der server-getriebenen Sektion navigiert dies zu program/{programId}. Es gibt keine inneren interaktiven Elemente. ProgramGuideCard.kt:73 · ProgramGuideSectionRenderer.kt:56
Anders als die horizontalen Reihen-Sektionen stapelt program_guide seine Karten vertikal in voller Breite, mit dem Reihen-Item-Abstand zwischen den Karten und dem Reihen-Seitenabstand links/rechts. Der optionale Sektionstitel verwendet dieselben Titel-Tokens wie die übrigen Reihen. ProgramGuideSectionRenderer.kt:36–60
Jedes ProgramGuideItem wird in das interne EPG-Modell überführt (Titel → Sendungstitel, Untertitel → Zusatz, Zeiten, Genre, Bild als EPG-Bild-Eintrag); die Karte selbst bleibt dadurch single-source und wird unverändert auch außerhalb des server-getriebenen Pfads verwendet. ProgramGuideSectionRenderer.kt:64–76
Barrierefreiheit
- Karte: role button; zugänglicher Name = "{Zeitspanne}. {Titel}. {Untertitel}." — bei Live-Sendungen mit vorangestelltem "Live."
- Vorschaubild: zugänglicher Name = Sendungstitel (
contentDescription = program.terminTitel); in den Karten-Button zusammenführen. ProgramGuideCard.kt:90 - LIVE-Badge und Fortschrittsbalken sind visuell redundant — den Live-Status zusätzlich textuell ankündigen, nicht nur über Farbe (
broadcast.live-Rot allein ist für Farbfehlsichtige nicht ausreichend). - Der Fortschrittsbalken ist dekorativ (kein eigener Accessibility-Knoten nötig); die Zeitspanne trägt die Information textuell.
- Hit-Target: gesamte Karte (volle Breite × 160dp) ≥ 48dp-Minimum.
- Kontrast: "LIVE"-Text in
text.onColor(Weiß) auf Schwarz @ α 0.4 über Bildmaterial — gegen helle Bilder verifizieren; das Badge liegt bewusst auf einem abdunkelnden Untergrund.
Motion
| Auslöser | Eigenschaft | Dauer | Easing |
|---|---|---|---|
| Fortschritts-Tick (live) | Balkenbreite | sofort, 1s-Takt (kein Tween) | — |
| Karten-Druck | Press-Feedback | Plattform-Standard | — |
| sonst | statisch | — | — |
Audit-Notizen
- Screenshot ausstehend — in
docs/screenshots/existiert keine Aufnahme der ProgramGuideCard (Live + Kommend, Light/Dark). Aus der laufenden App nachliefern. - Tick-Intervall nicht tokenisiert — der sekündliche Fortschritts-Tick ist als Literal
1000implementiert (ProgramGuideCard.kt:45), während der Hero-Live-Slide dafür den verhaltensbezogenen TokenheroLive.progressUpdateIntervalMsbesitzt. Angleichen (eigenerprogramGuideCard-Behavioral-Token oder gemeinsamer Token). - Drift:
isLive-Flag ignoriert — siehe Kontrakt-Tabelle; Server-Flag vs. clientseitige Uhr vor Vertrags-Freeze entscheiden. - Drift:
startsAt/endsAtOptionalität — BFF erforderlich, Android optional (Leerstring-Fallback). Eine Seite angleichen. - Ungenutzter Parameter —
isLightModewird angenommen, aber nicht gelesen; das Theming läuft vollständig über die semantische Palette (ProgramGuideCard.kt:34, 50–61). Parameter entfernen oder begründen. - Schriftfamilie nicht tokenisiert — die Karte verwendet die Diagramm-Familie direkt;
programGuideCardhat (anders alsheroLivenach T011) keinefontFamily-Tokens. Bei Bedarf intokens.jsonergänzen, nicht hartkodiert lassen. - Zeitformat-Inkonsistenz (kosmetisch) — die Karte rendert die Zeitspanne mit Bindestrich "20:15 - 21:00" (EPGApi.kt:107–111), der Hero-Live-Slide mit Gedankenstrich "20:15 – 21:00" (HeroLiveStreamSlide.kt:427). Vereinheitlichen.
- Verifiziert —
broadcast.live-Bindung (T012), alleprogramGuideCard.*-Tokens gegentokens.source.json(borderWidth 1, cornerRadius 34, dotSize 6, progressHeight 3, progressCornerRadius 1.5, liveBadgeBackgroundAlpha 0.4), Sektions-Contract Android ↔ BFF ↔ Beispiel-Payload.
Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/components/epg/ProgramGuideCard.kt. Server-getriebener Pfad: CollectionRenderer.kt:139–144 dispatcht ProgramGuideSection an ui/components/sections/ProgramGuideSectionRenderer.kt (vertikale Spalte + Payload-Adapter toEpgProgramItem, Zeilen 64–76). EPG-Hilfsfunktionen (isCurrentlyAiring, getProgress, getFormattedTimeRange, getFormattedDuration, getThumbnailUrl) in data/api/EPGApi.kt:57–118. Live-Farbe über LocalSemanticColors.current.broadcast.live (ui/theme/SemanticColors.kt:192).
DetailSeries
Iteration 3 scope Detailseite SerienDetailseite für eine Serie (Staffel- und Episodenliste). Zeigt Hero-Bild, Serientitel, Metadaten, einen primären Play-Button und eine Aktionszeile. Auf dem Tablet/Querformat wird ein zweispaltiges Layout verwendet (DetailTwoColumnLayout): links Hero-Bild + Play-Button + Aktionszeile, rechts Serientitel + Metadaten + scrollbare Episodenliste. Auf Mobile im Hochformat gilt ein einspaltiges Layout; im Querformat wechselt der Zweispalter (orientierungsgesteuert via isLandscapeDetailLayout(w,h)=w>h).
SeriesDetailScreen.kt · SeriesDetailContent.kt · SeriesDetailTwoColumn.kt · DetailTwoColumnLayout.kt · DetailHeroMobile.kt
⚠ Layout finalisiert (094) — Wireframes unten werden nachgezogen: Im finalen Querformat-Zweispalter steht der Text (Serientitel + Metadaten) zusammen mit der Episodenliste in der rechten Spalte; die linke Spalte enthält nur Hero-Bild + Play-Button (ButtonSize.Small, fillMaxWidth) + Aktions-Zeile (fillWidth=true, weight(1f)-Slots). DetailHeroTablet und die Tokens detailHeroTablet.landscapeHeightInset/scrimAnchorFromBottom/scrimFadeHeight + detailTwoColumn.contentMaxWidth sind toter Code (keine Aufrufer); der Querformat-Hero ist TwoColumnHeroImage (16:9, ohne Scrim/Inset). Die SVG-Wireframes und einzelne Callouts unten zeigen noch die frühere Aufteilung (Text links). Verifizierte Shell-Beschreibung: specs/094-iteration-3-of/doc-fragments/detail-page.html.SeriesDetailTwoColumn.kt:84–160 · DetailTwoColumnLayout.kt:24–55
Aufbau
Zwei responsive Varianten je nach Breakpoint:
- Mobile (Hochformat): Einspaltiger Scroll — Hero-Bild mit Scrim, darüber Serientitel + Metadaten, darunter Play-Button + Aktionszeile + Staffel-Tabs + Episodenliste.
- Tablet (Querformat):
DetailTwoColumnLayout— linke Spalte (leftFraction): Hero-Bild + Play-Button + Aktionszeile. Rechte Spalte: Serientitel + Metadaten +LazyColumnmit Staffel-Tabs + Episodenliste.
Callout-Legende Mobile
- TopBar — TopBarVariant.Detail (transparent)
- Hero-Bild + Scrim
- Play-Button
- Aktionszeile — DetailActionRow
- Episodenliste — Staffel-Tabs + LazyColumn
Callout-Legende Tablet
- TopBar — TopBarVariant.LandscapeDetail (transparent)
- Zweispalter — DetailTwoColumnLayout
- Rechte Spalte — Episodenliste
Verwendete Token
| Token-Pfad | Beschreibung |
|---|---|
component.{mode}.detailHeroMobile.aspectRatio | Seitenverhältnis des Hero-Bilds auf Mobile |
component.{mode}.detailHeroMobile.spacerWidth | Seitlicher Abstand im Mobile-Hero |
component.{mode}.detailHeroTablet.scrimAnchorFromBottom | Ankerpunkt des Scrim-Verlaufs von unten (Tablet) |
component.{mode}.detailHeroTablet.scrimFadeHeight | Höhe des Scrim-Verlaufs (Tablet) |
component.{mode}.detailHeroTablet.aspectRatio | Seitenverhältnis des Hero-Bilds auf Tablet |
component.{mode}.detailHeroTablet.landscapeHeightInset | Höhen-Inset im Querformat-Hero (Tablet) |
component.{mode}.detailActionButton.iconSize | Icon-Größe in der Aktionszeile |
component.{mode}.detailActionButton.iconLabelGap | Abstand zwischen Icon und Label in der Aktionszeile |
component.{mode}.detailActionButton.touchTarget | Touch-Target-Größe der Aktionszeilen-Buttons |
component.{mode}.detailTwoColumn.leftFraction | Anteil der linken Spalte an der Gesamtbreite (Tablet) |
component.{mode}.detailTwoColumn.columnGap | Abstand zwischen den beiden Spalten (Tablet) |
component.{mode}.detailTwoColumn.contentMaxWidth | Maximale Inhaltsbreite des Zweispalters (Tablet) |
component.{mode}.btvTopBar.actionRowHeight | Höhe der TopBar-Aktionszeile (52dp) |
Referenz





Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/screens/detail/DetailSeriesScreen.kt · Layout: DetailTwoColumnLayout.kt · Hero: DetailHeroMobile.kt / DetailHeroTablet.kt · Aktionszeile: DetailActionRow.kt, DetailActionButton.kt · TopBar: BtvTopBar.kt mit TopBarVariant.Detail (Hochformat) / TopBarVariant.LandscapeDetail (Querformat).
Composition-API-Kontrakt
Serien-Detailseite. Felder: title, imageUrl, seasons (Liste mit Staffel-Nr. + Episodenliste), episodeCount, year. Keine Bibelverse-Felder.
DetailVideo
Iteration 3 scope Detailseite VideoDetailseite für ein einzelnes Video (Episode oder Standalone). Zeigt Hero-Bild, Serientitel/Episodenmetadaten, Videotitel, primären Play-Button, Aktionszeile (inkl. Download + Schlaftimer), Beschreibung, die VideoBibleVersesSection, eine Spendenkarte und optionale Transkript/Details-Tabs. Auf dem Tablet/Querformat: DetailTwoColumnLayout analog zur Serien-Detailseite — links Hero + Play + Aktionszeile, rechts Videotitel/Metadaten + Beschreibung + Bibelverse + Spende + Tabs.
VideoDetailScreen.kt · VideoDetailContent.kt · VideoDetailTwoColumn.kt · DetailTwoColumnLayout.kt · VideoBibleVersesSection.kt
⚠ Layout finalisiert (094) — Wireframes unten werden nachgezogen: Im finalen Querformat-Zweispalter steht der gesamte Text (Eyebrow/Serientitel ›, Videotitel, Metadaten, Beschreibung) zusammen mit Bibelversen, Spendenkarte und Tabs in der rechten Spalte; die linke Spalte enthält nur Hero-Bild + Play-Button (ButtonSize.Small, fillMaxWidth) + Aktions-Zeile (fillWidth=true, weight(1f)-Slots, isLandscapeTablet=true → „MeineListe“). Die Beschreibung nutzt text.primary. DetailHeroTablet + detailHeroTablet.* (Scrim/Inset) + detailTwoColumn.contentMaxWidth sind toter Code; der Querformat-Hero ist TwoColumnHeroImage (16:9). Die DonationCard (rechte Spalte, unter der Beschreibung) trägt derzeit fest verdrahteten Text — FR-022 fordert servergesteuerte Inhalte (T062, offen). Die SVG-Wireframes/Callouts unten zeigen noch die frühere Aufteilung (Text links). Verifizierte Shell-Beschreibung: specs/094-iteration-3-of/doc-fragments/detail-page.html.VideoDetailTwoColumn.kt:96–256 · DetailTwoColumnLayout.kt:24–55
Aufbau
Zwei responsive Varianten:
- Mobile (Hochformat): Einspaltiger Scroll — Hero-Bild + Serientitel/Episodenmeta, Videotitel, Play-Button, Aktionszeile, Beschreibung, Bibelverse-Sektion, Tabs (Transkript | Details).
- Tablet (Querformat):
DetailTwoColumnLayout— links: Hero-Bild + Play-Button + Aktionszeile; rechts: scrollbare Spalte mit Eyebrow/Videotitel/Metadaten + Beschreibung + Bibelverse-Sektion + Spendenkarte + Tabs.
Callout-Legende Mobile
- TopBar — TopBarVariant.Detail (transparent → opak)
- Hero-Bild + Serientitel/Episodenmeta
- Play-Button
- Aktionszeile — DetailActionRow (5 Buttons)
- Bibelverse-Sektion — VideoBibleVersesSection
- Tabs — Transkript | Details
Callout-Legende Tablet
- TopBar — TopBarVariant.LandscapeDetail
- Linke Spalte — DetailTwoColumnLayout
- Rechte Spalte — Bibelverse-Sektion
- Tabs — Transkript | Details
Verwendete Token
| Token-Pfad | Beschreibung |
|---|---|
component.{mode}.detailHeroMobile.aspectRatio | Seitenverhältnis des Hero-Bilds auf Mobile |
component.{mode}.detailHeroMobile.spacerWidth | Seitlicher Abstand im Mobile-Hero |
component.{mode}.detailHeroTablet.scrimAnchorFromBottom | Ankerpunkt des Scrim-Verlaufs von unten (Tablet) |
component.{mode}.detailHeroTablet.scrimFadeHeight | Höhe des Scrim-Verlaufs (Tablet) |
component.{mode}.detailHeroTablet.aspectRatio | Seitenverhältnis des Hero-Bilds auf Tablet |
component.{mode}.detailHeroTablet.landscapeHeightInset | Höhen-Inset im Querformat-Hero (Tablet) |
component.{mode}.detailActionButton.iconSize | Icon-Größe in der Aktionszeile |
component.{mode}.detailActionButton.iconLabelGap | Abstand zwischen Icon und Label in der Aktionszeile |
component.{mode}.detailActionButton.touchTarget | Touch-Target-Größe der Aktionszeilen-Buttons |
component.{mode}.detailTwoColumn.leftFraction | Anteil der linken Spalte an der Gesamtbreite (Tablet) |
component.{mode}.detailTwoColumn.columnGap | Abstand zwischen den beiden Spalten (Tablet) |
component.{mode}.detailTwoColumn.contentMaxWidth | Maximale Inhaltsbreite des Zweispalters (Tablet) |
component.{mode}.btvTopBar.actionRowHeight | Höhe der TopBar-Aktionszeile (52dp) |
Referenz




Implementierungsreferenz
packages/android/app/src/main/java/com/protobible/android/ui/screens/detail/DetailVideoScreen.kt · Layout: DetailTwoColumnLayout.kt · Hero: DetailHeroMobile.kt / DetailHeroTablet.kt · Bibelverse: VideoBibleVersesSection.kt · Aktionszeile: DetailActionRow.kt, DetailActionButton.kt · TopBar: BtvTopBar.kt mit TopBarVariant.Detail (Hochformat) / TopBarVariant.LandscapeDetail (Querformat).
Composition-API-Kontrakt
Video-Detailseite. Felder: title, imageUrl, seriesTitle, episodeMeta, description, duration, publishedAt, bibleVerses (Liste mit reference, seekPositionMs), transcript (optional). Der Verstext wird clientseitig über die Bibeltext-API geladen.
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, 100 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 |
colors.brand.liveRed | #E3000B |
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} |
broadcast — 1 tokens
| Path | Dark | Light |
|---|---|---|
broadcast.live | #E3000B | #E3000B |
focusRing — 1 tokens
| Path | Dark | Light |
|---|---|---|
focusRing.color | #1B8FD2 | #0D477B |
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 — 19 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.icon.xl | 80 | 80 | 80 | 80 |
buttons.icon.xxl | 96 | 96 | 96 | 96 |
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 — 17 tokens
| Path | Dark | Light |
|---|---|---|
cardOverlay.gradientStartOpacity | 0 | 0 |
cardOverlay.gradientEndOpacity | 1 | 1 |
cardOverlay.scrimAlpha1 | 0.7 | 0.7 |
cardOverlay.scrimAlpha2 | 0.9 | 0.9 |
cardOverlay.cornerRadius | 16 | 16 |
cardOverlay.textPadding | 8 | 8 |
cardOverlay.titleFontSize | 14 | 14 |
cardOverlay.titleFontWeight | SemiBold | SemiBold |
cardOverlay.titleLineHeight | 15 | 15 |
cardOverlay.titleMaxLines | 2 | 2 |
cardOverlay.subtitleFontSize | 11 | 11 |
cardOverlay.subtitleFontWeight | Normal | Normal |
cardOverlay.subtitleLineHeight | 13 | 13 |
cardOverlay.imageFadeDurationMs | 300 | 300 |
cardOverlay.imageRequestWidthWide | 640 | 640 |
cardOverlay.imageRequestWidthTall | 400 | 400 |
cardOverlay.borderColor | #00000000 | #00000000 |
cardSplit — 19 tokens
| Path | Dark | Light |
|---|---|---|
cardSplit.infoAreaPaddingHorizontal | 10 | 10 |
cardSplit.infoAreaPaddingVertical | 10 | 10 |
cardSplit.cornerRadius | 16 | 16 |
cardSplit.imageAspectRatio | 1.7777778 | 1.7777778 |
cardSplit.titleFontSize | 16 | 16 |
cardSplit.titleFontWeight | SemiBold | SemiBold |
cardSplit.titleLineHeight | 19.2 | 19.2 |
cardSplit.metadataFontSize | 10 | 10 |
cardSplit.metadataFontWeight | Normal | Normal |
cardSplit.metadataLineHeight | 13 | 13 |
cardSplit.eyebrowTitleSpacing | 4 | 4 |
cardSplit.actionInset | 48 | 48 |
cardSplit.titleMaxLines | 2 | 2 |
cardSplit.eyebrowFontWeight | Normal | Normal |
cardSplit.eyebrowFontSize | 12 | 12 |
cardSplit.eyebrowLineHeight | 15 | 15 |
cardSplit.imageFadeDurationMs | 300 | 300 |
cardSplit.imageRequestWidth | 520 | 520 |
cardSplit.bgColor | #141B1F | #FFFFFF |
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 — 15 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 |
cardPoster.fallbackTitleFontSize | 16 | 16 |
cardPoster.fallbackTitleFontWeight | Medium | Medium |
cardPoster.fallbackTitleLineHeight | 20 | 20 |
cardTop10 — 16 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) |
cardTop10.rankFontFamily | Inter | Inter |
cardTop10.rankNumLetterSpacingEm | 0 | 0 |
cardTop10.rankDigitMaxLines | 1 | 1 |
cardTop10.visibleCardsPhone | 1.2 | 1.2 |
cardTop10.innerCardMinWidth | 180 | 180 |
cardTop10.innerCardWidthTablet | 280 | 280 |
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 — 13 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 |
cardAvatar.displayFontFamily | Diagramm | Diagramm |
cardAvatar.initialsFontSizeRatio | 0.3 | 0.3 |
cardAvatar.initialsCount | 2 | 2 |
cardAvatar.labelMaxLines | 2 | 2 |
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 — 29 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 |
heroCarousel.displayFontFamily | Diagramm | Diagramm |
heroCarousel.bodyFontFamily | Inter | Inter |
heroCarousel.shadowAmbientAlpha | 0.5 | 0.5 |
heroCarousel.autoAdvanceAnimationDurationMs | 600 | 600 |
heroCarousel.pageIndicatorAnimationDurationMs | 200 | 200 |
heroCarousel.pageIndicatorInactiveAlpha | 0.3 | 0.3 |
heroCarousel.titleMaxLines | 3 | 3 |
heroCarousel.eyebrowMaxLines | 1 | 1 |
heroCarousel.descriptionMaxLines | 3 | 3 |
heroCarousel.splitLayoutSecondaryTextAlpha | 0.7 | 0.7 |
heroCarousel.splitLayoutTertiaryTextAlpha | 0.8 | 0.8 |
heroCarousel.splitLayoutLightGradientAlpha | 0.12 | 0.12 |
heroVideo — 40 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 |
heroVideo.displayFontFamily | Diagramm | Diagramm |
heroVideo.bodyFontFamily | Inter | Inter |
heroVideo.tabletTitleMaxLines | 3 | 3 |
heroVideo.tabletDescMaxLines | 5 | 5 |
heroVideo.eyebrowMaxLines | 1 | 1 |
heroBibleVerse — 51 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.verseBaseSizeMobile | 36 | 36 |
heroBibleVerse.verseMinSizeMobile | 16 | 16 |
heroBibleVerse.verseMaxSizeMobile | 48 | 48 |
heroBibleVerse.verseBaseSizeTabletLandscape | 22 | 22 |
heroBibleVerse.verseMinSizeTabletLandscape | 14 | 14 |
heroBibleVerse.verseMaxSizeTabletLandscape | 26 | 26 |
heroBibleVerse.verseBaseSizeTabletPortrait | 16 | 16 |
heroBibleVerse.verseMinSizeTabletPortrait | 11 | 11 |
heroBibleVerse.verseMaxSizeTabletPortrait | 18 | 18 |
heroBibleVerse.textLengthMin | 20 | 20 |
heroBibleVerse.textLengthMax | 250 | 250 |
heroBibleVerse.lineMinHeight | 115 | 115 |
heroBibleVerse.lineMaxHeight | 90 | 90 |
heroBibleVerse.tabletImageWidthFraction | 0.7 | 0.7 |
heroBibleVerse.tabletGradientWidthFraction | 0.5 | 0.5 |
heroBibleVerse.tabletContentWidthFraction | 0.4 | 0.4 |
heroBibleVerse.tabletGradientOnlyContentWidthFraction | 0.5 | 0.5 |
heroBibleVerse.verseTextAlpha | 0.9 | 0.9 |
heroBibleVerse.buttonIconSize | 14 | 14 |
heroBibleVerse.buttonCornerRadiusPercent | 50 | 50 |
heroBibleVerse.scrimMobileLightAlpha1 | 0.05 | 0.05 |
heroBibleVerse.scrimMobileLightAlpha2 | 0.5 | 0.5 |
heroBibleVerse.scrimMobileLightAlpha3 | 0.82 | 0.82 |
heroBibleVerse.scrimMobileLightAlpha4 | 0.95 | 0.95 |
heroBibleVerse.scrimMobileDarkAlpha1 | 0.15 | 0.15 |
heroBibleVerse.scrimMobileDarkAlpha2 | 0.45 | 0.45 |
heroBibleVerse.scrimMobileDarkAlpha3 | 0.72 | 0.72 |
heroBibleVerse.scrimMobileDarkAlpha4 | 0.9 | 0.9 |
heroBibleVerse.scrimMobileDarkAlpha5 | 0.97 | 0.97 |
heroBibleVerse.scrimTabletAlpha1 | 0.75 | 0.75 |
heroBibleVerse.scrimTabletAlpha2 | 0.5 | 0.5 |
heroBibleVerse.scrimTabletAlpha3 | 0.25 | 0.25 |
heroBibleVerse.imageAspectRatioMobile | 3:4 | 3:4 |
heroBibleVerse.imageWidthMobile | 600 | 600 |
heroBibleVerse.imageAspectRatioTablet | 16:9 | 16:9 |
heroBibleVerse.imageWidthTablet | 1200 | 1200 |
heroBibleVerse.imageFocalPointY | 0.35 | 0.35 |
heroBibleVerse.verseMaxLinesMobile | 8 | 8 |
heroBibleVerse.gradientTopLight | #DEECF2 | #DEECF2 |
heroBibleVerse.gradientBottomLight | #66B0D9 | #66B0D9 |
heroBibleVerse.gradientTopDark | #10223A | #10223A |
heroBibleVerse.gradientBottomDark | #0D477B | #0D477B |
heroBibleVerse.contentTopPadding | 140 | 140 |
heroBibleVerse.displayFontFamily | Diagramm | Diagramm |
heroBibleVerse.bodyFontFamily | Inter | Inter |
heroFeatured — 23 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 |
heroFeatured.displayFontFamily | Diagramm | Diagramm |
heroFeatured.bodyFontFamily | Inter | Inter |
heroFeatured.tabletTitleMaxLines | 3 | 3 |
heroFeatured.tabletDescMaxLines | 4 | 4 |
heroFeatured.imageFocalPointY | 0.35 | 0.35 |
heroLive — 8 tokens
| Path | Dark | Light |
|---|---|---|
heroLive.cornerRadius | 24 | 24 |
heroLive.displayFontFamily | Diagramm | Diagramm |
heroLive.bodyFontFamily | Inter | Inter |
heroLive.tabletInfoWidthFraction | 0.3 | 0.3 |
heroLive.channelNameMaxLines | 1 | 1 |
heroLive.programTitleMaxLines | 2 | 2 |
heroLive.miniTimelineTitleMaxLines | 2 | 2 |
heroLive.borderColorAlpha | 0.15 | 0.15 |
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 — 7 tokens
| Path | Dark | Light |
|---|---|---|
cardNotification.aspectRatio | 1.7778 | 1.7778 |
cardNotification.scaleInDurationMs | 400 | 400 |
cardNotification.opacityInDurationMs | 300 | 300 |
cardNotification.dismissDurationMs | 200 | 200 |
cardNotification.dismissOpacityDurationMs | 300 | 300 |
cardNotification.textSecondaryOpacity | 0.8 | 0.8 |
cardNotification.closeIconAlpha | 0.5 | 0.5 |
featuredSection — 6 tokens
| Path | Dark | Light |
|---|---|---|
featuredSection.gradientBlendFactor1 | 0.85 | 0.85 |
featuredSection.gradientBlendFactor2 | 0.6 | 0.6 |
featuredSection.gradientBlendFactor3 | 0.3 | 0.3 |
featuredSection.fallbackSlideColor | #10223A | #10223A |
featuredSection.canvasDark | #080D16 | #080D16 |
featuredSection.canvasLight | #F3F5F7 | #F3F5F7 |
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 — 5 tokens
| Path | Dark | Light |
|---|---|---|
btvTopBar.height | 42 | 42 |
btvTopBar.horizontalPadding | 16 | 16 |
btvTopBar.verticalPadding | 8 | 8 |
btvTopBar.scrollFadeDistance | 300 | 300 |
btvTopBar.actionSpacing | 0 | 0 |
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 — 6 tokens
| Path | Dark | Light |
|---|---|---|
episodeListItem.aspectRatio | 1.7778 | 1.7778 |
episodeListItem.thumbnailWidth | 160 | 160 |
episodeListItem.descriptionTopGap | 10 | 10 |
episodeListItem.contentPaddingHorizontal | 16 | 16 |
episodeListItem.contentPaddingVertical | 12 | 12 |
episodeListItem.thumbnailGap | 16 | 16 |
detailHeroMobile — 11 tokens
| Path | Dark | Light |
|---|---|---|
detailHeroMobile.aspectRatio | 1.7778 | 1.7778 |
detailHeroMobile.spacerWidth | 2 | 2 |
detailHeroMobile.contentPaddingHorizontal | 16 | 16 |
detailHeroMobile.coverTopGap | 8 | 8 |
detailHeroMobile.coverBottomGap | 16 | 16 |
detailHeroMobile.subtitleBottomGap | 8 | 8 |
detailHeroMobile.metadataTopGap | 8 | 8 |
detailHeroMobile.playButtonTopGap | 16 | 16 |
detailHeroMobile.actionRowTopGap | 8 | 8 |
detailHeroMobile.buttonRowItemGap | 8 | 8 |
detailHeroMobile.contentBottomGap | 16 | 16 |
detailHeroTablet — 8 tokens
| Path | Dark | Light |
|---|---|---|
detailHeroTablet.aspectRatio | 1.7778 | 1.7778 |
detailHeroTablet.landscapeHeightInset | 128 | 128 |
detailHeroTablet.scrimAnchorFromBottom | 240 | 240 |
detailHeroTablet.scrimFadeHeight | 360 | 360 |
detailHeroTablet.contentPaddingHorizontal | 16 | 16 |
detailHeroTablet.contentItemGap | 8 | 8 |
detailHeroTablet.actionRowTopGap | 4 | 4 |
detailHeroTablet.actionRowItemGap | 8 | 8 |
detailActionButton — 13 tokens
| Path | Dark | Light |
|---|---|---|
detailActionButton.iconSize | 20 | 20 |
detailActionButton.iconLabelGap | 4 | 4 |
detailActionButton.touchTarget | 48 | 48 |
detailActionButton.labelColor | #EBF0F2 | #252F35 |
detailActionButton.labelColorActive | #EBF0F2 | #252F35 |
detailActionButton.labelColorDisabled | #7A8B94 | #A8B5BC |
detailActionButton.badgeIconGap | 4 | 4 |
detailActionButton.clusterItemGap | 8 | 8 |
detailActionButton.loadingStopCornerRadius | 2 | 2 |
detailActionButton.loadingStopFraction | 0.38 | 0.38 |
detailActionButton.progressStrokeFraction | 0.083 | 0.083 |
detailActionButton.progressTrackAlpha | 0.24 | 0.24 |
detailActionButton.labelMaxLines | 1 | 1 |
detailTwoColumn — 5 tokens
| Path | Dark | Light |
|---|---|---|
detailTwoColumn.leftFraction | 0.6 | 0.6 |
detailTwoColumn.columnGap | 16 | 16 |
detailTwoColumn.contentMaxWidth | 960 | 960 |
detailTwoColumn.actionRowGap | 8 | 8 |
detailTwoColumn.heroAspectRatio | 1.7778 | 1.7778 |
donationCard — 8 tokens
| Path | Dark | Light |
|---|---|---|
donationCard.bodyMaxLines | 3 | 3 |
donationCard.padding | 16 | 16 |
donationCard.horizontalMargin | 16 | 16 |
donationCard.cornerRadius | 12 | 12 |
donationCard.buttonSpacingTop | 16 | 16 |
donationCard.elevation | 0 | 0 |
donationCard.containerColor | #4D000000 | #F8F9FA |
donationCard.textColor | #EBF0F2 | #252F35 |
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 — 8 tokens
| Path | Dark | Light |
|---|---|---|
menuDrawer.overlayBgAlpha | 0.95 | 0.95 |
menuDrawer.backdropBlurRadius | 24 | 24 |
menuDrawer.rowBgColor | #00000000 | #00000000 |
menuDrawer.tabletDrawerWidth | 380 | 380 |
menuDrawer.tabletPanelAlpha | 0.92 | 0.92 |
menuDrawer.tabletScrimAlpha | 0.4 | 0.4 |
menuDrawer.dividerAlpha | 0.1 | 0.1 |
menuDrawer.sectionLabelAlpha | 0.5 | 0.5 |
button — 40 tokens
| Path | Dark | Light |
|---|---|---|
button.primary.bg | #FFFFFF | #252F35 |
button.primary.bg-pressed | #EBF0F2 | rgba(37, 47, 53, 0.8) |
button.primary.bg-disabled | rgba(255, 255, 255, 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) |
searchField — 12 tokens
| Path | Dark | Light |
|---|---|---|
searchField.borderWidth | 2 | 2 |
searchField.cornerRadius | 28 | 28 |
searchField.fieldHeight | 56 | 56 |
searchField.glowAlphaMin | 0.25 | 0.25 |
searchField.glowAlphaMax | 0.6 | 0.6 |
searchField.glowEdgeWidth | 24 | 24 |
searchField.glowBreatheDurationMs | 2400 | 2400 |
searchField.glowSweepDurationMs | 8000 | 8000 |
searchField.transitionDurationMs | 450 | 450 |
searchField.expandedPaddingHorizontal | 16 | 16 |
searchField.expandedPaddingVertical | 12 | 12 |
searchField.chipCornerRadius | 16 | 16 |
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 |
detail — 8 tokens
| Path | Value |
|---|---|
views.detail.contentPaddingHorizontal | 16 |
views.detail.topBarContentGap | 0 |
views.detail.heroImageBottomGap | 16 |
views.detail.titleMetaGap | 8 |
views.detail.metaDescriptionGap | 8 |
views.detail.buttonRowTopGap | 16 |
views.detail.descriptionBottomGap | 16 |
views.detail.sectionGap | 16 |
Coverage der in dieser Spec zitierten Pfade
Coverage summary
| Status | Count | Meaning |
|---|---|---|
| ⚙️ API | 241 | Path resolves through the design-tokens API right now. Implementers fetch the value at runtime from /api/v1/tokens. |
| 🔧 Kotlin | 32 | Path corresponds to a field in the Android Kotlin token-model data class (with a default value), but is not yet promoted to the API. Implementers can read the value from the Android source as the canonical default until promotion lands. |
| 📝 Spec-only | 33 | Spec-only path — not yet a token in any form. Treat as a logical placeholder; the implementer should propose a real token when adopting the spec. |
Detailed list (306 paths)
| Path | Status |
|---|---|
component.badge.* | 🔧 Kotlin |
component.btvLogo.heightMobile | 🔧 Kotlin |
component.btvLogo.heightTablet | 🔧 Kotlin |
component.btvTopBar.actionSpacing | ⚙️ API |
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.buttons | 📝 Spec-only |
component.cardAvatar.defaultSize | ⚙️ API |
component.cardAvatar.labelFontSize | ⚙️ API |
component.cardAvatar.labelMaxLines | ⚙️ API |
component.cardAvatar.shadowElevation | ⚙️ API |
component.cardImageOnly.bgColor | ⚙️ API |
component.cardLandscape.aspectRatio | ⚙️ API |
component.cardLandscape.borderColor | ⚙️ API |
component.cardLandscape.fallbackTitleFontSize/Weight/LineHeight | 📝 Spec-only |
component.cardLandscape.shadowElevation | ⚙️ API |
component.cardPoster.aspectRatio | ⚙️ API |
component.cardPoster.bgColor | ⚙️ API |
component.cardPoster.borderColor | ⚙️ API |
component.cardPoster.fallbackTextColor | ⚙️ API |
component.cardPoster.shadowColor | ⚙️ API |
component.cardPoster.shadowElevation | ⚙️ API |
component.dark.cardTop10.rankNumBorderColorDark | ⚙️ API |
component.heroCarousel.aspectRatioMobile | ⚙️ API |
component.heroCarousel.aspectRatioTablet | ⚙️ API |
component.heroCarousel.autoAdvanceAnimationDurationMs | ⚙️ API |
component.heroCarousel.autoAdvanceIntervalMs | ⚙️ API |
component.heroCarousel.borderWidth | ⚙️ API |
component.heroCarousel.colorEmitThresholdMs | 🔧 Kotlin |
component.heroCarousel.contentPadding | ⚙️ API |
component.heroCarousel.elevation | ⚙️ API |
component.heroCarousel.indicatorDotSelectedWidth | ⚙️ API |
component.heroCarousel.indicatorDotSize | ⚙️ API |
component.heroCarousel.indicatorDotSpacing | ⚙️ API |
component.heroCarousel.pageIndicatorAnimationDurationMs | ⚙️ API |
component.heroCarousel.pageIndicatorInactiveAlpha | ⚙️ API |
component.heroCarousel.pageSpacing | ⚙️ API |
component.heroCarousel.tabletContentWidthFraction | ⚙️ API |
component.heroCarousel.tabletGradientWidthFraction | ⚙️ API |
component.heroCarousel.tabletImageWidthFraction | ⚙️ API |
component.heroVideo.maxLines.* | 🔧 Kotlin |
component.heroVideo.tabletTitleFontSize | ⚙️ API |
component.heroVideo.titleFontSize | ⚙️ API |
component.light.cardTop10.rankNumBorderColorLight | ⚙️ API |
component.playlist.matchedRowHeight | 📝 Spec-only |
component.playlist.rowTitleFontWeight | ⚙️ API |
component.progress.barColor | ⚙️ API |
component.progress.barHeight | ⚙️ API |
component.protoBibleButton.primary.* | 🔧 Kotlin |
component.{mode}.bannerOverlay.closeButtonBackgroundAlpha | ⚙️ API |
component.{mode}.bannerOverlay.scrimAlpha | ⚙️ API |
component.{mode}.btvDropdown.minWidth | ⚙️ API |
component.{mode}.btvTopBar.actionRowHeight | 🔧 Kotlin |
component.{mode}.button.primary.bg | ⚙️ API |
component.{mode}.button.primary.bgDisabled | 📝 Spec-only |
component.{mode}.button.primary.bgPressed | 📝 Spec-only |
component.{mode}.button.primary.text | ⚙️ API |
component.{mode}.button.primary.textDisabled | 📝 Spec-only |
component.{mode}.button.secondary.bg | ⚙️ API |
component.{mode}.button.secondary.bgDisabled | 📝 Spec-only |
component.{mode}.button.secondary.bgPressed | 📝 Spec-only |
component.{mode}.button.secondary.border | ⚙️ API |
component.{mode}.button.secondary.text | ⚙️ API |
component.{mode}.button.secondary.textDisabled | 📝 Spec-only |
component.{mode}.buttonGroup.cornerRadius | ⚙️ API |
component.{mode}.buttonGroup.height | ⚙️ API |
component.{mode}.buttonGroup.spacing | ⚙️ API |
component.{mode}.cardImageOnly.widthLarge | ⚙️ API |
component.{mode}.cardImageOnly.widthMedium | ⚙️ API |
component.{mode}.cardImageOnly.widthSmall | ⚙️ API |
component.{mode}.cardLandscape.aspectRatio | ⚙️ API |
component.{mode}.cardLandscape.cornerRadius | ⚙️ API |
component.{mode}.cardNotification.aspectRatio | ⚙️ API |
component.{mode}.cardOverlay.borderColor | ⚙️ API |
component.{mode}.cardOverlay.cornerRadius | ⚙️ API |
component.{mode}.cardOverlay.gradientStartOpacity | ⚙️ API |
component.{mode}.cardOverlay.subtitleFontSize/Weight/LineHeight | 📝 Spec-only |
component.{mode}.cardOverlay.titleFontSize/Weight/LineHeight | 📝 Spec-only |
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.fallbackTitleFontSize | ⚙️ API |
component.{mode}.cardPoster.fallbackTitleFontWeight | ⚙️ API |
component.{mode}.cardPoster.fallbackTitleLineHeight | ⚙️ 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}.cardSplit.bgColor | ⚙️ API |
component.{mode}.cardSplit.cornerRadius | ⚙️ API |
component.{mode}.cardSplit.eyebrowFontWeight | ⚙️ API |
component.{mode}.cardSplit.infoAreaPaddingHorizontal/Vertical | 📝 Spec-only |
component.{mode}.cardSplit.metadataFontSize/Weight/LineHeight | 📝 Spec-only |
component.{mode}.cardSplit.titleFontSize/Weight/LineHeight | 📝 Spec-only |
component.{mode}.cardSplit.titleMaxLines | ⚙️ API |
component.{mode}.cardTop10.posterWidth | ⚙️ API |
component.{mode}.cardTop10.rankFontFamily | ⚙️ API |
component.{mode}.cardTop10.rankNumFontSize | ⚙️ API |
component.{mode}.cardTop10.rankNumLetterOverlapFactor | ⚙️ API |
component.{mode}.cardTop10.rankNumOuterOverlap | ⚙️ API |
component.{mode}.cardTop10.rankNumStrokeWidth | ⚙️ API |
component.{mode}.collectionView.headerFadeScrollDivisor | 🔧 Kotlin |
component.{mode}.collectionView.headerHeightDp | 🔧 Kotlin |
component.{mode}.collectionView.headerLeftPadding | 🔧 Kotlin |
component.{mode}.collectionView.headerLogoHeightDp | 🔧 Kotlin |
component.{mode}.collectionView.headerRightPadding | 🔧 Kotlin |
component.{mode}.collectionView.headerZIndex | 🔧 Kotlin |
component.{mode}.detailActionButton.iconLabelGap | ⚙️ API |
component.{mode}.detailActionButton.iconSize | ⚙️ API |
component.{mode}.detailActionButton.touchTarget | ⚙️ API |
component.{mode}.detailHeroMobile.aspectRatio | ⚙️ API |
component.{mode}.detailHeroMobile.spacerWidth | ⚙️ API |
component.{mode}.detailHeroTablet.aspectRatio | ⚙️ API |
component.{mode}.detailHeroTablet.landscapeHeightInset | ⚙️ API |
component.{mode}.detailHeroTablet.scrimAnchorFromBottom | ⚙️ API |
component.{mode}.detailHeroTablet.scrimFadeHeight | ⚙️ API |
component.{mode}.detailTwoColumn.columnGap | ⚙️ API |
component.{mode}.detailTwoColumn.contentMaxWidth | ⚙️ API |
component.{mode}.detailTwoColumn.leftFraction | ⚙️ API |
component.{mode}.heroCarousel.aspectRatioMobile | ⚙️ API |
component.{mode}.heroCarousel.aspectRatioTablet | ⚙️ API |
component.{mode}.heroCarousel.bodyFontFamily | ⚙️ API |
component.{mode}.heroCarousel.borderColor | ⚙️ API |
component.{mode}.heroCarousel.borderWidth | ⚙️ API |
component.{mode}.heroCarousel.contentPadding | ⚙️ API |
component.{mode}.heroCarousel.cornerRadius | ⚙️ API |
component.{mode}.heroCarousel.descriptionMaxLines | ⚙️ API |
component.{mode}.heroCarousel.displayFontFamily | ⚙️ API |
component.{mode}.heroCarousel.eyebrowMaxLines | ⚙️ API |
component.{mode}.heroCarousel.pageSpacing | ⚙️ API |
component.{mode}.heroCarousel.tabletContentWidthFraction | ⚙️ API |
component.{mode}.heroCarousel.tabletGradientWidthFraction | ⚙️ API |
component.{mode}.heroCarousel.tabletImageWidthFraction | ⚙️ API |
component.{mode}.heroCarousel.titleMaxLines | ⚙️ API |
component.{mode}.heroLive.cornerRadius | ⚙️ API |
component.{mode}.heroVideo.bodyFontFamily | ⚙️ API |
component.{mode}.heroVideo.contentTopPadding | ⚙️ API |
component.{mode}.heroVideo.displayFontFamily | ⚙️ API |
component.{mode}.heroVideo.eyebrowMaxLines | ⚙️ API |
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 | ⚙️ API |
component.{mode}.heroVideo.tabletTitleFontSize | ⚙️ API |
component.{mode}.heroVideo.tabletTitleFontWeight | ⚙️ API |
component.{mode}.heroVideo.tabletTitleLineHeight | ⚙️ API |
component.{mode}.heroVideo.tabletTitleMaxLines | ⚙️ API |
component.{mode}.mediaCard.defaultMaxWidth | ⚙️ API |
component.{mode}.menuDrawer.dividerAlpha | ⚙️ API |
component.{mode}.menuDrawer.overlayBgAlpha | ⚙️ API |
component.{mode}.menuDrawer.rowBgColor | ⚙️ API |
component.{mode}.menuDrawer.sectionLabelAlpha | ⚙️ API |
component.{mode}.menuDrawer.swipeDismissThresholdDp | 🔧 Kotlin |
component.{mode}.menuDrawer.tabletDrawerWidth | ⚙️ API |
component.{mode}.menuDrawer.tabletScrimAlpha | ⚙️ API |
component.{mode}.playlist.rowHorizontalPadding | ⚙️ API |
component.{mode}.playlist.rowItemSpacing | ⚙️ API |
component.{mode}.playlist.rowTitleBottomSpacing | ⚙️ API |
component.{mode}.playlist.rowTitleFontSize | ⚙️ API |
component.{mode}.playlist.rowTitleFontSizeSp | 🔧 Kotlin |
component.{mode}.playlist.rowTitleFontWeight | ⚙️ API |
component.{mode}.programGuideCard.borderWidth | ⚙️ API |
component.{mode}.programGuideCard.progressHeight | ⚙️ 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}.protoBibleButton.secondary.* | 🔧 Kotlin |
component.{mode}.rowSection.cardAspectRatio | ⚙️ API |
component.{mode}.searchField.* | 🔧 Kotlin |
component.{mode}.searchField.chipCornerRadius | ⚙️ API |
component.{mode}.searchField.transitionDurationMs | ⚙️ API |
component.{mode}.section.horizontalPadding | ⚙️ API |
component.{mode}.section.verticalSpacing | ⚙️ API |
primitives.colors.brand.liveRed | ⚙️ API |
primitives.opacity.* | 🔧 Kotlin |
primitives.opacity.scale* | 🔧 Kotlin |
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.scale60 | ⚙️ API |
primitives.opacity.scale70 | ⚙️ API |
primitives.opacity.scale75 | ⚙️ API |
primitives.opacity.scale80 | ⚙️ API |
primitives.opacity.scale90 | ⚙️ API |
primitives.opacity.scale95 | ⚙️ API |
primitives.spacing.Space20 | ⚙️ API |
primitives.spacing.Space24 | ⚙️ API |
primitives.spacing.Space40 | ⚙️ API |
primitives.spacing.Space4_5 | ⚙️ API |
primitives.spacing.Space5_5 | ⚙️ API |
semantic.bg.page | 🔧 Kotlin |
semantic.buttons.*.icon.xl | 📝 Spec-only |
semantic.buttons.*.icon.xxl | 📝 Spec-only |
semantic.buttons.*.icon.{xl,xxl} | 📝 Spec-only |
semantic.dark.background.canvas | ⚙️ API |
semantic.dark.border.strong | ⚙️ API |
semantic.dark.text.primary | ⚙️ API |
semantic.desktop.* | 🔧 Kotlin |
semantic.effect.backdropBlur | ⚙️ API |
semantic.focusRing.{bp}.* | 🔧 Kotlin |
semantic.focusRing.{bp}.color | 🔧 Kotlin |
semantic.focusRing.{bp}.offset | ⚙️ API |
semantic.focusRing.{bp}.width | ⚙️ API |
semantic.iconSize.{bp}.xxxl | 📝 Spec-only |
semantic.icons.md | 📝 Spec-only |
semantic.icons.{bp}.md | ⚙️ API |
semantic.light.background.canvas | ⚙️ API |
semantic.light.background.surface | ⚙️ API |
semantic.light.background.surfaceSunken | ⚙️ API |
semantic.light.border.strong | ⚙️ API |
semantic.light.text.primary | ⚙️ API |
semantic.mobile.* | 🔧 Kotlin |
semantic.radius.{bp}.lg | ⚙️ API |
semantic.spacing.{bp}.dividerHeight | ⚙️ API |
semantic.spacing.{bp}.elevationHero | ⚙️ 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}.paddingDefault | ⚙️ API |
semantic.spacing.{bp}.paddingSm | ⚙️ API |
semantic.tablet.* | 🔧 Kotlin |
semantic.tv.* | 🔧 Kotlin |
semantic.typography.{bp}.bodySmall | ⚙️ API |
semantic.typography.{bp}.epgDetail | ⚙️ API |
semantic.typography.{bp}.epgTime | ⚙️ API |
semantic.typography.{bp}.epgTitle | ⚙️ API |
semantic.typography.{bp}.heroTitle | ⚙️ API |
semantic.typography.{bp}.label | ⚙️ API |
semantic.{dark,light}.* | 🔧 Kotlin |
semantic.{mobile,tablet,desktop,tv} | 📝 Spec-only |
semantic.{mode}.ai.gradient | ⚙️ API |
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.* | 🔧 Kotlin |
semantic.{mode}.background.canvas | ⚙️ API |
semantic.{mode}.background.surfaceRaised | ⚙️ API |
semantic.{mode}.border.default | ⚙️ API |
semantic.{mode}.border.strong | ⚙️ API |
semantic.{mode}.border.subtle | ⚙️ API |
semantic.{mode}.brand.accent | ⚙️ API |
semantic.{mode}.brand.orange | ⚙️ API |
semantic.{mode}.brand.primary | ⚙️ API |
semantic.{mode}.broadcast.live | ⚙️ API |
semantic.{mode}.focusRing.default | 🔧 Kotlin |
semantic.{mode}.text.onColor | ⚙️ API |
semantic.{mode}.text.primary | ⚙️ API |
semantic.{mode}.text.primary/secondary | 📝 Spec-only |
semantic.{mode}.text.secondary | ⚙️ API |
semantic.{mode}.text.{primary,onColor} | 📝 Spec-only |
views.detail.buttonRowTopGap | ⚙️ API |
views.detail.contentPaddingHorizontal | ⚙️ API |
views.detail.descriptionBottomGap | ⚙️ API |
views.detail.heroImageBottomGap | ⚙️ API |
views.detail.metaDescriptionGap | ⚙️ API |
views.detail.sectionGap | ⚙️ API |
views.detail.titleMetaGap | ⚙️ API |
views.detail.topBarContentGap | ⚙️ API |
views.home.backgroundGradient.stops | 📝 Spec-only |
views.home.layout.* | 🔧 Kotlin |
views.home.layout.contentPaddingBottomMobile | ⚙️ API |
views.home.layout.contentPaddingBottomTablet | ⚙️ API |
views.home.layout.contentPaddingBottom{Mobile,Tablet} | 📝 Spec-only |
views.home.layout.contentPaddingHorizontalMobile | ⚙️ API |
views.home.layout.contentPaddingHorizontalTablet | ⚙️ API |
views.home.layout.contentPaddingHorizontal{Mobile,Tablet} | 📝 Spec-only |
views.home.layout.contentPaddingTopMobile | ⚙️ API |
views.home.layout.contentPaddingTopTablet | ⚙️ API |
views.home.layout.contentPaddingTop{Mobile,Tablet} | 📝 Spec-only |
views.home.layout.rowCardSpacingMobile | ⚙️ API |
views.home.layout.rowCardSpacingTablet | ⚙️ API |
views.home.layout.rowCardSpacing{Mobile,Tablet} | 📝 Spec-only |
views.home.layout.sectionGapMobile | ⚙️ API |
views.home.layout.sectionGapTablet | ⚙️ API |
views.home.layout.sectionGap{Mobile,Tablet} | 📝 Spec-only |
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).




