Design System — Komponentenspezifikation

Kanonische, plattformunabhängige Spezifikation aller Komponenten — wächst über die Iterationen hinweg (nichts wird gelöscht, nur ergänzt). Verbindlich für die plattformübergreifende Implementierung (Android · iOS · Web). Der jeweilige Pro-Iteration-Lieferumfang steht in iteration-1.html (Home + Burger-Menü) und iteration-2.html (Lesepläne-Komponenten). Die Referenz-Implementierung für Android befindet sich in packages/android.

Zielgruppe
Engineers (plattformübergreifend), Designer zur Verifikation des Kontrakts, QA, agentische Implementierungs-Tools.
Status
v1 — übergabebereit · 16 Komponenten · 21 Abschnitte · 250 Token-Pfade katalogisiert.
Begleitdokumente
index.html (Engineering-Referenz, generierte Tabellen) · handover.json (maschinenlesbare Zusammenfassung)
Konventionen
dp / sp · ms · cubic-bezier · DTCG-Token-Pfade.

Aufbau dieses Dokuments

Jede Komponente unten ist nach einem einheitlichen Kontrakt dokumentiert:

  1. Überblick — was die Komponente ist und warum sie im Erlebnis existiert.
  2. Visuelle Spezifikation — Dimensionen, Aufbau (Layer-Stack), Tokens, die ihr Erscheinungsbild bestimmen.
  3. Zustände — jeder visuelle Zustand mit Auslöser und Austrittsbedingung.
  4. Verhalten — Zustandsautomat, Lebenszyklus, Seiteneffekte, Datenabhängigkeiten.
  5. Bewegung — Dauern, Easings, Choreografie. Immer in Millisekunden + cubic-bezier angegeben.
  6. Interaktion & UX — Eingabebehandlung (Drücken, Ziehen, Wischen, Tastatur, Fokus), Gesten, Scrollen.
  7. Barrierefreiheit — semantische Rolle, Labels, Fokusreihenfolge, Mindest-Trefferflächen, Verhalten bei reduzierter Bewegung.
  8. Verwendete Tokens — jeder DTCG-Token-Pfad, den die Komponente liest, nach Ebene.
  9. 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 (sp auf Android, äquivalent zu Dynamic Type auf iOS, rem im 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.* über MaterialSymbols.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 / iOS UIView / Web div). "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.

EbeneZweckBeispiel
PrimitiveRohwerte: Farben, Abstände, Schriftstärken, Deckkraft-Stufen.primitives.opacity.scale80 = 0.8
SemanticTheme-bewusste Bedeutung: dunkler/heller Text, Rahmen, Oberflächen.semantic.dark.text.primary = #FFFFFF
ComponentKomposition pro Komponente.component.heroVideo.tabletTitleFontSize = 26
ViewKomposition 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.

Token-Pfad-Konvention. In diesem Dokument folgen Pfade innerhalb von <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

Android. Referenz-Implementierung. Verfügbar in 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.
iOS. Implementierung ausstehend. Ziel: SwiftUI. Material-3-äquivalente Semantik über die SwiftUI-Material-Bibliothek oder ein dünnes Shim adoptieren; keine Material-spezifische Ikonografie importieren.
Web. Implementierung ausstehend. Ziel: React + ein token-getriebenes CSS-Variablen-System. Das HTML-Referenzdokument index.html zeigt eine Möglichkeit, component-tokens.css zu konsumieren.

Glossar

In diesem Dokument verwendete Begriffe — plattformunabhängig definiert.

BegriffBedeutung
dpDichteunabhä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.
spSkalierbares 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.
ContainerEine generische Layout-Box, die weitere Elemente enthält. Entspricht Android Box, iOS UIView / SwiftUI VStack-Wrapper, Web div.
StackEine lineare Anordnung von Kindelementen, entweder vertikal oder horizontal. Entspricht Compose Column/Row, SwiftUI VStack/HStack, CSS flex-direction: column/row.
Press-FeedbackDie transiente visuelle Bestätigung, wenn ein Nutzer ein interaktives Element antippt. Android = Ripple, iOS = Highlight/Scale, Web = Active-State.
ScrimEine halbtransparente Überlagerung hinter einer modalen Oberfläche, die den darunterliegenden Inhalt abdunkelt und visuell trennt. Immer antippbar zum Schließen.
SlotEin 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.
ChromeWiederverwendbare visuelle Umrandung um Inhalt (Rahmen, Eckenradius, Schatten, Badge-Slot) — die Komponente dekoriert, ohne den Inhalt vorzugeben.
SettleDer Endzustand nach einem Wisch- oder Fling-Gesten, wenn der scrollbare Bereich an einer stabilen Seitenposition zur Ruhe kommt.
TrefferflächeDer 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 / BreakpointViewport-Größenschwelle, die den Layout-Modus umschaltet. Iteration 1 hat zwei aktive: mobile (kürzeste Seite < 600dp) und tablet (≥ 600dp). Siehe Breakpoints.
DTCGW3C 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.
TokenEin 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-PaletteLaufzeitanalyse 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.
SkeletonEin 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 PayloadServer-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 / Web prefers-color-scheme).
  • Semantic-Ebene: Der Großteil der Token-Auflösung liegt in semantic.{dark,light}.* — jede sichtbare Farbe stammt von dort. Komponenten lesen semantic.{mode}.text.primary, background.canvas usw., 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)

KomponenteHeller ModusDunkler Modus
TopBarStatusleiste = 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).
HeroCarouselKein Schlagschatten am Hero-Kartenkörper. Rahmenfarbe aus Light-Mode-Semantic.Schlagschatten bei heroCarousel.elevation = 16dp. Rahmenfarbe aus Dark-Mode-Semantic.
HeroVideoCardUnterer Gradient = 7 Stufen (hellere Rampe). Light-Mode-Textfarben.Unterer Gradient = 8 Stufen (dichtere Rampe für Textlesbarkeit auf Foto). Dark-Mode-Textfarben.
ButtonGroupSectionPrimärer Button = vollflächige Oberfläche, KI-Variante verwendet Light-Mode-Akzent-Gradient.Gleiche Komponente, Dark-Mode-Akzent-Gradient.
RowSectionTiteltext aus semantic.light.text.primary.Titeltext aus semantic.dark.text.primary.
CardPosterRahmen- + Schattenwerte aus Light-Mode-Tokens. Skeleton-Füllung verwendet helle bgColor.Dasselbe mit Dark-Mode-Tokens.
MenuDrawerPanel-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-BildschirmCanvas = 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.* über MaterialSymbols.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 Skala 28/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 mobileEyebrowFontSize entfernt (Report-Punkt 2). Die Eyebrow-Größe kommt — wie im Code — aus heroVideo.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 #252F35 korrigiert (vorher „Neutral700 #1A2733", ein Hex außerhalb jeder Palette).
  • [Iteration 1] Proto: globales Text-Letterspacing entfernt (Report-Punkt 4). Die Material3-Typography der Referenz-App setzte (M3-Default) letterSpacing = 0.5sp auf bodyLarge/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-Wert 14/SemiBold war selbst Drift — die Referenz-Implementierung (Kotlin-Truth) rendert 16sp/SemiBold/1.2; API + Doku wurden am 10.06. darauf korrigiert (Drift-Gate verifiziert). Bitte den BTV-Fix „16/Medium → 14/SemiBold" auf 16/SemiBold nachziehen — 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ängigtabletGradientWidthFraction hell 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), Rampe scrimFadeHeight(240dp) nach oben, darunter Vollton — unabhängig von der Texthöhe. Neue ⚙️-API-Token scrimFadeShift/scrimFadeHeight; Stop-Kurven (band-relativ) in heroVideo.gradient.stops dokumentiert. 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 via appearance.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, #1A1A1A auf hellen Hintergründen (Schwelle 0.179). Neuer Token badge.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.maxLines Portrait 3/4 → 2/3 (crowded 2/2); heroFeatured erhä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.cornerRadius 16→12 · overlay.titleLineHeight 1.07→1.2143 · split.titleFontSize 14→16 · split.titleLineHeight 1.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.

  1. Token-API-Client verdrahten. Beim App-Start /api/v1/tokens abrufen. 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-resolveMediathekTokens für das Merge-Muster.
  2. 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).
  3. 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.)
  4. Dann die Abschnitts-Container. RowSection (rendert eine horizontal scrollende Reihe von Karten) · ButtonGroupSection. RowSection steuert die Karten-Varianten-Dispatch aus Serverdaten.
  5. 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.
  6. Dann die bildschirmebene Oberflächen. TopBar · BottomNavigation · Home-Bildschirm-Orchestrator, der alles komponiert.
  7. Schließlich das Overlay. MenuDrawer — das einzige Modal im Umfang von Iteration 1. Fokus-Falle und reduzierte Bewegung korrekt umsetzen.
  8. 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.
Agenten- / autonomes Tooling. Eine maschinenlesbare Zusammenfassung dieses Kontrakts wird unter 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.

KomponenteKlasseIter.BP-bewusstAnimiertAbschnitt
Home-BildschirmComposite (Orchestrator)1Scroll, Hintergrund-Transition
TopBarSticky1Scroll-Fade
HeroCarouselComposite · wischbar (inkl. Shell + Indicator)1Auto-Advance · Drag
HeroVideoCardHero-Karte · Video1Bild-Fade
HeroPosterCardHero-Karte · image-only (2-Asset)2Bild-Fade
HeroGenericCardHero-Karte · custom URL2Bild-Fade · Press
ButtonGroupSectionSection (Single-AI · Multi · Overflow)1 · 2Press-Feedback
RowSectionSection · horizontales Scrollen1horizontales Scrollen
CardPosterRow-Item · 9:161Bild-Fade · Press
CardLandscapeRow-Item · 16:9 image-only (S/M/L)2Bild-Fade · Press
CardAvatarRow-Item · Kreis + Label2Press
MenuDrawerModal · Overlay1Slide · 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.

Maschinenlesbare Zusammenfassung. Ein strukturierter JSON-Snapshot dieser Matrix plus Bewegungsskala, Breakpoints, Barrierefreiheits-Baseline und Token-Abdeckungszahlen wird neben dem Dokument unter 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 Composite

Der 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.

Oberfläche
Vollbild.
Hintergrund
semantic.{mode}.background.canvas
Scroll
Vertikal, einspaltig, volle Breite.
Design
Übernimmt Hell oder Dunkel gemäß Nutzerpräferenz. Hero-Blöcke überschreiben lokal basierend auf Slide-Inhalt.

Layout-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.

Mobil — vertikale Komposition Alle Abschnittsabstände aus views.home.layout.* Tokens
canvas · effectiveBaseColor ↧ Hintergrund-Gradient · 5 Stufen · oben = Hero-Slide-Hintergrund über Canvas gemischt TopBar · sticky · Scroll-Fade-Hintergrund contentPaddingTop {Mobile/Tablet} HeroCarousel + HeroVideoCard sectionGap {Mobile/Tablet} ButtonGroupSection sectionGap RowSection · CardPoster-Reihe RowSection · …N weitere, server-getrieben contentPaddingBottom 1 2 3 4 5 6

Callout-Legende

  1. Canvas — Basis-HintergrundVollbild-Hintergrund = pageBackgroundColor: semantic.{mode}.background.canvas in beiden Modi (hell #F8F9FA / dunkel #080D16). Die Page-API kann pro Seite per appearance.backgroundColorLight/Dark übersteuern. Der Gradient-Bleed (Punkt 2) zeichnet darüber.CollectionScreen.kt:98–104, 152–155
  2. Hintergrund-Gradient-Bleed (die "Hintergrund-Transition")Vertikaler Gradient mit 6 Stufen, gezeichnet von GradientBackground von y=0 bis zur Bildschirmhöhe via ColorTransformations.pageGradientStops. Obere Farbe = Mischung der gradientSourceColor über canvas bei α 0.8. Stop-Anteile: 0.0 → obere Farbe · headerStopFraction → obere Farbe · 0.3 → α 0.68 · 0.5 → α 0.48 · 0.75 → α 0.24 · 1.0 → canvas exakt (α 0) — der Gradient konvergiert nahtlos in den Seitenhintergrund (identisch zur color-engine computeGradientStops). Dies ist es, was dem Home seine hero-getriebene Stimmung verleiht.CollectionScreen.kt:315–343 (GradientBackground) · ColorTransformations.kt:35 (pageGradientStops)
  3. HeroCarousel-BlockOberster Inhaltsblock. Sein onBackgroundColorChange-Callback aktualisiert den heroBackgroundColorArgb-State auf dem Home. Die Gradient-Quellfarbe wird per resolvePageGradientSource aufgelöst: API-appearance.gradientColorLight/Dark gewinnt, extrahierte Hero-Farbe ist Fallback, sonst Canvas.CollectionScreen.kt:107–110, 133–140, 345–351
  4. AbschnittsabstandVertikaler Abstand zwischen allen Abschnitten · views.home.layout.sectionGapMobile = 24dp · sectionGapTablet = 32dp · getrieben von verticalArrangement = Arrangement.spacedBy(section.verticalSpacing) in der LazyColumnCollectionRenderer.kt:54 · views/home.json (Spec 083)
  5. ButtonGroupSectionCTA-Streifen über volle Breite. Siehe ButtonGroupSection.
  6. RowSection × N — server-getriebenAnzahl der Reihen + ihre Kartentypen kommen aus der Composition-API. Iteration 1 rendert nur POSTER_PORTRAIT-Reihen. Siehe RowSection.

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.

PhaseWas passiertQuelle
Initiales LadenHome 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 settledHeroCarousel 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 KarussellHeroCarousel 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 untenDer 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-AnteilFarbeHinweise
0.0blendOver(gradientColor, canvas, α 0.8)Oberer Bildschirmrand — stärkster Hero-Farbeinfluss.
headerStopFractionblendOver(gradientColor, canvas, α 0.8)Gleiche Farbe wie 0.0 — hält den oberen Farbton flach unter der TopBar. headerStopFraction = stickyHeaderHeight / screenHeight.
0.3blendOver(gradientColor, canvas, α 0.68) = 0.8 × 0.85Beginnt zur Canvas auszublenden.
0.5blendOver(gradientColor, canvas, α 0.48) = 0.8 × 0.6Mitte des Bildschirms — halb gemischt.
0.75blendOver(gradientColor, canvas, α 0.24) = 0.8 × 0.3Letzter getönter Stop.
1.0canvas (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

Abschnittskomposition

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.

Produktiver Home-Payload (Iteration 1, live)

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:

  1. slider_hero mit 2 card_hero_media-Slides (→ HeroCarousel mit HeroVideoCard)
  2. group_button mit 1 Button "Die Bibel erkunden" (→ ButtonGroupSection im Single-Item-AI-CTA-Modus)
  3. slider mit 3 card_poster_small-Karten (→ RowSection mit CardPoster)
  4. slider mit 3 card_poster_medium-Karten
  5. slider mit 3 card_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.

Sticky Header

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).

Statusleiste

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.

Pull to Refresh

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

EigenschaftMobilTabletToken
Horizontales Seitenpadding16dp32dpviews.home.layout.contentPaddingHorizontal{Mobile,Tablet}
Oberes Inhalts-Padding8dp16dpviews.home.layout.contentPaddingTop{Mobile,Tablet}
Abschnittsabstand (zwischen Abschnitten)24dp32dpviews.home.layout.sectionGap{Mobile,Tablet}
Unteres Inhalts-Padding24dp32dpviews.home.layout.contentPaddingBottom{Mobile,Tablet}
Karten-Abstand innerhalb Reihe16dp16dpviews.home.layout.rowCardSpacing{Mobile,Tablet}
Höhe des Sticky-HeadersstatusBar + 56dpstatusBar + 56dpcollectionView.headerHeightDp + System-Inset
Hintergrund-Gradient — Blend-Alpha0.80.8collectionView.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

Initiales Laden

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.

Scroll-Verhalten

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.

Tab-Reta

Das Antippen des Home-Tabs, während Home bereits aktiv ist, scrollt den Inhalt mit der plattformüblichen Smooth-Scroll-Animation (≤300ms) nach oben.

Pull to Refresh

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

Leere / Fehlerzustände

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):

FeldTypBedeutung
idStringSeiten-Slug (z. B. home, mediathek, leseplan).
titleString?Optionaler Seitentitel.
sectionsList<Section>Servergesteuerte Abschnittssequenz; pro Abschnitt siehe den jeweiligen Komponenten-Kontrakt.
appearance.gradientColorDark / gradientColorLightString? (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 / backgroundColorLightString? (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

Home mobile, dark
Mobil · Dunkel · Scrollposition 0
Home mobile, light
Mobil · Hell · Scrollposition 0
Home tablet, dark
Tablet · Dunkel · Scrollposition 0
Home tablet, light
Tablet · Hell · Scrollposition 0

TopBar

Umfang Iteration 1 Sticky

Persistente 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.

Mobil & Tablet — gleiche Struktur, breakpoint-bewusstes Padding Leistenhöhe 56dp · Logohöhe 22dp
Statusleisten-Inset · vom Gerät gemeldet · transparent Header-Container · Höhe 56dp · scroll-getriebener bg-Fade · zIndex 10 bibel APP (Wortmarke) 22dp Höhe [cast] [person] ↓ Scroll-Fortschritt α = 0 → 1 über 300 px Scroll 1 2 3 4 5 6

Callout-Legende

  1. Header-Container — sticky, scroll-getriebener HintergrundHöhe: collectionView.headerHeightDp = 56dp · linkes Padding 16dp (gapDefault) · rechtes Padding 8dp (gap2xs) · zIndex collectionView.headerZIndex = 10CollectionScreen.kt:282–283 · MediathekTokenModels.kt CollectionViewTokens
  2. Statusleisten-Inset — system-gestelltTransparenter Bereich über der Leiste = WindowInsets.statusBars.calculateTopPadding() · Statusleisten-Hintergrund transparent gezeichnet, sodass der Gradient/Scroll-Fade der Leiste darunter durchgreiftCollectionScreen.kt:105, 119–122 · Statusleiste im SideEffect transparent gesetzt
  3. Marken-Logo (BtvLogo) — linksHöhe PrimitiveSpaceTokens.Space5_5 = 22dp · Textfarbe semantic.{mode}.text.primary · Akzent (Stern) ist dynamisch — kommt vom composition-gelieferten Akzent des aktiven Hero-SlidesCollectionScreen.kt:287–291 · BtvLogo.kt
  4. Cast-Icon (Chromecast-Trigger)MaterialSymbols.Cast · 24dp-Icon-Glyph innerhalb 48dp-IconButton-Trefferfläche · Tint semantic.{mode}.text.primary · Content-Description "Streamen"CollectionScreen.kt:296–302
  5. Konto-/Menü-Icon — öffnet Burger-DrawerMaterialSymbols.PersonText · 24dp-Glyph in 48dp-IconButton · gleicher Tint · Content-Description "Konto" · onClick = onBurgerMenuOpen ruft den MenuDrawer aufCollectionScreen.kt:303–309
  6. Scroll-getriebener Hintergrund-FadeHintergrund-Alpha der Leiste = (scrollOffset / 300f).coerceIn(0, 1), wobei scrollOffset = listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex × 500f. Aufgelöste Farbe = semantic.{mode}.background.canvas bei diesem Alpha.CollectionScreen.kt:141–152 · headerFadeScrollDivisor = 300f und scrollOffsetItemMultiplier = 500f sind beide @TokenExempt(behavioral) in CollectionViewTokens

Dimensionen — alle Tokens, keine Inline-Werte

EigenschaftWertTokenQuelle
Leistenhöhe56dpcomponent.{mode}.collectionView.headerHeightDpMediathekTokenModels.kt
Logohöhe22dpcomponent.{mode}.collectionView.headerLogoHeightDp (oder Äquivalent primitives.spacing.Space5_5)MediathekTokenModels.kt
Linkes Padding16dpcomponent.{mode}.collectionView.headerLeftPadding = semantic.spacing.{bp}.gapDefaultCollectionScreen.kt:283
Rechtes Padding8dpcomponent.{mode}.collectionView.headerRightPadding = semantic.spacing.{bp}.gap2xsCollectionScreen.kt:283
Action-Icon-Abstand0dp (Icons berühren sich)Sentinel-Null, exception-getaggtCollectionScreen.kt:293
Icon-Trefferfläche48dp × 48dpMaterial 3 IconButton-Default · erfüllt a11y-Minimuma11y-Baseline
z-index10component.{mode}.collectionView.headerZIndexCollectionScreen.kt:274
Scroll-Fade-Divisor300 (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 ≥ 300 ist der Hintergrund vollständig undurchsichtig semantic.{mode}.background.canvas.
Gedrückt (Icon)
Standard-Material-3-IconButton-Ripple innerhalb einer 48dp-kreisförmigen Trefferfläche · Tint semantic.{mode}.text.primary bei primitives.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 an BtvLogo ü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 onBurgerMenuOpen auf, das der bildschirmebenen Orchestrator an den MenuDrawer weiterleitet.
  • Statusleisten-Behandlungwindow.statusBarColor wird in einem SideEffect auf 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 auf 1 - x abgebildet. Hintergrund-Deckkraft der Leiste entspricht.
Gedrückt (Icon)
Jedes Icon zeigt ein kreisförmiges Press-Feedback bei opacity.scale15, getintet mit semantic.{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-SDK MediaRouteButton. 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öserEigenschaftDauerEasing
ScrollLogo-Deckkraft, Leisten-Hintergrund-Deckkraftkontinuierlich (1:1 mit Scroll)linear
Icon-DruckPress-Feedback-Deckkraft120ms in / 200ms outease-out
MenuDrawer öffnensiehe 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

PfadEbene
semantic.{mode}.background.canvassemantic
semantic.{mode}.text.primarysemantic
semantic.icons.{bp}.mdsemantic
semantic.focusRing.{bp}.*semantic
component.btvTopBar.horizontalPaddingMobilecomponent
component.btvTopBar.horizontalPaddingTabletcomponent
component.btvTopBar.verticalPaddingMobilecomponent
component.btvTopBar.verticalPaddingTabletcomponent
component.btvTopBar.scrollFadeDistanceDpcomponent
component.btvTopBar.actionSpacingcomponent
component.btvLogo.heightMobilecomponent
component.btvLogo.heightTabletcomponent
primitives.opacity.scale15primitive
primitives.opacity.scale40primitive

Referenz

Mobile · TopBar (light + dark variants on the home tab)
Mobil · TopBar (helle und dunkle Variante im Home-Tab)

Implementierungsreferenz

Android · 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.

HeroVideoCard

Umfang Iteration 1 Hero-Slide-Variante Bildgeführt

Ein 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. 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. 2. Cover-Bildebene — füllt die Shell auf Mobile (full bleed); füllt den rechten Anteil component.heroCarousel.tabletImageWidthFraction auf 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. 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. 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 aus component.badge.*; oberhalb des Verlaufs gerendert. Padding innerhalb der Shell: semantic.spacing.{bp}.paddingDefault = 24dp.
  5. 5. Content-Stack — vertikaler Stack innerhalb der Verlaufsregion.
    1. 5a. Optionaler Eyebrow (Untertitel), einzeilig, sekundäre Textfarbe.
    2. 5b. Titel, primäre Textfarbe, bis zu N Zeilen (Token tabletTitleMaxLines) mit Ellipsis.
    3. 5c. Optionale Beschreibung (oder EPG-Kurztext, falls verfügbar), sekundäre Textfarbe, bis zu N Zeilen mit Ellipsis.
    4. 5d. Primärer CTA — MediathekButton, Typ Primary, Größe Small, vorangestelltes Icon play_arrow.
    5. 5e. Optionaler sekundärer CTA — MediathekButton, Typ Secondary, Größe Small.

Abmessungen

EigenschaftMobileTabletToken
Karten-Seitenverhältnis0.75 (3:4 Portrait, füllt Shell)0.394 (≈2.54:1 Landscape)component.heroCarousel.aspectRatioMobile / aspectRatioTablet
Seitenverhältnis Cover-Bild15:16 (imgix-Anfrage)16:9 (imgix-Anfrage)
Breitenanteil Cover-Bild1.0tabletImageWidthFractioncomponent.heroCarousel.tabletImageWidthFraction
Breitenanteil Content-Spalte1.0tabletContentWidthFractioncomponent.heroCarousel.tabletContentWidthFraction
Breitenanteil Verlaufsregion1.0 (vertikal)tabletGradientWidthFraction (horizontal)component.heroCarousel.tabletGradientWidthFraction
Content Top-Padding (oberhalb der Verlauf-Textregion)140dpcomponent.{mode}.heroVideo.contentTopPadding
Content seitliches Padding16dp20dpsemantic.spacing.{bp}.paddingDefault (mobile 16 · tablet 20 · desktop 24)
Content unteres Padding16dp20dpsemantic.spacing.{bp}.paddingDefault (mobile 16 · tablet 20 · desktop 24)
Eyebrow Schriftgröße12sp14spcomponent.{mode}.heroVideo.mobileDescFontSize / tabletDescFontSize
Titel Schriftgröße16sp26spcomponent.{mode}.heroVideo.mobileTitleFontSize / tabletTitleFontSize
Titel Zeilenhöhe1.15× Schriftgröße (Standard)28spcomponent.{mode}.heroVideo.tabletTitleLineHeight
Titel SchriftstärkeSemiBold (Mobile)Medium (Tablet)component.{mode}.heroVideo.mobileTitleFontWeight / tabletTitleFontWeight
Beschreibung Schriftgröße12sp14spcomponent.{mode}.heroVideo.mobileDescFontSize / tabletDescFontSize
Beschreibung Zeilenhöhe18spcomponent.{mode}.heroVideo.tabletDescLineHeight
Beschreibung SchriftstärkeNormalMediumcomponent.{mode}.heroVideo.mobileDescFontWeight / tabletDescFontWeight
CTA primärer Icon-Größe14dp14dpcomponent.{mode}.heroVideo.iconSize
Abstand Eyebrow → Titel4dp4dpsemantic.spacing.{bp}.gap4xs / gap3xs
Abstand Titel → Beschreibung4dp4dpsemantic.spacing.{bp}.gap4xs / gap2xs
Abstand Content → primärer CTA12dpsemantic.spacing.{bp}.gapSm
Abstand primärer → sekundärer CTA8dp8dpsemantic.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.

PositionDark Mode (8 Stops)Light Mode (7 Stops)
0%transparenttransparent
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)

PositionFarbe
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 3:4 Portrait · aspectRatioMobile = 0.75 (B/H)
Bildebene (full-bleed) durch Komposition gelieferte Bild-URL {slide.category} ↓ Gradient-Overlay (8 Stops, ↦ durch Komposition gelieferte Basis) Eyebrow · "Serie / Sendungsname" Titel (bis zu 3 Zeilen) Beschreibung (mit Ellipsis) [play_arrow] Jetzt ansehen 1 2 3 4 5 6 7 8

Mobile Callout-Legende

  1. Äußerer Container — Karten-ChromeEckenradius 24dp HeroCarouselTokens.cornerRadius · 1dp eingerückter Rahmen borderColor + borderWidth = 1.dp · Schlagschatten 16dp nur im Dark Mode elevation = 16.dpMediathekTokenModels.kt:99–102
  2. Bildebene — Full-bleed-CoverAuf Container-Radius beschnitten · Skeleton-Füllung cardPoster.bgColor während des Ladens · Bild-URL aus HeroSlide.thumbnail in der Composition-PayloadHeroVideoCard.kt · HeroSharedContent.kt
  3. BtvBadge — API-getriebene Kategorie-PilleWiederverwendbares Primitive BtvBadge(color, text, modifier). Sowohl color (semantischer Schlüssel wie "live.default" oder ein Hex-String) ALS AUCH text stammen aus der Composition-Payload (slide.category) — KEIN fest codiertes Label. Fällt auf badgeTokens.defaultBackgroundColor zurück, wenn die Farbe null ist; versteckt sich vollständig, wenn der Text null/leer ist. Der Text wird in uppercase() gerendert. Oben links, eingerückt um paddingDefault = 16dp. Die Textfarbe wird per Luminanz gegen die aufgelöste Hintergrundfarbe gewählt (Schwelle ≈ 0.179, der Punkt gleicher WCAG-Kontraste für Weiß/Schwarz): dunkle/transluzente Hintergründe → badge.textColor (weiß), helle → badge.textColorOnLight (#1A1A1A). Alle Abmessungen aus component.badge.*.BtvBadge.kt (badgeTextColorFor) · HeroVideoCard.kt
  4. Gradient-Overlay — am unteren Rand verankert, mehrstufig vertikalTransparent (oben) → durch Komposition gelieferte Basisfarbe (unten). Mobile = 8 Stops im Dark Mode (7 im Light Mode). Opazitäten aus den primitives.opacity.scale*-Stufen. Höhe der Verlaufsregion = Kartenhöhe − heroVideo.contentTopPadding = 140.dpMediathekTokenModels.kt:114 (contentTopPadding)
  5. Eyebrow — Serie / SendungsnameEinzeilig · heroCarousel.eyebrowMaxLines = 1 · Diagramm Display-Familie · Schriftstärke Medium · Größe aus heroVideo.tabletDescFontSize (= 14sp, bewusst auch auf Mobile — ein separater mobileEyebrowFontSize-Token existiert nicht)HeroSharedContent.kt (RegularHeroContent eyebrow)
  6. Titel — bis zu 2 Zeilen (Mobile)Mobile titleMaxLines = 2 · Schriftstärke Medium · mit Ellipsis. Die Schriftgröße ist dynamisch abhängig von der Titel-Zeichenlänge (siehe Tabelle "Dynamische Titel-Schriftgröße" unterhalb der Diagramme).rememberHeroVideoDynamics() · HeroVideoCard.kt:80 (Mobile → mobileTitle = 2), als titleMaxLinesOverride übergeben (217) und in RegularHeroContent honoriert (HeroSharedContent.kt:570)
  7. Beschreibung — bis zu 3 Zeilen (Mobile)Mobile descriptionMaxLines = 3 · Inter Body-Familie · mit EllipsisrememberHeroVideoDynamics() · HeroVideoCard.kt:80 (Mobile → mobileDescription = 3), als descriptionMaxLinesOverride übergeben (218) und in RegularHeroContent honoriert (HeroSharedContent.kt). Tablet läuft über einen eigenen Pfad (HeroVideoCardTablet, 370).
  8. Primärer CTA "Jetzt ansehen"Small Primary Button mit vorangestelltem Play-Icon · bezogen von component.protoBibleButton.primary.* · Hit-Target ≥ 48 × 48 dp durch transparente AufpolsterungProtoBibleButton.kt
Tablet ~2.54 : 1 Landscape · aspectRatioTablet = 0.394 (gespeichert als H/B, angewendet als 1f / 0.394)
Bildebene · tabletImageWidthFraction = 0.7 am nachfolgenden Rand verankert Gradient-Overlay · 50% Breite · deckend LINKS → transparent RECHTS Content-Panel · tabletContentWidthFraction = 0.4 {slide.category} Eyebrow Titel (bis zu 3 Zeilen auf Tablet) Beschreibung (bis zu 5 Zeilen auf Tablet) [play_arrow] Jetzt ansehen 1 2 3 4 5 6 7 8 9

Tablet Callout-Legende

  1. Äußerer Container — Karten-ChromeDieselben Shell-Tokens wie Mobile · Seitenverhältnis abgeleitet aus aspectRatioTablet = 0.394 gespeichert als H/B; angewendet als 1f / 0.394 ≈ 2.5381 : 1 LandscapeMediathekTokenModels.kt:86
  2. Bildebene — am nachfolgenden Rand verankertBreitenanteil tabletImageWidthFraction = 0.7 · Motiv so komponiert, dass das visuelle Zentrum innerhalb des nicht ausgeblendeten Bereichs liegtMediathekTokenModels.kt:111
  3. Gradient-Overlay — horizontal, blendet Bild in Canvas-Farbe überBreitenanteil tabletGradientWidthFraction = 0.5 · Rampe vom deckenden Bild (rechts) zur Canvas-Farbe (links)MediathekTokenModels.kt:112
  4. Content-Panel — am führenden Rand verankert über Canvas-Color-BleedBreitenanteil tabletContentWidthFraction = 0.4 · gepolstert mit paddingDefault = 16dp · Text bleibt lesbar, da er über dem ausgeblendeten Teil des Verlaufs liegtMediathekTokenModels.kt:113
  5. BtvBadge — API-getriebene Kategorie-PilleDasselbe Primitive BtvBadge(color, text) wie auf Mobile. color + text stammen beide aus slide.category in der Composition-Payload — niemals fest codiert (die Beispiele "VIDEO" / "SERIES" / "LIVE" in diesem Dokument sind Platzhalter, keine Enum-Werte). Versteckt sich, wenn der Text null/leer ist. Tokens aus component.badge.*. Oben links der Karte, eingerückt um paddingDefault = 16dp.BtvBadge.kt:100–128 · HeroVideoCard.kt:318
  6. Eyebrow — Serie / SendungsnameEinzeilig · heroVideo.eyebrowMaxLines = 1 · Diagramm Display-FamilieMediathekTokenModels.kt:283 (eyebrowMaxLines)
  7. Titel — dynamische maximale Zeilenanzahl (Tablet)Portrait: max. 2 Zeilen. Landscape: max. 3 Zeilen. ("crowded top" ändert nur die Beschreibung.) Schriftgröße ebenfalls dynamisch nach Titellänge (siehe Tabelle unten).rememberHeroVideoDynamics() · HeroVideoCard.kt:87–95 · Tablet-Konsum: 353
  8. Beschreibung — dynamische maximale Zeilenanzahl (Tablet)Portrait: normalerweise 3 Zeilen, 2 Zeilen bei "crowded top" (sowohl Kategorie-Pille als auch Untertitel vorhanden). Landscape: max. 5 Zeilen. Inter Body-Familie · mit Ellipsis.rememberHeroVideoDynamics() · HeroVideoCard.kt:87–95 · Tablet-Konsum: 370
  9. Primärer CTA "Jetzt ansehen"Dieselbe Small-Primary-Komponente wie auf Mobile · am unteren Ende des Content-Stacks · Hit-Target ≥ 48 × 48 dpProtoBibleButton.kt
Dynamische Regeln — Titel-Schriftgröße & maximale Zeilenanzahl Eingeführt in Commit 4b17f706 · "smart tablet line limits + dynamic font sizing"

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ößeZeilenhöhe
< 2028sp30sp
< 3024sp26sp
< 4022sp24sp
< 5020sp22sp
≥ 5018sp20sp

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"

ModushasCrowdedTopTitel max. ZeilenBeschreibung max. Zeilen
Mobile(irrelevant)23
Tablet Portraitnein23
Tablet Portraitja22
Tablet Landscape(irrelevant)35

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).

Fallback-Zustand Wenn die Bild-URL leer ist oder das Laden fehlschlägt

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

Auswahl Mobile vs. Tablet

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.

Lebenszyklus des Cover-Bildes

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.

Vorgelieferte Palette → Verlaufsbasisfarbe

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 / Titel / Beschreibung Rendering

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.

CTA-Aufruf

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.

Light- vs. Dark-Modus

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öserEigenschaftDauerEasing
Bild dekodiertAlpha der Bildebene 0 → 1200ms (Plattform-Standard-Cross-Fade)ease-out
Slide-DruckPress-Feedback der Shellsiehe HeroCardShellsiehe HeroCardShell
CTA-DruckOpazität des tonalen Overlays120ms ein / 200ms ausease-out
Karussell Auto-Advancesiehe HeroCarousel
Modus-Wechsel (Light/Dark)Verlauf-Stops, Textfarbensofort (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 aus secondaryCtaLabel.
  • 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.onColor für primären Text und einer mit scale75 abgeschwä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

PfadTier
semantic.{mode}.background.canvassemantic
semantic.{mode}.text.primarysemantic
semantic.{mode}.text.onColorsemantic
semantic.{mode}.brand.primarysemantic
semantic.{mode}.brand.accentsemantic
semantic.{mode}.border.subtlesemantic
semantic.spacing.{bp}.paddingDefaultsemantic
semantic.spacing.{bp}.gap2xssemantic
semantic.spacing.{bp}.gap3xssemantic
semantic.spacing.{bp}.gap4xssemantic
semantic.spacing.{bp}.gapSmsemantic
semantic.focusRing.{bp}.*semantic
component.{mode}.heroCarousel.aspectRatioMobilecomponent
component.{mode}.heroCarousel.aspectRatioTabletcomponent
component.{mode}.heroCarousel.tabletImageWidthFractioncomponent
component.{mode}.heroCarousel.tabletGradientWidthFractioncomponent
component.{mode}.heroCarousel.tabletContentWidthFractioncomponent
component.{mode}.heroCarousel.cornerRadiuscomponent
component.{mode}.heroCarousel.borderColorcomponent
component.{mode}.heroCarousel.borderWidthcomponent
component.{mode}.heroCarousel.bodyFontFamilycomponent
component.{mode}.heroCarousel.displayFontFamilycomponent
component.{mode}.heroCarousel.eyebrowMaxLinescomponent
component.{mode}.heroCarousel.titleMaxLinescomponent
component.{mode}.heroCarousel.descriptionMaxLinescomponent
component.{mode}.heroVideo.contentTopPaddingcomponent
component.{mode}.heroVideo.mobileTitleFontSizecomponent
component.{mode}.heroVideo.mobileTitleFontWeightcomponent
component.{mode}.heroVideo.mobileDescFontSizecomponent
component.{mode}.heroVideo.mobileDescFontWeightcomponent
component.{mode}.heroVideo.tabletTitleFontSizecomponent
component.{mode}.heroVideo.tabletTitleLineHeightcomponent
component.{mode}.heroVideo.tabletTitleFontWeightcomponent
component.{mode}.heroVideo.tabletTitleMaxLinescomponent
component.{mode}.heroVideo.tabletDescFontSizecomponent
component.{mode}.heroVideo.tabletDescLineHeightcomponent
component.{mode}.heroVideo.tabletDescFontWeightcomponent
component.{mode}.heroVideo.tabletDescMaxLinescomponent
component.{mode}.heroVideo.eyebrowMaxLinescomponent
component.{mode}.heroVideo.iconSizecomponent
component.{mode}.heroVideo.displayFontFamilycomponent
component.{mode}.heroVideo.bodyFontFamilycomponent
primitives.opacity.scale25primitive
primitives.opacity.scale50primitive
primitives.opacity.scale75primitive
primitives.opacity.scale15primitive

Referenz

Mobile · Dark — full-bleed image with bottom gradient
Mobile · Dark — Full-bleed-Bild mit Verlauf am unteren Rand
Mobile · Light
Mobile · Light
Tablet · Dark — split-pane image left, content right
Tablet · Dark — Split-Pane Bild links, Inhalt rechts
Tablet · Light
Tablet · Light

Implementierungsreferenz

Android · 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.

Composition API annotations for HeroCard_Video / HeroCarouselContainer — badge, sponsor, eyebrow, headline, description, button, image, and color fields
HeroCard_Video Composition-API-Annotationen (deutsch). Click-Ziel aufgelöst über 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 CompositionCardTyp (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.

FeldTypHinweise
idStringStabile Slide-Kennung.
type"video"Diskriminator → HeroVideoCard.
titleStringTitel des Inhalts; max. 2 Zeilen im Split-Layout.
subtitleString?Eyebrow-/Serienzeile über dem Titel.
seriesTitleString?Serienname (im Showcase nicht gesetzt).
descriptionString?Langbeschreibung; epgInfo hat Vorrang, wenn beide gesetzt sind.
contentTypeString?Kategorie-Badge ("Serie", "Drama", …).
durationString?Längen-Label ("44 Min.").
epgInfoString?EPG-Kurzinfo; verdrängt description in der Beschreibungszeile.
imageUrlString?Hero-Bild; Layout-spezifisch zugeschnitten (Hochformat mobil, ~2.54:1 Tablet).
colorsCollectionExtractedColors?Vorab extrahierte Palette {light,dark}.{base,accent} — treibt Gradient-Scrim + Badge-Farben.
deepLinkString?Navigationsziel beim Tippen (z. B. video/19225).

HeroBibleVerseCard

Iteration 3 scope Hero-Variante

Eine 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

Variante A — Gradient-only · Mobile 3:4 Portrait · aspectRatioMobile = 0.75 · kein Bild im Payload
vertikaler Verlauf: gradientTop → gradientBottom (volle Karte) Verstext — dynamische Schriftgröße 16–48sp je nach Textlänge · vertikal zentriert [menu_book] Versangabe [share] Teilen [play_arrow] Anhören 1 2 3 4 5

Callout-Legende

  1. Shell — Karten-ChromeGeteilte HeroCardShell: Eckenradius 24dp heroCarousel.cornerRadius, 1dp Rahmen, Schatten nur im Dark Mode. Seitenverhältnis vom Karussell: aspectRatioMobile = 0.75.HeroBibleVerseCard.kt:98–103 · MediathekTokenModels.kt:695
  2. Vertikaler Verlauf — volle KartenflächeLight: gradientTopLight = Blue150 (#DEECF2) oben → gradientBottomLight = Blue300 (#66B0D9) unten. Dark: gradientTopDark = Blue800 (#10223A) oben → gradientBottomDark = Blue600 (#0D477B) unten (Dark zusätzlich über den Farbmodus-Manager transformiert). 094-Lift: jetzt Komponenten-Tokens (Quelle = Blue-Primitive-Aliase + Kotlin-Default + Merge), nicht mehr hartkodierte Blue*-Imports; NICHT aus der Slide-Palette.HeroBibleVerseCard.kt:110–123 · MediathekTokenModels.kt:227–230 · Theme.kt:770–773
  3. Verstext — vertikal zentriert, dynamische GrößeDiagramm Display-Familie, Schriftstärke Normal, Zeilenumbruch "Balanced". Schriftgröße logarithmisch interpoliert über die Zeichenlänge: Mobile 16–48sp (verseMinSizeMobile/verseMaxSizeMobile). Zeilenhöhe 115%→90% gegenläufig zur Größe. Wird im weight(1f)-Bereich oberhalb der Buttons zentriert.HeroBibleVerseCard.kt:126–135 · HeroSharedContent.kt:263–309, 62–82, 93–104
  4. Button-Zeile — [Versangabe] ←→ [Teilen][Anhören]Links: Versangabe-Pille mit [menu_book]-Icon (Label = secondaryCtaLabel ?: reference). Rechts: Teilen-Pille [share] + Anhören-Button (MediathekButton Secondary XSmall mit audioPlaybackState). SpaceBetween-Anordnung, Abstand innerhalb der rechten Gruppe gap2xs = 8dp.HeroSharedContent.kt:467–510
  5. Kollaps-Verhalten bei Audio aktivSobald der Zustand Playing/Paused ist, kollabieren Versangabe- und Teilen-Pille per Federanimation zu Icon-Kreisen; der Anhören-Button zeigt zusätzlich Fortschrittsbalken + Restzeit (siehe Verhalten).HeroSharedContent.kt:468, 317–371
Variante A — Gradient-only · Tablet ~2.54:1 Landscape · 1f / aspectRatioTablet (0.394) · horizontaler Verlauf
horizontaler Verlauf: gradientStart → gradientEnd Content-Spalte · tabletGradientOnlyContentWidthFraction = 0.5 Verstext — 11–18sp Portrait / 14–26sp Landscape [menu_book] Versangabe [play_arrow] Anhören [share] Teilen 1 2 3 4 5

Callout-Legende

  1. Shell — Landscape-SeitenverhältnisBreite/Höhe = 1f / aspectRatioTablet (0.394) ≈ 2.54:1, identisch zur HeroVideoCard.HeroBibleVerseCard.kt:249–252 · MediathekTokenModels.kt:696
  2. Horizontaler Verlauf — volle KartenflächeLight: gradientBottomLight = Blue300 (links) → gradientTopLight = Blue150 (rechts — umgekehrte Reihenfolge ggü. Mobile). Dark: gradientTopDark = Blue800gradientBottomDark = Blue600, farbmodus-transformiert. Dieselben Komponenten-Tokens wie die Mobile-Variante; zweifarbiger Verlauf ohne Stop-Tabelle.HeroBibleVerseCard.kt:268–276 · MediathekTokenModels.kt:227–230
  3. Content-Spalte — 50% KartenbreiteBreitenanteil tabletGradientOnlyContentWidthFraction = 0.5, gepolstert mit gapDefault = 16dp auf allen Seiten.HeroBibleVerseCard.kt:287–292 · MediathekTokenModels.kt:209
  4. Verstext — orientierungsabhängige GrößenbereicheTablet Portrait: 11–18sp (verseMinSizeTabletPortrait/verseMaxSizeTabletPortrait); Tablet Landscape: 14–26sp (verseMinSizeTabletLandscape/verseMaxSizeTabletLandscape). Vertikal zentriert im weight(1f)-Bereich.HeroSharedContent.kt:274–290 · MediathekTokenModels.kt:194–199
  5. Button-Zeile — drei Buttons linksbündig, niemals umbrechendReihenfolge auf Tablet: [Versangabe] [Anhören] [Teilen], alle MediathekButton Secondary XSmall, Abstand gap2xs = 8dp. Die Zeile darf über die Content-Spalte hinauswachsen bis zur Karten-Innenbreite (Einzeilen-Regel FR-006, siehe Verhalten).HeroSharedContent.kt:422–466 · HeroBibleVerseCard.kt:257–260, 303–314
Variante B — Bildhintergrund · Mobile 3:4 Portrait · Bild full-bleed · neu in Iteration 3
Bildebene (full-bleed) · 3:4 · w=600 · Fokus Y=0.35 ↓ Gradient-Overlay (8 Stops Dark / 7 Stops Light, Basis aus Palette) Verstext — dynamische Schriftgröße (16–48sp) [menu_book] Versangabe [share] Teilen [play_arrow] Anhören 1 2 3 4

Callout-Legende

  1. Bildebene — full-bleedZentriert beschnitten, Cross-Fade beim Laden. imgix-Anfrage: Seitenverhältnis 3:4, Breite 600, Fokuspunkt Y = 0.35 (höher als beim Video-Hero, 0.5).HeroBibleVerseCard.kt:91–96, 150–167
  2. Gradient-Overlay — am unteren Rand verankertIdentisches Stop-Schema wie die HeroVideoCard: Dark 8 Stops (0 → 0.08@α0.15 → 0.22@α0.45 → 0.38@α0.72 → 0.52@α0.90 → 0.62@α0.97 → 0.70 deckend → 1.0), Light 7 Stops (0 → 0.15@α0.05 → 0.35@α0.50 → 0.50@α0.82 → 0.60@α0.95 → 0.68 deckend → 1.0). Basisfarbe C aus der aufgelösten Slide-Palette. Textregion beginnt bei contentTopPadding = 140dp Innenabstand von oben; Seiten/unten gapDefault = 16dp.HeroBibleVerseCard.kt:175–203 · MediathekTokenModels.kt:190
  3. Verstext in der VerlaufsregionGleiche dynamische Schriftgröße wie Variante A (Mobile-Bereiche 16–48sp), linksbündig statt vertikal zentriert — der Text sitzt am unteren Kartenrand über dem Verlauf.HeroBibleVerseCard.kt:205–216 · HeroSharedContent.kt:107–173
  4. Button-Zeile — identisch zu Variante A[Versangabe] ←→ [Teilen][Anhören] mit demselben Kollaps-Verhalten bei aktivem Audio.HeroSharedContent.kt:215–258
Variante B — Bildhintergrund · Tablet ~2.54:1 Landscape · Split-Panel wie HeroVideoCard · neu in Iteration 3
Bildebene · tabletImageWidthFraction = 0.7 am nachfolgenden Rand verankert · 16:9 · w=1200 Gradient-Overlay · 50% Breite · deckend LINKS → transparent RECHTS Content-Spalte · 0.4 Verstext — vertikal zentriert [menu_book] Versangabe [play_arrow] Anhören [share] Teilen → Zeile darf bis zur Karten-Innenbreite wachsen (FR-006) 1 2 3 4 5 6

Callout-Legende

  1. Shell — Landscape-SeitenverhältnisWie Variante A: 1f / aspectRatioTablet ≈ 2.54:1.HeroBibleVerseCard.kt:249–252
  2. Bildebene — am nachfolgenden Rand verankertBreitenanteil heroBibleVerse.tabletImageWidthFraction = 0.7 (eigener Token, nicht der heroCarousel-Wert). imgix: 16:9, Breite 1200, Fokus Y = 0.35.HeroBibleVerseCard.kt:242–247, 317–342 · MediathekTokenModels.kt:206
  3. Gradient-Overlay — horizontal, am führenden RandBreitenanteil tabletGradientWidthFraction = 0.5. Stops: 0 deckend → 0.60 deckend (Plateau) → 0.70 @ α0.75 → 0.80 @ α0.50 → 0.90 @ α0.25 → 1.0 transparent. Basisfarbe C aus der aufgelösten Slide-Palette.HeroBibleVerseCard.kt:344–361 · MediathekTokenModels.kt:207
  4. Badge (konditional)Nur wenn slide.category != null — oben links, eingerückt um gapDefault = 16dp. Das aktuelle Pages-Mapping setzt für Vers-Slides keine Kategorie, daher im Default nicht sichtbar.HeroBibleVerseCard.kt:363–371 · CollectionRenderer.kt:161–176
  5. Content-Spalte — Verstext vertikal zentriertBreitenanteil tabletContentWidthFraction = 0.4, Padding gapDefault = 16dp. Verstext mit orientierungsabhängigen Größenbereichen (Portrait 11–18sp, Landscape 14–26sp), linksbündig, vertikal zentriert.HeroBibleVerseCard.kt:373–388 · MediathekTokenModels.kt:208
  6. Button-Zeile — Einzeilen-Regel (FR-006)Die drei Buttons dürfen über die 40%-Spalte hinauswachsen bis maxRowWidth = Karten-Innenbreite − 2 × gapDefault; Labels brechen niemals auf zwei Zeilen um (siehe Verhalten).HeroBibleVerseCard.kt:257–260, 390–401 · HeroSharedContent.kt:422–435

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

FeldTypHinweise
idStringStabile Slide-Kennung. PlaylistCollectionModels.kt:173.
type"bible_verse"Diskriminator → HeroBibleVerseCard. PlaylistCollectionModels.kt:171.
verseTextStringDer Verstext (wird als Slide-Titel gemappt); dynamische Schriftgröße nach Textlänge. Validierung: 15–180 Wörter (validateWordCount(), PlaylistCollectionModels.kt:184–187).
verseReferenceStringVersangabe ("Johannes 3,16"); zugleich Label des Versangabe-Buttons (Sekundär-CTA). PlaylistCollectionModels.kt:178 · CollectionRenderer.kt:169.
imageUrlString?Varianten-Schalter: gesetzt → Bildhintergrund-Variante; null/leer (und kein imageResId) → Gradient-only. PlaylistCollectionModels.kt:174 · HeroColorResolver.kt:55.
audioUrlString?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.
imageResIdInt?Nur für integrierte Demo-Assets; nicht Teil des Wire-Vertrags. PlaylistCollectionModels.kt:180.
gradientColorString?Hex-Fallback für die Verlaufsbasis (Auflösungsstufe 3 im Farb-Resolver). PlaylistCollectionModels.kt:181.
accentColorString?Hex-Akzentfarbe (Logo-/Akzent-Emission; auch im Share-Bild verwendet). PlaylistCollectionModels.kt:182.
colorsCollectionExtractedColors?Vorab extrahierte Palette {light,dark}.{base,accent} — höchste Priorität im Farb-Resolver; treibt Gradient-Scrim der Bild-Variante. PlaylistCollectionModels.kt:175.
deepLinkString?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).

TokenWertDTCGVerwendung
tabletTitleFontSize26spjaReserviert (Titel-Stil-Paar mit heroVideo); im Vers-Rendering nicht gelesen.
tabletTitleLineHeight28spjadito
tabletTitleFontWeightMediumjadito
tabletDescFontSize14spjadito
tabletDescLineHeight18spjadito
tabletDescFontWeightMediumjadito
scrimMobileLightAlpha1–40.05/0.50/0.82/0.95jaAlpha-Rampe des Bild-Scrims (Light, Mobile). Stop-POSITIONEN bleiben strukturelle Code-Konstanten (Alpha-only-Tuning-Regel). HeroBibleVerseCard.kt
scrimMobileDarkAlpha1–50.15/0.45/0.72/0.90/0.97jaAlpha-Rampe des Bild-Scrims (Dark, Mobile).
scrimTabletAlpha1–30.75/0.5/0.25jaAlpha-Rampe des horizontalen Tablet-Scrims.
imageAspectRatioMobile / imageWidthMobile3:4 / 600jaImgix-Fetch-Parameter (Mobile).
imageAspectRatioTablet / imageWidthTablet16:9 / 1200jaImgix-Fetch-Parameter (Tablet/Wide).
imageFocalPointY0.35jaFokuspunkt beider Bild-Requests.
verseMaxLinesMobile8jaFont-Scale-Sicherheitsnetz: ungebundene Vers-Layouts ellipsieren nach dieser Zeilenzahl.
contentTopPadding140dpjaOberer Innenabstand der Verlaufs-Textregion (Bild-Variante Mobile). HeroBibleVerseCard.kt:203
verseBaseSizeMobile36jaParameter der dynamischen Schriftgröße Mobile. HeroSharedContent.kt:127–129, 288
verseMinSizeMobile16jaUntergrenze Mobile (sp).
verseMaxSizeMobile48jaObergrenze Mobile (sp).
verseBaseSizeTabletLandscape22jaTablet Landscape. HeroSharedContent.kt:276–281
verseMinSizeTabletLandscape14jaUntergrenze Tablet Landscape (sp).
verseMaxSizeTabletLandscape26jaObergrenze Tablet Landscape (sp).
verseBaseSizeTabletPortrait16jaTablet Portrait. HeroSharedContent.kt:282–287
verseMinSizeTabletPortrait11jaUntergrenze Tablet Portrait (sp).
verseMaxSizeTabletPortrait18jaObergrenze Tablet Portrait (sp).
textLengthMin20jaUntere Klemmgrenze der Zeichenlänge; wird an calculateDynamicFontSize durchgereicht.
textLengthMax250jaObere Klemmgrenze — dito.
lineMinHeight115 (%)jaZeilenhöhe bei kleinster Schriftgröße; wird an calculateDynamicLineHeight durchgereicht.
lineMaxHeight90 (%)jaZeilenhöhe bei größter Schriftgröße — dito.
displayFontFamilyDiagrammjaVerstext-Schriftfamilie. HeroSharedContent.kt:166, 300
bodyFontFamilyInterjaReserviert für Sekundärtext.
tabletImageWidthFraction0.7jaBildbreite Tablet (Bild-Variante). HeroBibleVerseCard.kt:320
tabletGradientWidthFraction0.5jaVerlaufsbreite Tablet (Bild-Variante). HeroBibleVerseCard.kt:346
tabletContentWidthFraction0.4jaContent-Spalte Tablet (Bild-Variante). HeroBibleVerseCard.kt:375
tabletGradientOnlyContentWidthFraction0.5jaContent-Spalte Tablet (Gradient-only). HeroBibleVerseCard.kt:289
verseTextAlpha0.9jaNur im Legacy-Light-Pfad des Karussells verwendet. HeroCarousel.kt:596
buttonIconSize14dpjaIcon-Größe der Kollaps-Pille (CollapsingPillButton).
buttonCornerRadiusPercent50jaPillenform 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

FormfaktorMinMax
Mobile (beide Varianten)16sp48sp
Tablet Portrait11sp18sp
Tablet Landscape14sp26sp

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

Variantenwahl Gradient-only vs. Bildhintergrund

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

Audio-Zustandsmaschine (Anhören / Pausieren / Fortsetzen)

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 Kollaps-Choreografie der Buttonzeile

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

Tablet: Einzeilen-Regel der Buttonzeile (FR-006, Iteration 3)

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)

Versangabe-Button und Teilen

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

Witwen-Vermeidung

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 onClick gesetzt); 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öserEigenschaftDauerEasing
Bild dekodiert (Variante B)Alpha der Bildebene 0 → 1Plattform-Standard-Cross-Fadeease-out
Audio-Fortschritt ändert sichFüllstand des Fortschrittsbalkens300mstween 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-DruckPress-Feedback der Shellsiehe HeroCardShell
Modus-Wechsel (Light/Dark)Verlaufs-/Textfarbensofort (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", Commit e28327bf). Wireframes oben sind aus dem Quellcode abgeleitet; bei Verfügbarkeit der Screenshots gegenprüfen.
  • Implementierungslücke — Audio: audioUrl wird 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/Min wurde ENTFERNT — der Renderer liest die (jetzt vollständig serialisierte) verse{Base,Min,Max}Size*-Familie; (b) textLengthMin/Max und lineMinHeight/lineMaxHeight werden jetzt aus den Tokens an calculateDynamicFontSize/calculateDynamicLineHeight durchgereicht (keine Hardcode-Duplikate mehr); (c) die Kollaps-Pille konsumiert buttonIconSize und buttonCornerRadiusPercent. Weiterhin wahr: calculateDynamicFontSize nimmt einen baseSize-Parameter an, der im Funktionskörper nie verwendet wird — die verseBaseSize*-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 (maxLines aus der Boxhöhe bzw. verseMaxLinesMobile, TextOverflow.Ellipsis). Die Scrim-Alphas sind als scrim*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), der verseTextAlpha = 0.9 anwendet. 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 gegen BibleVerseHeroItem.

Implementierungsreferenz

Android · 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 variant

Eine 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 (Fallback imageUrl), Imgix ar=2:3, w=600.
  • Tablet / breit (Landscape): coverImageUrl (Fallback imageUrl), Imgix ar=16:9, w=1200.

HeroPosterCard.kt:41–88 · Dispatch: HeroCarousel.kt:332 (ContentType.POSTER)

Mobile (Portrait) · Default + Fallback 2:3 Mobile · 16:9 Tablet · kein Gradient, kein Text-Overlay
Bild · 2:3 Portrait (kein Text / kein Gradient) Default — image-only Titel Fallback — Bild fehlt 1 2

Callout-Legende

  1. Bild — formfaktor-abhängiger ZuschnittMobile: posterImageUrl (2:3, w=600); Tablet/breit: coverImageUrl (16:9, w=1200); Fallback-Asset imageUrl. Kein Gradient-Scrim, kein Text im Normalzustand. In der gemeinsamen HeroCardShell (Eckenradius, Border, Schatten).HeroPosterCard.kt:54–63,65
  2. Fallback-TitelNur bei imageLoadFailed || imageUrl.isEmpty(): zentrierter Titel im cardHero.fallbackTitle*-Font.HeroPosterCard.kt:71–88

Composition-API-Vertrag

FeldTypHinweise
type"poster"Diskriminator (kotlinx @SerialName("poster")).
imagePosterUrlString?Portrait-Zuschnitt (2:3) für Mobile.
imageCoverUrlString?Landscape-/Cover-Zuschnitt (16:9) für Tablet.
imageUrlString?Fallback-Asset für beide Formfaktoren (Single-Asset-Rückwärtskompatibilität).
titleStringNur als Fallback-Text gerendert.
deepLinkString?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

Mobile · image-only poster hero (portrait)
Mobil · Hell · image-only Poster-Hero (Portrait-Zuschnitt)

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.

FeldTypHinweise
idStringStabile Slide-Kennung.
type"poster"Diskriminator → HeroPosterCard (bild-only, kein Gradient/Text-Overlay).
titleStringAnzeigetitel (Accessibility/Tracking; nicht im Bild gerendert).
imageUrlString?Einzel-Asset-Fallback — aktuell das einzige Bildfeld, das der Android-Client liest.
imagePosterUrlString? (nur BFF)Portrait-Asset (3:4) für Mobile. Wird vom BFF geliefert, vom Android-Modell aber noch nicht ingestiert — siehe Gap-Hinweis.
imageCoverUrlString? (nur BFF)Landscape-Asset (16:9) für Tablet. Gleiche Einschränkung.
colorsCollectionExtractedColors?Palette (im Showcase nicht gesetzt).
deepLinkString?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 variant

Eine 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 eine http(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)

Mobile (Portrait) Identischer Aufbau wie HeroVideoCard · CTA-Icon [arrow_forward]
ANZEIGE Eyebrow Titel [arrow_forward] CTA 1 2 3 4 5

Callout-Legende

  1. Bild + Gradient-ScrimIdentisch zur HeroVideoCard: full-bleed-Bild in der HeroCardShell + token-definierter unterer Verlauf.HeroGenericCard.kt · HeroVideoCard.kt
  2. Badge (optional)Aus category (z. B. „Anzeige") · BtvBadge, oben-links im Padding.HeroVideoCard.kt (BtvBadge)
  3. EyebrowAus subtitle · einzeilig.RegularHeroContent · HeroSharedContent.kt
  4. Titel + BeschreibungAus title / description · adaptive Titelzeilen wie HeroVideoCard.HeroSharedContent.kt
  5. Primary-CTA — [arrow_forward], nicht [play_arrow]Beschriftung aus ctaLabel · Leading-Icon Icons.AutoMirrored.Filled.ArrowForward (Link, kein Video). Tap == Karten-Tap: beide rufen denselben onClick → öffnet deepLink (http(s) extern). Ein Secondary-CTA hätte eine eigene Aktion.HeroGenericCard.kt · HeroVideoCard.kt (onClick) · CollectionRenderer.kt (openExternalUrl)

Composition-API-Vertrag

FeldTypHinweise
type"generic"Diskriminator (kotlinx @SerialName("generic")).
titleStringHeadline.
subtitleString?Eyebrow über der Headline.
descriptionString?Beschreibungstext.
categoryString?Badge (z. B. „Anzeige").
ctaLabelString?Button-Beschriftung (z. B. „WhatsApp-Kanal beitreten").
deepLinkString?Die Ziel-URL. http(s) → öffnet extern.
imageUrlString?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; bei http(s) extern über den System-Handler.

Referenz

Mobile · generic hero — Anzeige badge, WhatsApp CTA with forward arrow
Mobil · Hell · Generic-Hero („Anzeige" · CTA „WhatsApp-Kanal beitreten" mit [arrow_forward])

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.

FeldTypHinweise
idStringStabile Slide-Kennung.
type"generic"Diskriminator → HeroGenericCard (Video-Look, beliebiges CTA-Ziel).
titleStringTitel ("Bibel TV jetzt auf WhatsApp").
subtitleString?Sekundäre Headline.
descriptionString?Beschreibungstext unter dem Titel.
categoryString?Badge-Label ("Anzeige").
ctaLabelString?Text des Primär-CTA ("WhatsApp-Kanal beitreten"); Icon: Pfeil-nach-rechts statt Play.
imageUrlString?Hero-Bild.
colorsCollectionExtractedColors?Palette (optional).
deepLinkString?CTA-Ziel; http(s)-URLs öffnen extern (Browser/App), alles andere navigiert intern.

HeroLiveStreamSlide

Iteration 3 scope Hero-Variante

Ein 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. 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 Alpha heroLive.borderColorAlpha = 0.15, Schlagschatten nur im Dark Mode. HeroLiveStreamSlide.kt:155–183 · MediathekTokenModels.kt:243, 254
  2. 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. 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. 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 Token semantic.{mode}.broadcast.live aufgelöst (siehe Token-Abschnitt). Mobile: oben links über dem Video, eingerückt um paddingDefault. Tablet: oben in der Info-Spalte. HeroLiveStreamSlide.kt:195–198, 291–301 · BtvBadge.kt:45–51, 73–74
  5. 5. Content-Stack — vertikaler Stack:
    1. 5a. Sendername + ggf. Serienname ("Bibel TV · {Serie}"), einzeilig. HeroLiveStreamSlide.kt:377–385
    2. 5b. Titel der laufenden Sendung — Episodentitel falls vorhanden, sonst Sendungstitel, max. heroLive.programTitleMaxLines = 2 Zeilen mit Ellipsis. HeroLiveStreamSlide.kt:366–374, 390–399
    3. 5c. Fortschrittszeile — Balken (Track + Akzent-Füllung) plus Zeitspanne "HH:mm – HH:mm". HeroLiveStreamSlide.kt:405–433
    4. 5d. Beschreibung — Episodentext falls vorhanden, sonst der längste EPG-Texteintrag; mit Ellipsis, füllt den Restraum. HeroLiveStreamSlide.kt:147–153, 436–447
Mobile gestapelt · Video-Bereich 16:9 (cardLandscape.aspectRatio = 1.7778) · Inhalt füllt den Rest
Stream-Vorschau (animiertes WebP) channel.previewUrl · 16:9 [play_arrow] LIVE Sendername · "Bibel TV · Serie" Titel der laufenden Sendung (max. 2 Zeilen) 20:15 – 21:00 Beschreibung (Episodentext, mit Ellipsis, füllt den verbleibenden Raum) 1 2 3 4 5 6 7 8

Mobile Callout-Legende

  1. Äußerer Container — Karten-ChromeEckenradius heroLive.cornerRadius = 24dp · Hintergrund elevierte Fläche (Dark #1A1A1A / Light #FFFFFF) · 0.5dp-Rahmen primäre Textfarbe @ Alpha heroLive.borderColorAlpha = 0.15 · Schatten elevationHero = 16dp nur Dark · Außenabstand gap2xs = 8dp rundumHeroLiveStreamSlide.kt:65–72, 155–183 · MediathekTokenModels.kt:243, 254
  2. Stream-Vorschau — 16:9, animiertSeitenverhältnis cardLandscape.aspectRatio = 1.7778 · obere Ecken auf radius.xxl = 24dp geclippt · animierte WebP-Quelle aus channel.previewUrl (hochauflösende Variante, "-240.webp" → ".webp") · center-cropHeroLiveStreamSlide.kt:97–100, 257–262, 274–282
  3. Play-Button-OverlayKreis 56dp (iconSize.{bp}.xxxl) · Hintergrund Overlay #80000000 · Icon [play_arrow] 32dp (iconSize.lg) in semantic.{mode}.text.onColor · Beschriftung "Live ansehen"HeroLiveStreamSlide.kt:326–346
  4. Live-Badge — BtvBadge mit broadcast.livePrimitive BtvBadge(color = "semantic.color.broadcast.live", text = "Live") · Farbe aufgelöst über den NEUEN semantischen Token semantic.{mode}.broadcast.live = primitives.colors.brand.liveRed = #E3000B (vor Spec 094 T012 hartkodiert) · oben links, eingerückt um paddingDefaultHeroLiveStreamSlide.kt:291–301 · BtvBadge.kt:73–74 · SemanticColors.kt:192, 432, 653 · Color.kt:26
  5. Sendername + Serie"{Kanal} · {Serie}" wenn ein Episodentitel existiert, sonst nur der Kanalname · typography.bodySmall = 14sp · Medium · heroLive.bodyFontFamily (Inter) · primäre Textfarbe @ opacity.scale80 · max. heroLive.channelNameMaxLines = 1HeroLiveStreamSlide.kt:366–385 · MediathekTokenModels.kt:245, 249
  6. Titel der laufenden SendungEpisodentitel falls vorhanden, sonst Sendungstitel; Fallback-Text "Jetzt live" · typography.heroTitle (26sp mobile / 32sp tablet) · Medium · heroLive.displayFontFamily (Diagramm) · Zeilenhöhe lineHeightHeroTitle (28/34sp) · max. heroLive.programTitleMaxLines = 2, EllipsisHeroLiveStreamSlide.kt:366–374, 390–399 · MediathekTokenModels.kt:246, 248
  7. Fortschrittszeile — Balken + ZeitspanneTrack: Höhe gap3xs = 4dp · auf radius.sm geclippt · primäre Textfarbe @ opacity.scale15. Füllung: Breitenanteil = Sendungsfortschritt, Farbe Akzent (accentGenres #FFA014, durch den Farbmodus transformiert). Zeitlabel "HH:mm – HH:mm" · typography.label = 12sp · Medium · primäre Textfarbe @ opacity.scale60 · Abstand Balken→Label gapSm = 12dp. Aktualisierung sekündlich (siehe Verhalten).HeroLiveStreamSlide.kt:76, 405–433
  8. BeschreibungepisodenText bevorzugt, sonst der längste nicht-leere EPG-Texteintrag · typography.bodySmall = 14sp · Normal · heroLive.bodyFontFamily (Inter) · primäre Textfarbe @ opacity.scale70 · Zeilenhöhe lineHeightHeroSub (19/22sp) · füllt den Restraum, EllipsisHeroLiveStreamSlide.kt:147–153, 436–447
Tablet horizontale Teilung · Info-Spalte tabletInfoWidthFraction = 0.3 links · Video füllt die restlichen 70 %
Info-Spalte · 0.3 LIVE Sendername · Serie Titel (max. 2 Zeilen) 20:15 – 21:00 Beschreibung (Restraum) Stream-Vorschau / Live-Player · 70 % [play_arrow] 1 2 3 4 5

Tablet Callout-Legende

  1. Äußerer ContainerDieselben Chrome-Werte wie Mobile (Radius 24dp, 0.5dp-Rahmen, Dark-only-Schatten). Die Höhe wird vom übergeordneten Karussell-Slot bestimmt.HeroLiveStreamSlide.kt:155–183
  2. Info-Spalte — führende 30 %Breitenanteil heroLive.tabletInfoWidthFraction = 0.3 (Kotlin-Default; nicht in tokens.json serialisiert) · Innenabstand paddingDefault · enthält Live-Badge oben und den vertikal zentrierten Content-StackHeroLiveStreamSlide.kt:184–217 · MediathekTokenModels.kt:244
  3. Live-BadgeIdentisch zu Mobile: BtvBadge("semantic.color.broadcast.live", "Live"), oben in der Info-SpalteHeroLiveStreamSlide.kt:195–198
  4. Video-Bereich — nachfolgende 70 %Füllt die Restbreite und die volle Höhe · Endkanten auf radius.xxl = 24dp geclippt · Vorschau bzw. aktivierter Live-PlayerHeroLiveStreamSlide.kt:219–251
  5. Play-Button-OverlayWie Mobile: 56dp-Kreis, [play_arrow] 32dp, "Live ansehen"HeroLiveStreamSlide.kt:246–249, 326–346
Mini-Timeline (definiert, derzeit nicht verdrahtet) MiniTimelineItem · ein Eintrag pro Programm: Startzeit, Fortschrittsbalken, Titel

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).

ElementWertQuelle
Startzeit-Labeltypography.overline = 10sp · Medium · heroLive.displayFontFamily (Diagramm) · aktuell: volle Textfarbe, sonst @ opacity.scale50HeroLiveStreamSlide.kt:481–487
FortschrittsbalkenHöhe programGuideCard.progressHeight = 3dp · Radius programGuideCard.progressCornerRadius = 1.5dp · Track @ opacity.scale10/15 (Light/Dark) · Füllung nur bei isCurrent in AkzentfarbeHeroLiveStreamSlide.kt:474–506
Programmtiteltypography.caption = 11sp · SemiBold wenn aktuell, sonst Normal · max. heroLive.miniTimelineTitleMaxLines = 2, EllipsisHeroLiveStreamSlide.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).

FeldTypHinweise
idStringStabile Slide-Kennung (Beispiel: "live-tv-hero").
type"live_tv"Diskriminator → HeroLiveStreamSlide. PlaylistCollectionModels.kt:220 · playlist.ts:174
channelNameStringAnzeigename des Senders ("Bibel TV"). Erscheint in der Sendername-Zeile.
channelChannelInfo? = { 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
streamUrlString?HLS-Stream-URL auf Item-Ebene; das Rendering nutzt channel.streamUrl.
imageUrlString?Stream-Vorschaubild (WebP). Fallback-Kette im Adapter: imageUrl ?: channelLogoUrl ?: "". CollectionRenderer.kt:206
channelLogoUrlString?Sender-Logo als Bild-Fallback.
currentProgram / nextProgramEPGProgram? = { 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
colorsCollectionExtractedColors?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
deepLinkString?Navigationsziel (Beispiel: "live/13").
epgItem / nextEpgItemEPGProgramItem? (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

EigenschaftMobileTabletTokenQuelle
Eckenradius Karte24dp24dpcomponent.{mode}.heroLive.cornerRadiustokens.source.json · HeroLiveStreamSlide.kt:166, 176
Rahmen0.5dp · primäre Textfarbe @ α 0.15semantic.spacing.{bp}.dividerHeight + heroLive.borderColorAlphaHeroLiveStreamSlide.kt:65–71, 178–182 · MediathekTokenModels.kt:254
Schatten (nur Dark)Elevation 16dp · Overlay-Farbe #80000000semantic.spacing.{bp}.elevationHeroHeroLiveStreamSlide.kt:159–171
Außenabstand Slide8dp horizontal + vertikalsemantic.spacing.{bp}.gap2xsHeroLiveStreamSlide.kt:157
Video-Seitenverhältnis16:9 (1.7778)— (füllt 70 % × volle Höhe)component.{mode}.cardLandscape.aspectRatioHeroLiveStreamSlide.kt:261
Breitenanteil Info-Spalte1.0 (gestapelt)0.3heroLive.tabletInfoWidthFraction (Kotlin-Default, nicht in tokens.json)MediathekTokenModels.kt:244 · HeroLiveStreamSlide.kt:190
Innenabstand Content16dp20dpsemantic.spacing.{bp}.paddingDefaultHeroLiveStreamSlide.kt:193, 295, 317
Play-Overlay Kreis / Icon56dp / 32dpsemantic.iconSize.{bp}.xxxl / .lgHeroLiveStreamSlide.kt:333, 343
Sendername Schriftgröße14sp · Medium · Intersemantic.typography.{bp}.bodySmall + heroLive.bodyFontFamilyHeroLiveStreamSlide.kt:379–381
Titel Schriftgröße / Zeilenhöhe26sp / 28sp32sp / 34spsemantic.typography.{bp}.heroTitle / .lineHeightHeroTitle + heroLive.displayFontFamilyHeroLiveStreamSlide.kt:392–398
Titel max. Zeilen2heroLive.programTitleMaxLinesMediathekTokenModels.kt:246
Fortschrittsbalken Höhe4dp · Radius radius.smsemantic.spacing.{bp}.gap3xsHeroLiveStreamSlide.kt:412–413
Zeitlabel Schriftgröße12sp · Mediumsemantic.typography.{bp}.labelHeroLiveStreamSlide.kt:428
Beschreibung Schriftgröße / Zeilenhöhe14sp / 19sp14sp / 22spsemantic.typography.{bp}.bodySmall / .lineHeightHeroSubHeroLiveStreamSlide.kt:440, 445
Vertikale Abstände im StackName→Titel 2dp · Titel→Fortschritt 8dp · Fortschritt→Beschreibung 6dpgap4xs / gap2xs / gap2_5xsHeroLiveStreamSlide.kt:387, 402, 437

Farb-Token broadcast.live (neu — Spec 094 T012)

TokenDarkLightQuelle
semantic.{mode}.broadcast.live#E3000B#E3000Btokens.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-Farbe broadcast.live ist in beiden Modi identisch. HeroLiveStreamSlide.kt:65–72, 159–171
Nicht selektierter Slide
isSelected = false pausiert die Sichtbarkeit des aktivierten Players (kein Audio im Hintergrund), die Vorschau bleibt. HeroLiveStreamSlide.kt:231, 270

Verhalten

EPG-Polling — alle 30 Sekunden

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

Fortschrittsbalken — sekündliche Aktualisierung

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

Stream-Aktivierung

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

Keine Hintergrund-Färbung der Seite

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

Titel-/Beschreibungs-Ableitung

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 #E3000B mit 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öserEigenschaftDauerEasing
Stream-Vorschauanimiertes WebP (kontinuierlich)
Fortschritts-TickBalkenbreitesofort, 1s-Takt (kein Tween)
EPG-RefreshTextinhaltesofort (Re-Composition)
Stream-AktivierungVorschau → Playersofortiger 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/60tabletInfoWidthFraction = 0.3 ist der Kotlin-Default (MediathekTokenModels.kt:244) und wird weder in tokens.source.json noch im Live-Payload übersteuert. Falls 40/60 die Design-Absicht ist: Token in tokens.json aufnehmen und Wert ändern — nicht im Code hartkodieren.
  • Mini-Timeline ohne AufrufstelleMiniTimelineItem (HeroLiveStreamSlide.kt:457–521) und der schedule-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 #FFFFFF usw., design/tokens/DesignTokens.kt:28–34) statt aus semantic.{mode}.background.* / text.*. Für andere Plattformen gelten die semantischen Tokens als Vertrag.
  • Drift (Wire-Modell)epgItem / nextEpgItem existieren nur im Android-Modell, nicht im BFF-Typ (siehe Kontrakt-Tabelle).
  • Verifiziertbroadcast.live-Token (T012), Font-Family-Tokens heroLive.bodyFontFamily = Inter / displayFontFamily = Diagramm (T011, MediathekTokenModels.kt:248–249), Polling- und Tick-Intervalle, 16:9-Video auf Mobile, 0.3-Split auf Tablet.

Implementierungsreferenz

Android · 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).

ButtonGroupSection

Umfang Iteration 1 Section Adaptiv

Vollbreiter Abschnitt, der eine horizontale Reihe von Navigationsaktionen unmittelbar unter dem HeroCarousel rendert. Zwei Render-Modi werden ausschließlich aus der Composition-Payload dispatched: ein Multi-Item-Modus (eine horizontale Reihe kompakter sekundärer Pillen mit einem messungsbasierten "Mehr"-Overflow-Trigger) und ein Single-Item-Modus (ein einzelner vollbreiter primärer CTA — meist "Die Bibel erkunden", aber auch die KI-Verlaufs-Pille "Bibelaufschlag mit KI"). Der Abschnitt ist die primäre Navigations-Übergabe des Home-Bildschirms nach dem Hero.

Visuelle Spezifikation

Aufbau

Vollbreiter Call-to-Action-Streifen, gerendert zwischen dem Hero und dem ersten Row-Abschnitt auf dem Home-Bildschirm. Zwei Render-Modi: einzelner primärer CTA (der häufige Fall — z. B. "Die Bibel erkunden" mit KI-Verlauf) oder eine Flex-Reihe sekundärer Chips mit einem Overflow-"Mehr"-Dropdown, wenn sie nicht passen.

Single-Item-Modus — vollbreiter primärer CTA items.size == 1 · heute auf Home am häufigsten ("Die Bibel erkunden")
Section-Container · horizontal gepolstert durch DesignTokens.Spacing.pageX [auto_awesome] Die Bibel erkunden Seitenrand-Padding 1 2 3

Single-Item Callout-Legende

  1. Section-ContainerVollbreit · horizontales Padding DesignTokens.Spacing.pageX auf beiden SeitenButtonGroupSection.kt:79
  2. Primärer CTA — vollbreitEntweder ProtoBibleButton mit KI-Verlauf (wenn item.style == "button_bibelaufschlag") oder MediathekButton Primary Medium. Vorangestelltes Icon ist für die KI-Variante standardmäßig AutoAwesome.ButtonGroupSection.kt:75–106
  3. Seitenrand-PaddingDesignTokens.Spacing.pageX — das globale Home-Content-Padding · 16dp Mobile / 32dp Tablet · entspricht views.home.layout.contentPaddingHorizontal{Mobile,Tablet}ButtonGroupSection.kt:79, 93
Default-Modus — mehrere Chips, alle passen items.size > 1 · alle Chips schmaler als verfügbare Breite · "Mehr" nicht angezeigt
Section-Container · pageX horizontales Padding Bibel Live-Streams Lesepläne Podcasts kein Overflow-Trigger, wenn alle passen SubcomposeLayout führt dennoch beide Durchläufe aus — Durchlauf 2 ermittelt einfach visibleCount == items.size 1 2

Default-Modus Callout-Legende

  1. Alle Chips — Secondary Small ButtonsGleiches Rendering pro Chip wie im Multi-Item-Overflow-Modus (MediathekButton Secondary Small mit Per-Item-Leading-/Trailing-Icons). Inter-Chip-Abstand aus DesignTokens.Spacing.gapMd.ButtonGroupSection.kt:152–165
  2. "Mehr" weggelassencalculateOverflow() liefert visibleCount = items.size, wenn die Summe der gemessenen Chip-Breiten + gap × (count − 1)availableWidth ist. Das "Mehr"-Composable wird einfach nicht platziert. Keine Dropdown-Affordance, kein Chevron.ButtonGroupSection.kt:33–62 (calculateOverflow) · 115–135 (SubcomposeLayout)
Multi-Item-Modus — Flex-Chips + "Mehr"-Overflow items.size > 1 · Chips überlaufen verfügbare Breite · "Mehr" erscheint · SubcomposeLayout Zwei-Pass-Messung
Bibel Live-Streams Lesepläne Mehr [keyboard_arrow_down] …ausgeblendete Chips werden in "Mehr" zusammengefasst SubcomposeLayout: Durchlauf 1 misst alle Chips + "Mehr"; Durchlauf 2 wählt sichtbare Anzahl via calculateOverflow() 1 2 3

Multi-Item Callout-Legende

  1. Sichtbare Chips — Secondary Small ButtonsMediathekButton Secondary Small mit Leading-/Trailing-Icons aus dem Item · spacedBy DesignTokens.Spacing.gapMd zwischen ChipsButtonGroupSection.kt:152–165
  2. "Mehr"-Overflow-DropdownErscheint nur, wenn visibleCount < items.size. Nachgestelltes Icon KeyboardArrowDown. Ausgeblendete Chips werden bei Tippen als DropdownMenuItems in einem DropdownContainer gerendert.ButtonGroupSection.kt:138–144 (Mehr) und darunter für Dropdown-Rendering
  3. Zwei-Pass-Messung (SubcomposeLayout)Durchlauf 1: jedes Item UND den "Mehr"-Button bei ihren bevorzugten Breiten messen. Durchlauf 2: calculateOverflow(itemWidths, availableWidth, moreWidth, gap) liefert den visibleCount zurück, der ohne Überlauf passt. Items jenseits von visibleCount landen im Dropdown.ButtonGroupSection.kt:33–62 (calculateOverflow) · 115–135 (SubcomposeLayout)

Verwendete Button-Varianten (Querverweis)

ButtonGroupSection definiert kein eigenes Button-Styling. Sie komponiert existierende Design-System-Buttons (ProtoBibleButton / MediathekButton) und wählt nur bestimmte Varianten aus. Die vollständige Button-Spezifikation — Größen, Typen, Funktionen, Farbschemata, Hit-Target-Aufpolsterung, Press-Feedback, Light-/Dark-Theming — befindet sich im Abschnitt ProtoBibleButton. Diese Tabelle listet nur die Varianten auf, die dieser Abschnitt instanziiert.

ModusComposableButtonTypeButtonSizeButtonFunctionBreite
Single — KI ("Bibelaufschlag")ProtoBibleButtonPrimaryMedium (40dp)AI (Verlauf-Füllung)fillMaxWidth()
Single — regulärer CTAMediathekButtonPrimaryMedium (40dp)DefaultfillMaxWidth()
Default / Multi — jeder ChipMediathekButtonSecondarySmall (32dp)Defaultintrinsisch (wrap content)
"Mehr"-Overflow-TriggerMediathekButtonSecondarySmall (32dp)Defaultintrinsisch

Branch-Auswahl: item.style == "button_bibelaufschlag" → KI-Variante; items.size == 1 & nicht KI → Single regulär; andernfalls → Chip-Reihe. Siehe ButtonGroupSection.kt:75–106.

Icon-Button-Größen (Referenz)

Neben den Text-Button-Größen (ButtonSize: XSmall 28dp · Small 32dp · Medium 40dp) führt das Button-System eine eigene Größenskala für Icon-Buttons (IconButtonSize). Iteration 3 ergänzt die Stufen XL (80dp) und XXL (96dp); beide sind als semantische Tokens semantic.buttons.*.icon.{xl,xxl} definiert — Aliasse auf primitives.spacing.Space20 (80) bzw. primitives.spacing.Space24 (96).ProtoBibleButton.kt:109–115 · tokens.source.json semantic.buttons.*.icon

IconButtonSizeKantenlängeTokenQuelle
Small32dpProtoBibleButton.kt:109–115
Medium36dpProtoBibleButton.kt:109–115
Large56dpProtoBibleButton.kt:109–115
XL80dpsemantic.buttons.*.icon.xlprimitives.spacing.Space20ProtoBibleButton.kt:109–115
XXL96dpsemantic.buttons.*.icon.xxlprimitives.spacing.Space24ProtoBibleButton.kt:109–115

Die parallele IconOnlySize-Skala (rahmenlose Icon-Glyphen) führt dieselben XL-/XXL-Stufen (80/96dp).ProtoBibleButton.kt:117–124

Verwendete Icons

IconWoQuelle
MaterialSymbols.AutoAwesome (Sparkle, Material Symbols Rounded)Vorangestelltes Icon des Single-Item-KI-Buttons. Verwendet, wenn item.leadingIcon bei einem KI-Style-Item null ist — d. h. Default-Sparkle für die KI-Variante. Liefert die Payload ein anderes vorangestelltes Icon, wird stattdessen dieses verwendet.ButtonGroupSection.kt (Single-Item KI-Branch)
MaterialSymbols.KeyboardArrowDown (Down-Chevron, Material Symbols Rounded)Nachgestelltes Icon des "Mehr"-Overflow-Buttons — signalisiert Dropdown-Affordance. Fest codiert am "Mehr"-Button, nicht Payload-gesteuert.ButtonGroupSection.kt:138–144
Per-Item-Icons (Payload-gesteuert)Jedes NavigationButtonItem kann optionale leadingIcon / trailingIcon aus der Composition-Payload tragen. Wird auf dem entsprechenden Chip sowohl im Default- als auch im Overflow-Modus gerendert.NavigationButtonItem-Modell

Light- vs. Dark-Modus

ButtonGroupSection selbst hat keine Light-/Dark-Verzweigung — sie reicht alles an die Buttons durch. Sämtliches Theming (Hintergrundfüllungen, Label-Farben, KI-Verlauf-Stops, Rahmenfarben, Press-State-Tints, Chevron-Farbe) wird innerhalb von ProtoBibleButton / MediathekButton aufgelöst, indem aus LocalSemanticColors.current und den component.{mode}.protoBibleButton.*-Token-Branches gelesen wird. Implementierer sollten verifizieren, dass die Button-Komponente in beiden Schemata korrekt rendert; der darüberliegende Abschnitt benötigt keine separaten Light-/Dark-Wireframes.

Abmessungen

EigenschaftWertTokenQuelle
Seitenrand-Padding16dp Mobile / 32dp TabletDesignTokens.Spacing.pageX (auch views.home.layout.contentPaddingHorizontal{Mobile,Tablet})ButtonGroupSection.kt:79, 93
Inter-Chip-AbstandTokenDesignTokens.Spacing.gapMdButtonGroupSection.kt:107
ButtonGroupTokens.spacingTokencomponent.{mode}.buttonGroup.spacingMediathekTokenModels.kt
ButtonGroupTokens.cornerRadiusTokencomponent.{mode}.buttonGroup.cornerRadiusMediathekTokenModels.kt
ButtonGroupTokens.heightTokencomponent.{mode}.buttonGroup.heightMediathekTokenModels.kt
Primary-Button (Single-Modus)Medium-Größecomponent.protoBibleButton.primary.* in Medium-SkalaProtoBibleButton.kt
Sekundärer Chip (Multi-Modus)Small-Größecomponent.{mode}.protoBibleButton.secondary.* in Small-SkalaProtoBibleButton.kt

Zustände

Empty
items.isEmpty() → frühzeitige Rückkehr, nichts gerendert. Abschnitt im Layout unsichtbar.
Single-Item
Vollbreiter Primary-Medium-Button. KI-Verlauf-Variante, wenn der Style-Hinweis passt.
Default (Multi-Item, alle passen)
Alle Chips in einer Reihe sichtbar. calculateOverflow liefert visibleCount == items.size. "Mehr" nicht gerendert, kein Chevron, keine Dropdown-Affordance.
Multi-Item (überläuft)
Erste N Chips sichtbar (berechnet durch calculateOverflow), "Mehr"-Trailing-Chip mit KeyboardArrowDown-Chevron öffnet die übrigen in einem Dropdown.
Dropdown geöffnet
DropdownContainer erscheint unter "Mehr" mit den ausgeblendeten Items als DropdownMenuItems. Wichtig: Das Dropdown ist kein natives Material-Default-Dropdown — es ist das projekteigene DropdownContainer-Primitiv (Material 3 DropdownMenu mit Token-gesteuerten Overrides, siehe gestyltes Dropdown unten).
Chip gedrückt
Standard-ProtoBibleButton-Press-Feedback bei opacity.scale15-Tint.

Verhalten

  • Items-Quelle — servergesteuert via der NavigationButtonItem-Liste in der Composition-Payload. Jedes Item hat label, destination (Deep-Link-Route), optionale leadingIcon / trailingIcon, optionalen style-Hinweis.
  • KI-Verlauf-Routing — wenn item.style == "button_bibelaufschlag", verwendet der Single-Item-Branch ProtoBibleButton mit function = ButtonFunction.AI und gibt ihm die Accent-→-Primary-Verlauf-Füllung statt des Standard-Solid-Hintergrunds. Der Verlauf wird unter einem festen Winkel von semantic.ai.gradient.angleDeg = 135° (CSS-Konvention: oben-links → unten-rechts) gerendert — unabhängig vom Seitenverhältnis des Buttons. Wichtig (Android-Implementierungsdetail): die Standard-Compose-Brush.linearGradient(colors) verwendet Offset.Zero → Offset.Infinite, was den Verlauf entlang der Diagonale der Bounding-Box zieht. Auf einem breiten Single-Item-CTA (≈360 × 40dp) flacht das auf ~6° ab und wirkt fast horizontal. Wir verwenden stattdessen die projekteigene AngledLinearGradient ShaderBrush (ProtoBibleButton.kt:182–215), die die Gradient-Linie aus dem Mittelpunkt der Bounding-Kreis-Diagonale berechnet und so den 135°-Winkel konstant hält. iOS: LinearGradient(gradient: Gradient(colors: [...]), startPoint: .topLeading, endPoint: .bottomTrailing). Web: background: linear-gradient(135deg, …).
  • Tipp (regulärer Button) — ruft onNavigate(item.destination) auf. Routing wird vom übergeordneten Bildschirm gehandhabt.
  • Tipp (AI-Button) — verbindlicher Vertrag — Der AI-Button (jeder Button mit function = ButtonFunction.AI) MUSS auf Komponenten-Ebene so verdrahtet sein, dass er die KI-Bibelaufschlag-Modal öffnet. Diese Modal existiert bereits in der Produktions-App und ist NICHT Teil des Iteration-1-Lieferumfangs zum Neubau — die Agentur erbt sie. Wichtig: Im aktuellen Android-Quellcode ist onClick des AI-Buttons in ButtonGroupSection.kt:77 noch nicht mit der Modal verdrahtet (delegiert generisch an onNavigate(item.destination)). Das ist ein bekannter Implementierungs-Gap, der vor dem Iteration-1-Launch geschlossen werden muss. Vertrag für iOS/Web: der AI-Button-Klick MUSS — unabhängig vom item.destination-Wert in der Payload — die KI-Bibelaufschlag-Modal aufrufen.
  • Layout-Recompose — Overflow wird bei jeder Constraint-Änderung neu berechnet (Orientierung, Multi-Window-Resize), sodass Chips elegant umfließen.

Gestyltes "Mehr"-Dropdown

Das "Mehr"-Overflow-Dropdown ist nicht das native Material-Default-Dropdown. Es ist das projekteigene DropdownContainer-Primitiv (packages/android/.../ui/components/primitives/BtvDropdown.kt:305–340), das die Material-3-DropdownMenu-Struktur mit Token-gesteuerten Overrides verwendet. Die folgenden Eigenschaften sind explizit überschrieben (alle anderen Material-Defaults bleiben in Kraft):

EigenschaftWertQuelle
containerColorsemanticColors.background.canvas × semantic.effect.backdropBlur.tintAlphaMobile (0.85) — gleicher Frosted-Glass-Tint wie der MenuDrawer-PanelBtvDropdown.kt:316–322
shadowElevationThemeDesignTokens.spacing.gap2xsBtvDropdown.kt:332
shape (Ecken-Radius)RoundedCornerShape(ThemeDesignTokens.radius.md)BtvDropdown.kt:333
minWidthtokens.btvDropdown.minWidth (komponentenspezifisches Token)BtvDropdown.kt:335 · component.btvDropdown.minWidth
Trigger-ButtonMediathekButton mit type = ButtonType.Secondary, trailingIcon = MaterialSymbols.KeyboardArrowDownBtvDropdown.kt:319–326
Menü-ItemsStandard DropdownMenuItems, deren Text/Icon-Farben über MenuItemColors mit semantischen Tokens überschrieben werden (siehe BtvDropdown.kt:290–298 für den Pattern in den anderen BtvDropdown-Varianten)BtvDropdown.kt

iOS / Web Implementierungshinweis: bei Reimplementierung MUSS das Dropdown denselben visuellen Vertrag erfüllen — angehobene Surface-Hintergrundfarbe (nicht system-default), Token-gesteuerter Ecken-Radius (mittlerer Radius statt nativem System-Radius), explizite Mindestbreite, und ein Schatten in Token-Höhe. Die Material-3-Native-Defaults für DropdownMenu (System-Hintergrund, eckige/scharfe Ecken auf älteren Systemen, kein Mindestbreite-Constraint) reichen NICHT aus — sie würden im Light-Mode mit weißem System-Hintergrund auf weißer Page-Surface verschmelzen.

Visuell — Dropdown geöffnet
Mehr Gottesdienste TV Programm Musik Kinder Dokumentationen Dark · surfaceRaised = #252F35 · radius.md · drop shadow Mehr Gottesdienste TV Programm Musik Kinder Dokumentationen Light · surfaceRaised = White · radius.md · weicher Schatten

Schematische Wireframes — die Schatten- und Border-Werte verifizieren beim Implementieren gegen BtvDropdown.kt:331–334 und semantic.{mode}.background.surfaceRaised. Die Trennlinien zwischen Menü-Items kommen vom Material-3-DropdownMenu-Default (subtile onSurface-Variante).

Barrierefreiheit

  • Single-Item-Branch: native Button-Semantik (role button, Label = item.label).
  • Multi-Item-Branch: jeder Chip ist ein separater button; "Mehr" ist ebenfalls button, erweiterbar (aria-expanded entsprechend Web).
  • Dropdown-Items: jedes DropdownMenuItem ist ein separater Fokusstop; Material 3 handhabt Tastatur-Navigation (Pfeiltasten hoch/runter) standardmäßig.
  • Hit-Targets ≥ 48dp über die Standard-Aufpolsterung der Button-Komponenten.

Audit-Notizen

Keine Regressionen festgestellt. Zwei-Pass-SubcomposeLayout-Messung ist korrekt; calculateOverflow ist eine reine Funktion mit sinnvollen Randfällen (empty, single-item, full-fit, partial-fit).

Beobachtung zu Iteration 1: Home rendert heute nur eine Single-Item-ButtonGroupSection ("Die Bibel erkunden" mit KI-Verlauf). Der Multi-Item-Branch ist vollständig implementiert, wird aber derzeit von der produktiven Composition-Payload nicht ausgeübt. Implementierer sollten beide Branches funktionsfähig halten.

Zustände

Multi-Item-Pillen (pro Item, secondary Mediathek-Schema):

Default
Hintergrund = component.{mode}.button.secondary.bg (transparent-tintiert auf Dark; weiche Oberfläche auf Light). Rahmen 1dp = component.{mode}.button.secondary.border. Label = component.{mode}.button.secondary.text.
Pressed
Hintergrund = component.{mode}.button.secondary.bgPressed. Ein Plattform-Ripple in der Inhaltsfarbe wird darüber gelegt. Kehrt beim Loslassen zu Default zurück.
Focused
Plattform-Fokusring (semantic.focusRing.{bp}.*) außerhalb des Pillen-Rahmens gezeichnet, keine Formänderung.
Disabled
Hintergrund = component.{mode}.button.secondary.bgDisabled. Label = component.{mode}.button.secondary.textDisabled. Touch- und Tastaturereignisse werden ignoriert. Effektives Alpha folgt component.{mode}.protoBibleButton.disabledAlpha = 0.38.

Mehr-Trigger:

Default
Gleiches Chrome wie eine sichtbare Pille. Trailing-Chevron zeigt nach unten.
Expanded
Trigger bleibt in der Press-Feedback-Farbe, solange das Dropdown geöffnet ist. Das Chevron ist für beide Zustände als nach unten zeigend dokumentiert (keine Drehung in Iteration 1).

Mehr-Dropdown-Zeilen:

Default
Hintergrund = Surface-Raised. Label = semantic.{mode}.text.primary. Typografie = Brand-Body.
Hover (Pointer-Plattformen)
Hintergrund wird um component.{mode}.protoBibleButton.hoverAlpha = 0.08 aufgehellt.
Pressed
Hintergrund wird um component.{mode}.protoBibleButton.pressedAlpha = 0.12 aufgehellt.

Single-Item-CTA — Default-Variante (Primary, Mediathek-Schema):

Default
Solider Hintergrund = component.{mode}.button.primary.bg. Label = component.{mode}.button.primary.text. Vorangestelltes Icon (falls vorhanden) auf Labelfarbe getöntet.
Pressed
Hintergrund = component.{mode}.button.primary.bgPressed.
Focused
Plattform-Fokusring außerhalb der Pille.
Disabled
Hintergrund = component.{mode}.button.primary.bgDisabled; Label = component.{mode}.button.primary.textDisabled.

Single-Item-CTA — KI-Variante:

Default
Hintergrund ersetzt durch einen linearen Verlauf von semantic.{mode}.ai.gradient.defaultStart nach semantic.{mode}.ai.gradient.defaultEnd (links → rechts gelesen; bei RTL spiegelt sich der Verlauf mit dem Layout). Label und Icon = semantic.{mode}.ai.textOnBackground.default.
Pressed
Verlauf unverändert, der Inhaltsfarbe-Ripple der Plattform überlagert sich an der Gestenposition.
Disabled
Verlauf getauscht auf semantic.{mode}.ai.gradient.disabledStartsemantic.{mode}.ai.gradient.disabledEnd. Label = semantic.{mode}.ai.textOnBackground.disabled.

Verhalten

Modus-Dispatch (Composition-gesteuert)

Der Renderer liest items: NavigationButtonItem[] aus der Page-Composition-Payload. items.length === 0 → nichts rendern (der Abschnitt wird vom Bildschirm ausgelassen). items.length === 1 → Single-Item-Modus, wobei die KI-Variante genau dann ausgewählt wird, wenn items[0].style === "button_bibelaufschlag". items.length ≥ 2 → Multi-Item-Modus.

Overflow-Messung (Multi-Item)

Der Track muss immer auf eine einzelne Zeile passen — horizontales Scrollen ist explizit KEIN Fallback. Die Messung läuft in zwei Durchläufen:

  1. Jedes Payload-Item mit seinem realen Label + Icons subcomposen; die intrinsische Breite jedes Items erfassen.
  2. Den Mehr-Trigger subcomposen; seine intrinsische Breite erfassen.

Dann calculateOverflow(itemWidths, availableWidth, moreWidth, gap) ausführen: sei availableWidth = containerWidth − 2 × rowHorizontalPadding; wenn alle Items + Gaps in availableWidth passen, items.length zurückgeben (kein Mehr); andernfalls iterativ die längste führende Präfixlänge probieren, bei der Präfix-Breite + Gap + Mehr-Breite noch in availableWidth passt; diese Präfixlänge zurückgeben (Minimum 1). Items jenseits des Präfixes werden in das Dropdown sortiert.

Eine erneute Messung läuft, wenn sich die Container-Breite ändert (Rotation, Split-Screen-Resize, Wechsel der Foldable-Posture) oder die Items-Liste sich ändert.

Mehr öffnen/schließen

Tippen auf den Mehr-Trigger öffnet das Dropdown verankert unter ihm. Tippen außerhalb, Drücken von Escape, Auswählen einer Zeile oder Scrollen der Seite schließt es. Das Auswählen einer Zeile ruft sowohl onNavigate(item.destination) auf als auch schließt das Dropdown.

Item-Auswahl

Tippen auf eine beliebige sichtbare Pille, den Single-CTA oder eine Dropdown-Zeile dispatched onNavigate(destination). Ziele sind Deep-Link-Strings; das Routing liegt in der Verantwortung des Host-Bildschirms — diese Komponente navigiert nicht selbst.

Empty / Loading / Error

Der Abschnitt hat kein Skeleton — er wird erst eingehängt, wenn seine Payload aufgelöst ist. Wenn der übergeordnete Bildschirm noch andere Zeilen lädt, kann dieser Abschnitt normal rendern, sobald items verfügbar ist. Es gibt keinen Error-State für diesen Abschnitt isoliert; wenn der Page-Composition-Fetch fehlschlägt, rendert die gesamte Home-Seite stattdessen den globalen Fehlerblock.

Motion

AuslöserEigenschaftDauerEasing
Pille Press-InHintergrund-Opazität (Ripple)120mscubic-bezier(0.0, 0.0, 0.2, 1) (ease-out)
Pille Press-OutHintergrund-Opazität (Ripple)200mscubic-bezier(0.4, 0.0, 0.2, 1) (standard)
Mehr-Dropdown öffnenOpazität 0 → 1, Skalierung 0.96 → 1 vom Anker180mscubic-bezier(0.0, 0.0, 0.2, 1)
Mehr-Dropdown schließenOpazität 1 → 0, Skalierung 1 → 0.96120mscubic-bezier(0.4, 0.0, 1, 1) (ease-in)
Track-Neumessung bei ResizeCrossfade der sichtbaren Anzahl150mslinear
Single-CTA Größenänderung (z. B. Label-Wechsel)BreiteSpring (medium-bouncy, medium stiffness)Spring (kein Cubic-Bezier-Äquivalent)

Interaktion & UX

  • Der Track scrollt nicht horizontal. Wenn Items wirklich nicht auf ein einziges sichtbares Item plus Mehr reduziert werden können (extrem schmale Breiten), sollte die Plattform dennoch mindestens das erste Item plus Mehr rendern; niemals ein Label mitten in einem Glyphen abschneiden.
  • Zweimaliges Drücken derselben Pille ist idempotent — kein visueller Lock-Zustand, der Bildschirm leitet erneut weiter.
  • Long-Press hat in Iteration 1 kein definiertes Verhalten; es darf kein Kontextmenü erzeugen.
  • Die Single-CTA-Variante muss die verfügbare Breite einschließlich des Einzugs visuell ausfüllen; keine kleinere Pille innerhalb des Streifens zentrieren.
  • Dropdown-Zeilen zeigen in Iteration 1 keine Icons, auch wenn das Quell-Item ein vorangestelltes Icon hatte — das Dropdown ist nur Text.

Barrierefreiheit

  • Pillen · role = button · zugänglicher Name = Pillen-Label · deutsche Labels werden wortgetreu beibehalten ("Filme", "Serien", …).
  • Mehr-Trigger · role = button · zugänglicher Name = "Mehr" · Barrierefreiheitszustand = expanded / collapsed · kündigt Zustandsänderungen an.
  • Mehr-Dropdown · role = menu · Zeilen haben role = menuitem. Fokus bewegt sich beim Öffnen zur ersten Zeile und kehrt beim Schließen zum Trigger zurück. Escape schließt.
  • Single-CTA · role = button · zugänglicher Name = Label ("Die Bibel erkunden" / "Bibelaufschlag mit KI"). Das vorangestellte Icon ist dekorativ — kein separates Label.
  • Fokus-Reihenfolge auf dem Streifen: erste sichtbare Pille → … → letzte sichtbare Pille → Mehr (falls vorhanden). Das Dropdown nimmt nur dann an der Fokus-Reihenfolge teil, wenn es geöffnet ist.
  • Hit-Targets sind auf jedem interaktiven Element ≥ 48dp; Gesten-Aufpolsterung ist transparent und reicht nicht in Geschwister hinein.
  • Reduzierte Bewegung: Dropdown-Öffnen/Schließen verzichtet auf die Skalierungs-Transformation und verwendet nur Opazität (180ms / 120ms). Spring beim Single-CTA wird auf ein 150ms-Tween reduziert.
  • Farbkontrast: Der KI-Verlauf muss Label + Icon bei ≥ 4.5:1 Kontrast gegen beide Verlaufs-Endpunkte halten; Iteration 1 liefert Verläufe aus, die dies für das kanonische Label "Bibelaufschlag mit KI" erfüllen.

Genutzte Tokens

PfadTier
component.{mode}.buttonGroup.spacingcomponent
component.{mode}.buttonGroup.cornerRadiuscomponent
component.{mode}.buttonGroup.heightcomponent
component.{mode}.section.verticalSpacingcomponent
component.{mode}.section.horizontalPaddingcomponent
component.{mode}.playlist.rowHorizontalPaddingcomponent
component.{mode}.protoBibleButton.borderWidthcomponent
component.{mode}.protoBibleButton.pressedAlphacomponent
component.{mode}.protoBibleButton.hoverAlphacomponent
component.{mode}.protoBibleButton.disabledAlphacomponent
component.{mode}.button.primary.bg / bgPressed / bgDisabledcomponent
component.{mode}.button.primary.text / textDisabledcomponent
component.{mode}.button.secondary.bg / bgPressed / bgDisabled / bordercomponent
component.{mode}.button.secondary.text / textDisabledcomponent
component.{mode}.button.medium.height / paddingX / paddingY / fontSize / iconSizecomponent
component.{mode}.button.small.height / paddingX / paddingY / fontSize / iconSizecomponent
component.{mode}.btvDropdown.minWidthcomponent
semantic.{mode}.ai.gradient.defaultStart / defaultEndsemantic
semantic.{mode}.ai.gradient.disabledStart / disabledEndsemantic
semantic.{mode}.ai.textOnBackground.default / disabledsemantic
semantic.{mode}.text.primarysemantic
semantic.focusRing.{bp}.*semantic

Referenz

ButtonGroupSection mobile
Mobile · ButtonGroupSection — primärer CTA "Die Bibel erkunden"

Implementierungsreferenz

Android · packages/android/app/src/main/java/com/protobible/android/ui/components/sections/ButtonGroupSection.kt (Renderer + calculateOverflow) · packages/android/app/src/main/java/com/protobible/android/ui/components/ProtoBibleButton.kt (MediathekButton, ProtoBibleButton, AI-Gradient-Branch). Die Zwei-Pass-Messung ist mit SubcomposeLayout umgesetzt; das Dropdown wird an das projekteigene DropdownContainer-Primitiv delegiert.
iOS · ausstehend. Vorgeschlagener Ansatz: ein HStack, eingebettet in ViewThatFits, das eine Reihe progressiv kleinerer Item-Anzahl-Teilmengen durchprobiert und auf die kleinste zurückfällt, die den Mehr-Trigger enthält. Das Dropdown ist ein Menu (oder ein benutzerdefiniertes Popover zur Parität mit dem Offset des Dropdowns).
Web · ausstehend. Vorgeschlagener Ansatz: eine Flex-Zeile mit einem ResizeObserver am Container, die die offsetWidth jedes Kindes nach einem Layout-Pass misst; verlassen Sie sich nicht auf flex-wrap (das würde die Single-Line-Beschränkung brechen). Das Mehr-Dropdown ist ein an einer Schaltfläche verankertes Popover mit Menü-Semantik.
Anti-Pattern. Implementieren Sie den Multi-Item-Modus nicht als horizontal scrollenden Chip-Strip; das Design tauscht bewusst Laufzeitkosten (Messung) gegen ein stabiles, vollständig sichtbares Aktions-Set.

Composition-API-Vertrag

ButtonGroupSection rendert die Komponente BUTTON_GROUP (oder den Legacy-Alias GROUP_BUTTON). Die Item-Liste stammt aus component.buttons; der Renderer entscheidet anhand der Listenlänge und des optionalen style-Hinweises an jedem Item zwischen Single-Item- und Multi-Item-Modus.

Feld an CompositionComponent / CompositionButtonGroupItemTyp (laut CompositionModels.kt)Hinweise
type ComponentType.BUTTON_GROUP oder GROUP_BUTTON → Wire-Werte "button_group" / "group_button" Beide Wire-Werte werden an denselben Renderer geleitet (CompositionAdapter.kt:14–17). GROUP_BUTTON ist ein Legacy-Alias, der aus Gründen der Abwärtskompatibilität beibehalten wird — neue Payloads sollten "button_group" verwenden. CompositionModels.kt:60–61.
headlineText String? Optionaler Abschnittstitel über der Reihe. Die meisten Home-Payloads lassen ihn weg (der Single-AI-CTA-Fall hat keinen Titel).
buttons List<CompositionButtonGroupItem> Erforderlich. Leere Liste → Abschnitt wird vollständig übersprungen (CompositionAdapter.kt:28). Ein Item → Branch mit vollbreitem primärem CTA; mehr als ein Item → Flex-Row + Overflow-Branch.
buttons[].displayText String? (fällt auf label zurück) Button-Label. Der Adapter unter CompositionAdapter.kt:34 liest displayText ?: label ?: "" — beide werden auf der Leitung akzeptiert, neue Payloads sollten jedoch displayText senden.
buttons[].style String? Stil-Hinweis. "button_bibelaufschlag" löst das AI-Gradient-Rendering für den Einzel-Item-Fall aus. Andere Werte werden als Daten an den Renderer weitergegeben; unbekannte Styles fallen auf das Standard-Rendering zurück. CompositionModels.kt:48.
buttons[].deepLink String? (fällt auf type zurück) Tap-Ziel. Der Adapter verwendet deepLink ?: type ?: "" unter CompositionAdapter.kt:35. Die Route wird vom Host-Bildschirm verarbeitet.
buttons[].leadingIcon / buttons[].trailingIcon String? Optionale Icon-Namen aus dem Material Symbols Rounded Set. An die Schaltfläche als MaterialSymbols.{Name} weitergegeben. Das Standard-Leading-Icon der AI-Variante (AutoAwesome) wird nur angewendet, wenn kein Icon angegeben ist.
buttons[].id / buttons[].type String? Legacy-Felder, beibehalten zur Abwärtskompatibilität — id für Tracking, type als Deep-Link-Fallback. Neue Payloads müssen sich darauf nicht verlassen.
tracking ComponentTracking? Impression- und Click-Events auf Komponentenebene. Click-Events pro Button werden heute auf Komponentenebene nicht separat erfasst; der Host-Bildschirm übernimmt die Deep-Link-Telemetrie.
cards List<CompositionCard> = emptyList() Im Wire-Modell für alle Komponenten vorhanden, von BUTTON_GROUP jedoch nicht verwendet.

Composition-API-Kontrakt

Sektion mit Diskriminator type="button_group". Modelle: ButtonGroupSection (PlaylistCollectionModels.kt:84), ButtonGroupItem (PlaylistCollectionModels.kt:285).

FeldTypHinweise
idStringSektions-Kennung.
type"button_group"Diskriminator → ButtonGroupSection.
titleString?Optionaler Sektionstitel (im Showcase nicht gesetzt).
buttonsList<ButtonGroupItem>Reihenfolge = Render-Reihenfolge. 1 Button mit AI-Style → ProtoBibleButton in voller Breite; >1 Buttons → Overflow in "Mehr"-Dropdown.

Felder pro ButtonGroupItem:

FeldTypHinweise
idStringButton-Kennung; client-seitig derzeit nicht weiterverwendet.
labelStringButton-Text.
deepLinkStringNavigationsziel.
styleString?"primary" / "secondary" / "button_bibelaufschlag" (AI-Button mit Gradient).
leadingIconString?Material-Symbol-Name (z. B. AutoAwesome).
trailingIconString?Material-Symbol-Name (z. B. KeyboardArrowDown).

SearchFieldAI (KI-Suchfeld)

Iteration 3 scope Composition-gesteuert Overlay

Zweiteilige 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

Ruhendes Feld — Section im Seiteninhalt Höhe 56dp · Radius 28dp · KI-Verlauf-Rahmen 2dp bei 135° · Anzeige-only, kein Texteingabe-Cursor
Section-Container · horizontal gepolstert durch playlist.rowHorizontalPadding [search] Frag die Bibel oder suche Sendungen … rowHorizontalPadding 1 2 3 4 5

Callout-Legende — ruhendes Feld

  1. Feld-ShellVolle Sektionsbreite · Höhe searchField.fieldHeight = 56dp · Radius searchField.cornerRadius = 28dp · Hintergrund semantic.{mode}.background.surfaceRaised mit Alpha 0.6 (kt-Literal, nicht tokenisiert — siehe Audit-Notizen)SearchFieldAI.kt:58, 64–69
  2. KI-Verlauf-RahmenBreite searchField.borderWidth = 2dp · Verlauf semantic.{mode}.ai.gradient.defaultStart → defaultEnd unter konstantem angleDeg = 135° via projekteigener AngledLinearGradient-Brush — dieselbe Verlaufsdefinition wie der KI-Button (siehe ButtonGroupSection)SearchFieldAI.kt:70–77 · AngledLinearGradient.kt:30
  3. Such-IconMaterial Symbol [search] (Outlined) · Größe semantic.icons.md · Tönung semantic.{mode}.text.secondary · dekorativ (kein Accessibility-Label)SearchFieldAI.kt:81–86
  4. Placeholder-TextServer-gesteuert aus search_field.placeholder (FR-022) · Brand-Body-Typografie (Diagramm, typography.body) · Farbe semantic.{mode}.text.secondary · einzeilig mit EllipsisSearchFieldAI.kt:87–94 · SearchFieldSectionRenderer.kt:29
  5. Seitenrand-Paddingcomponent.{mode}.playlist.rowHorizontalPadding — vom Section-Renderer angewendet, nicht vom Feld selbst · Inhalts-Padding innen: spacing.gapDefault horizontal, Icon↔Text-Abstand spacing.gap2xsSearchFieldSectionRenderer.kt:32–35 · SearchFieldAI.kt:63, 79
Expandiertes Overlay — Vollbild-Chat-Ansicht Scrim = Canvas-Farbe · animierter Edge-Glow am Viewport-Rand · Eingabefeld unten verankert, über der Tastatur
Scrim · background.canvas · Alpha = Transitions-Progress [close] Ruheposition des Feldes (Start der Reise) Offset-Interpolation · 450ms Stelle eine Frage oder gib eine Bibelstelle ein „Johannes 3,16“ in der Bibel öffnen [search] Johannes 3,16 Bildschirmtastatur (IME) · Layout weicht via Safe-Area-/IME-Insets aus 1 2 3 4 5 6 7

Callout-Legende — expandiertes Overlay

  1. Scrim — Canvas-Farbe, progress-gekoppeltVollbild-Hintergrund semantic.{mode}.background.canvas; sein Alpha folgt 1:1 dem Transitions-Progress (0 → 1), sodass der darunterliegende Seiteninhalt während der Expansion ausblendetSearchFieldExpandedOverlay.kt:102–106
  2. Edge-Glow — animierter RandVier nach innen auslaufende Verlaufstreifen in den KI-Verlaufsfarben an allen Viewport-Kanten · Streifenbreite searchField.glowEdgeWidth = 24dp · Alpha atmet zwischen glowAlphaMin = 0.25 und glowAlphaMax = 0.6 · Farbphase wandert um den Rand (Details unter Verhalten) · während der Expansion mit dem Progress eingeblendetEdgeGlow.kt:31–101 · SearchFieldExpandedOverlay.kt:107
  3. Schließen-ButtonMaterial Symbol [close] · oben rechts · Accessibility-Label "Suche schließen" · Tönung semantic.{mode}.text.primary · blendet mit dem Progress einSearchFieldExpandedOverlay.kt:116–130
  4. Hinweistext — zentriert"Stelle eine Frage oder gib eine Bibelstelle ein" · Brand-Body (Diagramm, typography.body) · semantic.{mode}.text.secondary · kt-Literal, nicht server-gesteuert (siehe Audit-Notizen)SearchFieldExpandedOverlay.kt:139–147
  5. Intent-ChipErscheint oberhalb des Eingabefeldes, sobald die Eingabe klassifizierbar ist · Radius searchField.chipCornerRadius = 16dp · Hintergrund surfaceRaised α 0.8 · Rahmen 1dp semantic.{mode}.border.subtle · Label-Typografie Inter typography.bodySmall (< 16sp)SearchFieldExpandedOverlay.kt:234–274
  6. Wanderndes Eingabefeld — unten verankertEchtes Texteingabefeld mit identischem Chrome wie das ruhende Feld (56dp / Radius 28dp / 2dp-Verlauf-Rahmen), Hintergrund-Alpha hier 0.85 · Cursor-Farbe = ai.gradient.defaultEnd · Außenabstand expandedPaddingHorizontal = 16dp / expandedPaddingVertical = 12dpSearchFieldExpandedOverlay.kt:168–227
  7. Tastatur-VerankerungDie gesamte Spalte weicht Statusleiste, Navigationsleiste und Bildschirmtastatur über Safe-Area-/IME-Insets aus — das Eingabefeld sitzt dadurch immer direkt über der TastaturoberkanteSearchFieldExpandedOverlay.kt:109–115

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

search Frag die Bibel oder suche Sendungen … Dark · surfaceRaised #252F35 α0.6 · Rahmen #0097A7 → #4D3F71 · 135° search Frag die Bibel oder suche Sendungen … Light · surfaceRaised White α0.6 · Rahmen #00ACC1 → #5E4D89 · 135°

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).

FeldTyp (laut PlaylistCollectionModels.kt)Hinweise
type"search_field" (@SerialName-Diskriminator)Dispatch an den SearchField-Renderer.PlaylistCollectionModels.kt:112
idStringSektions-Kennung (Beispiel-Payload: "search-ai").
titleString?Optionaler Sektionstitel — vom Renderer derzeit nicht dargestellt.
placeholderString?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
intentsList<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.

EigenschaftWertTokenVerwendung
Rahmenbreite2dpsearchField.borderWidthSearchFieldAI.kt:71 · SearchFieldExpandedOverlay.kt:219
Ecken-Radius Feld28dpsearchField.cornerRadiusSearchFieldAI.kt:58 · SearchFieldExpandedOverlay.kt:167
Feldhöhe56dpsearchField.fieldHeightSearchFieldAI.kt:66 · SearchFieldExpandedOverlay.kt:215
Glow-Alpha min0.25searchField.glowAlphaMin → Alias primitives.opacity.scale25EdgeGlow.kt:37
Glow-Alpha max0.6searchField.glowAlphaMax → Alias primitives.opacity.scale60EdgeGlow.kt:38
Glow-Randbreite24dpsearchField.glowEdgeWidthEdgeGlow.kt:58
Glow-Atmen-Dauer2400mssearchField.glowBreatheDurationMsEdgeGlow.kt:40
Glow-Sweep-Dauer8000mssearchField.glowSweepDurationMsEdgeGlow.kt:49
Expansions-Dauer450mssearchField.transitionDurationMsSearchFieldExpandedOverlay.kt:90
Overlay-Padding horizontal16dpsearchField.expandedPaddingHorizontalSearchFieldExpandedOverlay.kt:121, 145, 157, 205
Overlay-Padding vertikal12dpsearchField.expandedPaddingVerticalSearchFieldExpandedOverlay.kt:158, 206, 228
Ecken-Radius Intent-Chip16dpsearchField.chipCornerRadiusSearchFieldExpandedOverlay.kt:255
Inhalts-Padding Feld (innen)Tokenspacing.gapDefault horizontal · spacing.gap2xs Icon↔TextSearchFieldAI.kt:63, 79
Icon-GrößeTokensemantic.icons.mdSearchFieldAI.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 Transitioning Expanded Dismissing Tap / Fokus Bounds settled Back / Schließen Abbruch während der Transition Overlay ausgeblendet
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

Expansions-Transition — geteilte Feldgeometrie

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).

Dismiss-Pfade

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.

Edge-Glow — Atmen + Farb-Sweep

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

Intent-Klassifikation + Chips — Prototyp-Stubs, kein Netzwerk

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).

Server-gesteuerte Texte (FR-022)

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.gap2xs vertikalem 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 (paneTitle o. ä.); 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 auf surfaceRaised-Flächen; die semantischen Paarungen entsprechen den übrigen Formularflächen des Systems. Auf dem Gerät gegen WCAG AA verifizieren (T026 ausstehend).

Motion

AuslöserEigenschaftDauerEasing
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.transitionDurationMsStandard-Tween der Plattform (Android: FastOutSlowInEasingcubic-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 AtmenGlow-Alpha 0.25 ↔ 0.62400ms = searchField.glowBreatheDurationMs · endlos, RichtungsumkehrlinearEdgeGlow.kt:36–44
Edge-Glow Farb-SweepFarbphase zwischen ai.gradient.defaultStart und defaultEnd (Dreieckswelle, gegenläufig auf gegenüberliegenden Kanten)8000ms = searchField.glowSweepDurationMs · endlos, NeustartlinearEdgeGlow.kt:45–52, 61–63
Intent-Chip erscheint/wechseltSofortiger Austausch bei jeder Neuklassifikation (keine eigene Animation in Iteration 3)SearchFieldExpandedOverlay.kt:149–160

Genutzte Tokens

PfadEbene
component.{mode}.searchField.borderWidth / cornerRadius / fieldHeightcomponent
component.{mode}.searchField.glowAlphaMin / glowAlphaMax / glowEdgeWidthcomponent
component.{mode}.searchField.glowBreatheDurationMs / glowSweepDurationMscomponent
component.{mode}.searchField.transitionDurationMscomponent
component.{mode}.searchField.expandedPaddingHorizontal / expandedPaddingVerticalcomponent
component.{mode}.searchField.chipCornerRadiuscomponent
component.{mode}.playlist.rowHorizontalPadding (Section-Einzug)component
semantic.{mode}.ai.gradient.defaultStart / defaultEnd / angleDegsemantic
semantic.{mode}.background.canvas / surfaceRaisedsemantic
semantic.{mode}.text.primary / secondarysemantic
semantic.{mode}.border.subtle (Chip-Rahmen)semantic
semantic.icons.{bp}.mdsemantic
spacing.gapDefault / gap2xsprimitive
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

Android · 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.
iOS · ausstehend. Vorgeschlagener Ansatz: 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).
Web · ausstehend. Vorgeschlagener Ansatz: FLIP-Technik für die Feld-Reise (Position des ruhenden Feldes messen → Overlay-Feld mit 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 Repeating

Die 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.

Mobile & Tablet — gleiche Struktur Horizontaler Scroll · standardmäßig 1,5 Karten auf dem Smartphone sichtbar
Abschnittstitel LazyRow · horizontaler Scroll · spacedBy(rowItemSpacing) Karte 1 Karte 2 Karte 3 Karte 4 Peek → Abstand Padding 1 2 3 4 5 6

Callout-Legende

  1. Abschnittstitel (optional)Wird nur gerendert, wenn section.title != null · Schriftfamilie Diagramm Display · Größe playlist.rowTitleFontSizeSp · Gewicht playlist.rowTitleFontWeight = Medium · Padding am Start durch playlist.rowHorizontalPadding, am unteren Rand durch playlist.rowTitleBottomSpacingRowSection.kt:69–80 · MediathekTokenModels.kt PlaylistTokens
  2. Reihen-Container — LazyRowLazyRow mit contentPadding = playlist.rowHorizontalPadding horizontal · Arrangement.spacedBy(playlist.rowItemSpacing) zwischen Items · freier Scroll (kein Snap)RowSection.kt:84–87
  3. Karten-Slot — servergesteuerter TypIn Iteration 1 ist nur POSTER_PORTRAIT (CardPoster) im Umfang. Die Reihe unterstützt zusätzlich IMAGE_ONLY_LANDSCAPE, SPLIT, OVERLAY, AVATAR — der Server bestimmt dies pro Reihe über section.cardType. Die Kartenbreite wird dynamisch berechnet (siehe Formel unten).RowSection.kt:88–95 · PlaylistCardDispatch
  4. Halb-Peek der nächsten Karte (Scroll-Hinweis)Die Smartphone-Breite wird so berechnet, dass pro Viewport ~1,5 Karten sichtbar sind. Die halbe Karte ist der Scroll-Hinweis — sie signalisiert dem Benutzer, dass es horizontal weitere Inhalte gibt. Kein Edge-Fade, kein Scrollbar.RowSection.kt:108–128 (getCardWidth Phone-Formel)
  5. Abstand zwischen Kartenplaylist.rowItemSpacing · 16dp Standard auf Mobile/Tablet · ebenfalls auf View-Ebene als views.home.layout.rowCardSpacing{Mobile,Tablet} verfügbarPlaylistTokens · views/home.json
  6. Reihen-Randpaddingplaylist.rowHorizontalPadding · derselbe Wert wird als contentPadding horizontal an der LazyRow angewendet · hält die erste/letzte Karte vom Viewport-Rand abgesetztRowSection.kt:85

Kartenbreiten-Formel (POSTER_PORTRAIT, nur im Scope)

ModusKartenbreiteFormel / Quelle
Mobile (Smartphone)dynamisch berechnet, damit ~1,5 Karten sichtbar sind(screenWidthDp − 2 × rowHorizontalPadding − 0.5 × rowItemSpacing) / 1.5
RowSection.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.widthLargeStatischer Tokenwert
MATCHED-Größenvariante (höhengetrieben)Höhe 300dp × aspectRatioMATCHED_HEIGHT × cardPoster.aspectRatio = 300 × 0.5625 = 168.75dp
RowSection.kt:97–106

Abmessungen

EigenschaftWertTokenQuelle
Titel-SchriftgrößeTokencomponent.{mode}.playlist.rowTitleFontSizeSpMediathekTokenModels.kt PlaylistTokens
Titel-SchriftstärkeMediumcomponent.playlist.rowTitleFontWeightMediathekTokenModels.kt PlaylistTokens
Abstand Titel zu ReiheTokencomponent.{mode}.playlist.rowTitleBottomSpacingMediathekTokenModels.kt
Horizontales Padding der ReiheTokencomponent.{mode}.playlist.rowHorizontalPaddingMediathekTokenModels.kt
Abstand zwischen KartenTokencomponent.{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 displayItems leer 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 mit fixedPosition werden an ihrem Index fixiert, die übrigen werden gemischt und anschließend an den fixierten Indizes wieder eingefügt. Memoisiert via remember(section.items, section.shuffled).
  • Karten-DispatchPlaylistCardDispatch wählt den Renderer basierend auf cardType aus. Iteration 1 leitet nur an CardPoster (POSTER_PORTRAIT) weiter.
  • Gesten-Priorität — Composes LazyRow beansprucht 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. alle fixedPosition-Platzierungen entfernt), rendert der Abschnitt nichts — der übergeordnete Home-Bildschirm lässt die Lücke aus.

Verhalten

Item-Reihenfolge

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.

Dispatch der Kartenvariante

Für jedes Item wird effectiveCardType = item.cardTypeOverride ?? section.cardType berechnet. Rendern Sie die entsprechende Karten-Komponente in der abgeleiteten Breite:

  • POSTER_PORTRAITCardPoster
  • IMAGE_ONLY_LANDSCAPECardLandscape
  • OVERLAY → 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.

Scroll-Verhalten

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.

Gesten-Priorität

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.

Karten-Tap

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.

Recycling

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.

Re-Layout bei Größenänderung

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öserEigenschaftDauerEasing
Freier Scroll des Trackstrack translateXkontinuierlich (1:1 zur Eingabe)linear (Geste) / Plattform-Inertia (Freigabe)
Inertia-Abklingentrack translateXPlattform-Standard (≈ 350–600ms)cubic-bezier(0.0, 0.0, 0.2, 1)
Fokuskarte in Sicht scrollentrack translateX250mscubic-bezier(0.4, 0.0, 0.2, 1)
Re-Layout bei Resizetrack translateX0ms (sofortiger Snap)
Skelett-Pulsopacity1200ms Schleifecubic-bezier(0.4, 0.0, 0.2, 1)
Karten-Druckdelegiert 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.cardSize gilt 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

PfadTier
component.{mode}.section.verticalSpacingcomponent
component.{mode}.section.horizontalPaddingcomponent
component.{mode}.playlist.rowHorizontalPaddingcomponent
component.{mode}.playlist.rowItemSpacingcomponent
component.{mode}.playlist.rowTitleFontSizecomponent
component.{mode}.playlist.rowTitleFontWeightcomponent
component.{mode}.playlist.rowTitleBottomSpacingcomponent
component.{mode}.rowSection.cardAspectRatiocomponent
component.{mode}.cardPoster.aspectRatiocomponent
component.{mode}.cardPoster.widthSmall / widthMedium / widthLargecomponent
component.{mode}.cardImageOnly.widthSmall / widthMedium / widthLargecomponent
semantic.{mode}.text.primarysemantic
semantic.{mode}.background.canvassemantic

Implementierungsreferenz

Android · 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.
iOS · ausstehend. Vorgeschlagener Ansatz: ein 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.
Web · ausstehend. Vorgeschlagener Ansatz: eine Flex-Zeile innerhalb eines 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.
Anti-Pattern. Zentrieren Sie die Karten nicht innerhalb des Viewports (keine horizontale Zentrierung), rasten Sie nicht an Kartenrändern ein und rendern Sie keine Scroll-Edge-Fading-Verläufe — der Inset + Halb-Karten-Peek ist der einzige dokumentierte Overflow-Hinweis.

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 CompositionComponentTyp (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:
  • card_poster_smallPOSTER_PORTRAIT + Größe SMALL
  • card_poster_mediumPOSTER_PORTRAIT + Größe MEDIUM
  • card_poster_largePOSTER_PORTRAIT + Größe LARGE
  • card_hero / card_hero_mediaPOSTER_PORTRAIT + Größe MATCHED (höhengetrieben 300dp × aspect)
Andere Kartenvarianten (IMAGE_ONLY_LANDSCAPE, SPLIT, OVERLAY, AVATAR) existieren im Renderer, sind jedoch außerhalb des Scopes von Iteration 1.
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).

FeldTypHinweise
idStringZeilen-Kennung.
type"playlist_row"Diskriminator → RowSection.
titleString?Zeilen-Headline über den Karten.
itemsList<PlaylistItem>Karten der Zeile; Reihenfolge = Render-Reihenfolge (außer shuffled).
cardTypePlaylistCardTypeOVERLAY / SPLIT / POSTER_PORTRAIT / IMAGE_ONLY_LANDSCAPE / AVATAR — wählt das Karten-Composable.
cardSizeCardSize?SMALL / MEDIUM (Default) / LARGE / MATCHED — Kartenbreite über Komponenten-Tokens (widthSmall/Medium/Large).
metadataVariantMetadataVariant?FULL (Default) / MINIMAL; wirkt auf die Metadaten-Zeile der SPLIT-Karte.
shuffledBooleanClient mischt die Items; fixedPosition pro Item bleibt respektiert.
contextActionsList<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-FeldOVERLAYSPLITPOSTER_PORTRAITIMAGE_ONLY_LANDSCAPEAVATAR
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 item

Eine 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.

Default (Bild vorhanden) 9:16 Hochformat · aspectRatio = 9f / 16f = 0.5625
Schattenplatte · Elevation 8dp Bild · 9:16 cover · auf Radius geclippt Fortschrittsbalken · optional · 4dp hoch · 60% Füllung gezeigt Breiten-Stufen S 120dp M (Standard) 160dp L · 200dp 1 2 3 4

Callout-Legende für den Default-Zustand

  1. Container — Karten-ChromeEckenradius cardPoster.cornerRadius (Token, kein Standard) · 1dp Inset-Border borderColor = 0x26FFFFFF (≈ Weiß mit α 0.15) · geclipptMediathekTokenModels.kt CardPosterTokens · CardPoster.kt:85
  2. Bild-Layer — Full-Bleed-CoverURL über das CDN umgeschrieben mit ar=9:16, w=400. Auf den Container-Eckenradius geclippt. Während des Ladens ist cardPoster.bgColor = 0xFF141B1F als Skelett sichtbar.CardPoster.kt:118–130 · ImgixUtils für URL-Rewrite
  3. SchlagschattenElevation cardPoster.shadowElevation = 8.dp · Spot-Farbe shadowColor = 0x4D000000 (Schwarz α 0.3) · Material-3-Schattenform an Eckenradius angepasstCardPoster.kt:80–84
  4. Fortschrittsbalken (optional)Gesteuert durch user.progressPercent (0–100) in der Payload. API-Vertrag: Balken ausblenden, wenn user == null, wenn progressPercent == 0 oder wenn progressPercent == 100. Andernfalls: unten verankert · Höhe aus component.progress.barHeight · Füllfarbe component.progress.barColor über dem Track. Hinweis für Implementierer: Der aktuelle Android-Quellcode verwendet watchProgress != null && watchProgress > 0f — dies blendet bei 0 und null aus, jedoch nicht bei 100; richten Sie sich nach dem obigen API-Vertrag aus.CardPoster.kt:133–138 · ProgressBar component
Fallback — Bild leer oder Ladefehler Ausgelöst durch: imageLoadFailed || imageUrl.isEmpty()
Titeltext (zentriert, Diagramm Display Familie) ↧ 3-Stopp-Linearverlauf bgColor → bgColor × α 0.8 → canvas 1 2 3

Callout-Legende für den Fallback-Zustand

  1. Verlaufsfüllung — 3-Stopp-LinearverlaufStopps: 0% → cardPoster.bgColor · 50% → bgColor × α 0.8 · 100% → semantic.{mode}.background.canvas · vertikal (von oben nach unten)CardPoster.kt:90–101
  2. Titel — zentriertFamilie Diagramm Display · Größe aus cardPoster.fallbackTitleFontSizeSp · Gewicht aus cardPoster.fallbackTitleFontWeight · Zeilenhöhe aus cardPoster.fallbackTitleLineHeightSp · Farbe cardPoster.fallbackTextColor = 0xFFEBF0F2 · zentriert mit paddingDefault = 16dp rundherumCardPoster.kt:117–128
  3. Fortschrittsbalken (wird ggf. weiterhin gerendert)Die Fallback-Schicht unterdrückt den Fortschrittsbalken nicht — er wird weiterhin am unteren Rand gerendert, wenn watchProgress ungleich null istCardPoster.kt:133–138

Abmessungen

EigenschaftWertTokenQuelle
Seitenverhältnis9:16 (≈ 0.5625)component.cardPoster.aspectRatioMediathekTokenModels.kt
Breite — KleinTokencomponent.{mode}.cardPoster.widthSmallMediathekTokenModels.kt
Breite — Mittel (Standard)Tokencomponent.{mode}.cardPoster.widthMediumMediathekTokenModels.kt
Breite — GroßTokencomponent.{mode}.cardPoster.widthLargeMediathekTokenModels.kt
EckenradiusTokencomponent.{mode}.cardPoster.cornerRadiusMediathekTokenModels.kt
Rahmen1dp bei #26FFFFFF (Weiß α 0.15)component.cardPoster.borderColor + borderWidthMediathekTokenModels.kt
Schatten-Elevation8dpcomponent.cardPoster.shadowElevationMediathekTokenModels.kt
SchattenfarbeSchwarz α 0.3component.cardPoster.shadowColorMediathekTokenModels.kt
Hintergrund (Loading + Fallback-Start)#141B1Fcomponent.cardPoster.bgColorMediathekTokenModels.kt
Fallback-Textfarbe#EBF0F2component.cardPoster.fallbackTextColorMediathekTokenModels.kt
Hit-Targetgesamte Karte (≥ 120 × 213 dp in der kleinsten Stufe — deutlich über dem 48dp-Minimum)a11y baseline
Abstand zwischen Karten (in Reihe)16dpviews.home.layout.rowCardSpacing{Mobile,Tablet}views/home.json (Spec 083)

Zustände

Loading (Skelett)
Container wird ausschließlich in cardPoster.bgColor gezeichnet. 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.primary mit primitives.opacity.scale15.
Mit Fortschritt
Wird nur gerendert, wenn user != null UND 0 < 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 onClick auf. Ö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 und w=400 umgeschrieben. Innerhalb der Karte selbst kein Prefetch.
  • Bildfehler — schaltet den imageLoadFailed-State über den onError-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.bgColor gezeichnet. 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.primary bei opacity.scale15.
Fokussiert (Tastatur / D-Pad)
Äußerer Fokusring bei semantic.focusRing.{bp}.width, versetzt um semantic.focusRing.{bp}.offset, Farbe semantic.{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 onClick der 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öserEigenschaftDauerEasing
Bild aufgelöstimage opacity 0 → 1200msease-out
DruckPress-Feedback-Opazität120ms ein / 200ms ausease-out
Fokus rein/rausFokusring-Opazität100mslinear
Fortschritts-FüllungFüllbreiteinnerhalb 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 watchProgress 0, 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

PfadTier
component.{mode}.cardPoster.cornerRadiuscomponent
component.{mode}.cardPoster.widthSmallcomponent
component.{mode}.cardPoster.widthMediumcomponent
component.{mode}.cardPoster.widthLargecomponent
component.{mode}.cardPoster.aspectRatiocomponent
component.{mode}.cardPoster.borderColorcomponent
component.{mode}.cardPoster.borderWidthcomponent
component.{mode}.cardPoster.shadowElevationcomponent
component.{mode}.cardPoster.shadowColorcomponent
component.{mode}.cardPoster.bgColorcomponent
component.{mode}.cardPoster.fallbackGradientEndcomponent
component.{mode}.cardPoster.fallbackTextColorcomponent
component.{mode}.cardPoster.fallbackTitleFontSizecomponent (Fallback-Titel)
component.{mode}.cardPoster.fallbackTitleFontWeightcomponent (Fallback-Titel)
component.{mode}.cardPoster.fallbackTitleLineHeightcomponent (Fallback-Titel)
component.{mode}.progress.barHeightcomponent
component.{mode}.progress.barCornerRadiuscomponent
component.{mode}.progress.barColorcomponent
component.{mode}.progress.barTrackColorcomponent
semantic.{mode}.background.canvassemantic (Fallback-Verlauf)
semantic.{mode}.text.primarysemantic (Fallback-Text + Druck-Feedback)
semantic.focusRing.{bp}.widthsemantic
semantic.focusRing.{bp}.offsetsemantic
primitives.opacity.scale15primitive (Druck-Feedback)

Referenz

Mobile · CardPoster row
Mobile · CardPoster-Reihe
Tablet · CardPoster row
Tablet · CardPoster-Reihe

Implementierungsreferenz

Android · 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.

Composition API annotations for CardPoster — type variants, imageUrl, user.progressPercent, colors.base, headlineText, and click target
CardPoster Composition-API-Annotationen (Deutsch). Alle vier Größenvarianten teilen sich einen API-Typ; 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 CompositionCardTyp (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 user=null (aber vorhanden) sein. Wenn der Wert 0 oder 100 ist, gibt es keine Leiste."
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):

FeldTypHinweise
imageUrlStringHochformat-Poster (3:4-Zuschnitt).
titleString?Titel; Fallback-Darstellung, wenn das Bild fehlt.
tag / tagVariantString?Status-Badge oben links (tagVariantVideoState-Pille); Fallback ohne tag: Kategorie-Badge aus contentType; ohne beides entfällt es. Seit 2026-06-11 unterstützen alle Kartenvarianten Badge + Fortschrittsbalken.
progressFloat?Fortschrittsbalken 0.0–1.0; entfällt bei null.
deepLinkStringNavigationsziel 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 item

Eine 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

Default + Größen-Stufen 16:9 · aspectRatio = 1.7778 · S / M / L token-getrieben
Bild · 16:9 cover · auf Radius geclippt Fortschrittsbalken · optional · unten-links Breiten-Stufen S · widthSmall M · widthMedium L · widthLarge 1 2 3

Callout-Legende

  1. Container — Karten-ChromeEckenradius cardLandscape.cornerRadius (= radius.lg, 16dp) · 0.5dp Border cardLandscape.borderColor (#26FFFFFF) · geclippt · Dunkelmodus-Schatten shadowElevation = 8dpCardLandscape.kt:52,56,57,81
  2. Bild — Full-Bleed-Cover (16:9)Coil crossfade über 300ms · Lade-Skelett in cardImageOnly.bgColor (#141B1F). Fallback bei Ladefehler: 3-Stopp-Verlauf + zentrierter Titel im cardLandscape.fallbackTitle*-Font.CardLandscape.kt:90–130
  3. Fortschrittsbalken (optional)Nur bei watchProgress != null && > 0f · ProgressBar-Primitiv · unten-links verankert. Breiten-Stufen S/M/L kommen aus cardImageOnly.widthSmall/Medium/Large (in RowSection, nicht in der Karte).CardLandscape.kt:133–139 · RowSection.kt:149–154

Abmessungen

EigenschaftWertTokenQuelle
Seitenverhältnis16:9 (≈ 1.7778)component.cardLandscape.aspectRatioMediathekTokenModels.kt
Breite — KleinTokencomponent.{mode}.cardImageOnly.widthSmallRowSection.kt:150
Breite — Mittel (Standard)Tokencomponent.{mode}.cardImageOnly.widthMediumRowSection.kt:151
Breite — GroßTokencomponent.{mode}.cardImageOnly.widthLargeRowSection.kt:152
EckenradiusToken (= radius.lg, 16dp)component.{mode}.cardLandscape.cornerRadiusMediathekTokenModels.kt
Rahmen0.5dp bei #26FFFFFF (gemeinsame Karten-Border)component.cardLandscape.borderColor + borderWidthCardLandscape.kt:52,57
Schatten-Elevation (nur Dunkel)8dpcomponent.cardLandscape.shadowElevationCardLandscape.kt:81
Hintergrund (Loading)#141B1Fcomponent.cardImageOnly.bgColorMediathekTokenModels.kt
Fallback-Titel-Font14sp · SemiBold · LH 15component.cardLandscape.fallbackTitleFontSize/Weight/LineHeightCardLandscape.kt:109–111

Zustände

Loading (Skelett)
Box wird in cardImageOnly.bgColor gezeichnet; 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 im cardLandscape.fallbackTitle*-Font.
Mit Fortschritt
Nur gerendert, wenn watchProgress != null && watchProgress > 0f. ProgressBar unten-links verankert.
Gedrückt
Material-3-Ripple innerhalb des geclippten Eckenradius.

Referenz

Mobile · CardLandscape rows (SMALL / MEDIUM)
Mobil · Hell · „Landscape SMALL" + „Landscape MEDIUM"-Reihen

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):

FeldTypHinweise
imageUrlString16:9-Landscape-Bild.
titleString?Titel; zentrierter Fallback-Text, wenn das Bild fehlt.
tag / tagVariantString?Status-Pill ("Neu", "Live", …).
progressFloat?Fortschrittsbalken 0.0–1.0; entfällt bei null.
deepLinkStringNavigationsziel 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 item

Eine 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

Default + Fallback Kreis-Durchmesser 160dp · Label darunter
Face-Crop Titel-Label · max 2 Zeilen AB Fallback · Initialen 1 2 3 4

Callout-Legende

  1. Kreisbild — Face-CropDurchmesser cardAvatar.defaultSize = 160dp · CircleShape-Clip · ContentScale.Crop · Bild über Face-Crop-URLCardAvatar.kt:71 · MediathekTokenModels.kt:474
  2. Border — gemeinsame Karten-BorderKreis-Stroke aus cardLandscape.borderColor + borderWidth (1dp, #26FFFFFF) — geteilt mit allen anderen Karten · Dunkelmodus-Schatten cardAvatar.shadowElevationCardAvatar.kt:55,59,82–86
  3. Titel-LabelUnter dem Kreis · 12sp · Medium · DiagrammFontFamily · max 2 Zeilen · EllipsisCardAvatar.kt:131–143
  4. Initialen-FallbackBei Bildfehler: erste 2 Zeichen des Titels · Bold · 0.3 × Durchmesser · zentriertCardAvatar.kt:106–110

Abmessungen

EigenschaftWertTokenQuelle
Durchmesser (Standard)160dpcomponent.cardAvatar.defaultSizeMediathekTokenModels.kt:474
FormKreisCardAvatar.kt (CircleShape)
Rahmen1dp bei #26FFFFFF (gemeinsame Karten-Border)component.cardLandscape.borderColor + borderWidthCardAvatar.kt:55,59
Schatten-Elevation (nur Dunkel)8dpcomponent.cardAvatar.shadowElevationCardAvatar.kt:82–86
Label-Font12sp · Medium · DiagrammFontFamilycomponent.cardAvatar.labelFontSize + labelFontWeightCardAvatar.kt:135–138
Label max. Zeilen2 · Ellipsiscomponent.cardAvatar.labelMaxLinesCardAvatar.kt:141
Initialen (Fallback)2 Zeichen · Bold · 0.3 × DurchmessercardAvatar.initialsCount / initialsFontSizeRatio / initialsFontWeightCardAvatar.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

Mobile · CardAvatar row (Personen / Sendereihen)
Mobil · Hell · „Personen / Sendereihen"-Reihe

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):

FeldTypHinweise
imageUrlStringRundes Avatar-Bild; gesichtszentrierter Zuschnitt über Imgix-Face-Crop.
titleString?Label unter dem Avatar (Person / Sendereihe).
deepLinkStringNavigationsziel beim Tippen.

Kein Fortschritt, keine Pills, keine Metadaten — bewusst minimaler Vertrag (Matrix in RowSection).

CardOverlay

Iteration 2 scope Row item

Eine 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

Default (Bild vorhanden, mit Badge + Fortschritt) 16:9 · Parameter-Default aspectRatio = 16f / 9f
Bild · 16:9 cover · auf Radius geclippt Badge Eyebrow · 1 Zeile Titel · max 2 Zeilen Fortschrittsbalken · optional · unten verankert Scrim-Stopps 0% → α 0 30% → α 0 60% → α 0.7 80% → α 0.9 100% → α 1.0 1 2 3 4 5 6

Callout-Legende

  1. Container — Karten-ChromeEckenradius cardOverlay.cornerRadius = 16 · 0.5dp-Border (spacing.dividerHeight) in cardOverlay.borderColor · Schatten spacing.elevationCard = 8dp mit Spot-Farbe Schwarz α 0.3 · Grundfläche = extrahierte Basisfarbe, animiert über 300msMediaCard.kt:239–254 · MediaCard.kt:139–143
  2. Bild — Full-Bleed-CoverURL über das CDN umgeschrieben mit ar=16:9, w=640 (bei Hochformat-Ratio 3:4 / 9:16 mit w=400) · Einblendung der Opazität 0 → 1 über 300ms nach Lade-ErfolgMediaCard.kt:221–237, 256–264
  3. Badge (optional)Oben-links, Inset spacing.gap2xs = 8dp. Mit videoState: Status-Pille (Farbe/Label aus dem Status). Ohne Status, aber mit Kategorie: Kategorie-Badge auf #00000066. Ohne beides entfällt das Badge.MediaCard.kt:266–282
  4. Scrim-Verlauf — extrahierte GrundfarbeVertikaler Verlauf in der Grundfarbe: Stopps 0% / 30% → α 0 · 60% → α 0.7 · 80% → α 0.9 · 100% → α 1.0 (Literale mit EXCEPTION: derived contrast-Annotation — siehe Audit-Notizen). Oberer Inhalts-Abstand: Dunkel spacing.gapL = 24dp, Hell spacing.gap5xl = 48dp.MediaCard.kt:284–299
  5. Textblock — Eyebrow + Titel, unten verankertEyebrow (Serienname): Inter · cardOverlay.subtitleFontSize = 11 · subtitleFontWeight = Normal · LH subtitleLineHeight = 13 · Sekundär-Textfarbe der Palette · 1 Zeile, Ellipsis. Titel: Diagramm · cardOverlay.titleFontSize = 14 · titleFontWeight = SemiBold · LH titleLineHeight = 15 · Primär-Textfarbe der Palette · max 2 Zeilen, Ellipsis. Seiten-/Unten-Abstand spacing.gap2xs = 8dp.MediaCard.kt:299–329
  6. Fortschrittsbalken (optional)Nur bei watchProgress != null && > 0f · gemeinsames ProgressBar-Primitiv · unten-links verankert, über dem Textblock-VerlaufMediaCard.kt:331–337

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):

FeldTypHinweise
imageUrlString16:9-Cover (CDN-Zuschnitt, w=640).
titleString?Titel über dem Scrim, max 2 Zeilen.
seriesString?Eyebrow-Zeile über dem Titel; entfällt bei null/leer.
tag / tagVariantString?Status-Badge oben-links (tagVariantVideoState-Pille); Fallback ohne tag: Kategorie-Badge aus contentType; ohne beides entfällt es.
progressFloat?Fortschrittsbalken 0.0–1.0; entfällt bei null.
colorsCollectionExtractedColors?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
deepLinkStringNavigationsziel 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

EigenschaftWertTokenQuelle
Seitenverhältnis16:9 (Parameter-Default)— (kein Token; Funktions-Default)MediaCard.kt:106
Breite — Telefondynamisch (~1,5 Karten sichtbar)abgeleitet aus playlist.rowHorizontalPadding + rowItemSpacingRowSection.kt:139–143, 161
Breite — Tablet / MATCHED280dp (Literal; ≙ mediaCard.defaultMaxWidth = 280, aber nicht darüber gelesen)component.{mode}.mediaCard.defaultMaxWidthRowSection.kt:130–134, 161 · MediathekTokenModels.kt:474–476
Eckenradius16component.{mode}.cardOverlay.cornerRadiustokens.source.json (layers.component.*.cardOverlay) · MediaCard.kt:244
Rahmen0.5dp in cardOverlay.borderColorcomponent.{mode}.cardOverlay.borderColor + spacing.dividerHeightMediaCard.kt:248–252 · DesignTokens.kt:304
Schatten8dp Elevation · Spot Schwarz α 0.3spacing.elevationCard + colors.overlayLightMediaCard.kt:242–246 · DesignTokens.kt:302 · design/tokens/DesignTokens.kt:37
Scrim-Deckkraft (Token-Soll)Dunkel 0 → 1 · Hell 0 → 0.85component.{mode}.cardOverlay.gradientStartOpacity / gradientEndOpacitytokens.source.json · MediathekTokenModels.kt:259–260 — von der Implementierung derzeit nicht gelesen, siehe Audit
Oberer Inhalts-AbstandDunkel 24dp · Hell 48dpspacing.gapL / spacing.gap5xlMediaCard.kt:284 · DesignTokens.kt:291,305
Text-Innenabstand (Seiten + unten)8dpspacing.gap2xsMediaCard.kt:299 · DesignTokens.kt:286
Titel-Font14sp · SemiBold · LH 15 · Diagramm · max 2 Zeilencomponent.{mode}.cardOverlay.titleFontSize/Weight/LineHeighttokens.source.json · MediaCard.kt:317–328
Eyebrow-Font11sp · Normal · LH 13 · Inter · 1 Zeilecomponent.{mode}.cardOverlay.subtitleFontSize/Weight/LineHeighttokens.source.json · MediaCard.kt:302–315
Badge-Inset8dpspacing.gap2xsMediaCard.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 aus contentType zurü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 onClick auf; navigiert zum deepLink des 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/textSecondary pro 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öserEigenschaftDauerEasing
Bild aufgelöstimage opacity 0 → 1300mstween (Standard)
Palette wechseltGrundfarbe (Fläche + Scrim)300mstween (Standard)
DruckPlattform-DruckfeedbackPlattform-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 item

Eine 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

Default (mit Badge, Fortschritt + Kontextmenü) Bild 16:9 · Textblock mit festem Zeilenbudget
Bild · 16:9 cover Badge Eyebrow · 1 Zeile Titel · max titleMaxLines Metadaten · unten verankert [more_vert] Textblock-Höhe = Eyebrow-LH + Titel-LH × titleMaxLines + Metadaten-LH + 2 × 4dp 1 2 3 4 5 6 7

Callout-Legende

  1. Container — Karten-ChromeEckenradius cardSplit.cornerRadius = 16 · Schatten spacing.elevationCard = 8dp mit Spot-Farbe Schwarz α 0.3 · Hintergrund cardSplit.bgColor (Dunkel #141B1F, Hell #FFFFFF) · keine Border (bewusst anders als Poster/Landscape/Avatar)CardSplit.kt:108–118 · MediathekTokenModels.kt:294, 867–870
  2. Bild — Full-Bleed-Cover (16:9)URL über das CDN umgeschrieben mit ar=16:9, w=520 · Einblendung 0 → 1 über 300ms · Höhe folgt der reihen-getriebenen KartenbreiteCardSplit.kt:81–92, 121–134
  3. Badge (optional)Oben-links auf dem Bild, Inset spacing.gap2xs = 8dp. Mit videoState: Status-Pille; sonst Kategorie-Badge auf #00000066; ohne beides entfällt es.CardSplit.kt:136–152
  4. Fortschrittsbalken (optional)Nur bei watchProgress != null && > 0f · ProgressBar-Primitiv · an der Bild-Unterkante (nicht an der Karten-Unterkante)CardSplit.kt:154–160
  5. Textblock — Eyebrow + TitelInnenabstand cardSplit.infoAreaPaddingHorizontal/Vertical = 10dp. Eyebrow (Serienname): Inter · eyebrowFontSize (12sp, Kotlin-Default) · cardSplit.eyebrowFontWeight = Normal · LH 15 · Sekundär-Textfarbe · 1 Zeile; danach 4dp Abstand (spacing.gap3xs). Titel: Diagramm · cardSplit.titleFontSize/Weight/LineHeight · Primär-Textfarbe · max cardSplit.titleMaxLines = 2 Zeilen, Ellipsis.CardSplit.kt:172–208
  6. Metadaten-Zeile — unten verankertInter · cardSplit.metadataFontSize/Weight/LineHeight · Sekundär-Textfarbe · 1 Zeile. Inhalt: Dauer · Staffel/Folge ("S2, E5" / "Staffel 2" / "Folge 5") · Kategorie, verbunden mit " • ". Wird auch leer gerendert (Höhen-Reservierung).CardSplit.kt:94–105, 212–223
  7. Kontextmenü-Trigger (optional)Nur wenn Aktionen vorhanden sind: Icon-Button [more_vert], Label "Optionen", in der unteren End-Ecke der Karte; öffnet ein Dropdown im Frosted-Glass-Stil (Canvas-Farbe × α 0.95).CardSplit.kt:227–230 · CardMenu.kt:36–87

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):

FeldTypHinweise
imageUrlString16:9-Bild der oberen Kartenhälfte (CDN-Zuschnitt, w=520).
titleString?Titel, max cardSplit.titleMaxLines Zeilen.
seriesString?Eyebrow-Zeile über dem Titel; entfällt bei null/leer (der Titel rückt nach oben).RowSection.kt:238 · CardSplit.kt:181–195
metadata / durationString?Metadaten-Zeile: metadata hat Vorrang, sonst duration.RowSection.kt:239
contentTypeString?Doppelrolle: Kategorie-Badge-Fallback (nur wenn tag == null) und letztes Segment der Metadaten-Zeile.RowSection.kt:240 · CardSplit.kt:104, 144–152
tag / tagVariantString?Status-Badge oben-links (tagVariantVideoState-Pille).
progressFloat?Fortschrittsbalken 0.0–1.0 an der Bild-Unterkante; entfällt bei null.
deepLinkStringNavigationsziel beim Tippen.
Zeilen-Ebene: contextActionsList<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

EigenschaftWertTokenQuelle
Bild-Seitenverhältnis16: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 — Telefondynamisch: (Bildschirmbreite − 2 × rowHorizontalPadding − 0.5 × rowItemSpacing) / 1.5abgeleitet aus playlist.rowHorizontalPadding + rowItemSpacingRowSection.kt:125–128, 139–143, 162
Breite — Tablet / MATCHED280dp (Literal)RowSection.kt:129, 162
Eckenradius16component.{mode}.cardSplit.cornerRadiustokens.source.json (layers.component.*.cardSplit) · CardSplit.kt:113,116
Hintergrund TextblockDunkel #141B1F · Hell #FFFFFFcomponent.{mode}.cardSplit.bgColor (Kotlin-Default; nicht in der Payload)MediathekTokenModels.kt:294, 867–870
Schatten8dp Elevation · Spot Schwarz α 0.3 · keine Borderspacing.elevationCard + colors.overlayLightCardSplit.kt:111–115 · DesignTokens.kt:302
Textblock-Innenabstand10dp horizontal · 10dp vertikalcomponent.{mode}.cardSplit.infoAreaPaddingHorizontal/Verticaltokens.source.json · CardSplit.kt:175–178
Textblock-HöheEyebrow-LH + Titel-LH × titleMaxLines + Metadaten-LH + 2 × 4dp (spacing.gap3xs)abgeleitet aus den cardSplit.*LineHeight-TokensCardSplit.kt:167–171
Titel-FontDTCG/Payload: 14sp · SemiBold · LH 15 — Kotlin-Default: 16sp · SemiBold · LH 19.2 (091 FR-006) · Diagramm · Drift, siehe Auditcomponent.{mode}.cardSplit.titleFontSize/Weight/LineHeighttokens.source.json · MediathekTokenModels.kt:281–283 · CardSplit.kt:197–208
Titel — max. Zeilen2 · Ellipsiscomponent.{mode}.cardSplit.titleMaxLines (T007-Lift; in Quelle + Payload-Parsing verifiziert)tokens.source.json · MediathekTokenModels.kt:284 · Theme.kt:508 · CardSplit.kt:206
Eyebrow-Font12sp (Kotlin-Default) · Gewicht Normal (Token) · LH 15 (Kotlin-Default) · Inter · 1 Zeilecomponent.{mode}.cardSplit.eyebrowFontWeight (T007-Lift); Größe/LH nur als Kotlin-Default, siehe Audittokens.source.json · MediathekTokenModels.kt:286–288 · Theme.kt:502–504 · CardSplit.kt:181–193
Abstand Eyebrow → Titel4dpspacing.gap3xsCardSplit.kt:194 · DesignTokens.kt:284
Metadaten-FontPayload: 10sp · Normal · LH 13 (Kotlin-Default 11/14) · Inter · 1 Zeilecomponent.{mode}.cardSplit.metadataFontSize/Weight/LineHeighttokens.source.json · MediathekTokenModels.kt:289–291 · CardSplit.kt:212–223
Badge-Inset8dpspacing.gap2xsCardSplit.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
series leer → 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ü
contextActions vorhanden → [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 onClick auf; navigiert zum deepLink des 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/secondary auf 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öserEigenschaftDauerEasing
Bild aufgelöstimage opacity 0 → 1300mstween (Standard)
Kontextmenü öffnen/schließenDropdown-SichtbarkeitPlattform-Standard (Menü-Transition)
DruckPlattform-DruckfeedbackPlattform-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 item

Eine 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

Default (cardType = POSTER_PORTRAIT) Außenbreite = posterWidth (160dp) + rankNumOuterOverlap (100dp) = 260dp · Maßstab 1dp ≈ 1.2px
Außencontainer · 260dp breit (cardWidth + 100dp Überhang) 4 4 Karten-Slot hier: CardPoster · 160dp ↤ Ziffer wird ab 55% ihrer Breite nach rechts ausgeblendet (Maske) Zweistellig (Rang 10) 1 0 Negativer Buchstaben-Überlapp: -0.2 × Schriftgröße zwischen Ziffern 1 2 3 4 5

Callout-Legende

  1. Außencontainer — Maß-BoxBreite = cardWidth + cardTop10.rankNumOuterOverlap (100dp). Die Ziffern-Gruppe ist links vertikal zentriert verankert, die Karte rechtsbündig — der Überhang von 100dp ist der Teil der Ziffer, der links neben der Karte sichtbar bleibt.CardTop10.kt:100–107 · CardTop10.kt:58–60
  2. Rang-Ziffer — Kontur-Schicht (Stroke)Pro Ziffer ein eigener Text-Knoten, gezeichnet als reine Kontur mit Strichstärke cardTop10.rankNumStrokeWidth = 3dp. Konturfarbe: Dunkel rankNumBorderColorDark (Weiß α 0.22, Kotlin-Default — siehe Audit-Notizen), Hell rankNumBorderColorLight (#141B1F α 0.25). Schrift: Inter · Black (900) · rankNumFontSize = 200sp · Zeilenhöhe = Schriftgröße.CardTop10.kt:78,88,91–97,126–135 · MediathekTokenModels.kt:372–377
  3. Rang-Ziffer — Füll-Schicht (Verlauf) + Ausblend-MaskeÜber der Kontur liegt eine zweite Text-Schicht mit vertikalem 2-Stopp-Linearverlauf aus den semantischen Farben (Dunkel: border.strong → background.canvas; Hell: background.surfaceSunken → border.strong). Die gesamte Gruppe wird offscreen komponiert und mit einem horizontalen Verlauf (transparent bis 55 %, dann Ausblendung) maskiert, sodass die Ziffer unter der Karte ausläuft.CardTop10.kt:76–84,108–122,136–140
  4. Karten-Slot — rechtsbündigDie Inhaltskarte wird rechtsbündig (vertikal zentriert) in den Außencontainer gelegt und verdeckt die rechte Flanke der Ziffer. Bei POSTER_PORTRAIT: CardPoster mit fester Breite cardTop10.posterWidth = 160dp. Badge und Fortschrittsbalken werden an die innere Karte durchgereicht, kein eigenes Overlay.CardTop10.kt:43–62 · Top10Section.kt:110–122
  5. Mehrstellige Ränge — negativer ÜberlappJede Ziffer ist ein eigener Messknoten; die Ziffern werden mit horizontalem Versatz rankNumLetterOverlapFactor × Schriftgröße = -0.2 × 200sp platziert, d. h. sie überlappen einander um 20 % der Schriftgröße ("10" rückt zusammen).CardTop10.kt:89,144–156 · MediathekTokenModels.kt:374

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)

FeldTyp (laut PlaylistCollectionModels.kt:95–99)Hinweise
typeDiskriminator, Wire-Wert "top10"Wählt diese Sektionsart aus (@SerialName("top10")). PlaylistCollectionModels.kt:94
idStringEindeutige Sektions-ID (z. B. "top-10" auf der Home-Seite). packages/playlist-api/data/pages/home.json
titleString?Optionaler Reihen-Titel über der Reihe (z. B. „Top 10 diese Woche"). Entfällt bei null. Top10Section.kt:51–63
itemsList<Top10Item>Genau die gerankten Items; Reihen-Schlüssel ist rank. Top10Section.kt:69
cardTypePlaylistCardType (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)

FeldTypHinweise
rankInt1–10, eindeutig, aufsteigend. Steuert die gezeichnete Ziffer und dient als Listen-Schlüssel. PlaylistCollectionModels.kt:101–107
imageUrlStringArtwork; Zuschnitt übernimmt die innere Karte (Poster 9:16, Landscape 16:9 …).
titleStringTitel; von allen inneren Kartenvarianten konsumiert (auch als Bild-Fallback).
contentTypeString?Kategorie-Fallback für das Badge, wenn tag fehlt. Top10Section.kt:119
deepLinkStringNavigationsziel beim Tippen auf die innere Karte. Top10Section.kt:74
progressFloat?0.0–1.0; wird als Fortschrittsbalken an die innere Karte durchgereicht; entfällt bei null. Top10Section.kt:116
tagString?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
subtitleString?Nur von OVERLAY konsumiert. Top10Section.kt:148
seriesString?Nur von OVERLAY und SPLIT konsumiert. Top10Section.kt:149,165
descriptionString?Auf dem Modell vorhanden, von keiner Top10-Dispatch-Variante konsumiert (Nutzungsregel).
durationString?Von OVERLAY konsumiert; bei SPLIT Fallback, wenn metadata fehlt. Top10Section.kt:150,166
metadataString?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

cardTypeGerenderte VarianteBreite der inneren KarteQuelle
POSTER_PORTRAIT (Default)CardTop10 — Convenience-Wrapper mit CardPoster im SlotcardTop10.posterWidth = 160dp (fest)Top10Section.kt:110–122 · CardTop10.kt:31–64
IMAGE_ONLY_LANDSCAPECardLandscape in CardTop10LayoutPhone: dynamisch (~1.2 sichtbare Karten, min. 180dp) · Tablet: cardImageOnly.widthMediumTop10Section.kt:95,132–144
OVERLAYMediaCard im Stil OVERLAY in CardTop10LayoutPhone: dynamisch (min. 180dp) · Tablet: 280dp (Literal, kein Token — siehe Audit)Top10Section.kt:96,145–161
SPLITCardSplit in CardTop10LayoutPhone: dynamisch (min. 180dp) · Tablet: 280dp (Literal, kein Token — siehe Audit)Top10Section.kt:97,162–176
AVATARNicht 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

EigenschaftWertTokenQuelle
AußenbreiteKartenbreite + 100dpcardWidth + component.{mode}.cardTop10.rankNumOuterOverlapCardTop10.kt:100
Rang-Überhang (sichtbarer Ziffern-Anteil)100dpcomponent.{mode}.cardTop10.rankNumOuterOverlapMediathekTokenModels.kt:375
Ziffern-Schriftgröße200sp · Zeilenhöhe 200spcomponent.{mode}.cardTop10.rankNumFontSizeCardTop10.kt:87,93,96 · MediathekTokenModels.kt:372
Ziffern-SchriftInter · Black (900) · Laufweite 0emcomponent.{mode}.cardTop10.rankFontFamily + rankNumLetterSpacingCardTop10.kt:91–95 · MediathekTokenModels.kt:378,380 · DesignTokens.kt:59
Kontur-Strichstärke3dpcomponent.{mode}.cardTop10.rankNumStrokeWidthCardTop10.kt:88 · MediathekTokenModels.kt:373
Konturfarbe — DunkelWeiß α 0.22 (Kotlin-Default; Token sagt rgba(255,255,255,0.25) — siehe Audit)component.dark.cardTop10.rankNumBorderColorDarkMediathekTokenModels.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.rankNumBorderColorLightMediathekTokenModels.kt:377 · tokens.source.json cardTop10
Ziffern-Füllung — DunkelVertikaler Verlauf border.strong (#7A8B94) → background.canvas (#080D16)semantic.dark.border.strong + semantic.dark.background.canvasCardTop10.kt:76–77 · SemanticColors.kt:439,462
Ziffern-Füllung — HellVertikaler Verlauf background.surfaceSunken (#EBF0F2) → border.strong (#A8B5BC)semantic.light.background.surfaceSunken + semantic.light.border.strongCardTop10.kt:76–77 · SemanticColors.kt:221,241
Buchstaben-Überlapp (mehrstellig)-0.2 × Schriftgrößecomponent.{mode}.cardTop10.rankNumLetterOverlapFactorCardTop10.kt:89,144–156 · MediathekTokenModels.kt:374
Ausblend-Maske der Zifferhorizontal · sichtbar 0–55 %, Ausblendung 55–100 %— (grafisches Primitiv, mit EXCEPTION-Kommentar im Quellcode)CardTop10.kt:109–122
Posterbreite (POSTER_PORTRAIT)160dpcomponent.{mode}.cardTop10.posterWidthCardTop10.kt:43 · MediathekTokenModels.kt:371
Abstand zwischen Items / Reihen-EinzugTokencomponent.{mode}.playlist.rowItemSpacing + rowHorizontalPaddingTop10Section.kt:65–67
Reihen-TitelToken (geteilt mit allen Reihen-Sektionen)component.{mode}.playlist.rowTitleFontSize / rowTitleFontWeight / rowTitleBottomSpacingTop10Section.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 ist rank.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-Heuristiktag == "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 (CardPoster mit role=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

Android · packages/android/app/src/main/java/com/protobible/android/ui/components/cards/CardTop10.ktCardTop10 (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 Dismissible

Eine 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 ![alt](url) erkennt — aller übrige Text wird unverändert als Fließtext gerendert (kein Fett, keine Links).CardNotification.kt:51–81 (parseMarkdown) · 191–264 (Spalte)

Default — schließbar, nur Text-Body volle Breite · Radius radius.lg · Innenabstand paddingLg × sectionGap
[close] Titel · Diagramm · h4 (22sp) · SemiBold Body · Inter · body (16sp) · Textfarbe × α 0.8 CTA · volle Breite · Pill-Form 1 2 3 4 5

Callout-Legende Default

  1. Container — Karten-ChromeVolle Breite · Eckenradius radius.lg (16dp Mobile / 32dp Tablet) · Innenabstand horizontal spacing.paddingLg (24dp / 32dp), vertikal spacing.sectionGap (32dp / 80dp). Hintergrund: Hell-Modus semantic.{mode}.background.canvas, Dunkel-Modus Colors.overlayLight = #000000 × α 0.3 — oder eine eigene Farbe aus den Daten (siehe Zustände).CardNotification.kt:113–120,167–176 · DesignTokens.kt:299–300,313,457–458,471 · design/tokens/DesignTokens.kt:37
  2. Schließen-Icon — oben rechts, nur wenn schließbarMaterialSymbols.Close ([close]) · Größe icon.sm = 20dp · Farbe: kontextabhängige Kontrastfarbe × α 0.5 · Label "Schließen". Entfällt komplett bei requiresInteraction = true.CardNotification.kt:178–189 · DesignTokens.kt:323
  3. TitelDiagramm-Familie · typography.h4 = 22sp · SemiBold · Zeilenhöhe typography.lineHeightNotificationTitle = 28sp · End-Padding spacing.sectionGap reserviert den Platz neben dem Schließen-Icon (entfällt bei requiresInteraction).CardNotification.kt:197–207 · DesignTokens.kt:248,267
  4. Body — geparste Markdown-ElementeInter-Familie · typography.body = 16sp · Normal · Zeilenhöhe typography.lineHeightNotificationBody = 24sp · Farbe = Textfarbe × α 0.8 · vertikaler Abstand zwischen Elementen spacing.gapXs = 10dp.CardNotification.kt:115,195,224–234 · DesignTokens.kt:250,268,287
  5. CTA-Button — volle InnenbreiteHöhe spacing.gap5xl (48dp Mobile / 64dp Tablet) · Pill-Radius radius.xxl (24dp / 48dp) · Label Inter · 16sp · Medium · Farben aus der adaptiven Kontrastlogik (siehe Zustände) · davor Abstand spacing.gap2xs = 8dp.CardNotification.kt:239–264 · DesignTokens.kt:286,305,315,463,473
Variante — Markdown-Bild im Body + nicht schließbar Bild 16:9 · cardNotification.aspectRatio · kein Schließen-Icon
Titel ohne End-Padding · kein [close] Markdown-Bild · 16:9 cover · Radius radius.default … Body-Text + CTA wie im Default darunter 1 2

Callout-Legende Variante

  1. Markdown-Bild — optional, 16:9Jedes ![alt](url) im Body wird als eigenes Bild-Element gerendert: volle Innenbreite · Seitenverhältnis component.{mode}.cardNotification.aspectRatio = 1.7778 (16:9) · cover-Zuschnitt · Eckenradius radius.default = 8dp · darüber/darunter je spacing.gap3xs = 4dp Abstand · Alt-Text wird als Bildbeschreibung übernommen.CardNotification.kt:211–223 · MediathekTokenModels.kt:469–471 · tokens.source.json:1596–1598 (dark) / 2057–2059 (light) · DesignTokens.kt:284,311
  2. Nicht schließbar (requiresInteraction = true)Das Schließen-Icon wird nicht gerendert und der Titel verzichtet auf das End-Padding. Die Karte verschwindet ausschließlich über den CTA. Server-seitig entspricht das dismissible = false im Section-Payload.CardNotification.kt:107–111,178,206 · NotificationSectionRenderer.kt:38,41

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)

FeldTyp (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 + ![alt](url)-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

EigenschaftWertTokenQuelle
Breitevolle Verfügbarkeitsbreite · im Section-Kontext mit horizontalem Seitenabstand playlist.rowHorizontalPaddingNotificationSectionRenderer.kt:43–45
Eckenradius Karte16dp Mobile / 32dp Tabletradius.lgCardNotification.kt:174 · DesignTokens.kt:313,471
Innenabstand horizontal24dp Mobile / 32dp Tabletspacing.paddingLgCardNotification.kt:113,176 · DesignTokens.kt:299,457
Innenabstand vertikal32dp Mobile / 80dp Tabletspacing.sectionGapCardNotification.kt:114,176 · DesignTokens.kt:300,458
Element-Abstand (Spalte)10dpspacing.gapXsCardNotification.kt:115,195 · DesignTokens.kt:287
Markdown-Bild — Seitenverhältnis16:9 (1.7778)component.{mode}.cardNotification.aspectRatioCardNotification.kt:218 · MediathekTokenModels.kt:469–471 · tokens.source.json:1596–1598 / 2057–2059 · Theme.kt:754–755 (Payload-Merge)
Markdown-Bild — Radius8dp Mobileradius.defaultCardNotification.kt:219 · DesignTokens.kt:311
Markdown-Bild — Außenabstand4dp oben + untenspacing.gap3xsCardNotification.kt:212,222 · DesignTokens.kt:284
Titel-Font22sp · SemiBold · LH 28sp · Diagrammtypography.h4 + lineHeightNotificationTitleCardNotification.kt:197–204 · DesignTokens.kt:248,267
Body-Font16sp · Normal · LH 24sp · Intertypography.body + lineHeightNotificationBodyCardNotification.kt:224–232 · DesignTokens.kt:250,268
CTA — Höhe48dp Mobile / 64dp Tabletspacing.gap5xlCardNotification.kt:249 · DesignTokens.kt:305,463
CTA — Radius24dp Mobile / 48dp Tablet (Pill)radius.xxlCardNotification.kt:250 · DesignTokens.kt:315,473
CTA — Font16sp · Medium · Intertypography.bodyCardNotification.kt:256–263
Schließen-Icon20dp · [close]icon.smCardNotification.kt:180,185 · DesignTokens.kt:323
Hintergrund Hell (Standard)semantic.light.background.canvassemanticCardNotification.kt:118
Hintergrund Dunkel (Standard)#000000 × α 0.3 (Colors.overlayLight)component (Kotlin-Default)CardNotification.kt:118 · design/tokens/DesignTokens.kt:37
Body-TextfarbeTextfarbe × α 0.8abgeleitet (// 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 backgroundColor bestimmt 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 neuer notificationId) erscheint die Karte erneut. Keine persistente Speicherung — siehe Audit-Hinweise.NotificationSectionRenderer.kt:28–29

Verhalten

Markdown-Rendering

Der Body wird einmalig pro Inhalt geparst (memoisiert auf den Body-String). Der Parser extrahiert ausschließlich Bild-Ausdrücke ![alt](url) 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

CTA-Tap

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

Schließen-Tap

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

Platzierung im Seitenfluss

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öserEigenschaftDauerEasing
Erscheinen — Schritt 1Karten-Scale 0 → 1400msEaseOutBack (Overshoot)
Erscheinen — Schritt 2 (sequenziell danach)Inhalts-Opacity 0 → 1300msStandard-tween
SchließenOpacity 1 → 0200msStandard-tween
Schließen (parallel)Höhe kollabiert nach oben300msEaseInOut

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

Android · 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.
BFF · packages/playlist-api/src/types/playlist.ts:38–49 (NotificationSection-Interface) · Beispielseite packages/playlist-api/data/pages/search.json:193–203.

ProgramGuideCard

Iteration 3 scope

Eine 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. 1. Karten-Container — volle Breite, feste Höhe 160dp, Eckenradius radius.lg, Hintergrund semantic.{mode}.background.surfaceRaised, 1dp-Rahmen in border.subtle, ganzflächig tippbar. ProgramGuideCard.kt:63–79
  2. 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. 3. LIVE-Badge (nur live) — Pille oben links über dem Bild: 18dp hoch, Hintergrund Schwarz @ programGuideCard.liveBadgeBackgroundAlpha = 0.4, runder Punkt (6dp) in semantic.{mode}.broadcast.live, Text "LIVE". ProgramGuideCard.kt:96–127
  4. 4. Content-Spalte — Zeitspanne, Titel (max. 2 Zeilen), optionaler Untertitel (max. 2 Zeilen), unten Meta-Zeile "Genre • Dauer". ProgramGuideCard.kt:132–204
  5. 5. Fortschrittsbalken (nur live) — 3dp hoch, volle Kartenbreite am unteren Rand; Track border.subtle, Füllung broadcast.live, Breite = Sendungsfortschritt. ProgramGuideCard.kt:208–223
Live-Zustand volle Breite × 160dp · Thumbnail 160 × 160dp · Badge + Fortschrittsbalken sichtbar
Bild 160×160 LIVE 20:15 - 21:00 Titel (max. 2 Zeilen) Untertitel (optional, max. 2 Zeilen) Gespräch • 45 Min. Fortschrittsbalken · 3dp · Füllung broadcast.live · nur live 1 2 3 4 5

Callout-Legende (Live)

  1. Karten-ContainerVolle Breite · Höhe der Inhaltszeile 160dp (primitives.spacing.Space40) · Radius semantic.radius.{bp}.lg · Hintergrund semantic.{mode}.background.surfaceRaised · Rahmen programGuideCard.borderWidth = 1dp in semantic.{mode}.border.subtle · ganzflächig tippbarProgramGuideCard.kt:63–79 · MediathekTokenModels.kt:499 · DesignTokens.kt:49
  2. Vorschaubild — 160 × 160dpEPG-Bild bevorzugt, sonst Serien-Bild, sonst erstes Bild; CDN-Anfrage ?w=400&auto=format · führende Ecken auf radius.lg geclippt · center-cropProgramGuideCard.kt:82–93 · EPGApi.kt:57–64
  3. LIVE-Badge — gebunden an broadcast.live (T012)Nur wenn die Sendung gerade läuft. Pille 18dp hoch (primitives.spacing.Space4_5) · Hintergrund Schwarz @ programGuideCard.liveBadgeBackgroundAlpha = 0.4 · Pillenform über programGuideCard.cornerRadius = 34dp · Punkt programGuideCard.dotSize = 6dp in semantic.{mode}.broadcast.live = #E3000B (vor Spec 094 T012 hartkodiert) · Text "LIVE" 10sp (typography.epgTime) Medium in text.onColor, Letter-Spacing letterSpacingOverline = 1 · Einrückung gap2xs = 8dpProgramGuideCard.kt:54, 96–127 · MediathekTokenModels.kt:500–504 · SemanticColors.kt:192, 432, 653
  4. Content-SpalteInnenabstand paddingSm (12dp mobile / 14dp tablet) · vertikal verteilt (oben Text-Stack, unten Meta-Zeile). Zeit 11sp (epgDetail; live → text.primary, sonst text.secondary) · Titel 14sp SemiBold (epgTitle, Zeilenhöhe bodyLarge = 18sp, max. 2 Zeilen) · Untertitel 12sp (label, Zeilenhöhe body = 16sp, max. 2 Zeilen, nur wenn nicht leer und ≠ Titel) · Meta "Genre • Dauer" 11sp in text.tertiary · Schriftfamilie DiagrammProgramGuideCard.kt:57–60, 132–204
  5. Fortschrittsbalken (nur live)Höhe programGuideCard.progressHeight = 3dp · volle Kartenbreite, unterhalb der Inhaltszeile · Track border.subtle · Füllung broadcast.live · Breitenanteil = (jetzt − start) / (ende − start), sekündlich neu berechnetProgramGuideCard.kt:38–48, 208–223 · EPGApi.kt:80–87 · MediathekTokenModels.kt:502
Kommend (nicht live) kein Badge, kein Fortschrittsbalken · Zeitlabel in sekundärer Textfarbe
Bild 160×160 21:00 - 21:30 (sekundär) Titel Talk • 30 Min.

Unterschiede zum Live-Zustand

  1. Kein LIVE-Badge, kein FortschrittsbalkenBeide rendern nur, wenn die Sendung laut Start-/Endzeit gerade läuftProgramGuideCard.kt:37, 96, 209
  2. Zeitlabel abgedimmtLive → semantic.{mode}.text.primary; nicht live → text.secondaryProgramGuideCard.kt:60

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

FeldTypHinweise
type"program_guide"Diskriminator → vertikale Liste aus ProgramGuideCards. PlaylistCollectionModels.kt:138 · playlist.ts:53
idStringStabile Sektions-Kennung (Beispiel: "search-epg").
titleString?Optionaler Sektionstitel (Beispiel: "Jetzt im TV"); gerendert mit den Reihen-Titel-Tokens (playlist.rowTitleFontSizeSp usw.). ProgramGuideSectionRenderer.kt:40–51
itemsProgramGuideItem[]Muss mindestens ein Item enthalten. PlaylistCollectionModels.kt:144

Item ProgramGuideItem

FeldTyp (Android / BFF)Hinweise
programIdString / stringKennung der Sendung; Tipp auf die Karte navigiert zu program/{programId}. ProgramGuideSectionRenderer.kt:56
thumbnailUrlString? / string | nullVorschaubild; das CDN-Rewrite (?w=400) erfolgt clientseitig.
titleString / stringSendungstitel, max. 2 Zeilen.
subtitleString? / string | nullUntertitel; Rendering-Bedingung: nur wenn nicht leer und ungleich title. ProgramGuideCard.kt:163
genreString? / string | nullMeta-Zeile; der Trennpunkt "•" erscheint nur, wenn ein Genre vorhanden ist. ProgramGuideCard.kt:182–197
durationMinInt? / numberNutzungsregel: 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 / endsAtString? (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
isLiveBoolean? / booleanDrift: 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

EigenschaftWertTokenQuelle
Höhe Inhaltszeile160dp (+ 3dp Fortschrittsbalken bei live)primitives.spacing.Space40ProgramGuideCard.kt:79 · DesignTokens.kt:49
Vorschaubild160 × 160dpprimitives.spacing.Space40ProgramGuideCard.kt:84
Eckenradius Karte16dp mobile / 32dp tabletsemantic.radius.{bp}.lgProgramGuideCard.kt:66, 71, 86
Rahmen1dp in border.subtlecomponent.{mode}.programGuideCard.borderWidthMediathekTokenModels.kt:499
HintergrundDark: neutral.900 · Light: Weißsemantic.{mode}.background.surfaceRaisedProgramGuideCard.kt:55
LIVE-Badge Höhe18dpprimitives.spacing.Space4_5ProgramGuideCard.kt:104 · DesignTokens.kt:28
LIVE-Badge HintergrundSchwarz @ α 0.4 · Pillenform 34dpprogramGuideCard.liveBadgeBackgroundAlpha + .cornerRadiusMediathekTokenModels.kt:500, 504
LIVE-Punkt6dp · #E3000BprogramGuideCard.dotSize + semantic.{mode}.broadcast.liveMediathekTokenModels.kt:501 · ProgramGuideCard.kt:113–117
"LIVE"-Text10sp · Medium · Letter-Spacing 1semantic.typography.{bp}.epgTime + .letterSpacingOverlineProgramGuideCard.kt:118–124
Innenabstand Content12dp mobile / 14dp tabletsemantic.spacing.{bp}.paddingSmProgramGuideCard.kt:136
Zeitlabel11sp · Diagrammsemantic.typography.{bp}.epgDetailProgramGuideCard.kt:141–146
Titel14sp · SemiBold · Zeilenhöhe 18sp · max. 2 Zeilensemantic.typography.{bp}.epgTitle / .bodyLargeProgramGuideCard.kt:151–160
Untertitel12sp · Zeilenhöhe 16sp · max. 2 Zeilensemantic.typography.{bp}.label / .bodyProgramGuideCard.kt:165–173
Meta-Zeile11sp in text.tertiary · Abstände gap2xs = 8dpsemantic.typography.{bp}.epgDetailProgramGuideCard.kt:178–204
Vertikale Text-AbständeZeit→Titel 2dp · Titel→Untertitel 2dpsemantic.spacing.{bp}.gap4xsProgramGuideCard.kt:148, 164
Fortschrittsbalken3dp hoch · volle Breitecomponent.{mode}.programGuideCard.progressHeightMediathekTokenModels.kt:502 · ProgramGuideCard.kt:213
Abstand zwischen Karten (Sektion)playlist.rowItemSpacing + horizontales Padding playlist.rowHorizontalPaddingProgramGuideSectionRenderer.kt:38, 58
Hit-Targetgesamte 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.live ist in beiden Modi #E3000B. ProgramGuideCard.kt:50–61
Pressed
Plattform-Standard-Press-Feedback auf der ganzen Karte (Ripple auf der Referenzplattform).

Verhalten

Live-Erkennung & Fortschritt

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

Tap-Ziel

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

Sektions-Layout

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

Payload-Adaption

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öserEigenschaftDauerEasing
Fortschritts-Tick (live)Balkenbreitesofort, 1s-Takt (kein Tween)
Karten-DruckPress-FeedbackPlattform-Standard
sonststatisch

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 1000 implementiert (ProgramGuideCard.kt:45), während der Hero-Live-Slide dafür den verhaltensbezogenen Token heroLive.progressUpdateIntervalMs besitzt. Angleichen (eigener programGuideCard-Behavioral-Token oder gemeinsamer Token).
  • Drift: isLive-Flag ignoriert — siehe Kontrakt-Tabelle; Server-Flag vs. clientseitige Uhr vor Vertrags-Freeze entscheiden.
  • Drift: startsAt/endsAt Optionalität — BFF erforderlich, Android optional (Leerstring-Fallback). Eine Seite angleichen.
  • Ungenutzter ParameterisLightMode wird 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; programGuideCard hat (anders als heroLive nach T011) keine fontFamily-Tokens. Bei Bedarf in tokens.json ergä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.
  • Verifiziertbroadcast.live-Bindung (T012), alle programGuideCard.*-Tokens gegen tokens.source.json (borderWidth 1, cornerRadius 34, dotSize 6, progressHeight 3, progressCornerRadius 1.5, liveBadgeBackgroundAlpha 0.4), Sektions-Contract Android ↔ BFF ↔ Beispiel-Payload.

Implementierungsreferenz

Android · 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 Serien

Detailseite 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 + LazyColumn mit Staffel-Tabs + Episodenliste.
Mobile — einspaltiger Scroll Hero-Bild → Scrim → Titel + Meta → Play + Aktionszeile → Tabs → Episodenliste
Hero-Bild · detailHeroMobile.aspectRatio Scrim → Serientitel [back] Serienname [share] Serientitel (H2) Staffeln · Episoden · Jahr ▶ Abspielen [bookmark]\nMerken [thumb_up]\nMag ich [share]\nTeilen Staffel 1 Staffel 2 Episode 1 — Titel 24 min · Beschreibung Episode 2 — Titel 24 min · Beschreibung 1 2 3 4 5

Callout-Legende Mobile

  1. TopBar — TopBarVariant.Detail (transparent)Zurück-Button (links) + Teilen-Icon (rechts) · Transparent beim Scrollen oben; wird beim Hochscrollen opak. Höhe btvTopBar.actionRowHeight = 52dp.BtvTopBar.kt · TopBarVariant.Detail
  2. Hero-Bild + ScrimSeitenverhältnis detailHeroMobile.aspectRatio · Scrim-Verlauf von unten für Lesebarkeit des Titels · Serientitel + Metadaten (Staffeln, Episodenanzahl, Jahr) am unteren Rand des Hero.DetailHeroMobile.kt · tokens: detailHeroMobile.*
  3. Play-ButtonMediathekButton · ButtonType.Primary · ButtonSize.Small · führendes PlayArrow-Icon · Label "Abspielen".DetailSeriesScreen.kt
  4. Aktionszeile — DetailActionRowDrei DetailActionButton-Einträge: Merken (BookmarkOutlinedBookmark gefüllt wenn aktiv), Mag ich (ThumbUpOutlinedThumbUp gefüllt + Zähler-Badge), Teilen (Share). Icon-Größe detailActionButton.iconSize · Abstand Icon–Label detailActionButton.iconLabelGap · Touch-Target detailActionButton.touchTarget.DetailActionRow.kt · DetailActionButton.kt
  5. Episodenliste — Staffel-Tabs + LazyColumnHorizontale Tabs für Staffeln; darunter scrollbare Episodenliste. Jede Zeile (EpisodeListItem): 160dp-Thumbnail (CardLandscape) · Titel „{Nr}. {Titel}“ (brandBody) · Metadaten-Zeile „{Dauer}min • FSK {N}“ (label) · Download-Icon · Beschreibung darunter (caption). Altersfreigabe (FSK): aus VideoHubVideo.getAgeRating(), das den freitextlichen Upstream-fsk („ab 12 Jahren“ / „nicht definiert“) auf eine numerische Freigabe normalisiert; ist keine definiert, entfällt das Segment. Status-Badge (optional): als Eyebrow ÜBER dem Titel — nicht als Thumbnail-Overlay, da lange VideoState-Labels („Anmeldung erforderlich“) das 160dp-Thumbnail sprengen — via BtvBadge/VideoState (Neu · Nur noch kurze Zeit · Anmeldung/Verifizierung erforderlich). Keine Bibelverse-Sektion (nur auf VideoDetail).EpisodeListItem.kt:42, 62, 104–112 · VideoHubModels.kt:140 · BtvBadge.kt:181–198
Tablet Querformat — DetailTwoColumnLayout Links: Hero + Titel + Steuerung · Rechts: Episodenliste · leftFraction aus Token
[back] Serienname [cast] Hero-Bild · detailHeroTablet.aspectRatio Serientitel (H2) Staffeln · Episoden · Jahr ▶ Abspielen [bookmark] Merken [thumb_up] Mag ich [share] Teilen gap: detailTwoColumn.columnGap Staffel 1 | Staffel 2 Episode 1 — Titel 24 min · Beschreibung Episode 2 — Titel 24 min · Beschreibung Episode 3 — Titel 24 min · Beschreibung 1 2 3

Callout-Legende Tablet

  1. TopBar — TopBarVariant.LandscapeDetail (transparent)Zurück-Button (links) + Cast-Icon (rechts) · Im Querformat immer transparent (kein Scroll-Übergang).BtvTopBar.kt · TopBarVariant.LandscapeDetail
  2. Zweispalter — DetailTwoColumnLayoutLinke Spalte: leftFraction aus detailTwoColumn.leftFraction · Spaltenabstand: detailTwoColumn.columnGap · Maximale Inhaltsbreite: detailTwoColumn.contentMaxWidth. Linke Spalte enthält Hero (Inset detailHeroTablet.landscapeHeightInset), Serientitel, Metadaten, Play-Button, Aktionszeile.DetailTwoColumnLayout.kt · tokens: detailTwoColumn.*
  3. Rechte Spalte — EpisodenlisteStaffel-Tabs oben, darunter scrollbare LazyColumn mit EpisodeListItem-Einträgen (Thumbnail · Titel · „{Dauer}min • FSK {N}“ · Download · Beschreibung; optionaler Status-Eyebrow über dem Titel — siehe Mobile-Callout). Keine Bibelverse-Sektion.EpisodeListItem.kt:42, 62, 104–112 · VideoHubModels.kt:140

Verwendete Token

Token-PfadBeschreibung
component.{mode}.detailHeroMobile.aspectRatioSeitenverhältnis des Hero-Bilds auf Mobile
component.{mode}.detailHeroMobile.spacerWidthSeitlicher Abstand im Mobile-Hero
component.{mode}.detailHeroTablet.scrimAnchorFromBottomAnkerpunkt des Scrim-Verlaufs von unten (Tablet)
component.{mode}.detailHeroTablet.scrimFadeHeightHöhe des Scrim-Verlaufs (Tablet)
component.{mode}.detailHeroTablet.aspectRatioSeitenverhältnis des Hero-Bilds auf Tablet
component.{mode}.detailHeroTablet.landscapeHeightInsetHöhen-Inset im Querformat-Hero (Tablet)
component.{mode}.detailActionButton.iconSizeIcon-Größe in der Aktionszeile
component.{mode}.detailActionButton.iconLabelGapAbstand zwischen Icon und Label in der Aktionszeile
component.{mode}.detailActionButton.touchTargetTouch-Target-Größe der Aktionszeilen-Buttons
component.{mode}.detailTwoColumn.leftFractionAnteil der linken Spalte an der Gesamtbreite (Tablet)
component.{mode}.detailTwoColumn.columnGapAbstand zwischen den beiden Spalten (Tablet)
component.{mode}.detailTwoColumn.contentMaxWidthMaximale Inhaltsbreite des Zweispalters (Tablet)
component.{mode}.btvTopBar.actionRowHeightHöhe der TopBar-Aktionszeile (52dp)

Referenz

DetailSeries · Mobile · Dark Mode
Mobile · Dark
DetailSeries · Mobile · Light Mode
Mobile · Light
DetailSeries · Mobile · Light Mode · gescrollt
Mobile · Light · gescrollt
DetailSeries · Tablet · Dark Mode
Tablet · Dark
DetailSeries · Tablet · Light Mode
Tablet · Light

Implementierungsreferenz

Android · 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 Video

Detailseite 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.
Mobile — einspaltiger Scroll Hero → Videotitel + Meta → Play → Aktionszeile → Beschreibung → Bibelverse → Tabs
Hero-Bild · detailHeroMobile.aspectRatio [back] Serienname [share] Serientitel · Ep. Nr. · Staffel Videotitel (H2) Dauer · Veröffentlichungsdatum ▶ Abspielen [bookmark]\nMerken [thumb_up]\nMag ich [share]\nTeilen [download]\nLaden [timer]\nSchlaftimer Beschreibungstext (mehrere Zeilen) Zusammenfassung der Episode … Bibelverse im Video Joh. 3,16 — "Denn also hat Gott …" [seek] [open in Bible] [translation] Ps. 23,1 — "Der Herr ist mein …" [seek] [open in Bible] [translation] Transkript Details Transkript-Inhalt / Metadaten 1 2 3 4 5 6

Callout-Legende Mobile

  1. TopBar — TopBarVariant.Detail (transparent → opak)Zurück + Teilen · Transparent beim Scrollen oben, scrollt zu opak bei Hochformat. Höhe btvTopBar.actionRowHeight = 52dp.BtvTopBar.kt · TopBarVariant.Detail
  2. Hero-Bild + Serientitel/EpisodenmetaHero-Bild mit detailHeroMobile.aspectRatio · Scrim-Verlauf von unten · Serientitel + Episodennummer + Staffelangabe als Overlay am unteren Rand.DetailHeroMobile.kt · DetailVideoScreen.kt
  3. Play-ButtonMediathekButton · ButtonType.Primary · verwendet den White-Primary-Token (semantic.bg.page = Weiß im Light-Mode, getönt im Dark-Mode). White-Primary-Button-Token in Iteration 3 ergänzt.DetailVideoScreen.kt · MediathekButton.kt
  4. Aktionszeile — DetailActionRow (5 Buttons)Merken · Mag ich · Teilen · Download (Download-Icon → Ladeanzeige (kein Icon, "Lädt") → Delete-Icon wenn fertig) · Schlaftimer (SleepTimer-Icon). Icon-Größe und Abstände via detailActionButton.*-Tokens.DetailActionRow.kt · DetailActionButton.kt
  5. Bibelverse-Sektion — VideoBibleVersesSectionAufklappbare Verseingaben mit Buch/Kapitel/Vers-Referenz, Verstext aus der geladenen Übersetzung, Sprung-zur-Verse-Button, Übersetzungsauswahl und "In der Bibel öffnen"-Aktion. Nur auf VideoDetail vorhanden, nicht auf SeriesDetail.VideoBibleVersesSection.kt · DetailVideoScreen.kt
  6. Tabs — Transkript | DetailsNur angezeigt, wenn Transkript vorhanden. "Transkript"-Tab zeigt den Volltext; "Details"-Tab (VideoMetadataSection) zeigt strukturierte Label/Wert-Zeilen: Typ · Dauer · Altersfreigabe · Produktionsland · Genre · Produktionsjahr — jede nur, wenn das Feld gesetzt ist. Altersfreigabe ist die blanke Freigabe-Zahl (kein „FSK“-Präfix, das Label benennt es bereits) aus VideoHubVideo.getAgeRating(), das den freitextlichen fsk normalisiert.VideoMetadataSection.kt:21–41 · VideoHubModels.kt:140
Tablet Querformat — DetailTwoColumnLayout Links: Hero + Videotitel + Steuerung · Rechts: Beschreibung + Bibelverse + Tabs
[back] Serienname · Ep. 1 [cast] Hero-Bild · detailHeroTablet.aspectRatio Serientitel · Ep. Nr. · Staffel Videotitel (H2) Dauer · Datum ▶ Abspielen [bookmark] Merken [thumb] Mag ich [share] Teilen [dl] [timer] Beschreibungstext … Zusammenfassung Bibelverse im Video Joh. 3,16 — "Denn also hat Gott …" Ps. 23,1 — "Der Herr ist mein …" Transkript Details Transkript-Inhalt 1 2 3 4

Callout-Legende Tablet

  1. TopBar — TopBarVariant.LandscapeDetailZurück + Cast-Icon · Transparent im Querformat (kein Scroll-Übergang).BtvTopBar.kt · TopBarVariant.LandscapeDetail
  2. Linke Spalte — DetailTwoColumnLayoutEnthält Hero-Bild (Inset detailHeroTablet.landscapeHeightInset), Serientitel/Episodenmeta-Overlay, Videotitel, Play-Button und Aktionszeile (5 Buttons inkl. Download + Schlaftimer).DetailTwoColumnLayout.kt · DetailVideoScreen.kt
  3. Rechte Spalte — Bibelverse-SektionVideoBibleVersesSection mit aufklappbaren Verseingaben, Verstext, Seek-to-Verse, Übersetzungsauswahl und "In der Bibel öffnen". Nur in der rechten Spalte auf Tablet (und auf Mobile als vertikaler Block).VideoBibleVersesSection.kt
  4. Tabs — Transkript | DetailsScrollbar in der rechten Spalte; nur sichtbar wenn Transkript vorhanden.DetailVideoScreen.kt

Verwendete Token

Token-PfadBeschreibung
component.{mode}.detailHeroMobile.aspectRatioSeitenverhältnis des Hero-Bilds auf Mobile
component.{mode}.detailHeroMobile.spacerWidthSeitlicher Abstand im Mobile-Hero
component.{mode}.detailHeroTablet.scrimAnchorFromBottomAnkerpunkt des Scrim-Verlaufs von unten (Tablet)
component.{mode}.detailHeroTablet.scrimFadeHeightHöhe des Scrim-Verlaufs (Tablet)
component.{mode}.detailHeroTablet.aspectRatioSeitenverhältnis des Hero-Bilds auf Tablet
component.{mode}.detailHeroTablet.landscapeHeightInsetHöhen-Inset im Querformat-Hero (Tablet)
component.{mode}.detailActionButton.iconSizeIcon-Größe in der Aktionszeile
component.{mode}.detailActionButton.iconLabelGapAbstand zwischen Icon und Label in der Aktionszeile
component.{mode}.detailActionButton.touchTargetTouch-Target-Größe der Aktionszeilen-Buttons
component.{mode}.detailTwoColumn.leftFractionAnteil der linken Spalte an der Gesamtbreite (Tablet)
component.{mode}.detailTwoColumn.columnGapAbstand zwischen den beiden Spalten (Tablet)
component.{mode}.detailTwoColumn.contentMaxWidthMaximale Inhaltsbreite des Zweispalters (Tablet)
component.{mode}.btvTopBar.actionRowHeightHöhe der TopBar-Aktionszeile (52dp)

Referenz

DetailVideo · Mobile · Dark Mode
Mobile · Dark
DetailVideo · Mobile · Light Mode
Mobile · Light
DetailVideo · Tablet · Dark Mode
Tablet · Dark
DetailVideo · Tablet · Light Mode
Tablet · Light

Implementierungsreferenz

Android · 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 Erforderlich

Motion 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.

TokenWertVerwendung
motion.duration.short120msPress-Feedback rein, Hover-/Focus-Statuswechsel, Tooltip-Erscheinen, Icon-Tausch, Beginn eines Ripples.
motion.duration.medium220msPress-Feedback raus, Enter/Exit von Listenelementen, Page-Indicator-Dot-Übergänge, kleine Surface-Fades, Tab-Indicator-Slide.
motion.duration.long340msDrawer Öffnen/Schließen, Modal-Scrim-Fade, Crossfade-Body eines Hero-Slides, Theme-Wechsel, Banner-Overlay-Reveal.
motion.duration.carousel600msNur 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.

TokenKurveVerwendung
motion.easing.linearlinearNur 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.gentlespring(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.snappyspring(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

Vorderkante erscheint, Hinterkante verschwindet

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.

Stagger

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 & Follow-Through

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.

Richtung passt zur Affordance

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.

Keine gleichzeitigen großen Bewegungen

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.

PhaseDauerEasingEigenschaft
Press rein (Finger drückt)motion.duration.short (120ms)motion.easing.standardOutRipple-Opacity 0 → token-definierter Press-Alpha; Ripple-Radius 0 → Max.
Press raus (Finger löst)200ms (custom — Ausnahme von der Skala)motion.easing.standardInRipple-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 einem motion.easing.standard mit 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

Zurückgestellt. Iteration 1 spezifiziert keine bildschirmübergreifenden Route-Übergänge (Home → Detail, Home → Bibel). Verwenden Sie die Plattform-Standards (Android Shared-Axis-X, iOS Push, Web instant), bis die Spec für Seitenübergänge in einer späteren Iteration ausgeliefert wird. Komponenten-Overlays (Drawer, Modal, Banner) sind im Scope und folgen den obigen Regeln.

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.standardOut beim Öffnen, standardIn beim Schließen.
  • Notification-Surface der BottomNavigation (Tablet) — gepaartes motion.easing.spring.gentle rein / spring.snappy raus.
  • Scroll-Fade der TopBarmotion.easing.linear, scroll-getrieben (keine Dauer).

Barrierefreiheits-Baseline

Cross-cutting-Vertrag WCAG 2.2 AA

Dieser 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}.color mit 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

Nicht in Iteration 1. Der Home-Screen und das Burger-Menü enthalten keine Formular-Eingaben. Wenn Formulare ausgeliefert werden (Suche, Konto, Einstellungen), müssen sie zusätzlich spezifizieren: Eingabe-Labels (sichtbar, programmatisch zugeordnet), Markierung von Pflichtfeldern (nicht ausschließlich über Farbe), Inline-Fehlermeldungen, die per 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

KonzeptAndroidiOSWeb
LabelcontentDescriptionaccessibilityLabelaria-label / sichtbarer Text
RolleModifier.semantics { role = ... }accessibilityTraitsrole-Attribut
VerstecktimportantForAccessibility="no"accessibilityElementsHiddenaria-hidden="true"
Live-RegionliveRegion in semanticsUIAccessibility.post(notification:)aria-live="polite"
Reduced MotionSettings.Global.ANIMATOR_DURATION_SCALEUIAccessibility.isReduceMotionEnabledprefers-reduced-motion
Fokus-RingCompose indication + FocusRing-ModifierUIFocusEffect:focus-visible-Outline

Breakpoints

Cross-cutting-Vertrag 2 aktiv · 2 vorgerüstet

Das 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

ModusToken-SchlüsselTriggerStatus in Iteration 1
mobilesemantic.mobile.*Kürzeste Dimension < 600dp und Hochformat.Aktiv. Referenz-Designfläche.
tabletsemantic.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.
desktopsemantic.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.
tvsemantic.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.

  1. Meldet die Plattform TV-UI-Modus → tv.
  2. Andernfalls shortSide = min(widthDp, heightDp) und ratio = widthDp / heightDp berechnen.
  3. Wenn shortSide ≥ 900dp und die Tablet-Regel (Schritt 4) zutrifft → desktop.
  4. Wenn shortSide ≥ 600dp und ratio die Hysterese-Schwelle erfüllt → tablet.
  5. Andernfalls, wenn das Gerät im Querformat ist → tablet. (Telefone im Querformat übernehmen das Tablet-Layout.)
  6. 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:

  1. Token-Auswahl — derselbe logische Token löst zu einem anderen Wert auf: component.heroVideo.titleFontSize liest auf Tablet aus component.heroVideo.tabletTitleFontSize.
  2. Seitenverhältnis — Hero-Cards sind auf Mobile 3:4 Hochformat und auf Tablet 2,54:1 Querformat. Siehe HeroCarousel.
  3. 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.json unter semantic.{mobile,tablet,desktop,tv}.

Token-Referenz

Generiert

Die 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:

PhaseVerhaltenAndroid-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:
  • 200 OK → neuer Payload, persistieren (DataStore overwrite), StateFlow updaten, UI rekomponiert via Compose-Beobachtung.
  • 304 Not Modified → bestehende Tokens behalten, kein Re-Persist.
  • Netzwerkfehler / 5xx → bestehende Tokens behalten. KEIN Crash, KEIN Clear.
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.

EbeneQuellpfadDateien
Primitivetokens/primitive/colors.json, opacity.json
Semantictokens/semantic/colors.json, spacing.json, typography.json
Componenttokens/component/badge.json, brand.json, button.json, card.json, detail.json, epg.json, hero.json, navigation.json, player.json, screen.json, switch.json
Viewtokens/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

PathValue
colors.base.white#FFFFFF
colors.base.black#141B1F

neutral

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
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

PathValue
colors.brand.dunkelblau#143764
colors.brand.orange#FFA014
colors.brand.gelb#FABE00
colors.brand.creme#FAFAEB
colors.brand.liveRed#E3000B
Spacing — 37 tokens
PathValue
spacing.Space00
spacing.Space0_52
spacing.Space14
spacing.Space1_56
spacing.Space28
spacing.Space2_259
spacing.Space2_510
spacing.Space2_7511
spacing.Space312
spacing.Space3_2513
spacing.Space3_514
spacing.Space3_7515
spacing.Space416
spacing.Space4_2517
spacing.Space4_518
spacing.Space520
spacing.Space5_522
spacing.Space624
spacing.Space728
spacing.Space832
spacing.Space936
spacing.Space1040
spacing.Space1144
spacing.Space1248
spacing.Space1352
spacing.Space1456
spacing.Space14_2557
spacing.Space1560
spacing.Space1664
spacing.Space1872
spacing.Space2080
spacing.Space2288
spacing.Space2496
spacing.Space28112
spacing.Space32128
spacing.Space40160
spacing.Space48192
Font Weights — 4 tokens
PathValue
fontWeights.Normal400
fontWeights.Medium500
fontWeights.SemiBold600
fontWeights.Bold700
Opacity — 14 rungs
PathValue
opacity.scale00
opacity.scale100.1
opacity.scale150.15
opacity.scale250.25
opacity.scale400.4
opacity.scale450.45
opacity.scale500.5
opacity.scale600.6
opacity.scale700.7
opacity.scale750.75
opacity.scale800.8
opacity.scale900.9
opacity.scale950.95
opacity.scale1001
Semantic — theme-aware (Dark/Light) + breakpoint-aware

Theme-aware (dark / light)

background — 6 tokens
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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.angleDeg135135
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
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
PathDarkLight
broadcast.live#E3000B#E3000B
focusRing — 1 tokens
PathDarkLight
focusRing.color#1B8FD2#0D477B

Responsive (mobile / tablet / desktop / tv)

typography — 37 tokens
PathMobileTabletDesktopTV
typography.display575780128
typography.h132324896
typography.h228283680
typography.h324242864
typography.h422222456
typography.bodyLarge18181852
typography.body16161648
typography.bodySmall14141440
typography.label12121236
typography.caption11111132
typography.overline10101028
typography.finePrint99924
typography.micro8880
typography.xs12121224
typography.sm14141432
typography.base16161640
typography.md16161844
typography.lg18182048
typography.xl20202456
typography.xxl24242864
typography.xxxl32323680
typography.xxxxl40404896
typography.heroTitle26324064
typography.heroSubtitle14161828
typography.screenTitle32404880
typography.playerTitle18181818
typography.playerTime12121212
typography.epgTime10101010
typography.epgTitle14141414
typography.epgDetail11111111
typography.bodyMedium15151545
typography.labelMedium13131339
typography.lineHeightNotificationTitle28282884
typography.lineHeightNotificationBody24242472
typography.lineHeightHeroTitle28344368
typography.lineHeightHeroSub19222438
typography.letterSpacingOverline1113
spacing — 24 tokens
PathMobileTabletDesktopTV
spacing.gap4xs2224
spacing.gap3xs4448
spacing.gap2_5xs66612
spacing.gap2xs88816
spacing.gapXs10101020
spacing.gapSm12121224
spacing.gapDefault16161632
spacing.gapM20202040
spacing.gapL24242448
spacing.gapXl28282856
spacing.gap2xl32323264
spacing.gap3xl36363672
spacing.gap4xl40404080
spacing.gap8xl969696192
spacing.paddingSm12141624
spacing.paddingDefault16202440
spacing.paddingLg24324864
spacing.sectionGap32804880
spacing.sectionMargin16482448
spacing.elevationCard8888
spacing.elevationHero16161616
spacing.dividerHeight0.50.50.50.5
spacing.gap5xl48644896
spacing.bottomScrollPadding100120100200
radius — 11 tokens
PathMobileTabletDesktopTV
radius.none0000
radius.sm4848
radius.default816816
radius.md12241224
radius.lg16321632
radius.xl20402040
radius.xxl24482448
radius.xxxl28000
radius.xxxxl32000
radius.xxxxxl36000
radius.full9999999999999999
icons — 9 tokens
PathMobileTabletDesktopTV
icons.xs16161632
icons.sm20202040
icons.md24242448
icons.lg32323264
icons.xl36363672
icons.xxl40404080
icons.xxxl565656112
icons.xxxxl808080160
icons.xxxxxl969696192
buttons — 19 tokens
PathMobileTabletDesktopTV
buttons.xsmall.height28362848
buttons.xsmall.paddingX12161224
buttons.xsmall.paddingY68612
buttons.xsmall.fontSize12141224
buttons.small.height32443256
buttons.small.paddingX16241632
buttons.small.paddingY812816
buttons.small.fontSize14161428
buttons.medium.height40564072
buttons.medium.paddingX20322048
buttons.medium.paddingY10161024
buttons.medium.fontSize16181632
buttons.icon.xl80808080
buttons.icon.xxl96969696
buttons.radius816816
buttons.height44644064
buttons.paddingX24402440
buttons.paddingY12201220
buttons.fontSize16161632
input — 4 tokens
PathMobileTabletDesktopTV
input.height44644064
input.paddingX16321632
input.radius816816
input.fontSize16161632
card — 4 tokens
PathMobileTabletDesktopTV
card.padding16202440
card.gap16161632
card.radius16161632
card.nestedRadius0000
videoCard — 3 tokens
PathMobileTabletDesktopTV
videoCard.padding12141632
videoCard.radius16161632
videoCard.thumbnailRadius4448
navigation — 2 tokens
PathMobileTabletDesktopTV
navigation.navBarHeight56806480
navigation.tabBarHeight48805680
focusRing — 2 tokens
PathMobileTabletDesktopTV
focusRing.width2224
focusRing.offset4448
safeArea — 4 tokens
PathMobileTabletDesktopTV
safeArea.top4432032
safeArea.bottom3232032
safeArea.left048048
safeArea.right048048
effect — 0 tokens
PathMobileTabletDesktopTV
elevation — 0 tokens
PathMobileTabletDesktopTV
Component — Komposition pro Komponente
hero — 21 tokens
PathDarkLight
hero.titleMaxFontSize2626
hero.titleMinFontSize1616
hero.autoAdvanceIntervalMs30003000
hero.aspectRatioMobile0.750.75
hero.aspectRatioTablet0.3940.394
hero.indicatorDotSize66
hero.indicatorDotSelectedWidth2020
hero.indicatorDotSpacing33
hero.cornerRadius2424
hero.contentPadding2424
hero.pageSpacing1616
hero.tabletTitleFontSize2626
hero.tabletTitleLineHeight2828
hero.tabletTitleFontWeightMediumMedium
hero.tabletDescFontSize1414
hero.tabletDescLineHeight1818
hero.tabletDescFontWeightMediumMedium
hero.mobileTitleFontSize1616
hero.mobileTitleFontWeightSemiBoldSemiBold
hero.mobileDescFontSize1212
hero.mobileDescFontWeightNormalNormal
cardOverlay — 17 tokens
PathDarkLight
cardOverlay.gradientStartOpacity00
cardOverlay.gradientEndOpacity11
cardOverlay.scrimAlpha10.70.7
cardOverlay.scrimAlpha20.90.9
cardOverlay.cornerRadius1616
cardOverlay.textPadding88
cardOverlay.titleFontSize1414
cardOverlay.titleFontWeightSemiBoldSemiBold
cardOverlay.titleLineHeight1515
cardOverlay.titleMaxLines22
cardOverlay.subtitleFontSize1111
cardOverlay.subtitleFontWeightNormalNormal
cardOverlay.subtitleLineHeight1313
cardOverlay.imageFadeDurationMs300300
cardOverlay.imageRequestWidthWide640640
cardOverlay.imageRequestWidthTall400400
cardOverlay.borderColor#00000000#00000000
cardSplit — 19 tokens
PathDarkLight
cardSplit.infoAreaPaddingHorizontal1010
cardSplit.infoAreaPaddingVertical1010
cardSplit.cornerRadius1616
cardSplit.imageAspectRatio1.77777781.7777778
cardSplit.titleFontSize1616
cardSplit.titleFontWeightSemiBoldSemiBold
cardSplit.titleLineHeight19.219.2
cardSplit.metadataFontSize1010
cardSplit.metadataFontWeightNormalNormal
cardSplit.metadataLineHeight1313
cardSplit.eyebrowTitleSpacing44
cardSplit.actionInset4848
cardSplit.titleMaxLines22
cardSplit.eyebrowFontWeightNormalNormal
cardSplit.eyebrowFontSize1212
cardSplit.eyebrowLineHeight1515
cardSplit.imageFadeDurationMs300300
cardSplit.imageRequestWidth520520
cardSplit.bgColor#141B1F#FFFFFF
cardImageOnly — 5 tokens
PathDarkLight
cardImageOnly.cornerRadius1616
cardImageOnly.widthSmall200200
cardImageOnly.widthMedium280280
cardImageOnly.widthLarge360360
cardImageOnly.bgColor#141B1F#FFFFFF
cardPoster — 15 tokens
PathDarkLight
cardPoster.cornerRadius1616
cardPoster.widthSmall120120
cardPoster.widthMedium160160
cardPoster.widthLarge200200
cardPoster.aspectRatio0.56250.5625
cardPoster.borderColorrgba(255, 255, 255, 0.15)rgba(26, 26, 26, 0.15)
cardPoster.borderWidth11
cardPoster.shadowElevation88
cardPoster.shadowColorrgba(0, 0, 0, 0.3)rgba(0, 0, 0, 0.3)
cardPoster.bgColor#141B1F#FFFFFF
cardPoster.fallbackGradientEnd#080D16#FFFFFF
cardPoster.fallbackTextColor#EBF0F2#252F35
cardPoster.fallbackTitleFontSize1616
cardPoster.fallbackTitleFontWeightMediumMedium
cardPoster.fallbackTitleLineHeight2020
cardTop10 — 16 tokens
PathDarkLight
cardTop10.rankFontSize6464
cardTop10.cardAspectRatio0.56250.5625
cardTop10.gradientColors[ #7A8B94 #252F35 ][ #F8F9FA #CFD9DD ]
cardTop10.posterWidth160160
cardTop10.rankNumFontSize200200
cardTop10.rankNumStrokeWidth33
cardTop10.rankNumLetterOverlapFactor-0.2-0.2
cardTop10.rankNumOuterOverlap100100
cardTop10.rankNumBorderColorDarkrgba(255, 255, 255, 0.25)rgba(255, 255, 255, 0.25)
cardTop10.rankNumBorderColorLightrgba(26, 26, 26, 0.25)rgba(26, 26, 26, 0.25)
cardTop10.rankFontFamilyInterInter
cardTop10.rankNumLetterSpacingEm00
cardTop10.rankDigitMaxLines11
cardTop10.visibleCardsPhone1.21.2
cardTop10.innerCardMinWidth180180
cardTop10.innerCardWidthTablet280280
playlist — 5 tokens
PathDarkLight
playlist.rowHorizontalPadding1616
playlist.rowItemSpacing1616
playlist.rowTitleFontSize1616
playlist.rowTitleFontWeightMediumMedium
playlist.rowTitleBottomSpacing88
section — 2 tokens
PathDarkLight
section.verticalSpacing2424
section.horizontalPadding1616
buttonGroup — 3 tokens
PathDarkLight
buttonGroup.spacing88
buttonGroup.cornerRadius5050
buttonGroup.height3232
badge — 9 tokens
PathDarkLight
badge.height1818
badge.cornerRadius3434
badge.horizontalPadding88
badge.fontSize1010
badge.fontWeightMediumMedium
badge.letterSpacing0.50.5
badge.defaultBackgroundColorrgba(0, 0, 0, 0.6)rgba(0, 0, 0, 0.6)
badge.textColor#FFFFFF#FFFFFF
badge.textColorOnLight#1A1A1A#1A1A1A
cardAvatar — 13 tokens
PathDarkLight
cardAvatar.initialsFontWeightBoldBold
cardAvatar.labelFontSize1212
cardAvatar.labelFontWeightMediumMedium
cardAvatar.defaultSize128128
cardAvatar.borderColorrgba(255, 255, 255, 0.15)rgba(26, 26, 26, 0.15)
cardAvatar.borderWidth11
cardAvatar.shadowElevation88
cardAvatar.shadowColorrgba(0, 0, 0, 0.3)rgba(0, 0, 0, 0.3)
cardAvatar.bgColor#141B1F#FFFFFF
cardAvatar.displayFontFamilyDiagrammDiagramm
cardAvatar.initialsFontSizeRatio0.30.3
cardAvatar.initialsCount22
cardAvatar.labelMaxLines22
liveTv — 3 tokens
PathDarkLight
liveTv.progressBarColor#FFA014#FFA014
liveTv.livePillBackgroundColor#FF0000#FF0000
liveTv.channelLogoSize4848
progress — 4 tokens
PathDarkLight
progress.barHeight44
progress.barCornerRadius22
progress.barColor#FFFFFF#141B1F
progress.barTrackColorrgba(255, 255, 255, 0.15)rgba(0, 0, 0, 0.15)
heroCarousel — 29 tokens
PathDarkLight
heroCarousel.autoAdvanceIntervalMs30003000
heroCarousel.aspectRatioMobile0.750.75
heroCarousel.aspectRatioTablet0.3940.394
heroCarousel.indicatorDotSize66
heroCarousel.indicatorDotSelectedWidth2020
heroCarousel.indicatorDotSpacing33
heroCarousel.indicatorDotCornerRadius44
heroCarousel.contentPadding2424
heroCarousel.pageSpacing1616
heroCarousel.cornerRadius2424
heroCarousel.borderColorrgba(255, 255, 255, 0.15)rgba(26, 26, 26, 0.15)
heroCarousel.borderWidth11
heroCarousel.elevation1616
heroCarousel.shadowColor#000000#000000
heroCarousel.tabletImageWidthFraction0.70.7
heroCarousel.tabletGradientWidthFraction0.50.6
heroCarousel.tabletContentWidthFraction0.40.4
heroCarousel.displayFontFamilyDiagrammDiagramm
heroCarousel.bodyFontFamilyInterInter
heroCarousel.shadowAmbientAlpha0.50.5
heroCarousel.autoAdvanceAnimationDurationMs600600
heroCarousel.pageIndicatorAnimationDurationMs200200
heroCarousel.pageIndicatorInactiveAlpha0.30.3
heroCarousel.titleMaxLines33
heroCarousel.eyebrowMaxLines11
heroCarousel.descriptionMaxLines33
heroCarousel.splitLayoutSecondaryTextAlpha0.70.7
heroCarousel.splitLayoutTertiaryTextAlpha0.80.8
heroCarousel.splitLayoutLightGradientAlpha0.120.12
heroVideo — 40 tokens
PathDarkLight
heroVideo.titleMaxFontSize2626
heroVideo.titleMinFontSize1616
heroVideo.tabletTitleFontSize2626
heroVideo.tabletTitleLineHeight2828
heroVideo.tabletTitleFontWeightMediumMedium
heroVideo.tabletDescFontSize1414
heroVideo.tabletDescLineHeight1818
heroVideo.tabletDescFontWeightMediumMedium
heroVideo.mobileTitleFontSize1616
heroVideo.mobileTitleFontWeightSemiBoldSemiBold
heroVideo.mobileDescFontSize1212
heroVideo.mobileDescFontWeightNormalNormal
heroVideo.contentTopPadding140140
heroVideo.iconSize1414
heroVideo.maxLines.mobileTitle22
heroVideo.maxLines.mobileDescription33
heroVideo.maxLines.tabletPortraitTitle22
heroVideo.maxLines.tabletPortraitDescription33
heroVideo.maxLines.tabletPortraitCrowdedTitle22
heroVideo.maxLines.tabletPortraitCrowdedDescription22
heroVideo.maxLines.tabletLandscapeTitle33
heroVideo.maxLines.tabletLandscapeDescription55
heroVideo.titleFontSize.thresholdShortChars2020
heroVideo.titleFontSize.thresholdMediumChars3030
heroVideo.titleFontSize.thresholdLongChars4040
heroVideo.titleFontSize.thresholdVeryLongChars5050
heroVideo.titleFontSize.spShort2828
heroVideo.titleFontSize.spMedium2424
heroVideo.titleFontSize.spLong2222
heroVideo.titleFontSize.spVeryLong2020
heroVideo.titleFontSize.spXVeryLong1818
heroVideo.titleFontSize.lineHeightDelta22
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.scrimFadeShift5252
heroVideo.scrimFadeHeight240240
heroVideo.displayFontFamilyDiagrammDiagramm
heroVideo.bodyFontFamilyInterInter
heroVideo.tabletTitleMaxLines33
heroVideo.tabletDescMaxLines55
heroVideo.eyebrowMaxLines11
heroBibleVerse — 51 tokens
PathDarkLight
heroBibleVerse.tabletTitleFontSize2626
heroBibleVerse.tabletTitleLineHeight2828
heroBibleVerse.tabletTitleFontWeightMediumMedium
heroBibleVerse.tabletDescFontSize1414
heroBibleVerse.tabletDescLineHeight1818
heroBibleVerse.tabletDescFontWeightMediumMedium
heroBibleVerse.verseBaseSizeMobile3636
heroBibleVerse.verseMinSizeMobile1616
heroBibleVerse.verseMaxSizeMobile4848
heroBibleVerse.verseBaseSizeTabletLandscape2222
heroBibleVerse.verseMinSizeTabletLandscape1414
heroBibleVerse.verseMaxSizeTabletLandscape2626
heroBibleVerse.verseBaseSizeTabletPortrait1616
heroBibleVerse.verseMinSizeTabletPortrait1111
heroBibleVerse.verseMaxSizeTabletPortrait1818
heroBibleVerse.textLengthMin2020
heroBibleVerse.textLengthMax250250
heroBibleVerse.lineMinHeight115115
heroBibleVerse.lineMaxHeight9090
heroBibleVerse.tabletImageWidthFraction0.70.7
heroBibleVerse.tabletGradientWidthFraction0.50.5
heroBibleVerse.tabletContentWidthFraction0.40.4
heroBibleVerse.tabletGradientOnlyContentWidthFraction0.50.5
heroBibleVerse.verseTextAlpha0.90.9
heroBibleVerse.buttonIconSize1414
heroBibleVerse.buttonCornerRadiusPercent5050
heroBibleVerse.scrimMobileLightAlpha10.050.05
heroBibleVerse.scrimMobileLightAlpha20.50.5
heroBibleVerse.scrimMobileLightAlpha30.820.82
heroBibleVerse.scrimMobileLightAlpha40.950.95
heroBibleVerse.scrimMobileDarkAlpha10.150.15
heroBibleVerse.scrimMobileDarkAlpha20.450.45
heroBibleVerse.scrimMobileDarkAlpha30.720.72
heroBibleVerse.scrimMobileDarkAlpha40.90.9
heroBibleVerse.scrimMobileDarkAlpha50.970.97
heroBibleVerse.scrimTabletAlpha10.750.75
heroBibleVerse.scrimTabletAlpha20.50.5
heroBibleVerse.scrimTabletAlpha30.250.25
heroBibleVerse.imageAspectRatioMobile3:43:4
heroBibleVerse.imageWidthMobile600600
heroBibleVerse.imageAspectRatioTablet16:916:9
heroBibleVerse.imageWidthTablet12001200
heroBibleVerse.imageFocalPointY0.350.35
heroBibleVerse.verseMaxLinesMobile88
heroBibleVerse.gradientTopLight#DEECF2#DEECF2
heroBibleVerse.gradientBottomLight#66B0D9#66B0D9
heroBibleVerse.gradientTopDark#10223A#10223A
heroBibleVerse.gradientBottomDark#0D477B#0D477B
heroBibleVerse.contentTopPadding140140
heroBibleVerse.displayFontFamilyDiagrammDiagramm
heroBibleVerse.bodyFontFamilyInterInter
heroFeatured — 23 tokens
PathDarkLight
heroFeatured.tabletTitleFontSize2626
heroFeatured.tabletTitleLineHeight2828
heroFeatured.tabletTitleFontWeightMediumMedium
heroFeatured.tabletDescFontSize1414
heroFeatured.tabletDescLineHeight1818
heroFeatured.tabletDescFontWeightMediumMedium
heroFeatured.mobileTitleFontSize1616
heroFeatured.mobileTitleFontWeightSemiBoldSemiBold
heroFeatured.mobileDescFontSize1212
heroFeatured.mobileDescFontWeightNormalNormal
heroFeatured.contentTopPadding140140
heroFeatured.scrimFadeShift5252
heroFeatured.scrimFadeHeight240240
heroFeatured.tabletPortraitTitleMaxLines22
heroFeatured.tabletPortraitDescMaxLines33
heroFeatured.tabletImageWidthFraction0.70.7
heroFeatured.tabletGradientWidthFraction0.50.6
heroFeatured.tabletContentWidthFraction0.40.4
heroFeatured.displayFontFamilyDiagrammDiagramm
heroFeatured.bodyFontFamilyInterInter
heroFeatured.tabletTitleMaxLines33
heroFeatured.tabletDescMaxLines44
heroFeatured.imageFocalPointY0.350.35
heroLive — 8 tokens
PathDarkLight
heroLive.cornerRadius2424
heroLive.displayFontFamilyDiagrammDiagramm
heroLive.bodyFontFamilyInterInter
heroLive.tabletInfoWidthFraction0.30.3
heroLive.channelNameMaxLines11
heroLive.programTitleMaxLines22
heroLive.miniTimelineTitleMaxLines22
heroLive.borderColorAlpha0.150.15
cardLandscape — 10 tokens
PathDarkLight
cardLandscape.aspectRatio1.77781.7778
cardLandscape.cornerRadius1212
cardLandscape.borderColorrgba(255, 255, 255, 0.15)rgba(26, 26, 26, 0.15)
cardLandscape.borderWidth11
cardLandscape.shadowElevation88
cardLandscape.shadowColorrgba(0, 0, 0, 0.3)rgba(0, 0, 0, 0.3)
cardLandscape.bgColor#141B1F#FFFFFF
cardLandscape.fallbackTitleFontSize1414
cardLandscape.fallbackTitleFontWeightSemiBoldSemiBold
cardLandscape.fallbackTitleLineHeight1515
cardNotification — 7 tokens
PathDarkLight
cardNotification.aspectRatio1.77781.7778
cardNotification.scaleInDurationMs400400
cardNotification.opacityInDurationMs300300
cardNotification.dismissDurationMs200200
cardNotification.dismissOpacityDurationMs300300
cardNotification.textSecondaryOpacity0.80.8
cardNotification.closeIconAlpha0.50.5
featuredSection — 6 tokens
PathDarkLight
featuredSection.gradientBlendFactor10.850.85
featuredSection.gradientBlendFactor20.60.6
featuredSection.gradientBlendFactor30.30.3
featuredSection.fallbackSlideColor#10223A#10223A
featuredSection.canvasDark#080D16#080D16
featuredSection.canvasLight#F3F5F7#F3F5F7
mediaCard — 11 tokens
PathDarkLight
mediaCard.defaultMaxWidth280280
mediaCard.defaultHeight256256
mediaCard.metadataSpacing33
mediaCard.overlayLinearAlphaMiddle0.20.2
mediaCard.overlayLinearAlphaEnd0.60.6
mediaCard.overlayRadialAlphaMiddle0.30.3
mediaCard.overlayRadialAlphaEnd0.70.7
mediaCard.overlayAngledAlphaFirst0.20.2
mediaCard.overlayAngledAlphaMid0.50.5
mediaCard.overlayAngledAlphaEnd0.80.8
mediaCard.overlayFallbackAlphaEnd0.40.4
programGuideCard — 6 tokens
PathDarkLight
programGuideCard.borderWidth11
programGuideCard.cornerRadius3434
programGuideCard.dotSize66
programGuideCard.progressHeight33
programGuideCard.progressCornerRadius1.51.5
programGuideCard.liveBadgeBackgroundAlpha0.40.4
btvTopBar — 5 tokens
PathDarkLight
btvTopBar.height4242
btvTopBar.horizontalPadding1616
btvTopBar.verticalPadding88
btvTopBar.scrollFadeDistance300300
btvTopBar.actionSpacing00
btvDropdown — 1 tokens
PathDarkLight
btvDropdown.minWidth250250
loadingSpinner — 1 tokens
PathDarkLight
loadingSpinner.strokeWidth33
playlistVideoListItem — 3 tokens
PathDarkLight
playlistVideoListItem.thumbnailWidth120120
playlistVideoListItem.thumbnailCornerRadius22
playlistVideoListItem.thumbnailVerticalPadding22
episodeListItem — 6 tokens
PathDarkLight
episodeListItem.aspectRatio1.77781.7778
episodeListItem.thumbnailWidth160160
episodeListItem.descriptionTopGap1010
episodeListItem.contentPaddingHorizontal1616
episodeListItem.contentPaddingVertical1212
episodeListItem.thumbnailGap1616
detailHeroMobile — 11 tokens
PathDarkLight
detailHeroMobile.aspectRatio1.77781.7778
detailHeroMobile.spacerWidth22
detailHeroMobile.contentPaddingHorizontal1616
detailHeroMobile.coverTopGap88
detailHeroMobile.coverBottomGap1616
detailHeroMobile.subtitleBottomGap88
detailHeroMobile.metadataTopGap88
detailHeroMobile.playButtonTopGap1616
detailHeroMobile.actionRowTopGap88
detailHeroMobile.buttonRowItemGap88
detailHeroMobile.contentBottomGap1616
detailHeroTablet — 8 tokens
PathDarkLight
detailHeroTablet.aspectRatio1.77781.7778
detailHeroTablet.landscapeHeightInset128128
detailHeroTablet.scrimAnchorFromBottom240240
detailHeroTablet.scrimFadeHeight360360
detailHeroTablet.contentPaddingHorizontal1616
detailHeroTablet.contentItemGap88
detailHeroTablet.actionRowTopGap44
detailHeroTablet.actionRowItemGap88
detailActionButton — 13 tokens
PathDarkLight
detailActionButton.iconSize2020
detailActionButton.iconLabelGap44
detailActionButton.touchTarget4848
detailActionButton.labelColor#EBF0F2#252F35
detailActionButton.labelColorActive#EBF0F2#252F35
detailActionButton.labelColorDisabled#7A8B94#A8B5BC
detailActionButton.badgeIconGap44
detailActionButton.clusterItemGap88
detailActionButton.loadingStopCornerRadius22
detailActionButton.loadingStopFraction0.380.38
detailActionButton.progressStrokeFraction0.0830.083
detailActionButton.progressTrackAlpha0.240.24
detailActionButton.labelMaxLines11
detailTwoColumn — 5 tokens
PathDarkLight
detailTwoColumn.leftFraction0.60.6
detailTwoColumn.columnGap1616
detailTwoColumn.contentMaxWidth960960
detailTwoColumn.actionRowGap88
detailTwoColumn.heroAspectRatio1.77781.7778
donationCard — 8 tokens
PathDarkLight
donationCard.bodyMaxLines33
donationCard.padding1616
donationCard.horizontalMargin1616
donationCard.cornerRadius1212
donationCard.buttonSpacingTop1616
donationCard.elevation00
donationCard.containerColor#4D000000#F8F9FA
donationCard.textColor#EBF0F2#252F35
protoBibleButton — 4 tokens
PathDarkLight
protoBibleButton.borderWidth11
protoBibleButton.pressedAlpha0.120.12
protoBibleButton.disabledAlpha0.380.38
protoBibleButton.hoverAlpha0.080.08
btvLogo — 4 tokens
PathDarkLight
btvLogo.starBibelGap55
btvLogo.bibelAppGap33
btvLogo.bibelWidth5252
btvLogo.appWidth2828
rowSection — 1 tokens
PathDarkLight
rowSection.cardAspectRatio0.56250.5625
cardHero — 22 tokens
PathDarkLight
cardHero.cornerRadius1616
cardHero.minHeight480480
cardHero.badgePaddingHorizontal1010
cardHero.badgePaddingVertical55
cardHero.badgeCornerRadius66
cardHero.badgeFontSize1111
cardHero.eyebrowFontSize1212
cardHero.titleFontSize2222
cardHero.descriptionFontSize1414
cardHero.buttonHeight4444
cardHero.buttonFontSize1414
cardHero.buttonHorizontalPadding2020
cardHero.gradientStartFraction0.30.3
cardHero.contentBottomPadding2424
cardHero.contentHorizontalPadding2020
cardHero.eyebrowTopSpacing44
cardHero.descriptionTopSpacing66
cardHero.buttonTopSpacing1212
cardHero.progressBarHeight44
cardHero.fallbackTitleFontSize1414
cardHero.fallbackTitleFontWeightSemiBoldSemiBold
cardHero.fallbackTitleLineHeight1515
slider — 4 tokens
PathDarkLight
slider.titleFontSize1818
slider.titleBottomPadding1212
slider.horizontalPadding1616
slider.itemSpacing1212
sliderHero — 4 tokens
PathDarkLight
sliderHero.heightMobile520520
sliderHero.heightTablet600600
sliderHero.autoAdvanceIntervalMs50005000
sliderHero.indicatorBottomPadding1616
bannerOverlay — 2 tokens
PathDarkLight
bannerOverlay.scrimAlpha0.60.6
bannerOverlay.closeButtonBackgroundAlpha0.50.5
menuDrawer — 8 tokens
PathDarkLight
menuDrawer.overlayBgAlpha0.950.95
menuDrawer.backdropBlurRadius2424
menuDrawer.rowBgColor#00000000#00000000
menuDrawer.tabletDrawerWidth380380
menuDrawer.tabletPanelAlpha0.920.92
menuDrawer.tabletScrimAlpha0.40.4
menuDrawer.dividerAlpha0.10.1
menuDrawer.sectionLabelAlpha0.50.5
button — 40 tokens
PathDarkLight
button.primary.bg#FFFFFF#252F35
button.primary.bg-pressed#EBF0F2rgba(37, 47, 53, 0.8)
button.primary.bg-disabledrgba(255, 255, 255, 0.3)rgba(37, 47, 53, 0.3)
button.primary.text#252F35#EBF0F2
button.primary.text-disabledrgba(37, 47, 53, 0.4)rgba(235, 240, 242, 0.5)
button.primary.secondary-textrgba(37, 47, 53, 0.7)rgba(235, 240, 242, 0.7)
button.primary.secondary-text-disabledrgba(37, 47, 53, 0.4)rgba(235, 240, 242, 0.4)
button.primary.borderrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.secondary.bgrgba(235, 240, 242, 0.15)rgba(37, 47, 53, 0.1)
button.secondary.bg-pressedrgba(235, 240, 242, 0.25)rgba(37, 47, 53, 0.15)
button.secondary.bg-disabledrgba(235, 240, 242, 0.08)rgba(37, 47, 53, 0.05)
button.secondary.text#EBF0F2#252F35
button.secondary.text-disabledrgba(235, 240, 242, 0.4)rgba(37, 47, 53, 0.4)
button.secondary.secondary-textrgba(235, 240, 242, 0.7)rgba(37, 47, 53, 0.7)
button.secondary.secondary-text-disabledrgba(235, 240, 242, 0.4)rgba(37, 47, 53, 0.3)
button.secondary.borderrgba(235, 240, 242, 0.3)rgba(37, 47, 53, 0.15)
button.tertiary.bgrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.tertiary.bg-pressedrgba(235, 240, 242, 0.1)rgba(37, 47, 53, 0.1)
button.tertiary.bg-disabledrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.tertiary.text#EBF0F2#252F35
button.tertiary.text-disabledrgba(235, 240, 242, 0.4)rgba(37, 47, 53, 0.4)
button.tertiary.secondary-textrgba(235, 240, 242, 0.7)rgba(37, 47, 53, 0.7)
button.tertiary.secondary-text-disabledrgba(235, 240, 242, 0.4)rgba(37, 47, 53, 0.3)
button.tertiary.borderrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.overlay.bgrgba(37, 47, 53, 0.5)rgba(37, 47, 53, 0.5)
button.overlay.bg-pressedrgba(37, 47, 53, 0.65)rgba(37, 47, 53, 0.65)
button.overlay.bg-disabledrgba(37, 47, 53, 0.25)rgba(37, 47, 53, 0.25)
button.overlay.text#EBF0F2#EBF0F2
button.overlay.text-disabledrgba(235, 240, 242, 0.4)rgba(235, 240, 242, 0.4)
button.overlay.secondary-textrgba(235, 240, 242, 0.7)rgba(235, 240, 242, 0.7)
button.overlay.secondary-text-disabledrgba(235, 240, 242, 0.3)rgba(235, 240, 242, 0.3)
button.overlay.borderrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.tertiary-dark.bgrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.tertiary-dark.bg-pressedrgba(37, 47, 53, 0.1)rgba(37, 47, 53, 0.1)
button.tertiary-dark.bg-disabledrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
button.tertiary-dark.text#252F35#252F35
button.tertiary-dark.text-disabledrgba(37, 47, 53, 0.4)rgba(37, 47, 53, 0.4)
button.tertiary-dark.secondary-textrgba(37, 47, 53, 0.7)rgba(37, 47, 53, 0.7)
button.tertiary-dark.secondary-text-disabledrgba(37, 47, 53, 0.3)rgba(37, 47, 53, 0.3)
button.tertiary-dark.borderrgba(0, 0, 0, 0)rgba(0, 0, 0, 0)
searchField — 12 tokens
PathDarkLight
searchField.borderWidth22
searchField.cornerRadius2828
searchField.fieldHeight5656
searchField.glowAlphaMin0.250.25
searchField.glowAlphaMax0.60.6
searchField.glowEdgeWidth2424
searchField.glowBreatheDurationMs24002400
searchField.glowSweepDurationMs80008000
searchField.transitionDurationMs450450
searchField.expandedPaddingHorizontal1616
searchField.expandedPaddingVertical1212
searchField.chipCornerRadius1616
Views (4. Ebene) — Komposition pro Screen (Home)
home — 14 tokens
PathValue
views.home.navBar.borderAlpha0.15
views.home.videoSurface.background#000000
views.home.modalScrim.base#000000
views.home.modalScrim.alpha0.8
views.home.layout.contentPaddingHorizontalMobile16
views.home.layout.contentPaddingHorizontalTablet32
views.home.layout.sectionGapMobile24
views.home.layout.sectionGapTablet32
views.home.layout.contentPaddingTopMobile8
views.home.layout.contentPaddingTopTablet16
views.home.layout.contentPaddingBottomMobile24
views.home.layout.contentPaddingBottomTablet32
views.home.layout.rowCardSpacingMobile16
views.home.layout.rowCardSpacingTablet16
detail — 8 tokens
PathValue
views.detail.contentPaddingHorizontal16
views.detail.topBarContentGap0
views.detail.heroImageBottomGap16
views.detail.titleMetaGap8
views.detail.metaDescriptionGap8
views.detail.buttonRowTopGap16
views.detail.descriptionBottomGap16
views.detail.sectionGap16

Coverage der in dieser Spec zitierten Pfade

Vor der Implementierung lesen. Dieses Dokument zitiert Token-Pfade in der Naming-Konvention der Consumer-Seite (entsprechend dem, wie der Kotlin-Code den Wert liest). Nicht jeder Pfad ist bereits in die Design-Tokens-API überführt — manche existieren als Kotlin-Model-Defaults; einige sind reine Spec-Vorschläge, die der Implementierer formalisieren soll. Die nachstehende Coverage-Tabelle katalogisiert alle 250 in der Spec zitierten, eindeutigen Pfade nach ihrem aktuellen Status.

Coverage summary

StatusCountMeaning
⚙️ API241Path resolves through the design-tokens API right now. Implementers fetch the value at runtime from /api/v1/tokens.
🔧 Kotlin32Path 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-only33Spec-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)
PathStatus
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 / entferntnode specs/080-create-a-plan/docs/scripts/generate-tokens.mjs ausführen (regeneriert die von index.html konsumierten partials/tokens-*.html) und node 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 zu handover-v2.html forken, 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 dp oder sp, niemals in px oder pt. Zeit in ms. Easings als cubic-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).